+
{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 (
-
+
-

+
+
+
-
+
);
}