"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: "var(--nl-accent-muted-strong)",
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 toneMenuRef = useRef(null);
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
if (!toneMenuOpen) return;
const handler = (e: MouseEvent) => {
if (toneMenuRef.current && !toneMenuRef.current.contains(e.target as Node)) {
setToneMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [toneMenuOpen]);
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 (
);
}