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';