From 5dceb39544c5b4f003186ebba3dd298dbb667d30 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 17 Jul 2026 17:33:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=95=B0=E5=AD=A6=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E8=8A=82=E7=82=B9=E5=B8=A6=20KaTeX=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88,=E6=A0=B9=E6=B2=BB=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E7=A0=B4=E5=9D=8F=20LaTeX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:经 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 序列化往返 --- libs/pnpm-lock.yaml | 3 + libs/tiptap-editor/package.json | 1 + libs/tiptap-editor/src/__tests__/math.test.ts | 169 ++++++++++ libs/tiptap-editor/src/index.ts | 7 + libs/tiptap-editor/src/math.ts | 318 ++++++++++++++++++ libs/tiptap-editor/src/style.css | 96 ++++++ 6 files changed, 594 insertions(+) create mode 100644 libs/tiptap-editor/src/__tests__/math.test.ts create mode 100644 libs/tiptap-editor/src/math.ts diff --git a/libs/pnpm-lock.yaml b/libs/pnpm-lock.yaml index e53ee34..4e2285a 100644 --- a/libs/pnpm-lock.yaml +++ b/libs/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@tiptap/suggestion': 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) + katex: + specifier: ^0.16.22 + version: 0.16.47 lowlight: specifier: ^3.3.0 version: 3.3.0 diff --git a/libs/tiptap-editor/package.json b/libs/tiptap-editor/package.json index fbf279b..33e2a1e 100644 --- a/libs/tiptap-editor/package.json +++ b/libs/tiptap-editor/package.json @@ -22,6 +22,7 @@ "@tiptap/pm": "^3.27.3", "@tiptap/starter-kit": "^3.27.3", "@tiptap/suggestion": "^3.27.3", + "katex": "^0.16.22", "lowlight": "^3.3.0" } } diff --git a/libs/tiptap-editor/src/__tests__/math.test.ts b/libs/tiptap-editor/src/__tests__/math.test.ts new file mode 100644 index 0000000..7ff7cfb --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/math.test.ts @@ -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); + }); +}); diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index e0b2309..80cbd7e 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -8,6 +8,7 @@ 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 { lowlight } from './highlight'; +import { DisplayMath, InlineMath } from './math'; import { SlashCommand } from './slash-command'; import { TaskInputRule } from './task-input-rule'; import { @@ -112,6 +113,12 @@ class TiptapEditorInstance { codeBlock: false, }), Markdown, + // 数学公式节点必须在 Markdown 之后注册:MarkdownManager 在 onBeforeCreate + // 遍历 baseExtensions 收集 markdown spec(tokenizer/parse/render),此时 + // InlineMath/DisplayMath 需已在扩展列表里,才能让 getMarkdown() 走自定义 + // renderMarkdown 路径,绕过 encodeTextForMarkdown 对 LaTeX 的双重转义。 + InlineMath, + DisplayMath, CodeBlockLowlight.configure({ lowlight }).extend({ addNodeView() { return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos }); diff --git a/libs/tiptap-editor/src/math.ts b/libs/tiptap-editor/src/math.ts new file mode 100644 index 0000000..7832cbe --- /dev/null +++ b/libs/tiptap-editor/src/math.ts @@ -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 用 ,block 用 内部 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 }); + }, +}); diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 17dc68f..e07753f 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -1068,3 +1068,99 @@ /* Mocha text */ 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); +}