import { useEffect, useRef, useState } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import { TiptapEditorProps } from "./props"; import { TiptapExtensions } from "./extensions"; import { useDebouncedCallback } from "use-debounce"; import { useCompletion } from "../../lib/ai/react/useCompletion"; import { toast } from "react-hot-toast"; // import DEFAULT_EDITOR_CONTENT from "./default-content"; import { EditorBubbleMenu } from "./components"; import { Editor, EditorOptions, JSONContent } from "@tiptap/core"; import clsx from "clsx"; type ValueDto = { html?: string; json?: JSONContent; text?: string; }; interface Props { content: string | JSONContent; onChange: (value: ValueDto) => void; readOnly?: boolean; className?: string; autoFocus?: boolean; } export default function NovelEditor({ content, onChange, readOnly, className = "min-h-[500px]", autoFocus = true }: Props) { const [saveStatus, setSaveStatus] = useState("Saved"); const [hydrated, setHydrated] = useState(false); const debouncedUpdates = useDebouncedCallback(async ({ editor }: { editor: Editor }) => { const json = editor.getJSON(); const html = editor.getHTML(); const text = editor.getText(); setSaveStatus("Saving..."); // onChange(json); onChange({ json, html, text, }); // Simulate a delay in saving. // setTimeout(() => { setSaveStatus("Saved"); // }, 500); }, 750); let options: Partial = { extensions: TiptapExtensions, editorProps: TiptapEditorProps, onUpdate: (e) => { setSaveStatus("Saving..."); const selection = e.editor.state.selection; const lastTwo = e.editor.state.doc.textBetween(selection.from - 2, selection.from, "\n"); if (lastTwo === "++" && !isLoading) { e.editor.commands.deleteRange({ from: selection.from - 2, to: selection.from, }); complete(e.editor.getText(), { body: { systemContent: "You are an AI writing assistant that continues existing text based on context from prior text. " + "Give more weight/priority to the later characters than the beginning ones. Make sure to construct complete sentences.", }, }); // va.track("Autocomplete Shortcut Used"); } else { debouncedUpdates(e); } }, }; if (autoFocus) { options.autofocus = "start"; } const editor = useEditor(options); const { complete, completion, isLoading, stop } = useCompletion({ id: "novel", api: "/api/ai/generate", onResponse: (response) => { if (response.status === 429) { toast.error("You have reached your request limit for the day."); // va.track("Rate Limit Reached"); return; } }, onFinish: (_prompt, completion) => { editor?.commands.setTextSelection({ from: editor.state.selection.from - completion.length, to: editor.state.selection.from, }); }, onError: () => { toast.error("Something went wrong."); }, }); const prev = useRef(""); // Insert chunks of the generated text useEffect(() => { const diff = completion.slice(prev.current.length); prev.current = completion; editor?.commands.insertContent(diff, { parseOptions: { preserveWhitespace: "full", }, }); }, [isLoading, editor, completion]); useEffect(() => { // if user presses escape or cmd + z and it's loading, // stop the request, delete the completion, and insert back the "++" const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" || (e.metaKey && e.key === "z")) { stop(); if (e.key === "Escape") { editor?.commands.deleteRange({ from: editor.state.selection.from - completion.length, to: editor.state.selection.from, }); } editor?.commands.insertContent("++"); } }; const mousedownHandler = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); stop(); if (window.confirm("AI writing paused. Continue?")) { complete(editor?.getText() || "", { body: { systemContent: "You are an AI writing assistant that continues existing text based on context from prior text. " + "Give more weight/priority to the later characters than the beginning ones. Make sure to construct complete sentences.", }, }); } }; if (isLoading) { document.addEventListener("keydown", onKeyDown); window.addEventListener("mousedown", mousedownHandler); } else { document.removeEventListener("keydown", onKeyDown); window.removeEventListener("mousedown", mousedownHandler); } return () => { document.removeEventListener("keydown", onKeyDown); window.removeEventListener("mousedown", mousedownHandler); }; }, [stop, isLoading, editor, complete, completion.length]); // Hydrate the editor with the content from localStorage. useEffect(() => { if (editor && content && !hydrated) { editor.commands.setContent(content); setHydrated(true); } }, [editor, content, hydrated]); return (
{ editor?.chain().focus().run(); }} className={clsx("relative w-full max-w-screen-lg border-stone-200 bg-white p-12 px-8 sm:rounded-lg sm:border sm:px-12 sm:shadow-lg", className)} >
{saveStatus}
{/* value: {JSON.stringify(value)} */} {editor ? ( <> ) : ( <> )}
); }