m4: drag-to-reorder polish — grip handle + drop affordances
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 26s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-21 08:33:29 -04:00
co-authored by Claude Opus 4.8
parent 3e9b6095dd
commit 5e225075f3
3 changed files with 79 additions and 6 deletions
+1
View File
@@ -18,6 +18,7 @@ const paths: Record<string, string> = {
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
};
</script>
+71 -6
View File
@@ -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<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. ---
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))
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="[cardClass(note.color), active ? 'ring-2 ring-brand' : '']"
:draggable="reorderable && !note.trashed"
@dragstart="emit('dragstart', note)"
@dragover.prevent
@drop="emit('drop', note)"
:class="[
cardClass(note.color),
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
: '',
active ? 'ring-2 ring-brand' : '',
]"
:draggable="canDrag() && grabbing"
@dragstart="onDragStart"
@dragend="onDragEnd"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<!-- 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. -->
<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"
title="Drag to reorder"
aria-label="Drag to reorder"
@mousedown="grabbing = true"
@mouseup="grabbing = false"
>
<Icon name="grip" />
</button>
<img
v-if="note.attachments.length"
:src="note.attachments[0].url"