// 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; 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( items.map((i) => [tokenOf(i), i]), ); const placed = new Set(); 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('.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(); }