diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index 28353a4..b8e2c3e 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -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(you.deck ?? []) const dragIndex = useRef(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(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(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, i: number) { - e.preventDefault() + function clearPress() { + if (press.current) window.clearTimeout(press.current.timer) + press.current = null + } + + function onPointerDown(e: React.PointerEvent, 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) {

The topmost card fights first. Food cards power up the next pet below them. + + Drag a card to move it — on a touchscreen, hold it a moment first. The + ▲▼ arrows work too. +

@@ -227,45 +350,35 @@ export function ArrangePhase({ view, you, send }: Props) { style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined} >
-
startDrag(e, i)} +
-
- - -
+ ▲ + + +
+ {/* The card is its own drag surface: press it and move. */} +
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() + }} + > +
- setZoom({ card: c, bonus: bonuses.get(c.id) ?? 0 }) - : undefined - } - />
) diff --git a/web/src/styles.css b/web/src/styles.css index bd14b32..f104df3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 --- */