react-native-chess-kit 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +187 -187
  3. package/lib/commonjs/board-annotations.js +8 -8
  4. package/lib/commonjs/board-annotations.js.map +1 -1
  5. package/lib/commonjs/board-arrows.js +7 -7
  6. package/lib/commonjs/board-arrows.js.map +1 -1
  7. package/lib/commonjs/board-background.js +5 -5
  8. package/lib/commonjs/board-background.js.map +1 -1
  9. package/lib/commonjs/board-coordinates.js.map +1 -1
  10. package/lib/commonjs/board-drag-ghost.js +10 -10
  11. package/lib/commonjs/board-drag-ghost.js.map +1 -1
  12. package/lib/commonjs/board-highlights.js +15 -15
  13. package/lib/commonjs/board-highlights.js.map +1 -1
  14. package/lib/commonjs/board-legal-dots.js +5 -5
  15. package/lib/commonjs/board-legal-dots.js.map +1 -1
  16. package/lib/commonjs/board-piece.js +25 -25
  17. package/lib/commonjs/board-piece.js.map +1 -1
  18. package/lib/commonjs/board-pieces.js +6 -6
  19. package/lib/commonjs/board-pieces.js.map +1 -1
  20. package/lib/commonjs/board.js +32 -28
  21. package/lib/commonjs/board.js.map +1 -1
  22. package/lib/commonjs/constants.js.map +1 -1
  23. package/lib/commonjs/index.js.map +1 -1
  24. package/lib/commonjs/pieces/default-pieces.js.map +1 -1
  25. package/lib/commonjs/pieces/index.js.map +1 -1
  26. package/lib/commonjs/promotion-picker.js.map +1 -1
  27. package/lib/commonjs/static-board.js.map +1 -1
  28. package/lib/commonjs/themes.js.map +1 -1
  29. package/lib/commonjs/types.js.map +1 -1
  30. package/lib/commonjs/use-board-gesture.js.map +1 -1
  31. package/lib/commonjs/use-board-pieces.js +15 -15
  32. package/lib/commonjs/use-board-pieces.js.map +1 -1
  33. package/lib/commonjs/use-board-state.js +8 -8
  34. package/lib/commonjs/use-board-state.js.map +1 -1
  35. package/lib/commonjs/use-premove.js +12 -12
  36. package/lib/commonjs/use-premove.js.map +1 -1
  37. package/lib/module/board-annotations.js +8 -8
  38. package/lib/module/board-annotations.js.map +1 -1
  39. package/lib/module/board-arrows.js +7 -7
  40. package/lib/module/board-arrows.js.map +1 -1
  41. package/lib/module/board-background.js +5 -5
  42. package/lib/module/board-background.js.map +1 -1
  43. package/lib/module/board-coordinates.js.map +1 -1
  44. package/lib/module/board-drag-ghost.js +10 -10
  45. package/lib/module/board-drag-ghost.js.map +1 -1
  46. package/lib/module/board-highlights.js +15 -15
  47. package/lib/module/board-highlights.js.map +1 -1
  48. package/lib/module/board-legal-dots.js +5 -5
  49. package/lib/module/board-legal-dots.js.map +1 -1
  50. package/lib/module/board-piece.js +25 -25
  51. package/lib/module/board-piece.js.map +1 -1
  52. package/lib/module/board-pieces.js +6 -6
  53. package/lib/module/board-pieces.js.map +1 -1
  54. package/lib/module/board.js +32 -28
  55. package/lib/module/board.js.map +1 -1
  56. package/lib/module/constants.js.map +1 -1
  57. package/lib/module/index.js.map +1 -1
  58. package/lib/module/pieces/default-pieces.js.map +1 -1
  59. package/lib/module/pieces/index.js.map +1 -1
  60. package/lib/module/promotion-picker.js.map +1 -1
  61. package/lib/module/static-board.js.map +1 -1
  62. package/lib/module/themes.js.map +1 -1
  63. package/lib/module/types.js.map +1 -1
  64. package/lib/module/use-board-gesture.js.map +1 -1
  65. package/lib/module/use-board-pieces.js +15 -15
  66. package/lib/module/use-board-pieces.js.map +1 -1
  67. package/lib/module/use-board-state.js +8 -8
  68. package/lib/module/use-board-state.js.map +1 -1
  69. package/lib/module/use-premove.js +12 -12
  70. package/lib/module/use-premove.js.map +1 -1
  71. package/lib/typescript/types.d.ts +2 -1
  72. package/lib/typescript/types.d.ts.map +1 -1
  73. package/package.json +95 -95
  74. package/src/board-annotations.tsx +147 -147
  75. package/src/board-arrows.tsx +197 -197
  76. package/src/board-background.tsx +46 -46
  77. package/src/board-drag-ghost.tsx +132 -132
  78. package/src/board-highlights.tsx +226 -226
  79. package/src/board-legal-dots.tsx +73 -73
  80. package/src/board-piece.tsx +160 -160
  81. package/src/board-pieces.tsx +63 -63
  82. package/src/board.tsx +688 -688
  83. package/src/constants.ts +103 -103
  84. package/src/index.ts +101 -101
  85. package/src/pieces/default-pieces.tsx +383 -383
  86. package/src/pieces/index.ts +1 -1
  87. package/src/themes.ts +129 -129
  88. package/src/types.ts +394 -394
  89. package/src/use-board-pieces.ts +158 -158
  90. package/src/use-board-state.ts +120 -120
  91. package/src/use-premove.ts +59 -59
