- 可视化选择(核心痛点解决):Sveltia 原生支持 registerEditorComponent
的 inline 形态且 onInsert 能拿到 selectedText → 实现「选中文字 → Insert ▸
排版 → 选字体/颜色 → 自动包 span class」完整链路;因 inline 组件只能进
Insert 菜单(field-based 虽可上工具栏但拿不到 selectedText),两个独立组件
会互相抢 pattern 匹配,故收敛为单入口 + 18 项分组面板(键盘可达/Esc 取消/
onEdit 回填当前类)
- public/admin/typography-classes.js 为单一事实源(扩展与文档共用)
- 颜色扩充 4 类:ink-warm / ink-cool / ink-faint / ink-accent-soft,全部亮暗
双态实测 ≥4.5:1(ink-faint 取白底达标的最淡值 4.69/4.75)
- 字体:体积账否决 webfont 新增(@fontsource zcool-xiaowei 用到即 +647.7KB,
远超 150KB 阈值;smiley-sans 无可用版本、sarasa 包为空壳),改为落地 4 个
**系统回退类**(font-song/font-kai-sys/font-hei-sys/font-fangsong,零下载,
文档注明依赖访客设备)
- 白名单 17 类同步;docs/写作指南.md 三表更新
- docs/research/10-页面后台化方案.md:五方案对比 + 推荐方案 E(页面骨架 blocks
/ 区块字段 / 局部自由区 / 样式即数据四层)+ 关于页九类区块 + 消毒与验收断言
+ 分阶段清单(约 7.2d)
验证: 16/16 颜色 computed 与对比度实测;选择器 7/7(桩 CMS 覆盖注册/插入/
编辑三层);构建 48 页 exit 0;E2E 37/37
Spec: none (排版能力 + 方案文档)
Co-authored-by: GLM-5.3
131 lines
5.6 KiB
JavaScript
131 lines
5.6 KiB
JavaScript
/**
|
||
* 后台可视化排版选择(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 ▸ 排版 → 选类 → 文字被 <span class> 包住)。
|
||
* 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 class="(?<cls>[a-z0-9-]+)">(?<text>[^<]*?)<\/span>/,
|
||
toInline: ({ cls = '', text = '' }) => `<span class="${cls}">${text}</span>`,
|
||
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;
|
||
},
|
||
});
|
||
})();
|