From 2b75ac4d284ce72f1f66e8eaaf506ab11dd937da Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 11:10:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=20tiptap=20?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E8=BD=AC=E4=B9=89=E8=84=9A=E6=B3=A8?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=20[^id]=20=E2=86=92=20\[^id\]=20=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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。 --- .../src/__tests__/footnote.test.ts | 75 +++++++++++++++++++ libs/tiptap-editor/src/index.ts | 21 +++++- 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 libs/tiptap-editor/src/__tests__/footnote.test.ts diff --git a/libs/tiptap-editor/src/__tests__/footnote.test.ts b/libs/tiptap-editor/src/__tests__/footnote.test.ts new file mode 100644 index 0000000..07b3e1c --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/footnote.test.ts @@ -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\\]'); + }); +}); diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 80cbd7e..1c5092d 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -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 对脚注语法的破坏。 + * + * escapeMarkdownSyntax(MarkdownManager.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';