diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx index b8e2c3e..369a2f0 100644 --- a/web/src/components/ArrangePhase.tsx +++ b/web/src/components/ArrangePhase.tsx @@ -15,6 +15,13 @@ interface Props { const DRAG_SLOP = 5 // How long a finger must rest on a card before it picks it up. const HOLD_MS = 220 +// How long after release the drag counts as still settling. The dropped card's +// own glide is 180ms (`.arrange-drag.is-dropping`), but the neighbours it passed +// keep gliding for MOVE_MS (240ms, anim.ts) — and rows sliding out from under a +// stationary cursor don't reliably fire `mouseleave`, which is how the hover +// magnifier used to get stuck open after a drop. So hold the drag state, and +// with it CardView's `noMagnify`, until every row has come to rest. +const SETTLE_MS = 260 // 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. @@ -152,8 +159,8 @@ export function ArrangePhase({ view, you, send }: Props) { 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. + // Cancel a previous card's settle, so re-grabbing inside that window doesn't + // have its timer tear down this drag's state mid-gesture. window.clearTimeout(dropTimer.current) dragIndex.current = i startIndex.current = i @@ -234,14 +241,15 @@ export function ArrangePhase({ view, you, send }: Props) { function endDrag() { if (dragIndex.current === null) return dragIndex.current = null - // Glide the lifted card down into its resting slot, then clear drag state. + // Glide the lifted card down into its resting slot, then — once the whole + // tray has settled, not just this card — clear drag state. setDropping(true) setDragTranslate(0) dropTimer.current = window.setTimeout(() => { setDragId(null) setDropping(false) setDragging(false) - }, 180) + }, SETTLE_MS) } // While a press is live, track the pointer on `window` so the gesture keeps diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index 36bbae0..a56133a 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { Card } from '../types' import { artFor, artUrlFor } from '../petArt' @@ -222,8 +222,9 @@ interface Props { dead?: boolean // preview marks the floating magnified copy so it doesn't magnify itself. preview?: boolean - // noMagnify suppresses the hover magnifier (e.g. while this card is being - // dragged, so the frozen preview copy doesn't linger over the drag ghost). + // noMagnify turns the hover magnifier off entirely — not just hidden but not + // tracked, so nothing is left half-armed to spring open when it flips back + // (e.g. while cards are being dragged and the rows slide under the cursor). noMagnify?: boolean } @@ -245,6 +246,17 @@ export function CardView({ // Hover magnifier: show a large floating copy beside the cursor. The // preview copy itself opts out so it can't recurse. const [hover, setHover] = useState<{ x: number; y: number } | null>(null) + // When the magnifier is off, don't track hover at all — and forget anything + // already tracked. Suppressing only the rendering isn't enough: a drag slides + // rows around under the cursor, so the `mouseleave` that would normally clear + // this frequently never reaches the card it belongs to (the cursor outruns the + // lifted card, and React re-inserts the rows it passes). The stale hover then + // sprang the preview open the instant the drag ended and stuck there until + // that exact card was hovered and left again. + const hoverOff = preview || !!noMagnify || !CAN_HOVER + useEffect(() => { + if (hoverOff) setHover(null) + }, [hoverOff]) const power = (card.power ?? 0) + bonus // Shrink long ability text to fit the (fixed-height) card, re-fitting when // the text or card size changes. Only the branch that renders attaches it. @@ -265,8 +277,10 @@ export function CardView({ .join(' ') // Place the magnified preview near the cursor, clamped into the viewport. + // Checking `hoverOff` here as well as in the effect keeps the preview from + // flashing for the one render between it turning on and the effect clearing. const previewEl = - hover && !preview && !noMagnify ? ( + hover && !hoverOff ? ( ) : null @@ -276,9 +290,9 @@ export function CardView({ data-card-id={card.id || undefined} onClick={disabled ? undefined : onClick} role={onClick ? 'button' : undefined} - onMouseEnter={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} - onMouseMove={preview || !CAN_HOVER ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} - onMouseLeave={preview || !CAN_HOVER ? undefined : () => setHover(null)} + onMouseEnter={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} + onMouseMove={hoverOff ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} + onMouseLeave={hoverOff ? undefined : () => setHover(null)} > {previewEl} {/* Upper illustration: a stylised landscape with the pet as a sticker and