mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-18 20:14:44 +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 { Button } from "~/components/ui/button";
|
||||||
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
|
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
|
||||||
import { katexOptions } from "~/core/markdown/katex";
|
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 { cn } from "~/lib/utils";
|
||||||
|
|
||||||
import Image from "./image";
|
import Image from "./image";
|
||||||
@@ -70,7 +70,7 @@ export function Markdown({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{autoFixMarkdown(
|
{autoFixMarkdown(
|
||||||
dropMarkdownQuote(processKatexInMarkdown(children ?? "")) ?? "",
|
dropMarkdownQuote(normalizeMathForDisplay(children ?? "")) ?? "",
|
||||||
)}
|
)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
{enableCopy && typeof children === "string" && (
|
{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 {
|
function dropMarkdownQuote(markdown?: string | null): string | null {
|
||||||
if (!markdown) return null;
|
if (!markdown) return null;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
GlobalDragHandle,
|
GlobalDragHandle,
|
||||||
HighlightExtension,
|
HighlightExtension,
|
||||||
HorizontalRule,
|
HorizontalRule,
|
||||||
Mathematics,
|
|
||||||
Placeholder,
|
Placeholder,
|
||||||
StarterKit,
|
StarterKit,
|
||||||
TaskItem,
|
TaskItem,
|
||||||
@@ -31,6 +30,7 @@ import { TableRow } from "@tiptap/extension-table-row";
|
|||||||
import { TableCell } from "@tiptap/extension-table-cell";
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { cx } from "class-variance-authority";
|
import { cx } from "class-variance-authority";
|
||||||
import { common, createLowlight } from "lowlight";
|
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
|
//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;
|
const aiHighlight = AIHighlight;
|
||||||
@@ -132,7 +132,7 @@ const twitter = Twitter.configure({
|
|||||||
inline: false,
|
inline: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mathematics = Mathematics.configure({
|
const mathematics = MathematicsWithMarkdown.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
|
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 { uploadFn } from "./image-upload";
|
||||||
import { TextButtons } from "./selectors/text-buttons";
|
import { TextButtons } from "./selectors/text-buttons";
|
||||||
import { slashCommand, suggestionItems } from "./slash-command";
|
import { slashCommand, suggestionItems } from "./slash-command";
|
||||||
|
import { normalizeMathForEditor } from "~/core/utils/markdown";
|
||||||
// import { defaultEditorContent } from "./content";
|
// import { defaultEditorContent } from "./content";
|
||||||
|
|
||||||
import "~/styles/prosemirror.css";
|
import "~/styles/prosemirror.css";
|
||||||
@@ -45,7 +46,13 @@ export interface ReportEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ReportEditor = ({ content, onMarkdownChange }: 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 [saveStatus, setSaveStatus] = useState("Saved");
|
||||||
|
|
||||||
const [openNode, setOpenNode] = useState(false);
|
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);
|
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 {
|
function autoCloseTrailingLink(markdown: string): string {
|
||||||
// Fix unclosed Markdown links or images
|
// Fix unclosed Markdown links or images
|
||||||
let fixedMarkdown: string = markdown;
|
let fixedMarkdown: string = markdown;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
import katex from "katex";
|
import katex from "katex";
|
||||||
|
|
||||||
import { katexOptions } from "../src/core/markdown/katex";
|
import { katexOptions } from "../src/core/markdown/katex.ts";
|
||||||
|
|
||||||
function render(expression: string) {
|
function render(expression: string) {
|
||||||
return katex.renderToString(expression, {
|
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