fix(creator-studio): render math/footnote shortcodes, inline code blocks, block deletion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 23:52:39 +08:00
1 parent 7885f42cff
commit 6928127bfa
11 files changed
+235 -202

No files matched your search

@@ -10,20 +10,15 @@
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { CODE_LANGUAGES } from '../utils/block-markup';
export type BlockDialogKind = 'table' | 'math' | 'footnote' | 'code';
export type BlockDialogKind = 'table' | 'math' | 'footnote';
export type BlockDialogResult =
| { kind: 'table'; rows: number; cols: number; header: boolean; striped: boolean }
| { kind: 'math'; formula: string; display: boolean }
| { kind: 'footnote'; note: string }
| { kind: 'code'; language: string; code: string };
| { kind: 'footnote'; note: string };
type Props = {
kind: BlockDialogKind;
initialLanguage?: string;
initialCode?: string;
onSubmit: (result: BlockDialogResult) => void;
onClose: () => void;
};
@@ -34,10 +29,9 @@ const TITLES: Record<BlockDialogKind, string> = {
table: __('插入表格', 'jio'),
math: __('插入公式', 'jio'),
footnote: __('插入注释', 'jio'),
code: __('插入代码块', 'jio'),
};
export function BlockDialog({ kind, initialLanguage = 'generic', initialCode = '', onSubmit, onClose }: Props) {
export function BlockDialog({ kind, onSubmit, onClose }: Props) {
const [rows, setRows] = useState(3);
const [cols, setCols] = useState(3);
const [header, setHeader] = useState(true);
@@ -45,8 +39,6 @@ export function BlockDialog({ kind, initialLanguage = 'generic', initialCode = '
const [formula, setFormula] = useState('a^2 + b^2 = c^2');
const [display, setDisplay] = useState(true);
const [note, setNote] = useState('');
const [language, setLanguage] = useState(initialLanguage);
const [code, setCode] = useState(initialCode);
const panelRef = useRef<HTMLDivElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const formulaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -86,10 +78,6 @@ export function BlockDialog({ kind, initialLanguage = 'generic', initialCode = '
onSubmit({ kind, formula: formula.trim(), display });
return;
}
if (kind === 'code') {
onSubmit({ kind, language, code });
return;
}
onSubmit({ kind, note: note.trim() });
};
@@ -163,28 +151,6 @@ export function BlockDialog({ kind, initialLanguage = 'generic', initialCode = '
</>
)}
{kind === 'code' && (
<>
<label className="jio-creator-dialog__field">
<span>{__('语言', 'jio')}</span>
<select value={language} onChange={(event) => setLanguage(event.target.value)}>
{CODE_LANGUAGES.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
</label>
<label className="jio-creator-dialog__field">
<span>{__('代码(可留空,稍后在正文里填写)', 'jio')}</span>
<textarea
rows={6}
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="console.log('hello');"
/>
</label>
</>
)}
{kind === 'footnote' && (
<label className="jio-creator-dialog__field">
<span>{__('注释内容', 'jio')}</span>
@@ -50,7 +50,7 @@ img { height: auto; }
border-radius: 4px;
background: linear-gradient(135deg, #3dcabd 10%, #254bdd 100%);
color: #fff;
content: attr(data-enlighter-language);
content: attr(data-jio-code-label);
font-size: 12px;
line-height: 1.5;
pointer-events: none;
@@ -117,6 +117,15 @@ img { height: auto; }
}
.jio-toc-placeholder::after { content: '目录将在发布后自动生成'; }
/* 注释上标:编辑器里只显示序号,发布后由服务端渲染脚注列表。 */
.jio-footnote-ref,
.jio-footnote-ref a {
color: #267665;
font-size: 0.8em;
text-decoration: none;
vertical-align: super;
}
/* 附件链接。 */
.jio-file-link {
display: inline-flex;
+75 -52
View File
@@ -104,6 +104,76 @@ function initialDraft(): Draft {
};
}
/**
* Backspace / Delete 删除相邻的块级元素。
*
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
* 且紧邻目标块时直接移除该块。
*/
function removeAdjacentBlock(editor: any, event: any): void {
const key = Number(event.keyCode);
if (key !== 8 && key !== 46) {
return;
}
const selection = editor.selection;
if (!selection || !selection.isCollapsed()) {
return;
}
const body = editor.getBody();
const block = selection.getNode();
const isBlank = (node: any): boolean =>
!!node && node.nodeType === 1 && (node.textContent || '').trim() === '' &&
!editor.dom.getParent(node, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
const isBlockElement = (node: any): boolean =>
!!node && node.nodeType === 1 && !!(node.matches?.('table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder'));
// 光标可能落在空段落里,也可能落在块级元素的边界(TinyMCE 常见行为);
// 两种情况下都优先删除紧邻的块,而不是让按键悄悄失效。
const blankContainer = isBlank(block) ? block : null;
const direct = blankContainer
? (key === 8 ? blankContainer.previousElementSibling : blankContainer.nextElementSibling)
: null;
if (isBlockElement(direct)) {
event.preventDefault();
editor.dom.remove(direct);
editor.nodeChanged();
editor.undoManager.add();
return;
}
// 光标停在块首/块尾时,删除块本身。
const caretBlock = editor.dom.getParent(block, 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder', body);
if (!caretBlock) {
return;
}
const caretIsAtEdge = (start: boolean): boolean => {
const probe = editor.dom.createRng();
probe.selectNodeContents(caretBlock);
probe.collapse(start);
const caret = selection.getRng();
return caret && typeof caret.compareBoundaryPoints === 'function'
&& caret.compareBoundaryPoints(start ? 1 : 3, probe) === 0;
};
if (!((key === 8 && caretIsAtEdge(true)) || (key === 46 && caretIsAtEdge(false)))) {
return;
}
event.preventDefault();
const parent = caretBlock.parentElement;
editor.dom.remove(caretBlock);
// 删掉最后一个块后补一个空段落,否则没有可放置光标的位置。
if (parent && parent.children.length === 0) {
editor.setContent('<p></p>');
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
}
editor.nodeChanged();
editor.undoManager.add();
}
function escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -145,8 +215,6 @@ function Studio(): JSX.Element {
}));
const [drawerOpen, setDrawerOpen] = useState(false);
const [blockDialog, setBlockDialog] = useState<BlockDialogKind | null>(null);
const [codeDialog, setCodeDialog] = useState<null | { language: string; code: string }>(null);
const codeTargetRef = useRef<any>(null);
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
const dirtyRef = useRef(false);
@@ -416,23 +484,6 @@ function Studio(): JSX.Element {
});
editor.on('NodeChange', () => updateFormatValue(editor));
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
editor.on('click', (event: any) => {
const pre = editor.dom.getParent(event.target, 'pre.enlighter-pre');
if (!pre) {
return;
}
// 只响应右上角语言标签那一片,光标点进代码正文时保持正常编辑。
const rect = pre.getBoundingClientRect();
const relativeY = Number(event.clientY) - rect.top;
const relativeX = Number(event.clientX) - rect.left;
if (relativeY > 40 || relativeX < rect.width - 120) {
return;
}
event.preventDefault();
const language = String(pre.getAttribute('data-enlighter-language') || 'generic');
codeTargetRef.current = pre;
setCodeDialog({ language, code: String((pre.textContent || '').trim()) });
});
editor.on('input change Undo Redo AddUndo', () => {
updateContent(editor.getContent());
updateHistoryAvailability(editor);
@@ -441,7 +492,9 @@ function Studio(): JSX.Element {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
event.preventDefault();
saveActionRef.current();
return;
}
removeAdjacentBlock(editor, event);
});
},
},
@@ -561,7 +614,8 @@ function Studio(): JSX.Element {
return;
}
if (kind === 'code') {
setCodeDialog({ language: 'generic', code: '' });
// 直接插入;语言可稍后点代码块右上角的角标更换。
insertHtml(codeBlockMarkup('generic'));
return;
}
if (kind === 'list') {
@@ -599,10 +653,8 @@ function Studio(): JSX.Element {
insertHtml(tableMarkup(result));
} else if (result.kind === 'math') {
insertHtml(mathMarkup(result));
} else if (result.kind === 'footnote') {
insertHtml(footnoteMarkup(result.note));
} else {
insertHtml(codeBlockMarkup(result.language, result.code));
insertHtml(footnoteMarkup(result.note));
}
}, [insertHtml]);
@@ -759,35 +811,6 @@ function Studio(): JSX.Element {
/>
)}
{codeDialog && (
<BlockDialog
kind="code"
initialLanguage={codeDialog.language}
initialCode={codeDialog.code}
onSubmit={(result) => {
setCodeDialog(null);
if (result.kind !== 'code') {
return;
}
const previous = codeTargetRef.current;
codeTargetRef.current = null;
insertHtml(codeBlockMarkup(result.language, result.code));
if (previous) {
// 换语言时移除旧块,避免留下两份代码。
const editor = editorRef.current;
if (editor && !editor.removed) {
editor.dom.remove(previous);
updateContent(editor.getContent());
}
}
}}
onClose={() => {
setCodeDialog(null);
codeTargetRef.current = null;
}}
/>
)}
{drawerOpen && (
<PublishDrawer
draft={draft}
@@ -54,8 +54,11 @@ export function codeLabel(language: string): string {
/** 代码块:语言写在 data 属性里,前台 enlighter 与编辑器标签共用。 */
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)}"><code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
`<pre class="enlighter-pre" data-enlighter-language="${escapeHtml(language)}" data-jio-code-label="${label}">` +
`<code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
body +
'</code></pre><p></p>'
);
@@ -93,25 +96,27 @@ export function tableMarkup({ rows, cols, header, striped }: TableOptions): stri
/**
* 目录:文章页的目录树由前台 section-navs 依据 [data-nav] 生成,
* 因此这里只放一个占位标记,读者端不会看到裸露的标记文本。
* 短代码在服务端替换成目录卡片,编辑器里保留可识别的占位。
*/
export function tocMarkup(): string {
return '<div class="jio-toc-placeholder"></div><p></p>';
return '<p>[jio_toc]</p><p></p>';
}
/** 注释:Markdown 脚注语法,前台渲染成编号脚注列表。 */
/** 注释:用短代码落库,服务端生成上标序号与文末注释列表。 */
export function footnoteMarkup(note: string): string {
const text = note.trim() || __('注释内容', 'jio');
return `<p>[^1]<br>[^1]: ${escapeHtml(text)}</p>`;
// 短代码属性里再放 HTML 会被 kses 改写,这里只允许纯文本内容。
return `<p>[jio_footnote]${escapeHtml(text)}[/jio_footnote]</p>`;
}
export type MathOptions = { formula: string; display: boolean };
/** 公式:行内或独占一行,数据保存在 .jio-math 容器里。 */
/** 公式:用短代码落库,服务端统一做上标 / 下标排版。 */
export function mathMarkup({ formula, display }: MathOptions): string {
const className = display ? 'jio-math jio-math--block' : 'jio-math';
const body = `<span class="${className}">${escapeHtml(formula.trim())}</span>`;
return display ? `<p>${body}</p>` : body;
const body = escapeHtml(formula.trim());
return display
? `<p>[jio_math display="1"]${body}[/jio_math]</p>`
: `<p>[jio_math display="0"]${body}[/jio_math]</p>`;
}
export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';