From 08a2e7406704cb3e717463ff652d789465f2df5e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Sun, 26 Jul 2026 22:47:04 -0400 Subject: [PATCH] Fix bug in drag and drop. --- web/src/components/ArrangePhase.tsx | 51 ++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index b6045b1..c4d5355 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -33,6 +33,9 @@ export function ArrangePhase({ view, you, send }: Props) { // One entry per card row, in current play order, so a pointer drag can find // which slot the finger/cursor is currently over by hit-testing rects. const cardRefs = useRef<(HTMLDivElement | null)[]>([]) + // Holds the latest `onDragMove` so the window listener (attached once per + // drag) always calls the fresh closure — see the drag effect below. + const moveRef = useRef<(clientY: number) => void>(() => {}) const locked = you.ready // The arrow buttons reorder `order` and the cards glide to their new slot. @@ -65,10 +68,16 @@ export function ArrangePhase({ view, you, send }: Props) { } // Drag-to-reorder via pointer events, driven by the grip handle. Native HTML5 - // drag doesn't fire on touch, so we use pointer events (mouse + touch alike) - // and capture the pointer on the handle so the drag keeps tracking even when - // the finger/cursor leaves the handle. `touch-action: none` on the handle - // stops the browser from scrolling the page mid-drag. + // drag doesn't fire on touch, so we use pointer events (mouse + touch alike). + // `touch-action: none` on the handle stops the browser from scrolling the + // page mid-drag. + // + // We do NOT rely on `setPointerCapture` here: the captured handle lives inside + // the keyed row that reorders mid-drag, and React moves that DOM node + // (`insertBefore`) as the list changes — which makes browsers drop the active + // pointer capture, freezing the drag until the user re-grabs. Instead we + // listen on `window` for the drag's lifetime (see the effect below), so + // reordering the rows can never interrupt the gesture. // // The lifted card follows the finger while the list reorders live beneath it. // The translate is applied to an inner wrapper, not the `.arrange-card` box @@ -86,10 +95,9 @@ export function ArrangePhase({ view, you, send }: Props) { setDragTranslate(0) setDropping(false) setDragging(true) - e.currentTarget.setPointerCapture(e.pointerId) } - function onDragMove(e: React.PointerEvent) { + function onDragMove(clientY: number) { if (dragIndex.current === null) return // Find the slot the pointer has crossed into by hit-testing the *other* // rows' midpoints (the lifted row's own box stays in its natural slot). @@ -100,8 +108,8 @@ export function ArrangePhase({ view, you, send }: Props) { if (!el) continue const r = el.getBoundingClientRect() const mid = r.top + r.height / 2 - if (j < dragIndex.current && e.clientY < mid) target = Math.min(target, j) - else if (j > dragIndex.current && e.clientY > mid) target = Math.max(target, j) + if (j < dragIndex.current && clientY < mid) target = Math.min(target, j) + else if (j > dragIndex.current && clientY > mid) target = Math.max(target, j) } if (target !== dragIndex.current) { move(dragIndex.current, target) @@ -110,8 +118,11 @@ export function ArrangePhase({ view, you, send }: Props) { // Follow the finger from where the drag began, minus how far the card's own // slot has since shifted — keeping it pinned under the finger. const shift = (dragIndex.current - startIndex.current) * stride.current - setDragTranslate(e.clientY - grabY.current - shift) + setDragTranslate(clientY - grabY.current - shift) } + // Keep the window listener pointed at the current-render closure (fresh + // `order`/refs) without re-attaching the listener on every reorder. + moveRef.current = onDragMove function endDrag() { if (dragIndex.current === null) return @@ -126,6 +137,25 @@ export function ArrangePhase({ view, you, send }: Props) { }, 180) } + // While a drag is active, track the pointer on `window` so the gesture keeps + // running even as the list reorders under the finger (the handle's own + // pointer capture would be lost when React moves its row). Attached once per + // drag; `moveRef` keeps it calling the latest closure. + useEffect(() => { + if (!dragging) return + const onMove = (e: PointerEvent) => moveRef.current(e.clientY) + const onUp = () => endDrag() + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dragging]) + // Which pets do the foods land on? Foods buff the next pet later in play order // (i.e. the next pet below them in the list). Compute buff per card for preview. const bonuses = new Map() @@ -196,9 +226,6 @@ export function ArrangePhase({ view, you, send }: Props) { aria-label="drag to reorder" title="Drag to reorder" onPointerDown={(e) => startDrag(e, i)} - onPointerMove={onDragMove} - onPointerUp={endDrag} - onPointerCancel={endDrag} > ⠿