diff --git a/web/src/components/deer-flow/markdown.tsx b/web/src/components/deer-flow/markdown.tsx index 567bae3..6786b18 100644 --- a/web/src/components/deer-flow/markdown.tsx +++ b/web/src/components/deer-flow/markdown.tsx @@ -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 ?? "")) ?? "", )} {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; diff --git a/web/src/components/editor/extensions.tsx b/web/src/components/editor/extensions.tsx index 2cd4c9f..ffd7664 100644 --- a/web/src/components/editor/extensions.tsx +++ b/web/src/components/editor/extensions.tsx @@ -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"), }, diff --git a/web/src/components/editor/index.tsx b/web/src/components/editor/index.tsx index 87ccc56..88dfaf7 100644 --- a/web/src/components/editor/index.tsx +++ b/web/src/components/editor/index.tsx @@ -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); + const [initialContent, setInitialContent] = useState(() => { + // 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); diff --git a/web/src/components/editor/math-serializer.ts b/web/src/components/editor/math-serializer.ts new file mode 100644 index 0000000..d23b611 --- /dev/null +++ b/web/src/components/editor/math-serializer.ts @@ -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("$"); + } + }, + }, + }; + }, +}); diff --git a/web/src/core/utils/markdown.ts b/web/src/core/utils/markdown.ts index 8b4a70f..50add92 100644 --- a/web/src/core/utils/markdown.ts +++ b/web/src/core/utils/markdown.ts @@ -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; diff --git a/web/tests/markdown-katex.test.ts b/web/tests/markdown-katex.test.ts index b627494..210533b 100644 --- a/web/tests/markdown-katex.test.ts +++ b/web/tests/markdown-katex.test.ts @@ -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, { diff --git a/web/tests/markdown-math-editor.test.ts b/web/tests/markdown-math-editor.test.ts new file mode 100644 index 0000000..372eee4 --- /dev/null +++ b/web/tests/markdown-math-editor.test.ts @@ -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")); + }); +});