"use client"; import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import type { NoteDetail } from "@/lib/types"; import { useDebounce } from "@/lib/use-debounce"; import { copilotTransform, type CopilotAction, type CopilotTone } from "@/lib/copilot-client"; import { toast } from "@/lib/toast"; const TOOLBAR_BTN: React.CSSProperties = { border: "none", borderRadius: "var(--nl-radius-sm)", padding: "4px 8px", background: "transparent", color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)", cursor: "pointer", }; const TOOLBAR_BTN_ACTIVE: React.CSSProperties = { ...TOOLBAR_BTN, background: "rgba(90,140,255,0.18)", color: "var(--nl-text-primary)", }; function ToolbarButton({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) { return ( ); } export function NoteEditor({ note, onSave, isSaving = false, autoSave = true, autoSaveDelayMs = 1500, copilotNoteId, copilotWorkspaceId, }: { note: NoteDetail; onSave: (updates: { title: string; body: string }, options?: { quiet?: boolean }) => Promise; isSaving?: boolean; autoSave?: boolean; autoSaveDelayMs?: number; copilotNoteId?: string; copilotWorkspaceId?: string; }) { const [title, setTitle] = useState(note.title); const [, setBodyTick] = useState(0); const [copilotBusy, setCopilotBusy] = useState(false); const [toneMenuOpen, setToneMenuOpen] = useState(false); const [explainResult, setExplainResult] = useState(null); const onSaveRef = useRef(onSave); onSaveRef.current = onSave; const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), Placeholder.configure({ placeholder: "Start writing…" }), ], content: note.body, editorProps: { attributes: { class: "input-shell", style: "min-height:360px;outline:none;line-height:1.7;", }, }, onUpdate: () => setBodyTick((t) => t + 1), }); const bodyHtml = editor?.getHTML() ?? note.body; useEffect(() => { setTitle(note.title); if (editor && note.body !== editor.getHTML()) { editor.commands.setContent(note.body); } }, [note.title, note.body, editor]); const debouncedTitle = useDebounce(title, autoSaveDelayMs); const debouncedBody = useDebounce(bodyHtml, autoSaveDelayMs); useEffect(() => { if (!autoSave || !editor || isSaving) return; if (debouncedTitle === note.title && debouncedBody === note.body) return; void onSaveRef.current({ title: debouncedTitle, body: debouncedBody }, { quiet: true }); }, [autoSave, autoSaveDelayMs, debouncedTitle, debouncedBody, note.title, note.body, note.id, editor, isSaving]); const handleSave = useCallback(() => { if (!editor) return; void onSave({ title, body: editor.getHTML() }, { quiet: false }); }, [editor, title, onSave]); const dirty = useMemo( () => title !== note.title || bodyHtml !== note.body, [title, bodyHtml, note.title, note.body], ); const runCopilot = useCallback( async (action: CopilotAction, tone?: CopilotTone) => { if (!editor || !copilotNoteId || !copilotWorkspaceId) return; const { from, to } = editor.state.selection; const selected = editor.state.doc.textBetween(from, to, "\n").trim(); // "continue" uses all text before cursor, not selection if (action === "continue") { const fullText = editor.state.doc.textBetween(0, editor.state.selection.to, "\n").trim(); if (!fullText) { toast.error("Place cursor in the editor first"); return; } setCopilotBusy(true); try { const out = await copilotTransform(copilotNoteId, copilotWorkspaceId, action, fullText); const escaped = out.split("\n").map((l) => l.replace(//g, ">")).join("

"); editor.chain().focus().insertContent(`

${escaped}

`).run(); toast.success("Continuation inserted — review and save"); } catch (e) { toast.error(e instanceof Error ? e.message : "Continue failed"); } finally { setCopilotBusy(false); } return; } // "explain" shows result in a tooltip, doesn't replace text if (action === "explain") { if (!selected) { toast.error("Select text to explain"); return; } setCopilotBusy(true); try { const out = await copilotTransform(copilotNoteId, copilotWorkspaceId, action, selected); setExplainResult(out); } catch (e) { toast.error(e instanceof Error ? e.message : "Explain failed"); } finally { setCopilotBusy(false); } return; } if (!selected) { toast.error("Select text in the editor first"); return; } setCopilotBusy(true); try { const out = await copilotTransform(copilotNoteId, copilotWorkspaceId, action, selected, tone); const escaped = out .split("\n") .map((line) => line.replace(//g, ">")) .join("

"); editor.chain().focus().deleteSelection().insertContent(`

${escaped}

`).run(); toast.success("Copilot insert applied — review and save"); } catch (e) { toast.error(e instanceof Error ? e.message : "Copilot failed"); } finally { setCopilotBusy(false); } }, [editor, copilotNoteId, copilotWorkspaceId], ); return (
{ e.preventDefault(); handleSave(); }} >
setTitle(e.target.value)} />
{editor && (
editor.chain().focus().toggleBold().run()} label="B" /> editor.chain().focus().toggleItalic().run()} label="I" /> editor.chain().focus().toggleStrike().run()} label="S" /> editor.chain().focus().toggleHeading({ level: 1 }).run()} label="H1" /> editor.chain().focus().toggleHeading({ level: 2 }).run()} label="H2" /> editor.chain().focus().toggleHeading({ level: 3 }).run()} label="H3" /> editor.chain().focus().toggleBulletList().run()} label="•" /> editor.chain().focus().toggleOrderedList().run()} label="1." /> editor.chain().focus().toggleCodeBlock().run()} label="<>" /> editor.chain().focus().toggleBlockquote().run()} label="❝" />
)} {editor && copilotNoteId && copilotWorkspaceId ? (
Copilot {(["shorten", "bulletize", "expand", "grammar"] as const).map((a) => ( ))} AI
{toneMenuOpen && (
{(["formal", "casual", "professional", "friendly"] as const).map((t) => ( ))}
)}
) : null} {explainResult && (
{explainResult}
)}
{isSaving ? "Saving…" : dirty ? "Unsaved changes (auto-save on pause)" : "All changes saved"}
); }