NotesEditor.tsx

8.3 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 { NoteRunes } from "../components/NoteRunes";6import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";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  function scheduleAutosave() {37    setDirty(true);38    if (saveTimer) clearTimeout(saveTimer);39    saveTimer = window.setTimeout(save, AUTOSAVE_MS);40  }4142  async function save() {43    const t = title();44    const b = body();45    // Don't save if genuinely empty (new note, nothing typed)46    if (!t.trim() && !b.trim()) return;47    const res = await sendMessage({48      type: "SAVE_NOTE",49      payload: { id: noteId(), title: t || "Untitled", body: b },50    }) as { ok: boolean; data?: VaultEntry };51    if (res?.ok && res.data) {52      setNoteId(res.data.id);53      setDirty(false);54      showSaved();55      props.onSaved(res.data);56    }57  }5859  function showSaved() {60    setSaved(true);61    if (savedFadeTimer) clearTimeout(savedFadeTimer);62    savedFadeTimer = window.setTimeout(() => setSaved(false), SAVED_DISPLAY_MS);63  }6465  function exec(cmd: string, val?: string) {66    document.execCommand(cmd, false, val);67    bodyRef?.focus();68    scheduleAutosave();69  }7071  function handleBack() {72    // Final save before going back (only if there's content and it's dirty)73    if (dirty() && (title().trim() || body().trim())) {74      if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }75      void save();76    } else if (!noteId() && !title().trim() && !body().trim()) {77      // Empty new note — discard78    }79    props.onBack();80  }8182  function handleKeyboardResize() {83    if (!isNativePlatform()) return;84    const vp = window.visualViewport;85    const viewportH = vp?.height ?? window.innerHeight;86    const kbH = window.innerHeight - viewportH;87    if (kbH > 50) {88      setKeyboardVisible(true);89      setContentHeight(viewportH - HEADER_HEIGHT);90      if (document.activeElement) {91        (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });92      }93    } else {94      setKeyboardVisible(false);95      setContentHeight(null);96    }97  }9899  onMount(() => {100    // Populate editor content101    if (titleRef) titleRef.textContent = props.note?.title ?? "";102    if (bodyRef) bodyRef.innerHTML = props.note?.notes ?? "";103104    if (props.autoFocusTitle && titleRef) {105      setTimeout(() => {106        titleRef.focus();107        // Place cursor at end108        const range = document.createRange();109        const sel = window.getSelection();110        range.selectNodeContents(titleRef);111        range.collapse(false);112        sel?.removeAllRanges();113        sel?.addRange(range);114      }, 300);115    }116117    if (isNativePlatform()) {118      window.addEventListener("resize", handleKeyboardResize);119    }120121    onCleanup(() => {122      if (saveTimer) clearTimeout(saveTimer);123      if (savedFadeTimer) clearTimeout(savedFadeTimer);124      if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);125    });126  });127128  // Swipe right from left edge to go back (mobile)129  let swipeStartX = 0;130  let swipeStartY = 0;131  let swipeCaptured = false;132133  function handleSwipeStart(e: TouchEvent) {134    const t = e.touches[0];135    if (!t) return;136    swipeStartX = t.clientX;137    swipeStartY = t.clientY;138    swipeCaptured = false;139  }140141  function handleSwipeMove(e: TouchEvent) {142    const t = e.touches[0];143    if (!t) return;144    const dx = t.clientX - swipeStartX;145    const dy = Math.abs(t.clientY - swipeStartY);146    if (!swipeCaptured && swipeStartX < 30 && dx > 40 && dy < dx) {147      swipeCaptured = true;148    }149  }150151  function handleSwipeEnd() {152    if (swipeCaptured) {153      handleBack();154    }155    swipeCaptured = false;156  }157158  const execFn: ToolbarExec = exec;159160  return (161    <div162      class="peach-note-editor-surface"163      onTouchStart={handleSwipeStart}164      onTouchMove={handleSwipeMove}165      onTouchEnd={handleSwipeEnd}166    >167      <NoteRunes mode="editor" />168169      {/* Header */}170      <div class="peach-notes-header">171        <button class="peach-notes-back" onClick={handleBack} aria-label="Back to notes list">‹</button>172        <span class="peach-notes-header-title is-breadcrumb" onClick={handleBack}>notes</span>173        <div class="peach-notes-header-actions">174          <Show when={noteId()}>175            <button176              class="peach-notes-header-btn"177              aria-label="Delete note"178              onClick={() => {179                const id = noteId();180                if (!id) return;181                // Find entry by id for callback182                props.onDelete({ id, title: title(), notes: body(), type: "note", createdAt: 0, modifiedAt: Date.now() } as VaultEntry);183              }}184              title="Delete note"185            >186              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">187                <polyline points="1,3.5 13,3.5" />188                <path d="M5 3.5V2h4v1.5" />189                <rect x="2.5" y="3.5" width="9" height="9" rx="1.5" />190                <line x1="5.5" y1="6.5" x2="5.5" y2="10" />191                <line x1="8.5" y1="6.5" x2="8.5" y2="10" />192              </svg>193            </button>194          </Show>195        </div>196      </div>197198      {/* Scrollable content */}199      <div200        ref={contentScrollRef}201        class="peach-note-editor-scroll"202        style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}203      >204        {/* Desktop toolbar — above content */}205        <Show when={!isNativePlatform()}>206          <NoteToolbar exec={execFn} />207        </Show>208209        <div210          ref={titleRef}211          class="peach-note-title-field"212          contenteditable="true"213          data-placeholder="Title"214          onInput={() => {215            setTitle(titleRef.textContent ?? "");216            scheduleAutosave();217          }}218          onKeyDown={(e) => {219            if (e.key === "Enter") {220              e.preventDefault();221              bodyRef?.focus();222            }223          }}224          aria-label="Note title"225        />226227        <div class="peach-note-hairline" />228229        <div230          ref={bodyRef}231          class="peach-note-body-field"232          contenteditable="true"233          data-placeholder="Begin writing…"234          onInput={() => {235            setBody(bodyRef.innerHTML ?? "");236            scheduleAutosave();237          }}238          onKeyDown={(e) => {239            const mod = e.metaKey || e.ctrlKey;240            if (mod && e.key === "b") { e.preventDefault(); exec("bold"); }241            if (mod && e.key === "i") { e.preventDefault(); exec("italic"); }242            if (mod && e.key === "u") { e.preventDefault(); exec("underline"); }243          }}244          aria-label="Note body"245          aria-multiline="true"246        />247      </div>248249      {/* Saved indicator */}250      <div class="peach-note-saved-indicator" style={{ opacity: saved() ? "1" : "0" }}>251        saved.252      </div>253254      {/* Mobile keyboard toolbar */}255      <Show when={isNativePlatform()}>256        <NoteToolbar257          exec={execFn}258          keyboardVisible={keyboardVisible()}259          onDismissKeyboard={() => {260            titleRef?.blur();261            bodyRef?.blur();262          }}263        />264      </Show>265    </div>266  );267}