mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 14:22:13 +08:00
fix: resolve math formula display abnormal after editing report
This fix addresses the issue where math formulas become corrupted or incorrectly displayed after editing the generated report in the editor. **Root Cause:** The issue occurred due to incompatibility between markdown processing in the display component and the Tiptap editor: 1. Display component used \[\] and \(\) LaTeX delimiters 2. Tiptap Mathematics extension expects $ and 70868 delimiters 3. tiptap-markdown didn't have built-in math node serialization 4. Math syntax was lost/corrupted during editor save operations **Solution Implemented:** 1. Created MathematicsWithMarkdown extension that adds markdown serialization support to Tiptap's Mathematics nodes 2. Added math delimiter normalization functions: - normalizeMathForEditor(): Converts LaTeX delimiters to $/70868 - normalizeMathForDisplay(): Standardizes all delimiters to 70868 3. Updated Markdown component to use new normalization 4. Updated ReportEditor to normalize content before loading **Changes:** - web/src/components/editor/math-serializer.ts (new) - web/src/components/editor/extensions.tsx - web/src/components/editor/index.tsx - web/src/components/deer-flow/markdown.tsx - web/src/core/utils/markdown.ts - web/tests/markdown-math-editor.test.ts (new) - web/tests/markdown-katex.test.ts **Testing:** - Added 15 comprehensive tests for math normalization round-trip - All tests passing (math editor + existing katex tests) - Verified TypeScript compilation and linting Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
committed by
Willem Jiang
parent
24e2d86f7b
commit
58c1743ed5
@@ -14,7 +14,7 @@ import "katex/dist/katex.min.css";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
|
||||
import { katexOptions } from "~/core/markdown/katex";
|
||||
import { autoFixMarkdown } from "~/core/utils/markdown";
|
||||
import { autoFixMarkdown, normalizeMathForDisplay } from "~/core/utils/markdown";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import Image from "./image";
|
||||
@@ -70,7 +70,7 @@ export function Markdown({
|
||||
{...props}
|
||||
>
|
||||
{autoFixMarkdown(
|
||||
dropMarkdownQuote(processKatexInMarkdown(children ?? "")) ?? "",
|
||||
dropMarkdownQuote(normalizeMathForDisplay(children ?? "")) ?? "",
|
||||
)}
|
||||
</ReactMarkdown>
|
||||
{enableCopy && typeof children === "string" && (
|
||||
@@ -112,20 +112,7 @@ function CopyButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function processKatexInMarkdown(markdown?: string | null) {
|
||||
if (!markdown) return markdown;
|
||||
|
||||
const markdownWithKatexSyntax = markdown
|
||||
.replace(/\\\\\[/g, "$$$$") // Replace '\\[' with '$$'
|
||||
.replace(/\\\\\]/g, "$$$$") // Replace '\\]' with '$$'
|
||||
.replace(/\\\\\(/g, "$$$$") // Replace '\\(' with '$$'
|
||||
.replace(/\\\\\)/g, "$$$$") // Replace '\\)' with '$$'
|
||||
.replace(/\\\[/g, "$$$$") // Replace '\[' with '$$'
|
||||
.replace(/\\\]/g, "$$$$") // Replace '\]' with '$$'
|
||||
.replace(/\\\(/g, "$$$$") // Replace '\(' with '$$'
|
||||
.replace(/\\\)/g, "$$$$"); // Replace '\)' with '$$';
|
||||
return markdownWithKatexSyntax;
|
||||
}
|
||||
|
||||
function dropMarkdownQuote(markdown?: string | null): string | null {
|
||||
if (!markdown) return null;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
GlobalDragHandle,
|
||||
HighlightExtension,
|
||||
HorizontalRule,
|
||||
Mathematics,
|
||||
Placeholder,
|
||||
StarterKit,
|
||||
TaskItem,
|
||||
@@ -31,6 +30,7 @@ import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { cx } from "class-variance-authority";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { MathematicsWithMarkdown } from "./math-serializer";
|
||||
|
||||
//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
|
||||
const aiHighlight = AIHighlight;
|
||||
@@ -132,7 +132,7 @@ const twitter = Twitter.configure({
|
||||
inline: false,
|
||||
});
|
||||
|
||||
const mathematics = Mathematics.configure({
|
||||
const mathematics = MathematicsWithMarkdown.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import GenerativeMenuSwitch from "./generative/generative-menu-switch";
|
||||
import { uploadFn } from "./image-upload";
|
||||
import { TextButtons } from "./selectors/text-buttons";
|
||||
import { slashCommand, suggestionItems } from "./slash-command";
|
||||
import { normalizeMathForEditor } from "~/core/utils/markdown";
|
||||
// import { defaultEditorContent } from "./content";
|
||||
|
||||
import "~/styles/prosemirror.css";
|
||||
@@ -45,7 +46,13 @@ export interface ReportEditorProps {
|
||||
}
|
||||
|
||||
const ReportEditor = ({ content, onMarkdownChange }: ReportEditorProps) => {
|
||||
const [initialContent, setInitialContent] = useState<Content>(() => content);
|
||||
const [initialContent, setInitialContent] = useState<Content>(() => {
|
||||
// Normalize math delimiters for editor consumption
|
||||
if (typeof content === "string") {
|
||||
return normalizeMathForEditor(content);
|
||||
}
|
||||
return content;
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState("Saved");
|
||||
|
||||
const [openNode, setOpenNode] = useState(false);
|
||||
|
||||
34
web/src/components/editor/math-serializer.ts
Normal file
34
web/src/components/editor/math-serializer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Mathematics } from "novel";
|
||||
|
||||
/**
|
||||
* Extended Mathematics extension with markdown serialization support
|
||||
* Handles both inline math ($...$) and block/display math ($$...$$)
|
||||
*/
|
||||
export const MathematicsWithMarkdown = Mathematics.extend({
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: any, node: any) {
|
||||
const latex = node.attrs?.latex || "";
|
||||
const isBlock = node.attrs?.display === true;
|
||||
|
||||
if (isBlock) {
|
||||
// Block/display math: $$...$$
|
||||
state.write("$$");
|
||||
state.write(latex);
|
||||
state.write("$$");
|
||||
state.closeBlock(node);
|
||||
} else {
|
||||
// Inline math: $...$
|
||||
state.write("$");
|
||||
state.write(latex);
|
||||
state.write("$");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,48 @@ export function autoFixMarkdown(markdown: string): string {
|
||||
return autoCloseTrailingLink(markdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize math delimiters for editor consumption
|
||||
* Converts display delimiters (\[...\], \\[...\\]) to $$ format
|
||||
* Converts inline delimiters (\(...\), \\(...\\)) to $ format
|
||||
* This ensures consistent format before loading into Tiptap editor
|
||||
*/
|
||||
export function normalizeMathForEditor(markdown: string): string {
|
||||
let normalized = markdown;
|
||||
|
||||
// Convert display math - handle double backslash first to avoid conflicts
|
||||
normalized = normalized
|
||||
.replace(/\\\\\[([^\]]*)\\\\\]/g, (_match, content) => `$$${content}$$`) // \\[...\\] → $$...$$
|
||||
.replace(/\\\[([^\]]*)\\\]/g, (_match, content) => `$$${content}$$`); // \[...\] → $$...$$
|
||||
|
||||
// Convert inline math - handle double backslash first to avoid conflicts
|
||||
normalized = normalized
|
||||
.replace(/\\\\\(([^)]*)\\\\\)/g, (_match, content) => `$${content}$`) // \\(...\\) → $...$
|
||||
.replace(/\\\(([^)]*)\\\)/g, (_match, content) => `$${content}$`); // \(...\) → $...$
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize math delimiters for display consumption
|
||||
* Ensures all math delimiters are in $$ format for remarkMath/rehypeKatex
|
||||
* This is used by the Markdown display component
|
||||
*/
|
||||
export function normalizeMathForDisplay(markdown: string): string {
|
||||
let normalized = markdown;
|
||||
|
||||
// Convert all LaTeX-style delimiters to $$
|
||||
// Both display and inline math use $$ for display component (remarkMath handles both)
|
||||
// Handle double backslash first to avoid conflicts
|
||||
normalized = normalized
|
||||
.replace(/\\\\\[([^\]]*)\\\\\]/g, (_match, content) => `$$${content}$$`) // \\[...\\] → $$...$$
|
||||
.replace(/\\\[([^\]]*)\\\]/g, (_match, content) => `$$${content}$$`) // \[...\] → $$...$$
|
||||
.replace(/\\\\\(([^)]*)\\\\\)/g, (_match, content) => `$$${content}$$`) // \\(...\\) → $$...$$
|
||||
.replace(/\\\(([^)]*)\\\)/g, (_match, content) => `$$${content}$$`); // \(...\) → $$...$$
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function autoCloseTrailingLink(markdown: string): string {
|
||||
// Fix unclosed Markdown links or images
|
||||
let fixedMarkdown: string = markdown;
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import katex from "katex";
|
||||
|
||||
import { katexOptions } from "../src/core/markdown/katex";
|
||||
import { katexOptions } from "../src/core/markdown/katex.ts";
|
||||
|
||||
function render(expression: string) {
|
||||
return katex.renderToString(expression, {
|
||||
|
||||
119
web/tests/markdown-math-editor.test.ts
Normal file
119
web/tests/markdown-math-editor.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizeMathForEditor, normalizeMathForDisplay } from "../src/core/utils/markdown.ts";
|
||||
|
||||
describe("markdown math normalization for editor", () => {
|
||||
it("converts LaTeX display delimiters to $$ for editor", () => {
|
||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Here is a formula $$E=mc^2$$ in the text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX display delimiters to $$ for editor", () => {
|
||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Formula $$x^2 + y^2 = z^2$$ here.");
|
||||
});
|
||||
|
||||
it("converts LaTeX inline delimiters to $ for editor", () => {
|
||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Inline formula $a + b = c$ in text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX inline delimiters to $ for editor", () => {
|
||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Inline $x = 5$ here.");
|
||||
});
|
||||
|
||||
it("handles mixed delimiters for editor", () => {
|
||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Display $$E=mc^2$$ and inline $F=ma$ formulas.");
|
||||
});
|
||||
|
||||
it("preserves already normalized math syntax for editor", () => {
|
||||
const input = "Already normalized $$E=mc^2$$ and $F=ma$ formulas.";
|
||||
const output = normalizeMathForEditor(input);
|
||||
assert.strictEqual(output, "Already normalized $$E=mc^2$$ and $F=ma$ formulas.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown math normalization for display", () => {
|
||||
it("converts LaTeX display delimiters to $$ for display", () => {
|
||||
const input = "Here is a formula \\[E=mc^2\\] in the text.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.strictEqual(output, "Here is a formula $$E=mc^2$$ in the text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX display delimiters to $$ for display", () => {
|
||||
const input = "Formula \\\\[x^2 + y^2 = z^2\\\\] here.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.strictEqual(output, "Formula $$x^2 + y^2 = z^2$$ here.");
|
||||
});
|
||||
|
||||
it("converts LaTeX inline delimiters to $$ for display", () => {
|
||||
const input = "Inline formula \\(a + b = c\\) in text.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.strictEqual(output, "Inline formula $$a + b = c$$ in text.");
|
||||
});
|
||||
|
||||
it("converts escaped LaTeX inline delimiters to $$ for display", () => {
|
||||
const input = "Inline \\\\(x = 5\\\\) here.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.strictEqual(output, "Inline $$x = 5$$ here.");
|
||||
});
|
||||
|
||||
it("handles mixed delimiters for display", () => {
|
||||
const input = "Display \\[E=mc^2\\] and inline \\(F=ma\\) formulas.";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.strictEqual(output, "Display $$E=mc^2$$ and inline $$F=ma$$ formulas.");
|
||||
});
|
||||
|
||||
it("handles complex physics formulas", () => {
|
||||
const input = "Maxwell equation: \\[\\nabla \\times \\vec{E} = -\\frac{\\partial \\vec{B}}{\\partial t}\\]";
|
||||
const output = normalizeMathForDisplay(input);
|
||||
assert.ok(output.includes("$$"));
|
||||
assert.ok(output.includes("nabla"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown math round-trip consistency", () => {
|
||||
it("handles editor normalization consistently", () => {
|
||||
const original = "Formula \\[E=mc^2\\] and \\(F=ma\\)";
|
||||
const forEditor = normalizeMathForEditor(original);
|
||||
|
||||
// Simulate editor output (should have $ and $$)
|
||||
assert.ok(forEditor.includes("$$"));
|
||||
assert.ok(forEditor.includes("$"));
|
||||
});
|
||||
|
||||
it("handles multiple formulas correctly", () => {
|
||||
const input = `
|
||||
# Physics Formulas
|
||||
|
||||
Energy: \\[E = mc^2\\]
|
||||
|
||||
Force: \\(F = ma\\)
|
||||
|
||||
Momentum: \\[p = mv\\]
|
||||
`;
|
||||
|
||||
const forEditor = normalizeMathForEditor(input);
|
||||
const forDisplay = normalizeMathForDisplay(input);
|
||||
|
||||
// Both should have converted the delimiters
|
||||
assert.ok(forEditor.includes("$$"));
|
||||
assert.ok(forDisplay.includes("$$"));
|
||||
});
|
||||
|
||||
it("preserves text content around formulas", () => {
|
||||
const input = "Text before \\[E=mc^2\\] text after";
|
||||
const output = normalizeMathForEditor(input);
|
||||
|
||||
assert.ok(output.startsWith("Text before"));
|
||||
assert.ok(output.endsWith("text after"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user