From 5e225075f3b3b8ef7c09064f186742397f85fbc8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Jul 2026 08:33:29 -0400 Subject: [PATCH] =?UTF-8?q?m4:=20drag-to-reorder=20polish=20=E2=80=94=20gr?= =?UTF-8?q?ip=20handle=20+=20drop=20affordances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the masonry drag interaction (task 1869) so reordering reads as intentional instead of an accidental, feedbackless jump: - explicit grip handle (top-left, hover-revealed, board views only) gates dragging — a plain click or text-select no longer starts a drag - source card dims while dragging; the card under the pointer shows a brand ring + slight lift, so the drop position is clear before release - proper move cursor via dataTransfer effectAllowed/dropEffect - dragleave uses a relatedTarget guard so the target ring doesn't flicker over child elements - clear drag state on dragend (BoardView clears the tracked source even when a drag is cancelled off-target) - add a "grip" icon to the shared Icon set Native HTML5 DnD stays library-free; it remains a desktop/mouse affordance (touch reorder is the M5 Android client's domain). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/components/Icon.vue | 1 + frontend/src/components/NoteCard.vue | 77 +++++++++++++++++++++++++--- frontend/src/views/BoardView.vue | 7 +++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 19faf9e..1271f78 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -18,6 +18,7 @@ const paths: Record = { image: '', graph: '', bell: '', + grip: '', }; diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 49ff9a2..45ffd1d 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -19,12 +19,52 @@ const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean const emit = defineEmits<{ (e: "open", note: Note): void; (e: "dragstart", note: Note): void; + (e: "dragend", note: Note): void; (e: "drop", note: Note): void; }>(); const notes = useNotesStore(); -// When this card becomes the keyboard-focused card, scroll it into view. 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. --- +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 + +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 + } + emit("dragstart", props.note); +} +function onDragEnd() { + dragging.value = false; + grabbing.value = false; + dragOver.value = false; + 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); +} + +// When this card becomes the keyboard-focused card, scroll it into view. watch( () => props.active, (a) => { @@ -67,12 +107,37 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
+ + + (null); function onDragStart(note: Note) { draggingId.value = note.id; } +function onDragEnd() { + // Clear the tracked source even when a drag is cancelled (dropped on nothing). + draggingId.value = null; +} async function onDrop(target: Note) { const from = draggingId.value; draggingId.value = null; @@ -205,6 +209,7 @@ async function onDrop(target: Note) { reorderable @open="openEditor" @dragstart="onDragStart" + @dragend="onDragEnd" @drop="onDrop" />
@@ -222,6 +227,7 @@ async function onDrop(target: Note) { reorderable @open="openEditor" @dragstart="onDragStart" + @dragend="onDragEnd" @drop="onDrop" /> @@ -237,6 +243,7 @@ async function onDrop(target: Note) { reorderable @open="openEditor" @dragstart="onDragStart" + @dragend="onDragEnd" @drop="onDrop" />