Files
blog/public/admin/typography-editor.js
T
wait4xandGLM-5.3 b99e6e744b chore(spec): 归档 blog-5l——三任务收敛(预览一致性/排版提醒/瞬间置顶隐藏)
Spec: 2026-11-05_blog-5l-admin-preview-moments

Co-authored-by: GLM-5.3
2026-11-05 10:03:34 +08:00

154 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 后台可视化排版选择(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>/, // 多类空格分隔(font-kai c-red)也匹配
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;
},
});
})();