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 { CardView, CardZoom } from './CardView'
import { useCardAnimations } from '../anim'
@@ -10,6 +10,23 @@ interface Props {
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
// 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
@@ -18,6 +35,11 @@ interface Props {
export function ArrangePhase({ view, you, send }: Props) {
const [order, setOrder] = useState<Card[]>(you.deck ?? [])
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)
// The card currently under the finger/cursor: it lifts and follows the
// 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 [dragTranslate, setDragTranslate] = useState(0)
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
// slot-to-slot pixel stride (rows are uniform), so the lifted card can track
// 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
// 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>(() => {})
// Hold the latest move/release handlers so the window listeners (attached once
// per gesture) always call the fresh closures — see the gesture effect below.
const moveRef = useRef<(e: PointerEvent) => void>(() => {})
const upRef = useRef<(tapped: boolean) => void>(() => {})
const locked = you.ready
// 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
// mean "read it". Ignored on desktop, which keeps the hover magnifier.
// instead. A drag never counts as a tap (see `endPress`), so dragging a card
// 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 [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 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.
// Drag-to-reorder via pointer events, grabbing the card itself — there's no
// separate grip handle. Native HTML5 drag doesn't fire on touch, so pointer
// events (mouse and finger alike) drive it, with one wrinkle per input type:
//
// 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
// (`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.
// listen on `window` for the gesture's lifetime (see the effect below), so
// reordering the rows can never interrupt 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 FLIP animator measures, so the two never fight.
function startDrag(e: React.PointerEvent<HTMLElement>, i: number) {
e.preventDefault()
function clearPress() {
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
startIndex.current = i
grabY.current = e.clientY
grabY.current = clientY
// Row stride = distance between two adjacent slots; rows are uniform.
const a = cardRefs.current[0]?.getBoundingClientRect()
const b = cardRefs.current[1]?.getBoundingClientRect()
@@ -105,6 +168,41 @@ export function ArrangePhase({ view, you, send }: Props) {
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) {
if (dragIndex.current === null) return
// 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
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
// Keep the window listeners pointed at the current-render closures (fresh
// `order`/refs) without re-attaching them on every reorder.
moveRef.current = onPointerMove
upRef.current = endPress
function endDrag() {
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.
setDropping(true)
setDragTranslate(0)
window.setTimeout(() => {
dropTimer.current = window.setTimeout(() => {
setDragId(null)
setDropping(false)
setDragging(false)
}, 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()
// While a press is live, track the pointer on `window` so the gesture keeps
// running even as the list reorders under the finger (a pointer capture on the
// card would be lost when React moves its row). Attached once per gesture;
// `moveRef`/`upRef` keep it calling the latest closures.
//
// A *layout* effect so the listeners are in place before the browser can
// deliver the matching `pointerup`: a quick tap must not slip through, since
// 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('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
window.addEventListener('touchmove', onTouchMove, { passive: false })
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
window.removeEventListener('touchmove', onTouchMove)
}
// 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
// (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">
The <strong>topmost</strong> card fights first. Food cards power up the
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>
<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}
>
<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)}
<button
className="btn btn-ghost arrange-arrow"
disabled={i === 0}
onClick={() => move(i, i - 1)}
aria-label="move up (earlier)"
>
</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>
</button>
<button
className="btn btn-ghost arrange-arrow"
disabled={i === order.length - 1}
onClick={() => move(i, i + 1)}
aria-label="move down (later)"
>
</button>
</div>
{/* The card is its own drag surface: press it and move. */}
<div
className="arrange-grab"
onPointerDown={(e) => onPointerDown(e, i)}
// The hold that picks a card up must not also raise the touch
// callout / context menu on top of the drag.
onContextMenu={(e) => {
if (press.current || dragIndex.current !== null) e.preventDefault()
}}
>
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
</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>
)
+45 -35
View File
@@ -1876,6 +1876,15 @@ h3 {
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
the top and later pets flow downward. */
.arrange-col {
@@ -1916,9 +1925,7 @@ h3 {
justify-content: center;
}
/* The lifted card floats above its neighbors while being dragged. The z-index
sits on the inner wrapper (which carries the follow transform) so it wins
over sibling rows without disturbing the measured `.arrange-card` box. */
/* The lifted card floats above its neighbors while being dragged. */
.arrange-card.is-dragging {
z-index: 5;
}
@@ -1936,8 +1943,9 @@ h3 {
transition: transform 0.18s ease-out;
}
/* Reorder controls: the grip handle on top, the up/down arrows below. Hung to
the right of the card so the card itself stays centered under the marker. */
/* The up/down arrows, hung to the right of the card so the card itself stays
centered under the marker. They're the precise alternative to dragging, so
they're sized as comfortable targets rather than tucked away. */
.arrange-controls {
position: absolute;
left: 100%;
@@ -1947,40 +1955,41 @@ h3 {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 10px;
}
/* Grab this to drag-reorder. `touch-action: none` keeps the browser from
scrolling the page while a touch drag is in progress; the pointer handlers
in ArrangePhase do the reordering. */
.arrange-handle {
touch-action: none;
.arrange-arrow {
display: grid;
place-items: center;
width: 46px;
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;
user-select: none;
-webkit-user-select: none;
display: flex;
align-items: center;
justify-content: center;
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);
-webkit-touch-callout: none;
border-radius: var(--card-radius);
transition: transform 140ms ease, box-shadow 140ms ease;
}
.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;
opacity: 1;
}
.arrange-arrows {
display: flex;
flex-direction: column;
gap: 6px;
transform: scale(1.05);
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.45);
}
/* ---------- battle ---------- */
@@ -3223,11 +3232,12 @@ h3 {
}
.arrange-controls {
margin-left: 8px;
gap: 8px;
}
.arrange-handle {
min-width: 34px;
min-height: 34px;
font-size: 1.25rem;
.arrange-arrow {
width: 40px;
height: 40px;
font-size: 1rem;
}
/* --- battle: fit both sides and the clash between the rails --- */