learning_ai_notes/web/src/components/NoteEditor.tsx
saravanakumardb1 3260b7ea0a feat(smart-actions): F1-F4 inline editor AI, F15-F19 mobile capture modes, F25-F27 scheduler/webhooks/approval
F1-F4: Inline editor AI
- Backend: expand CopilotAction with fix-rewrite, change-tone, continue, explain
- Backend: add tone parameter to copilot route for change-tone action
- Web: copilot-client adds CopilotTone type and tone parameter
- Web: NoteEditor toolbar gains AI row with Fix & Rewrite, Change Tone dropdown,
  Continue Writing (appends at cursor), Explain (inline popover)

F15-F19: Mobile capture enhancements
- Backend: POST /note-prompts/url-extract endpoint (fetch, strip HTML, LLM summarize)
- Mobile API: extractFromUrl() and copilotTransform() client functions
- Mobile: capture tab rewritten with 6 capture modes grid (text, photo, voice,
  URL, scan, paste) — URL extract + clipboard paste fully wired, camera/voice/scan
  surface native permission prompts (require expo-av/expo-image-picker)
- expo-clipboard added as dependency

F25-F27: Scheduled actions, webhook triggers, approval-gated actions
- New scheduler.ts module with PromptScheduleDoc + PromptWebhookDoc types
- Schedule CRUD: GET/POST/PATCH/DELETE /prompt-schedules
- Webhook CRUD: GET/POST/PATCH/DELETE /prompt-webhooks
- POST /prompt-webhooks/:id/trigger — execute template against note
- Scheduler loop (60s tick) with cron next-run calculation
- Diagnostics endpoint: GET /prompt-schedules/diagnostics
- Cosmos containers: note_prompt_schedules, note_prompt_webhooks
- PromptTemplateDoc gains requiresApproval field (F27)
- Runner produces approvalState: proposed|applied based on template flag
- Create/Update schemas accept requiresApproval boolean
2026-04-06 10:25:34 -07:00

265 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: "rgba(90,140,255,0.18)",
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 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, "&lt;").replace(/>/g, "&gt;")).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, "&lt;").replace(/>/g, "&gt;"))
.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 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>
);
}