277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
"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 (
|
|
<button type="button" style={active ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN} onClick={onClick} title={label}>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<void>;
|
|
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<string | null>(null);
|
|
const toneMenuRef = useRef<HTMLDivElement>(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, "<").replace(/>/g, ">")).join("</p><p>");
|
|
editor.chain().focus().insertContent(`<p>${escaped}</p>`).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, "<").replace(/>/g, ">"))
|
|
.join("</p><p>");
|
|
editor.chain().focus().deleteSelection().insertContent(`<p>${escaped}</p>`).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 (
|
|
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
|
|
<form
|
|
style={{ display: "grid", gap: "var(--nl-space-4)" }}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleSave();
|
|
}}
|
|
>
|
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
|
<label htmlFor="note-title" style={{ color: "var(--nl-text-secondary)" }}>Title</label>
|
|
<input
|
|
id="note-title"
|
|
className="input-shell"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
|
<label style={{ color: "var(--nl-text-secondary)" }}>Body</label>
|
|
|
|
{editor && (
|
|
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", padding: "4px 0" }}>
|
|
<ToolbarButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} label="B" />
|
|
<ToolbarButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()} label="I" />
|
|
<ToolbarButton active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()} label="S" />
|
|
<span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px" }} />
|
|
<ToolbarButton active={editor.isActive("heading", { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} label="H1" />
|
|
<ToolbarButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} label="H2" />
|
|
<ToolbarButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} label="H3" />
|
|
<span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px" }} />
|
|
<ToolbarButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()} label="•" />
|
|
<ToolbarButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()} label="1." />
|
|
<ToolbarButton active={editor.isActive("codeBlock")} onClick={() => editor.chain().focus().toggleCodeBlock().run()} label="<>" />
|
|
<ToolbarButton active={editor.isActive("blockquote")} onClick={() => editor.chain().focus().toggleBlockquote().run()} label="❝" />
|
|
</div>
|
|
)}
|
|
|
|
{editor && copilotNoteId && copilotWorkspaceId ? (
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center", padding: "4px 0" }}>
|
|
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)", marginRight: 4 }}>Copilot</span>
|
|
{(["shorten", "bulletize", "expand", "grammar"] as const).map((a) => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
disabled={copilotBusy}
|
|
style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }}
|
|
onClick={() => void runCopilot(a)}
|
|
>
|
|
{a}
|
|
</button>
|
|
))}
|
|
<span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px", alignSelf: "stretch" }} />
|
|
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)", marginRight: 4 }}>AI</span>
|
|
<button type="button" disabled={copilotBusy} style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }} onClick={() => void runCopilot("fix-rewrite")}>Fix & Rewrite</button>
|
|
<div ref={toneMenuRef} style={{ position: "relative" }}>
|
|
<button type="button" disabled={copilotBusy} style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }} onClick={() => setToneMenuOpen(!toneMenuOpen)}>Change Tone ▾</button>
|
|
{toneMenuOpen && (
|
|
<div style={{ position: "absolute", top: "100%", left: 0, zIndex: 50, background: "var(--nl-surface-card)", border: "1px solid var(--nl-border-default)", borderRadius: "var(--nl-radius-md)", padding: 4, display: "grid", gap: 2, minWidth: 120 }}>
|
|
{(["formal", "casual", "professional", "friendly"] as const).map((t) => (
|
|
<button key={t} type="button" style={{ ...TOOLBAR_BTN, textAlign: "left", width: "100%" }} onClick={() => { setToneMenuOpen(false); void runCopilot("change-tone", t); }}>{t}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button type="button" disabled={copilotBusy} style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }} onClick={() => void runCopilot("continue")}>Continue ✍</button>
|
|
<button type="button" disabled={copilotBusy} style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }} onClick={() => void runCopilot("explain")}>Explain</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{explainResult && (
|
|
<div style={{ background: "var(--nl-surface-card)", border: "1px solid var(--nl-border-default)", borderRadius: "var(--nl-radius-md)", padding: "var(--nl-space-3)", fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)", display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
<div style={{ flex: 1 }}>{explainResult}</div>
|
|
<button type="button" style={{ ...TOOLBAR_BTN, fontSize: 12 }} onClick={() => setExplainResult(null)}>✕</button>
|
|
</div>
|
|
)}
|
|
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "var(--nl-space-3)" }}>
|
|
<div style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }} aria-live="polite">
|
|
{isSaving ? "Saving…" : dirty ? "Unsaved changes (auto-save on pause)" : "All changes saved"}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
style={{
|
|
border: "none",
|
|
borderRadius: "var(--nl-radius-md)",
|
|
padding: "10px 14px",
|
|
background: "var(--nl-accent-primary)",
|
|
color: "var(--nl-text-primary)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{isSaving ? "Saving…" : "Save now"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|