PURPOSE
Mobile-first drag-and-drop primitive for the Ships sub-tabs. Provides a unified pointer-event-based DnD pipeline that works identically on iOS Safari, Android Chrome, and desktop mouse. Built on Zustand plus document.elementsFromPoint for hit testing; no third-party DnD library. Powers card dragging, slot assignment, and grid-snap previews used by UpgradesTab and other Ships sub-screens.
OWNS
- The global
useDragStoreZustand store holding the single active drag (active,hoverTarget,kind,pointerX,pointerY,payload). useDraggable<T>hook that attaches a draggable source element, returning{ ref, onPointerDown, dragging, ghostX, ghostY, hoverTarget, sourceW, sourceH, payload }.DragGhostcomponent — a body-portaled, pointer-events-disabled visual that follows the pointer with a slight scale/rotate/drop-shadow.DropShieldcomponent — wraps the inside of a draggable so child elements (images, buttons) can’t intercept pointer events; also disables text selection and the iOS long-press callout.computeGridSnappure helper — translates pointer coords into a clamped{ col, row, overGrid }for grid-based drop previews.- Click suppression on the source element after a drag ends, preventing the synthetic post-drag click from re-opening inspectors/popovers.
READS FROM
- Browser pointer events:
pointermove,pointerup,pointercancelonwindow. document.elementsFromPoint(clientX, clientY)to discover drop targets by walking elements with thedata-dnd-targetattribute.- The source element’s
getBoundingClientRect()for ghost sizing on drag start. - Internal refs holding the latest
payload,onDrop, andkindso closures stay current without re-binding listeners.
PUSHES TO
useDragStore—set({ active, hoverTarget, kind, pointerX, pointerY, payload })on every move; cleared on up/cancel/unmount.- The consumer’s
onDrop(targetId, payload)callback on pointerup once the movement threshold has been crossed;targetIdis thedata-dnd-targetvalue under the pointer, ornullif released over empty space. document.bodyvia ReactcreatePortal— theDragGhostrenders here so it escapes any clipping/stacking contexts.- A one-shot capture-phase
clicklistener attached to the source element for ~400 ms after drag-end to swallow the synthetic click.
DOES NOT
- Does not consume the React DnD ecosystem or HTML5 native drag events; pointer events only.
- Does not handle multi-touch or pinch — single primary pointer (
button === 0or unset) only. - Does not animate ghost release back to source on a missed drop; on null target it just calls
onDrop(null, payload). - Does not own drop-target rendering. Drop zones declare themselves by setting
data-dnd-target="<id>"and readinguseDragStorefor hover state. - Does not persist drag state across reloads or read from game stores; it is purely a UI primitive.
- Does not enforce kind compatibility — drop zones must filter on
kindthemselves before reacting. - Does not throttle/raf pointer updates; relies on React batching and the simplicity of the store writes.
Signals
useDragStoreis the public broadcast channel. Any drop zone or HUD element can subscribe:active: boolean— a drag is in progress.hoverTarget: string | null— id of the current hover drop zone, or null.kind: string | null— discriminator the source declared (e.g.'mod','ship'); drop zones use this to ignore foreign drags.pointerX,pointerY— viewport coords of the current pointer.payload: unknown— the opaque source payload, cast by consumers based onkind.
- The
useDraggablereturn also exposesdragging,ghostX/Y,sourceW/H, andhoverTargetfor the source-side render of the ghost and any inline visual feedback.
Entry points
useDraggable<T>({ payload, onDrop, kind?, threshold? })— attached to the source element viarefandonPointerDown.thresholddefaults to6px before drag engages.<DropShield>— wrap inside any draggable. Sets the inner subtree topointer-events: noneand disables selection/callout so the outer wrapper is the sole pointer surface.<DragGhost x y w h>— render whiledrag.draggingis true; portals todocument.bodyand follows the pointer.computeGridSnap(pointerX, pointerY, gridLeft, gridTop, cell, gap, gridSize, pieceW, pieceH, slop?)— returns the auto-clamped{ col, row, overGrid }snap position for a piece of footprintpieceW × pieceH. Slop defaults to24px.useDragStore— direct subscription for drop-zone hover/preview rendering.
Pattern notes
- Standard usage:
const drag = useDraggable({ payload, onDrop, kind }), attachdrag.refplusonPointerDownto a wrapper withtouchAction: 'none', wrap inner visuals in<DropShield>, and conditionally render<DragGhost>whendrag.dragging. - Drop targets are declared declaratively via
data-dnd-target="<id>"on any DOM node — no registration step. Hit testing walks the stack at the pointer and picks the first matching element. - Drag engages only after movement crosses
threshold(default6px); short taps pass through to normal click handlers. - Pointer listeners are attached to
windowso the drag survives the pointer leaving the source element; they are removed on up, cancel, and component unmount. - Refs (
payloadRef,onDropRef,kindRef) mirror props each render so the long-livedpointermoveclosure always sees current values without churn-and-rebind cycles. - After a successful pointerup, a capture-phase
clicklistener is attached to the source for one click or 400 ms — whichever fires first — to suppress the synthetic click that would otherwise reopen inspectors when dropping a card on itself. - Only
e.button === 0(primary) or unset triggers a drag; secondary buttons are ignored. DragGhostusestransform: scale(1.08) rotate(-2deg)plus adrop-shadowfilter andzIndex: 100000so it floats above all screen chrome.DropShieldis a plainwidth: 100%; height: 100%block wrapper withpointer-events: none; sized children that useheight: 100%still resolve because the parent’s height propagates.computeGridSnapclamps the pointer to the grid’s pixel rect, divides bycell + gapstride for the raw cell, then clamps again bygridSize - pieceW/pieceHso the piece’s full footprint always lands inside the grid; dragging a 1×4 to the bottom-right corner therefore shifts it up so the bottom row aligns with the last row.- The store is intentionally shared/singleton: only one drag exists at a time on a mobile screen, so a single slot is enough and the price of prop-drilling is avoided.