Files
deer-flow/web/src/components/editor/index.tsx
2025-04-28 10:29:35 +08:00

174 lines
6.1 KiB
TypeScript

"use client";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
EditorContent,
type EditorInstance,
EditorRoot,
ImageResizer,
type JSONContent,
handleCommandNavigation,
handleImageDrop,
handleImagePaste,
} from "novel";
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 { defaultEditorContent } from "./content";
import "~/styles/prosemirror.css";
const hljs = require("highlight.js");
const extensions = [...defaultExtensions, slashCommand];
const ReportEditor = () => {
const [initialContent, setInitialContent] = useState<null | JSONContent>(
null,
);
const [saveStatus, setSaveStatus] = useState("Saved");
const [charsCount, setCharsCount] = useState();
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) => {
const json = editor.getJSON();
setCharsCount(editor.storage.characterCount.words());
window.localStorage.setItem(
"html-content",
highlightCodeblocks(editor.getHTML()),
);
window.localStorage.setItem("novel-content", JSON.stringify(json));
window.localStorage.setItem(
"markdown",
editor.storage.markdown.getMarkdown(),
);
setSaveStatus("Saved");
},
500,
);
useEffect(() => {
const content = window.localStorage.getItem("novel-content");
if (content) setInitialContent(JSON.parse(content));
else setInitialContent(defaultEditorContent);
}, []);
if (!initialContent) return null;
return (
<div className="relative w-full">
<div className="absolute top-5 right-5 z-10 mb-5 flex gap-2">
<div className="bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm">
{saveStatus}
</div>
<div
className={
charsCount
? "bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm"
: "hidden"
}
>
{charsCount} Words
</div>
</div>
<EditorRoot>
<EditorContent
immediatelyRender={false}
initialContent={initialContent}
extensions={extensions}
className="border-muted bg-background relative h-full w-full overflow-auto sm:mb-[calc(20vh)] sm:border sm:shadow-lg"
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" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<MathSelector />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</GenerativeMenuSwitch>
</EditorContent>
</EditorRoot>
</div>
);
};
export default ReportEditor;