react-native-chess-kit 0.5.2 → 0.5.3

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 (106) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +187 -168
  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.map +1 -1
  6. package/lib/commonjs/board-background.js +5 -5
  7. package/lib/commonjs/board-background.js.map +1 -1
  8. package/lib/commonjs/board-coordinates.js +8 -8
  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 +68 -63
  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 +9 -9
  27. package/lib/commonjs/promotion-picker.js.map +1 -1
  28. package/lib/commonjs/static-board.js +7 -7
  29. package/lib/commonjs/static-board.js.map +1 -1
  30. package/lib/commonjs/themes.js.map +1 -1
  31. package/lib/commonjs/types.js.map +1 -1
  32. package/lib/commonjs/use-board-gesture.js +26 -26
  33. package/lib/commonjs/use-board-gesture.js.map +1 -1
  34. package/lib/commonjs/use-board-pieces.js +15 -15
  35. package/lib/commonjs/use-board-pieces.js.map +1 -1
  36. package/lib/commonjs/use-board-state.js +21 -12
  37. package/lib/commonjs/use-board-state.js.map +1 -1
  38. package/lib/commonjs/use-premove.js +12 -12
  39. package/lib/commonjs/use-premove.js.map +1 -1
  40. package/lib/module/board-annotations.js +8 -8
  41. package/lib/module/board-annotations.js.map +1 -1
  42. package/lib/module/board-arrows.js.map +1 -1
  43. package/lib/module/board-background.js +5 -5
  44. package/lib/module/board-background.js.map +1 -1
  45. package/lib/module/board-coordinates.js +8 -8
  46. package/lib/module/board-coordinates.js.map +1 -1
  47. package/lib/module/board-drag-ghost.js +10 -10
  48. package/lib/module/board-drag-ghost.js.map +1 -1
  49. package/lib/module/board-highlights.js +15 -15
  50. package/lib/module/board-highlights.js.map +1 -1
  51. package/lib/module/board-legal-dots.js +5 -5
  52. package/lib/module/board-legal-dots.js.map +1 -1
  53. package/lib/module/board-piece.js +25 -25
  54. package/lib/module/board-piece.js.map +1 -1
  55. package/lib/module/board-pieces.js +6 -6
  56. package/lib/module/board-pieces.js.map +1 -1
  57. package/lib/module/board.js +68 -63
  58. package/lib/module/board.js.map +1 -1
  59. package/lib/module/constants.js.map +1 -1
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/module/pieces/default-pieces.js.map +1 -1
  62. package/lib/module/pieces/index.js.map +1 -1
  63. package/lib/module/promotion-picker.js +9 -9
  64. package/lib/module/promotion-picker.js.map +1 -1
  65. package/lib/module/static-board.js +7 -7
  66. package/lib/module/static-board.js.map +1 -1
  67. package/lib/module/themes.js.map +1 -1
  68. package/lib/module/types.js.map +1 -1
  69. package/lib/module/use-board-gesture.js +26 -26
  70. package/lib/module/use-board-gesture.js.map +1 -1
  71. package/lib/module/use-board-pieces.js +15 -15
  72. package/lib/module/use-board-pieces.js.map +1 -1
  73. package/lib/module/use-board-state.js +21 -12
  74. package/lib/module/use-board-state.js.map +1 -1
  75. package/lib/module/use-premove.js +12 -12
  76. package/lib/module/use-premove.js.map +1 -1
  77. package/lib/typescript/board-coordinates.d.ts.map +1 -1
  78. package/lib/typescript/board.d.ts.map +1 -1
  79. package/lib/typescript/promotion-picker.d.ts.map +1 -1
  80. package/lib/typescript/static-board.d.ts.map +1 -1
  81. package/lib/typescript/types.d.ts +12 -2
  82. package/lib/typescript/types.d.ts.map +1 -1
  83. package/lib/typescript/use-board-gesture.d.ts.map +1 -1
  84. package/lib/typescript/use-board-state.d.ts.map +1 -1
  85. package/package.json +23 -3
  86. package/src/board-annotations.tsx +147 -147
  87. package/src/board-background.tsx +46 -46
  88. package/src/board-coordinates.tsx +192 -192
  89. package/src/board-drag-ghost.tsx +132 -132
  90. package/src/board-highlights.tsx +226 -226
  91. package/src/board-legal-dots.tsx +73 -73
  92. package/src/board-piece.tsx +160 -160
  93. package/src/board-pieces.tsx +63 -63
  94. package/src/board.tsx +688 -685
  95. package/src/constants.ts +103 -103
  96. package/src/index.ts +101 -101
  97. package/src/pieces/default-pieces.tsx +383 -383
  98. package/src/pieces/index.ts +1 -1
  99. package/src/promotion-picker.tsx +147 -147
  100. package/src/static-board.tsx +186 -187
  101. package/src/themes.ts +129 -129
  102. package/src/types.ts +394 -373
  103. package/src/use-board-gesture.ts +459 -429
  104. package/src/use-board-pieces.ts +158 -158
  105. package/src/use-board-state.ts +120 -111
  106. package/src/use-premove.ts +59 -59
