import { useCallback, useRef, useState, useEffect, useLayoutEffect, useMemo } from 'react'; import type { Pieces, Square, Orientation, Arrow, ArrowBrushes, PieceColor, PromotionPiece, PromotionContext, Dests, PremoveConfig, } from '../types'; import { DEFAULT_ARROW_BRUSHES } from '../types'; import { screenPos2square } from '../utils/coords'; import { premoveDests } from '../utils/premove'; import { getSquareFromEvent, getClientPos, isRightButton } from './pointer'; import type { DragState } from './pointer'; const DRAG_THRESHOLD_MOUSE = 4; const DRAG_THRESHOLD_TOUCH = 10; const TOUCH_MOUSE_SUPPRESS_MS = 500; const EMPTY_SQUARES: string[] = []; const EMPTY_ARROWS: Arrow[] = []; const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; function sameSquares(a: string[], b: string[]): boolean { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } return true; } function hasDragStarted(drag: DragState, isTouch: boolean): boolean { const dx = drag.currentPos[0] - drag.startPos[0]; const dy = drag.currentPos[1] - drag.startPos[1]; const threshold = isTouch ? DRAG_THRESHOLD_TOUCH : DRAG_THRESHOLD_MOUSE; return Math.sqrt(dx * dx + dy * dy) >= threshold; } function samePremove(a: [string, string] | null, b: [string, string] | null): boolean { return a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]); } function getControlledPremoveCurrent(premovable?: PremoveConfig): [string, string] | null | undefined { if (!premovable || !Object.prototype.hasOwnProperty.call(premovable, 'current')) { return undefined; } return premovable.current ?? null; } // Determine arrow brush color from modifier keys (matches chessground) function eventBrushColor(e: MouseEvent | TouchEvent, brushes: ArrowBrushes): string { if (!('shiftKey' in e)) return brushes.green; // touch events const modA = (e.shiftKey || e.ctrlKey); const modB = e.altKey || e.metaKey; const idx = (modA ? 1 : 0) + (modB ? 2 : 0); const keys: (keyof ArrowBrushes)[] = ['green', 'red', 'blue', 'yellow']; return brushes[keys[idx]]; } interface UseInteractionOptions { position: string; pieces: Pieces; orientation: Orientation; interactive: boolean; allowDragging: boolean; allowDrawingArrows: boolean; boardRef: React.RefObject; boardBounds: DOMRect | null; getFreshBounds?: () => DOMRect | null; onMove?: (from: string, to: string, promotion?: PromotionPiece) => boolean; dests?: Dests; turnColor?: PieceColor; movableColor?: PieceColor | 'both'; premovable?: PremoveConfig; arrows: Arrow[]; onArrowsChange?: (arrows: Arrow[]) => void; arrowBrushes?: Partial; snapArrowsToValidMoves?: boolean; markedSquares?: string[]; onMarkedSquaresChange?: (squares: string[]) => void; plyIndex?: number; plyArrows?: Map; onPlyArrowsChange?: (plyIndex: number, arrows: Arrow[]) => void; plyMarks?: Map; onPlyMarksChange?: (plyIndex: number, marks: string[]) => void; onSquareClick?: (square: string) => void; onClearOverlays?: () => void; blockTouchScroll?: boolean; } export interface InteractionState { selectedSquare: string | null; legalSquares: string[]; premoveSquares: string[]; premoveCurrent: [string, string] | null; pendingPromotion: PromotionContext | null; drag: DragState | null; dragHoverSquare: string | null; dragGhostRef: React.RefObject; activeMarkedSquares: Record; renderedArrows: Arrow[]; clearSelection: () => void; handlePointerDown: (e: React.MouseEvent | React.TouchEvent) => void; handlePromotionSelect: (piece: PromotionPiece) => void; handlePromotionDismiss: () => void; } export function useInteraction(opts: UseInteractionOptions): InteractionState { const { position, pieces, orientation, interactive, allowDragging, allowDrawingArrows, boardRef, boardBounds, getFreshBounds, onMove, dests, turnColor, movableColor, premovable, arrows, onArrowsChange, arrowBrushes: customBrushes, snapArrowsToValidMoves = true, markedSquares: externalMarkedSquares, onMarkedSquaresChange, plyIndex, plyArrows, onPlyArrowsChange, plyMarks, onPlyMarksChange, onSquareClick, onClearOverlays, blockTouchScroll, } = opts; const asWhite = orientation === 'white'; const brushes: ArrowBrushes = useMemo( () => ({ ...DEFAULT_ARROW_BRUSHES, ...customBrushes }), [customBrushes], ); const [selectedSquare, setSelectedSquare] = useState(null); const [legalSquares, setLegalSquares] = useState([]); const [premoveSquares, setPremoveSquares] = useState([]); const [premoveCurrent, setPremoveCurrent] = useState<[string, string] | null>(null); const [pendingPromotion, setPendingPromotion] = useState(null); const [dragHoverSquare, setDragHoverSquare] = useState(null); const [drag, setDrag] = useState(null); // Live arrow being drawn (right-drag in progress). Rendered as a preview so the // arrow follows the cursor instead of only appearing once the drag is released. const [drawingArrow, setDrawingArrow] = useState(null); const dragGhostRef = useRef(null); const [internalArrowsMap, setInternalArrowsMap] = useState>(new Map()); const [internalMarksMap, setInternalMarksMap] = useState>(new Map()); const arrowStartRef = useRef(null); const arrowColorRef = useRef(brushes.green); const arrowPosRef = useRef<[number, number] | null>(null); // track mouse pos during arrow draw const drawingArrowRef = useRef(null); // last previewed arrow, for cheap change detection const justDrewArrowRef = useRef(false); const dragKeyChangedRef = useRef(false); const isTouchRef = useRef(false); const lastTouchTsRef = useRef(0); const selectedRef = useRef(selectedSquare); const legalRef = useRef(legalSquares); const premoveRef = useRef(premoveSquares); const pendingPromotionRef = useRef(pendingPromotion); const piecesRef = useRef(pieces); const dragRef = useRef(null); const arrowsRef = useRef(arrows); const internalMarksMapRef = useRef(internalMarksMap); const internalArrowsMapRef = useRef(internalArrowsMap); const activeBoundsRef = useRef(null); selectedRef.current = selectedSquare; legalRef.current = legalSquares; premoveRef.current = premoveSquares; pendingPromotionRef.current = pendingPromotion; piecesRef.current = pieces; arrowsRef.current = arrows; internalMarksMapRef.current = internalMarksMap; internalArrowsMapRef.current = internalArrowsMap; // When turnColor/movableColor not provided, allow both colors (free mode) const freeMode = turnColor === undefined && movableColor === undefined; // Is this piece's color the one that can move right now? const canMoveColor = useCallback((_color: PieceColor): boolean => { if (freeMode) return true; const effective = movableColor ?? turnColor ?? 'w'; if (effective === 'both') return true; return _color === effective && (turnColor === undefined || _color === turnColor); }, [freeMode, movableColor, turnColor]); // Is this piece premovable? (it's our color but not our turn) const canPremoveColor = useCallback((_color: PieceColor): boolean => { if (freeMode) return false; if (!premovable?.enabled) return false; const effective = movableColor ?? turnColor ?? 'w'; if (effective === 'both') return false; return _color === effective && turnColor !== undefined && _color !== turnColor; }, [freeMode, movableColor, turnColor, premovable?.enabled]); // Reset selection on position change. // // Exception: if the user had a piece selected and that same piece is still // on the same square (i.e. the position change was the opponent's move, not // ours), AND it is now our turn, keep the selection alive and just refresh // the legal-move dots from the new `dests`. Without this, tapping a piece // while the opponent is thinking would "flash off" the moment they moved. useEffect(() => { const sel = selectedRef.current; if (sel !== null) { const piece = piecesRef.current.get(sel as Square); if (piece && canMoveColor(piece.color)) { const nextLegal = dests?.get(sel as Square) ?? EMPTY_SQUARES; if (!sameSquares(legalRef.current, nextLegal)) { setLegalSquares(nextLegal.length === 0 ? EMPTY_SQUARES : [...nextLegal]); } if (premoveRef.current.length > 0) setPremoveSquares(EMPTY_SQUARES); if (pendingPromotionRef.current !== null) setPendingPromotion(null); return; } } if ( selectedRef.current === null && legalRef.current.length === 0 && premoveRef.current.length === 0 && pendingPromotionRef.current === null ) { return; } setSelectedSquare(null); setLegalSquares(EMPTY_SQUARES); setPremoveSquares(EMPTY_SQUARES); setPendingPromotion(null); }, [position, dests, canMoveColor]); // Apply premove when turn changes (if there's a stored premove). Use a layout // effect so the opponent's move and the immediate premove response do not // paint as two separate board states. useIsomorphicLayoutEffect(() => { if (!premoveCurrent || !premovable?.enabled) return; const [from, to] = premoveCurrent; const piece = piecesRef.current.get(from as Square); if (piece && piece.color === turnColor) { // It's now our turn and we have a premove stored const validDests = dests?.get(from as Square) || []; if (validDests.includes(to as Square) || !dests) { setPremoveCurrent(null); premovable.events?.unset?.(); onMove?.(from, to); } else { // Premove is not valid in the new position, cancel it setPremoveCurrent(null); premovable.events?.unset?.(); } } }, [turnColor, premoveCurrent, premovable, dests, onMove]); // Sync external premove.current. The presence of the `current` key means the // premove is controlled, and null/undefined intentionally clears it. useEffect(() => { const controlledCurrent = getControlledPremoveCurrent(premovable); if (controlledCurrent === undefined) return; setPremoveCurrent(prev => ( samePremove(prev, controlledCurrent) ? prev : controlledCurrent )); if (controlledCurrent === null) { setPremoveSquares(prev => (prev.length === 0 ? prev : EMPTY_SQUARES)); } }, [premovable]); useEffect(() => { if (premovable?.enabled) return; setPremoveCurrent(prev => (prev === null ? prev : null)); setPremoveSquares(prev => (prev.length === 0 ? prev : EMPTY_SQUARES)); }, [premovable?.enabled]); const getDestsForSquare = useCallback((sq: string): string[] => { if (dests) return dests.get(sq as Square) || []; return []; }, [dests]); const getCurrentBounds = useCallback((): DOMRect | null => { return getFreshBounds?.() ?? boardBounds; }, [boardBounds, getFreshBounds]); const attemptMove = useCallback((from: string, to: string, promotion?: PromotionPiece): 'pending' | boolean => { if (!onMove || !interactive) return false; const validDests = getDestsForSquare(from); if (dests && !validDests.includes(to)) return false; const piece = piecesRef.current.get(from as Square); if (piece?.role === 'p' && !promotion) { const toRank = parseInt(to[1]); if ((piece.color === 'w' && toRank === 8) || (piece.color === 'b' && toRank === 1)) { setPendingPromotion({ from, to, color: piece.color }); return 'pending'; } } const success = onMove(from, to, promotion); if (success) { setSelectedSquare(null); setLegalSquares([]); setPremoveSquares([]); } return success; }, [onMove, interactive, getDestsForSquare, dests]); const attemptPremove = useCallback((from: string, to: string) => { if (!premovable?.enabled) return; setPremoveCurrent([from, to]); premovable.events?.set?.(from, to); setSelectedSquare(null); setLegalSquares([]); setPremoveSquares([]); }, [premovable]); const clearSelection = useCallback(() => { setSelectedSquare(null); setLegalSquares([]); setPremoveSquares([]); setPendingPromotion(null); if (premoveCurrent) { setPremoveCurrent(null); premovable?.events?.unset?.(); } }, [premoveCurrent, premovable]); // ── Clear overlays for current ply ── const clearOverlaysForPly = useCallback(() => { const ply = plyIndex ?? 0; let changed = false; if (externalMarkedSquares !== undefined) { if (externalMarkedSquares.length > 0) { onMarkedSquaresChange?.([]); changed = true; } } else if (onPlyMarksChange && plyIndex !== undefined) { const current = plyMarks?.get(plyIndex) || EMPTY_SQUARES; if (current.length > 0) { onPlyMarksChange(plyIndex, []); changed = true; } } else { const current = internalMarksMapRef.current.get(ply) || EMPTY_SQUARES; if (current.length > 0) { setInternalMarksMap(prev => { const m = new Map(prev); m.set(ply, EMPTY_SQUARES); return m; }); changed = true; } } if (onPlyArrowsChange && plyIndex !== undefined) { const current = plyArrows?.get(plyIndex) || EMPTY_ARROWS; if (current.length > 0) { onPlyArrowsChange(plyIndex, []); changed = true; } } else if (onArrowsChange) { if (arrowsRef.current.length > 0) { onArrowsChange([]); changed = true; } } else { const current = internalArrowsMapRef.current.get(ply) || EMPTY_ARROWS; if (current.length > 0) { setInternalArrowsMap(prev => { const m = new Map(prev); m.set(ply, EMPTY_ARROWS); return m; }); changed = true; } } if (changed) { onClearOverlays?.(); } }, [plyIndex, externalMarkedSquares, onMarkedSquaresChange, onPlyMarksChange, onPlyArrowsChange, onArrowsChange, onClearOverlays, plyMarks, plyArrows]); // ── Square click/selection logic ── const handleSquareInteraction = useCallback((sq: string) => { if (!interactive) { onSquareClick?.(sq); return; } if (justDrewArrowRef.current) { justDrewArrowRef.current = false; return; } const sel = selectedRef.current; const legal = legalRef.current; const pmDests = premoveRef.current; clearOverlaysForPly(); // Cancel existing premove on any click if (premoveCurrent) { setPremoveCurrent(null); premovable?.events?.unset?.(); } // If we have a selection and the target is a legal move if (sel && legal.includes(sq)) { const result = attemptMove(sel, sq); if (result === 'pending' || result) return; } // If we have a selection and the target is a premove destination if (sel && pmDests.includes(sq)) { attemptPremove(sel, sq); return; } // Clicking the same square deselects if (sel === sq) { clearSelection(); return; } // Clicking a piece const piece = piecesRef.current.get(sq as Square); if (piece) { // Can move this piece normally if (canMoveColor(piece.color)) { const targets = getDestsForSquare(sq); if (targets.length > 0 || !dests) { setSelectedSquare(sq); setLegalSquares(targets); setPremoveSquares([]); onSquareClick?.(sq); return; } } // Can premove this piece if (canPremoveColor(piece.color)) { const pmTargets = premoveDests(sq as Square, piecesRef.current, piece.color); if (pmTargets.length > 0) { setSelectedSquare(sq); setLegalSquares([]); setPremoveSquares(pmTargets); onSquareClick?.(sq); return; } } } clearSelection(); onSquareClick?.(sq); }, [interactive, attemptMove, attemptPremove, getDestsForSquare, dests, canMoveColor, canPremoveColor, premoveCurrent, premovable, onSquareClick, clearOverlaysForPly, clearSelection]); // ── Toggle arrow/mark helpers ── const toggleArrow = useCallback((start: string, end: string, color: string) => { const ply = plyIndex ?? 0; const key = `${start}-${end}`; const current = (plyArrows && plyIndex !== undefined) ? (plyArrows.get(plyIndex) || []) : (onArrowsChange ? arrowsRef.current : (internalArrowsMap.get(ply) || [])); const exists = current.some(a => `${a.startSquare}-${a.endSquare}` === key); const next = exists ? current.filter(a => `${a.startSquare}-${a.endSquare}` !== key) : [...current, { startSquare: start, endSquare: end, color }]; if (onPlyArrowsChange && plyIndex !== undefined) { onPlyArrowsChange(plyIndex, next); } else if (onArrowsChange) { onArrowsChange(next); } else { setInternalArrowsMap(prev => { const m = new Map(prev); m.set(ply, next); return m; }); } }, [plyIndex, plyArrows, onPlyArrowsChange, onArrowsChange, internalArrowsMap]); const toggleMark = useCallback((sq: string) => { const ply = plyIndex ?? 0; if (externalMarkedSquares !== undefined) { const set = new Set(externalMarkedSquares); if (set.has(sq)) set.delete(sq); else set.add(sq); onMarkedSquaresChange?.(Array.from(set)); } else if (onPlyMarksChange && plyIndex !== undefined) { const current = plyMarks?.get(plyIndex) || []; const set = new Set(current); if (set.has(sq)) set.delete(sq); else set.add(sq); onPlyMarksChange(plyIndex, Array.from(set)); } else { setInternalMarksMap(prev => { const m = new Map(prev); const set = new Set(m.get(ply) || []); if (set.has(sq)) set.delete(sq); else set.add(sq); m.set(ply, Array.from(set)); return m; }); } }, [plyIndex, externalMarkedSquares, onMarkedSquaresChange, plyMarks, onPlyMarksChange]); // ── Snap arrow destination to closest valid queen/knight square (pixel distance) ── // This matches chessground's getSnappedKeyAtDomPos approach const getSnappedSquare = useCallback((origSq: string, clientX: number, clientY: number): string | undefined => { const activeBounds = activeBoundsRef.current ?? getCurrentBounds(); if (!activeBounds) return undefined; const origF = origSq.charCodeAt(0) - 97; const origR = parseInt(origSq[1]) - 1; let bestSq: string | undefined; let bestDist = Infinity; for (let f = 0; f < 8; f++) { for (let r = 0; r < 8; r++) { if (f === origF && r === origR) continue; // Only queen or knight directions const df = Math.abs(f - origF); const dr = Math.abs(r - origR); const isKnight = (df === 1 && dr === 2) || (df === 2 && dr === 1); const isQueen = df === 0 || dr === 0 || df === dr; if (!isKnight && !isQueen) continue; // Compute pixel center of this square const col = asWhite ? f : 7 - f; const row = asWhite ? 7 - r : r; const cx = activeBounds.left + (col + 0.5) * activeBounds.width / 8; const cy = activeBounds.top + (row + 0.5) * activeBounds.height / 8; const dx = clientX - cx; const dy = clientY - cy; const dist = dx * dx + dy * dy; if (dist < bestDist) { bestDist = dist; bestSq = String.fromCharCode(97 + f) + (r + 1); } } } return bestSq; }, [asWhite, getCurrentBounds]); // ── Pointer down handler ── const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => { const nativeEvent = e.nativeEvent; const isTouch = 'touches' in nativeEvent; isTouchRef.current = isTouch; if (isTouch) { lastTouchTsRef.current = Date.now(); } else if (Date.now() - lastTouchTsRef.current < TOUCH_MOUSE_SUPPRESS_MS) { // Mobile browsers can emit a synthetic mouse sequence after touch. // Ignore it to avoid select->immediate-deselect flicker on tap. return; } // Capture fresh geometry for the full pointer sequence. ResizeObserver can lag // behind a user resize, but pointer hit-testing must use the current DOM rect. const activeBounds = getCurrentBounds(); if (!activeBounds) return; // Promotion chooser is modal: ignore board pointer handling until resolved. if (pendingPromotion) return; activeBoundsRef.current = activeBounds; const sq = getSquareFromEvent(nativeEvent, asWhite, activeBounds); if (!sq) return; // Right-click: arrow drawing if ('button' in e && isRightButton(e as React.MouseEvent)) { if (allowDrawingArrows) { e.preventDefault(); arrowStartRef.current = sq; arrowColorRef.current = eventBrushColor(nativeEvent as MouseEvent, brushes); const pos = getClientPos(nativeEvent); arrowPosRef.current = pos ?? null; } return; } // Left click: clear arrows/marks const piece = piecesRef.current.get(sq); const pos = getClientPos(nativeEvent); if (!pos) return; // Initiate drag if there's a piece and dragging is allowed let startedDragCandidate = false; if (piece && interactive && allowDragging) { const canMove = canMoveColor(piece.color); const canPremove = canPremoveColor(piece.color); if (canMove || canPremove) { dragKeyChangedRef.current = false; const newDrag = { origSquare: sq, piece, startPos: pos, currentPos: pos, started: false, isTouch, }; dragRef.current = newDrag; setDrag(newDrag); startedDragCandidate = true; } } // Prevent touch scroll if configured or if interacting with a piece if (blockTouchScroll && 'touches' in e && piece) { e.preventDefault(); } if (!startedDragCandidate) { handleSquareInteraction(sq); } }, [getCurrentBounds, pendingPromotion, asWhite, interactive, allowDragging, allowDrawingArrows, handleSquareInteraction, brushes, canMoveColor, canPremoveColor, blockTouchScroll]); // ── Document-level move/up for drag and arrow drawing ── useEffect(() => { const handleMove = (e: MouseEvent | TouchEvent) => { const pos = getClientPos(e); if (!pos) return; // Track mouse position + update the live arrow preview while drawing. if (arrowStartRef.current) { arrowPosRef.current = pos; const arrowBounds = activeBoundsRef.current ?? getCurrentBounds(); if (arrowBounds) { const startSq = arrowStartRef.current; const rawSq = screenPos2square(pos[0], pos[1], asWhite, arrowBounds); let endSq: string | undefined; if (!rawSq || rawSq === startSq) { // Hovering the origin square draws nothing (that gesture toggles a mark). endSq = undefined; } else if (snapArrowsToValidMoves) { endSq = getSnappedSquare(startSq, pos[0], pos[1]); } else { endSq = rawSq; } const next: Arrow | null = (endSq && endSq !== startSq) ? { startSquare: startSq, endSquare: endSq, color: arrowColorRef.current } : null; const prev = drawingArrowRef.current; // Only re-render when the previewed arrow actually changes (square granularity). if ( prev?.startSquare !== next?.startSquare || prev?.endSquare !== next?.endSquare || prev?.color !== next?.color ) { drawingArrowRef.current = next; setDrawingArrow(next); } } } if (blockTouchScroll && 'touches' in e && (arrowStartRef.current || dragRef.current)) { e.preventDefault(); } let dragStartedSquare: string | null = null; if (dragRef.current) { dragRef.current.currentPos = pos; if (!dragRef.current.started && hasDragStarted(dragRef.current, isTouchRef.current)) { dragRef.current.started = true; dragStartedSquare = dragRef.current.origSquare; // React state only needs to know that drag has formally started, // so it mounts the DragGhost. Subsequent moves are purely DOM-managed. setDrag({ ...dragRef.current }); } const activeBounds = activeBoundsRef.current ?? getCurrentBounds(); if (dragRef.current.started && activeBounds) { const currentSq = screenPos2square(pos[0], pos[1], asWhite, activeBounds); if (currentSq && currentSq !== dragRef.current.origSquare) { dragKeyChangedRef.current = true; } const origSq = dragRef.current.origSquare; setDragHoverSquare(prev => { const next = (currentSq && currentSq !== origSq) ? currentSq : null; return prev === next ? prev : next; }); if (dragGhostRef.current) { const squareSize = activeBounds.width / 8; const offset = squareSize / 2; dragGhostRef.current.style.transform = `translate(${pos[0] - offset}px, ${pos[1] - offset}px)`; } } } if (dragStartedSquare) { const piece = piecesRef.current.get(dragStartedSquare as Square); if (!piece) return; if (canMoveColor(piece.color)) { const targets = getDestsForSquare(dragStartedSquare); if (targets.length > 0 || !dests) { setSelectedSquare(prev => (prev === dragStartedSquare ? prev : dragStartedSquare)); setLegalSquares(prev => (sameSquares(prev, targets) ? prev : targets)); setPremoveSquares(prev => (prev.length === 0 ? prev : EMPTY_SQUARES)); return; } } if (canPremoveColor(piece.color)) { const pmTargets = premoveDests(dragStartedSquare as Square, piecesRef.current, piece.color); if (pmTargets.length > 0) { setSelectedSquare(prev => (prev === dragStartedSquare ? prev : dragStartedSquare)); setLegalSquares(prev => (prev.length === 0 ? prev : EMPTY_SQUARES)); setPremoveSquares(prev => (sameSquares(prev, pmTargets) ? prev : pmTargets)); } } } }; const handleUp = (e: MouseEvent | TouchEvent) => { const releaseBounds = activeBoundsRef.current ?? getCurrentBounds(); // Arrow drawing end if ('button' in e && isRightButton(e as MouseEvent) && arrowStartRef.current && releaseBounds) { const startSq = arrowStartRef.current; const color = arrowColorRef.current; // Use the last tracked position (more reliable than event position for right-click) const pos = arrowPosRef.current || getClientPos(e); arrowStartRef.current = null; arrowPosRef.current = null; // Tear down the live preview; the committed arrow (if any) renders below. if (drawingArrowRef.current) { drawingArrowRef.current = null; setDrawingArrow(null); } if (pos) { // Get the raw square under cursor const rawSq = screenPos2square(pos[0], pos[1], asWhite, releaseBounds); if (rawSq === startSq || !rawSq) { // Same square or no square: toggle mark toggleMark(startSq); } else if (snapArrowsToValidMoves) { // Snap to closest valid queen/knight direction const snapped = getSnappedSquare(startSq, pos[0], pos[1]); if (snapped && snapped !== startSq) { toggleArrow(startSq, snapped, color); justDrewArrowRef.current = true; setTimeout(() => { justDrewArrowRef.current = false; }, 150); } } else { toggleArrow(startSq, rawSq, color); justDrewArrowRef.current = true; setTimeout(() => { justDrewArrowRef.current = false; }, 150); } } activeBoundsRef.current = null; return; } // Drag end const capturedDrag = dragRef.current; activeBoundsRef.current = null; setDrag(null); dragRef.current = null; setDragHoverSquare(null); if (capturedDrag && !capturedDrag.started) { handleSquareInteraction(capturedDrag.origSquare); return; } queueMicrotask(() => { if (!capturedDrag) return; const freshBounds = getCurrentBounds() ?? releaseBounds; if (!freshBounds || !interactive) return; const pos = getClientPos(e); const target = pos ? screenPos2square(pos[0], pos[1], asWhite, freshBounds) : undefined; if (target && target !== capturedDrag.origSquare) { const piece = piecesRef.current.get(capturedDrag.origSquare); if (piece) { // Try normal move first if (canMoveColor(piece.color)) { const result = attemptMove(capturedDrag.origSquare, target); if (result === 'pending' || result) return; } // Try premove if (canPremoveColor(piece.color)) { const pmDests = premoveDests(capturedDrag.origSquare as Square, piecesRef.current, piece.color); if (pmDests.includes(target as Square)) { attemptPremove(capturedDrag.origSquare, target); return; } } } } else if (capturedDrag.origSquare === target || !target) { if (dragKeyChangedRef.current) { setSelectedSquare(null); setLegalSquares(EMPTY_SQUARES); setPremoveSquares(EMPTY_SQUARES); } } }); }; document.addEventListener('mousemove', handleMove); document.addEventListener('touchmove', handleMove, { passive: !blockTouchScroll }); document.addEventListener('mouseup', handleUp); document.addEventListener('touchend', handleUp); return () => { document.removeEventListener('mousemove', handleMove); document.removeEventListener('touchmove', handleMove); document.removeEventListener('mouseup', handleUp); document.removeEventListener('touchend', handleUp); }; }, [getCurrentBounds, asWhite, interactive, attemptMove, attemptPremove, toggleArrow, toggleMark, getSnappedSquare, snapArrowsToValidMoves, canMoveColor, canPremoveColor, blockTouchScroll, getDestsForSquare, dests, handleSquareInteraction]); // Prevent context menu on the board useEffect(() => { const el = boardRef.current; if (!el) return; const handler = (e: Event) => e.preventDefault(); el.addEventListener('contextmenu', handler); return () => el.removeEventListener('contextmenu', handler); }, [boardRef]); // ── Computed values ── const activeMarkedSquares = useMemo((): Record => { if (externalMarkedSquares !== undefined) { return Object.fromEntries(externalMarkedSquares.map(s => [s, true])); } const ply = plyIndex ?? 0; const marks = (plyMarks && plyIndex !== undefined ? plyMarks.get(plyIndex) : undefined) || internalMarksMap.get(ply) || []; return Object.fromEntries(marks.map(s => [s, true])); }, [externalMarkedSquares, plyIndex, plyMarks, internalMarksMap]); const renderedArrows = useMemo((): Arrow[] => { const final: Arrow[] = []; const seen = new Set(); const ply = plyIndex ?? 0; const lists: Arrow[][] = []; if (plyArrows && plyIndex !== undefined) { lists.push(plyArrows.get(plyIndex) || []); lists.push(arrows); } else if (onArrowsChange) { lists.push(arrows); } else { lists.push(internalArrowsMap.get(ply) || []); lists.push(arrows); } for (const list of lists) { for (const a of list) { const k = `${a.startSquare}-${a.endSquare}`; if (!seen.has(k)) { final.push(a); seen.add(k); } } } // Live preview of the arrow currently being drawn, on top of committed arrows. if (drawingArrow) { const k = `${drawingArrow.startSquare}-${drawingArrow.endSquare}`; if (!seen.has(k)) final.push(drawingArrow); } return final; }, [arrows, plyIndex, plyArrows, internalArrowsMap, onArrowsChange, drawingArrow]); const handlePromotionSelect = useCallback((piece: PromotionPiece) => { if (!pendingPromotion) return; setPendingPromotion(null); attemptMove(pendingPromotion.from, pendingPromotion.to, piece); }, [pendingPromotion, attemptMove]); const handlePromotionDismiss = useCallback(() => { clearSelection(); }, [clearSelection]); return { selectedSquare, legalSquares, premoveSquares, premoveCurrent, pendingPromotion, drag: drag?.started ? drag : null, dragHoverSquare, dragGhostRef, activeMarkedSquares, renderedArrows, clearSelection, handlePointerDown, handlePromotionSelect, handlePromotionDismiss, }; }