Improve drag and drop.
This commit is contained in:
@@ -18,6 +18,21 @@ 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)
|
||||||
const [dragging, setDragging] = useState(false)
|
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<string | null>(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
|
const locked = you.ready
|
||||||
|
|
||||||
// The arrow buttons reorder `order` and the cards glide to their new slot.
|
// 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<HTMLElement>, 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<HTMLElement>) {
|
||||||
|
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
|
// 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.
|
||||||
const bonuses = new Map<string, number>()
|
const bonuses = new Map<string, number>()
|
||||||
@@ -96,49 +173,59 @@ export function ArrangePhase({ view, you, send }: Props) {
|
|||||||
|
|
||||||
<div className="arrange-col" ref={arrangeAnim.containerRef}>
|
<div className="arrange-col" ref={arrangeAnim.containerRef}>
|
||||||
<div className="arrange-marker">⚔️ first to fight</div>
|
<div className="arrange-marker">⚔️ first to fight</div>
|
||||||
{order.map((c, i) => (
|
{order.map((c, i) => {
|
||||||
<div
|
const isDragged = c.id === dragId
|
||||||
key={c.id}
|
return (
|
||||||
className="arrange-card"
|
<div
|
||||||
data-flip-key={c.id}
|
key={c.id}
|
||||||
draggable
|
className={`arrange-card${isDragged ? ' is-dragging' : ''}`}
|
||||||
onDragStart={() => {
|
data-flip-key={c.id}
|
||||||
dragIndex.current = i
|
ref={(el) => {
|
||||||
setDragging(true)
|
cardRefs.current[i] = el
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => {
|
>
|
||||||
e.preventDefault()
|
<div
|
||||||
if (dragIndex.current !== null && dragIndex.current !== i) {
|
className={`arrange-drag${isDragged && dropping ? ' is-dropping' : ''}`}
|
||||||
move(dragIndex.current, i)
|
style={isDragged ? { transform: `translateY(${dragTranslate}px)` } : undefined}
|
||||||
dragIndex.current = i
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragEnd={() => {
|
|
||||||
dragIndex.current = null
|
|
||||||
setDragging(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="arrange-arrows">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
disabled={i === 0}
|
|
||||||
onClick={() => move(i, i - 1)}
|
|
||||||
aria-label="move up (earlier)"
|
|
||||||
>
|
>
|
||||||
▲
|
<div className="arrange-controls">
|
||||||
</button>
|
<div
|
||||||
<button
|
className="arrange-handle"
|
||||||
className="btn btn-ghost btn-sm"
|
role="button"
|
||||||
disabled={i === order.length - 1}
|
tabIndex={-1}
|
||||||
onClick={() => move(i, i + 1)}
|
aria-label="drag to reorder"
|
||||||
aria-label="move down (later)"
|
title="Drag to reorder"
|
||||||
>
|
onPointerDown={(e) => startDrag(e, i)}
|
||||||
▼
|
onPointerMove={onDragMove}
|
||||||
</button>
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardView card={c} bonus={bonuses.get(c.id) ?? 0} noMagnify={dragging} />
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{trailingFoods > 0 && (
|
{trailingFoods > 0 && (
|
||||||
|
|||||||
+70
-9
@@ -406,7 +406,8 @@ h3 {
|
|||||||
inset 0 1px 0 rgba(255, 220, 170, 0.25),
|
inset 0 1px 0 rgba(255, 220, 170, 0.25),
|
||||||
0 4px 14px rgba(0, 0, 0, 0.4);
|
0 4px 14px rgba(0, 0, 0, 0.4);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,8 +551,10 @@ h3 {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
/* Sit below the now-sticky topbar (~60px tall on wide screens) instead of
|
||||||
max-height: calc(100vh - 60px);
|
sliding behind it when the page scrolls. */
|
||||||
|
top: 60px;
|
||||||
|
max-height: calc(100vh - 68px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background:
|
background:
|
||||||
@@ -1802,17 +1805,75 @@ h3 {
|
|||||||
padding-bottom: 2px;
|
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 {
|
.arrange-card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
justify-content: center;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
cursor: grabbing;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrange-arrows {
|
.arrange-arrows {
|
||||||
|
|||||||
Reference in New Issue
Block a user