Improve drag and drop.

This commit is contained in:
Greyson Parrelli
2026-07-25 22:14:05 -04:00
parent e6c895d6b4
commit 0bf9712c5e
2 changed files with 198 additions and 50 deletions
+128 -41
View File
@@ -18,6 +18,21 @@ export function ArrangePhase({ view, you, send }: Props) {
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
const dragIndex = useRef<number | null>(null)
const [dragging, setDragging] = useState(false)
// The card currently under the finger/cursor: it lifts and follows the
// pointer (`dragTranslate`), while `dropping` glides it into its final slot
// when released.
const [dragId, setDragId] = useState<string | null>(null)
const [dragTranslate, setDragTranslate] = useState(0)
const [dropping, setDropping] = useState(false)
// Drag bookkeeping: where the drag began, the pointer Y at grab time, and the
// slot-to-slot pixel stride (rows are uniform), so the lifted card can track
// the finger even as the list reorders beneath it.
const startIndex = useRef(0)
const grabY = useRef(0)
const stride = useRef(0)
// 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)[]>([])
const locked = you.ready
// The arrow buttons reorder `order` and the cards glide to their new slot.
@@ -49,6 +64,68 @@ 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.
//
// 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
// the FLIP animator measures, so the two never fight.
function startDrag(e: React.PointerEvent<HTMLElement>, i: number) {
e.preventDefault()
dragIndex.current = i
startIndex.current = i
grabY.current = e.clientY
// Row stride = distance between two adjacent slots; rows are uniform.
const a = cardRefs.current[0]?.getBoundingClientRect()
const b = cardRefs.current[1]?.getBoundingClientRect()
stride.current = a && b ? b.top - a.top : (a?.height ?? 0) + 12
setDragId(order[i].id)
setDragTranslate(0)
setDropping(false)
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}
function onDragMove(e: React.PointerEvent<HTMLElement>) {
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).
let target = dragIndex.current
for (let j = 0; j < order.length; j++) {
if (j === dragIndex.current) continue
const el = cardRefs.current[j]
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 (target !== dragIndex.current) {
move(dragIndex.current, target)
dragIndex.current = target
}
// 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)
}
function endDrag() {
if (dragIndex.current === null) return
dragIndex.current = null
// Glide the lifted card down into its resting slot, then clear drag state.
setDropping(true)
setDragTranslate(0)
window.setTimeout(() => {
setDragId(null)
setDropping(false)
setDragging(false)
}, 180)
}
// 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<string, number>()
@@ -96,49 +173,59 @@ export function ArrangePhase({ view, you, send }: Props) {
<div className="arrange-col" ref={arrangeAnim.containerRef}>
<div className="arrange-marker"> first to fight</div>
{order.map((c, i) => (
<div
key={c.id}
className="arrange-card"
data-flip-key={c.id}
draggable
onDragStart={() => {
dragIndex.current = i
setDragging(true)
}}
onDragOver={(e) => {
e.preventDefault()
if (dragIndex.current !== null && dragIndex.current !== i) {
move(dragIndex.current, i)
dragIndex.current = i
}
}}
onDragEnd={() => {
dragIndex.current = null
setDragging(false)
}}
>
<div className="arrange-arrows">
<button
className="btn btn-ghost btn-sm"
disabled={i === 0}
onClick={() => move(i, i - 1)}
aria-label="move up (earlier)"
{order.map((c, i) => {
const isDragged = c.id === dragId
return (
<div
key={c.id}
className={`arrange-card${isDragged ? ' is-dragging' : ''}`}
data-flip-key={c.id}
ref={(el) => {
cardRefs.current[i] = el
}}
>
<div
className={`arrange-drag${isDragged && dropping ? ' is-dropping' : ''}`}
style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined}
>
</button>
<button
className="btn btn-ghost btn-sm"
disabled={i === order.length - 1}
onClick={() => move(i, i + 1)}
aria-label="move down (later)"
>
</button>
<div className="arrange-controls">
<div
className="arrange-handle"
role="button"
tabIndex={-1}
aria-label="drag to reorder"
title="Drag to reorder"
onPointerDown={(e) => startDrag(e, i)}
onPointerMove={onDragMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
</div>
<div className="arrange-arrows">
<button
className="btn btn-ghost btn-sm"
disabled={i === 0}
onClick={() => move(i, i - 1)}
aria-label="move up (earlier)"
>
</button>
<button
className="btn btn-ghost btn-sm"
disabled={i === order.length - 1}
onClick={() => move(i, i + 1)}
aria-label="move down (later)"
>
</button>
</div>
</div>
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
</div>
</div>
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
</div>
))}
)
})}
</div>
{trailingFoods > 0 && (