From 0764818ee17c585f38839669509404a0e4c51a5c Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 13:47:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(M2):=20FR-E12=20=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E5=9D=97=E2=80=94=E2=80=94MathBlock=20=E8=8A=82=E7=82=B9(KaTeX?= =?UTF-8?q?=20=E5=9D=97=E7=BA=A7=E6=B8=B2=E6=9F=93+=E5=8F=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=BA=90=E7=A0=81=E5=8C=BA)+InlineMath=20=E8=A1=8C?= =?UTF-8?q?=E5=86=85=E5=85=AC=E5=BC=8F;=20SlashMenu=20=E5=8A=A0=20?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=20=E9=A1=B9;=20addStorage=20markdown=20?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96(../$..$);=20katex=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96;=20editor-math=20E2E=201=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/package.json | 2 + .../renderer/features/editor/MathBlock.tsx | 85 +++++++++++++++++++ .../renderer/features/editor/SlashMenu.tsx | 1 + .../renderer/features/editor/TiptapEditor.tsx | 3 + apps/desktop/tests/e2e/editor-math.spec.ts | 54 ++++++++++++ pnpm-lock.yaml | 6 ++ 6 files changed, 151 insertions(+) create mode 100644 apps/desktop/src/renderer/features/editor/MathBlock.tsx create mode 100644 apps/desktop/tests/e2e/editor-math.spec.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d55149b..b173acf 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -52,6 +52,7 @@ "@tiptap/starter-kit": "^3.30.2", "@tiptap/suggestion": "^3.30.2", "hotkeys-js": "^4.0.5", + "katex": "^0.16.47", "lowlight": "^3.3.0", "markmap-view": "^0.18.12", "remark-gfm": "^4.0.1", @@ -65,6 +66,7 @@ "devDependencies": { "@playwright/test": "^1.62.1", "@types/d3-drag": "^3.0.7", + "@types/katex": "^0.16.8", "@types/d3-force": "^3.0.10", "@types/d3-selection": "^3.0.11", "@types/d3-zoom": "^3.0.8", diff --git a/apps/desktop/src/renderer/features/editor/MathBlock.tsx b/apps/desktop/src/renderer/features/editor/MathBlock.tsx new file mode 100644 index 0000000..e9fa531 --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/MathBlock.tsx @@ -0,0 +1,85 @@ +import { Node } from '@tiptap/core'; +import { ReactNodeViewRenderer, type NodeViewProps } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; + +// 块级公式节点:内容为 LaTeX 文本,渲染为 KaTeX +export interface MathBlockOptions { inline: boolean } + +function renderLatex(tex: string, displayMode: boolean): string { + try { + return katex.renderToString(tex, { displayMode, throwOnError: false }); + } catch { + return '公式错误'; + } +} + +const MathBlockView = (props: NodeViewProps) => { + const tex = props.node.textContent; + const html = renderLatex(tex, true); + return ( + +
+ + + ); +}; + +export const MathBlock = Node.create({ + name: 'mathBlock', + group: 'block', + content: 'text*', + marks: '', + defining: true, + isolating: true, + addOptions() { return { inline: false }; }, + parseHTML() { return [{ tag: 'div[data-type="mathBlock"]' }]; }, + renderHTML({ HTMLAttributes }) { + return ['div', { ...HTMLAttributes, 'data-type': 'mathBlock', class: 'math-block' }, 0]; + }, + addNodeView() { return ReactNodeViewRenderer(MathBlockView); }, + addStorage() { + return { + markdown: { + serialize(state: any, node: any) { + state.write('$$\n'); + state.text(node.textContent, false); + state.write('\n$$'); + state.closeBlock(node); + }, + }, + }; + }, + addCommands() { + return { + insertMathBlock: () => ({ chain }: { chain: () => any }) => chain().insertContent({ type: 'mathBlock' }).run(), + } as any; + }, +}); + +// 行内公式 mark(简化:用 code 外观 + KaTeX 渲染) +export const InlineMath = Node.create({ + name: 'inlineMath', + group: 'inline', + inline: true, + atom: true, + addAttributes() { return { tex: { default: '' } }; }, + parseHTML() { return [{ tag: 'span[data-type="inlineMath"]' }]; }, + renderHTML({ node, HTMLAttributes }) { + return ['span', { ...HTMLAttributes, 'data-type': 'inlineMath', class: 'inline-math' }, renderLatex(node.attrs.tex, false)]; + }, + renderText({ node }) { return '$' + node.attrs.tex + '$'; }, + addStorage() { + return { + markdown: { + serialize(state: any, node: any) { state.write('$' + node.attrs.tex + '$'); }, + }, + }; + }, + addCommands() { + return { + insertInlineMath: (tex: string) => ({ chain }: { chain: () => any }) => chain().insertContent({ type: 'inlineMath', attrs: { tex } }).run(), + } as any; + }, +}); diff --git a/apps/desktop/src/renderer/features/editor/SlashMenu.tsx b/apps/desktop/src/renderer/features/editor/SlashMenu.tsx index f9a66c5..cc43324 100644 --- a/apps/desktop/src/renderer/features/editor/SlashMenu.tsx +++ b/apps/desktop/src/renderer/features/editor/SlashMenu.tsx @@ -12,6 +12,7 @@ const ITEMS: { label: string; cmd: (e: any) => void }[] = [ { label: '引用', cmd: e => e.chain().focus().toggleBlockquote().run() }, { label: '表格', cmd: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, { label: '分割线', cmd: e => e.chain().focus().setHorizontalRule().run() }, + { label: '公式', cmd: e => (e as any).commands.insertMathBlock() }, ]; export function SlashMenu() { diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index 243e293..9d34a8a 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -24,6 +24,7 @@ import { BlockOps } from './BlockOps'; import { CodeBlockView } from './CodeBlockView'; import { ImageView } from './ImageView'; import { LinkBubbleMenu } from './LinkBubbleMenu'; +import { MathBlock, InlineMath } from './MathBlock'; const lowlight = createLowlight(common); @@ -56,6 +57,8 @@ export function TiptapEditor() { Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }), BlockOps, FindReplaceHighlight, + MathBlock, + InlineMath, ], content: body, editorProps: { diff --git a/apps/desktop/tests/e2e/editor-math.spec.ts b/apps/desktop/tests/e2e/editor-math.spec.ts new file mode 100644 index 0000000..4afa8c9 --- /dev/null +++ b/apps/desktop/tests/e2e/editor-math.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { promises as fs } from 'node:fs'; +import nodePath from 'node:path'; +import os from 'node:os'; + +const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js'); + +async function launch(userData: string) { + const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] }); + const page = await app.firstWindow(); + await page.waitForLoadState('domcontentloaded'); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +async function newDoc(a: { page: any; invoke: any }, title: string) { + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill(title); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 }); +} + +// FR-E12:斜杠菜单插入公式块 + KaTeX 渲染 +test('FR-E12:斜杠菜单插入公式块 + KaTeX 渲染', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e12-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault = nodePath.join(tmp, 'vault'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + await newDoc(a, '公式测试'); + + await a.page.locator('.ProseMirror').click(); + await a.page.keyboard.type('/'); + await a.page.waitForTimeout(300); + // 斜杠菜单应出现 + await a.page.getByText('公式', { exact: true }).click(); + + // 应插入 mathBlock 节点 + await expect(a.page.locator('.ProseMirror .math-block')).toHaveCount(1, { timeout: 5000 }); + + // 在公式源码区输入 LaTeX + const source = a.page.locator('.ProseMirror .math-source'); + await source.click(); + await a.page.keyboard.type('c = \\sqrt{a^2 + b^2}'); + + // KaTeX 预览区应渲染出 .katex 元素 + await expect(a.page.locator('.ProseMirror .math-preview .katex')).toHaveCount(1, { timeout: 8000 }); + + await a.app.close(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ad63be..16fae7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: hotkeys-js: specifier: ^4.0.5 version: 4.0.5 + katex: + specifier: ^0.16.47 + version: 0.16.47 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -132,6 +135,9 @@ importers: '@types/d3-zoom': specifier: ^3.0.8 version: 3.0.8 + '@types/katex': + specifier: ^0.16.8 + version: 0.16.8 '@types/node': specifier: ^20.0.0 version: 20.19.43