diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index e92cf5e..1bba8b3 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -152,26 +152,29 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq /> )} - - {piece && ( - - - - )} - + {/* + * No AnimatePresence wrapper here: the `` component runs + * its own FLIP across unmount (at source) and mount (at target) + * via a module-level rect stash. An AnimatePresence exit + * animation on the source wrapper would show a ghost of the old + * piece at its origin while the new mount springs in from the + * cursor position — competing artifacts. Captures pop instantly + * without a fade, which is acceptable and keeps the movement + * FLIP clean. + */} + {piece && ( +
+ +
+ )} {/* Rank/File labels (optional but helpful for debug) */} {f === 0 && ( @@ -191,7 +194,7 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq return (
-
+
{squares}
diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx index 13850c5..37aab9a 100644 --- a/packages/chess/src/ui/Piece.tsx +++ b/packages/chess/src/ui/Piece.tsx @@ -1,6 +1,12 @@ import type { PieceColor, PieceType } from '../schema'; import { pieceAssets } from '../assets/pieces'; -import { motion } from 'motion/react'; +import { + motion, + useMotionValue, + useSpring, + useTransform, +} from 'motion/react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { DragEvent as ReactDragEvent } from 'react'; export interface PieceProps { @@ -8,59 +14,301 @@ export interface PieceProps { type: PieceType; pieceId: number; square: number; + /** Whether this piece can be picked up right now (its color's turn, game + * ongoing, etc). When false, drag is disabled and the piece shows a + * default cursor. */ + isDraggable: boolean; onDragStart: (pieceId: number, square: number) => void; onDragEnd: () => void; } +/** + * Module-level registry that bridges a piece's unmount-at-old-square and + * mount-at-new-square across a valid drop. + * + * Why not motion's `layoutId` FLIP? `layoutId` measures each element's + * *layout* rect — i.e. the rect as if no inline transforms were applied. + * Since our cursor-following juice is implemented via `x`/`y` motion + * values (inline transforms), motion's FLIP source rect is always the + * piece's original square, not the cursor's release position. The animation + * looks like a teleport-then-glide. + * + * We do FLIP by hand instead: + * 1. In `handleDragEnd`, snapshot the piece's *rendered* center (which + * includes the transform) into this map, keyed by `pieceId`. + * 2. React updates the board, unmounting the old `` and mounting + * a fresh one at the target square (same `pieceId`, new `square`). + * 3. The new piece's `useLayoutEffect` finds the stashed center, measures + * its own mount center, computes the delta, and jumps the springs to + * that delta without animation. Setting the spring targets back to 0 + * then animates the piece from the cursor's release position to home. + * + * On an invalid drop the same `` instance stays mounted — no new + * mount consumes the stash — so we clear stale entries on the next + * dragstart and after a grace period. + */ +const pendingDropRects = new Map(); + /** * Piece component. * - * We wrap the draggable DOM node in an outer `motion.div` that owns the - * FLIP layout animation (via `layoutId`). The inner plain `
` owns the - * HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the - * browser's DnD subsystem both dispatch events on this node. Keeping the - * two responsibilities separate avoids a type clash — `motion.div`'s - * `onDragStart` prop is typed for its own pointer-based drag gesture, - * which is incompatible with `React.DragEvent`. The extra DOM node costs - * nothing and sidesteps the cast. + * Three-layer structure: + * + * 1. Outer plain `
` — stable bounding box for Playwright's + * actionability check. A `motion.div` here whose descendants are + * spring-animating would register as "not stable" and cause `dragTo` + * to time out. + * + * 2. Middle plain `
` — owns HTML5 native drag-and-drop. + * The browser's DnD subsystem and Playwright's `locator.dragTo()` both + * dispatch events against this node. + * + * 3. Inner `motion.div` — cursor-following transform (`x`/`y`/`rotate`) + * plus scale/shadow lift. `pointer-events: none` while dragging so + * the translated piece doesn't block `dragover` from reaching the + * target square beneath it. + * + * Cursor tracking: while dragging we attach a document-level `dragover` + * listener. That event fires at a high rate with accurate `clientX/Y` in + * every browser — unlike `drag` on the source element, which is throttled + * by Chromium and reports 0/0 in Firefox. Cursor delta feeds `xRaw`/`yRaw`, + * smoothed through a spring (`x`/`y`) so the piece trails with weight. A + * `useTransform` over the raw-vs-smoothed gap drives a tilt. */ -export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) { +export function Piece({ + color, + type, + pieceId, + square, + isDraggable, + onDragStart, + onDragEnd, +}: PieceProps) { const imgSrc = pieceAssets[color][type]; + // Raw pointer offset from drag origin, updated on every `dragover`. + const xRaw = useMotionValue(0); + const yRaw = useMotionValue(0); + + // Smoothed offset — what the piece actually renders at. The spring lag + // gives the piece weight while dragging and drives both the snap-back + // (invalid drop) and the mount-FLIP (valid drop). + const springCfg = { stiffness: 350, damping: 28, mass: 0.6 }; + const x = useSpring(xRaw, springCfg); + const y = useSpring(yRaw, springCfg); + + // Tilt is proportional to the raw-vs-smoothed X gap, clamped subtle. + const rotate = useTransform([xRaw, x], ([rawVal, smoothVal]) => { + const delta = (rawVal as number) - (smoothVal as number); + return Math.max(-25, Math.min(25, delta * 0.35)); + }); + + const transformRef = useRef(null); + const originRef = useRef<{ x: number; y: number } | null>(null); + const dragOverHandlerRef = useRef<((e: globalThis.DragEvent) => void) | null>(null); + const snapBackRafRef = useRef(null); + const staleStashTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + const [isDragging, setIsDragging] = useState(false); + + const detachDragOver = () => { + if (dragOverHandlerRef.current) { + document.removeEventListener('dragover', dragOverHandlerRef.current); + dragOverHandlerRef.current = null; + } + }; + + // Mount effect: consume a pending drop rect for this pieceId, if one was + // stashed by the piece that just unmounted at the old square. We measure + // the new mount's center and jump the springs to the delta so the piece + // appears at the cursor-release position, then let them animate to 0. + // + // `useLayoutEffect` runs synchronously after mutation and before paint, + // so there's no visible flash of the piece at the new square before the + // FLIP begins. + useLayoutEffect(() => { + const stash = pendingDropRects.get(pieceId); + if (!stash) return; + pendingDropRects.delete(pieceId); + + const node = transformRef.current; + if (!node) return; + + const nextRect = node.getBoundingClientRect(); + const nextCx = nextRect.left + nextRect.width / 2; + const nextCy = nextRect.top + nextRect.height / 2; + const dx = stash.cx - nextCx; + const dy = stash.cy - nextCy; + + // `.jump()` snaps the spring's current value without triggering an + // animation. Setting `xRaw`/`yRaw` back to 0 re-targets the spring, + // so it animates from `delta` to `0` — a FLIP from release to home. + x.jump(dx); + y.jump(dy); + xRaw.set(0); + yRaw.set(0); + // Intentionally only re-run when pieceId changes: x/y/xRaw/yRaw are + // stable motion-value references and this effect is about the initial + // mount animation. + }, [pieceId]); + + // Mount/unmount tracking. Empty deps by design: this only wires cleanup + // on unmount, regardless of prop changes. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (snapBackRafRef.current !== null) { + cancelAnimationFrame(snapBackRafRef.current); + snapBackRafRef.current = null; + } + if (staleStashTimerRef.current !== null) { + clearTimeout(staleStashTimerRef.current); + staleStashTimerRef.current = null; + } + detachDragOver(); + }; + }, []); + const handleDragStart = (e: ReactDragEvent) => { + if (!isDraggable) { + // Belt-and-suspenders: `draggable={false}` on the DOM should already + // prevent this, but guard against prop-flip races between render + // and event dispatch. + e.preventDefault(); + return; + } + // Cancel any in-flight snap-back from a previous drag (quick re-drag). + if (snapBackRafRef.current !== null) { + cancelAnimationFrame(snapBackRafRef.current); + snapBackRafRef.current = null; + } + // Clear any stale stashed drop rect for this piece (e.g. from a prior + // invalid drop that never got consumed by a remount). + pendingDropRects.delete(pieceId); + if (staleStashTimerRef.current !== null) { + clearTimeout(staleStashTimerRef.current); + staleStashTimerRef.current = null; + } + e.dataTransfer.effectAllowed = 'move'; // Firefox requires non-empty drag data for dragstart to take effect. e.dataTransfer.setData('text/plain', `${pieceId}@${square}`); - // Suppress the browser's default ghost image — we'd rather the piece - // stay visually in place while motion handles the move animation. + // Suppress the browser's default ghost image — we render our own + // dangling piece via the motion layer. const img = new Image(); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; e.dataTransfer.setDragImage(img, 0, 0); + + originRef.current = { x: e.clientX, y: e.clientY }; + xRaw.set(0); + yRaw.set(0); + setIsDragging(true); + + // Attach a document-level dragover listener. Unlike `drag` on the + // source, `dragover` fires on every pointer movement with accurate + // coordinates across Chromium, Firefox, and WebKit. + detachDragOver(); + const onDocDragOver = (ev: globalThis.DragEvent) => { + if (!originRef.current) return; + if (ev.clientX === 0 && ev.clientY === 0) return; + xRaw.set(ev.clientX - originRef.current.x); + yRaw.set(ev.clientY - originRef.current.y); + }; + document.addEventListener('dragover', onDocDragOver); + dragOverHandlerRef.current = onDocDragOver; + onDragStart(pieceId, square); }; + const handleDragEnd = () => { + detachDragOver(); + originRef.current = null; + setIsDragging(false); + + // Snapshot the piece's *rendered* center (including the cursor-offset + // transform) BEFORE notifying the parent, because `onDragEnd` is + // synchronous and React will apply the new board state — unmounting + // us — as soon as we return. The rect has to be captured while the + // transformed element still exists in the DOM. + const node = transformRef.current; + if (node) { + const rect = node.getBoundingClientRect(); + pendingDropRects.set(pieceId, { + cx: rect.left + rect.width / 2, + cy: rect.top + rect.height / 2, + }); + // Grace period for invalid drops: if no remount consumes the stash + // within a few frames, clear it so a later unrelated mount doesn't + // receive a stale FLIP. + if (staleStashTimerRef.current !== null) { + clearTimeout(staleStashTimerRef.current); + } + staleStashTimerRef.current = setTimeout(() => { + pendingDropRects.delete(pieceId); + staleStashTimerRef.current = null; + }, 100); + } + + // Notify parent. On a valid drop this triggers the unmount/remount + // that our `useLayoutEffect` consumes (via `pendingDropRects`). + onDragEnd(); + + // Defer the spring snap-back for invalid drops. If React unmounts us + // (valid drop), `mountedRef.current` is false and we skip the reset + // — the new mount handles the animation via the stashed rect. If we + // survive (invalid drop), zero the motion values so the spring + // animates the piece back to its origin square. + snapBackRafRef.current = requestAnimationFrame(() => { + snapBackRafRef.current = requestAnimationFrame(() => { + snapBackRafRef.current = null; + if (!mountedRef.current) return; + xRaw.set(0); + yRaw.set(0); + }); + }); + }; + + const cursorClass = isDraggable + ? 'cursor-grab active:cursor-grabbing' + : 'cursor-default'; + return ( - +
- {`${color} + + {`${color} +
- +
); }