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
3 changed files with 131 additions and 47 deletions
Showing only changes of commit be0eb94225 - Show all commits
+82 -44
View File
@@ -14,6 +14,13 @@ import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import {
cardIdAt,
draggingId,
DRAG_THRESHOLD_PX,
endCardDrag,
overId,
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
@@ -22,7 +29,9 @@ const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
(e: "dragend", note: Note): void;
(e: "drop", note: Note): void;
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
* for it, so an id is all it can know without looking the note up again. */
(e: "drop", targetId: string): void;
}>();
const notes = useNotesStore();
const config = useConfigStore();
@@ -43,42 +52,71 @@ const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.
const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Native HTML5 DnD, gated behind an explicit grip handle so
// a plain click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land. ---
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
// plain tap/click/select never starts a drag by accident. `dragging` dims the
// source card; `dragOver` shows where the drop will land.
//
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed;
const grabbing = ref(false); // handle pressed → the card is momentarily draggable
const dragging = ref(false); // this card is the one being dragged
const dragOver = ref(false); // another card is hovering over this one as a drop target
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);
function onDragStart(e: DragEvent) {
dragging.value = true;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", props.note.id); // some browsers need a payload to drag
/** The pointer that owns the current gesture; null when no press is in flight. */
let activePointer: number | null = null;
let startX = 0;
let startY = 0;
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
let moved = false;
function onGripDown(e: PointerEvent) {
if (!canDrag()) return;
activePointer = e.pointerId;
startX = e.clientX;
startY = e.clientY;
moved = false;
// Capture so the gesture keeps reporting to this grip even once the finger has
// travelled onto another card — without it the stream stops at the first
// boundary crossing.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Claims the gesture from the browser's own scroll/text-selection handling.
e.preventDefault();
}
function onGripMove(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
if (!moved) {
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
moved = true;
draggingId.value = props.note.id;
emit("dragstart", props.note);
}
emit("dragstart", props.note);
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
}
function onDragEnd() {
dragging.value = false;
grabbing.value = false;
dragOver.value = false;
emit("dragend", props.note);
function onGripUp(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const target = moved ? overId.value : null;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (target) emit("drop", target);
// Always paired with dragstart, dropped or not, so the parent can clear its own
// tracked source rather than leaking it into the next gesture.
if (wasDragging) emit("dragend", props.note);
}
function onDragOver(e: DragEvent) {
e.preventDefault(); // allow drop
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (!dragging.value) dragOver.value = true; // don't flag the source as its own target
}
function onDragLeave(e: DragEvent) {
// dragleave also fires when moving onto a child; only clear when truly leaving the card.
const to = e.relatedTarget as Node | null;
if (to && root.value?.contains(to)) return;
dragOver.value = false;
}
function onDrop() {
dragOver.value = false;
emit("drop", props.note);
// Fires when the system takes the gesture away (an incoming call, a browser-level
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
function onGripCancel(e: PointerEvent) {
if (activePointer !== e.pointerId) return;
const wasDragging = moved;
activePointer = null;
moved = false;
endCardDrag();
if (wasDragging) emit("dragend", props.note);
}
// When this card becomes the keyboard-focused card, scroll it into view.
@@ -132,25 +170,25 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
: '',
active ? 'ring-2 ring-brand' : '',
]"
:draggable="canDrag() && grabbing"
@dragstart="onDragStart"
@dragend="onDragEnd"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
:data-note-id="note.id"
>
<!-- Drag handle: reorder is gated behind this grip so a normal click/select
never starts a drag. Appears on hover; board views only (reorderable).
Mouse-only affordance native DnD has no keyboard equivalent. -->
<!-- Drag handle: reorder is gated behind this grip so a normal click or tap
never starts a drag. Board views only (reorderable). Still a pointer-only
affordance there is no keyboard equivalent, hence tabindex="-1".
`touch-none` hands the gesture to us instead of the browser's scrolling,
and `hover-reveal` keeps the grip on screen where hovering is impossible
(it is now reachable there, which it was not under native drag-and-drop). -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="pointer-events-none absolute left-1.5 top-1.5 z-10 flex cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
class="hover-reveal pointer-events-none absolute left-1.5 top-1.5 z-10 flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@mousedown="grabbing = true"
@mouseup="grabbing = false"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
+44
View File
@@ -0,0 +1,44 @@
import { ref } from "vue";
/**
* The card reorder gesture, shared across every NoteCard on screen.
*
* Module-level refs on purpose: with native HTML5 drag-and-drop the browser routed
* `dragover`/`drop` to whatever element was under the cursor, so each card learned
* on its own that it was the drop target. Pointer Events don't work that way — once
* a pointer is captured, every move goes to the element that captured it. The card
* being dragged therefore has to hit-test for itself and publish the result
* somewhere the other cards can see, which is what these are.
*
* Why replace native DnD at all: its events never fire from touch. It predates
* touch and was never wired to it, so on a phone reordering did nothing whatsoever.
* Pointer Events cover mouse, touch and stylus on one code path.
*/
/** The card currently being dragged, or null. */
export const draggingId = ref<string | null>(null);
/** The card the pointer is over, if it isn't the dragged one. */
export const overId = ref<string | null>(null);
/** How far the pointer must travel before a press becomes a drag. */
export const DRAG_THRESHOLD_PX = 6;
export function endCardDrag(): void {
draggingId.value = null;
overId.value = null;
}
/**
* The id of the card under this point, ignoring `self`.
*
* Reads the DOM rather than tracking geometry because the board is a CSS masonry
* (`columns-*`), where the visual order isn't derivable from the model order and
* cards reflow as the column count changes. Asking the browser what is actually
* under the finger is the only answer that stays true.
*/
export function cardIdAt(x: number, y: number, self: string): string | null {
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]");
const id = el?.dataset.noteId ?? null;
return id && id !== self ? id : null;
}
+5 -3
View File
@@ -238,13 +238,15 @@ function onDragEnd() {
// Clear the tracked source even when a drag is cancelled (dropped on nothing).
draggingId.value = null;
}
async function onDrop(target: Note) {
// Receives the TARGET'S ID, not the note: the dragged card finds its target by
// hit-testing the DOM, so an id is all it can know without a second lookup.
async function onDrop(targetId: string) {
const from = draggingId.value;
draggingId.value = null;
if (!from || from === target.id) return;
if (!from || from === targetId) return;
const order = notes.items.map((n) => n.id);
const fromIdx = order.indexOf(from);
const toIdx = order.indexOf(target.id);
const toIdx = order.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
order.splice(fromIdx, 1);
order.splice(toIdx, 0, from);