feat(editor): tiptap 富文本编辑器内脚注所见即所得

脚注 [^id] 引用与 [^id]: 内容 定义此前在富文本模式下只是裸文本,
作者无法看到渲染效果。现在像数学公式一样建模为 atom 节点,在编辑器内
直接预览:引用渲染成上标编号、定义渲染成可双击编辑的卡片块,编号按
引用首次出现顺序分配,与线上文章页 render_markdown_enhanced 的 GFM
模式一致。

- footnoteRef(inline): [^label] 解析成上标节点,双击浮层预览定义内容
- footnoteDef(block): [^label]: 内容 解析成卡片块,双击进入 textarea 编辑
- FootnoteNumbering: ProseMirror plugin,文档变化后重算编号表写 storage,
  并直接遍历 DOM 刷编号文本(不 dispatch transaction,避免循环);编号是
  派生视图,不进文档模型
- 多行定义序列化用 4 空格缩进续行,与 pulldown-cmark GFM 续行规则对齐
- 样式复用文章页 .fn-ref/.footnote-definition 视觉语义,限定 .tiptap-editor
  作用域,Latte/Mocha 双主题

脚注改走 atom 节点的 renderMarkdown 后,不再经过 escapeMarkdownSyntax 的
文本转义路径,unescapeFootnoteSyntax 后处理及其三处调用随之移除(转义问题
从根上消失)。测试从旧的"转义+还原"模型重写为验证节点解析、序列化往返、
编号实时分配。服务端 markdown.rs / input.css 零改动。
This commit is contained in:
xfy 2026-07-23 14:33:25 +08:00
parent f569258633
commit 86781d8bbc
4 changed files with 847 additions and 56 deletions

View File

@ -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<Record<string, unknown>> {
const found: Array<Record<string, unknown>> = [];
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<string, number> {
const storage = editor.storage as unknown as {
footnoteNumbering: { numbering: Map<string, number> };
};
return storage.footnoteNumbering.numbering;
}
function getDefinitions(editor: Editor): Map<string, string> {
const storage = editor.storage as unknown as {
footnoteNumbering: { definitions: Map<string, string> };
};
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('定义内容');
});
});

View File

@ -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<label, number>
* 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<string, number>;
/** 每次重算自增,NodeView 用它判断是否需要重绘。 */
version: number;
/** label → 定义内容(供引用节点双击预览)。 */
definitions: Map<string, string>;
}
/** 把多行 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<string, unknown>).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<string, number>(),
definitions: new Map<string, string>(),
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<string, number>();
const definitions = new Map<string, string>();
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<string, unknown>,
numbering as Map<string, unknown>,
);
const defsChanged = !mapsEqual(
fnStorage.definitions as Map<string, unknown>,
definitions as Map<string, unknown>,
);
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<string, number>): 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<string, unknown>, b: Map<string, unknown>): 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;
}

View File

@ -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
*
* 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]');
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';

View File

@ -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);
}