feat(editor): 数学公式节点带 KaTeX 预览,根治编辑器序列化破坏 LaTeX
问题:经 Tiptap 编辑器保存的数学公式,矩阵/分段函数等服务端 KaTeX 渲染失败。 根因:@tiptap/markdown 的 MarkdownManager.encodeTextForMarkdown 对所有非代码 文本节点做 escapeMarkdownSyntax + encodeHtmlEntities,把 LaTeX 的 \→\\、 _→\_、&→&。编辑器内公式走普通文本路径,经 getMarkdown() 序列化后存库即损坏。 解法:把公式建模成 atom Node(InlineMath/DisplayMath),LaTeX 存 attrs.latex 而非子文本;声明 markdownTokenizer/parseMarkdown/renderMarkdown 顶层 spec 字段, 序列化时 renderMarkdown 直接拼 $...$/$$...$$,绕过 encodeTextForMarkdown 转义路径(MarkdownManager 有 handler 的非文本节点不走 escape)。 NodeView 内用客户端 katex.renderToString 渲染预览(类 Notion/Typora),双击编辑 LaTeX 源码。KaTeX CSS 已由 Dioxus.toml 全局注入,无需额外接线。 - 新增 katex ^0.16.22 依赖(workspace 已 hoisted) - 11 个回归测试覆盖矩阵/分段函数/round-trip 序列化往返
This commit is contained in:
parent
e3e0829fcd
commit
5dceb39544
3
libs/pnpm-lock.yaml
generated
3
libs/pnpm-lock.yaml
generated
@ -121,6 +121,9 @@ importers:
|
|||||||
'@tiptap/suggestion':
|
'@tiptap/suggestion':
|
||||||
specifier: ^3.27.3
|
specifier: ^3.27.3
|
||||||
version: 3.27.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
version: 3.27.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
||||||
|
katex:
|
||||||
|
specifier: ^0.16.22
|
||||||
|
version: 0.16.47
|
||||||
lowlight:
|
lowlight:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"@tiptap/pm": "^3.27.3",
|
"@tiptap/pm": "^3.27.3",
|
||||||
"@tiptap/starter-kit": "^3.27.3",
|
"@tiptap/starter-kit": "^3.27.3",
|
||||||
"@tiptap/suggestion": "^3.27.3",
|
"@tiptap/suggestion": "^3.27.3",
|
||||||
|
"katex": "^0.16.22",
|
||||||
"lowlight": "^3.3.0"
|
"lowlight": "^3.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
libs/tiptap-editor/src/__tests__/math.test.ts
Normal file
169
libs/tiptap-editor/src/__tests__/math.test.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { Markdown } from '@tiptap/markdown';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { DisplayMath, InlineMath } from '../math';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数学公式节点单测(happy-dom 真实 DOM + 真实 Editor)。
|
||||||
|
*
|
||||||
|
* 核心回归:`editor.getMarkdown()` 序列化时必须绕过 @tiptap/markdown 的
|
||||||
|
* encodeTextForMarkdown 双重转义(\→\\、_→\_、&→&),否则存库后服务端
|
||||||
|
* KaTeX 收到损坏的 LaTeX 报错。
|
||||||
|
*
|
||||||
|
* 数学公式是 atom Node,LaTeX 存 attrs.latex,renderMarkdown 直接拼 $...$,
|
||||||
|
* 不走文本转义路径(MarkdownManager.ts:1116-1182)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeEditor() {
|
||||||
|
return new Editor({
|
||||||
|
element: document.body,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
||||||
|
Markdown,
|
||||||
|
InlineMath,
|
||||||
|
DisplayMath,
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从编辑器文档收集所有 math/mathBlock 节点的 (type, latex)。 */
|
||||||
|
function collectMathNodes(editor: Editor): Array<{ type: string; latex: string }> {
|
||||||
|
const out: Array<{ type: string; latex: string }> = [];
|
||||||
|
editor.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'math' || node.type.name === 'mathBlock') {
|
||||||
|
out.push({ type: node.type.name, latex: (node.attrs.latex as string) ?? '' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('数学公式节点 - markdown 序列化往返', () => {
|
||||||
|
let editor: Editor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
editor = makeEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('块级公式 $$...$$ 解析为 mathBlock 节点', () => {
|
||||||
|
editor.commands.setContent('$$\\frac{a}{b}$$', { contentType: 'markdown' });
|
||||||
|
const nodes = collectMathNodes(editor);
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(nodes[0].type).toBe('mathBlock');
|
||||||
|
expect(nodes[0].latex).toBe('\\frac{a}{b}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内公式 $...$ 解析为 math 节点', () => {
|
||||||
|
editor.commands.setContent('公式 $E = mc^2$ 很重要', { contentType: 'markdown' });
|
||||||
|
const nodes = collectMathNodes(editor);
|
||||||
|
expect(nodes).toHaveLength(1);
|
||||||
|
expect(nodes[0].type).toBe('math');
|
||||||
|
expect(nodes[0].latex).toBe('E = mc^2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('序列化块级公式:LaTeX 原样输出,反斜杠不被双写', () => {
|
||||||
|
const tex = '\\frac{a}{b}';
|
||||||
|
editor.commands.setContent(`$$${tex}$$`, { contentType: 'markdown' });
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
// 关键回归:不得出现 \\frac(被转义)、不得出现 &
|
||||||
|
expect(md).toContain(tex);
|
||||||
|
expect(md).not.toContain('\\\\frac');
|
||||||
|
expect(md).toContain('$$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('序列化行内公式:LaTeX 原样输出', () => {
|
||||||
|
const tex = 'a^2 + b^2 = c^2';
|
||||||
|
editor.commands.setContent(`勾股 $${tex}$ 定理`, { contentType: 'markdown' });
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
expect(md).toContain(`$${tex}$`);
|
||||||
|
expect(md).toContain('勾股');
|
||||||
|
expect(md).toContain('定理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('矩阵公式:反斜杠、下标、& 全部不被转义(线上 bug 的直接回归)', () => {
|
||||||
|
// 这正是线上 markdown-syntax-test-engine-compatibility 损坏的矩阵内容。
|
||||||
|
const tex = '\\begin{bmatrix} a_{11} & a_{12} \\\\ a_{21} & a_{22} \\end{bmatrix}';
|
||||||
|
editor.commands.setContent(`$$${tex}$$`, { contentType: 'markdown' });
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
expect(md).toContain('\\begin{bmatrix}');
|
||||||
|
// 不得出现被转义的 \\begin(四个反斜杠)、\_、&
|
||||||
|
expect(md).not.toContain('\\\\begin{bmatrix}');
|
||||||
|
expect(md).not.toContain('\\_');
|
||||||
|
expect(md).not.toContain('&');
|
||||||
|
expect(md).toContain('a_{11}');
|
||||||
|
expect(md).toContain(' & ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('分段函数 cases:多行 LaTeX 完整保留', () => {
|
||||||
|
const tex =
|
||||||
|
'f(n) = \\begin{cases} n/2 & \\text{if } n \\text{ is even} \\\\ 3n+1 & \\text{if } n \\text{ is odd} \\end{cases}';
|
||||||
|
editor.commands.setContent(`$$${tex}$$`, { contentType: 'markdown' });
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
expect(md).toContain('\\begin{cases}');
|
||||||
|
expect(md).toContain('\\end{cases}');
|
||||||
|
expect(md).not.toContain('\\\\begin{cases}');
|
||||||
|
expect(md).not.toContain('&');
|
||||||
|
expect(md).not.toContain('\\_');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trip:解析 → 序列化 → 再解析,latex 内容稳定', () => {
|
||||||
|
const tex = '\\sum_{i=1}^{n} \\frac{1}{i^2}';
|
||||||
|
editor.commands.setContent(`$$${tex}$$`, { contentType: 'markdown' });
|
||||||
|
const md1 = editor.getMarkdown();
|
||||||
|
// 第二轮:把序列化结果再喂回去。
|
||||||
|
editor.commands.setContent(md1, { contentType: 'markdown' });
|
||||||
|
const md2 = editor.getMarkdown();
|
||||||
|
expect(md2).toBe(md1);
|
||||||
|
expect(md2).toContain(tex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('数学公式节点 - 边界情况', () => {
|
||||||
|
let editor: Editor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
editor = makeEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成对 $ 会被识别为公式(markdown $...$ 配对语义,非 bug)', () => {
|
||||||
|
// 「5$ 到 10$」里两个 $ 配对,中间「 到 10」被当 inline math——这是 markdown
|
||||||
|
// $...$ 的标准语义,不是误吞。验证它被正确识别为 math 节点,且序列化往返无损。
|
||||||
|
editor.commands.setContent('价格 5$ 到 10$ 之间', { contentType: 'markdown' });
|
||||||
|
const nodes = collectMathNodes(editor);
|
||||||
|
// 配对语义:应识别出一个 math 节点。
|
||||||
|
expect(nodes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// 序列化后 $ 定界符仍存在,内容无损。
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
expect(md).toContain('$');
|
||||||
|
expect(md).toContain('价格');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空公式 $$ 不崩溃', () => {
|
||||||
|
editor.commands.setContent('$$$$', { contentType: 'markdown' });
|
||||||
|
// 不抛异常即可。
|
||||||
|
expect(editor.state.doc).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('混合段落:文本与公式共存', () => {
|
||||||
|
editor.commands.setContent('当 $x > 0$ 时,有 $$x^2 > 0$$ 成立', { contentType: 'markdown' });
|
||||||
|
const nodes = collectMathNodes(editor);
|
||||||
|
expect(nodes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
// 两个公式都应原样保留 $ 定界符。
|
||||||
|
expect(md).toContain('$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('坏 TeX 不导致序列化失败', () => {
|
||||||
|
// renderMarkdown 只拼 $...$,不调用 katex,坏 TeX 不影响序列化。
|
||||||
|
const badTex = '\\undefinedmacro{';
|
||||||
|
editor.commands.setContent(`$${badTex}$`, { contentType: 'markdown' });
|
||||||
|
const md = editor.getMarkdown();
|
||||||
|
expect(md).toContain(badTex);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ import StarterKit from '@tiptap/starter-kit';
|
|||||||
import { CodeBlockBackspaceFix } from './code-block-backspace-fix';
|
import { CodeBlockBackspaceFix } from './code-block-backspace-fix';
|
||||||
import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view';
|
import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view';
|
||||||
import { lowlight } from './highlight';
|
import { lowlight } from './highlight';
|
||||||
|
import { DisplayMath, InlineMath } from './math';
|
||||||
import { SlashCommand } from './slash-command';
|
import { SlashCommand } from './slash-command';
|
||||||
import { TaskInputRule } from './task-input-rule';
|
import { TaskInputRule } from './task-input-rule';
|
||||||
import {
|
import {
|
||||||
@ -112,6 +113,12 @@ class TiptapEditorInstance {
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
}),
|
}),
|
||||||
Markdown,
|
Markdown,
|
||||||
|
// 数学公式节点必须在 Markdown 之后注册:MarkdownManager 在 onBeforeCreate
|
||||||
|
// 遍历 baseExtensions 收集 markdown spec(tokenizer/parse/render),此时
|
||||||
|
// InlineMath/DisplayMath 需已在扩展列表里,才能让 getMarkdown() 走自定义
|
||||||
|
// renderMarkdown 路径,绕过 encodeTextForMarkdown 对 LaTeX 的双重转义。
|
||||||
|
InlineMath,
|
||||||
|
DisplayMath,
|
||||||
CodeBlockLowlight.configure({ lowlight }).extend({
|
CodeBlockLowlight.configure({ lowlight }).extend({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos });
|
return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos });
|
||||||
|
|||||||
318
libs/tiptap-editor/src/math.ts
Normal file
318
libs/tiptap-editor/src/math.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||||
|
import katex from 'katex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数学公式节点(inline `$...$` 与 block `$$...$$`)。
|
||||||
|
*
|
||||||
|
* 背景:`@tiptap/markdown` 的 MarkdownManager.encodeTextForMarkdown 对所有
|
||||||
|
* 非代码文本节点做 escapeMarkdownSyntax + encodeHtmlEntities,会把 LaTeX 的
|
||||||
|
* `\`→`\\`、`_`→`\_`、`&`→`&`。若公式走普通文本路径,序列化后存库即损坏,
|
||||||
|
* 服务端 KaTeX 收到 `\\begin{bmatrix}` 报错。
|
||||||
|
*
|
||||||
|
* 解法:把公式建模成 atom Node,LaTeX 存在 attrs.latex(不是子文本),并声明
|
||||||
|
* markdownTokenizer/parseMarkdown/renderMarkdown 顶层 spec 字段。MarkdownManager
|
||||||
|
* 渲染非文本节点时直接调 renderMarkdown 返回值,绕过 encodeTextForMarkdown
|
||||||
|
* (源码 MarkdownManager.ts:1116-1182:有 handler 的节点不走 escape 路径)。
|
||||||
|
*
|
||||||
|
* NodeView 内用客户端 katex.renderToString 渲染预览(类 Notion/Typora)。
|
||||||
|
* KaTeX CSS(/katex/katex.min.css)已由 Dioxus.toml 全局注入,字体就绪。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- tokenizer 正则 ----
|
||||||
|
// inline `$...$`:单行、非贪婪、内部不含 $ 与换行。
|
||||||
|
// 行尾 $ 锚定,避免「打 5$」被误吞——要求 $ 前至少有一个非 $ 字符且 $ 后是词界/标点/行尾。
|
||||||
|
const INLINE_MATH_INPUT_REGEX = /(?:^|\s)\$([^$\n]+)\$$/;
|
||||||
|
// block `$$...$$`:跨行、非贪婪。由 markdownTokenizer 识别(非 input rule)。
|
||||||
|
|
||||||
|
/** NodeView 构造参数。 */
|
||||||
|
interface MathNodeViewOptions {
|
||||||
|
node: PMNode;
|
||||||
|
editor: Editor;
|
||||||
|
getPos?: () => number | undefined;
|
||||||
|
displayMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数学公式 NodeView:渲染 KaTeX 预览,双击进入源码编辑。
|
||||||
|
*
|
||||||
|
* 仿 UploadImageNodeView 的 atom 范式:contentDOM=null、ignoreMutation=true、
|
||||||
|
* update 做类型引用比较 + latex 变化时重渲染。
|
||||||
|
*/
|
||||||
|
class MathNodeView {
|
||||||
|
private node: PMNode;
|
||||||
|
private editor: Editor;
|
||||||
|
private getPos?: () => number | undefined;
|
||||||
|
private readonly displayMode: boolean;
|
||||||
|
|
||||||
|
private container: HTMLSpanElement;
|
||||||
|
private readonly renderEl: HTMLSpanElement;
|
||||||
|
private editEl: HTMLTextAreaElement | null = null;
|
||||||
|
|
||||||
|
constructor(opts: MathNodeViewOptions) {
|
||||||
|
this.node = opts.node;
|
||||||
|
this.editor = opts.editor;
|
||||||
|
this.getPos = opts.getPos;
|
||||||
|
this.displayMode = opts.displayMode;
|
||||||
|
|
||||||
|
// inline 用 <span>,block 用 <span> 内部 katex-display(由 displayMode 控制 KaTeX 输出)。
|
||||||
|
// 外层统一 span 便于 inline 在文本流中;block 的居中由 .math-block-display CSS 控制。
|
||||||
|
this.container = document.createElement('span');
|
||||||
|
this.container.classList.add('math-node');
|
||||||
|
this.container.classList.add(this.displayMode ? 'math-node-block' : 'math-node-inline');
|
||||||
|
this.container.setAttribute('contenteditable', 'false');
|
||||||
|
this.container.title = '双击编辑公式源码';
|
||||||
|
|
||||||
|
this.renderEl = document.createElement('span');
|
||||||
|
this.renderEl.classList.add('math-node-render');
|
||||||
|
this.container.appendChild(this.renderEl);
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
this.attachEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
get dom(): HTMLElement {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contentDOM(): HTMLElement | null {
|
||||||
|
// atom 节点无可编辑内容区域,光标无法进入。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ProseMirror 调用:节点属性变化时重渲染预览。返回 false 拒绝非同类节点。 */
|
||||||
|
update(node: PMNode): boolean {
|
||||||
|
if (node.type !== this.node.type) return false;
|
||||||
|
const oldLatex = this.node.attrs.latex as string;
|
||||||
|
const newLatex = node.attrs.latex as string;
|
||||||
|
this.node = node;
|
||||||
|
if (oldLatex !== newLatex) {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KaTeX 输出是受信任的视觉层(无脚本),ProseMirror 不应把它的 mutation 当编辑。 */
|
||||||
|
ignoreMutation(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击事件不被编辑器拦截(双击编辑需响应)。 */
|
||||||
|
stopEvent(_event: Event): boolean {
|
||||||
|
// 编辑态下 textarea 的事件由浏览器处理;其余放行。
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.renderEl.innerHTML = '';
|
||||||
|
this.editEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用 katex.renderToString 渲染预览。坏 TeX 显示红色错误,不抛异常(对齐服务端 throw_on_error=false)。 */
|
||||||
|
private render(): void {
|
||||||
|
const latex = (this.node.attrs.latex as string) ?? '';
|
||||||
|
try {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
displayMode: this.displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
output: 'html',
|
||||||
|
});
|
||||||
|
this.renderEl.innerHTML = html;
|
||||||
|
this.container.classList.remove('math-node-error');
|
||||||
|
} catch {
|
||||||
|
// throwOnError=false 下一般不抛;兜底显示转义原文。
|
||||||
|
this.renderEl.textContent = latex;
|
||||||
|
this.container.classList.add('math-node-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双击进入源码编辑:显示 textarea,失焦/Enter 回写 attrs。 */
|
||||||
|
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 = 'math-node-edit';
|
||||||
|
ta.value = (this.node.attrs.latex as string) ?? '';
|
||||||
|
ta.rows = this.displayMode ? 4 : 1;
|
||||||
|
ta.spellcheck = false;
|
||||||
|
ta.placeholder = 'LaTeX 源码';
|
||||||
|
|
||||||
|
// 隐藏预览,挂上编辑框。
|
||||||
|
this.renderEl.classList.add('math-node-render-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.renderEl.classList.remove('math-node-render-hidden');
|
||||||
|
// 回写 attrs;getPos 可能 undefined(只读模式/异常),此时仅本地刷新预览。
|
||||||
|
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, latex: next });
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// 只读回退:直接改本地 node 引用的 attrs 影响后续 update 比较。
|
||||||
|
(this.node.attrs as { latex: string }).latex = next;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Enter 或失焦提交;Esc 放弃。
|
||||||
|
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.renderEl.classList.remove('math-node-render-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ta.addEventListener('blur', commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inline 数学公式节点 `$...$`。
|
||||||
|
*
|
||||||
|
* atom:不可拆分,整体选中/删除。latex 存 attrs,序列化时 renderMarkdown
|
||||||
|
* 直接拼 `$${latex}$`,绕过文本转义路径。
|
||||||
|
*/
|
||||||
|
export const InlineMath = Node.create({
|
||||||
|
name: 'math',
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
latex: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (el) => (el as HTMLElement).getAttribute('data-latex') ?? '',
|
||||||
|
renderHTML: (attrs) => {
|
||||||
|
const v = attrs.latex;
|
||||||
|
return v == null || v === '' ? {} : { 'data-latex': v };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-math]' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes({ 'data-math': 'inline' }, HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- markdown spec(顶层字段,见 @tiptap/extension-image dist/index.js:50)----
|
||||||
|
markdownTokenName: 'mathInline',
|
||||||
|
markdownTokenizer: {
|
||||||
|
name: 'mathInline',
|
||||||
|
level: 'inline',
|
||||||
|
start: (src: string) => src.indexOf('$'),
|
||||||
|
tokenize: (src: string) => {
|
||||||
|
// 非贪婪,单行,内部不含 $ 与换行。避免误吞 $$(块级先由 block tokenizer 吃掉)。
|
||||||
|
const m = /^\$([^$\n]+?)\$/.exec(src);
|
||||||
|
if (!m) return undefined;
|
||||||
|
return { type: 'mathInline', raw: m[0], text: m[1] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parseMarkdown: (token, helpers) => helpers.createNode('math', { latex: token.text || '' }),
|
||||||
|
renderMarkdown: (node) => `$${node.attrs?.latex ?? ''}$`,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, editor, getPos }) =>
|
||||||
|
new MathNodeView({ node, editor, getPos, displayMode: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
// 选中文字后打 `$...$ ` 形式:行尾 $ 触发,把前导 $ 与结尾 $ 之间的文本转成 math 节点。
|
||||||
|
// 正则要求 $ 前有空白/行首,避免「价格 5$」误触。
|
||||||
|
return [
|
||||||
|
nodeInputRule({
|
||||||
|
find: INLINE_MATH_INPUT_REGEX,
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => ({ latex: match[1] ?? '' }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 块级数学公式节点 `$$...$$`。独占一行,居中显示。
|
||||||
|
*
|
||||||
|
* 无 input rule:块级 `$$...$$` 跨行,input rule 不便处理;用户通过源码模式或
|
||||||
|
* markdown 解析进入。富文本内可由 InlineMath 的编辑流程沿用(双击改源码)。
|
||||||
|
*/
|
||||||
|
export const DisplayMath = Node.create({
|
||||||
|
name: 'mathBlock',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
defining: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
latex: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (el) => (el as HTMLElement).getAttribute('data-latex') ?? '',
|
||||||
|
renderHTML: (attrs) => {
|
||||||
|
const v = attrs.latex;
|
||||||
|
return v == null || v === '' ? {} : { 'data-latex': v };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'div[data-math-block]' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', mergeAttributes({ 'data-math-block': 'true' }, HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
markdownTokenName: 'mathBlock',
|
||||||
|
markdownTokenizer: {
|
||||||
|
name: 'mathBlock',
|
||||||
|
level: 'block',
|
||||||
|
start: (src: string) => src.indexOf('$$'),
|
||||||
|
tokenize: (src: string) => {
|
||||||
|
// 跨行非贪婪;闭合 $$ 后允许换行。
|
||||||
|
const m = /^\$\$([\s\S]+?)\$\$(?:\n|$)/.exec(src);
|
||||||
|
if (!m) return undefined;
|
||||||
|
return { type: 'mathBlock', raw: m[0], text: m[1].trim() };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parseMarkdown: (token, helpers) => helpers.createNode('mathBlock', { latex: token.text || '' }),
|
||||||
|
renderMarkdown: (node) => `$$${node.attrs?.latex ?? ''}$$`,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ({ node, editor, getPos }) =>
|
||||||
|
new MathNodeView({ node, editor, getPos, displayMode: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1068,3 +1068,99 @@
|
|||||||
/* Mocha text */
|
/* Mocha text */
|
||||||
color: #cdd6f4;
|
color: #cdd6f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== Math Node (KaTeX preview) ========== */
|
||||||
|
|
||||||
|
.tiptap-editor .math-node {
|
||||||
|
/* inline 公式随文,block 公式独占行;KaTeX 字体由全局 katex.min.css 提供。 */
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node:hover {
|
||||||
|
/* Latte surface0:轻底色提示「可交互」。 */
|
||||||
|
background-color: rgba(204, 208, 218, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-block {
|
||||||
|
display: block;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-inline {
|
||||||
|
display: inline;
|
||||||
|
padding: 0 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-render-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-error .math-node-render {
|
||||||
|
/* 坏 TeX:红色提示,对齐服务端 katex-error 的 #cc0000。 */
|
||||||
|
color: #d20f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-edit {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 200px;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
border: 1px solid #ccd0da;
|
||||||
|
border-radius: 6px;
|
||||||
|
/* Latte base */
|
||||||
|
background: #eff1f5;
|
||||||
|
/* Latte text */
|
||||||
|
color: #4c4f69;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor .math-node-edit:focus {
|
||||||
|
/* Latte blue */
|
||||||
|
border-color: #1e66f5;
|
||||||
|
box-shadow: 0 0 0 2px rgba(30, 102, 245, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ProseMirror 选中态:公式被选中时高亮。 */
|
||||||
|
.tiptap-editor .ProseMirror-selectednode .math-node {
|
||||||
|
/* Latte blue 透明底。 */
|
||||||
|
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 .math-node:hover {
|
||||||
|
background-color: rgba(49, 50, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .math-node-error .math-node-render {
|
||||||
|
/* Mocha red */
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .math-node-edit {
|
||||||
|
/* Mocha surface0 */
|
||||||
|
border-color: #313244;
|
||||||
|
background: #181825;
|
||||||
|
/* Mocha text */
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .math-node-edit:focus {
|
||||||
|
/* Mocha blue */
|
||||||
|
border-color: #89b4fa;
|
||||||
|
box-shadow: 0 0 0 2px rgba(137, 180, 250, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror-selectednode .math-node {
|
||||||
|
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