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
+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);