mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-10 17:24:45 +08:00
174 lines
6.1 KiB
TypeScript
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;
|