e8965044190f

improve expansion animation: flying title shared element with cubic-bezier(0,0,0,1), staggered row fade, collapse reverse

time
2026-05-13 14:16 UTC · 5 months ago
machine
koh2
parent
273bcb437c64
3 files src/popup/styles/popup.css+39 −0src/popup/views/NotesMode.tsx+116 −49src/popup/views/NotesTOC.tsx+25 −10
src/popup/styles/popup.css+39 −0
@@ -8686,3 +8686,42 @@86868686     padding: 14px 16px;86878687   }86888688 }8689+8690+/* Flying title — shared element during expansion/collapse */8691+.peach-note-expanding-title {8692+  position: absolute;8693+  left: 20px;8694+  right: 20px;8695+  font-family: 'Cormorant Garamond', serif;8696+  font-style: italic;8697+  font-weight: 600;8698+  color: var(--peach-t0);8699+  line-height: 1.2;8700+  white-space: nowrap;8701+  overflow: hidden;8702+  text-overflow: ellipsis;8703+  pointer-events: none;8704+  z-index: 50;8705+  transition:8706+    top 280ms cubic-bezier(0, 0, 0, 1),8707+    font-size 280ms cubic-bezier(0, 0, 0, 1);8708+  will-change: top, font-size;8709+}8710+8711+/* TOC wrapper states */8712+.peach-notes-toc-wrapper.is-toc { opacity: 1; pointer-events: auto; }8713+.peach-notes-toc-wrapper.is-expanding {8714+  opacity: 0;8715+  pointer-events: none;8716+  transition: opacity 150ms ease-out;8717+}8718+.peach-notes-toc-wrapper.is-collapsing {8719+  pointer-events: none;8720+  transition: opacity 200ms 100ms ease-in;8721+  opacity: 1;8722+}8723+8724+/* Expanding target row hides its title text (flying title takes over) */8725+.peach-note-row.is-expanding-target .peach-note-title {8726+  opacity: 0;8727+}

view file

