import { createSignal, onCleanup, onMount, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { sendMessage } from "../../shared/bridge"; import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar"; import { toHtml, handleRichTextPaste } from "./NoteEditor"; const AUTOSAVE_MS = 800; const SAVED_DISPLAY_MS = 1500; interface NotesEditorProps { note: VaultEntry | null; // null = new note onBack: () => void; onSaved: (note: VaultEntry) => void; onDelete: (note: VaultEntry) => void; autoFocusTitle?: boolean; } export function NotesEditor(props: NotesEditorProps) { let titleRef!: HTMLDivElement; let bodyRef!: HTMLDivElement; const [noteId, setNoteId] = createSignal(props.note?.id); const [title, setTitle] = createSignal(props.note?.title ?? ""); const [body, setBody] = createSignal(props.note?.notes ?? ""); const [saved, setSaved] = createSignal(false); const [dirty, setDirty] = createSignal(false); let saveTimer: number | null = null; let savedFadeTimer: number | null = null; // An "empty" contenteditable body frequently serialises to "
", a // zero-width space,  , or whitespace-only markup rather than "". Trim // alone can't catch those, so strip tags/entities/formatting and check for // any actual visible character before deciding a note has content. function bodyIsBlank(html: string): boolean { return !html .replace(/<[^>]*>| | |\u200b|\ufeff/g, "") .replace(/\s+/g, ""); } function scheduleAutosave() { setDirty(true); if (saveTimer) clearTimeout(saveTimer); saveTimer = window.setTimeout(save, AUTOSAVE_MS); } async function save() { const t = title(); const b = body(); // Don't save if genuinely empty (new note, nothing typed) if (!t.trim() && bodyIsBlank(b)) return; const res = await sendMessage({ type: "SAVE_NOTE", payload: { id: noteId(), title: t || "Untitled", body: b }, }) as { ok: boolean; data?: VaultEntry }; if (res?.ok && res.data) { setNoteId(res.data.id); setDirty(false); showSaved(); props.onSaved(res.data); } } function showSaved() { setSaved(true); if (savedFadeTimer) clearTimeout(savedFadeTimer); savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS); } function exec(cmd: string, val?: string) { document.execCommand(cmd, false, val); // execCommand does not reliably emit an input event in every browser. // Read the formatted DOM immediately so the pending autosave cannot write // the stale pre-formatting signal when the user taps Back. setBody(bodyRef?.innerHTML ?? ""); bodyRef?.focus(); scheduleAutosave(); } async function handleBack() { // Await the final save before navigating away — fire-and-forget would let // the component unmount before the message round-trip completes. if (dirty() && (title().trim() || !bodyIsBlank(body()))) { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } await save(); } props.onBack(); } onMount(() => { // Populate editor content if (titleRef) titleRef.textContent = props.note?.title ?? ""; // M15/THREAT-006: sanitize stored note body before assigning to innerHTML. if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? ""); if (props.autoFocusTitle && titleRef) { setTimeout(() => { titleRef.focus(); // Place cursor at end const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(titleRef); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); }, 300); } onCleanup(() => { if (saveTimer) clearTimeout(saveTimer); if (savedFadeTimer) clearTimeout(savedFadeTimer); }); }); // Swipe right from left edge to go back (mobile) let swipeStartX = 0; let swipeStartY = 0; let swipeCaptured = false; function handleSwipeStart(e: TouchEvent) { const t = e.touches[0]; if (!t) return; swipeStartX = t.clientX; swipeStartY = t.clientY; swipeCaptured = false; } function handleSwipeMove(e: TouchEvent) { const t = e.touches[0]; if (!t) return; const dx = t.clientX - swipeStartX; const dy = Math.abs(t.clientY - swipeStartY); if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) { swipeCaptured = true; } } function handleSwipeEnd() { if (swipeCaptured) { handleBack(); } swipeCaptured = false; } const execFn: ToolbarExec = exec; return (
{/* Header */}
notes.
{/* Scrollable content */}
{ setTitle(titleRef.textContent ?? ""); scheduleAutosave(); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); bodyRef?.focus(); } }} onPaste={handleRichTextPaste} aria-label="Note title" />
{ setBody(bodyRef.innerHTML ?? ""); scheduleAutosave(); }} onKeyDown={(e) => { const mod = e.metaKey || e.ctrlKey; if (mod && e.key === "b") { e.preventDefault(); exec("bold"); } if (mod && e.key === "i") { e.preventDefault(); exec("italic"); } if (mod && e.key === "u") { e.preventDefault(); exec("underline"); } }} onPaste={handleRichTextPaste} aria-label="Note body" aria-multiline="true" />
{/* Saved indicator */}
saved.
); }