mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: kanban-style drag feedback with a real drop gap
The old feedback was a card faded to 0.4 opacity and a 3px line above the target. The fade read as "disabled" rather than "held", and a thin line is a weak signal for where something lands. The dragged card lifts now: full opacity, accent border, raised shadow, a small scale and tilt. Cards it passes slide by the space it vacated, which opens a real gap at the destination. The gap can't disagree with where the card lands, because it is where the card lands. Also fixes something found while building it. targetIndexFor measured live rects, but those rows carry the shift transforms the drag applies, so the midpoints used to pick the target moved as a result of picking it and the choice oscillated. Slot geometry is captured once at drag start, relative to the list so scrolling mid-drag is fine. The displacement rule moves to src/lib/drag.js with tests. Its up/down boundaries are where off-by-ones live.
This commit is contained in:
+57
-6
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { fly, fade } from 'svelte/transition';
|
import { fly, fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
import * as taskOps from './lib/tasks.js';
|
import * as taskOps from './lib/tasks.js';
|
||||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||||
import { shouldHandleUndo } from './lib/shortcuts.js';
|
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||||
|
import { displacement } from './lib/drag.js';
|
||||||
|
|
||||||
const storage = createStorage();
|
const storage = createStorage();
|
||||||
const undoStack = createUndoStack();
|
const undoStack = createUndoStack();
|
||||||
@@ -31,6 +33,8 @@
|
|||||||
let selectedKey = $state(toKey(bootNow));
|
let selectedKey = $state(toKey(bootNow));
|
||||||
let draggedItem = $state(null);
|
let draggedItem = $state(null);
|
||||||
let draggedOverIndex = $state(null);
|
let draggedOverIndex = $state(null);
|
||||||
|
let dragOffsetY = $state(0);
|
||||||
|
let dragShift = $state(0);
|
||||||
let midnightTimer = null;
|
let midnightTimer = null;
|
||||||
|
|
||||||
/** Single write path, so persistence cannot drift out of step with the list.
|
/** Single write path, so persistence cannot drift out of step with the list.
|
||||||
@@ -134,6 +138,25 @@
|
|||||||
drag.active = true;
|
drag.active = true;
|
||||||
draggedItem = drag.index;
|
draggedItem = drag.index;
|
||||||
draggedOverIndex = drag.index;
|
draggedOverIndex = drag.index;
|
||||||
|
dragOffsetY = 0;
|
||||||
|
|
||||||
|
// Snapshot the slot geometry before anything is transformed, and store it
|
||||||
|
// relative to the list so page scrolling during a drag stays harmless.
|
||||||
|
//
|
||||||
|
// Measuring live rects instead would feed the drag back into itself: the
|
||||||
|
// shift transforms move the very midpoints used to pick the target, so the
|
||||||
|
// choice would oscillate between two slots.
|
||||||
|
const list = drag.row.parentElement;
|
||||||
|
const listTop = list.getBoundingClientRect().top;
|
||||||
|
drag.slots = [...list.children].map(child => {
|
||||||
|
const rect = child.getBoundingClientRect();
|
||||||
|
return { top: rect.top - listTop, height: rect.height };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The space a lifted card vacates is its own height plus one gap, so that
|
||||||
|
// is exactly how far the cards it displaces need to travel.
|
||||||
|
const gap = parseFloat(getComputedStyle(list).rowGap) || 0;
|
||||||
|
dragShift = drag.slots[drag.index].height + gap;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
drag.row.setPointerCapture(drag.pointerId);
|
drag.row.setPointerCapture(drag.pointerId);
|
||||||
@@ -161,15 +184,40 @@
|
|||||||
drag = null;
|
drag = null;
|
||||||
draggedItem = null;
|
draggedItem = null;
|
||||||
draggedOverIndex = null;
|
draggedOverIndex = null;
|
||||||
|
dragOffsetY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-row styling during a drag.
|
||||||
|
*
|
||||||
|
* The lifted card stays fully opaque and tracks the pointer with no
|
||||||
|
* transition, so it feels attached to the finger or cursor. Every card
|
||||||
|
* between its origin and its destination slides by exactly the space the
|
||||||
|
* lifted card vacated, which opens a real gap where it will land — the gap
|
||||||
|
* is the drop indicator, so it is always accurate by construction.
|
||||||
|
*/
|
||||||
|
function rowStyle(index) {
|
||||||
|
if (draggedItem === null) return '';
|
||||||
|
|
||||||
|
if (index === draggedItem) {
|
||||||
|
return `transform: translateY(${dragOffsetY}px) scale(1.02) rotate(-0.4deg); transition: none;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift = displacement(index, draggedItem, draggedOverIndex, dragShift);
|
||||||
|
return `transform: translateY(${shift}px);`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetIndexFor(clientY) {
|
function targetIndexFor(clientY) {
|
||||||
const rows = [...drag.row.parentElement.children];
|
// Re-read the list top each time so scrolling mid-drag is accounted for,
|
||||||
for (let i = 0; i < rows.length; i += 1) {
|
// then compare against the untransformed slots captured at drag start.
|
||||||
const rect = rows[i].getBoundingClientRect();
|
const listTop = drag.row.parentElement.getBoundingClientRect().top;
|
||||||
if (clientY < rect.top + rect.height / 2) return i;
|
const y = clientY - listTop;
|
||||||
|
|
||||||
|
for (let i = 0; i < drag.slots.length; i += 1) {
|
||||||
|
const slot = drag.slots[i];
|
||||||
|
if (y < slot.top + slot.height / 2) return i;
|
||||||
}
|
}
|
||||||
return rows.length - 1;
|
return drag.slots.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event, index) {
|
function handlePointerDown(event, index) {
|
||||||
@@ -206,6 +254,7 @@
|
|||||||
if (!drag?.active) return;
|
if (!drag?.active) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dragOffsetY = event.clientY - drag.startY;
|
||||||
draggedOverIndex = targetIndexFor(event.clientY);
|
draggedOverIndex = targetIndexFor(event.clientY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +473,9 @@
|
|||||||
data-task-id={task.id}
|
data-task-id={task.id}
|
||||||
class:completed={task.completed}
|
class:completed={task.completed}
|
||||||
class:dragging={draggedItem === index}
|
class:dragging={draggedItem === index}
|
||||||
class:drag-over={draggedOverIndex === index && draggedItem !== index}
|
class:drag-settling={draggedItem !== null && draggedItem !== index}
|
||||||
|
style={rowStyle(index)}
|
||||||
|
animate:flip={{ duration: 220, easing: cubicOut }}
|
||||||
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
||||||
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
||||||
onpointerdown={(e) => handlePointerDown(e, index)}
|
onpointerdown={(e) => handlePointerDown(e, index)}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* How far a row should slide, in pixels, while another row is being dragged
|
||||||
|
* over the list.
|
||||||
|
*
|
||||||
|
* A lifted card vacates exactly its own height plus one gap, so every row it
|
||||||
|
* passes travels by that same amount — `shift` — and everything else stays
|
||||||
|
* put. The result is a real gap at the destination, which doubles as the drop
|
||||||
|
* indicator and is therefore always accurate.
|
||||||
|
*
|
||||||
|
* Negative moves a row up the list, positive moves it down.
|
||||||
|
*/
|
||||||
|
export function displacement(index, fromIndex, toIndex, shift) {
|
||||||
|
if (fromIndex === null || toIndex === null) return 0
|
||||||
|
if (index === fromIndex) return 0
|
||||||
|
|
||||||
|
if (fromIndex < toIndex && index > fromIndex && index <= toIndex) return -shift
|
||||||
|
if (fromIndex > toIndex && index >= toIndex && index < fromIndex) return shift
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { displacement } from './drag.js'
|
||||||
|
|
||||||
|
const SHIFT = 70
|
||||||
|
|
||||||
|
describe('displacement', () => {
|
||||||
|
it('is zero when nothing is being dragged', () => {
|
||||||
|
expect(displacement(2, null, null, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is zero for the dragged row itself', () => {
|
||||||
|
expect(displacement(1, 1, 3, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is zero when the row would not move', () => {
|
||||||
|
expect(displacement(2, 2, 2, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dragging downward', () => {
|
||||||
|
// Row 0 heading to slot 2: rows 1 and 2 slide up into the vacated space,
|
||||||
|
// row 3 stays put.
|
||||||
|
it('slides passed rows up', () => {
|
||||||
|
expect(displacement(1, 0, 2, SHIFT)).toBe(-SHIFT)
|
||||||
|
expect(displacement(2, 0, 2, SHIFT)).toBe(-SHIFT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves rows beyond the destination alone', () => {
|
||||||
|
expect(displacement(3, 0, 2, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves rows above the origin alone', () => {
|
||||||
|
expect(displacement(0, 1, 3, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the destination row itself', () => {
|
||||||
|
expect(displacement(3, 0, 3, SHIFT)).toBe(-SHIFT)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dragging upward', () => {
|
||||||
|
// Row 3 heading to slot 1: rows 1 and 2 slide down.
|
||||||
|
it('slides passed rows down', () => {
|
||||||
|
expect(displacement(1, 3, 1, SHIFT)).toBe(SHIFT)
|
||||||
|
expect(displacement(2, 3, 1, SHIFT)).toBe(SHIFT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves rows above the destination alone', () => {
|
||||||
|
expect(displacement(0, 3, 1, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves rows below the origin alone', () => {
|
||||||
|
expect(displacement(4, 3, 1, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the destination row itself', () => {
|
||||||
|
expect(displacement(0, 2, 0, SHIFT)).toBe(SHIFT)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves exactly one row for a single-step drag', () => {
|
||||||
|
expect(displacement(1, 0, 1, SHIFT)).toBe(-SHIFT)
|
||||||
|
expect(displacement(2, 0, 1, SHIFT)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displaces every row between the ends of a full-list drag', () => {
|
||||||
|
const shifts = [0, 1, 2, 3, 4].map(i => displacement(i, 0, 4, SHIFT))
|
||||||
|
expect(shifts).toEqual([0, -SHIFT, -SHIFT, -SHIFT, -SHIFT])
|
||||||
|
})
|
||||||
|
})
|
||||||
+25
-14
@@ -273,34 +273,45 @@ body {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The lifted card. It stays fully opaque and sits above the list — the old
|
||||||
|
ghosted 0.4 opacity read as "this card is disabled" rather than "you are
|
||||||
|
holding this card". The gap that opens beneath it is the drop indicator. */
|
||||||
.task-item.dragging {
|
.task-item.dragging {
|
||||||
opacity: 0.4;
|
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
/* Only while a drag is actually in flight, so an ordinary touch on a row
|
z-index: 20;
|
||||||
still scrolls the page. */
|
box-shadow:
|
||||||
|
0 12px 28px rgba(0, 0, 0, 0.18),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
/* Only while a drag is in flight, so an ordinary touch still scrolls. */
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item.drag-over::before {
|
html.dark .task-item.dragging {
|
||||||
content: '';
|
box-shadow:
|
||||||
position: absolute;
|
0 12px 28px rgba(0, 0, 0, 0.5),
|
||||||
top: -6px;
|
0 4px 8px rgba(0, 0, 0, 0.35);
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background-color: var(--accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 0 8px rgba(96, 122, 251, 0.4);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cards sliding aside to open the gap. */
|
||||||
|
.task-item.drag-settling {
|
||||||
|
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A drag is in progress: suppress hover lift on the cards being displaced,
|
||||||
|
which would otherwise fight the transform that opens the gap. */
|
||||||
.task-item:hover {
|
.task-item:hover {
|
||||||
background-color: var(--hover);
|
background-color: var(--hover);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-items:has(.dragging) .task-item:not(.dragging):hover {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.task-item.completed {
|
.task-item.completed {
|
||||||
background-color: var(--completed-bg);
|
background-color: var(--completed-bg);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|||||||
Reference in New Issue
Block a user