M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
5 changed files with 186 additions and 17 deletions
Showing only changes of commit 18a58fb5da - Show all commits
+60 -11
View File
@@ -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;
}
+66
View File
@@ -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
+12 -6
View File
@@ -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>