/* Drag to reorder — the playlist page's rows and the player sheet's queue, one gesture for both. A phone should drag better than a desktop does: the desktop has a grip and a mouse (src/views/Playlist.svelte), the phone has a finger that is also how the list is scrolled, so most of this file is about telling those two apart and then getting out of the scroller's way. The contract, and it is strictly additive: hold 320ms the row lifts (and the phone taps back) move the rows below make room; let go and it lands release, unmoved the row's sheet opens, exactly as it always did That last line is load-bearing. A queue row has no ⋯ — `oncontextmenu` is its only door to Play now, Move to next and Remove from queue — so a long press that merely started a drag would strand that sheet. A Svelte action on the CONTAINER, in util.js's `drag` idiom: armed on pointerdown, live only once the hold has landed, every pointer capture in a try/catch (a synthetic pointer id is not capturable, and that must not be fatal — it is what the walk drives this with). use:reorder={{ enabled, rowSel, skipSel, scrollSel, onLift(from), onDrop(from, to), onHold(index), onEnd(dropped) }} `enabled` may be a value or a FUNCTION, and the playlist passes a function on purpose: an action's `update` is deprecated in Svelte 5, so an option object that has to change identity to be heard is one this cannot lean on. Asked at the press instead, the answer is always the current one. */ /** ms of stillness before a press becomes a lift: past a tap, and short of * Android's own 500ms contextmenu, so the gesture is ours by then */ const HOLD = 320; /** px of movement before that: the list is being scrolled, let go of it */ const SLOP = 8; /** px from the scroller's edge that pulls the list along */ const EDGE = 64; /** px per frame at the very edge */ const MAXSTEP = 14; export function reorder(el, opts) { let o = opts; let armed = null; /* { id, x, y, row } — pressed, the hold not yet landed */ let live = null; /* the lift: the rows, their rects, the slot we are over */ let holdTimer = 0; /* a released drag would otherwise fire the row's own click and play the track, and Android's long press would open the row's sheet in the middle of one. Both are swallowed at the container in the CAPTURE phase — which beats the row's handler and Svelte's delegated root listener alike — and only while a lift is live. */ let suppressUntil = 0; const contentY = (clientY) => (live.scroller ? clientY - live.box.top + live.scroller.scrollTop : clientY); /** asked at the press, at the lift and at the drop, never cached */ const ok = () => { const v = o && o.enabled; return typeof v === "function" ? !!v() : !!v; }; function attach() { /* on the window, not the row: the gesture has to survive a capture that never happened */ window.addEventListener("pointermove", move); window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", finish); } function detach() { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", finish); window.removeEventListener("pointercancel", finish); } /* `touch-action` is latched at touchstart and cannot be flipped mid-gesture, and touch-action: none on a row would cost the list the flick that scrolls it — most of the page. So the scroll is refused here instead: after 320ms of stillness the browser has not started one yet, and eating the first touchmove after the lift cancels it. Registered imperatively because it must be NON-passive; Svelte registers touch handlers passively, and a passive listener may not preventDefault. */ const eatTouch = (e) => { if (live && e.cancelable) e.preventDefault(); }; const guard = (e) => { if (Date.now() > suppressUntil) return; e.preventDefault(); e.stopPropagation(); }; function down(e) { if (live || armed || !ok()) return; if (e.button > 0) return; const t = e.target; if (!t || !t.closest) return; if (o.skipSel && t.closest(o.skipSel)) return; /* the row's own ⋯ */ const row = t.closest(o.rowSel); if (!row || !el.contains(row)) return; armed = { id: e.pointerId, x: e.clientX, y: e.clientY, row }; clearTimeout(holdTimer); holdTimer = setTimeout(lift, HOLD); attach(); } function lift() { holdTimer = 0; if (!armed || live || !ok()) { if (armed) { armed = null; detach(); } return; } const a = armed; const rows = [].slice.call(el.querySelectorAll(o.rowSel)); const from = rows.indexOf(a.row); if (from < 0 || rows.length < 2) { armed = null; detach(); return; } const scroller = o.scrollSel ? el.closest(o.scrollSel) : null; const box = scroller ? scroller.getBoundingClientRect() : { top: 0 }; const top0 = scroller ? scroller.scrollTop : 0; /* every row measured once, in the scroller's CONTENT space. That is what makes the edge pull work: as scrollTop grows the snapshot stays true and the delta grows on its own, so the row keeps up with the finger. Measured rather than one assumed row height — the desktop's `rowH` — because the queue face puts an album separator between rows. */ const snap = rows.map((r) => { const b = r.getBoundingClientRect(); const top = b.top - box.top + top0; return { el: r, top, mid: top + b.height / 2 }; }); live = { id: a.id, row: a.row, from, to: from, snap, scroller, box, grabY: a.y - box.top + top0, y: a.y, y0: a.y, x0: a.x, moved: false, raf: 0, shift: snap.map(() => 0), }; armed = null; el.classList.add("dragging"); a.row.classList.add("lifted"); try { a.row.setPointerCapture(a.id); } catch { /* synthetic ids are not capturable */ } window.addEventListener("touchmove", eatTouch, { passive: false }); el.addEventListener("click", guard, true); el.addEventListener("contextmenu", guard, true); suppressUntil = Date.now() + 400; try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* no motor, or none allowed */ } if (o.onLift) o.onLift(from); paint(); } function paint() { if (!live) return; live.raf = 0; const s = live.scroller; if (s) { const b = s.getBoundingClientRect(); live.box = b; /* the edges pull: near the top or the bottom of the scroller the list comes to the finger, rather than the finger having to leave the screen to reach the end of a long playlist */ const above = live.y - b.top; const below = b.bottom - live.y; let step = 0; if (above < EDGE) step = -Math.ceil(((EDGE - Math.max(0, above)) / EDGE) * MAXSTEP); else if (below < EDGE) step = Math.ceil(((EDGE - Math.max(0, below)) / EDGE) * MAXSTEP); if (step) { const was = s.scrollTop; s.scrollTop = Math.max(0, Math.min(s.scrollHeight - s.clientHeight, was + step)); /* the list actually moved: keep the frames coming even while the finger is still */ if (s.scrollTop !== was) live.raf = requestAnimationFrame(paint); } } const dy = contentY(live.y) - live.grabY; live.row.style.transform = "translateY(" + Math.round(dy) + "px)"; /* where the lifted row's own middle now sits decides the slot */ const mid = live.snap[live.from].mid + dy; let to = live.from; for (let i = 0; i < live.snap.length; i++) { if (i === live.from) continue; if (i < live.from && mid < live.snap[i].mid) to = Math.min(to, i); else if (i > live.from && mid > live.snap[i].mid) to = Math.max(to, i); } /* each displaced row takes its neighbour's slot — the exact difference of their measured tops, not a row height. Only the rows whose shift CHANGED are written to, so a frame that moves nothing costs nothing. */ for (let i = 0; i < live.snap.length; i++) { if (i === live.from) continue; let sh = 0; if (to > live.from && i > live.from && i <= to) sh = live.snap[i - 1].top - live.snap[i].top; else if (to < live.from && i >= to && i < live.from) sh = live.snap[i + 1].top - live.snap[i].top; if (sh !== live.shift[i]) { live.shift[i] = sh; live.snap[i].el.style.transform = sh ? "translateY(" + Math.round(sh) + "px)" : ""; } } live.to = to; } function move(e) { if (live) { if (e.pointerId !== live.id) return; live.y = e.clientY; /* the same slop the arming uses, not a hair's breadth: a finger held still for 320ms still wanders a few px, and a press "released without moving" has to open the row's sheet the way it always did — on the queue there is no other door to it. */ if (Math.abs(e.clientY - live.y0) > SLOP || Math.abs(e.clientX - live.x0) > SLOP) live.moved = true; suppressUntil = Date.now() + 400; if (e.cancelable) e.preventDefault(); /* one frame, however many moves arrive in it */ if (!live.raf) live.raf = requestAnimationFrame(paint); return; } if (!armed || e.pointerId !== armed.id) return; if (Math.abs(e.clientY - armed.y) > SLOP || Math.abs(e.clientX - armed.x) > SLOP) { /* it moved before the hold landed: that was a scroll */ clearTimeout(holdTimer); holdTimer = 0; armed = null; detach(); } } function finish(e) { if (e && live && e.pointerId !== live.id) return; if (e && !live && armed && e.pointerId !== armed.id) return; clearTimeout(holdTimer); holdTimer = 0; detach(); armed = null; const was = live; live = null; if (!was) return; if (was.raf) cancelAnimationFrame(was.raf); window.removeEventListener("touchmove", eatTouch); /* every mark cleared BEFORE the mutation. The playlist is keyed by id so a drop MOVES nodes; the queue is keyed by index so it re-fills them — either way an inline transform left on one is a bug you can see. */ el.classList.remove("dragging"); was.row.classList.remove("lifted"); was.row.style.transform = ""; for (let i = 0; i < was.snap.length; i++) was.snap[i].el.style.transform = ""; try { was.row.releasePointerCapture(was.id); } catch { /* never captured */ } suppressUntil = Date.now() + 350; setTimeout(unguard, 420); /* asked once more here: a filter typed with a finger still down would make the landing index a lie about the saved order */ /* a cancelled pointer is not a drop: Android takes the touch away when it decides the gesture is its own, and landing the row then would be a reorder nobody asked for. Unmoved it is still the long press it looks like, so the sheet opens either way. */ const cancelled = !!e && e.type === "pointercancel"; const dropped = !cancelled && was.to !== was.from && ok(); if (dropped) { if (o.onDrop) o.onDrop(was.from, was.to); } else if (!was.moved) { if (o.onHold) o.onHold(was.from); } if (o.onEnd) o.onEnd(dropped); } function unguard() { if (live) return; /* another lift has taken the guards over */ el.removeEventListener("click", guard, true); el.removeEventListener("contextmenu", guard, true); } el.addEventListener("pointerdown", down); return { /* deprecated in Svelte 5 and not relied on — `enabled` is asked for through a function — but honoured if it ever does arrive */ update(next) { o = next; }, destroy() { clearTimeout(holdTimer); /* torn down with a row still in hand — a navigation that happened under the finger. onEnd is what releases the page's deferred re-read, so it has to be told, or the playlist would stop re-reading its rows for the rest of the session. */ if (live) { if (live.raf) cancelAnimationFrame(live.raf); live = null; if (o && o.onEnd) { try { o.onEnd(false); } catch { /* the page is leaving anyway */ } } } armed = null; detach(); window.removeEventListener("touchmove", eatTouch); el.removeEventListener("pointerdown", down); el.removeEventListener("click", guard, true); el.removeEventListener("contextmenu", guard, true); }, }; }