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
↓ peach-browser-e8965044.tar.gzatlas at this save
files changed3 files
src/popup/styles/popup.css+39 −0src/popup/views/NotesMode.tsx+116 −49src/popup/views/NotesTOC.tsx+25 −10src/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+}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 }}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