fix(editor): 修复 tiptap 序列化转义脚注语法 [^id] → \[^id\] 导致失效
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

@tiptap/markdown 的 escapeMarkdownSyntax (MarkdownManager.ts:1112) 对所有
非代码文本节点做 [→\[、]→\] 转义,导致脚注引用/定义 [^id] 被破坏为 \[^id\],
pulldown-cmark 将其视为字面文本而非脚注语法,渲染出纯文本而非上标链接。

与数学公式节点同类问题(LaTeX 通过 atom Node + renderMarkdown 绕过),
但脚注是普通文本节点无法走相同路径,故在 getMarkdown 输出后做后处理:
将 \[^label\] 还原为 [^label],仅匹配脚注专属模式不影响普通链接语法。

同时修复数据库中已损坏的 markdown-syntax-test 和 markdown-quan-te-xing-
ce-shi-wen-zhang 两篇文章的 content_md,并通过 rebuild 全量重建 content_html。
This commit is contained in:
xfy 2026-07-23 11:10:21 +08:00
parent c693d82bab
commit 2b75ac4d28
2 changed files with 93 additions and 3 deletions

View File

@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { beforeEach, describe, expect, it } from 'vitest';
/**
*
*
* @tiptap/markdown escapeMarkdownSyntax `[``\[``]``\]`
* `[^1]` `\[^1\]`pulldown-cmark
* TiptapEditorInstance.unescapeFootnoteSyntax
*
* escapeMarkdownSyntax unescape
*/
const UNESCAPE_RE = /\\\[\^([^\n\\]*?)\\\]/g;
const unescape = (md: string) => md.replace(UNESCAPE_RE, '[^$1]');
function makeEditor() {
return new Editor({
element: document.body,
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Markdown,
],
content: '',
});
}
describe('脚注语法 - escapeMarkdownSyntax 破坏与修复', () => {
let editor: Editor;
beforeEach(() => {
editor = makeEditor();
});
it('escapeMarkdownSyntax 确实会把 [^1] 转义为 \\[^1\\]', () => {
editor.commands.setContent('引用[^1]', { contentType: 'markdown' });
const raw = editor.getMarkdown();
// 未修复时 raw 为 `\[^1\]`——这就是脚注失效的直接原因
expect(raw).toContain('\\[^1\\]');
});
it('unescape 正则还原脚注引用 \\[^1\\] → [^1]', () => {
expect(unescape('\\[^1\\]')).toBe('[^1]');
});
it('unescape 正则还原含空格的脚注 label', () => {
expect(unescape('\\[^my note\\]')).toBe('[^my note]');
});
it('unescape 正则还原脚注定义 \\[^1\\]: → [^1]:', () => {
expect(unescape('\\[^1\\]: 定义内容')).toBe('[^1]: 定义内容');
});
it('unescape 正则不影响普通链接 [text](url)', () => {
const md = '\\[text\\](https://example.com)';
// 普通链接的 \[text\] 不以 ^ 开头,不会被误改
expect(unescape(md)).toBe(md);
});
it('unescape 正则一次处理多个脚注引用', () => {
const md = '引用了\\[^1\\]和\\[^pc\\]和\\[^2\\]';
expect(unescape(md)).toBe('引用了[^1]和[^pc]和[^2]');
});
it('完整往返unescape(getMarkdown()) 保留脚注引用语法', () => {
editor.commands.setContent('正文[^1]', { contentType: 'markdown' });
const fixed = unescape(editor.getMarkdown());
expect(fixed).toContain('[^1]');
expect(fixed).not.toContain('\\[^1\\]');
});
});

View File

@ -162,7 +162,7 @@ class TiptapEditorInstance {
autofocus: false,
onUpdate: ({ editor }) => {
if (this.options.onUpdate) {
this.options.onUpdate(editor.getMarkdown());
this.options.onUpdate(TiptapEditorInstance.unescapeFootnoteSyntax(editor.getMarkdown()));
}
},
onFocus: () => {
@ -205,7 +205,22 @@ class TiptapEditorInstance {
if (this.isSourceMode && this.sourceTextarea) {
return this.sourceTextarea.value;
}
return this.editor?.getMarkdown() || '';
return TiptapEditorInstance.unescapeFootnoteSyntax(this.editor?.getMarkdown() || '');
}
/**
* @tiptap/markdown escapeMarkdownSyntax
*
* escapeMarkdownSyntaxMarkdownManager.ts:1112 `[` `\[``]` `\]`
* `[^id]` `\[^id\]`pulldown-cmark /
* LaTeX atom Node + renderMarkdown
*
*
* Markdown `\[^label\]` `[^label]`
* `\[` + `^` `[text](url)`
*/
private static unescapeFootnoteSyntax(md: string): string {
return md.replace(/\\\[\^([^\n\\]*?)\\\]/g, '[^$1]');
}
/**
@ -219,7 +234,7 @@ class TiptapEditorInstance {
const proseMirrorDom = this.editor.view.dom;
if (!this.isSourceMode) {
// 富文本 → 源码:导出当前 Markdown 到 textarea
this.sourceTextarea.value = this.editor.getMarkdown();
this.sourceTextarea.value = TiptapEditorInstance.unescapeFootnoteSyntax(this.editor.getMarkdown());
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
const pmRatio = this.getScrollRatio(proseMirrorDom);
proseMirrorDom.style.display = 'none';