NotesMode.tsx

6.5 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../../shared/bridge";4import { NotesTOC } from "./NotesTOC";5import { NotesEditor } from "./NotesEditor";67interface NotesModeProps {8  onBack: () => void;9  onNoteListChanged: () => void;10  initialNoteId?: string;11  notes: VaultEntry[];12}1314type Phase =15  | "toc"          // TOC fully visible16  | "expanding"    // rows fading, title flying up17  | "editor"       // editor fully visible18  | "collapsing";  // editor fading, title flying down1920interface ExpansionOrigin {21  top: number;      // px from top of container22  title: string;23}2425// Editor title top offset (below header + spacing)26const EDITOR_TITLE_TOP = 68;27const EXPAND_DURATION = 320;28const COLLAPSE_DURATION = 320;2930export function NotesMode(props: NotesModeProps) {31  let modeEl!: HTMLDivElement;32  let flyingTitleEl: HTMLDivElement | undefined;33  let tocWrapperEl: HTMLDivElement | undefined;34  let editorWrapperEl: HTMLDivElement | undefined;3536  const [phase, setPhase] = createSignal<Phase>("toc");37  const [activeNote, setActiveNote] = createSignal<VaultEntry | null>(null);38  const [isNewNote, setIsNewNote] = createSignal(false);39  const [origin, setOrigin] = createSignal<ExpansionOrigin | null>(null);40  const [flyingTitle, setFlyingTitle] = createSignal<string>("");41  const [flyingTop, setFlyingTop] = createSignal<number>(0);42  const [flyingFontSize, setFlyingFontSize] = createSignal<number>(18);43  const [flyingVisible, setFlyingVisible] = createSignal(false);4445  onMount(() => {46    // Direct-to-editor from global search47    if (props.initialNoteId) {48      const note = props.notes.find((n) => n.id === props.initialNoteId);49      if (note) {50        setActiveNote(note);51        setPhase("editor");52      }53    }54  });5556  function vibrate(ms: number) {57    try { navigator.vibrate?.(ms); } catch { /* unsupported */ }58  }5960  function handleNoteRowTap(note: VaultEntry, rowEl: HTMLElement) {61    if (phase() !== "toc") return;62    vibrate(12);6364    const rect = rowEl.getBoundingClientRect();65    const containerRect = modeEl.getBoundingClientRect();66    const rowTop = rect.top - containerRect.top;67    // The title text starts about 50% vertically within the row68    const titleTop = rowTop + (rect.height - 18) / 2;6970    setOrigin({ top: titleTop, title: note.title || "Untitled" });71    setFlyingTitle(note.title || "Untitled");72    setFlyingTop(titleTop);73    setFlyingFontSize(18);74    setFlyingVisible(true);75    setActiveNote(note);76    setIsNewNote(false);7778    // Phase 1: show flying title at row position, fade TOC rows79    setPhase("expanding");8081    // Phase 2: animate title to editor position82    requestAnimationFrame(() => {83      requestAnimationFrame(() => {84        setFlyingTop(EDITOR_TITLE_TOP);85        setFlyingFontSize(32);86      });87    });8889    // Phase 3: after animation, show editor and hide flying title90    setTimeout(() => {91      setPhase("editor");92      setFlyingVisible(false);93    }, EXPAND_DURATION);94  }9596  function handleNewNote() {97    if (phase() !== "toc") return;98    vibrate(12);99    setActiveNote(null);100    setIsNewNote(true);101    setOrigin(null);102    // Simple expand from bottom — no flying title for new note103    setPhase("expanding");104    requestAnimationFrame(() => {105      setTimeout(() => {106        setPhase("editor");107      }, 80);108    });109  }110111  function handleEditorBack() {112    if (phase() !== "editor") return;113    vibrate(12);114115    const orig = origin();116    if (orig) {117      // Animate title back to its row118      setFlyingTitle(activeNote()?.title || "Untitled");119      setFlyingTop(EDITOR_TITLE_TOP);120      setFlyingFontSize(32);121      setFlyingVisible(true);122      setPhase("collapsing");123124      requestAnimationFrame(() => {125        requestAnimationFrame(() => {126          setFlyingTop(orig.top);127          setFlyingFontSize(18);128        });129      });130131      setTimeout(() => {132        setFlyingVisible(false);133        setActiveNote(null);134        setIsNewNote(false);135        setOrigin(null);136        setPhase("toc");137      }, COLLAPSE_DURATION);138    } else {139      // No animation (new note or direct open)140      setPhase("collapsing");141      setTimeout(() => {142        setActiveNote(null);143        setIsNewNote(false);144        setPhase("toc");145      }, 200);146    }147  }148149  function handleSaved(note: VaultEntry) {150    if (!activeNote() || activeNote()!.id !== note.id) {151      setActiveNote(note);152    }153    if (flyingTitle() !== note.title) {154      setFlyingTitle(note.title || "Untitled");155    }156    props.onNoteListChanged();157  }158159  function handleDeleteNote(note: VaultEntry) {160    void sendMessage({ type: "DELETE_NOTE", payload: { id: note.id } });161    props.onNoteListChanged();162    handleEditorBack();163  }164165  return (166    <div ref={modeEl} class="peach-notes-mode" data-phase={phase()}>167      {/* TOC wrapper */}168      <div169        ref={tocWrapperEl}170        class="peach-notes-toc-wrapper"171        classList={{172          "is-toc": phase() === "toc",173          "is-expanding": phase() === "expanding",174          "is-collapsing": phase() === "collapsing",175        }}176        aria-hidden={phase() !== "toc"}177      >178        <NotesTOC179          notes={props.notes}180          onBack={props.onBack}181          onOpenNote={handleNoteRowTap}182          onNewNote={handleNewNote}183          onRefresh={props.onNoteListChanged}184          expandingNoteId={phase() === "expanding" || phase() === "collapsing" ? (activeNote()?.id ?? null) : null}185        />186      </div>187188      {/* Flying title — shared element between TOC and editor during animation */}189      <Show when={flyingVisible()}>190        <div191          ref={flyingTitleEl}192          class="peach-note-expanding-title"193          style={{194            top: `${flyingTop()}px`,195            "font-size": `${flyingFontSize()}px`,196          }}197          aria-hidden="true"198        >199          {flyingTitle()}200        </div>201      </Show>202203      {/* Editor wrapper */}204      <Show when={activeNote() !== null || isNewNote()}>205        <div206          ref={editorWrapperEl}207          class="peach-notes-editor-wrapper"208          classList={{209            "is-visible": phase() === "editor",210            "is-entering": phase() === "expanding",211            "is-leaving": phase() === "collapsing",212          }}213        >214          <NotesEditor215            note={activeNote()}216            onBack={handleEditorBack}217            onSaved={handleSaved}218            onDelete={handleDeleteNote}219            autoFocusTitle={isNewNote()}220          />221        </div>222      </Show>223    </div>224  );225}