NotesEditor.tsx

7.5 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../../shared/bridge";4import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";5import { toHtml } from "./NoteEditor";67const AUTOSAVE_MS = 800;8const SAVED_DISPLAY_MS = 1500;910interface NotesEditorProps {11  note: VaultEntry | null; // null = new note12  onBack: () => void;13  onSaved: (note: VaultEntry) => void;14  onDelete: (note: VaultEntry) => void;15  autoFocusTitle?: boolean;16}1718export function NotesEditor(props: NotesEditorProps) {19  let titleRef!: HTMLDivElement;20  let bodyRef!: HTMLDivElement;2122  const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);23  const [title, setTitle] = createSignal(props.note?.title ?? "");24  const [body, setBody] = createSignal(props.note?.notes ?? "");25  const [saved, setSaved] = createSignal(false);26  const [dirty, setDirty] = createSignal(false);2728  let saveTimer: number | null = null;29  let savedFadeTimer: number | null = null;3031  // An "empty" contenteditable body frequently serialises to "<br>", a32  // zero-width space, &nbsp;, or whitespace-only markup rather than "". Trim33  // alone can't catch those, so strip tags/entities/formatting and check for34  // any actual visible character before deciding a note has content.35  function bodyIsBlank(html: string): boolean {36    return !html37      .replace(/<[^>]*>|&nbsp;|&#160;|\u200b|\ufeff/g, "")38      .replace(/\s+/g, "");39  }4041  function scheduleAutosave() {42    setDirty(true);43    if (saveTimer) clearTimeout(saveTimer);44    saveTimer = window.setTimeout(save, AUTOSAVE_MS);45  }4647  async function save() {48    const t = title();49    const b = body();50    // Don't save if genuinely empty (new note, nothing typed)51    if (!t.trim() && bodyIsBlank(b)) return;52    const res = await sendMessage({53      type: "SAVE_NOTE",54      payload: { id: noteId(), title: t || "Untitled", body: b },55    }) as { ok: boolean; data?: VaultEntry };56    if (res?.ok && res.data) {57      setNoteId(res.data.id);58      setDirty(false);59      showSaved();60      props.onSaved(res.data);61    }62  }6364  function showSaved() {65    setSaved(true);66    if (savedFadeTimer) clearTimeout(savedFadeTimer);67    savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);68  }6970  function exec(cmd: string, val?: string) {71    document.execCommand(cmd, false, val);72    // execCommand does not reliably emit an input event in every browser.73    // Read the formatted DOM immediately so the pending autosave cannot write74    // the stale pre-formatting signal when the user taps Back.75    setBody(bodyRef?.innerHTML ?? "");76    bodyRef?.focus();77    scheduleAutosave();78  }7980  async function handleBack() {81    // Await the final save before navigating away — fire-and-forget would let82    // the component unmount before the message round-trip completes.83    if (dirty() && (title().trim() || !bodyIsBlank(body()))) {84      if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }85      await save();86    }87    props.onBack();88  }8990  onMount(() => {91    // Populate editor content92    if (titleRef) titleRef.textContent = props.note?.title ?? "";93    // M15/THREAT-006: sanitize stored note body before assigning to innerHTML.94    if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? "");9596    if (props.autoFocusTitle && titleRef) {97      setTimeout(() => {98        titleRef.focus();99        // Place cursor at end100        const range = document.createRange();101        const sel = window.getSelection();102        range.selectNodeContents(titleRef);103        range.collapse(false);104        sel?.removeAllRanges();105        sel?.addRange(range);106      }, 300);107    }108109    onCleanup(() => {110      if (saveTimer) clearTimeout(saveTimer);111      if (savedFadeTimer) clearTimeout(savedFadeTimer);112    });113  });114115  // Swipe right from left edge to go back (mobile)116  let swipeStartX = 0;117  let swipeStartY = 0;118  let swipeCaptured = false;119120  function handleSwipeStart(e: TouchEvent) {121    const t = e.touches[0];122    if (!t) return;123    swipeStartX = t.clientX;124    swipeStartY = t.clientY;125    swipeCaptured = false;126  }127128  function handleSwipeMove(e: TouchEvent) {129    const t = e.touches[0];130    if (!t) return;131    const dx = t.clientX - swipeStartX;132    const dy = Math.abs(t.clientY - swipeStartY);133    if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {134      swipeCaptured = true;135    }136  }137138  function handleSwipeEnd() {139    if (swipeCaptured) {140      handleBack();141    }142    swipeCaptured = false;143  }144145  const execFn: ToolbarExec = exec;146147  return (148    <div149      class="peach-note-editor-surface"150      onTouchStart={handleSwipeStart}151      onTouchMove={handleSwipeMove}152      onTouchEnd={handleSwipeEnd}153    >154      {/* Header */}155      <div class="peach-notes-header peach-notes-header--editor">156        <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>157        <span class="peach-notes-header-title" onClick={handleBack}>notes.</span>158        <div class="peach-notes-header-actions">159          <Show when={noteId()}>160            <button161              class="peach-notes-header-btn"162              aria-label="Delete note"163              onClick={() => {164                const id = noteId();165                if (!id) return;166                // Find entry by id for callback167                props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);168              }}169              title="Delete note"170            >171              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">172                <polyline points="1,3.5 13,3.5" />173                <path d="M5 3.5V2h4v1.5" />174                <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />175                <line x1="5.5" y1="6.5" x2="5.5" y2="10" />176                <line x1="8.5" y1="6.5" x2="8.5" y2="10" />177              </svg>178            </button>179          </Show>180        </div>181      </div>182183      {/* Scrollable content */}184      <div185        class="peach-note-editor-scroll"186      >187        <NoteToolbar exec={execFn} />188189        <div190          ref={titleRef}191          class="peach-note-title-field"192          contenteditable="true"193          data-placeholder="Title"194          onInput={() => {195            setTitle(titleRef.textContent ?? "");196            scheduleAutosave();197          }}198          onKeyDown={(e) => {199            if (e.key === "Enter") {200              e.preventDefault();201              bodyRef?.focus();202            }203          }}204          aria-label="Note title"205        />206207        <div class="peach-note-hairline" />208209        <div210          ref={bodyRef}211          class="peach-note-body-field"212          contenteditable="true"213          data-placeholder="Begin writing…"214          onInput={() => {215            setBody(bodyRef.innerHTML ?? "");216            scheduleAutosave();217          }}218          onKeyDown={(e) => {219            const mod = e.metaKey || e.ctrlKey;220            if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }221            if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }222            if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }223          }}224          aria-label="Note body"225          aria-multiline="true"226        />227      </div>228229      {/* Saved indicator */}230      <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>231        saved.232      </div>233234    </div>235  );236}