feat(typography): 可视化排版选择器(Sveltia inline 组件)+ 颜色扩充 + 方案 10
- 可视化选择(核心痛点解决):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
This commit is contained in:
1 parent
c5cdcd5bf9
commit
2cab243891
9 files changed
+731
-6
No files matched your search
@@ -8,5 +8,8 @@
|
||||
<body>
|
||||
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
|
||||
<script src="./sveltia-cms.js"></script>
|
||||
<!-- 可视化排版选择:类清单(单一数据源)→ 编辑器组件注册(须在 CMS 之后、init 之前注册) -->
|
||||
<script src="./typography-classes.js"></script>
|
||||
<script src="./typography-editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 排版类清单 —— 单一事实源(后台编辑器面板与文档共用)。
|
||||
* 与 src/styles/global.css 的「受限排版类」段、src/lib/rehype-class-whitelist.ts 的白名单三处保持一致:
|
||||
* 加类时三处同改,否则构建期白名单会把新类剥掉。
|
||||
*/
|
||||
window.TYPOGRAPHY_CLASSES = {
|
||||
font: [
|
||||
{ cls: 'font-serif', label: '思源宋(书评正文)' },
|
||||
{ cls: 'font-kai', label: '霞鹜文楷(金句)' },
|
||||
{ cls: 'font-sans', label: '思源黑(UI/技术)' },
|
||||
{ cls: 'font-mono', label: 'JetBrains Mono(等宽)' },
|
||||
{ cls: 'font-song', label: '宋体(系统)' },
|
||||
{ cls: 'font-kai-sys', label: '楷体(系统)' },
|
||||
{ cls: 'font-hei-sys', label: '黑体(系统)' },
|
||||
{ cls: 'font-fangsong', label: '仿宋(系统)' },
|
||||
],
|
||||
color: [
|
||||
{ cls: 'ink-1', label: '最重(标题级)' },
|
||||
{ cls: 'ink-2', label: '正文' },
|
||||
{ cls: 'ink-3', label: '次要(日期/元信息)' },
|
||||
{ cls: 'ink-muted', label: '最淡(注释感)' },
|
||||
{ cls: 'ink-accent', label: '强调(链接色)' },
|
||||
{ cls: 'ink-accent-soft', label: '弱强调' },
|
||||
{ cls: 'ink-warm', label: '暖调正文' },
|
||||
{ cls: 'ink-cool', label: '冷调正文' },
|
||||
{ cls: 'ink-faint', label: '最淡可读档' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 后台可视化排版选择(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;
|
||||
},
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user