diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 1a28f96..ece6d59 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -10,6 +10,8 @@ import LabelPicker from "./LabelPicker.vue"; import LinkPreview from "./LinkPreview.vue"; import NoteChecklist from "./NoteChecklist.vue"; import { fromLocalInput, toLocalInput } from "../notes/datetime"; +import { takeMorphOrigin } from "../composables/useEditorMorph"; +import { prefersReducedMotion } from "../composables/useReducedMotion"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors"; @@ -174,10 +176,49 @@ async function commitAndContinue(): Promise { await nextTick(); bodyInput.value?.focus(); } +// ---- open/close animation (M7) ---- +// +// Owned here rather than by each of the five views that render this component: the +// leave has to play BEFORE the host unmounts us, so the component has to control +// its own visibility and tell the host afterwards. +// Starts TRUE, with `appear` driving the entry animation. Starting false and +// flipping it on mount would be the obvious shape, but the panel lives inside this +// v-if — it wouldn't exist yet to measure. +const visible = ref(true); +const panel = ref(null); +/** Matches .editor-leave-active in style.css. */ +const LEAVE_MS = 140; + +onMounted(() => { + const from = takeMorphOrigin(); + // Grow from the card that was clicked. Set as a transform-origin rather than + // animating between two rects — see useEditorMorph for why. No origin (compose, + // or a card that scrolled out of view) simply grows from its own centre. + if (!from || !panel.value) return; + // offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has + // already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN + // panel and the origin would land a few pixels off. Offsets are layout geometry + // and ignore transforms. The offset parent is the inset-0 backdrop, so these are + // effectively viewport coordinates — which is what the captured point is in. + panel.value.style.transformOrigin = + `${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`; +}); + +/** Play the leave, then let the host unmount us. */ +async function finish(): Promise { + if (prefersReducedMotion()) { + emit("close"); + return; + } + visible.value = false; + await new Promise((resolve) => setTimeout(resolve, LEAVE_MS)); + emit("close"); +} + // Persist (create in compose, save in edit) and close the editor. async function close(): Promise { await flush(); - emit("close"); + await finish(); } // Esc / click-away DISMISS. A brand-new, not-yet-persisted note is DISCARDED — so an // accidental keystroke or type-to-compose never litters the board. To keep a new note, @@ -185,7 +226,7 @@ async function close(): Promise { // compose already persisted by a rich action, closes normally (saving its text). async function dismiss(): Promise { if (isCreate.value) { - emit("close"); + await finish(); return; } await close(); @@ -489,7 +530,7 @@ async function onPaste(e: ClipboardEvent) { // ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ---- async function act(fn: () => Promise) { await fn(); - emit("close"); + await finish(); } // ---- version history (modal edit only) ---- @@ -535,14 +576,21 @@ function revPreview(rev: NoteRevision): string { diff --git a/frontend/src/composables/useEditorMorph.ts b/frontend/src/composables/useEditorMorph.ts new file mode 100644 index 0000000..6c4407e --- /dev/null +++ b/frontend/src/composables/useEditorMorph.ts @@ -0,0 +1,43 @@ +/** + * Where the editor should appear to grow from. + * + * The brief is continuity of the same object: opening a note shouldn't read as a + * modal cutting in over the board, it should read as THAT card becoming the editor. + * The cheap, robust way to say that is to scale the panel from the point the card + * occupies rather than from its own centre — the eye reads the origin and infers + * the rest. + * + * Deliberately NOT a true shared-element morph. Scaling the panel by the real + * card→panel ratio distorts the text inside it on the way, and a card is often a + * third of the modal's size, so an honest ratio reads as a zoom rather than as a + * transition. The task sanctions "a good-enough scale/position tween"; this is that. + * + * A viewport POINT, not a rect, for the same reason — nothing downstream needs the + * card's dimensions, and a point survives the card being filtered away or reflowed + * while the editor is open. + */ +let origin: { x: number; y: number } | null = null; + +/** Record the on-screen centre of the card being opened, if it is on screen. */ +export function captureMorphOrigin(noteId: string): void { + const el = document.querySelector(`[data-note-id="${CSS.escape(noteId)}"]`); + if (!el) { + origin = null; + return; + } + const rect = el.getBoundingClientRect(); + origin = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +} + +/** + * Read the origin and clear it. + * + * Consume-on-read so a compose (which has no card, and so captures nothing) can't + * inherit the origin of whatever note was edited before it and grow from a + * seemingly arbitrary corner. + */ +export function takeMorphOrigin(): { x: number; y: number } | null { + const taken = origin; + origin = null; + return taken; +} diff --git a/frontend/src/composables/useNoteEditor.ts b/frontend/src/composables/useNoteEditor.ts index 30f6879..e050315 100644 --- a/frontend/src/composables/useNoteEditor.ts +++ b/frontend/src/composables/useNoteEditor.ts @@ -1,5 +1,6 @@ import { ref } from "vue"; import { useNotesStore, type Note } from "../stores/notes"; +import { captureMorphOrigin } from "./useEditorMorph"; // Shared controller for hosting the NoteEditor modal. Every view that opens the // editor used to re-declare the same editing/open/close/navigate glue; this is the @@ -15,6 +16,10 @@ export function useNoteEditor(options: { onClose?: () => void; list?: () => Note const editing = ref(null); function open(note: Note): void { + // Every view opens the editor through here, so this is the one place that knows + // which card the user actually clicked — and therefore the only place the + // grow-from-the-card animation can be told where to start (M7). + captureMorphOrigin(note.id); editing.value = note; } diff --git a/frontend/src/style.css b/frontend/src/style.css index c93a54d..46d9060 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -61,6 +61,72 @@ body { } } +/* Board motion (M7). Defined once here rather than three times in BoardView's + * markup, because "how the board moves" is one idea even though the pinned, other + * and non-board grids are three TransitionGroups. + * + * Vue's TransitionGroup does the FLIP itself: it measures each card before and + * after the list changes and transitions the difference away. All we supply is the + * curve. Deliberately fast and small — the brief is continuity, so a card should + * read as having MOVED rather than as having performed. + * + * Leavers are NOT taken out of flow with `position: absolute`, which is the usual + * TransitionGroup trick: this masonry is CSS multi-column, and an absolutely + * positioned child escapes its column to the container's origin — it would fly + * across the board on its way out. Keeping them in flow costs a small settle when + * the element is finally removed, so the leave is the shortest of the three. + * + * Reduced motion needs no special case here: the global guard above already + * collapses every duration, so this degrades to an instant cut. */ +.board-move { + transition: transform 220ms cubic-bezier(0.2, 0, 0, 1); +} +.board-enter-active { + transition: + opacity 180ms ease-out, + transform 180ms cubic-bezier(0.2, 0, 0, 1); +} +.board-leave-active { + transition: opacity 120ms ease-in; +} +.board-enter-from { + opacity: 0; + /* Barely a scale — enough to read as arriving, not as zooming. */ + transform: scale(0.98); +} +.board-leave-to { + opacity: 0; +} + +/* Editor open/close (M7). The backdrop only fades; the panel scales from the point + * the card occupied, which is what carries "this card became the editor" without + * the distortion a true card→panel scale would put through the text. + * + * Enter is slower than leave on purpose: arriving wants to be noticed as a + * connection, leaving just wants to be out of the way. LEAVE_MS in NoteEditor.vue + * must stay in step with the leave duration here — it waits that long before + * telling its host to unmount it. */ +.editor-enter-active { + transition: opacity 200ms ease-out; +} +.editor-leave-active { + transition: opacity 140ms ease-in; +} +.editor-enter-from, +.editor-leave-to { + opacity: 0; +} +.editor-enter-active .editor-panel { + transition: transform 200ms cubic-bezier(0.2, 0, 0, 1); +} +.editor-leave-active .editor-panel { + transition: transform 140ms ease-in; +} +.editor-enter-from .editor-panel, +.editor-leave-to .editor-panel { + transform: scale(0.94); +} + @layer components { .icon-btn { @apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index 79941f4..e56982e 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -300,7 +300,7 @@ async function onDrop(targetId: string) { -
+ -
+