- 颜色:c-{blue,teal,green,yellow,orange,red,pink,purple} × {标准,soft}
共 16 类;tokens 明暗各一套值,32 项浏览器实测对比度 4.50–11.73(零失败);
黄色压到 #7a5900(原色白底仅 ~1.8:1)、黄/粉 soft 压深留余量
- 使用纪律入档:彩色只作用于正文强调,站点外壳(导航/页脚/按钮/背景/边框)
保持灰阶,单页彩色强调建议 ≤3 处
- 字体:3/4 回退类升级为「自托管优先 + 系统兜底」(font-song→Noto Serif SC、
font-kai-sys→霞鹜文楷、font-hei-sys→Noto Sans SC,+0.0KB 跨设备一致);
仿宋无 OFL 自托管包(fandol/朱雀仿宋均无版本)→ 纯系统回退,缺口待决策
- webfont 候选全部按体积账否决并留档:霞鹜文楷 Light 用到页 +8605KB、站酷小薇
+648KB、得意黑/更纱/汇文无可用 npm 包;根因=@fontsource CJK 切片粒度
- 可视化面板 v2:33 项(8 字族 + 25 颜色),颜色项带 16px 色块预览、字体项带
示例字形「秋海」并标注自托管/优先/系统;桩 CMS 7 项验证全过
- 白名单放行 25 色类 + 8 字族类;docs 三表(灰阶层级/语义色板含对比度/字体
含许可证与体积账)更新
车道:op-artalk(改口径后重跑,实测表齐全);收编复验:CSS 16 色类定义与
白名单一致、暗态值在 [data-theme="dark"] 段、字体回退类 computed 生效、
构建 48 页、E2E 37/37
验证: pnpm astro build 48 页 exit 0;E2E 37/37;playwright 抽查彩色类与
字体回退 computed
Spec: none (排版色板与字体回退,车道级交付)
Co-authored-by: GLM-5.3
154 lines
6.8 KiB
JavaScript
154 lines
6.8 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, withSwatch) => {
|
||
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.dataset.cls = item.cls;
|
||
b.className = item.cls === current ? 'active' : '';
|
||
Object.assign(b.style, {
|
||
display: 'inline-flex', alignItems: 'center', gap: '8px',
|
||
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',
|
||
});
|
||
if (withSwatch) {
|
||
// 色块预览:直接展示该类在亮态下的实际颜色
|
||
const chip = document.createElement('span');
|
||
Object.assign(chip.style, {
|
||
width: '16px', height: '16px', borderRadius: '4px', flex: 'none',
|
||
background: item.swatch || '#ccc',
|
||
border: '1px solid rgba(0,0,0,.12)',
|
||
});
|
||
chip.setAttribute('aria-hidden', 'true');
|
||
b.appendChild(chip);
|
||
} else {
|
||
// 字体项:名称 + 用该字体栈渲染的示例字形
|
||
const sample = document.createElement('span');
|
||
sample.textContent = '秋海';
|
||
Object.assign(sample.style, {
|
||
fontFamily: item.family || 'inherit', fontSize: '15px', lineHeight: '1',
|
||
padding: '0 4px', borderLeft: '1px solid #e6e7eb', color: '#33343a',
|
||
});
|
||
b.appendChild(sample);
|
||
}
|
||
const label = document.createElement('span');
|
||
label.textContent = item.label + (item.kind ? `(${item.kind})` : '');
|
||
b.appendChild(label);
|
||
row.appendChild(b);
|
||
buttons.push(b);
|
||
}
|
||
card.appendChild(row);
|
||
};
|
||
addGroup('字体', CLASSES.font, false);
|
||
addGroup('颜色', CLASSES.color, true);
|
||
|
||
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;
|
||
},
|
||
});
|
||
})();
|