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