Files
deer-flow/web/src/components/editor/index.tsx
Willem Jiang 1a16677d1a fix: unescape markdown-escaped characters in math formulas to fix #608 (#637)
When editing reports, tiptap-markdown escapes special characters (*, _, [, ])
which corrupts LaTeX formulas. This fix:

1. Adds unescapeLatexInMath() function to reverse markdown escaping within
   math delimiters ($...$ and 94410...94410)
2. Applies the unescape function in the editor's onChange callback to clean
   the markdown before storing it
3. Adds comprehensive tests covering edge cases and round-trip scenarios

The fix ensures formulas like $(f * g)[n]$ remain unescaped when editing,
preventing display errors after save/reload.
2025-10-21 10:06:31 +08:00

160 lines
5.7 KiB
TypeScript

// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
EditorContent,
type EditorInstance,
EditorRoot,
ImageResizer,
type JSONContent,
handleCommandNavigation,
handleImageDrop,
handleImagePaste,
} from "novel";
import type { Content } from "@tiptap/react";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { defaultExtensions } from "./extensions";
import { ColorSelector } from "./selectors/color-selector";
import { LinkSelector } from "./selectors/link-selector";
import { MathSelector } from "./selectors/math-selector";
import { NodeSelector } from "./selectors/node-selector";
import { Separator } from "../ui/separator";
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, unescapeLatexInMath } from "~/core/utils/markdown";
// import { defaultEditorContent } from "./content";
import "~/styles/prosemirror.css";
const hljs = require("highlight.js");
const extensions = [...defaultExtensions, slashCommand];
export interface ReportEditorProps {
content: Content;
onMarkdownChange?: (markdown: string) => void;
}
const ReportEditor = ({ content, onMarkdownChange }: ReportEditorProps) => {
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);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [openAI, setOpenAI] = useState(false);
//Apply Codeblock Highlighting on the HTML from editor.getHTML()
const highlightCodeblocks = (content: string) => {
const doc = new DOMParser().parseFromString(content, "text/html");
doc.querySelectorAll("pre code").forEach((el) => {
// @ts-ignore
// https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement
hljs.highlightElement(el);
});
return new XMLSerializer().serializeToString(doc);
};
const debouncedUpdates = useDebouncedCallback(
async (editor: EditorInstance) => {
if (onMarkdownChange) {
let markdown = editor.storage.markdown.getMarkdown();
markdown = unescapeLatexInMath(markdown);
onMarkdownChange(markdown);
}
setSaveStatus("Saved");
},
500,
);
if (!initialContent) return null;
return (
<div className="relative w-full">
<EditorRoot>
<EditorContent
immediatelyRender={false}
initialContent={initialContent as JSONContent}
extensions={extensions}
className="border-muted relative h-full w-full"
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
handlePaste: (view, event) =>
handleImagePaste(view, event, uploadFn),
handleDrop: (view, event, _slice, moved) =>
handleImageDrop(view, event, moved, uploadFn),
attributes: {
class:
"prose prose-base prose-p:my-4 dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
},
}}
onUpdate={({ editor }) => {
debouncedUpdates(editor);
setSaveStatus("Unsaved");
}}
slotAfter={<ImageResizer />}
>
<EditorCommand className="border-muted bg-background z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border px-1 py-2 shadow-md transition-all">
<EditorCommandEmpty className="text-muted-foreground px-2">
No results
</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command?.(val)}
className="hover:bg-accent aria-selected:bg-accent flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm"
key={item.title}
>
<div className="border-muted bg-background flex h-10 w-10 items-center justify-center rounded-md border">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground text-xs">
{item.description}
</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
<GenerativeMenuSwitch open={openAI} onOpenChange={setOpenAI}>
<Separator orientation="vertical" />
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
<Separator orientation="vertical" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<MathSelector />
</GenerativeMenuSwitch>
</EditorContent>
</EditorRoot>
</div>
);
};
export default ReportEditor;