feat(creator-studio): bottom status bar, editor-rendered math/footnote, code language picker, block delete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-07 00:38:39 +08:00
1 parent 6928127bfa
commit 4a119358ca
11 files changed
+754 -63

No files matched your search

+34 -15
View File
@@ -51,19 +51,26 @@ export function codeLabel(language: string): string {
return CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
}
/** 代码块:语言写在 data 属性里,前台 enlighter 与编辑器标签共用。 */
/** 代码块右上角角标:编辑器内由 React 渲染,这里给出标识供 CSS 定位。 */
export const CODE_LABEL_CLASS = 'jio-code-lang';
/**
* 代码块:语言写在 data 属性里,前台 enlighter 与编辑器角标共用。
*
* `data-jio-code` 标记这是编辑器创建的块,编辑器据此保证回车不跳出代码区、
* 并渲染可点击的语言角标。
*/
export function codeBlockMarkup(language = 'generic', code = ''): string {
const body = code !== '' ? escapeHtml(code) : escapeHtml(__('在此输入代码', 'jio'));
// data-jio-code-label 供编辑器角标直接显示,避免在 CSS 里映射语言标识。
const label = escapeHtml(codeLabel(language));
return (
`<pre class="enlighter-pre" data-enlighter-language="${escapeHtml(language)}" data-jio-code-label="${label}">` +
`<pre class="enlighter-pre" data-jio-code="1" data-enlighter-language="${escapeHtml(language)}">` +
`<code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
body +
'</code></pre><p></p>'
);
}
/** 把代码块的语言写成编辑器里的内联标签(纯文本,保存前由编辑器移除)。 */
export type TableOptions = {
rows: number;
cols: number;
@@ -102,21 +109,33 @@ export function tocMarkup(): string {
return '<p>[jio_toc]</p><p></p>';
}
/** 注释:用短代码落库,服务端生成上标序号与文末注释列表。 */
export function footnoteMarkup(note: string): string {
const text = note.trim() || __('注释内容', 'jio');
// 短代码属性里再放 HTML 会被 kses 改写,这里只允许纯文本内容。
return `<p>[jio_footnote]${escapeHtml(text)}[/jio_footnote]</p>`;
}
export type MathOptions = { formula: string; display: boolean };
/** 公式:用短代码落库,服务端统一做上标 / 下标排版。 */
/** 公式的上标 / 下标排版(与 ContentBlocks::typeset_math 保持一致)。 */
export function typesetMath(raw: string): string {
const escaped = escapeHtml(raw);
return escaped
.replace(/\^\{([^{}]*)\}|\^(\S)/gu, '<sup>$1$2</sup>')
.replace(/_\{([^{}]*)\}|_(\S)/gu, '<sub>$1$2</sub>');
}
/**
* 公式:编辑器里直接显示排版结果,保存前由 `serializeBlocks` 换回短代码。
*/
export function mathMarkup({ formula, display }: MathOptions): string {
const body = escapeHtml(formula.trim());
const body = typesetMath(formula.trim());
const attrs = `data-jio-math-source="${escapeHtml(formula.trim())}" data-jio-math-display="${display ? '1' : '0'}"`;
return display
? `<p>[jio_math display="1"]${body}[/jio_math]</p>`
: `<p>[jio_math display="0"]${body}[/jio_math]</p>`;
? `<p><span class="jio-math jio-math--block" ${attrs} contenteditable="false">${body}</span></p>`
: `<span class="jio-math" ${attrs}>${body}</span>`;
}
/**
* 注释:编辑器里显示上标序号,保存前由 `serializeBlocks` 换回短代码。
*/
export function footnoteMarkup(note: string): string {
const text = note.trim() || __('注释内容', 'jio');
return `<sup class="jio-footnote-ref" data-jio-footnote="${escapeHtml(text)}" contenteditable="false">${escapeHtml(text)}</sup>`;
}
export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';
@@ -0,0 +1,83 @@
/**
* 编辑器内容 ↔ 保存格式的转换。
*
* 编辑器里把公式、注释、代码语言标签渲染成可读的样子(方便写作时看到最终效果),
* 但落库必须回到短代码/纯标记,服务端才能稳定渲染。这里在两个方向上都做一次转换。
*
* @package Jio
*/
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const decodeEntities = (value: string): string => {
const area = document.createElement('textarea');
area.innerHTML = value;
return area.value;
};
/**
* 保存前:把编辑器里的可视化块换回短代码 / 纯标记。
*/
export function serializeBlocks(html: string): string {
if (!html || (html.indexOf('data-jio-') === -1 && html.indexOf('jio-code-lang') === -1)) {
return html;
}
const container = document.createElement('div');
container.innerHTML = html;
// 公式
container.querySelectorAll<HTMLElement>('[data-jio-math-source]').forEach((node) => {
const formula = node.getAttribute('data-jio-math-source') || '';
const display = node.getAttribute('data-jio-math-display') !== '0';
const shortcode = `[jio_math display="${display ? '1' : '0'}"]${formula}[/jio_math]`;
const paragraph = node.closest('p');
if (display && paragraph && paragraph.textContent?.trim() === node.textContent?.trim()) {
paragraph.textContent = shortcode;
} else {
node.replaceWith(document.createTextNode(shortcode));
}
});
// 注释
container.querySelectorAll<HTMLElement>('[data-jio-footnote]').forEach((node) => {
const note = node.getAttribute('data-jio-footnote') || '';
node.replaceWith(document.createTextNode(`[jio_footnote]${note}[/jio_footnote]`));
});
// 代码块语言角标只是编辑期提示
container.querySelectorAll('.jio-code-lang').forEach((node) => node.remove());
return container.innerHTML;
}
/**
* 打开已有内容时:把短代码还原成编辑器里的可视化块。
*/
export function hydrateBlocks(html: string): string {
if (!html || html.indexOf('[jio_') === -1) {
return html;
}
return html
.replace(
/\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g,
(_match, display: string, formula: string) =>
display === '0'
? `<span class="jio-math" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="0" contenteditable="false">${typeset(formula)}</span>`
: `<p><span class="jio-math jio-math--block" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="1" contenteditable="false">${typeset(formula)}</span></p>`
)
.replace(
/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,
(_match, note: string) =>
`<sup class="jio-footnote-ref" data-jio-footnote="${escapeAttr(decodeEntities(note))}" contenteditable="false">${note}</sup>`
);
}
const typeset = (raw: string): string => {
const escaped = raw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return escaped
.replace(/\^\{([^{}]*)\}|\^(\S)/gu, '<sup>$1$2</sup>')
.replace(/_\{([^{}]*)\}|_(\S)/gu, '<sub>$1$2</sub>');
};