diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 50a74af..9a46202 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -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(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" > - + diff --git a/frontend/src/composables/useCardDrag.ts b/frontend/src/composables/useCardDrag.ts new file mode 100644 index 0000000..3f08593 --- /dev/null +++ b/frontend/src/composables/useCardDrag.ts @@ -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(null); + +/** The card the pointer is over, if it isn't the dragged one. */ +export const overId = ref(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("[data-note-id]"); + const id = el?.dataset.noteId ?? null; + return id && id !== self ? id : null; +} diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index cff184b..79941f4 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -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);