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
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user