@@ -1,429 +1,459 @@
1
- import { useMemo, useCallback, useRef } from 'react';
2
- import { Gesture } from 'react-native-gesture-handler';
3
- import {
4
- useSharedValue,
5
- runOnJS,
6
- } from 'react-native-reanimated';
7
-
8
- import type {
9
- ChessColor,
10
- MoveMethod,
11
- BoardPiece,
12
- LegalMoveTarget,
13
- GestureState,
14
- PieceCode,
15
- HapticType,
16
- PremoveData,
17
- } from './types';
18
- import { xyToSquare } from './use-board-pieces';
19
-
20
- // ---------------------------------------------------------------------------
21
- // Callback types
22
- // ---------------------------------------------------------------------------
23
-
24
- type GestureCallbacks = {
25
- onPieceSelected: (square: string) => void;
26
- onPieceMoved: (from: string, to: string) => void;
27
- onSelectionCleared: () => void;
28
- };
29
-
30
- /** Rich callbacks exposed to consumers (all optional) */
31
- type RichCallbacks = {
32
- onPieceClick?: (square: string, piece: PieceCode) => void;
33
- onSquareClick?: (square: string) => void;
34
- onPieceDragBegin?: (square: string, piece: PieceCode) => void;
35
- onPieceDragEnd?: (square: string, piece: PieceCode) => void;
36
- onSquareLongPress?: (square: string) => void;
37
- onHaptic?: (type: HapticType) => void;
38
- };
39
-
40
- /** Premove-related callbacks from board.tsx */
41
- type PremoveCallbacks = {
42
- onPremoveSet?: (premove: PremoveData) => void;
43
- };
44
-
45
- // ---------------------------------------------------------------------------
46
- // Params
47
- // ---------------------------------------------------------------------------
48
-
49
- type UseBoardGestureParams = {
50
- squareSize: number;
51
- orientation: ChessColor;
52
- gestureEnabled: boolean;
53
- player: ChessColor | 'both';
54
- moveMethod: MoveMethod;
55
- pieces: BoardPiece[];
56
- callbacks: GestureCallbacks;
57
- richCallbacks?: RichCallbacks;
58
- premoveCallbacks?: PremoveCallbacks;
59
- /** Whether premoves are enabled */
60
- premovesEnabled?: boolean;
61
- /** Currently selected square (for tap-to-move second tap) */
62
- selectedSquare: string | null;
63
- /** Legal move targets from the currently selected piece */
64
- legalMoves: LegalMoveTarget[];
65
- /** Whose turn it is ('w' or 'b') — used for premove detection */
66
- currentTurn?: 'w' | 'b';
67
- };
68
-
69
- type UseBoardGestureReturn = {
70
- gesture: ReturnType<typeof Gesture.Pan>;
71
- gestureState: GestureState;
72
- };
73
-
74
- // ---------------------------------------------------------------------------
75
- // Helpers
76
- // ---------------------------------------------------------------------------
77
-
78
- /** Check if a piece color matches the current player turn */
79
- function isPieceTurn(pieceColor: 'w' | 'b', currentTurn: 'w' | 'b'): boolean {
80
- return pieceColor === currentTurn;
81
- }
82
-
83
- /** Map piece color char to player ChessColor */
84
- function pieceColorToPlayer(color: 'w' | 'b'): ChessColor {
85
- return color === 'w' ? 'white' : 'black';
86
- }
87
-
88
- // ---------------------------------------------------------------------------
89
- // Hook
90
- // ---------------------------------------------------------------------------
91
-
92
- /**
93
- * Single centralized gesture handler for the entire board.
94
- *
95
- * Instead of 32 separate Gesture.Pan() handlers (one per piece), we use ONE
96
- * handler on the board container. Touch -> coordinate math -> which piece.
97
- *
98
- * Supports three modes:
99
- * - 'drag': drag piece to target square
100
- * - 'click': tap source piece, then tap target square
101
- * - 'both': drag or click (default)
102
- *
103
- * All drag position tracking uses shared values zero JS bridge calls,
104
- * zero re-renders during drag. Only the final move triggers JS via runOnJS.
105
- *
106
- * The gesture object is STABLE (only recreated when squareSize, orientation,
107
- * gestureEnabled, player, or moveMethod change). Frequently-changing data
108
- * (pieces, selectedSquare, legalMoves) is read from refs via runOnJS bridge
109
- * functions, avoiding costly gesture teardown/rebuild on every move.
110
- *
111
- * v0.2.0 additions:
112
- * - Rich callbacks (onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd)
113
- * - Drag target square tracking (shared value for DragTargetHighlight)
114
- * - Premove support (queue move when not your turn)
115
- * - Haptic feedback via callback
116
- * - Long press detection for onSquareLongPress
117
- */
118
- export function useBoardGesture({
119
- squareSize,
120
- orientation,
121
- gestureEnabled,
122
- player,
123
- moveMethod,
124
- pieces,
125
- callbacks,
126
- richCallbacks,
127
- premoveCallbacks,
128
- premovesEnabled = false,
129
- selectedSquare,
130
- legalMoves,
131
- currentTurn,
132
- }: UseBoardGestureParams): UseBoardGestureReturn {
133
- // Shared values for drag tracking — updated on UI thread only
134
- const activeSquare = useSharedValue<string | null>(null);
135
- const dragX = useSharedValue(0);
136
- const dragY = useSharedValue(0);
137
- const isDragging = useSharedValue(false);
138
- const dragPieceCode = useSharedValue<string | null>(null);
139
- const dragTargetSquare = useSharedValue<string | null>(null);
140
-
141
- const gestureState: GestureState = {
142
- activeSquare,
143
- dragX,
144
- dragY,
145
- isDragging,
146
- dragPieceCode,
147
- dragTargetSquare,
148
- };
149
-
150
- // --- Refs for frequently-changing data (read from JS thread via runOnJS) ---
151
- // These update every move but do NOT cause gesture object recreation.
152
- const piecesRef = useRef(pieces);
153
- piecesRef.current = pieces;
154
-
155
- const selectedSquareRef = useRef(selectedSquare);
156
- selectedSquareRef.current = selectedSquare;
157
-
158
- const legalMovesRef = useRef(legalMoves);
159
- legalMovesRef.current = legalMoves;
160
-
161
- const callbacksRef = useRef(callbacks);
162
- callbacksRef.current = callbacks;
163
-
164
- const richCallbacksRef = useRef(richCallbacks);
165
- richCallbacksRef.current = richCallbacks;
166
-
167
- const premoveCallbacksRef = useRef(premoveCallbacks);
168
- premoveCallbacksRef.current = premoveCallbacks;
169
-
170
- const currentTurnRef = useRef(currentTurn);
171
- currentTurnRef.current = currentTurn;
172
-
173
- // Track the piece being dragged for rich drag-end callback
174
- const draggedPieceRef = useRef<{ square: string; code: PieceCode } | null>(null);
175
-
176
- // --- JS-thread bridge functions called from worklets via runOnJS ---
177
- // These read current values from refs, so they always have fresh data.
178
-
179
- const handleBegin = useCallback((touchX: number, touchY: number) => {
180
- const square = xyToSquare(touchX, touchY, squareSize, orientation);
181
- const currentPieces = piecesRef.current;
182
- const currentSelected = selectedSquareRef.current;
183
- const currentLegalMoves = legalMovesRef.current;
184
- const cbs = callbacksRef.current;
185
- const rich = richCallbacksRef.current;
186
- const canClick = moveMethod !== 'drag';
187
-
188
- // Build lookup for the current touch
189
- const piece = currentPieces.find((p) => p.square === square);
190
- const isPlayerPiece = piece
191
- ? player === 'both' || pieceColorToPlayer(piece.color) === player
192
- : false;
193
-
194
- // Check if it's this piece's turn (for premove detection)
195
- const turn = currentTurnRef.current;
196
- const isOwnTurn = piece && turn
197
- ? isPieceTurn(piece.color, turn)
198
- : true; // default to true if turn not tracked
199
-
200
- // Click-to-move: second tap on a legal target square
201
- const legalSquares = new Set(currentLegalMoves.map((m) => m.square));
202
- if (canClick && currentSelected && legalSquares.has(square)) {
203
- cbs.onPieceMoved(currentSelected, square);
204
- // Clear optimistic worklet value move is complete
205
- activeSquare.value = null;
206
- isDragging.value = false;
207
- dragPieceCode.value = null;
208
- dragTargetSquare.value = null;
209
- rich?.onHaptic?.('move');
210
- return;
211
- }
212
-
213
- // Chess.com-style deselect: tapping the already-selected piece toggles it off
214
- if (canClick && currentSelected === square) {
215
- cbs.onSelectionCleared();
216
- activeSquare.value = null;
217
- dragPieceCode.value = null;
218
- dragTargetSquare.value = null;
219
- return;
220
- }
221
-
222
- if (isPlayerPiece && piece) {
223
- // Premove: player piece but not their turn
224
- if (premovesEnabled && !isOwnTurn) {
225
- // If there's already a selected square, this tap completes a premove
226
- if (currentSelected && currentSelected !== square) {
227
- premoveCallbacksRef.current?.onPremoveSet?.({
228
- from: currentSelected,
229
- to: square,
230
- });
231
- cbs.onSelectionCleared();
232
- activeSquare.value = null;
233
- dragPieceCode.value = null;
234
- dragTargetSquare.value = null;
235
- return;
236
- }
237
- // First tap: select the piece for premove
238
- // activeSquare.value / dragX.value / dragY.value already set by onBegin worklet
239
- dragPieceCode.value = piece.code;
240
- cbs.onPieceSelected(square);
241
- rich?.onPieceClick?.(square, piece.code as PieceCode);
242
- rich?.onHaptic?.('select');
243
- return;
244
- }
245
-
246
- // Normal case: tapped/started dragging a player piece on their turn
247
- // activeSquare.value / dragX.value / dragY.value already set by onBegin worklet
248
- dragPieceCode.value = piece.code;
249
- draggedPieceRef.current = { square, code: piece.code as PieceCode };
250
- cbs.onPieceSelected(square);
251
-
252
- // Rich callbacks
253
- rich?.onPieceClick?.(square, piece.code as PieceCode);
254
- rich?.onHaptic?.('select');
255
- } else {
256
- // Tapped empty square or opponent piece
257
-
258
- // If premoves enabled and there's a selection, check if this is a premove target
259
- if (premovesEnabled && currentSelected && !isOwnTurn) {
260
- premoveCallbacksRef.current?.onPremoveSet?.({
261
- from: currentSelected,
262
- to: square,
263
- });
264
- cbs.onSelectionCleared();
265
- activeSquare.value = null;
266
- dragPieceCode.value = null;
267
- dragTargetSquare.value = null;
268
- return;
269
- }
270
-
271
- // Clear the optimistic worklet value — no valid piece here
272
- activeSquare.value = null;
273
- dragPieceCode.value = null;
274
- dragTargetSquare.value = null;
275
- if (currentSelected) {
276
- cbs.onSelectionCleared();
277
- }
278
-
279
- // Rich callback: square click (empty square or opponent piece)
280
- rich?.onSquareClick?.(square);
281
- }
282
- }, [squareSize, orientation, player, moveMethod, premovesEnabled, activeSquare, dragX, dragY, isDragging, dragPieceCode, dragTargetSquare]);
283
-
284
- const handleDragStart = useCallback((touchX: number, touchY: number) => {
285
- const rich = richCallbacksRef.current;
286
- const dragged = draggedPieceRef.current;
287
- if (dragged) {
288
- rich?.onPieceDragBegin?.(dragged.square, dragged.code);
289
- }
290
- // Update drag target to current square
291
- const square = xyToSquare(touchX, touchY, squareSize, orientation);
292
- dragTargetSquare.value = square;
293
- }, [squareSize, orientation, dragTargetSquare]);
294
-
295
- const handleDragUpdate = useCallback((touchX: number, touchY: number) => {
296
- // Update drag target square (for highlight). This runs on JS thread
297
- // but is called from worklet via runOnJS only when the square changes.
298
- const square = xyToSquare(touchX, touchY, squareSize, orientation);
299
- dragTargetSquare.value = square;
300
- }, [squareSize, orientation, dragTargetSquare]);
301
-
302
- const handleEnd = useCallback((touchX: number, touchY: number) => {
303
- const fromSquare = activeSquare.value;
304
- if (!fromSquare) return;
305
-
306
- const toSquare = xyToSquare(touchX, touchY, squareSize, orientation);
307
- isDragging.value = false;
308
- dragTargetSquare.value = null;
309
-
310
- const rich = richCallbacksRef.current;
311
-
312
- // Fire drag end callback
313
- const dragged = draggedPieceRef.current;
314
- if (dragged) {
315
- rich?.onPieceDragEnd?.(toSquare, dragged.code);
316
- draggedPieceRef.current = null;
317
- }
318
-
319
- if (fromSquare !== toSquare) {
320
- // Check if this is a premove (not your turn)
321
- const turn = currentTurnRef.current;
322
- const piece = piecesRef.current.find((p) => p.square === fromSquare);
323
- const isOwnTurn = piece && turn ? isPieceTurn(piece.color, turn) : true;
324
-
325
- if (premovesEnabled && !isOwnTurn) {
326
- premoveCallbacksRef.current?.onPremoveSet?.({
327
- from: fromSquare,
328
- to: toSquare,
329
- });
330
- } else {
331
- callbacksRef.current.onPieceMoved(fromSquare, toSquare);
332
- }
333
-
334
- // Move completed — clear selection
335
- activeSquare.value = null;
336
- dragPieceCode.value = null;
337
- }
338
- // When fromSquare === toSquare (tap with no movement), keep selection alive
339
- // so the user can complete a click-to-move on the next tap.
340
- }, [squareSize, orientation, activeSquare, isDragging, dragPieceCode, dragTargetSquare, premovesEnabled]);
341
-
342
- // Long press handler (separate gesture, composed with pan)
343
- const handleLongPress = useCallback((touchX: number, touchY: number) => {
344
- const square = xyToSquare(touchX, touchY, squareSize, orientation);
345
- richCallbacksRef.current?.onSquareLongPress?.(square);
346
- }, [squareSize, orientation]);
347
-
348
- // --- Build the gesture (STABLE — only changes on layout/config changes) ---
349
- const canDrag = moveMethod !== 'click';
350
-
351
- // Track the last drag target square to avoid redundant runOnJS calls
352
- const lastDragTargetCol = useSharedValue(-1);
353
- const lastDragTargetRow = useSharedValue(-1);
354
-
355
- const gesture = useMemo(() => {
356
- return Gesture.Pan()
357
- .enabled(gestureEnabled)
358
- .minDistance(0) // Also detect taps (zero-distance pans)
359
- .onBegin((e) => {
360
- 'worklet';
361
- // Set shared values IMMEDIATELY on the UI thread so onStart can read them
362
- // synchronously. xyToSquare is tagged 'worklet' — safe to call here.
363
- // handleBegin (JS thread) will CLEAR activeSquare if the square is invalid.
364
- const sq = xyToSquare(e.x, e.y, squareSize, orientation);
365
- activeSquare.value = sq;
366
- dragX.value = e.x;
367
- dragY.value = e.y;
368
- // Bridge to JS for piece lookup + selection logic
369
- runOnJS(handleBegin)(e.x, e.y);
370
- })
371
- .onStart((e) => {
372
- 'worklet';
373
- if (!canDrag || !activeSquare.value) return;
374
- isDragging.value = true;
375
- runOnJS(handleDragStart)(e.x, e.y);
376
- })
377
- .onUpdate((e) => {
378
- 'worklet';
379
- if (!canDrag || !isDragging.value) return;
380
- // Only 2 shared value writes — no JS bridge, no re-renders
381
- dragX.value = e.x;
382
- dragY.value = e.y;
383
-
384
- // Update drag target square (only when square changes to minimize JS bridge calls)
385
- const col = Math.max(0, Math.min(7, Math.floor(e.x / squareSize)));
386
- const row = Math.max(0, Math.min(7, Math.floor(e.y / squareSize)));
387
- if (col !== lastDragTargetCol.value || row !== lastDragTargetRow.value) {
388
- lastDragTargetCol.value = col;
389
- lastDragTargetRow.value = row;
390
- runOnJS(handleDragUpdate)(e.x, e.y);
391
- }
392
- })
393
- .onEnd((e) => {
394
- 'worklet';
395
- if (!isDragging.value || !activeSquare.value) return;
396
- runOnJS(handleEnd)(e.x, e.y);
397
- })
398
- .onFinalize(() => {
399
- 'worklet';
400
- // Safety reset if gesture was interrupted
401
- isDragging.value = false;
402
- dragTargetSquare.value = null;
403
- lastDragTargetCol.value = -1;
404
- lastDragTargetRow.value = -1;
405
- });
406
- }, [
407
- gestureEnabled,
408
- canDrag,
409
- squareSize,
410
- handleBegin,
411
- handleDragStart,
412
- handleDragUpdate,
413
- handleEnd,
414
- // Shared values are stable refs listed for exhaustive-deps but don't cause recreations
415
- activeSquare,
416
- dragX,
417
- dragY,
418
- isDragging,
419
- dragTargetSquare,
420
- lastDragTargetCol,
421
- lastDragTargetRow,
422
- ]);
423
-
424
- // Compose with long press if the consumer wants it
425
- // For now, long press is detected via a separate gesture that runs simultaneously.
426
- // This is done at the board level if needed — keeping the pan gesture clean here.
427
-
428
- return { gesture, gestureState };
429
- }
1
+ import { useMemo, useCallback, useRef } from 'react';
2
+ import { Gesture } from 'react-native-gesture-handler';
3
+ import { useSharedValue, runOnJS } from 'react-native-reanimated';
4
+
5
+ import type {
6
+ ChessColor,
7
+ MoveMethod,
8
+ BoardPiece,
9
+ LegalMoveTarget,
10
+ GestureState,
11
+ PieceCode,
12
+ HapticType,
13
+ PremoveData,
14
+ } from './types';
15
+ import { xyToSquare } from './use-board-pieces';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Callback types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ type GestureCallbacks = {
22
+ onPieceSelected: (square: string) => void;
23
+ onPieceMoved: (from: string, to: string) => void;
24
+ onSelectionCleared: () => void;
25
+ };
26
+
27
+ /** Rich callbacks exposed to consumers (all optional) */
28
+ type RichCallbacks = {
29
+ onPieceClick?: (square: string, piece: PieceCode) => void;
30
+ onSquareClick?: (square: string) => void;
31
+ onPieceDragBegin?: (square: string, piece: PieceCode) => void;
32
+ onPieceDragEnd?: (square: string, piece: PieceCode) => void;
33
+ onSquareLongPress?: (square: string) => void;
34
+ onHaptic?: (type: HapticType) => void;
35
+ };
36
+
37
+ /** Premove-related callbacks from board.tsx */
38
+ type PremoveCallbacks = {
39
+ onPremoveSet?: (premove: PremoveData) => void;
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Params
44
+ // ---------------------------------------------------------------------------
45
+
46
+ type UseBoardGestureParams = {
47
+ squareSize: number;
48
+ orientation: ChessColor;
49
+ gestureEnabled: boolean;
50
+ player: ChessColor | 'both';
51
+ moveMethod: MoveMethod;
52
+ pieces: BoardPiece[];
53
+ callbacks: GestureCallbacks;
54
+ richCallbacks?: RichCallbacks;
55
+ premoveCallbacks?: PremoveCallbacks;
56
+ /** Whether premoves are enabled */
57
+ premovesEnabled?: boolean;
58
+ /** Currently selected square (for tap-to-move second tap) */
59
+ selectedSquare: string | null;
60
+ /** Legal move targets from the currently selected piece */
61
+ legalMoves: LegalMoveTarget[];
62
+ /** Whose turn it is ('w' or 'b') — used for premove detection */
63
+ currentTurn?: 'w' | 'b';
64
+ };
65
+
66
+ type UseBoardGestureReturn = {
67
+ gesture: ReturnType<typeof Gesture.Pan>;
68
+ gestureState: GestureState;
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Check if a piece color matches the current player turn */
76
+ function isPieceTurn(pieceColor: 'w' | 'b', currentTurn: 'w' | 'b'): boolean {
77
+ return pieceColor === currentTurn;
78
+ }
79
+
80
+ /** Map piece color char to player ChessColor */
81
+ function pieceColorToPlayer(color: 'w' | 'b'): ChessColor {
82
+ return color === 'w' ? 'white' : 'black';
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Hook
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Single centralized gesture handler for the entire board.
91
+ *
92
+ * Instead of 32 separate Gesture.Pan() handlers (one per piece), we use ONE
93
+ * handler on the board container. Touch -> coordinate math -> which piece.
94
+ *
95
+ * Supports three modes:
96
+ * - 'drag': drag piece to target square
97
+ * - 'click': tap source piece, then tap target square
98
+ * - 'both': drag or click (default)
99
+ *
100
+ * All drag position tracking uses shared values zero JS bridge calls,
101
+ * zero re-renders during drag. Only the final move triggers JS via runOnJS.
102
+ *
103
+ * The gesture object is STABLE (only recreated when squareSize, orientation,
104
+ * gestureEnabled, player, or moveMethod change). Frequently-changing data
105
+ * (pieces, selectedSquare, legalMoves) is read from refs via runOnJS bridge
106
+ * functions, avoiding costly gesture teardown/rebuild on every move.
107
+ *
108
+ * v0.2.0 additions:
109
+ * - Rich callbacks (onPieceClick, onSquareClick, onPieceDragBegin, onPieceDragEnd)
110
+ * - Drag target square tracking (shared value for DragTargetHighlight)
111
+ * - Premove support (queue move when not your turn)
112
+ * - Haptic feedback via callback
113
+ * - Long press detection for onSquareLongPress
114
+ */
115
+ export function useBoardGesture({
116
+ squareSize,
117
+ orientation,
118
+ gestureEnabled,
119
+ player,
120
+ moveMethod,
121
+ pieces,
122
+ callbacks,
123
+ richCallbacks,
124
+ premoveCallbacks,
125
+ premovesEnabled = false,
126
+ selectedSquare,
127
+ legalMoves,
128
+ currentTurn,
129
+ }: UseBoardGestureParams): UseBoardGestureReturn {
130
+ // Shared values for drag tracking — updated on UI thread only
131
+ const activeSquare = useSharedValue<string | null>(null);
132
+ const dragX = useSharedValue(0);
133
+ const dragY = useSharedValue(0);
134
+ const isDragging = useSharedValue(false);
135
+ const dragPieceCode = useSharedValue<string | null>(null);
136
+ const dragTargetSquare = useSharedValue<string | null>(null);
137
+
138
+ const gestureState: GestureState = {
139
+ activeSquare,
140
+ dragX,
141
+ dragY,
142
+ isDragging,
143
+ dragPieceCode,
144
+ dragTargetSquare,
145
+ };
146
+
147
+ // --- Refs for frequently-changing data (read from JS thread via runOnJS) ---
148
+ // These update every move but do NOT cause gesture object recreation.
149
+ const piecesRef = useRef(pieces);
150
+ piecesRef.current = pieces;
151
+
152
+ const selectedSquareRef = useRef(selectedSquare);
153
+ selectedSquareRef.current = selectedSquare;
154
+
155
+ const legalMovesRef = useRef(legalMoves);
156
+ legalMovesRef.current = legalMoves;
157
+
158
+ const callbacksRef = useRef(callbacks);
159
+ callbacksRef.current = callbacks;
160
+
161
+ const richCallbacksRef = useRef(richCallbacks);
162
+ richCallbacksRef.current = richCallbacks;
163
+
164
+ const premoveCallbacksRef = useRef(premoveCallbacks);
165
+ premoveCallbacksRef.current = premoveCallbacks;
166
+
167
+ const currentTurnRef = useRef(currentTurn);
168
+ currentTurnRef.current = currentTurn;
169
+
170
+ // Track the piece being dragged for rich drag-end callback
171
+ const draggedPieceRef = useRef<{ square: string; code: PieceCode } | null>(null);
172
+
173
+ // --- JS-thread bridge functions called from worklets via runOnJS ---
174
+ // These read current values from refs, so they always have fresh data.
175
+
176
+ const handleBegin = useCallback(
177
+ (touchX: number, touchY: number) => {
178
+ const square = xyToSquare(touchX, touchY, squareSize, orientation);
179
+ const currentPieces = piecesRef.current;
180
+ const currentSelected = selectedSquareRef.current;
181
+ const currentLegalMoves = legalMovesRef.current;
182
+ const cbs = callbacksRef.current;
183
+ const rich = richCallbacksRef.current;
184
+ const canClick = moveMethod !== 'drag';
185
+
186
+ // Build lookup for the current touch
187
+ const piece = currentPieces.find((p) => p.square === square);
188
+ const isPlayerPiece = piece
189
+ ? player === 'both' || pieceColorToPlayer(piece.color) === player
190
+ : false;
191
+
192
+ // Check if it's this piece's turn (for premove detection)
193
+ const turn = currentTurnRef.current;
194
+ const isOwnTurn = piece && turn ? isPieceTurn(piece.color, turn) : true; // default to true if turn not tracked
195
+
196
+ // Click-to-move: second tap on a legal target square
197
+ const legalSquares = new Set(currentLegalMoves.map((m) => m.square));
198
+ if (canClick && currentSelected && legalSquares.has(square)) {
199
+ cbs.onPieceMoved(currentSelected, square);
200
+ // Clear optimistic worklet value move is complete
201
+ activeSquare.value = null;
202
+ isDragging.value = false;
203
+ dragPieceCode.value = null;
204
+ dragTargetSquare.value = null;
205
+ rich?.onHaptic?.('move');
206
+ return;
207
+ }
208
+
209
+ // Chess.com-style deselect: tapping the already-selected piece toggles it off
210
+ if (canClick && currentSelected === square) {
211
+ cbs.onSelectionCleared();
212
+ activeSquare.value = null;
213
+ dragPieceCode.value = null;
214
+ dragTargetSquare.value = null;
215
+ return;
216
+ }
217
+
218
+ if (isPlayerPiece && piece) {
219
+ // Premove: player piece but not their turn
220
+ if (premovesEnabled && !isOwnTurn) {
221
+ // If there's already a selected square, this tap completes a premove
222
+ if (currentSelected && currentSelected !== square) {
223
+ premoveCallbacksRef.current?.onPremoveSet?.({
224
+ from: currentSelected,
225
+ to: square,
226
+ });
227
+ cbs.onSelectionCleared();
228
+ activeSquare.value = null;
229
+ dragPieceCode.value = null;
230
+ dragTargetSquare.value = null;
231
+ return;
232
+ }
233
+ // First tap: select the piece for premove
234
+ // activeSquare.value / dragX.value / dragY.value already set by onBegin worklet
235
+ dragPieceCode.value = piece.code;
236
+ cbs.onPieceSelected(square);
237
+ rich?.onPieceClick?.(square, piece.code as PieceCode);
238
+ rich?.onHaptic?.('select');
239
+ return;
240
+ }
241
+
242
+ // Normal case: tapped/started dragging a player piece on their turn
243
+ // activeSquare.value / dragX.value / dragY.value already set by onBegin worklet
244
+ dragPieceCode.value = piece.code;
245
+ draggedPieceRef.current = { square, code: piece.code as PieceCode };
246
+ cbs.onPieceSelected(square);
247
+
248
+ // Rich callbacks
249
+ rich?.onPieceClick?.(square, piece.code as PieceCode);
250
+ rich?.onHaptic?.('select');
251
+ } else {
252
+ // Tapped empty square or opponent piece
253
+
254
+ // If premoves enabled and there's a selection, check if this is a premove target
255
+ if (premovesEnabled && currentSelected && !isOwnTurn) {
256
+ premoveCallbacksRef.current?.onPremoveSet?.({
257
+ from: currentSelected,
258
+ to: square,
259
+ });
260
+ cbs.onSelectionCleared();
261
+ activeSquare.value = null;
262
+ dragPieceCode.value = null;
263
+ dragTargetSquare.value = null;
264
+ return;
265
+ }
266
+
267
+ // Clear the optimistic worklet value no valid piece here
268
+ activeSquare.value = null;
269
+ dragPieceCode.value = null;
270
+ dragTargetSquare.value = null;
271
+ if (currentSelected) {
272
+ cbs.onSelectionCleared();
273
+ }
274
+
275
+ // Rich callback: square click (empty square or opponent piece)
276
+ rich?.onSquareClick?.(square);
277
+ }
278
+ },
279
+ [
280
+ squareSize,
281
+ orientation,
282
+ player,
283
+ moveMethod,
284
+ premovesEnabled,
285
+ activeSquare,
286
+ dragX,
287
+ dragY,
288
+ isDragging,
289
+ dragPieceCode,
290
+ dragTargetSquare,
291
+ ],
292
+ );
293
+
294
+ const handleDragStart = useCallback(
295
+ (touchX: number, touchY: number) => {
296
+ const rich = richCallbacksRef.current;
297
+ const dragged = draggedPieceRef.current;
298
+ if (dragged) {
299
+ rich?.onPieceDragBegin?.(dragged.square, dragged.code);
300
+ }
301
+ // Update drag target to current square
302
+ const square = xyToSquare(touchX, touchY, squareSize, orientation);
303
+ dragTargetSquare.value = square;
304
+ },
305
+ [squareSize, orientation, dragTargetSquare],
306
+ );
307
+
308
+ const handleDragUpdate = useCallback(
309
+ (touchX: number, touchY: number) => {
310
+ // Update drag target square (for highlight). This runs on JS thread
311
+ // but is called from worklet via runOnJS only when the square changes.
312
+ const square = xyToSquare(touchX, touchY, squareSize, orientation);
313
+ dragTargetSquare.value = square;
314
+ },
315
+ [squareSize, orientation, dragTargetSquare],
316
+ );
317
+
318
+ const handleEnd = useCallback(
319
+ (touchX: number, touchY: number) => {
320
+ const fromSquare = activeSquare.value;
321
+ if (!fromSquare) return;
322
+
323
+ const toSquare = xyToSquare(touchX, touchY, squareSize, orientation);
324
+ isDragging.value = false;
325
+ dragTargetSquare.value = null;
326
+
327
+ const rich = richCallbacksRef.current;
328
+
329
+ // Fire drag end callback
330
+ const dragged = draggedPieceRef.current;
331
+ if (dragged) {
332
+ rich?.onPieceDragEnd?.(toSquare, dragged.code);
333
+ draggedPieceRef.current = null;
334
+ }
335
+
336
+ if (fromSquare !== toSquare) {
337
+ // Check if this is a premove (not your turn)
338
+ const turn = currentTurnRef.current;
339
+ const piece = piecesRef.current.find((p) => p.square === fromSquare);
340
+ const isOwnTurn = piece && turn ? isPieceTurn(piece.color, turn) : true;
341
+
342
+ if (premovesEnabled && !isOwnTurn) {
343
+ premoveCallbacksRef.current?.onPremoveSet?.({
344
+ from: fromSquare,
345
+ to: toSquare,
346
+ });
347
+ } else {
348
+ callbacksRef.current.onPieceMoved(fromSquare, toSquare);
349
+ }
350
+
351
+ // Move completed clear selection
352
+ activeSquare.value = null;
353
+ dragPieceCode.value = null;
354
+ }
355
+ // When fromSquare === toSquare (tap with no movement), keep selection alive
356
+ // so the user can complete a click-to-move on the next tap.
357
+ },
358
+ [
359
+ squareSize,
360
+ orientation,
361
+ activeSquare,
362
+ isDragging,
363
+ dragPieceCode,
364
+ dragTargetSquare,
365
+ premovesEnabled,
366
+ ],
367
+ );
368
+
369
+ // Long press handler (separate gesture, composed with pan)
370
+ const _handleLongPress = useCallback(
371
+ (touchX: number, touchY: number) => {
372
+ const square = xyToSquare(touchX, touchY, squareSize, orientation);
373
+ richCallbacksRef.current?.onSquareLongPress?.(square);
374
+ },
375
+ [squareSize, orientation],
376
+ );
377
+
378
+ // --- Build the gesture (STABLE — only changes on layout/config changes) ---
379
+ const canDrag = moveMethod !== 'click';
380
+
381
+ // Track the last drag target square to avoid redundant runOnJS calls
382
+ const lastDragTargetCol = useSharedValue(-1);
383
+ const lastDragTargetRow = useSharedValue(-1);
384
+
385
+ const gesture = useMemo(() => {
386
+ return Gesture.Pan()
387
+ .enabled(gestureEnabled)
388
+ .minDistance(0) // Also detect taps (zero-distance pans)
389
+ .onBegin((e) => {
390
+ 'worklet';
391
+ // Set shared values IMMEDIATELY on the UI thread so onStart can read them
392
+ // synchronously. xyToSquare is tagged 'worklet' — safe to call here.
393
+ // handleBegin (JS thread) will CLEAR activeSquare if the square is invalid.
394
+ const sq = xyToSquare(e.x, e.y, squareSize, orientation);
395
+ activeSquare.value = sq;
396
+ dragX.value = e.x;
397
+ dragY.value = e.y;
398
+ // Bridge to JS for piece lookup + selection logic
399
+ runOnJS(handleBegin)(e.x, e.y);
400
+ })
401
+ .onStart((e) => {
402
+ 'worklet';
403
+ if (!canDrag || !activeSquare.value) return;
404
+ isDragging.value = true;
405
+ runOnJS(handleDragStart)(e.x, e.y);
406
+ })
407
+ .onUpdate((e) => {
408
+ 'worklet';
409
+ if (!canDrag || !isDragging.value) return;
410
+ // Only 2 shared value writes — no JS bridge, no re-renders
411
+ dragX.value = e.x;
412
+ dragY.value = e.y;
413
+
414
+ // Update drag target square (only when square changes to minimize JS bridge calls)
415
+ const col = Math.max(0, Math.min(7, Math.floor(e.x / squareSize)));
416
+ const row = Math.max(0, Math.min(7, Math.floor(e.y / squareSize)));
417
+ if (col !== lastDragTargetCol.value || row !== lastDragTargetRow.value) {
418
+ lastDragTargetCol.value = col;
419
+ lastDragTargetRow.value = row;
420
+ runOnJS(handleDragUpdate)(e.x, e.y);
421
+ }
422
+ })
423
+ .onEnd((e) => {
424
+ 'worklet';
425
+ if (!isDragging.value || !activeSquare.value) return;
426
+ runOnJS(handleEnd)(e.x, e.y);
427
+ })
428
+ .onFinalize(() => {
429
+ 'worklet';
430
+ // Safety reset if gesture was interrupted
431
+ isDragging.value = false;
432
+ dragTargetSquare.value = null;
433
+ lastDragTargetCol.value = -1;
434
+ lastDragTargetRow.value = -1;
435
+ });
436
+ }, [
437
+ gestureEnabled,
438
+ canDrag,
439
+ squareSize,
440
+ handleBegin,
441
+ handleDragStart,
442
+ handleDragUpdate,
443
+ handleEnd,
444
+ // Shared values are stable refs — listed for exhaustive-deps but don't cause recreations
445
+ activeSquare,
446
+ dragX,
447
+ dragY,
448
+ isDragging,
449
+ dragTargetSquare,
450
+ lastDragTargetCol,
451
+ lastDragTargetRow,
452
+ ]);
453
+
454
+ // Compose with long press if the consumer wants it
455
+ // For now, long press is detected via a separate gesture that runs simultaneously.
456
+ // This is done at the board level if needed — keeping the pan gesture clean here.
457
+
458
+ return { gesture, gestureState };
459
+ }