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"/>',
|
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"/>',
|
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"/>',
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,52 @@ const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "open", note: Note): void;
|
(e: "open", note: Note): void;
|
||||||
(e: "dragstart", note: Note): void;
|
(e: "dragstart", note: Note): void;
|
||||||
|
(e: "dragend", note: Note): void;
|
||||||
(e: "drop", note: Note): void;
|
(e: "drop", note: Note): void;
|
||||||
}>();
|
}>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
|
||||||
// When this card becomes the keyboard-focused card, scroll it into view.
|
|
||||||
const root = ref<HTMLElement | null>(null);
|
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(
|
watch(
|
||||||
() => props.active,
|
() => props.active,
|
||||||
(a) => {
|
(a) => {
|
||||||
@@ -67,12 +107,37 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
<div
|
<div
|
||||||
ref="root"
|
ref="root"
|
||||||
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
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' : '']"
|
:class="[
|
||||||
:draggable="reorderable && !note.trashed"
|
cardClass(note.color),
|
||||||
@dragstart="emit('dragstart', note)"
|
dragging ? 'opacity-40' : '',
|
||||||
@dragover.prevent
|
dragOver
|
||||||
@drop="emit('drop', note)"
|
? '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
|
<img
|
||||||
v-if="note.attachments.length"
|
v-if="note.attachments.length"
|
||||||
:src="note.attachments[0].url"
|
:src="note.attachments[0].url"
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ const draggingId = ref<string | null>(null);
|
|||||||
function onDragStart(note: Note) {
|
function onDragStart(note: Note) {
|
||||||
draggingId.value = note.id;
|
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) {
|
async function onDrop(target: Note) {
|
||||||
const from = draggingId.value;
|
const from = draggingId.value;
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
@@ -205,6 +209,7 @@ async function onDrop(target: Note) {
|
|||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,6 +227,7 @@ async function onDrop(target: Note) {
|
|||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,6 +243,7 @@ async function onDrop(target: Note) {
|
|||||||
reorderable
|
reorderable
|
||||||
@open="openEditor"
|
@open="openEditor"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
|
@dragend="onDragEnd"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user