src/popup/views/NotesMode.tsx+116 −49
@@ -11,99 +11,148 @@1111   notes: VaultEntry[];1212 }1313 14-type Phase = "toc" | "expanding" | "editor" | "collapsing";14+type Phase =15+  | "toc"          // TOC fully visible16+  | "expanding"    // rows fading, title flying up17+  | "editor"       // editor fully visible18+  | "collapsing";  // editor fading, title flying down1519 1620 interface ExpansionOrigin {17-  noteId: string;18-  top: number;19-  fontSize: number;21+  top: number;      // px from top of container22+  title: string;2023 }2124 22-let containerEl: HTMLDivElement | null = null;25+// Editor title top offset (below header + spacing)26+const EDITOR_TITLE_TOP = 68;27+const EXPAND_DURATION = 320;28+const COLLAPSE_DURATION = 320;2329 2430 export function NotesMode(props: NotesModeProps) {2531   let modeEl!: HTMLDivElement;26-  let flyingTitleEl: HTMLSpanElement | undefined;32+  let flyingTitleEl: HTMLDivElement | undefined;33+  let tocWrapperEl: HTMLDivElement | undefined;34+  let editorWrapperEl: HTMLDivElement | undefined;2735 2836   const [phase, setPhase] = createSignal<Phase>("toc");2937   const [activeNote, setActiveNote] = createSignal<VaultEntry | null>(null);3038   const [isNewNote, setIsNewNote] = createSignal(false);3139   const [origin, setOrigin] = createSignal<ExpansionOrigin | null>(null);32-  const [editorVisible, setEditorVisible] = createSignal(false);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);3344 3445   onMount(() => {35-    containerEl = modeEl;36-37-    // If an initialNoteId was passed (from global search), open it immediately46+    // Direct-to-editor from global search3847     if (props.initialNoteId) {3948       const note = props.notes.find((n) => n.id === props.initialNoteId);4049       if (note) {41-        // Open without expansion animation — just direct to editor4250         setActiveNote(note);4351         setPhase("editor");44-        setEditorVisible(true);4552       }4653     }47-48-    onCleanup(() => { containerEl = null; });4954   });5055 56+  function vibrate(ms: number) {57+    try { navigator.vibrate?.(ms); } catch { /* unsupported */ }58+  }59+5160   function handleNoteRowTap(note: VaultEntry, rowEl: HTMLElement) {5261     if (phase() !== "toc") return;62+    vibrate(12);63+5364     const rect = rowEl.getBoundingClientRect();5465     const containerRect = modeEl.getBoundingClientRect();55-    setOrigin({56-      noteId: note.id,57-      top: rect.top - containerRect.top,58-      fontSize: 18,59-    });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;69+70+    setOrigin({ top: titleTop, title: note.title || "Untitled" });71+    setFlyingTitle(note.title || "Untitled");72+    setFlyingTop(titleTop);73+    setFlyingFontSize(18);74+    setFlyingVisible(true);6075     setActiveNote(note);6176     setIsNewNote(false);62-    startExpansion();77+78+    // Phase 1: show flying title at row position, fade TOC rows79+    setPhase("expanding");80+81+    // Phase 2: animate title to editor position82+    requestAnimationFrame(() => {83+      requestAnimationFrame(() => {84+        setFlyingTop(EDITOR_TITLE_TOP);85+        setFlyingFontSize(32);86+      });87+    });88+89+    // Phase 3: after animation, show editor and hide flying title90+    setTimeout(() => {91+      setPhase("editor");92+      setFlyingVisible(false);93+    }, EXPAND_DURATION);6394   }6495 6596   function handleNewNote() {6697     if (phase() !== "toc") return;67-    // New note expands from the bottom68-    const containerRect = modeEl.getBoundingClientRect();69-    setOrigin({70-      noteId: "",71-      top: containerRect.height - 60,72-      fontSize: 14,73-    });98+    vibrate(12);7499     setActiveNote(null);75100     setIsNewNote(true);76-    startExpansion();77-  }78-79-  function startExpansion() {101+    setOrigin(null);102+    // Simple expand from bottom — no flying title for new note80103     setPhase("expanding");81-    // RAF to let the flying title render at its start position82104     requestAnimationFrame(() => {83-      requestAnimationFrame(() => {84-        setEditorVisible(true);105+      setTimeout(() => {85106         setPhase("editor");86-      });107+      }, 80);87108     });88109   }89110 90111   function handleEditorBack() {91112     if (phase() !== "editor") return;92-    setPhase("collapsing");93-    setEditorVisible(false);94-    setTimeout(() => {95-      setActiveNote(null);96-      setIsNewNote(false);97-      setOrigin(null);98-      setPhase("toc");99-    }, 350);113+    vibrate(12);114+115+    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");123+124+      requestAnimationFrame(() => {125+        requestAnimationFrame(() => {126+          setFlyingTop(orig.top);127+          setFlyingFontSize(18);128+        });129+      });130+131+      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+    }100147   }101148 102149   function handleSaved(note: VaultEntry) {103-    // If this was a new note, now it has an id — update active note104-    if (activeNote()?.id !== note.id) {150+    if (!activeNote() || activeNote()!.id !== note.id) {105151       setActiveNote(note);106152     }153+    if (flyingTitle() !== note.title) {154+      setFlyingTitle(note.title || "Untitled");155+    }107156     props.onNoteListChanged();108157   }109158 @@ -115,11 +164,12 @@115164 116165   return (117166     <div ref={modeEl} class="peach-notes-mode" data-phase={phase()}>118-      {/* TOC — always mounted, hidden during editor phase */}167+      {/* TOC wrapper */}119168       <div169+        ref={tocWrapperEl}120170         class="peach-notes-toc-wrapper"121171         classList={{122-          "is-visible": phase() === "toc",172+          "is-toc": phase() === "toc",123173           "is-expanding": phase() === "expanding",124174           "is-collapsing": phase() === "collapsing",125175         }}@@ -131,15 +181,32 @@131181           onOpenNote={handleNoteRowTap}132182           onNewNote={handleNewNote}133183           onRefresh={props.onNoteListChanged}184+          expandingNoteId={phase() === "expanding" || phase() === "collapsing" ? (activeNote()?.id ?? null) : null}134185         />135186       </div>136187 137-      {/* Editor — mounted only when a note is active */}188+      {/* 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>202+203+      {/* Editor wrapper */}138204       <Show when={activeNote() !== null || isNewNote()}>139205         <div206+          ref={editorWrapperEl}140207           class="peach-notes-editor-wrapper"141208           classList={{142-            "is-visible": editorVisible(),209+            "is-visible": phase() === "editor",143210             "is-entering": phase() === "expanding",144211             "is-leaving": phase() === "collapsing",145212           }}

view file

src/popup/views/NotesTOC.tsx+25 −10
@@ -10,6 +10,8 @@1010   onOpenNote: (note: VaultEntry, rowEl: HTMLElement) => void;1111   onNewNote: () => void;1212   onRefresh: () => void;13+  /** Note being expanded — other rows should fade away */14+  expandingNoteId?: string | null;1315 }1416 1517 export function NotesTOC(props: NotesTOCProps) {@@ -94,16 +96,29 @@9496           }9597         >9698           <For each={filteredNotes()}>97-            {(note, i) => (98-              <NoteRow99-                note={note}100-                index={i()}101-                searchQuery={searchQuery()}102-                onTap={(n, el) => props.onOpenNote(n, el)}103-                onDelete={handleDelete}104-                onArchive={handleArchive}105-              />106-            )}99+            {(note, i) => {100+              const expanding = () => props.expandingNoteId && props.expandingNoteId !== note.id;101+              const expandingNote = () => props.expandingNoteId === note.id;102+              return (103+                <NoteRow104+                  note={note}105+                  index={i()}106+                  searchQuery={searchQuery()}107+                  onTap={(n, el) => props.onOpenNote(n, el)}108+                  onDelete={handleDelete}109+                  onArchive={handleArchive}110+                  fadingClass={111+                    expanding()112+                      ? (i() < (filteredNotes().findIndex(n => n.id === props.expandingNoteId))113+                          ? "is-expanding-away is-expanding-away-up"114+                          : "is-expanding-away is-expanding-away-down")115+                      : expandingNote()116+                        ? "is-expanding-target"117+                        : undefined118+                  }119+                />120+              );121+            }}107122           </For>108123         </Show>109124 

view file