diff --git a/libs/tiptap-editor/src/__tests__/footnote.test.ts b/libs/tiptap-editor/src/__tests__/footnote.test.ts index 7f0152d..07443e6 100644 --- a/libs/tiptap-editor/src/__tests__/footnote.test.ts +++ b/libs/tiptap-editor/src/__tests__/footnote.test.ts @@ -5,68 +5,171 @@ 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 正则的修复效果。 - */ +import { FootnoteDef, FootnoteNumbering, FootnoteRef } from '../footnote'; -const UNESCAPE_RE = /\\\[\^([^\n\\]*?)\\\]/g; -const unescapeFootnote = (md: string) => md.replace(UNESCAPE_RE, '[^$1]'); +/** + * 脚注节点回归测试。 + * + * 新模型:[^id] 解析成 atom 节点(footnoteRef/footnoteDef),序列化走 + * renderMarkdown 直接拼字面量,绕过 @tiptap/markdown 的 escapeMarkdownSyntax。 + * 旧版 unescapeFootnoteSyntax 后处理已移除——不再需要。 + */ function makeEditor() { return new Editor({ element: document.body, - extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Markdown], + extensions: [ + StarterKit.configure({ heading: { levels: [1, 2, 3] } }), + Markdown, + FootnoteRef, + FootnoteDef, + FootnoteNumbering, + ], content: '', }); } -describe('脚注语法 - escapeMarkdownSyntax 破坏与修复', () => { +/** 遍历文档,收集指定类型节点的 attrs。 */ +function collectNodes(editor: Editor, type: string): Array> { + const found: Array> = []; + editor.state.doc.descendants((node) => { + if (node.type.name === type) { + found.push({ ...node.attrs }); + } + return true; + }); + return found; +} + +/** 从 editor.storage 取脚注编号表(storage 上无类型,需断言)。 */ +function getNumbering(editor: Editor): Map { + const storage = editor.storage as unknown as { + footnoteNumbering: { numbering: Map }; + }; + return storage.footnoteNumbering.numbering; +} + +function getDefinitions(editor: Editor): Map { + const storage = editor.storage as unknown as { + footnoteNumbering: { definitions: Map }; + }; + return storage.footnoteNumbering.definitions; +} + +describe('脚注节点 - 解析与序列化', () => { 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('引用 [^1] 解析成 footnoteRef 节点,不再被转义', () => { + editor.commands.setContent('正文[^1]结束', { contentType: 'markdown' }); + const refs = collectNodes(editor, 'footnoteRef'); + expect(refs).toHaveLength(1); + expect(refs[0]?.label).toBe('1'); + // 关键:序列化输出不应含转义形式 \[^1\] + const md = editor.getMarkdown(); + expect(md).toContain('[^1]'); + expect(md).not.toContain('\\[^1\\]'); }); - it('unescape 正则还原脚注引用 \\[^1\\] → [^1]', () => { - expect(unescapeFootnote('\\[^1\\]')).toBe('[^1]'); + it('定义 [^1]: 内容 解析成 footnoteDef 节点并往返无损', () => { + editor.commands.setContent('正文[^1]\n\n[^1]: 这是脚注内容', { + contentType: 'markdown', + }); + const defs = collectNodes(editor, 'footnoteDef'); + expect(defs).toHaveLength(1); + expect(defs[0]?.label).toBe('1'); + expect(defs[0]?.content).toBe('这是脚注内容'); + + const md = editor.getMarkdown(); + expect(md).toContain('[^1]: 这是脚注内容'); }); - it('unescape 正则还原含空格的脚注 label', () => { - expect(unescapeFootnote('\\[^my note\\]')).toBe('[^my note]'); + it('多行定义(缩进续行)被收集为单个 content', () => { + const src = ['正文[^a]', '', '[^a]: 第一行', ' 第二行', ' 第三行', ''].join('\n'); + editor.commands.setContent(src, { contentType: 'markdown' }); + const defs = collectNodes(editor, 'footnoteDef'); + expect(defs).toHaveLength(1); + expect(defs[0]?.content).toBe('第一行\n第二行\n第三行'); }); - it('unescape 正则还原脚注定义 \\[^1\\]: → [^1]:', () => { - expect(unescapeFootnote('\\[^1\\]: 定义内容')).toBe('[^1]: 定义内容'); + it('多行定义序列化后用 4 空格缩进续行(GFM 格式)', () => { + const src = ['正文[^a]', '', '[^a]: 第一行', ' 第二行', ''].join('\n'); + editor.commands.setContent(src, { contentType: 'markdown' }); + const md = editor.getMarkdown(); + // 首行无缩进,续行 4 空格——与 pulldown-cmark GFM 续行规则一致。 + expect(md).toContain('[^a]: 第一行'); + expect(md).toContain(' 第二行'); }); - it('unescape 正则不影响普通链接 [text](url)', () => { - const md = '\\[text\\](https://example.com)'; - // 普通链接的 \[text\] 不以 ^ 开头,不会被误改 - expect(unescapeFootnote(md)).toBe(md); + it('label 含空格也能正确解析与序列化', () => { + editor.commands.setContent('正文[^my note]', { contentType: 'markdown' }); + const refs = collectNodes(editor, 'footnoteRef'); + expect(refs[0]?.label).toBe('my note'); + expect(editor.getMarkdown()).toContain('[^my note]'); }); - it('unescape 正则一次处理多个脚注引用', () => { - const md = '引用了\\[^1\\]和\\[^pc\\]和\\[^2\\]'; - expect(unescapeFootnote(md)).toBe('引用了[^1]和[^pc]和[^2]'); + it('普通链接 [text](url) 不被脚注 tokenizer 误吞', () => { + editor.commands.setContent('链接[示例](http://x.com)此处', { + contentType: 'markdown', + }); + const refs = collectNodes(editor, 'footnoteRef'); + expect(refs).toHaveLength(0); + const md = editor.getMarkdown(); + expect(md).toContain('[示例](http://x.com)'); + // 也不应被转义成 \[示例\] + expect(md).not.toContain('\\[示例\\]'); }); - it('完整往返:unescape(getMarkdown()) 保留脚注引用语法', () => { - editor.commands.setContent('正文[^1]', { contentType: 'markdown' }); - const fixed = unescapeFootnote(editor.getMarkdown()); - expect(fixed).toContain('[^1]'); - expect(fixed).not.toContain('\\[^1\\]'); + it('多次引用同一 label 产生多个 footnoteRef 节点', () => { + editor.commands.setContent('前[^1]后[^1]再[^2]', { contentType: 'markdown' }); + const refs = collectNodes(editor, 'footnoteRef'); + expect(refs).toHaveLength(3); + expect(refs.map((r) => r.label)).toEqual(['1', '1', '2']); + }); +}); + +describe('脚注编号 - 实时分配', () => { + let editor: Editor; + + beforeEach(() => { + editor = makeEditor(); + }); + + it('编号按引用首次出现顺序分配', () => { + editor.commands.setContent('先[^b]后[^a]', { contentType: 'markdown' }); + const numbering = getNumbering(editor); + // b 先出现→1, a 后出现→2 + expect(numbering.get('b')).toBe(1); + expect(numbering.get('a')).toBe(2); + }); + + it('重复引用同一 label 共享同一编号', () => { + editor.commands.setContent('前[^1]中[^1]后[^2]', { contentType: 'markdown' }); + const numbering = getNumbering(editor); + expect(numbering.get('1')).toBe(1); + expect(numbering.get('2')).toBe(2); + }); + + it('删除引用后编号重排', () => { + editor.commands.setContent('A[^x]B[^y]', { contentType: 'markdown' }); + expect(getNumbering(editor).get('x')).toBe(1); + expect(getNumbering(editor).get('y')).toBe(2); + + // 删除第一个引用 [^x](用全量替换简化)。 + editor.commands.setContent('AB[^y]', { contentType: 'markdown' }); + const numbering = getNumbering(editor); + expect(numbering.has('x')).toBe(false); + // y 现在是唯一的,编号应是 1。 + expect(numbering.get('y')).toBe(1); + }); + + it('定义内容收集到 definitions 供引用预览', () => { + editor.commands.setContent('正文[^1]\n\n[^1]: 定义内容', { + contentType: 'markdown', + }); + expect(getDefinitions(editor).get('1')).toBe('定义内容'); }); }); diff --git a/libs/tiptap-editor/src/footnote.ts b/libs/tiptap-editor/src/footnote.ts new file mode 100644 index 0000000..1e3b68a --- /dev/null +++ b/libs/tiptap-editor/src/footnote.ts @@ -0,0 +1,534 @@ +import type { Editor } from '@tiptap/core'; +import { Extension, mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +/** + * 脚注节点(footnoteRef 引用 + footnoteDef 定义)与实时编号扩展。 + * + * 设计目标:让脚注像数学公式、代码块一样在富文本编辑器内所见即所得—— + * 引用 `[^1]` 渲染成上标编号节点,定义 `[^1]: 内容` 渲染成可编辑卡片块, + * 编号按引用首次出现顺序分配,与线上文章页(服务端 render_markdown_enhanced + * 的 GFM 模式)一致。 + * + * 技术范式照搬 `./math.ts`:inline/block atom Node + markdown spec 三件套 + * (markdownTokenizer/parseMarkdown/renderMarkdown)。atom 节点的 renderMarkdown + * 直接拼字面量,绕过 @tiptap/markdown 的 escapeMarkdownSyntax(它会把 + * `[`→`\[`、`]`→`\]`,这正是旧版 `unescapeFootnoteSyntax` 要修复的问题—— + * 走节点路径后该转义不再发生,unescape 后处理可移除)。 + * + * 编号是"派生视图",不写回 attrs(否则每次重算触发 transaction 循环): + * FootnoteNumbering 扩展在文档变化后重算 Map 并写入 + * editor.storage.footnoteNumbering,NodeView 读取它绘制编号。 + * + * 编号顺序与服务端 src/api/markdown.rs:74-116 的 fn_order 对齐:按 + * footnoteRef 在文档中首次出现顺序给唯一 label 分配 1, 2, 3… + */ + +// ---- tokenizer 正则 ---- +// inline `[^label]` 引用:label 不含 ] 与换行。负向断言 (?!:) 排除 `[label]:` 定义形式 +// (定义是 block 级,理论上不会进 inline tokenize,但加断言更稳妥,也防 [^1]: 被段落内联误吞)。 +const FOOTNOTE_REF_REGEX = /^\[\^([^\]\n]+)\](?!:)/; +// input rule:行内输入 `[^label]` 后空格/标点触发(仿 InlineMath 的行尾锚定)。 +// 要求 `[^` 前是空白或行首,避免误吞普通文本里的 `[`。 +const FOOTNOTE_REF_INPUT_REGEX = /(?:^|\s)\[\^([^\]\n\\]+)\](?:[\s。,、;:!?.,;:!?]|$)$/; +// block `[^label]: 内容` 定义首行 + 后续缩进续行(4 空格或 tab)。 +// 续行收集规则与服务端 pulldown-cmark GFM 模式一致。 +const FOOTNOTE_DEF_REGEX = /^\[\^([^\]\n]+)\]:[ \t]*(.*)\n?((?:[ \t]+[^\n]*(?:\n|$))*)/; + +/** storage 里存放的编号表,NodeView 读取它绘制编号。 */ +export interface FootnoteNumberingStorage { + /** label → 显示编号(1 起)。 */ + numbering: Map; + /** 每次重算自增,NodeView 用它判断是否需要重绘。 */ + version: number; + /** label → 定义内容(供引用节点双击预览)。 */ + definitions: Map; +} + +/** 把多行 content 重新格式化为 GFM 续行:首行跟在 `[^label]: ` 后,续行缩进 4 空格。 */ +function formatDefinitionBody(content: string): string { + const lines = content.replace(/\r\n/g, '\n').split('\n'); + if (lines.length <= 1) return content; + // 续行每行加 4 空格缩进(空行不缩进,保持可读)。 + return lines.map((line, i) => (i === 0 || line === '' ? line : ` ${line}`)).join('\n'); +} + +/** 从 marked 捕获的续行 raw 还原出纯内容(去掉每行前导缩进)。 */ +function unindentContinuation(raw: string): string { + return raw + .split('\n') + .map((line) => line.replace(/^[ \t]{1,4}/, '')) + .join('\n') + .replace(/\n+$/, ''); +} + +// ============================================================ +// NodeView:脚注引用(inline 上标) +// ============================================================ +class FootnoteRefNodeView { + private node: PMNode; + private editor: Editor; + private container: HTMLSpanElement; + private linkEl: HTMLAnchorElement; + + constructor(opts: { node: PMNode; editor: Editor }) { + this.node = opts.node; + this.editor = opts.editor; + + this.container = document.createElement('span'); + this.container.setAttribute('contenteditable', 'false'); + this.container.classList.add('fn-ref-node'); + + this.linkEl = document.createElement('a'); + this.linkEl.classList.add('fn-ref-link'); + this.linkEl.setAttribute('role', 'doc-noteref'); + this.container.appendChild(this.linkEl); + + this.applyAttrs(); + // 初始编号文本先占位,plugin 的 view.update 会立即覆盖为真实编号。 + this.linkEl.textContent = `^${(this.node.attrs.label as string) ?? ''}`; + this.attachPreview(); + } + + get dom(): HTMLElement { + return this.container; + } + + get contentDOM(): HTMLElement | null { + return null; + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type) return false; + const labelChanged = (node.attrs.label as string) !== (this.node.attrs.label as string); + this.node = node; + // label 变了才需更新 data-label/title;编号文本由 plugin 维护,这里不动。 + if (labelChanged) { + this.applyAttrs(); + } + return true; + } + + ignoreMutation(): boolean { + return true; + } + + stopEvent(): boolean { + return false; + } + + destroy(): void { + this.container.innerHTML = ''; + } + + private getStorage(): FootnoteNumberingStorage | null { + const s = (this.editor.storage as unknown as Record).footnoteNumbering; + return (s as FootnoteNumberingStorage) ?? null; + } + + private applyAttrs(): void { + const label = (this.node.attrs.label as string) ?? ''; + this.container.setAttribute('data-label', label); + this.linkEl.title = `脚注:${label}`; + } + + /** 双击弹出定义内容的只读预览气泡(定义节点里编辑)。 */ + private attachPreview(): void { + this.container.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + const label = (this.node.attrs.label as string) ?? ''; + const def = this.getStorage()?.definitions.get(label); + // 简易浮层:聚焦后失焦消失。避免引入额外 UI 依赖。 + const tip = document.createElement('span'); + tip.className = 'fn-ref-preview'; + tip.textContent = def ?? '(未定义的脚注)'; + tip.setAttribute('contenteditable', 'false'); + this.container.appendChild(tip); + const dismiss = () => { + tip.remove(); + document.removeEventListener('click', onOutside, true); + }; + const onOutside = (ev: MouseEvent) => { + if (!tip.contains(ev.target as globalThis.Node)) dismiss(); + }; + setTimeout(() => document.addEventListener('click', onOutside, true), 0); + tip.addEventListener('click', (ev) => ev.stopPropagation()); + }); + } +} + +// ============================================================ +// NodeView:脚注定义(block 卡片,可双击编辑) +// ============================================================ +class FootnoteDefNodeView { + private node: PMNode; + private editor: Editor; + private getPos?: () => number | undefined; + + private container: HTMLElement; + private readonly labelEl: HTMLSpanElement; + private readonly contentEl: HTMLSpanElement; + private editEl: HTMLTextAreaElement | null = null; + + constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) { + this.node = opts.node; + this.editor = opts.editor; + this.getPos = opts.getPos; + + this.container = document.createElement('aside'); + this.container.classList.add('footnote-definition'); + this.container.setAttribute('contenteditable', 'false'); + this.container.setAttribute('role', 'doc-footnote'); + this.container.title = '双击编辑脚注内容'; + + this.labelEl = document.createElement('sup'); + this.labelEl.classList.add('footnote-definition-label'); + this.container.appendChild(this.labelEl); + + this.contentEl = document.createElement('span'); + this.contentEl.classList.add('footnote-definition-content'); + this.container.appendChild(this.contentEl); + + this.applyAttrs(); + // 编号文本由 plugin 维护;此处先占位。 + this.labelEl.textContent = `^${(this.node.attrs.label as string) ?? ''}`; + this.attachEdit(); + } + + get dom(): HTMLElement { + return this.container; + } + + get contentDOM(): HTMLElement | null { + return null; + } + + update(node: PMNode): boolean { + if (node.type !== this.node.type) return false; + const oldLabel = this.node.attrs.label as string; + const oldContent = this.node.attrs.content as string; + this.node = node; + // label/content 变了才重绘 data 属性与内容文本;编号由 plugin 维护。 + if ( + oldLabel !== (node.attrs.label as string) || + oldContent !== (node.attrs.content as string) + ) { + this.applyAttrs(); + } + return true; + } + + ignoreMutation(): boolean { + return true; + } + + stopEvent(): boolean { + // 编辑态下 textarea 事件交给浏览器;非编辑态双击由 attachEdit 处理。 + return this.editEl !== null; + } + + destroy(): void { + this.container.innerHTML = ''; + this.editEl = null; + } + + private applyAttrs(): void { + const label = (this.node.attrs.label as string) ?? ''; + const content = (this.node.attrs.content as string) ?? ''; + this.container.setAttribute('data-label', label); + // 编辑态不覆盖 textarea,避免打断输入。 + if (!this.editEl) { + this.contentEl.textContent = content; + } + } + + /** 仿 MathNodeView.enterEdit:textarea + Ctrl/Cmd+Enter 或失焦提交、Esc 放弃。 */ + private attachEdit(): void { + this.container.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.enterEdit(); + }); + } + + private enterEdit(): void { + if (this.editEl) return; + const ta = document.createElement('textarea'); + ta.className = 'footnote-definition-edit'; + ta.value = (this.node.attrs.content as string) ?? ''; + ta.rows = 3; + ta.spellcheck = false; + ta.placeholder = '脚注内容(Markdown)'; + + this.contentEl.classList.add('footnote-definition-content-hidden'); + this.container.appendChild(ta); + this.editEl = ta; + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + + const commit = () => { + if (!this.editEl) return; + const next = this.editEl.value; + this.editEl = null; + ta.remove(); + this.contentEl.classList.remove('footnote-definition-content-hidden'); + const pos = this.getPos?.(); + if (pos !== undefined) { + this.editor + .chain() + .focus() + .command(({ tr, state }) => { + const target = state.doc.nodeAt(pos); + if (!target || target.type !== this.node.type) return false; + tr.setNodeMarkup(pos, undefined, { ...target.attrs, content: next }); + return true; + }) + .run(); + } else { + (this.node.attrs as { content: string }).content = next; + this.applyAttrs(); + } + }; + + ta.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { + ev.preventDefault(); + commit(); + } else if (ev.key === 'Escape') { + ev.preventDefault(); + this.editEl = null; + ta.remove(); + this.contentEl.classList.remove('footnote-definition-content-hidden'); + } + }); + ta.addEventListener('blur', commit); + } +} + +// ============================================================ +// footnoteRef:inline atom 引用节点 +// ============================================================ +export const FootnoteRef = Node.create({ + name: 'footnoteRef', + inline: true, + group: 'inline', + atom: true, + selectable: true, + + addAttributes() { + return { + label: { + default: '', + parseHTML: (el) => (el as HTMLElement).getAttribute('data-label') ?? '', + renderHTML: (attrs) => { + const v = attrs.label; + return v == null || v === '' ? {} : { 'data-label': v }; + }, + }, + }; + }, + + parseHTML() { + return [{ tag: 'sup[data-footnote-ref]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['sup', mergeAttributes({ 'data-footnote-ref': 'true' }, HTMLAttributes)]; + }, + + markdownTokenName: 'footnoteRef', + markdownTokenizer: { + name: 'footnoteRef', + level: 'inline', + start: (src: string) => src.indexOf('[^'), + tokenize: (src: string) => { + const m = FOOTNOTE_REF_REGEX.exec(src); + if (!m) return undefined; + return { type: 'footnoteRef', raw: m[0], text: m[1] }; + }, + }, + parseMarkdown: (token, helpers) => helpers.createNode('footnoteRef', { label: token.text || '' }), + renderMarkdown: (node) => `[^${node.attrs?.label ?? ''}]`, + + addNodeView() { + return ({ node, editor }) => new FootnoteRefNodeView({ node, editor }); + }, + + addInputRules() { + return [ + nodeInputRule({ + find: FOOTNOTE_REF_INPUT_REGEX, + type: this.type, + getAttributes: (match) => ({ label: match[1] ?? '' }), + }), + ]; + }, +}); + +// ============================================================ +// footnoteDef:block atom 定义节点 +// ============================================================ +export const FootnoteDef = Node.create({ + name: 'footnoteDef', + group: 'block', + atom: true, + defining: true, + + addAttributes() { + return { + label: { + default: '', + parseHTML: (el) => (el as HTMLElement).getAttribute('data-label') ?? '', + renderHTML: (attrs) => { + const v = attrs.label; + return v == null || v === '' ? {} : { 'data-label': v }; + }, + }, + content: { + default: '', + parseHTML: (el) => (el as HTMLElement).getAttribute('data-content') ?? '', + renderHTML: (attrs) => { + const v = attrs.content; + return v == null || v === '' ? {} : { 'data-content': v }; + }, + }, + }; + }, + + parseHTML() { + return [{ tag: 'aside[data-footnote-def]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['aside', mergeAttributes({ 'data-footnote-def': 'true' }, HTMLAttributes)]; + }, + + markdownTokenName: 'footnoteDef', + markdownTokenizer: { + name: 'footnoteDef', + level: 'block', + start: (src: string) => src.indexOf('[^'), + tokenize: (src: string) => { + const m = FOOTNOTE_DEF_REGEX.exec(src); + if (!m) return undefined; + const label = m[1]; + const firstLine = m[2] ?? ''; + const continuation = unindentContinuation(m[3] ?? ''); + const content = continuation ? `${firstLine}\n${continuation}` : firstLine; + return { type: 'footnoteDef', raw: m[0], label, text: content }; + }, + }, + parseMarkdown: (token, helpers) => + helpers.createNode('footnoteDef', { label: token.label || '', content: token.text || '' }), + renderMarkdown: (node) => { + const label = node.attrs?.label ?? ''; + const content = node.attrs?.content ?? ''; + return `[^${label}]: ${formatDefinitionBody(content)}`; + }, + + addNodeView() { + return ({ node, editor, getPos }) => new FootnoteDefNodeView({ node, editor, getPos }); + }, +}); + +// ============================================================ +// FootnoteNumbering:实时编号扩展 +// ============================================================ +const FOOTNOTE_NUMBERING_KEY = new PluginKey('footnoteNumbering'); + +export const FootnoteNumbering = Extension.create({ + name: 'footnoteNumbering', + + addStorage() { + return { + numbering: new Map(), + definitions: new Map(), + version: 0, + } satisfies FootnoteNumberingStorage; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + return [ + new Plugin({ + key: FOOTNOTE_NUMBERING_KEY, + // view.update 在每次 transaction 应用后触发;在此重算编号表、写 storage, + // 并直接遍历 DOM 重绘脚注节点的编号文本。 + // + // 为什么不 dispatch transaction 强制 NodeView.update:那会形成 + // update→dispatch→update 链(虽 changed 守卫能止住,但空 transaction + // 会让光标/选区异常)。直接改 DOM 文本是 NodeView 外部副作用,但脚注 + // 编号是纯派生视图(不进文档模型),ProseMirror 的 ignoreMutation 已让 + // 它对编辑无感,符合 atom + ignoreMutation 范式。 + view() { + return { + update(view) { + const numbering = new Map(); + const definitions = new Map(); + let order = 0; + // 先扫定义(label → content),供引用节点双击预览。 + view.state.doc.descendants((node) => { + if (node.type.name === 'footnoteDef') { + definitions.set(node.attrs.label as string, node.attrs.content as string); + } + return true; + }); + // 再按文档顺序扫引用,首次出现的 label 分配递增编号。 + view.state.doc.descendants((node) => { + if (node.type.name === 'footnoteRef') { + const label = node.attrs.label as string; + if (!numbering.has(label)) { + order += 1; + numbering.set(label, order); + } + } + return true; + }); + const fnStorage = ( + editor.storage as unknown as { footnoteNumbering: FootnoteNumberingStorage } + ).footnoteNumbering; + const numberingChanged = !mapsEqual( + fnStorage.numbering as Map, + numbering as Map, + ); + const defsChanged = !mapsEqual( + fnStorage.definitions as Map, + definitions as Map, + ); + fnStorage.numbering = numbering; + fnStorage.definitions = definitions; + if (numberingChanged || defsChanged || fnStorage.version === 0) { + fnStorage.version += 1; + redrawFootnoteLabels(view.dom, numbering); + } + }, + }; + }, + }), + ]; + }, +}); + +/** 遍历 DOM,把所有脚注节点的编号文本刷成最新值。 */ +function redrawFootnoteLabels(dom: HTMLElement, numbering: Map): void { + const nodes = dom.querySelectorAll('.fn-ref-node, .footnote-definition'); + for (const el of nodes) { + const label = el.getAttribute('data-label') ?? ''; + const num = numbering.get(label); + const display = num !== undefined ? String(num) : `^${label}`; + const target = el.classList.contains('fn-ref-node') + ? el.querySelector('.fn-ref-link') + : el.querySelector('.footnote-definition-label'); + if (target) target.textContent = display; + } +} + +function mapsEqual(a: Map, b: Map): boolean { + if (a.size !== b.size) return false; + for (const [k, v] of a) { + if (!b.has(k) || b.get(k) !== v) return false; + } + return true; +} diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 5717fbd..4347c3c 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -7,6 +7,7 @@ import { Markdown } from '@tiptap/markdown'; import StarterKit from '@tiptap/starter-kit'; import { CodeBlockBackspaceFix } from './code-block-backspace-fix'; import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view'; +import { FootnoteDef, FootnoteNumbering, FootnoteRef } from './footnote'; import { lowlight } from './highlight'; import { DisplayMath, InlineMath } from './math'; import { SlashCommand } from './slash-command'; @@ -119,6 +120,12 @@ class TiptapEditorInstance { // renderMarkdown 路径,绕过 encodeTextForMarkdown 对 LaTeX 的双重转义。 InlineMath, DisplayMath, + // 脚注节点(引用/定义)与编号扩展:同样依赖 markdown spec 收集时机, + // 必须在 Markdown 之后注册(见 math.ts 注释)。FootnoteNumbering 通过 + // ProseMirror plugin 在文档变化后重算编号表并直接刷 DOM,不进文档模型。 + FootnoteRef, + FootnoteDef, + FootnoteNumbering, CodeBlockLowlight.configure({ lowlight }).extend({ addNodeView() { return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos }); @@ -162,7 +169,9 @@ class TiptapEditorInstance { autofocus: false, onUpdate: ({ editor }) => { if (this.options.onUpdate) { - this.options.onUpdate(TiptapEditorInstance.unescapeFootnoteSyntax(editor.getMarkdown())); + // 脚注 [^id] 现由 atom 节点(footnoteRef/footnoteDef)承载,序列化走 + // renderMarkdown 直接拼字面量,不再经 escapeMarkdownSyntax,无需 unescape。 + this.options.onUpdate(editor.getMarkdown()); } }, onFocus: () => { @@ -205,22 +214,7 @@ class TiptapEditorInstance { if (this.isSourceMode && this.sourceTextarea) { return this.sourceTextarea.value; } - 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]'); + return this.editor?.getMarkdown() || ''; } /** @@ -234,9 +228,7 @@ class TiptapEditorInstance { const proseMirrorDom = this.editor.view.dom; if (!this.isSourceMode) { // 富文本 → 源码:导出当前 Markdown 到 textarea - this.sourceTextarea.value = TiptapEditorInstance.unescapeFootnoteSyntax( - this.editor.getMarkdown(), - ); + this.sourceTextarea.value = this.editor.getMarkdown(); // 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零 const pmRatio = this.getScrollRatio(proseMirrorDom); proseMirrorDom.style.display = 'none'; diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index e33ab1b..eade748 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -1168,3 +1168,165 @@ background-color: rgba(137, 180, 250, 0.15); box-shadow: 0 0 0 1px rgba(137, 180, 250, 0.5); } + +/* ---- 脚注引用(inline 上标) ---- + * 视觉与文章页 input.css 的 .fn-ref 对齐:上标、accent 色、无下划线。 + * 编号文本由 FootnoteNumbering plugin 直接写入 .fn-ref-link。 */ +.tiptap-editor .fn-ref-node { + cursor: pointer; + white-space: nowrap; +} + +.tiptap-editor .fn-ref-link { + font-size: 0.75em; + line-height: 0; + vertical-align: super; + /* Latte blue */ + color: #1e66f5; + text-decoration: none; + cursor: pointer; + padding: 0 0.05em; +} + +.tiptap-editor .fn-ref-link:hover { + text-decoration: underline; +} + +.tiptap-editor .ProseMirror-selectednode .fn-ref-node { + background-color: rgba(30, 102, 245, 0.12); + border-radius: 3px; +} + +/* 双击预览浮层 */ +.tiptap-editor .fn-ref-preview { + position: absolute; + z-index: 10; + /* Latte base / text */ + background: #eff1f5; + color: #4c4f69; + border: 1px solid #ccd0da; + border-radius: 6px; + padding: 0.4em 0.6em; + font-size: 0.85em; + line-height: 1.4; + max-width: 280px; + white-space: normal; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + margin-top: 0.5em; +} + +/* ---- 脚注定义(block 卡片) ---- + * 视觉与文章页 .footnote-definition 对齐:左边框、0.875em、可双击编辑。 */ +.tiptap-editor .footnote-definition { + display: flex; + gap: 0.5em; + align-items: flex-start; + margin: 0.75em 0; + padding: 0.5em 0.75em; + /* Latte surface0 透明底 */ + background-color: rgba(204, 208, 218, 0.25); + border-left: 0.2rem solid #1e66f5; + border-radius: 0 4px 4px 0; + font-size: 0.875em; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.tiptap-editor .footnote-definition:hover { + background-color: rgba(204, 208, 218, 0.45); +} + +.tiptap-editor .footnote-definition-label { + flex-shrink: 0; + font-size: 0.75em; + line-height: 1.6; + vertical-align: super; + /* Latte blue */ + color: #1e66f5; + font-weight: 600; +} + +.tiptap-editor .footnote-definition-content { + flex: 1; + /* Latte text */ + color: #4c4f69; + white-space: pre-wrap; + word-break: break-word; +} + +.tiptap-editor .footnote-definition-content-hidden { + display: none; +} + +.tiptap-editor .footnote-definition-edit { + flex: 1; + font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + line-height: 1.5; + padding: 0.4em 0.6em; + border: 1px solid #ccd0da; + border-radius: 6px; + background: #eff1f5; + color: #4c4f69; + resize: vertical; + outline: none; +} + +.tiptap-editor .footnote-definition-edit:focus { + border-color: #1e66f5; + box-shadow: 0 0 0 2px rgba(30, 102, 245, 0.2); +} + +.tiptap-editor .ProseMirror-selectednode .footnote-definition { + background-color: rgba(30, 102, 245, 0.12); + box-shadow: 0 0 0 1px rgba(30, 102, 245, 0.4); +} + +/* Dark theme (Mocha) */ +.dark .tiptap-editor .fn-ref-link { + /* Mocha blue */ + color: #89b4fa; +} + +.dark .tiptap-editor .ProseMirror-selectednode .fn-ref-node { + background-color: rgba(137, 180, 250, 0.15); +} + +.dark .tiptap-editor .fn-ref-preview { + background: #181825; + color: #cdd6f4; + border-color: #313244; +} + +.dark .tiptap-editor .footnote-definition { + background-color: rgba(49, 50, 68, 0.3); + border-left-color: #89b4fa; +} + +.dark .tiptap-editor .footnote-definition:hover { + background-color: rgba(49, 50, 68, 0.5); +} + +.dark .tiptap-editor .footnote-definition-label { + color: #89b4fa; +} + +.dark .tiptap-editor .footnote-definition-content { + color: #cdd6f4; +} + +.dark .tiptap-editor .footnote-definition-edit { + border-color: #313244; + background: #181825; + color: #cdd6f4; +} + +.dark .tiptap-editor .footnote-definition-edit:focus { + border-color: #89b4fa; + box-shadow: 0 0 0 2px rgba(137, 180, 250, 0.25); +} + +.dark .tiptap-editor .ProseMirror-selectednode .footnote-definition { + background-color: rgba(137, 180, 250, 0.15); + box-shadow: 0 0 0 1px rgba(137, 180, 250, 0.5); +}