feat(M2): FR-E12 公式块——MathBlock 节点(KaTeX 块级渲染+可编辑源码区)+InlineMath 行内公式; SlashMenu 加 公式 项; addStorage markdown 序列化(../$..$); katex 依赖; editor-math E2E 1 项

This commit is contained in:
hunter-dev
2026-08-26 13:47:59 +08:00
parent 56735f53e6
commit 0764818ee1
6 changed files with 151 additions and 0 deletions
@@ -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: {