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:
@@ -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,19 +350,8 @@ 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
|
|
||||||
className="arrange-handle"
|
|
||||||
role="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label="drag to reorder"
|
|
||||||
title="Drag to reorder"
|
|
||||||
onPointerDown={(e) => startDrag(e, i)}
|
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</div>
|
|
||||||
<div className="arrange-arrows">
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost arrange-arrow"
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
onClick={() => move(i, i - 1)}
|
onClick={() => move(i, i - 1)}
|
||||||
aria-label="move up (earlier)"
|
aria-label="move up (earlier)"
|
||||||
@@ -247,7 +359,7 @@ export function ArrangePhase({ view, you, send }: Props) {
|
|||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost arrange-arrow"
|
||||||
disabled={i === order.length - 1}
|
disabled={i === order.length - 1}
|
||||||
onClick={() => move(i, i + 1)}
|
onClick={() => move(i, i + 1)}
|
||||||
aria-label="move down (later)"
|
aria-label="move down (later)"
|
||||||
@@ -255,17 +367,18 @@ export function ArrangePhase({ view, you, send }: Props) {
|
|||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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
@@ -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 --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user