frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s

Native HTML5 drag-and-drop never fires from touch — the API predates it and was
never wired to it — so on a phone reordering did nothing whatsoever, and the grip
that starts it was hover-gated on top of that. Pointer Events cover mouse, touch
and stylus on one code path instead of two.

The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was
under the cursor, so each card learned on its own that it was the target. A
captured pointer sends every move to the element that captured it, so the dragged
card has to hit-test for itself and publish the result where the other cards can
see it — hence the shared refs in useCardDrag. It reads the DOM via
elementFromPoint rather than tracking geometry because the board is a CSS masonry:
visual order isn't derivable from 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.

Capture is what makes the gesture survive crossing a card boundary; touch-action:
none claims it from the browser's scrolling; a 6px threshold keeps a tap from
becoming a drag; and pointercancel is handled so a system interruption leaves no
half-set state.

The parent contract is unchanged apart from `drop` now carrying the target's ID
rather than its note — the dragged card finds its target in the DOM, so an id is
all it can know without a second lookup. BoardView keeps its own tracking of what
was picked up; that it now duplicates the composable's draggingId is real, and
noted for the DRY pass rather than expanded into here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:39:37 -04:00
co-authored by Claude Opus 5
parent f5837cd985
commit be0eb94225
3 changed files with 131 additions and 47 deletions
+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;
}