Files
www/src/scripts/toolset-drag.ts
T
2026-07-20 12:33:19 -05:00

322 lines
11 KiB
TypeScript

// Toolset drag-to-rearrange
// Custom pointer-events implementation with FLIP reorder animation,
// velocity-based tilt on grab, spring-y drop, localStorage persistence.
const STORAGE_KEY = 'rummage:toolset-order';
const DRAG_THRESHOLD = 5; // px before drag begins (so clicks don't trigger it)
const FLIP_DURATION = 280;
const DROP_DURATION = 360;
const TILT_GAIN = 1.8;
const TILT_CLAMP = 14;
const TILT_DECAY = 0.32;
const SCALE_ACTIVE = 1.06;
type ChipMap = Map<HTMLLIElement, DOMRect>;
function tokenOf(el: Element): string {
return (el as HTMLElement).dataset.skill || (el.textContent || '').trim();
}
function readReducedMotion(): boolean {
return (
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function snapshotPositions(list: HTMLUListElement): ChipMap {
const map: ChipMap = new Map();
for (const child of list.children) {
map.set(child as HTMLLIElement, child.getBoundingClientRect());
}
return map;
}
function flipSiblings(
list: HTMLUListElement,
prev: ChipMap,
exclude: HTMLLIElement,
duration: number,
): void {
for (const c of list.children) {
const child = c as HTMLLIElement;
if (child === exclude) continue;
const before = prev.get(child);
if (!before) continue;
const after = child.getBoundingClientRect();
const dx = before.left - after.left;
const dy = before.top - after.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
child.classList.add('is-flipping');
child.style.transition = 'none';
child.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
// Force a synchronous reflow so the no-transition transform commits
// BEFORE we install the transition. Reading offsetWidth flushes
// pending layout; this is more reliable than requestAnimationFrame,
// which can stall in non-rendering contexts.
void child.offsetWidth;
child.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
child.style.transform = '';
window.setTimeout(() => {
child.style.transition = '';
child.style.transform = '';
child.classList.remove('is-flipping');
}, duration + 40);
}
}
function restoreOrder(list: HTMLUListElement, items: HTMLLIElement[]): void {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return;
const order = JSON.parse(stored);
if (!Array.isArray(order)) return;
const byToken = new Map<string, HTMLLIElement>(
items.map((i) => [tokenOf(i), i]),
);
const placed = new Set<HTMLLIElement>();
for (const token of order) {
if (typeof token !== 'string') continue;
const el = byToken.get(token);
if (el) {
list.appendChild(el);
placed.add(el);
}
}
for (const i of items) {
if (!placed.has(i)) list.appendChild(i);
}
} catch {
/* ignore */
}
}
function persistOrder(list: HTMLUListElement): void {
try {
const order = Array.from(list.children).map((c) => tokenOf(c));
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch {
/* ignore */
}
}
function attachChip(chip: HTMLLIElement, list: HTMLUListElement): void {
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
let prevX = 0;
let tilt = 0;
let dragging = false;
const reduce = readReducedMotion();
function applyDragTransform(x: number, y: number): void {
// Measure the chip's natural (untransformed) rect to compute the
// delta needed to put it under the pointer.
chip.style.transition = 'none';
chip.style.transform = '';
const natural = chip.getBoundingClientRect();
const desiredLeft = x - offsetX;
const desiredTop = y - offsetY;
const dx = desiredLeft - natural.left;
const dy = desiredTop - natural.top;
const target = Math.max(
-TILT_CLAMP,
Math.min(TILT_CLAMP, (x - prevX) * TILT_GAIN),
);
tilt = tilt + (target - tilt) * TILT_DECAY;
prevX = x;
const scale = reduce ? 1 : SCALE_ACTIVE;
const rot = reduce ? 0 : tilt;
chip.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale}) rotate(${rot}deg)`;
}
function maybeSwap(x: number, y: number): void {
let target: HTMLLIElement | null = null;
let insertBefore = false;
for (const c of list.children) {
const sib = c as HTMLLIElement;
if (sib === chip) continue;
// Skip chips currently mid-FLIP — their visual rect is in
// transit and would cause oscillating re-swaps.
if (sib.classList.contains('is-flipping')) continue;
const r = sib.getBoundingClientRect();
if (y < r.top || y > r.bottom) continue;
if (x >= r.left && x <= r.right) {
target = sib;
insertBefore = x < r.left + r.width / 2;
break;
}
}
if (!target) return;
const all = Array.from(list.children);
const sibIdx = all.indexOf(target);
const myIdx = all.indexOf(chip);
let newIdx = insertBefore ? sibIdx : sibIdx + 1;
if (newIdx > myIdx) newIdx -= 1;
if (newIdx === myIdx) return;
const prev = snapshotPositions(list);
if (insertBefore) {
list.insertBefore(chip, target);
} else {
list.insertBefore(chip, target.nextSibling);
}
flipSiblings(list, prev, chip, reduce ? 60 : FLIP_DURATION);
}
function beginDrag(): void {
dragging = true;
tilt = 0;
chip.classList.add('is-dragging');
list.classList.add('is-dragging-list');
document.body.classList.add('is-toolset-dragging');
}
function dropAnimate(): void {
// FLIP the chip from its current visible position to its natural rest
// position, animating out scale, rotate, color, and shadow together.
const visible = chip.getBoundingClientRect();
const heldTilt = tilt;
chip.style.transition = 'none';
chip.style.transform = '';
const natural = chip.getBoundingClientRect();
const dx = visible.left - natural.left;
const dy = visible.top - natural.top;
const scale = reduce ? 1 : SCALE_ACTIVE;
const rot = reduce ? 0 : heldTilt;
chip.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${scale}) rotate(${rot}deg)`;
const dur = reduce ? 100 : DROP_DURATION;
const easing = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Synchronous force-reflow commits the no-transition mid-drag
// transform before we install the new transition. More reliable
// than RAF in non-rendering contexts.
void chip.offsetWidth;
chip.style.transition =
`transform ${dur}ms ${easing}, ` +
`background-color ${dur}ms ${easing}, ` +
`color ${dur}ms ${easing}, ` +
`box-shadow ${dur}ms ${easing}`;
chip.style.transform = '';
// Drop the dragging classes at the same instant the transform
// starts animating, so color/shadow fade together with position.
chip.classList.remove('is-dragging');
list.classList.remove('is-dragging-list');
document.body.classList.remove('is-toolset-dragging');
window.setTimeout(() => {
chip.style.transition = '';
chip.style.transform = '';
// Defensive: ensure classes are cleared even if the synchronous
// path above was interrupted by an earlier exception.
chip.classList.remove('is-dragging');
list.classList.remove('is-dragging-list');
document.body.classList.remove('is-toolset-dragging');
}, dur + 40);
}
function teardownEvents(): void {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
}
function onPointerDown(e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (pointerId !== null) return;
pointerId = e.pointerId;
const r = chip.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
offsetX = e.clientX - r.left;
offsetY = e.clientY - r.top;
prevX = e.clientX;
tilt = 0;
dragging = false;
try {
chip.setPointerCapture(e.pointerId);
} catch {
/* some browsers throw if already captured */
}
// Listen at the window level so events still fire even if pointer
// capture is lost (e.g. mid-reorder, mid-frame DOM moves) or the
// cursor leaves the chip's visual bounds.
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
}
function onPointerMove(e: PointerEvent): void {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!dragging) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
beginDrag();
}
e.preventDefault();
// CRITICAL ORDER: swap first (which may change the chip's natural
// rect), THEN apply transform against the new natural so the chip
// stays locked to the pointer with no visual jump.
maybeSwap(e.clientX, e.clientY);
applyDragTransform(e.clientX, e.clientY);
}
function onPointerUp(e: PointerEvent): void {
if (e.pointerId !== pointerId) return;
try {
chip.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
teardownEvents();
const wasDragging = dragging;
pointerId = null;
dragging = false;
if (!wasDragging) return;
dropAnimate();
persistOrder(list);
}
chip.addEventListener('pointerdown', onPointerDown);
}
function init(): void {
const list = document.querySelector<HTMLUListElement>('.toolset__list');
if (!list) return;
const items = Array.from(list.children) as HTMLLIElement[];
if (items.length === 0) return;
restoreOrder(list, items);
// After restoreOrder the children are re-arranged but the list reference
// stays. Re-collect references for the attach loop.
Array.from(list.children).forEach((c) =>
attachChip(c as HTMLLIElement, list),
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}