NotesEditor.tsx

8.1 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";67const HEADER_HEIGHT = 52;8const AUTOSAVE_MS = 800;9const SAVED_DISPLAY_MS = 1500;1011interface NotesEditorProps {12  note: VaultEntry | null; // null = new note13  onBack: () => void;14  onSaved: (note: VaultEntry) => void;15  onDelete: (note: VaultEntry) => void;16  autoFocusTitle?: boolean;17}1819export function NotesEditor(props: NotesEditorProps) {20  let titleRef!: HTMLDivElement;21  let bodyRef!: HTMLDivElement;22  let contentScrollRef!: HTMLDivElement;2324  const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);25  const [title, setTitle] = createSignal(props.note?.title ?? "");26  const [body, setBody] = createSignal(props.note?.notes ?? "");27  const [saved, setSaved] = createSignal(false);28  const [keyboardVisible, setKeyboardVisible] = createSignal(false);29  const [contentHeight, setContentHeight] = createSignal<number | null>(null);30  const [dirty, setDirty] = createSignal(false);3132  let saveTimer: number | null = null;33  let savedFadeTimer: number | null = null;3435  function scheduleAutosave() {36    setDirty(true);37    if (saveTimer) clearTimeout(saveTimer);38    saveTimer = window.setTimeout(save, AUTOSAVE_MS);39  }4041  async function save() {42    const t = title();43    const b = body();44    // Don't save if genuinely empty (new note, nothing typed)45    if (!t.trim() && !b.trim()) return;46    const res = await sendMessage({47      type: "SAVE_NOTE",48      payload: { id: noteId(), title: t || "Untitled", body: b },49    }) as { ok: boolean; data?: VaultEntry };50    if (res?.ok && res.data) {51      setNoteId(res.data.id);52      setDirty(false);53      showSaved();54      props.onSaved(res.data);55    }56  }5758  function showSaved() {59    setSaved(true);60    if (savedFadeTimer) clearTimeout(savedFadeTimer);61    savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);62  }6364  function exec(cmd: string, val?: string) {65    document.execCommand(cmd, false, val);66    bodyRef?.focus();67    scheduleAutosave();68  }6970  async function handleBack() {71    // Await the final save before navigating away — fire-and-forget would let72    // the component unmount before the message round-trip completes.73    if (dirty() && (title().trim() || body().trim())) {74      if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }75      await save();76    }77    props.onBack();78  }7980  function handleKeyboardResize() {81    if (!isNativePlatform()) return;82    const vp = window.visualViewport;83    const viewportH = vp?.height ?? window.innerHeight;84    const kbH = window.innerHeight - viewportH;85    if (kbH > 50) {86      setKeyboardVisible(true);87      setContentHeight(viewportH - HEADER_HEIGHT);88      if (document.activeElement) {89        (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });90      }91    } else {92      setKeyboardVisible(false);93      setContentHeight(null);94    }95  }9697  onMount(() => {98    // Populate editor content99    if (titleRef) titleRef.textContent = props.note?.title ?? "";100    if (bodyRef) bodyRef.innerHTML = props.note?.notes ?? "";101102    if (props.autoFocusTitle && titleRef) {103      setTimeout(() => {104        titleRef.focus();105        // Place cursor at end106        const range = document.createRange();107        const sel = window.getSelection();108        range.selectNodeContents(titleRef);109        range.collapse(false);110        sel?.removeAllRanges();111        sel?.addRange(range);112      }, 300);113    }114115    if (isNativePlatform()) {116      window.addEventListener("resize", handleKeyboardResize);117    }118119    onCleanup(() => {120      if (saveTimer) clearTimeout(saveTimer);121      if (savedFadeTimer) clearTimeout(savedFadeTimer);122      if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);123    });124  });125126  // Swipe right from left edge to go back (mobile)127  let swipeStartX = 0;128  let swipeStartY = 0;129  let swipeCaptured = false;130131  function handleSwipeStart(e: TouchEvent) {132    const t = e.touches[0];133    if (!t) return;134    swipeStartX = t.clientX;135    swipeStartY = t.clientY;136    swipeCaptured = false;137  }138139  function handleSwipeMove(e: TouchEvent) {140    const t = e.touches[0];141    if (!t) return;142    const dx = t.clientX - swipeStartX;143    const dy = Math.abs(t.clientY - swipeStartY);144    if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {145      swipeCaptured = true;146    }147  }148149  function handleSwipeEnd() {150    if (swipeCaptured) {151      handleBack();152    }153    swipeCaptured = false;154  }155156  const execFn: ToolbarExec = exec;157158  return (159    <div160      class="peach-note-editor-surface"161      onTouchStart={handleSwipeStart}162      onTouchMove={handleSwipeMove}163      onTouchEnd={handleSwipeEnd}164    >165      {/* Header */}166      <div class="peach-notes-header">167        <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>168        <span class="peach-notes-header-title is-breadcrumb" onClick={handleBack}>notes</span>169        <div class="peach-notes-header-actions">170          <Show when={noteId()}>171            <button172              class="peach-notes-header-btn"173              aria-label="Delete note"174              onClick={() => {175                const id = noteId();176                if (!id) return;177                // Find entry by id for callback178                props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);179              }}180              title="Delete note"181            >182              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">183                <polyline points="1,3.5 13,3.5" />184                <path d="M5 3.5V2h4v1.5" />185                <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />186                <line x1="5.5" y1="6.5" x2="5.5" y2="10" />187                <line x1="8.5" y1="6.5" x2="8.5" y2="10" />188              </svg>189            </button>190          </Show>191        </div>192      </div>193194      {/* Scrollable content */}195      <div196        ref={contentScrollRef}197        class="peach-note-editor-scroll"198        style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}199      >200        {/* Desktop toolbar — above content */}201        <Show when={!isNativePlatform()}>202          <NoteToolbar exec={execFn} />203        </Show>204205        <div206          ref={titleRef}207          class="peach-note-title-field"208          contenteditable="true"209          data-placeholder="Title"210          onInput={() => {211            setTitle(titleRef.textContent ?? "");212            scheduleAutosave();213          }}214          onKeyDown={(e) => {215            if (e.key === "Enter") {216              e.preventDefault();217              bodyRef?.focus();218            }219          }}220          aria-label="Note title"221        />222223        <div class="peach-note-hairline" />224225        <div226          ref={bodyRef}227          class="peach-note-body-field"228          contenteditable="true"229          data-placeholder="Begin writing…"230          onInput={() => {231            setBody(bodyRef.innerHTML ?? "");232            scheduleAutosave();233          }}234          onKeyDown={(e) => {235            const mod = e.metaKey || e.ctrlKey;236            if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }237            if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }238            if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }239          }}240          aria-label="Note body"241          aria-multiline="true"242        />243      </div>244245      {/* Saved indicator */}246      <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>247        saved.248      </div>249250      {/* Mobile keyboard toolbar */}251      <Show when={isNativePlatform()}>252        <NoteToolbar253          exec={execFn}254          keyboardVisible={keyboardVisible()}255          onDismissKeyboard={() => {256            titleRef?.blur();257            bodyRef?.blur();258          }}259        />260      </Show>261    </div>262  );263}