/** * 后台可视化排版选择(Sveltia CMS 自定义编辑器组件)。 * * 能力依据(sveltiacms.app/en/docs/api/editor-components,2026-11 核实): * - inline 型组件(type:'inline')在段落内以 pattern 匹配,可与选中文字互转; * - onInsert({ selectedText }) 拿到编辑器当前选中的文字,返回值经 toInline 生成 Markdown; * - inline 组件固定在 Insert 菜单下(trigger 对 inline 是文本触发,不能上工具栏)。 * * 因此「选中文字 → 套字体/颜色」由**一个「排版」组件 + 自建分组面板**实现: * 两个独立工具栏下拉(字体/颜色)会共用同一 span pattern 互相抢匹配,这里收成一个入口分两组, * 交互上等价(选中 → Insert ▸ 排版 → 选类 → 文字被 包住)。 * onEdit 支持点开已套用的片段改类。 */ (function () { const CLASSES = window.TYPOGRAPHY_CLASSES; if (!CLASSES || !window.CMS) return; /** 自建选择面板:返回 Promise<选中类名 | null>;无依赖、可键盘操作 */ function pickClass(current) { return new Promise((resolve) => { const wrap = document.createElement('div'); wrap.setAttribute('role', 'dialog'); wrap.setAttribute('aria-label', '选择排版'); Object.assign(wrap.style, { position: 'fixed', inset: '0', zIndex: '99999', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(8,10,14,.55)', }); const card = document.createElement('div'); Object.assign(card.style, { background: '#fff', color: '#111113', borderRadius: '12px', padding: '18px 20px', width: 'min(560px, 92vw)', maxHeight: '80vh', overflowY: 'auto', boxShadow: '0 8px 32px rgba(0,0,0,.28)', font: '14px/1.5 system-ui, sans-serif', }); const title = document.createElement('p'); title.textContent = '选择排版(作用于选中文字)'; Object.assign(title.style, { margin: '0 0 12px', fontWeight: '600' }); card.appendChild(title); const buttons = []; const addGroup = (name, list) => { const h = document.createElement('p'); h.textContent = name; Object.assign(h.style, { margin: '12px 0 6px', fontSize: '12px', color: '#6b6e78' }); card.appendChild(h); const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', flexWrap: 'wrap', gap: '8px' }); for (const item of list) { const b = document.createElement('button'); b.type = 'button'; b.textContent = item.label; b.dataset.cls = item.cls; b.className = item.cls === current ? 'active' : ''; Object.assign(b.style, { padding: '6px 12px', borderRadius: '8px', cursor: 'pointer', border: '1px solid ' + (item.cls === current ? '#3454d1' : '#e6e7eb'), background: item.cls === current ? '#eef1fd' : '#fff', color: '#111113', fontSize: '13px', }); row.appendChild(b); buttons.push(b); } card.appendChild(row); }; addGroup('字体', CLASSES.font); addGroup('颜色', CLASSES.color); const foot = document.createElement('div'); Object.assign(foot.style, { marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }); const cancel = document.createElement('button'); cancel.type = 'button'; cancel.textContent = '取消(Esc)'; Object.assign(cancel.style, { padding: '6px 12px', borderRadius: '8px', border: '1px solid #e6e7eb', background: '#fff', cursor: 'pointer' }); foot.appendChild(cancel); card.appendChild(foot); wrap.appendChild(card); const prevFocus = document.activeElement; const close = (val) => { document.removeEventListener('keydown', onKey, true); wrap.remove(); if (prevFocus && prevFocus.focus) prevFocus.focus(); resolve(val); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); } else if (e.key === 'Tab') { // 焦点循环留在面板内 const list = [...wrap.querySelectorAll('button')]; const first = list[0], last = list[list.length - 1]; if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } } }; wrap.addEventListener('click', (e) => { const t = e.target; if (t === wrap) return close(null); if (t.dataset && t.dataset.cls) close(t.dataset.cls); if (t === cancel) close(null); }); document.addEventListener('keydown', onKey, true); document.body.appendChild(wrap); (buttons[0] || cancel).focus(); }); } window.CMS.registerEditorComponent({ id: 'typography', label: '排版(字体/颜色)', icon: 'format_paint', type: 'inline', pattern: /(?[^<]*?)<\/span>/, toInline: ({ cls = '', text = '' }) => `${text}`, toPreview: ({ cls = '', text = '' }) => { const span = document.createElement('span'); span.className = cls; span.textContent = text; // DOM 预览:textContent 天然转义 return span; }, onInsert: async ({ selectedText = '' }) => { const cls = await pickClass(null); return cls ? { cls, text: selectedText } : null; }, onEdit: async ({ data }) => { const cls = await pickClass(data.cls); return cls ? { ...data, cls } : null; }, }); })();