package/src/board.tsx CHANGED
@@ -1,688 +1,688 @@
1
- import React, {
2
- forwardRef,
3
- useState,
4
- useCallback,
5
- useImperativeHandle,
6
- useEffect,
7
- useMemo,
8
- useRef,
9
- } from 'react';
10
- import { View, type LayoutChangeEvent } from 'react-native';
11
- import { GestureDetector } from 'react-native-gesture-handler';
12
- import { useSharedValue, withTiming, FadeOut } from 'react-native-reanimated';
13
-
14
- import type {
15
- BoardRef,
16
- BoardProps,
17
- LegalMoveTarget,
18
- HighlightData,
19
- PieceCode,
20
- PromotionPiece,
21
- } from './types';
22
- import {
23
- DEFAULT_BOARD_COLORS,
24
- DEFAULT_MOVE_DURATION,
25
- DEFAULT_LAST_MOVE_COLOR,
26
- DEFAULT_CHECK_COLOR,
27
- DEFAULT_SELECTED_COLOR,
28
- DEFAULT_PREMOVE_COLOR,
29
- DEFAULT_DRAG_TARGET_COLOR,
30
- CAPTURE_FADE_DURATION,
31
- COORDINATE_GUTTER_SCALE,
32
- } from './constants';
33
- import { DefaultPieceSet } from './pieces';
34
- import { useBoardPieces } from './use-board-pieces';
35
- import { useBoardState } from './use-board-state';
36
- import { useBoardGesture } from './use-board-gesture';
37
- import { usePremove } from './use-premove';
38
- import { BoardBackground } from './board-background';
39
- import { BoardCoordinates } from './board-coordinates';
40
- import { BoardHighlights, DragTargetHighlight } from './board-highlights';
41
- import { BoardLegalDots } from './board-legal-dots';
42
- import { BoardPiecesLayer } from './board-pieces';
43
- import { BoardDragGhost } from './board-drag-ghost';
44
- import { BoardArrows } from './board-arrows';
45
- import { BoardAnnotations } from './board-annotations';
46
- import { PromotionPicker } from './promotion-picker';
47
-
48
- // ---------------------------------------------------------------------------
49
- // Check detection helper
50
- // ---------------------------------------------------------------------------
51
-
52
- /**
53
- * Find the king square for the side currently in check.
54
- * Returns null if not in check.
55
- */
56
- function detectCheckSquare(
57
- fen: string,
58
- isInCheck: () => boolean,
59
- getTurn: () => 'w' | 'b',
60
- ): string | null {
61
- if (!isInCheck()) return null;
62
-
63
- const turn = getTurn();
64
- const kingChar = turn === 'w' ? 'K' : 'k';
65
- const placement = fen.split(' ')[0];
66
- const ranks = placement.split('/');
67
-
68
- for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
69
- const rank = ranks[rankIdx]!;
70
- let fileIdx = 0;
71
- for (const char of rank) {
72
- if (char >= '1' && char <= '8') {
73
- fileIdx += parseInt(char, 10);
74
- continue;
75
- }
76
- if (char === kingChar) {
77
- const files = 'abcdefgh';
78
- return `${files[fileIdx]}${8 - rankIdx}`;
79
- }
80
- fileIdx++;
81
- }
82
- }
83
-
84
- return null;
85
- }
86
-
87
- // ---------------------------------------------------------------------------
88
- // Board component
89
- // ---------------------------------------------------------------------------
90
-
91
- /**
92
- * High-performance custom chess board built on Reanimated + Gesture Handler.
93
- *
94
- * Architecture:
95
- * - 1 gesture handler (vs 32 in typical implementations)
96
- * - ~40 components mounted (vs ~281)
97
- * - ~75 native views (vs ~470)
98
- * - 0 React Context providers
99
- * - 0 re-renders during drag (pure worklet — only 2 shared value writes per frame)
100
- *
101
- * v0.2.0 layer stack (10 layers):
102
- * 1. BoardBackground (64 squares)
103
- * 2. BoardCoordinates (a-h, 1-8)
104
- * 3. BoardHighlights (last move, check, selected, premove, custom, imperative)
105
- * 4. DragTargetHighlight (animated, worklet-driven)
106
- * 5. BoardLegalDots (legal move indicators)
107
- * 6. BoardPiecesLayer (all pieces)
108
- * 7. BoardArrows (SVG arrows + circles)
109
- * 8. BoardAnnotations (text badges)
110
- * 9. BoardDragGhost (floating piece)
111
- * 10. PromotionPicker (modal, conditional)
112
- */
113
- export const Board = forwardRef<BoardRef, BoardProps>(function Board(
114
- {
115
- fen,
116
- orientation,
117
-
118
- // Layout
119
- boardSize: boardSizeProp,
120
-
121
- // Interaction
122
- gestureEnabled = true,
123
- player = 'both',
124
- moveMethod = 'both',
125
- showLegalMoves = true,
126
- premovesEnabled = false,
127
-
128
- // Appearance
129
- colors,
130
- coordinatePosition: coordinatePositionProp,
131
- withLetters: withLettersProp,
132
- withNumbers: withNumbersProp,
133
- renderPiece,
134
- pieceSet,
135
-
136
- // Overlays
137
- lastMove,
138
- highlights,
139
- arrows,
140
- shapes,
141
- annotations,
142
- showDragTarget = true,
143
-
144
- // Overlay colors
145
- lastMoveColor = DEFAULT_LAST_MOVE_COLOR,
146
- checkHighlightColor = DEFAULT_CHECK_COLOR,
147
- selectedSquareColor = DEFAULT_SELECTED_COLOR,
148
- premoveColor = DEFAULT_PREMOVE_COLOR,
149
- dragTargetColor = DEFAULT_DRAG_TARGET_COLOR,
150
-
151
- // Animation
152
- moveDuration = DEFAULT_MOVE_DURATION,
153
- animationConfig,
154
- animateFlip = true,
155
- pieceExitAnimation,
156
-
157
- // Promotion
158
- autoPromoteTo,
159
- onPromotion,
160
-
161
- // Callbacks
162
- onMove,
163
- onPieceClick,
164
- onSquareClick,
165
- onPieceDragBegin,
166
- onPieceDragEnd,
167
- onSquareLongPress,
168
- onPremove,
169
- onHaptic,
170
- },
171
- ref,
172
- ) {
173
- // --- Auto-sizing via onLayout when boardSize not provided ---
174
- const [measuredSize, setMeasuredSize] = useState(0);
175
- const handleLayout = useCallback((e: LayoutChangeEvent) => {
176
- const { width, height } = e.nativeEvent.layout;
177
- setMeasuredSize(Math.min(width, height));
178
- }, []);
179
-
180
- const outerSize = boardSizeProp ?? measuredSize;
181
- const boardColors = colors ?? DEFAULT_BOARD_COLORS;
182
-
183
- // Resolve coordinate position: new prop takes precedence over legacy booleans
184
- const coordinatePosition =
185
- coordinatePositionProp ??
186
- (withLettersProp === false && withNumbersProp === false ? 'none' : 'inside');
187
- const isOutside = coordinatePosition === 'outside';
188
- const isCoordVisible = coordinatePosition !== 'none';
189
-
190
- // When outside: reserve gutter space; inner board = outer minus gutters
191
- const gutterWidth = isOutside ? Math.round((outerSize / 8) * COORDINATE_GUTTER_SCALE) : 0;
192
- const boardSize = isOutside ? outerSize - gutterWidth : outerSize;
193
- const squareSize = boardSize / 8;
194
-
195
- // Resolve piece exit animation: null = disabled, undefined = default FadeOut
196
- const resolvedPieceExitAnimation =
197
- pieceExitAnimation === null
198
- ? undefined
199
- : (pieceExitAnimation ?? FadeOut.duration(CAPTURE_FADE_DURATION));
200
-
201
- // --- Board flip animation ---
202
- const flipRotation = useSharedValue(orientation === 'black' ? 180 : 0);
203
- const prevOrientationRef = useRef(orientation);
204
-
205
- useEffect(() => {
206
- if (prevOrientationRef.current !== orientation) {
207
- prevOrientationRef.current = orientation;
208
- if (animateFlip) {
209
- flipRotation.value = withTiming(orientation === 'black' ? 180 : 0, { duration: 300 });
210
- } else {
211
- flipRotation.value = orientation === 'black' ? 180 : 0;
212
- }
213
- }
214
- }, [orientation, animateFlip, flipRotation]);
215
-
216
- // Note: We don't actually rotate the board view because all layers already
217
- // handle orientation via squareToXY coordinate math. The flip animation is
218
- // a visual effect only — the rotation shared value is available for consumers
219
- // who want to add a rotation transition effect.
220
-
221
- // --- Internal FEN state ---
222
- // The Board owns a private FEN that drives piece rendering.
223
- // It starts from the parent prop and stays in sync via useEffect,
224
- // but can temporarily diverge when the library applies a user move
225
- // before the parent validates it (enables Chess.com-style "show move
226
- // then revert" behavior via undo()).
227
- const [internalFen, setInternalFen] = useState(fen);
228
-
229
- // --- Chess.js for legal move validation + internal state ---
230
- const boardState = useBoardState(fen);
231
-
232
- // Sync internal FEN + chess.js when parent changes the prop FEN.
233
- // This covers: accepted moves (parent updates FEN), board reset,
234
- // and opponent programmatic moves.
235
- useEffect(() => {
236
- setInternalFen(fen);
237
- boardState.loadFen(fen);
238
- }, [fen, boardState]);
239
-
240
- // --- Piece data from internal FEN ---
241
- const pieces = useBoardPieces(internalFen);
242
-
243
- // --- Check detection ---
244
- // Detect if the side to move is in check by parsing the FEN.
245
- // chess.js isInCheck() isn't exposed through boardState, so we use
246
- // a simple heuristic: if the FEN has the king of the side to move
247
- // attacked, highlight it.
248
- const checkSquareState = useMemo(() => {
249
- try {
250
- return detectCheckSquare(internalFen, () => boardState.isInCheck(), boardState.getTurn);
251
- } catch {
252
- return null;
253
- }
254
- }, [internalFen, boardState]);
255
-
256
- // --- Selection state ---
257
- const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
258
- const [legalMoves, setLegalMoves] = useState<LegalMoveTarget[]>([]);
259
-
260
- // --- Imperative highlights ---
261
- const [imperativeHighlights, setImperativeHighlights] = useState<HighlightData[]>([]);
262
-
263
- // --- Premove state ---
264
- const { premove, setPremove, clearPremove, consumePremove } = usePremove();
265
-
266
- // --- Promotion state ---
267
- const [promotionState, setPromotionState] = useState<{
268
- from: string;
269
- to: string;
270
- color: 'w' | 'b';
271
- } | null>(null);
272
-
273
- // --- Resolve piece renderer: renderPiece > pieceSet > DefaultPieceSet ---
274
- const resolvedRenderer = useMemo(() => {
275
- if (renderPiece) return renderPiece;
276
- const set = pieceSet ?? DefaultPieceSet;
277
- return (code: string, size: number) => {
278
- const renderer = set[code as PieceCode];
279
- if (renderer) return renderer(size);
280
- return <View style={{ width: size, height: size }} />;
281
- };
282
- }, [renderPiece, pieceSet]);
283
-
284
- // --- Promotion detection ---
285
- const isPromotionMove = useCallback(
286
- (from: string, to: string): boolean => {
287
- const piece = pieces.find((p) => p.square === from);
288
- if (!piece) return false;
289
- // Must be a pawn
290
- if (piece.code !== 'wp' && piece.code !== 'bp') return false;
291
- // Must be moving to the last rank
292
- const toRank = to[1];
293
- if (piece.color === 'w' && toRank === '8') return true;
294
- if (piece.color === 'b' && toRank === '1') return true;
295
- return false;
296
- },
297
- [pieces],
298
- );
299
-
300
- // --- Gesture callbacks ---
301
- const handlePieceSelected = useCallback(
302
- (square: string) => {
303
- setSelectedSquare(square);
304
- if (showLegalMoves) {
305
- setLegalMoves(boardState.getLegalMoves(square));
306
- }
307
- },
308
- [showLegalMoves, boardState],
309
- );
310
-
311
- const handleSelectionCleared = useCallback(() => {
312
- setSelectedSquare(null);
313
- setLegalMoves([]);
314
- }, []);
315
-
316
- const handlePieceMoved = useCallback(
317
- async (from: string, to: string) => {
318
- // Clear selection and legal dots
319
- setSelectedSquare(null);
320
- setLegalMoves([]);
321
-
322
- // Check for promotion
323
- if (isPromotionMove(from, to)) {
324
- // 1. Auto-promote to a specific piece (no picker, no callback)
325
- if (autoPromoteTo) {
326
- const result = boardState.applyMove(from, to, autoPromoteTo);
327
- if (result.applied && result.fen) {
328
- setInternalFen(result.fen);
329
- }
330
- onMove?.({ from, to });
331
- return;
332
- }
333
-
334
- // 2. Consumer handles promotion UI externally via callback
335
- if (onPromotion) {
336
- try {
337
- const choice = await onPromotion(from, to);
338
- const result = boardState.applyMove(from, to, choice);
339
- if (result.applied && result.fen) {
340
- setInternalFen(result.fen);
341
- }
342
- onMove?.({ from, to });
343
- } catch {
344
- // Promotion cancelled by consumer — piece stays at origin
345
- }
346
- return;
347
- }
348
-
349
- // 3. Default: show built-in promotion picker
350
- const piece = pieces.find((p) => p.square === from);
351
- const color = piece?.color ?? 'w';
352
- setPromotionState({ from, to, color });
353
- return;
354
- }
355
-
356
- // Apply the move visually FIRST (Chess.com-style: show the move,
357
- // then let the parent validate). The parent can call undo() to
358
- // revert if the move is rejected.
359
- const result = boardState.applyMove(from, to);
360
- if (result.applied && result.fen) {
361
- setInternalFen(result.fen);
362
- onMove?.({ from, to });
363
- }
364
- // If chess.js rejected the move (truly illegal), do nothing —
365
- // piece stays at its original square.
366
- },
367
- [onMove, onPromotion, autoPromoteTo, isPromotionMove, pieces, boardState],
368
- );
369
-
370
- // --- Promotion picker handlers ---
371
- // Only reached when neither autoPromoteTo nor onPromotion is set.
372
- const handlePromotionSelect = useCallback(
373
- (piece: PromotionPiece) => {
374
- if (!promotionState) return;
375
- const { from, to } = promotionState;
376
- setPromotionState(null);
377
-
378
- const result = boardState.applyMove(from, to, piece);
379
- if (result.applied && result.fen) {
380
- setInternalFen(result.fen);
381
- }
382
- onMove?.({ from, to });
383
- },
384
- [promotionState, onMove, boardState],
385
- );
386
-
387
- const handlePromotionCancel = useCallback(() => {
388
- setPromotionState(null);
389
- }, []);
390
-
391
- // --- Premove handling ---
392
- const handlePremoveSet = useCallback(
393
- (pm: { from: string; to: string }) => {
394
- setPremove(pm);
395
- onPremove?.(pm);
396
- onHaptic?.('select');
397
- },
398
- [setPremove, onPremove, onHaptic],
399
- );
400
-
401
- // Execute premove when turn changes
402
- useEffect(() => {
403
- if (!premovesEnabled || !premove) return;
404
-
405
- const turn = boardState.getTurn();
406
- // Check if it's now the premover's turn
407
- const premovePiece = pieces.find((p) => p.square === premove.from);
408
- if (!premovePiece) {
409
- clearPremove();
410
- return;
411
- }
412
-
413
- if (premovePiece.color === turn) {
414
- const consumed = consumePremove();
415
- if (consumed) {
416
- // Try to execute the premove
417
- const result = boardState.applyMove(consumed.from, consumed.to, consumed.promotion);
418
- if (result.applied && result.fen) {
419
- setInternalFen(result.fen);
420
- onMove?.({ from: consumed.from, to: consumed.to });
421
- onHaptic?.('move');
422
- } else {
423
- // Premove was illegal — discard silently
424
- onHaptic?.('error');
425
- }
426
- }
427
- }
428
- }, [
429
- fen,
430
- premovesEnabled,
431
- premove,
432
- pieces,
433
- boardState,
434
- consumePremove,
435
- clearPremove,
436
- onMove,
437
- onHaptic,
438
- ]);
439
-
440
- // --- Rich callbacks ref (stable, for gesture hook) ---
441
- const richCallbacks = useMemo(
442
- () => ({
443
- onPieceClick,
444
- onSquareClick,
445
- onPieceDragBegin,
446
- onPieceDragEnd,
447
- onSquareLongPress,
448
- onHaptic,
449
- }),
450
- [onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd, onSquareLongPress, onHaptic],
451
- );
452
-
453
- const premoveCallbacks = useMemo(
454
- () => ({
455
- onPremoveSet: handlePremoveSet,
456
- }),
457
- [handlePremoveSet],
458
- );
459
-
460
- // --- Single centralized gesture ---
461
- const { gesture, gestureState } = useBoardGesture({
462
- squareSize,
463
- orientation,
464
- gestureEnabled,
465
- player,
466
- moveMethod,
467
- pieces,
468
- callbacks: {
469
- onPieceSelected: handlePieceSelected,
470
- onPieceMoved: handlePieceMoved,
471
- onSelectionCleared: handleSelectionCleared,
472
- },
473
- richCallbacks,
474
- premoveCallbacks,
475
- premovesEnabled,
476
- selectedSquare,
477
- legalMoves,
478
- currentTurn: boardState.getTurn(),
479
- });
480
-
481
- // --- Imperative ref ---
482
- useImperativeHandle(ref, () => ({
483
- move: (moveArg) => {
484
- const result = boardState.applyMove(moveArg.from, moveArg.to, moveArg.promotion);
485
- if (result.applied && result.fen) {
486
- setInternalFen(result.fen);
487
- }
488
- // Resolve after the piece animation completes. The moveDuration prop
489
- // drives BoardPieceView's withTiming, so this matches exactly.
490
- const duration = moveDuration ?? DEFAULT_MOVE_DURATION;
491
- return new Promise<void>((resolve) => setTimeout(resolve, duration));
492
- },
493
-
494
- highlight: (square, color) => {
495
- setImperativeHighlights((prev) => {
496
- const filtered = prev.filter((h) => h.square !== square);
497
- return [...filtered, { square, color }];
498
- });
499
- },
500
-
501
- clearHighlights: () => {
502
- setImperativeHighlights([]);
503
- },
504
-
505
- resetBoard: (newFen) => {
506
- boardState.loadFen(newFen);
507
- setInternalFen(newFen);
508
- setSelectedSquare(null);
509
- setLegalMoves([]);
510
- setImperativeHighlights([]);
511
- clearPremove();
512
- setPromotionState(null);
513
- },
514
-
515
- undo: () => {
516
- const prevFen = boardState.undoMove();
517
- if (prevFen) {
518
- setInternalFen(prevFen);
519
- }
520
- setSelectedSquare(null);
521
- setLegalMoves([]);
522
- },
523
-
524
- clearPremoves: () => {
525
- clearPremove();
526
- },
527
- }));
528
-
529
- // If no size yet (auto-sizing), render invisible container for measurement
530
- if (outerSize === 0) {
531
- return <View style={{ flex: 1, aspectRatio: 1 }} onLayout={handleLayout} />;
532
- }
533
-
534
- // Inner board with all interactive layers
535
- const boardContent = (
536
- <GestureDetector gesture={gesture}>
537
- <View
538
- style={
539
- isOutside
540
- ? { width: boardSize, height: boardSize, position: 'absolute', top: 0, right: 0 }
541
- : { width: boardSize, height: boardSize }
542
- }
543
- onLayout={!isOutside && !boardSizeProp ? handleLayout : undefined}
544
- accessibilityLabel="Chess board"
545
- accessibilityRole="adjustable"
546
- >
547
- {/* Layer 1: Board background (64 colored squares) */}
548
- <BoardBackground
549
- boardSize={boardSize}
550
- lightColor={boardColors.light}
551
- darkColor={boardColors.dark}
552
- />
553
-
554
- {/* Layer 2: Inside coordinate labels (when position='inside') */}
555
- {isCoordVisible && !isOutside && (
556
- <BoardCoordinates
557
- boardSize={boardSize}
558
- orientation={orientation}
559
- lightColor={boardColors.light}
560
- darkColor={boardColors.dark}
561
- withLetters
562
- withNumbers
563
- position="inside"
564
- />
565
- )}
566
-
567
- {/* Layer 3: Square highlights (last move, check, selected, premove, custom, imperative) */}
568
- <BoardHighlights
569
- boardSize={boardSize}
570
- orientation={orientation}
571
- squareSize={squareSize}
572
- lastMove={lastMove}
573
- lastMoveColor={lastMoveColor}
574
- checkSquare={checkSquareState}
575
- checkColor={checkHighlightColor}
576
- selectedSquare={selectedSquare}
577
- selectedColor={selectedSquareColor}
578
- premoveSquares={premove ? { from: premove.from, to: premove.to } : null}
579
- premoveColor={premoveColor}
580
- highlights={highlights}
581
- imperativeHighlights={imperativeHighlights}
582
- />
583
-
584
- {/* Layer 4: Drag target highlight (animated, updates during drag) */}
585
- {showDragTarget && (
586
- <DragTargetHighlight
587
- squareSize={squareSize}
588
- orientation={orientation}
589
- dragTargetSquare={gestureState.dragTargetSquare}
590
- color={dragTargetColor}
591
- />
592
- )}
593
-
594
- {/* Layer 5: Legal move dots (only when a piece is selected) */}
595
- {showLegalMoves && (
596
- <BoardLegalDots
597
- legalMoves={legalMoves}
598
- squareSize={squareSize}
599
- orientation={orientation}
600
- />
601
- )}
602
-
603
- {/* Layer 6: Pieces */}
604
- <BoardPiecesLayer
605
- pieces={pieces}
606
- squareSize={squareSize}
607
- orientation={orientation}
608
- moveDuration={moveDuration}
609
- animationConfig={animationConfig}
610
- renderPiece={resolvedRenderer}
611
- activeSquare={gestureState.activeSquare}
612
- isDragging={gestureState.isDragging}
613
- exitingAnimation={resolvedPieceExitAnimation}
614
- />
615
-
616
- {/* Layer 7: Arrows + shapes (SVG overlay) */}
617
- {((arrows && arrows.length > 0) || (shapes && shapes.length > 0)) && (
618
- <BoardArrows
619
- boardSize={boardSize}
620
- orientation={orientation}
621
- arrows={arrows}
622
- shapes={shapes}
623
- />
624
- )}
625
-
626
- {/* Layer 8: Annotations (text badges) */}
627
- {annotations && annotations.length > 0 && (
628
- <BoardAnnotations
629
- boardSize={boardSize}
630
- orientation={orientation}
631
- squareSize={squareSize}
632
- annotations={annotations}
633
- />
634
- )}
635
-
636
- {/* Layer 9: Drag ghost (single floating piece) */}
637
- <BoardDragGhost
638
- squareSize={squareSize}
639
- isDragging={gestureState.isDragging}
640
- dragX={gestureState.dragX}
641
- dragY={gestureState.dragY}
642
- dragPieceCode={gestureState.dragPieceCode}
643
- renderPiece={resolvedRenderer}
644
- />
645
-
646
- {/* Layer 10: Promotion picker (conditional) */}
647
- {promotionState && (
648
- <PromotionPicker
649
- square={promotionState.to}
650
- pieceColor={promotionState.color}
651
- boardSize={boardSize}
652
- squareSize={squareSize}
653
- orientation={orientation}
654
- renderPiece={resolvedRenderer}
655
- onSelect={handlePromotionSelect}
656
- onCancel={handlePromotionCancel}
657
- />
658
- )}
659
- </View>
660
- </GestureDetector>
661
- );
662
-
663
- // Outside coordinates: wrap board in outer container with gutter
664
- if (isOutside) {
665
- return (
666
- <View
667
- style={{ width: outerSize, height: boardSize + gutterWidth }}
668
- onLayout={boardSizeProp ? undefined : handleLayout}
669
- >
670
- {boardContent}
671
-
672
- {/* Outside coordinate labels in the gutter area */}
673
- <BoardCoordinates
674
- boardSize={boardSize}
675
- orientation={orientation}
676
- lightColor={boardColors.light}
677
- darkColor={boardColors.dark}
678
- withLetters
679
- withNumbers
680
- position="outside"
681
- gutterWidth={gutterWidth}
682
- />
683
- </View>
684
- );
685
- }
686
-
687
- return boardContent;
688
- });
1
+ import React, {
2
+ forwardRef,
3
+ useState,
4
+ useCallback,
5
+ useImperativeHandle,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react';
10
+ import { View, type LayoutChangeEvent } from 'react-native';
11
+ import { GestureDetector } from 'react-native-gesture-handler';
12
+ import { useSharedValue, withTiming, FadeOut } from 'react-native-reanimated';
13
+
14
+ import type {
15
+ BoardRef,
16
+ BoardProps,
17
+ LegalMoveTarget,
18
+ HighlightData,
19
+ PieceCode,
20
+ PromotionPiece,
21
+ } from './types';
22
+ import {
23
+ DEFAULT_BOARD_COLORS,
24
+ DEFAULT_MOVE_DURATION,
25
+ DEFAULT_LAST_MOVE_COLOR,
26
+ DEFAULT_CHECK_COLOR,
27
+ DEFAULT_SELECTED_COLOR,
28
+ DEFAULT_PREMOVE_COLOR,
29
+ DEFAULT_DRAG_TARGET_COLOR,
30
+ CAPTURE_FADE_DURATION,
31
+ COORDINATE_GUTTER_SCALE,
32
+ } from './constants';
33
+ import { DefaultPieceSet } from './pieces';
34
+ import { useBoardPieces } from './use-board-pieces';
35
+ import { useBoardState } from './use-board-state';
36
+ import { useBoardGesture } from './use-board-gesture';
37
+ import { usePremove } from './use-premove';
38
+ import { BoardBackground } from './board-background';
39
+ import { BoardCoordinates } from './board-coordinates';
40
+ import { BoardHighlights, DragTargetHighlight } from './board-highlights';
41
+ import { BoardLegalDots } from './board-legal-dots';
42
+ import { BoardPiecesLayer } from './board-pieces';
43
+ import { BoardDragGhost } from './board-drag-ghost';
44
+ import { BoardArrows } from './board-arrows';
45
+ import { BoardAnnotations } from './board-annotations';
46
+ import { PromotionPicker } from './promotion-picker';
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Check detection helper
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Find the king square for the side currently in check.
54
+ * Returns null if not in check.
55
+ */
56
+ function detectCheckSquare(
57
+ fen: string,
58
+ isInCheck: () => boolean,
59
+ getTurn: () => 'w' | 'b',
60
+ ): string | null {
61
+ if (!isInCheck()) return null;
62
+
63
+ const turn = getTurn();
64
+ const kingChar = turn === 'w' ? 'K' : 'k';
65
+ const placement = fen.split(' ')[0];
66
+ const ranks = placement.split('/');
67
+
68
+ for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
69
+ const rank = ranks[rankIdx]!;
70
+ let fileIdx = 0;
71
+ for (const char of rank) {
72
+ if (char >= '1' && char <= '8') {
73
+ fileIdx += parseInt(char, 10);
74
+ continue;
75
+ }
76
+ if (char === kingChar) {
77
+ const files = 'abcdefgh';
78
+ return `${files[fileIdx]}${8 - rankIdx}`;
79
+ }
80
+ fileIdx++;
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Board component
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * High-performance custom chess board built on Reanimated + Gesture Handler.
93
+ *
94
+ * Architecture:
95
+ * - 1 gesture handler (vs 32 in typical implementations)
96
+ * - ~40 components mounted (vs ~281)
97
+ * - ~75 native views (vs ~470)
98
+ * - 0 React Context providers
99
+ * - 0 re-renders during drag (pure worklet — only 2 shared value writes per frame)
100
+ *
101
+ * v0.2.0 layer stack (10 layers):
102
+ * 1. BoardBackground (64 squares)
103
+ * 2. BoardCoordinates (a-h, 1-8)
104
+ * 3. BoardHighlights (last move, check, selected, premove, custom, imperative)
105
+ * 4. DragTargetHighlight (animated, worklet-driven)
106
+ * 5. BoardLegalDots (legal move indicators)
107
+ * 6. BoardPiecesLayer (all pieces)
108
+ * 7. BoardArrows (SVG arrows + circles)
109
+ * 8. BoardAnnotations (text badges)
110
+ * 9. BoardDragGhost (floating piece)
111
+ * 10. PromotionPicker (modal, conditional)
112
+ */
113
+ export const Board = forwardRef<BoardRef, BoardProps>(function Board(
114
+ {
115
+ fen,
116
+ orientation,
117
+
118
+ // Layout
119
+ boardSize: boardSizeProp,
120
+
121
+ // Interaction
122
+ gestureEnabled = true,
123
+ player = 'both',
124
+ moveMethod = 'both',
125
+ showLegalMoves = true,
126
+ premovesEnabled = false,
127
+
128
+ // Appearance
129
+ colors,
130
+ coordinatePosition: coordinatePositionProp,
131
+ withLetters: withLettersProp,
132
+ withNumbers: withNumbersProp,
133
+ renderPiece,
134
+ pieceSet,
135
+
136
+ // Overlays
137
+ lastMove,
138
+ highlights,
139
+ arrows,
140
+ shapes,
141
+ annotations,
142
+ showDragTarget = true,
143
+
144
+ // Overlay colors
145
+ lastMoveColor = DEFAULT_LAST_MOVE_COLOR,
146
+ checkHighlightColor = DEFAULT_CHECK_COLOR,
147
+ selectedSquareColor = DEFAULT_SELECTED_COLOR,
148
+ premoveColor = DEFAULT_PREMOVE_COLOR,
149
+ dragTargetColor = DEFAULT_DRAG_TARGET_COLOR,
150
+
151
+ // Animation
152
+ moveDuration = DEFAULT_MOVE_DURATION,
153
+ animationConfig,
154
+ animateFlip = true,
155
+ pieceExitAnimation,
156
+
157
+ // Promotion
158
+ autoPromoteTo,
159
+ onPromotion,
160
+
161
+ // Callbacks
162
+ onMove,
163
+ onPieceClick,
164
+ onSquareClick,
165
+ onPieceDragBegin,
166
+ onPieceDragEnd,
167
+ onSquareLongPress,
168
+ onPremove,
169
+ onHaptic,
170
+ },
171
+ ref,
172
+ ) {
173
+ // --- Auto-sizing via onLayout when boardSize not provided ---
174
+ const [measuredSize, setMeasuredSize] = useState(0);
175
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
176
+ const { width, height } = e.nativeEvent.layout;
177
+ setMeasuredSize(Math.min(width, height));
178
+ }, []);
179
+
180
+ const outerSize = boardSizeProp ?? measuredSize;
181
+ const boardColors = colors ?? DEFAULT_BOARD_COLORS;
182
+
183
+ // Resolve coordinate position: new prop takes precedence over legacy booleans
184
+ const coordinatePosition =
185
+ coordinatePositionProp ??
186
+ (withLettersProp === false && withNumbersProp === false ? 'none' : 'inside');
187
+ const isOutside = coordinatePosition === 'outside';
188
+ const isCoordVisible = coordinatePosition !== 'none';
189
+
190
+ // When outside: reserve gutter space; inner board = outer minus gutters
191
+ const gutterWidth = isOutside ? Math.round((outerSize / 8) * COORDINATE_GUTTER_SCALE) : 0;
192
+ const boardSize = isOutside ? outerSize - gutterWidth : outerSize;
193
+ const squareSize = boardSize / 8;
194
+
195
+ // Resolve piece exit animation: null = disabled, undefined = default FadeOut
196
+ const resolvedPieceExitAnimation =
197
+ pieceExitAnimation === null
198
+ ? undefined
199
+ : (pieceExitAnimation ?? FadeOut.duration(CAPTURE_FADE_DURATION));
200
+
201
+ // --- Board flip animation ---
202
+ const flipRotation = useSharedValue(orientation === 'black' ? 180 : 0);
203
+ const prevOrientationRef = useRef(orientation);
204
+
205
+ useEffect(() => {
206
+ if (prevOrientationRef.current !== orientation) {
207
+ prevOrientationRef.current = orientation;
208
+ if (animateFlip) {
209
+ flipRotation.value = withTiming(orientation === 'black' ? 180 : 0, { duration: 300 });
210
+ } else {
211
+ flipRotation.value = orientation === 'black' ? 180 : 0;
212
+ }
213
+ }
214
+ }, [orientation, animateFlip, flipRotation]);
215
+
216
+ // Note: We don't actually rotate the board view because all layers already
217
+ // handle orientation via squareToXY coordinate math. The flip animation is
218
+ // a visual effect only — the rotation shared value is available for consumers
219
+ // who want to add a rotation transition effect.
220
+
221
+ // --- Internal FEN state ---
222
+ // The Board owns a private FEN that drives piece rendering.
223
+ // It starts from the parent prop and stays in sync via useEffect,
224
+ // but can temporarily diverge when the library applies a user move
225
+ // before the parent validates it (enables Chess.com-style "show move
226
+ // then revert" behavior via undo()).
227
+ const [internalFen, setInternalFen] = useState(fen);
228
+
229
+ // --- Chess.js for legal move validation + internal state ---
230
+ const boardState = useBoardState(fen);
231
+
232
+ // Sync internal FEN + chess.js when parent changes the prop FEN.
233
+ // This covers: accepted moves (parent updates FEN), board reset,
234
+ // and opponent programmatic moves.
235
+ useEffect(() => {
236
+ setInternalFen(fen);
237
+ boardState.loadFen(fen);
238
+ }, [fen, boardState]);
239
+
240
+ // --- Piece data from internal FEN ---
241
+ const pieces = useBoardPieces(internalFen);
242
+
243
+ // --- Check detection ---
244
+ // Detect if the side to move is in check by parsing the FEN.
245
+ // chess.js isInCheck() isn't exposed through boardState, so we use
246
+ // a simple heuristic: if the FEN has the king of the side to move
247
+ // attacked, highlight it.
248
+ const checkSquareState = useMemo(() => {
249
+ try {
250
+ return detectCheckSquare(internalFen, () => boardState.isInCheck(), boardState.getTurn);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }, [internalFen, boardState]);
255
+
256
+ // --- Selection state ---
257
+ const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
258
+ const [legalMoves, setLegalMoves] = useState<LegalMoveTarget[]>([]);
259
+
260
+ // --- Imperative highlights ---
261
+ const [imperativeHighlights, setImperativeHighlights] = useState<HighlightData[]>([]);
262
+
263
+ // --- Premove state ---
264
+ const { premove, setPremove, clearPremove, consumePremove } = usePremove();
265
+
266
+ // --- Promotion state ---
267
+ const [promotionState, setPromotionState] = useState<{
268
+ from: string;
269
+ to: string;
270
+ color: 'w' | 'b';
271
+ } | null>(null);
272
+
273
+ // --- Resolve piece renderer: renderPiece > pieceSet > DefaultPieceSet ---
274
+ const resolvedRenderer = useMemo(() => {
275
+ if (renderPiece) return renderPiece;
276
+ const set = pieceSet ?? DefaultPieceSet;
277
+ return (code: string, size: number) => {
278
+ const renderer = set[code as PieceCode];
279
+ if (renderer) return renderer(size);
280
+ return <View style={{ width: size, height: size }} />;
281
+ };
282
+ }, [renderPiece, pieceSet]);
283
+
284
+ // --- Promotion detection ---
285
+ const isPromotionMove = useCallback(
286
+ (from: string, to: string): boolean => {
287
+ const piece = pieces.find((p) => p.square === from);
288
+ if (!piece) return false;
289
+ // Must be a pawn
290
+ if (piece.code !== 'wp' && piece.code !== 'bp') return false;
291
+ // Must be moving to the last rank
292
+ const toRank = to[1];
293
+ if (piece.color === 'w' && toRank === '8') return true;
294
+ if (piece.color === 'b' && toRank === '1') return true;
295
+ return false;
296
+ },
297
+ [pieces],
298
+ );
299
+
300
+ // --- Gesture callbacks ---
301
+ const handlePieceSelected = useCallback(
302
+ (square: string) => {
303
+ setSelectedSquare(square);
304
+ if (showLegalMoves) {
305
+ setLegalMoves(boardState.getLegalMoves(square));
306
+ }
307
+ },
308
+ [showLegalMoves, boardState],
309
+ );
310
+
311
+ const handleSelectionCleared = useCallback(() => {
312
+ setSelectedSquare(null);
313
+ setLegalMoves([]);
314
+ }, []);
315
+
316
+ const handlePieceMoved = useCallback(
317
+ async (from: string, to: string) => {
318
+ // Clear selection and legal dots
319
+ setSelectedSquare(null);
320
+ setLegalMoves([]);
321
+
322
+ // Check for promotion
323
+ if (isPromotionMove(from, to)) {
324
+ // 1. Auto-promote to a specific piece (no picker, no callback)
325
+ if (autoPromoteTo) {
326
+ const result = boardState.applyMove(from, to, autoPromoteTo);
327
+ if (result.applied && result.fen) {
328
+ setInternalFen(result.fen);
329
+ }
330
+ onMove?.({ from, to, promotion: autoPromoteTo });
331
+ return;
332
+ }
333
+
334
+ // 2. Consumer handles promotion UI externally via callback
335
+ if (onPromotion) {
336
+ try {
337
+ const choice = await onPromotion(from, to);
338
+ const result = boardState.applyMove(from, to, choice);
339
+ if (result.applied && result.fen) {
340
+ setInternalFen(result.fen);
341
+ }
342
+ onMove?.({ from, to, promotion: choice });
343
+ } catch {
344
+ // Promotion cancelled by consumer — piece stays at origin
345
+ }
346
+ return;
347
+ }
348
+
349
+ // 3. Default: show built-in promotion picker
350
+ const piece = pieces.find((p) => p.square === from);
351
+ const color = piece?.color ?? 'w';
352
+ setPromotionState({ from, to, color });
353
+ return;
354
+ }
355
+
356
+ // Apply the move visually FIRST (Chess.com-style: show the move,
357
+ // then let the parent validate). The parent can call undo() to
358
+ // revert if the move is rejected.
359
+ const result = boardState.applyMove(from, to);
360
+ if (result.applied && result.fen) {
361
+ setInternalFen(result.fen);
362
+ onMove?.({ from, to });
363
+ }
364
+ // If chess.js rejected the move (truly illegal), do nothing —
365
+ // piece stays at its original square.
366
+ },
367
+ [onMove, onPromotion, autoPromoteTo, isPromotionMove, pieces, boardState],
368
+ );
369
+
370
+ // --- Promotion picker handlers ---
371
+ // Only reached when neither autoPromoteTo nor onPromotion is set.
372
+ const handlePromotionSelect = useCallback(
373
+ (piece: PromotionPiece) => {
374
+ if (!promotionState) return;
375
+ const { from, to } = promotionState;
376
+ setPromotionState(null);
377
+
378
+ const result = boardState.applyMove(from, to, piece);
379
+ if (result.applied && result.fen) {
380
+ setInternalFen(result.fen);
381
+ }
382
+ onMove?.({ from, to, promotion: piece });
383
+ },
384
+ [promotionState, onMove, boardState],
385
+ );
386
+
387
+ const handlePromotionCancel = useCallback(() => {
388
+ setPromotionState(null);
389
+ }, []);
390
+
391
+ // --- Premove handling ---
392
+ const handlePremoveSet = useCallback(
393
+ (pm: { from: string; to: string }) => {
394
+ setPremove(pm);
395
+ onPremove?.(pm);
396
+ onHaptic?.('select');
397
+ },
398
+ [setPremove, onPremove, onHaptic],
399
+ );
400
+
401
+ // Execute premove when turn changes
402
+ useEffect(() => {
403
+ if (!premovesEnabled || !premove) return;
404
+
405
+ const turn = boardState.getTurn();
406
+ // Check if it's now the premover's turn
407
+ const premovePiece = pieces.find((p) => p.square === premove.from);
408
+ if (!premovePiece) {
409
+ clearPremove();
410
+ return;
411
+ }
412
+
413
+ if (premovePiece.color === turn) {
414
+ const consumed = consumePremove();
415
+ if (consumed) {
416
+ // Try to execute the premove
417
+ const result = boardState.applyMove(consumed.from, consumed.to, consumed.promotion);
418
+ if (result.applied && result.fen) {
419
+ setInternalFen(result.fen);
420
+ onMove?.({ from: consumed.from, to: consumed.to, promotion: consumed.promotion });
421
+ onHaptic?.('move');
422
+ } else {
423
+ // Premove was illegal — discard silently
424
+ onHaptic?.('error');
425
+ }
426
+ }
427
+ }
428
+ }, [
429
+ fen,
430
+ premovesEnabled,
431
+ premove,
432
+ pieces,
433
+ boardState,
434
+ consumePremove,
435
+ clearPremove,
436
+ onMove,
437
+ onHaptic,
438
+ ]);
439
+
440
+ // --- Rich callbacks ref (stable, for gesture hook) ---
441
+ const richCallbacks = useMemo(
442
+ () => ({
443
+ onPieceClick,
444
+ onSquareClick,
445
+ onPieceDragBegin,
446
+ onPieceDragEnd,
447
+ onSquareLongPress,
448
+ onHaptic,
449
+ }),
450
+ [onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd, onSquareLongPress, onHaptic],
451
+ );
452
+
453
+ const premoveCallbacks = useMemo(
454
+ () => ({
455
+ onPremoveSet: handlePremoveSet,
456
+ }),
457
+ [handlePremoveSet],
458
+ );
459
+
460
+ // --- Single centralized gesture ---
461
+ const { gesture, gestureState } = useBoardGesture({
462
+ squareSize,
463
+ orientation,
464
+ gestureEnabled,
465
+ player,
466
+ moveMethod,
467
+ pieces,
468
+ callbacks: {
469
+ onPieceSelected: handlePieceSelected,
470
+ onPieceMoved: handlePieceMoved,
471
+ onSelectionCleared: handleSelectionCleared,
472
+ },
473
+ richCallbacks,
474
+ premoveCallbacks,
475
+ premovesEnabled,
476
+ selectedSquare,
477
+ legalMoves,
478
+ currentTurn: boardState.getTurn(),
479
+ });
480
+
481
+ // --- Imperative ref ---
482
+ useImperativeHandle(ref, () => ({
483
+ move: (moveArg) => {
484
+ const result = boardState.applyMove(moveArg.from, moveArg.to, moveArg.promotion);
485
+ if (result.applied && result.fen) {
486
+ setInternalFen(result.fen);
487
+ }
488
+ // Resolve after the piece animation completes. The moveDuration prop
489
+ // drives BoardPieceView's withTiming, so this matches exactly.
490
+ const duration = moveDuration ?? DEFAULT_MOVE_DURATION;
491
+ return new Promise<void>((resolve) => setTimeout(resolve, duration));
492
+ },
493
+
494
+ highlight: (square, color) => {
495
+ setImperativeHighlights((prev) => {
496
+ const filtered = prev.filter((h) => h.square !== square);
497
+ return [...filtered, { square, color }];
498
+ });
499
+ },
500
+
501
+ clearHighlights: () => {
502
+ setImperativeHighlights([]);
503
+ },
504
+
505
+ resetBoard: (newFen) => {
506
+ boardState.loadFen(newFen);
507
+ setInternalFen(newFen);
508
+ setSelectedSquare(null);
509
+ setLegalMoves([]);
510
+ setImperativeHighlights([]);
511
+ clearPremove();
512
+ setPromotionState(null);
513
+ },
514
+
515
+ undo: () => {
516
+ const prevFen = boardState.undoMove();
517
+ if (prevFen) {
518
+ setInternalFen(prevFen);
519
+ }
520
+ setSelectedSquare(null);
521
+ setLegalMoves([]);
522
+ },
523
+
524
+ clearPremoves: () => {
525
+ clearPremove();
526
+ },
527
+ }));
528
+
529
+ // If no size yet (auto-sizing), render invisible container for measurement
530
+ if (outerSize === 0) {
531
+ return <View style={{ flex: 1, aspectRatio: 1 }} onLayout={handleLayout} />;
532
+ }
533
+
534
+ // Inner board with all interactive layers
535
+ const boardContent = (
536
+ <GestureDetector gesture={gesture}>
537
+ <View
538
+ style={
539
+ isOutside
540
+ ? { width: boardSize, height: boardSize, position: 'absolute', top: 0, right: 0 }
541
+ : { width: boardSize, height: boardSize }
542
+ }
543
+ onLayout={!isOutside && !boardSizeProp ? handleLayout : undefined}
544
+ accessibilityLabel="Chess board"
545
+ accessibilityRole="adjustable"
546
+ >
547
+ {/* Layer 1: Board background (64 colored squares) */}
548
+ <BoardBackground
549
+ boardSize={boardSize}
550
+ lightColor={boardColors.light}
551
+ darkColor={boardColors.dark}
552
+ />
553
+
554
+ {/* Layer 2: Inside coordinate labels (when position='inside') */}
555
+ {isCoordVisible && !isOutside && (
556
+ <BoardCoordinates
557
+ boardSize={boardSize}
558
+ orientation={orientation}
559
+ lightColor={boardColors.light}
560
+ darkColor={boardColors.dark}
561
+ withLetters
562
+ withNumbers
563
+ position="inside"
564
+ />
565
+ )}
566
+
567
+ {/* Layer 3: Square highlights (last move, check, selected, premove, custom, imperative) */}
568
+ <BoardHighlights
569
+ boardSize={boardSize}
570
+ orientation={orientation}
571
+ squareSize={squareSize}
572
+ lastMove={lastMove}
573
+ lastMoveColor={lastMoveColor}
574
+ checkSquare={checkSquareState}
575
+ checkColor={checkHighlightColor}
576
+ selectedSquare={selectedSquare}
577
+ selectedColor={selectedSquareColor}
578
+ premoveSquares={premove ? { from: premove.from, to: premove.to } : null}
579
+ premoveColor={premoveColor}
580
+ highlights={highlights}
581
+ imperativeHighlights={imperativeHighlights}
582
+ />
583
+
584
+ {/* Layer 4: Drag target highlight (animated, updates during drag) */}
585
+ {showDragTarget && (
586
+ <DragTargetHighlight
587
+ squareSize={squareSize}
588
+ orientation={orientation}
589
+ dragTargetSquare={gestureState.dragTargetSquare}
590
+ color={dragTargetColor}
591
+ />
592
+ )}
593
+
594
+ {/* Layer 5: Legal move dots (only when a piece is selected) */}
595
+ {showLegalMoves && (
596
+ <BoardLegalDots
597
+ legalMoves={legalMoves}
598
+ squareSize={squareSize}
599
+ orientation={orientation}
600
+ />
601
+ )}
602
+
603
+ {/* Layer 6: Pieces */}
604
+ <BoardPiecesLayer
605
+ pieces={pieces}
606
+ squareSize={squareSize}
607
+ orientation={orientation}
608
+ moveDuration={moveDuration}
609
+ animationConfig={animationConfig}
610
+ renderPiece={resolvedRenderer}
611
+ activeSquare={gestureState.activeSquare}
612
+ isDragging={gestureState.isDragging}
613
+ exitingAnimation={resolvedPieceExitAnimation}
614
+ />
615
+
616
+ {/* Layer 7: Arrows + shapes (SVG overlay) */}
617
+ {((arrows && arrows.length > 0) || (shapes && shapes.length > 0)) && (
618
+ <BoardArrows
619
+ boardSize={boardSize}
620
+ orientation={orientation}
621
+ arrows={arrows}
622
+ shapes={shapes}
623
+ />
624
+ )}
625
+
626
+ {/* Layer 8: Annotations (text badges) */}
627
+ {annotations && annotations.length > 0 && (
628
+ <BoardAnnotations
629
+ boardSize={boardSize}
630
+ orientation={orientation}
631
+ squareSize={squareSize}
632
+ annotations={annotations}
633
+ />
634
+ )}
635
+
636
+ {/* Layer 9: Drag ghost (single floating piece) */}
637
+ <BoardDragGhost
638
+ squareSize={squareSize}
639
+ isDragging={gestureState.isDragging}
640
+ dragX={gestureState.dragX}
641
+ dragY={gestureState.dragY}
642
+ dragPieceCode={gestureState.dragPieceCode}
643
+ renderPiece={resolvedRenderer}
644
+ />
645
+
646
+ {/* Layer 10: Promotion picker (conditional) */}
647
+ {promotionState && (
648
+ <PromotionPicker
649
+ square={promotionState.to}
650
+ pieceColor={promotionState.color}
651
+ boardSize={boardSize}
652
+ squareSize={squareSize}
653
+ orientation={orientation}
654
+ renderPiece={resolvedRenderer}
655
+ onSelect={handlePromotionSelect}
656
+ onCancel={handlePromotionCancel}
657
+ />
658
+ )}
659
+ </View>
660
+ </GestureDetector>
661
+ );
662
+
663
+ // Outside coordinates: wrap board in outer container with gutter
664
+ if (isOutside) {
665
+ return (
666
+ <View
667
+ style={{ width: outerSize, height: boardSize + gutterWidth }}
668
+ onLayout={boardSizeProp ? undefined : handleLayout}
669
+ >
670
+ {boardContent}
671
+
672
+ {/* Outside coordinate labels in the gutter area */}
673
+ <BoardCoordinates
674
+ boardSize={boardSize}
675
+ orientation={orientation}
676
+ lightColor={boardColors.light}
677
+ darkColor={boardColors.dark}
678
+ withLetters
679
+ withNumbers
680
+ position="outside"
681
+ gutterWidth={gutterWidth}
682
+ />
683
+ </View>
684
+ );
685
+ }
686
+
687
+ return boardContent;
688
+ });