initial commit
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
// 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.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();
|
||||
}
|
||||
Reference in New Issue
Block a user