Drag arrange cards by the card, not a grip handle.

Reordering the battle line meant finding a small ⠿ handle beside each
card. Now the card itself is the drag surface, with the gesture split by
input type so it can't collide with the two things a press already means:

  * A mouse lifts the card once the cursor travels 5px. Shorter presses
    stay clicks.
  * A finger has to hold still for 220ms first. Arrange is the one screen
    tall enough to scroll, and a finger swiping a card almost always
    means scrolling — so the card deliberately does NOT set
    `touch-action: none`. The hold is what distinguishes a drag, and once
    it lands the scroll is cancelled by preventing `touchmove`, which
    works precisely because a still finger hasn't started one.

That also fixes the phone's tap-to-magnify fighting the drag: only a
press that never became a drag counts as a tap, so hauling a card up the
list no longer pops the expanded card view open under your finger. A
picked-up card swells and casts a shadow, which on touch is the only
confirmation the hold registered.

The up/down arrows stay as the precise alternative and grow to 46px
(40px on phones) now that they aren't sharing space with the handle.
This commit is contained in:
Greyson Parrelli
2026-07-27 16:25:15 -04:00
parent a2024229ff
commit 8383f0f718
2 changed files with 224 additions and 101 deletions
+179 -66
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types' import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView, CardZoom } from './CardView' import { CardView, CardZoom } from './CardView'
import { useCardAnimations } from '../anim' import { useCardAnimations } from '../anim'
@@ -10,6 +10,23 @@ interface Props {
send: (msg: ClientMessage) => void send: (msg: ClientMessage) => void
} }
// How far the pointer must travel before a mouse press counts as a drag rather
// than a click.
const DRAG_SLOP = 5
// How long a finger must rest on a card before it picks it up.
const HOLD_MS = 220
// A press that hasn't become a drag yet: which pointer, where it landed, and
// (for touch) the pending hold timer that would lift the card.
interface Press {
pointerId: number
touch: boolean
x: number
y: number
index: number
timer: number
}
// ArrangePhase lets the player order their deck for battle. The list runs top to // ArrangePhase lets the player order their deck for battle. The list runs top to
// bottom in play order: the topmost card fights first, and food cards buff the // bottom in play order: the topmost card fights first, and food cards buff the
// next pet below them. `order` stays in play order (index 0 fights first) to // next pet below them. `order` stays in play order (index 0 fights first) to
@@ -18,6 +35,11 @@ interface Props {
export function ArrangePhase({ view, you, send }: Props) { export function ArrangePhase({ view, you, send }: Props) {
const [order, setOrder] = useState<Card[]>(you.deck ?? []) const [order, setOrder] = useState<Card[]>(you.deck ?? [])
const dragIndex = useRef<number | null>(null) const dragIndex = useRef<number | null>(null)
// `pressed` spans the whole gesture — pointerdown, through the press that may
// or may not become a drag, to release — so the window listeners cover all of
// it. `dragging` is the narrower state where a card is actually lifted.
const [pressed, setPressed] = useState(false)
const press = useRef<Press | null>(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
// The card currently under the finger/cursor: it lifts and follows the // The card currently under the finger/cursor: it lifts and follows the
// pointer (`dragTranslate`), while `dropping` glides it into its final slot // pointer (`dragTranslate`), while `dropping` glides it into its final slot
@@ -25,6 +47,8 @@ export function ArrangePhase({ view, you, send }: Props) {
const [dragId, setDragId] = useState<string | null>(null) const [dragId, setDragId] = useState<string | null>(null)
const [dragTranslate, setDragTranslate] = useState(0) const [dragTranslate, setDragTranslate] = useState(0)
const [dropping, setDropping] = useState(false) const [dropping, setDropping] = useState(false)
// The pending "drop glide finished" timer, so a fresh grab can cancel it.
const dropTimer = useRef(0)
// Drag bookkeeping: where the drag began, the pointer Y at grab time, and the // 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 // slot-to-slot pixel stride (rows are uniform), so the lifted card can track
// the finger even as the list reorders beneath it. // the finger even as the list reorders beneath it.
@@ -34,13 +58,15 @@ export function ArrangePhase({ view, you, send }: Props) {
// One entry per card row, in current play order, so a pointer drag can find // 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. // which slot the finger/cursor is currently over by hit-testing rects.
const cardRefs = useRef<(HTMLDivElement | null)[]>([]) const cardRefs = useRef<(HTMLDivElement | null)[]>([])
// Holds the latest `onDragMove` so the window listener (attached once per // Hold the latest move/release handlers so the window listeners (attached once
// drag) always calls the fresh closure — see the drag effect below. // per gesture) always call the fresh closures — see the gesture effect below.
const moveRef = useRef<(clientY: number) => void>(() => {}) const moveRef = useRef<(e: PointerEvent) => void>(() => {})
const upRef = useRef<(tapped: boolean) => void>(() => {})
const locked = you.ready const locked = you.ready
// Phones can't hover to preview a card, so a tap opens the magnified view // Phones can't hover to preview a card, so a tap opens the magnified view
// instead. Dragging uses the grip handle, so tapping the card body is free to // instead. A drag never counts as a tap (see `endPress`), so dragging a card
// mean "read it". Ignored on desktop, which keeps the hover magnifier. // around can't pop the magnified view open under your finger. Ignored on
// desktop, which keeps the hover magnifier.
const isPhone = useMediaQuery('(max-width: 600px)') const isPhone = useMediaQuery('(max-width: 600px)')
const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null) const [zoom, setZoom] = useState<{ card: Card; bonus: number } | null>(null)
@@ -75,26 +101,63 @@ export function ArrangePhase({ view, you, send }: Props) {
}) })
} }
// Drag-to-reorder via pointer events, driven by the grip handle. Native HTML5 // Drag-to-reorder via pointer events, grabbing the card itself — there's no
// drag doesn't fire on touch, so we use pointer events (mouse + touch alike). // separate grip handle. Native HTML5 drag doesn't fire on touch, so pointer
// `touch-action: none` on the handle stops the browser from scrolling the // events (mouse and finger alike) drive it, with one wrinkle per input type:
// page mid-drag.
// //
// We do NOT rely on `setPointerCapture` here: the captured handle lives inside // * A mouse lifts the card as soon as the cursor travels DRAG_SLOP from the
// press. Anything shorter is a click, which does nothing here — on
// desktop you read a card by hovering it.
// * A finger has to hold still for HOLD_MS first. A swipe starting on a card
// almost always means "scroll the page" (arrange is the one screen tall
// enough to need it), so the hold is what separates "move this card" from
// scrolling and from a plain tap, which opens the magnified view. It's
// also why the card does NOT set `touch-action: none` — that would kill
// scrolling outright. Instead, once the hold lands we cancel the scroll
// ourselves by preventing `touchmove`, which works precisely because a
// still finger hasn't started one yet.
//
// We do NOT rely on `setPointerCapture` here: the pressed card lives inside
// the keyed row that reorders mid-drag, and React moves that DOM node // the keyed row that reorders mid-drag, and React moves that DOM node
// (`insertBefore`) as the list changes — which makes browsers drop the active // (`insertBefore`) as the list changes — which makes browsers drop the active
// pointer capture, freezing the drag until the user re-grabs. Instead we // 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 // listen on `window` for the gesture's lifetime (see the effect below), so
// reordering the rows can never interrupt the gesture. // reordering the rows can never interrupt it.
// //
// The lifted card follows the finger while the list reorders live beneath it. // 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 translate is applied to an inner wrapper, not the `.arrange-card` box
// the FLIP animator measures, so the two never fight. // the FLIP animator measures, so the two never fight.
function startDrag(e: React.PointerEvent<HTMLElement>, i: number) { function clearPress() {
e.preventDefault() if (press.current) window.clearTimeout(press.current.timer)
press.current = null
}
function onPointerDown(e: React.PointerEvent<HTMLElement>, i: number) {
if (e.button !== 0 || press.current || dragIndex.current !== null) return
const { pointerId, clientX, clientY } = e
const touch = e.pointerType !== 'mouse'
// Stop a mouse press from selecting the card's text as it drags. A touch
// press is left alone so the browser can still scroll from here.
if (!touch) e.preventDefault()
press.current = {
pointerId,
touch,
x: clientX,
y: clientY,
index: i,
timer: touch ? window.setTimeout(() => beginDrag(i, clientY), HOLD_MS) : 0,
}
setPressed(true)
}
function beginDrag(i: number, clientY: number) {
clearPress()
// Cancel a previous card's drop glide, so re-grabbing within its 180ms
// doesn't have that timer tear down this drag's state mid-gesture.
window.clearTimeout(dropTimer.current)
dragIndex.current = i dragIndex.current = i
startIndex.current = i startIndex.current = i
grabY.current = e.clientY grabY.current = clientY
// Row stride = distance between two adjacent slots; rows are uniform. // Row stride = distance between two adjacent slots; rows are uniform.
const a = cardRefs.current[0]?.getBoundingClientRect() const a = cardRefs.current[0]?.getBoundingClientRect()
const b = cardRefs.current[1]?.getBoundingClientRect() const b = cardRefs.current[1]?.getBoundingClientRect()
@@ -105,6 +168,41 @@ export function ArrangePhase({ view, you, send }: Props) {
setDragging(true) setDragging(true)
} }
function onPointerMove(e: PointerEvent) {
const p = press.current
if (p) {
if (e.pointerId !== p.pointerId) return
if (Math.hypot(e.clientX - p.x, e.clientY - p.y) < DRAG_SLOP) return
if (p.touch) {
// The finger set off before the hold landed — that's a scroll, not a
// drag. Stand down and leave the page free to move.
endPress(false)
return
}
// Lift from where the press began, so the card sits under the cursor
// rather than jumping by the slop distance.
beginDrag(p.index, p.y)
}
if (dragIndex.current === null) return
onDragMove(e.clientY)
}
// Ends the gesture at whatever stage it reached: a lifted card glides into its
// slot, while a press that never became a drag counts as a tap.
function endPress(tapped: boolean) {
const p = press.current
clearPress()
setPressed(false)
if (dragIndex.current !== null) {
endDrag()
return
}
if (tapped && p && isPhone) {
const c = order[p.index]
if (c) setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
}
}
function onDragMove(clientY: number) { function onDragMove(clientY: number) {
if (dragIndex.current === null) return if (dragIndex.current === null) return
// Find the slot the pointer has crossed into by hit-testing the *other* // Find the slot the pointer has crossed into by hit-testing the *other*
@@ -128,9 +226,10 @@ export function ArrangePhase({ view, you, send }: Props) {
const shift = (dragIndex.current - startIndex.current) * stride.current const shift = (dragIndex.current - startIndex.current) * stride.current
setDragTranslate(clientY - grabY.current - shift) setDragTranslate(clientY - grabY.current - shift)
} }
// Keep the window listener pointed at the current-render closure (fresh // Keep the window listeners pointed at the current-render closures (fresh
// `order`/refs) without re-attaching the listener on every reorder. // `order`/refs) without re-attaching them on every reorder.
moveRef.current = onDragMove moveRef.current = onPointerMove
upRef.current = endPress
function endDrag() { function endDrag() {
if (dragIndex.current === null) return if (dragIndex.current === null) return
@@ -138,31 +237,51 @@ export function ArrangePhase({ view, you, send }: Props) {
// Glide the lifted card down into its resting slot, then clear drag state. // Glide the lifted card down into its resting slot, then clear drag state.
setDropping(true) setDropping(true)
setDragTranslate(0) setDragTranslate(0)
window.setTimeout(() => { dropTimer.current = window.setTimeout(() => {
setDragId(null) setDragId(null)
setDropping(false) setDropping(false)
setDragging(false) setDragging(false)
}, 180) }, 180)
} }
// While a drag is active, track the pointer on `window` so the gesture keeps // While a press is live, track the pointer on `window` so the gesture keeps
// running even as the list reorders under the finger (the handle's own // running even as the list reorders under the finger (a pointer capture on the
// pointer capture would be lost when React moves its row). Attached once per // card would be lost when React moves its row). Attached once per gesture;
// drag; `moveRef` keeps it calling the latest closure. // `moveRef`/`upRef` keep it calling the latest closures.
useEffect(() => { //
if (!dragging) return // A *layout* effect so the listeners are in place before the browser can
const onMove = (e: PointerEvent) => moveRef.current(e.clientY) // deliver the matching `pointerup`: a quick tap must not slip through, since
const onUp = () => endDrag() // that's what opens the magnified view on a phone.
useLayoutEffect(() => {
if (!pressed) return
const onMove = (e: PointerEvent) => moveRef.current(e)
const onUp = (e: PointerEvent) => upRef.current(e.type === 'pointerup')
// A touch drag must not scroll the page, and `touch-action` can't be flipped
// mid-gesture — so once a card is lifted, cancel the scroll here instead.
const onTouchMove = (e: TouchEvent) => {
if (dragIndex.current !== null && e.cancelable) e.preventDefault()
}
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp) window.addEventListener('pointercancel', onUp)
window.addEventListener('touchmove', onTouchMove, { passive: false })
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp) window.removeEventListener('pointercancel', onUp)
window.removeEventListener('touchmove', onTouchMove)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dragging]) }, [pressed])
// Don't leave timers behind if the phase ends mid-gesture.
useEffect(
() => () => {
if (press.current) window.clearTimeout(press.current.timer)
window.clearTimeout(dropTimer.current)
},
[],
)
// Which pets do the foods land on? Foods buff the next pet later in play order // 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. // (i.e. the next pet below them in the list). Compute buff per card for preview.
@@ -207,6 +326,10 @@ export function ArrangePhase({ view, you, send }: Props) {
<p className="hint"> <p className="hint">
The <strong>topmost</strong> card fights first. Food cards power up the The <strong>topmost</strong> card fights first. Food cards power up the
next pet <strong>below</strong> them. next pet <strong>below</strong> them.
<span className="arrange-tip">
Drag a card to move it on a touchscreen, hold it a moment first. The
arrows work too.
</span>
</p> </p>
<div className="arrange-col" ref={arrangeAnim.containerRef}> <div className="arrange-col" ref={arrangeAnim.containerRef}>
@@ -227,45 +350,35 @@ export function ArrangePhase({ view, you, send }: Props) {
style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined} style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined}
> >
<div className="arrange-controls"> <div className="arrange-controls">
<div <button
className="arrange-handle" className="btn btn-ghost arrange-arrow"
role="button" disabled={i === 0}
tabIndex={-1} onClick={() => move(i, i - 1)}
aria-label="drag to reorder" aria-label="move up (earlier)"
title="Drag to reorder"
onPointerDown={(e) => startDrag(e, i)}
> >
</div> </button>
<div className="arrange-arrows"> <button
<button className="btn btn-ghost arrange-arrow"
className="btn btn-ghost btn-sm" disabled={i === order.length - 1}
disabled={i === 0} onClick={() => move(i, i + 1)}
onClick={() => move(i, i - 1)} aria-label="move down (later)"
aria-label="move up (earlier)" >
>
</button>
</button> </div>
<button {/* The card is its own drag surface: press it and move. */}
className="btn btn-ghost btn-sm" <div
disabled={i === order.length - 1} className="arrange-grab"
onClick={() => move(i, i + 1)} onPointerDown={(e) => onPointerDown(e, i)}
aria-label="move down (later)" // The hold that picks a card up must not also raise the touch
> // callout / context menu on top of the drag.
onContextMenu={(e) => {
</button> if (press.current || dragIndex.current !== null) e.preventDefault()
</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}
onClick={
isPhone && !dragging
? () => setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 })
: undefined
}
/>
</div> </div>
</div> </div>
) )
+45 -35
View File
@@ -1876,6 +1876,15 @@ h3 {
gap: 20px; gap: 20px;
} }
/* How-to-reorder line, tucked under the ordering rule a shade quieter — the
drag gesture has no visible handle to advertise it. */
.arrange-tip {
display: block;
margin-top: 4px;
font-size: 0.85rem;
opacity: 0.78;
}
/* A vertical battle line laid into a felt tray: card 0 (fights first) sits at /* A vertical battle line laid into a felt tray: card 0 (fights first) sits at
the top and later pets flow downward. */ the top and later pets flow downward. */
.arrange-col { .arrange-col {
@@ -1916,9 +1925,7 @@ h3 {
justify-content: center; justify-content: center;
} }
/* The lifted card floats above its neighbors while being dragged. The z-index /* The lifted card floats above its neighbors while being dragged. */
sits on the inner wrapper (which carries the follow transform) so it wins
over sibling rows without disturbing the measured `.arrange-card` box. */
.arrange-card.is-dragging { .arrange-card.is-dragging {
z-index: 5; z-index: 5;
} }
@@ -1936,8 +1943,9 @@ h3 {
transition: transform 0.18s ease-out; transition: transform 0.18s ease-out;
} }
/* Reorder controls: the grip handle on top, the up/down arrows below. Hung to /* The up/down arrows, hung to the right of the card so the card itself stays
the right of the card so the card itself stays centered under the marker. */ centered under the marker. They're the precise alternative to dragging, so
they're sized as comfortable targets rather than tucked away. */
.arrange-controls { .arrange-controls {
position: absolute; position: absolute;
left: 100%; left: 100%;
@@ -1947,40 +1955,41 @@ h3 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
} }
/* Grab this to drag-reorder. `touch-action: none` keeps the browser from .arrange-arrow {
scrolling the page while a touch drag is in progress; the pointer handlers display: grid;
in ArrangePhase do the reordering. */ place-items: center;
.arrange-handle { width: 46px;
touch-action: none; height: 46px;
padding: 0;
font-size: 1.15rem;
line-height: 1;
border-radius: 12px;
}
/* The card is its own drag surface — press it and move (see ArrangePhase for
how a mouse and a finger each start a drag). Deliberately no
`touch-action: none`: a finger has to hold still to pick a card up, so plain
swipes are left to scroll the page. Selection and the long-press callout are
off so the hold reads as a grab, not as text selection. */
.arrange-grab {
display: flex;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
display: flex; -webkit-touch-callout: none;
align-items: center; border-radius: var(--card-radius);
justify-content: center; transition: transform 140ms ease, box-shadow 140ms ease;
min-width: 40px;
min-height: 40px;
font-size: 1.5rem;
line-height: 1;
color: var(--gold);
opacity: 0.75;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
} }
.arrange-handle:active { /* Picked up: the card swells off the tray and casts a shadow over the rows
below — on touch that lift is the only confirmation the hold landed. */
.arrange-card.is-dragging .arrange-grab {
cursor: grabbing; cursor: grabbing;
opacity: 1; transform: scale(1.05);
} box-shadow: 0 14px 24px rgba(0, 0, 0, 0.45);
.arrange-arrows {
display: flex;
flex-direction: column;
gap: 6px;
} }
/* ---------- battle ---------- */ /* ---------- battle ---------- */
@@ -3223,11 +3232,12 @@ h3 {
} }
.arrange-controls { .arrange-controls {
margin-left: 8px; margin-left: 8px;
gap: 8px;
} }
.arrange-handle { .arrange-arrow {
min-width: 34px; width: 40px;
min-height: 34px; height: 40px;
font-size: 1.25rem; font-size: 1rem;
} }
/* --- battle: fit both sides and the clash between the rails --- */ /* --- battle: fit both sides and the clash between the rails --- */