diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index b28ec1f..b6045b1 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -18,6 +18,21 @@ export function ArrangePhase({ view, you, send }: Props) { const [order, setOrder] = useState(you.deck ?? []) const dragIndex = 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 + // when released. + const [dragId, setDragId] = useState(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, 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) { + 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() @@ -96,49 +173,59 @@ export function ArrangePhase({ view, you, send }: Props) {
⚔️ first to fight
- {order.map((c, i) => ( -
{ - 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) - }} - > -
- - +
+
startDrag(e, i)} + onPointerMove={onDragMove} + onPointerUp={endDrag} + onPointerCancel={endDrag} + > + ⠿ +
+
+ + +
+
+ +
- -
- ))} + ) + })} {trailingFoods > 0 && ( diff --git a/web/src/styles.css b/web/src/styles.css index 1e37ff4..64a7925 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -406,7 +406,8 @@ h3 { inset 0 1px 0 rgba(255, 220, 170, 0.25), 0 4px 14px rgba(0, 0, 0, 0.4); flex-wrap: wrap; - position: relative; + position: sticky; + top: 0; z-index: 10; } @@ -550,8 +551,10 @@ h3 { flex-shrink: 0; align-self: stretch; position: sticky; - top: 0; - max-height: calc(100vh - 60px); + /* Sit below the now-sticky topbar (~60px tall on wide screens) instead of + sliding behind it when the page scrolls. */ + top: 60px; + max-height: calc(100vh - 68px); display: flex; flex-direction: column; background: @@ -1802,17 +1805,75 @@ h3 { padding-bottom: 2px; } -/* Each row: reorder arrows on the left, the card on the right. */ +/* Each row is centered in the tray so the card sits under the "first to fight" + marker; the reorder controls hang absolutely to its left (see below) and so + don't push the card off-center. `.arrange-card` is the box the FLIP animator + measures — it stays in its natural slot while a drag lifts its inner content. */ .arrange-card { + position: relative; display: flex; - flex-direction: row; - align-items: center; - gap: 12px; - cursor: grab; + justify-content: center; } -.arrange-card:active { +/* 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. */ +.arrange-card.is-dragging { + z-index: 5; +} + +/* Inner wrapper that actually moves during a drag. Transforming this rather + than `.arrange-card` keeps the FLIP animator's measurements honest. */ +.arrange-drag { + position: relative; + display: flex; + align-items: center; +} + +/* When released, glide from under the finger down into the resting slot. */ +.arrange-drag.is-dropping { + 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. */ +.arrange-controls { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +/* 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; + 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); +} + +.arrange-handle:active { cursor: grabbing; + opacity: 1; } .arrange-arrows {