M12 — the Android client, end to end #2
@@ -10,6 +10,8 @@ import LabelPicker from "./LabelPicker.vue";
|
|||||||
import LinkPreview from "./LinkPreview.vue";
|
import LinkPreview from "./LinkPreview.vue";
|
||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
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 type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
@@ -174,10 +176,49 @@ async function commitAndContinue(): Promise<void> {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
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.
|
// Persist (create in compose, save in edit) and close the editor.
|
||||||
async function close(): Promise<void> {
|
async function close(): Promise<void> {
|
||||||
await flush();
|
await flush();
|
||||||
emit("close");
|
await finish();
|
||||||
}
|
}
|
||||||
// Esc / click-away DISMISS. A brand-new, not-yet-persisted note is DISCARDED — so an
|
// 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,
|
// 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).
|
// compose already persisted by a rich action, closes normally (saving its text).
|
||||||
async function dismiss(): Promise<void> {
|
async function dismiss(): Promise<void> {
|
||||||
if (isCreate.value) {
|
if (isCreate.value) {
|
||||||
emit("close");
|
await finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await close();
|
await close();
|
||||||
@@ -489,7 +530,7 @@ async function onPaste(e: ClipboardEvent) {
|
|||||||
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
|
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
|
||||||
async function act(fn: () => Promise<void>) {
|
async function act(fn: () => Promise<void>) {
|
||||||
await fn();
|
await fn();
|
||||||
emit("close");
|
await finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- version history (modal edit only) ----
|
// ---- version history (modal edit only) ----
|
||||||
@@ -535,14 +576,21 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<!-- `appear` because we mount already-open: the host renders us with v-if, so the
|
||||||
ref="root"
|
enter has to fire on the first frame rather than on a later state change. -->
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
<Transition name="editor" appear>
|
||||||
@mousedown.self="onBackdropMousedown"
|
|
||||||
>
|
|
||||||
<!-- One editor card for BOTH compose (empty note) and edit — a single surface. -->
|
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@keydown.esc.stop="onEsc"
|
@keydown.esc.stop="onEsc"
|
||||||
@@ -899,5 +947,6 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</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 { ref } from "vue";
|
||||||
import { useNotesStore, type Note } from "../stores/notes";
|
import { useNotesStore, type Note } from "../stores/notes";
|
||||||
|
import { captureMorphOrigin } from "./useEditorMorph";
|
||||||
|
|
||||||
// Shared controller for hosting the NoteEditor modal. Every view that opens the
|
// 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
|
// 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);
|
const editing = ref<Note | null>(null);
|
||||||
|
|
||||||
function open(note: Note): void {
|
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;
|
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 {
|
@layer components {
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
|
@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">
|
<template v-if="isMainBoard">
|
||||||
<section v-if="pinnedNotes.length">
|
<section v-if="pinnedNotes.length">
|
||||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
<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
|
<NoteCard
|
||||||
v-for="(n, i) in pinnedNotes"
|
v-for="(n, i) in pinnedNotes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
@@ -312,13 +312,13 @@ async function onDrop(targetId: string) {
|
|||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
<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">
|
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
Others
|
Others
|
||||||
</h2>
|
</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
|
<NoteCard
|
||||||
v-for="(n, i) in otherNotes"
|
v-for="(n, i) in otherNotes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
@@ -330,11 +330,17 @@ async function onDrop(targetId: string) {
|
|||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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
|
<NoteCard
|
||||||
v-for="(n, i) in notes.items"
|
v-for="(n, i) in notes.items"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
@@ -346,7 +352,7 @@ async function onDrop(targetId: string) {
|
|||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</TransitionGroup>
|
||||||
</template>
|
</template>
|
||||||
</AsyncState>
|
</AsyncState>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user