feat(M2): FR-E12 公式块——MathBlock 节点(KaTeX 块级渲染+可编辑源码区)+InlineMath 行内公式; SlashMenu 加 公式 项; addStorage markdown 序列化(../$..$); katex 依赖; editor-math E2E 1 项
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 '<span style="color:#f44">公式错误</span>';
|
||||
}
|
||||
}
|
||||
|
||||
const MathBlockView = (props: NodeViewProps) => {
|
||||
const tex = props.node.textContent;
|
||||
const html = renderLatex(tex, true);
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="math-block" style={{ margin: '8px 0', padding: '8px', border: '1px solid #3a3a4a', borderRadius: 6, background: '#1a1a26', position: 'relative' }}>
|
||||
<div className="math-preview" dangerouslySetInnerHTML={{ __html: html }} style={{ minHeight: 24, fontSize: 15 }} />
|
||||
<NodeViewContent as={'div' as any} className="math-source" contentEditable={true} style={{ fontFamily: 'monospace', fontSize: 13, color: '#8ab4f8', marginTop: tex ? 4 : 0, outline: 'none' }} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const MathBlock = Node.create<MathBlockOptions>({
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user