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

169 lines
6.1 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 { 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>(() => 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) => {
// 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(),
// );
if (onMarkdownChange) {
const markdown = editor.storage.markdown.getMarkdown();
onMarkdownChange(markdown);
}
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">
<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;