M12 — the Android client, end to end #2
@@ -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<void> {
|
||||
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<HTMLElement | null>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// compose already persisted by a rich action, closes normally (saving its text).
|
||||
async function dismiss(): Promise<void> {
|
||||
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<void>) {
|
||||
await fn();
|
||||
emit("close");
|
||||
await finish();
|
||||
}
|
||||
|
||||
// ---- version history (modal edit only) ----
|
||||
@@ -535,14 +576,21 @@ function revPreview(rev: NoteRevision): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
||||
@mousedown.self="onBackdropMousedown"
|
||||
>
|
||||
<!-- One editor card for BOTH compose (empty note) and edit — a single surface. -->
|
||||
<!-- `appear` because we mount already-open: the host renders us with v-if, so the
|
||||
enter has to fire on the first frame rather than on a later state change. -->
|
||||
<Transition name="editor" appear>
|
||||
<div
|
||||
class="w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
v-if="visible"
|
||||
ref="root"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
||||
@mousedown.self="onBackdropMousedown"
|
||||
>
|
||||
<!-- One editor card for BOTH compose (empty note) and edit — a single surface.
|
||||
`editor-panel` is the animation's handle: the backdrop only fades, while
|
||||
this scales from the card that opened it (see useEditorMorph). -->
|
||||
<div
|
||||
ref="panel"
|
||||
class="editor-panel w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@keydown.esc.stop="onEsc"
|
||||
@@ -899,5 +947,6 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -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<HTMLElement>(`[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;
|
||||
}
|
||||
@@ -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<Note | null>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -300,7 +300,7 @@ async function onDrop(targetId: string) {
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in pinnedNotes"
|
||||
:key="n.id"
|
||||
@@ -312,13 +312,13 @@ async function onDrop(targetId: string) {
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<TransitionGroup tag="div" name="board" appear class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard
|
||||
v-for="(n, i) in otherNotes"
|
||||
:key="n.id"
|
||||
@@ -330,11 +330,17 @@ async function onDrop(targetId: string) {
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<TransitionGroup
|
||||
v-else
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="(n, i) in notes.items"
|
||||
:key="n.id"
|
||||
@@ -346,7 +352,7 @@ async function onDrop(targetId: string) {
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user