m4: drag-to-reorder polish — grip handle + drop affordances
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user