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:
parent
f569258633
commit
86781d8bbc
@ -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('定义内容');
|
||||
});
|
||||
});
|
||||
|
||||
534
libs/tiptap-editor/src/footnote.ts
Normal file
534
libs/tiptap-editor/src/footnote.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user