mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
fix: fluid drop instead of the whole list lurching
Letting go of a card was abrupt and pulled its neighbours with it. Frame capture showed non-dragged rows moving about 68px after release, still going 13 frames later. Two things owned transform on the same elements: manual shift transforms for the gap, and animate:flip for the reorder. On drop, flip measured a "before" rect that already had a manual offset in it, then the order changed and the offsets cleared in the same update, so it animated toward a position that never existed. One owner each now. The list reorders as the pointer crosses each boundary rather than on release, so flip moves the displaced cards on its own, one swap at a time, and the swaps look right while you're dragging. Only the lifted card is positioned by hand, with flip off for it so it stays under the pointer. By release the order is already final, and nothing moves but that card settling. After the change, every non-dragged row moves 0px on release and the dropped card eases 4px into place. Slot geometry is replaced with offsetTop and offsetHeight, layout values that ignore transforms, so the target can't feed back into itself and can't go stale. Removes src/lib/drag.js and its tests. The rule they covered doesn't exist in this design any more.
This commit is contained in:
+73
-57
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onMount, tick, flushSync } from 'svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -11,7 +11,6 @@
|
||||
import * as taskOps from './lib/tasks.js';
|
||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||
import { displacement } from './lib/drag.js';
|
||||
|
||||
const storage = createStorage();
|
||||
const undoStack = createUndoStack();
|
||||
@@ -31,10 +30,10 @@
|
||||
let isInitialized = false;
|
||||
let todayKey = $state(toKey(bootNow));
|
||||
let selectedKey = $state(toKey(bootNow));
|
||||
let draggedItem = $state(null);
|
||||
let draggedOverIndex = $state(null);
|
||||
let draggedId = $state(null);
|
||||
let settlingId = $state(null);
|
||||
let dragOffsetY = $state(0);
|
||||
let dragShift = $state(0);
|
||||
let settleTimer = null;
|
||||
let midnightTimer = null;
|
||||
|
||||
/** Single write path, so persistence cannot drift out of step with the list.
|
||||
@@ -126,6 +125,7 @@
|
||||
// and lets the page scroll normally.
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
const LONG_PRESS_MS = 400;
|
||||
const SETTLE_MS = 240;
|
||||
|
||||
let drag = null;
|
||||
|
||||
@@ -136,27 +136,18 @@
|
||||
function beginDrag() {
|
||||
if (!drag || drag.active) return;
|
||||
drag.active = true;
|
||||
draggedItem = drag.index;
|
||||
draggedOverIndex = drag.index;
|
||||
draggedId = drag.id;
|
||||
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 };
|
||||
});
|
||||
// Layout position of the row, which offsetTop reports free of any
|
||||
// transform. It is the fixed reference the pointer offset is measured
|
||||
// against, and it stays correct as the row changes slots mid-drag.
|
||||
drag.originTop = drag.row.offsetTop;
|
||||
|
||||
// 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;
|
||||
// A settling card from a previous drop must not keep its transition, or
|
||||
// it would fight the new gesture.
|
||||
clearTimeout(settleTimer);
|
||||
settlingId = null;
|
||||
|
||||
try {
|
||||
drag.row.setPointerCapture(drag.pointerId);
|
||||
@@ -182,42 +173,39 @@
|
||||
}
|
||||
|
||||
drag = null;
|
||||
draggedItem = null;
|
||||
draggedOverIndex = null;
|
||||
draggedId = null;
|
||||
dragOffsetY = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row styling during a drag.
|
||||
* Inline styling for the one row the pointer is carrying.
|
||||
*
|
||||
* 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.
|
||||
* Only the lifted card is styled here. Everything else is moved by
|
||||
* animate:flip, so exactly one mechanism owns `transform` per element —
|
||||
* when both did, flip measured a "before" rect that already included a
|
||||
* manual offset, computed a bogus delta, and slid the whole list on drop.
|
||||
*
|
||||
* The lifted card takes no transition, so it stays welded to the pointer.
|
||||
* On release it keeps its transform but gains one via the settling class,
|
||||
* which eases it into its slot instead of snapping.
|
||||
*/
|
||||
function rowStyle(index) {
|
||||
if (draggedItem === null) return '';
|
||||
|
||||
if (index === draggedItem) {
|
||||
function rowStyle(taskId) {
|
||||
if (taskId !== draggedId) return '';
|
||||
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) {
|
||||
// Re-read the list top each time so scrolling mid-drag is accounted for,
|
||||
// then compare against the untransformed slots captured at drag start.
|
||||
const listTop = drag.row.parentElement.getBoundingClientRect().top;
|
||||
const y = clientY - listTop;
|
||||
const list = drag.row.parentElement;
|
||||
// offsetTop and offsetHeight are layout values, unaffected by the
|
||||
// transforms in play, so the target cannot feed back into itself.
|
||||
const y = clientY - list.getBoundingClientRect().top;
|
||||
const children = [...list.children];
|
||||
|
||||
for (let i = 0; i < drag.slots.length; i += 1) {
|
||||
const slot = drag.slots[i];
|
||||
if (y < slot.top + slot.height / 2) return i;
|
||||
for (let i = 0; i < children.length; i += 1) {
|
||||
const child = children[i];
|
||||
if (y < child.offsetTop + child.offsetHeight / 2) return i;
|
||||
}
|
||||
return drag.slots.length - 1;
|
||||
return children.length - 1;
|
||||
}
|
||||
|
||||
function handlePointerDown(event, index) {
|
||||
@@ -226,7 +214,8 @@
|
||||
if (event.target.closest('button')) return;
|
||||
|
||||
drag = {
|
||||
index,
|
||||
id: tasks[index].id,
|
||||
originIndex: index,
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
startY: event.clientY,
|
||||
@@ -254,18 +243,45 @@
|
||||
if (!drag?.active) return;
|
||||
}
|
||||
|
||||
dragOffsetY = event.clientY - drag.startY;
|
||||
draggedOverIndex = targetIndexFor(event.clientY);
|
||||
// Reorder as the pointer crosses each boundary rather than waiting for the
|
||||
// drop. animate:flip then eases the displaced card across, one swap at a
|
||||
// time, and by release the list is already in its final order — so letting
|
||||
// go changes nothing but the lifted card settling into place.
|
||||
const from = tasks.findIndex(task => task.id === drag.id);
|
||||
const to = targetIndexFor(event.clientY);
|
||||
|
||||
if (from !== -1 && to !== from) {
|
||||
tasks = taskOps.reorderTask(tasks, from, to);
|
||||
// Apply now, so the row's new offsetTop is readable on the next line.
|
||||
flushSync();
|
||||
}
|
||||
|
||||
// Measured against layout, so the card stays under the pointer even though
|
||||
// the slot beneath it just changed.
|
||||
dragOffsetY = (event.clientY - drag.startY) - (drag.row.offsetTop - drag.originTop);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!drag) return;
|
||||
|
||||
if (drag.active && draggedOverIndex !== null && draggedOverIndex !== drag.index) {
|
||||
setTasks(taskOps.reorderTask(tasks, drag.index, draggedOverIndex));
|
||||
}
|
||||
const wasActive = drag.active;
|
||||
const landedAt = tasks.findIndex(task => task.id === drag.id);
|
||||
const settling = drag.id;
|
||||
|
||||
// The order is already correct — it was applied swap by swap during the
|
||||
// drag — so this only writes it through to storage.
|
||||
if (wasActive && landedAt !== drag.originIndex) setTasks(tasks);
|
||||
|
||||
endDrag();
|
||||
|
||||
if (!wasActive) return;
|
||||
|
||||
// Clearing the inline transform while the settling class supplies a
|
||||
// transition eases the card into its slot. Nothing else on the list moves,
|
||||
// because nothing else changed.
|
||||
settlingId = settling;
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(() => { settlingId = null; }, SETTLE_MS);
|
||||
}
|
||||
|
||||
async function moveTask(taskId, offset) {
|
||||
@@ -472,10 +488,10 @@
|
||||
class="task-item"
|
||||
data-task-id={task.id}
|
||||
class:completed={task.completed}
|
||||
class:dragging={draggedItem === index}
|
||||
class:drag-settling={draggedItem !== null && draggedItem !== index}
|
||||
style={rowStyle(index)}
|
||||
animate:flip={{ duration: 220, easing: cubicOut }}
|
||||
class:dragging={task.id === draggedId}
|
||||
class:settling={task.id === settlingId}
|
||||
style={rowStyle(task.id)}
|
||||
animate:flip={{ duration: task.id === draggedId ? 0 : 240, 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 }}
|
||||
onpointerdown={(e) => handlePointerDown(e, index)}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
+10
-5
@@ -248,6 +248,7 @@ body {
|
||||
|
||||
/* A real list, so screen readers announce item counts and position. */
|
||||
.task-items {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -294,13 +295,17 @@ html.dark .task-item.dragging {
|
||||
0 4px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Cards sliding aside to open the gap. */
|
||||
.task-item.drag-settling {
|
||||
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
|
||||
/* The released card easing back into its slot. It keeps the raised z-index so
|
||||
it stays above its neighbours on the way down, and supplies the transition
|
||||
that the lifted state deliberately withheld. */
|
||||
.task-item.settling {
|
||||
z-index: 20;
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.2, 0, 0, 1),
|
||||
box-shadow 240ms ease,
|
||||
border-color 240ms ease;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background-color: var(--hover);
|
||||
transform: translateY(-2px);
|
||||
|
||||
Reference in New Issue
Block a user