NotesEditor.tsx

9.0 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../../shared/bridge";4import { isNativePlatform } from "../../shared/native";5import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";6import { toHtml } from "./NoteEditor";78const HEADER_HEIGHT = 52;9const AUTOSAVE_MS = 800;10const SAVED_DISPLAY_MS = 1500;1112interface NotesEditorProps {13  note: VaultEntry | null; // null = new note14  onBack: () => void;15  onSaved: (note: VaultEntry) => void;16  onDelete: (note: VaultEntry) => void;17  autoFocusTitle?: boolean;18}1920export function NotesEditor(props: NotesEditorProps) {21  let titleRef!: HTMLDivElement;22  let bodyRef!: HTMLDivElement;23  let contentScrollRef!: HTMLDivElement;2425  const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);26  const [title, setTitle] = createSignal(props.note?.title ?? "");27  const [body, setBody] = createSignal(props.note?.notes ?? "");28  const [saved, setSaved] = createSignal(false);29  const [keyboardVisible, setKeyboardVisible] = createSignal(false);30  const [contentHeight, setContentHeight] = createSignal<number | null>(null);31  const [dirty, setDirty] = createSignal(false);3233  let saveTimer: number | null = null;34  let savedFadeTimer: number | null = null;3536  // An "empty" contenteditable body frequently serialises to "<br>", a37  // zero-width space, &nbsp;, or whitespace-only markup rather than "". Trim38  // alone can't catch those, so strip tags/entities/formatting and check for39  // any actual visible character before deciding a note has content.40  function bodyIsBlank(html: string): boolean {41    return !html42      .replace(/<[^>]*>|&nbsp;|&#160;|\u200b|\ufeff/g, "")43      .replace(/\s+/g, "");44  }4546  function scheduleAutosave() {47    setDirty(true);48    if (saveTimer) clearTimeout(saveTimer);49    saveTimer = window.setTimeout(save, AUTOSAVE_MS);50  }5152  async function save() {53    const t = title();54    const b = body();55    // Don't save if genuinely empty (new note, nothing typed)56    if (!t.trim() && bodyIsBlank(b)) return;57    const res = await sendMessage({58      type: "SAVE_NOTE",59      payload: { id: noteId(), title: t || "Untitled", body: b },60    }) as { ok: boolean; data?: VaultEntry };61    if (res?.ok && res.data) {62      setNoteId(res.data.id);63      setDirty(false);64      showSaved();65      props.onSaved(res.data);66    }67  }6869  function showSaved() {70    setSaved(true);71    if (savedFadeTimer) clearTimeout(savedFadeTimer);72    savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);73  }7475  function exec(cmd: string, val?: string) {76    document.execCommand(cmd, false, val);77    // execCommand does not reliably emit an input event in every browser.78    // Read the formatted DOM immediately so the pending autosave cannot write79    // the stale pre-formatting signal when the user taps Back.80    setBody(bodyRef?.innerHTML ?? "");81    bodyRef?.focus();82    scheduleAutosave();83  }8485  async function handleBack() {86    // Await the final save before navigating away — fire-and-forget would let87    // the component unmount before the message round-trip completes.88    if (dirty() && (title().trim() || !bodyIsBlank(body()))) {89      if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }90      await save();91    }92    props.onBack();93  }9495  function handleKeyboardResize() {96    if (!isNativePlatform()) return;97    const vp = window.visualViewport;98    const viewportH = vp?.height ?? window.innerHeight;99    const kbH = window.innerHeight - viewportH;100    if (kbH > 50) {101      setKeyboardVisible(true);102      setContentHeight(viewportH - HEADER_HEIGHT);103      if (document.activeElement) {104        (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });105      }106    } else {107      setKeyboardVisible(false);108      setContentHeight(null);109    }110  }111112  onMount(() => {113    // Populate editor content114    if (titleRef) titleRef.textContent = props.note?.title ?? "";115    // M15/THREAT-006: sanitize stored note body before assigning to innerHTML.116    if (bodyRef) bodyRef.innerHTML = toHtml(props.note?.notes ?? "");117118    if (props.autoFocusTitle && titleRef) {119      setTimeout(() => {120        titleRef.focus();121        // Place cursor at end122        const range = document.createRange();123        const sel = window.getSelection();124        range.selectNodeContents(titleRef);125        range.collapse(false);126        sel?.removeAllRanges();127        sel?.addRange(range);128      }, 300);129    }130131    if (isNativePlatform()) {132      window.addEventListener("resize", handleKeyboardResize);133    }134135    onCleanup(() => {136      if (saveTimer) clearTimeout(saveTimer);137      if (savedFadeTimer) clearTimeout(savedFadeTimer);138      if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);139    });140  });141142  // Swipe right from left edge to go back (mobile)143  let swipeStartX = 0;144  let swipeStartY = 0;145  let swipeCaptured = false;146147  function handleSwipeStart(e: TouchEvent) {148    const t = e.touches[0];149    if (!t) return;150    swipeStartX = t.clientX;151    swipeStartY = t.clientY;152    swipeCaptured = false;153  }154155  function handleSwipeMove(e: TouchEvent) {156    const t = e.touches[0];157    if (!t) return;158    const dx = t.clientX - swipeStartX;159    const dy = Math.abs(t.clientY - swipeStartY);160    if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {161      swipeCaptured = true;162    }163  }164165  function handleSwipeEnd() {166    if (swipeCaptured) {167      handleBack();168    }169    swipeCaptured = false;170  }171172  const execFn: ToolbarExec = exec;173174  return (175    <div176      class="peach-note-editor-surface"177      onTouchStart={handleSwipeStart}178      onTouchMove={handleSwipeMove}179      onTouchEnd={handleSwipeEnd}180    >181      {/* Header */}182      <div class="peach-notes-header peach-notes-header--editor">183        <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>184        <span class="peach-notes-header-title" onClick={handleBack}>notes.</span>185        <div class="peach-notes-header-actions">186          <Show when={noteId()}>187            <button188              class="peach-notes-header-btn"189              aria-label="Delete note"190              onClick={() => {191                const id = noteId();192                if (!id) return;193                // Find entry by id for callback194                props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);195              }}196              title="Delete note"197            >198              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">199                <polyline points="1,3.5 13,3.5" />200                <path d="M5 3.5V2h4v1.5" />201                <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />202                <line x1="5.5" y1="6.5" x2="5.5" y2="10" />203                <line x1="8.5" y1="6.5" x2="8.5" y2="10" />204              </svg>205            </button>206          </Show>207        </div>208      </div>209210      {/* Scrollable content */}211      <div212        ref={contentScrollRef}213        class="peach-note-editor-scroll"214        style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}215      >216        {/* Desktop toolbar — above content */}217        <Show when={!isNativePlatform()}>218          <NoteToolbar exec={execFn} />219        </Show>220221        <div222          ref={titleRef}223          class="peach-note-title-field"224          contenteditable="true"225          data-placeholder="Title"226          onInput={() => {227            setTitle(titleRef.textContent ?? "");228            scheduleAutosave();229          }}230          onKeyDown={(e) => {231            if (e.key === "Enter") {232              e.preventDefault();233              bodyRef?.focus();234            }235          }}236          aria-label="Note title"237        />238239        <div class="peach-note-hairline" />240241        <div242          ref={bodyRef}243          class="peach-note-body-field"244          contenteditable="true"245          data-placeholder="Begin writing…"246          onInput={() => {247            setBody(bodyRef.innerHTML ?? "");248            scheduleAutosave();249          }}250          onKeyDown={(e) => {251            const mod = e.metaKey || e.ctrlKey;252            if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }253            if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }254            if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }255          }}256          aria-label="Note body"257          aria-multiline="true"258        />259      </div>260261      {/* Saved indicator */}262      <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>263        saved.264      </div>265266      {/* Mobile keyboard toolbar */}267      <Show when={isNativePlatform()}>268        <NoteToolbar269          exec={execFn}270          keyboardVisible={keyboardVisible()}271          onDismissKeyboard={() => {272            titleRef?.blur();273            bodyRef?.blur();274          }}275        />276      </Show>277    </div>278  );279}