@dxos/react-ui-gameboard 0.8.3 → 0.8.4-main.1da679c

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 (75) hide show
  1. package/dist/lib/browser/index.mjs +522 -435
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +522 -435
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Chessboard/Chessboard.d.ts +20 -0
  8. package/dist/types/src/components/Chessboard/Chessboard.d.ts.map +1 -0
  9. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts +31 -0
  10. package/dist/types/src/components/Chessboard/Chessboard.stories.d.ts.map +1 -0
  11. package/dist/types/src/components/Chessboard/chess.d.ts +59 -0
  12. package/dist/types/src/components/Chessboard/chess.d.ts.map +1 -0
  13. package/dist/types/src/components/Chessboard/chess.test.d.ts +2 -0
  14. package/dist/types/src/components/Chessboard/chess.test.d.ts.map +1 -0
  15. package/dist/types/src/components/Chessboard/index.d.ts.map +1 -0
  16. package/dist/types/src/components/Gameboard/Gameboard.d.ts +38 -0
  17. package/dist/types/src/components/Gameboard/Gameboard.d.ts.map +1 -0
  18. package/dist/types/src/{Board → components/Gameboard}/Piece.d.ts +3 -2
  19. package/dist/types/src/components/Gameboard/Piece.d.ts.map +1 -0
  20. package/dist/types/src/components/Gameboard/Square.d.ts.map +1 -0
  21. package/dist/types/src/components/Gameboard/index.d.ts +4 -0
  22. package/dist/types/src/components/Gameboard/index.d.ts.map +1 -0
  23. package/dist/types/src/{Board → components/Gameboard}/types.d.ts +3 -1
  24. package/dist/types/src/components/Gameboard/types.d.ts.map +1 -0
  25. package/dist/types/src/components/Gameboard/util.d.ts.map +1 -0
  26. package/dist/types/src/components/index.d.ts +3 -0
  27. package/dist/types/src/components/index.d.ts.map +1 -0
  28. package/dist/types/src/index.d.ts +1 -2
  29. package/dist/types/src/index.d.ts.map +1 -1
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +20 -15
  32. package/src/{Chessboard → components/Chessboard}/Chessboard.stories.tsx +30 -27
  33. package/src/components/Chessboard/Chessboard.tsx +199 -0
  34. package/src/components/Chessboard/chess.test.ts +19 -0
  35. package/src/components/Chessboard/chess.ts +322 -0
  36. package/src/components/Gameboard/Gameboard.tsx +140 -0
  37. package/src/{Board → components/Gameboard}/Piece.tsx +25 -22
  38. package/src/{Board → components/Gameboard}/Square.tsx +4 -4
  39. package/src/components/Gameboard/index.ts +8 -0
  40. package/src/{Board → components/Gameboard}/types.ts +4 -1
  41. package/src/components/index.ts +6 -0
  42. package/src/index.ts +1 -2
  43. package/dist/lib/node/index.cjs +0 -1039
  44. package/dist/lib/node/index.cjs.map +0 -7
  45. package/dist/lib/node/meta.json +0 -1
  46. package/dist/types/src/Board/Board.d.ts +0 -15
  47. package/dist/types/src/Board/Board.d.ts.map +0 -1
  48. package/dist/types/src/Board/Container.d.ts +0 -14
  49. package/dist/types/src/Board/Container.d.ts.map +0 -1
  50. package/dist/types/src/Board/Piece.d.ts.map +0 -1
  51. package/dist/types/src/Board/Square.d.ts.map +0 -1
  52. package/dist/types/src/Board/context.d.ts +0 -10
  53. package/dist/types/src/Board/context.d.ts.map +0 -1
  54. package/dist/types/src/Board/index.d.ts +0 -8
  55. package/dist/types/src/Board/index.d.ts.map +0 -1
  56. package/dist/types/src/Board/types.d.ts.map +0 -1
  57. package/dist/types/src/Board/util.d.ts.map +0 -1
  58. package/dist/types/src/Chessboard/Chessboard.d.ts +0 -14
  59. package/dist/types/src/Chessboard/Chessboard.d.ts.map +0 -1
  60. package/dist/types/src/Chessboard/Chessboard.stories.d.ts +0 -16
  61. package/dist/types/src/Chessboard/Chessboard.stories.d.ts.map +0 -1
  62. package/dist/types/src/Chessboard/chess.d.ts +0 -40
  63. package/dist/types/src/Chessboard/chess.d.ts.map +0 -1
  64. package/dist/types/src/Chessboard/index.d.ts.map +0 -1
  65. package/src/Board/Board.tsx +0 -86
  66. package/src/Board/Container.tsx +0 -25
  67. package/src/Board/context.ts +0 -22
  68. package/src/Board/index.ts +0 -12
  69. package/src/Chessboard/Chessboard.tsx +0 -190
  70. package/src/Chessboard/chess.ts +0 -213
  71. /package/dist/types/src/{Chessboard → components/Chessboard}/index.d.ts +0 -0
  72. /package/dist/types/src/{Board → components/Gameboard}/Square.d.ts +0 -0
  73. /package/dist/types/src/{Board → components/Gameboard}/util.d.ts +0 -0
  74. /package/src/{Chessboard → components/Chessboard}/index.ts +0 -0
  75. /package/src/{Board → components/Gameboard}/util.ts +0 -0
@@ -0,0 +1,322 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type ReadonlySignal, signal } from '@preact/signals-core';
6
+ import { Chess as ChessJS } from 'chess.js';
7
+ import { type FC, type SVGProps } from 'react';
8
+
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+
12
+ import * as Alpha from '../../gen/pieces/chess/alpha';
13
+ import {
14
+ type GameboardModel,
15
+ type Location,
16
+ type Move,
17
+ type PieceMap,
18
+ type PieceType,
19
+ type Player,
20
+ locationToString,
21
+ } from '../Gameboard';
22
+
23
+ export type ChessPiece = 'BK' | 'BQ' | 'BR' | 'BB' | 'BN' | 'BP' | 'WK' | 'WQ' | 'WR' | 'WB' | 'WN' | 'WP';
24
+
25
+ export const ChessPieces: Record<ChessPiece, FC<SVGProps<SVGSVGElement>>> = Alpha;
26
+
27
+ export const posToLocation = (pos: string): Location => {
28
+ const col = pos.charCodeAt(0) - 'a'.charCodeAt(0);
29
+ const row = parseInt(pos[1]) - 1;
30
+ return [row, col];
31
+ };
32
+
33
+ export const locationToPos = ([row, col]: Location): string => {
34
+ return String.fromCharCode(col + 'a'.charCodeAt(0)) + (row + 1);
35
+ };
36
+
37
+ export const getRawPgn = (pgn: string) => {
38
+ return pgn.replace(/\[.*?\]/g, '').trim();
39
+ };
40
+
41
+ const styles = {
42
+ neutral: {
43
+ black: 'bg-neutral-50',
44
+ white: 'bg-neutral-200',
45
+ promotion: 'bg-neutral-200 hover:bg-neutral-300 opacity-70 hover:opacity-100',
46
+ },
47
+ original: {
48
+ black: 'bg-[#6C95B9]',
49
+ white: 'bg-[#CCD3DB]',
50
+ promotion: 'duration-500 bg-[#CCD3DB] opacity-70 hover:opacity-100',
51
+ },
52
+ blue: {
53
+ black: 'bg-[#608BC1]',
54
+ white: 'bg-[#CBDCEB]',
55
+ promotion: 'duration-500 bg-[#CBDCEB] opacity-70 hover:opacity-100',
56
+ },
57
+ green: {
58
+ black: 'bg-[#8EB486]',
59
+ white: 'bg-[#FDF7F4]',
60
+ promotion: 'duration-500 bg-[#FDF7F4] opacity-70 hover:opacity-100',
61
+ },
62
+ };
63
+
64
+ export const boardStyles = styles.original;
65
+
66
+ export const getSquareColor = ([row, col]: Location) => {
67
+ return (col + row) % 2 === 0 ? boardStyles.black : boardStyles.white;
68
+ };
69
+
70
+ export const createChess = (pgn?: string): ChessJS => {
71
+ const chess = new ChessJS();
72
+ if (pgn) {
73
+ try {
74
+ chess.loadPgn(pgn);
75
+ } catch {
76
+ log.warn(pgn);
77
+ }
78
+ }
79
+
80
+ return chess;
81
+ };
82
+
83
+ /**
84
+ * Chess model.
85
+ */
86
+ export class ChessModel implements GameboardModel<ChessPiece> {
87
+ private readonly _chess = new ChessJS();
88
+ private readonly _pieces = signal<PieceMap<ChessPiece>>({});
89
+ private readonly _moveIndex = signal(0);
90
+
91
+ constructor(pgn?: string) {
92
+ this.update(pgn);
93
+ }
94
+
95
+ get readonly(): boolean {
96
+ return this._moveIndex.value !== this._chess.history().length;
97
+ }
98
+
99
+ get turn(): Player {
100
+ return this._chess.turn() === 'w' ? 'white' : 'black';
101
+ }
102
+
103
+ get game(): ChessJS {
104
+ return this._chess;
105
+ }
106
+
107
+ get pieces(): ReadonlySignal<PieceMap<ChessPiece>> {
108
+ return this._pieces;
109
+ }
110
+
111
+ get moveIndex(): ReadonlySignal<number> {
112
+ return this._moveIndex;
113
+ }
114
+
115
+ get fen() {
116
+ return this._chess.fen();
117
+ }
118
+
119
+ /**
120
+ * PGN with headers.
121
+ *
122
+ * [Event "?"]
123
+ * [Site "?"]
124
+ * [Date "2025.08.05"]
125
+ * [Round "?"]
126
+ * [White "?"]
127
+ * [Black "?"]
128
+ * [Result "*"]
129
+ */
130
+ // TODO(burdon): Update headers.
131
+ get pgn(): string {
132
+ return getRawPgn(this._chess.pgn());
133
+ }
134
+
135
+ setMoveIndex(index: number) {
136
+ const temp = new ChessJS();
137
+ const history = this._chess.history({ verbose: true });
138
+ for (let i = 0; i < index && i < history.length; i++) {
139
+ temp.move(history[i]);
140
+ }
141
+ this._updateBoard(temp);
142
+ }
143
+
144
+ update(pgn = ''): void {
145
+ const previous = this._chess.history();
146
+ try {
147
+ this._chess.loadPgn(pgn);
148
+ // TODO(burdon): Get from TS.
149
+ // TODO(burdon): Update if not set.
150
+ this._chess.setHeader('Date', createDate());
151
+ this._chess.setHeader('Site', 'dxos.org');
152
+ // TODO(burdon): Update player keys.
153
+ // this._chess.setHeader('White', 'White');
154
+ // this._chess.setHeader('Black', 'Black');
155
+ } catch {
156
+ // Ignore.
157
+ }
158
+
159
+ const current = this._chess.history();
160
+ if (!isValidNextMove(previous, current)) {
161
+ this._pieces.value = {};
162
+ }
163
+
164
+ this._updateBoard(this._chess);
165
+ }
166
+
167
+ isValidMove(move: Move): boolean {
168
+ return tryMove(new ChessJS(this._chess.fen()), move) !== null;
169
+ }
170
+
171
+ canPromote(move: Move): boolean {
172
+ const isPawnMove = move.piece === 'BP' || move.piece === 'WP';
173
+ const isToLastRank = move.to[0] === 0 || move.to[0] === 7;
174
+ return isPawnMove && isToLastRank;
175
+ }
176
+
177
+ makeMove(move: Move): boolean {
178
+ const game = tryMove(this._chess, move);
179
+ if (!game) {
180
+ return false;
181
+ }
182
+
183
+ this._updateBoard(this._chess);
184
+ return true;
185
+ }
186
+
187
+ makeRandomMove(): boolean {
188
+ const moves = this._chess.moves();
189
+ if (!moves.length) {
190
+ return false;
191
+ }
192
+
193
+ const move = moves[Math.floor(Math.random() * moves.length)];
194
+ this._chess.move(move);
195
+
196
+ this._updateBoard(this._chess);
197
+ return true;
198
+ }
199
+
200
+ /**
201
+ * Update pieces preserving identity.
202
+ */
203
+ private _updateBoard(chess: ChessJS): void {
204
+ this._pieces.value = createPieceMap(chess);
205
+ this._moveIndex.value = chess.history().length;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Attempt move.
211
+ */
212
+ const tryMove = (chess: ChessJS, move: Move): ChessJS | null => {
213
+ const from = locationToPos(move.from);
214
+ const to = locationToPos(move.to);
215
+ try {
216
+ const promotion = move.promotion ? move.promotion[1].toLowerCase() : 'q';
217
+ chess.move({ from, to, promotion }, { strict: false });
218
+ return chess;
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
224
+ const isValidNextMove = (previous: string[], current: string[]) => {
225
+ if (current.length > previous.length + 1) {
226
+ return false;
227
+ }
228
+
229
+ for (let i = 0; i < previous.length; i++) {
230
+ if (previous[i] !== current[i]) {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ return true;
236
+ };
237
+
238
+ /**
239
+ * Starting from a new game, assign piece IDs based on their starting position.
240
+ * Then iterate through the history of the provided game and update the piece map.
241
+ */
242
+ export const createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
243
+ const temp = new ChessJS();
244
+ let pieces = _createPieceMap(temp);
245
+ const history = chess.history({ verbose: true });
246
+ for (let i = 0; i < history.length; i++) {
247
+ const move = history[i];
248
+ temp.move(move);
249
+ pieces = _diffPieces(pieces, _createPieceMap(temp));
250
+ const test = new Set();
251
+ Object.values(pieces).forEach((piece) => {
252
+ invariant(!test.has(piece.id), 'Duplicate: ' + piece.id);
253
+ test.add(piece.id);
254
+ });
255
+ }
256
+
257
+ return pieces;
258
+ };
259
+
260
+ /**
261
+ * Create a map of pieces from the board positions; assign each piece the ID of the current square.
262
+ */
263
+ const _createPieceMap = (chess: ChessJS): PieceMap<ChessPiece> => {
264
+ const pieces: PieceMap<ChessPiece> = {};
265
+ chess.board().flatMap((row) =>
266
+ row.forEach((record) => {
267
+ if (!record) {
268
+ return;
269
+ }
270
+
271
+ const { square, type, color } = record;
272
+ const pieceType = `${color.toUpperCase()}${type.toUpperCase()}` as ChessPiece;
273
+ const location = posToLocation(square);
274
+ pieces[locationToString(location)] = {
275
+ id: `${square}-${pieceType}`,
276
+ type: pieceType,
277
+ side: color === 'w' ? 'white' : 'black',
278
+ location,
279
+ };
280
+ }),
281
+ );
282
+
283
+ return pieces;
284
+ };
285
+
286
+ /**
287
+ * Preserve the original piece objects (and IDs).
288
+ */
289
+ const _diffPieces = <T extends PieceType>(before: PieceMap<T>, after: PieceMap<T>): PieceMap<T> => {
290
+ const difference: { added: PieceMap; removed: PieceMap } = {
291
+ removed: {},
292
+ added: {},
293
+ };
294
+
295
+ // Removed.
296
+ (Object.keys(before) as Array<keyof typeof before>).forEach((square) => {
297
+ if (after[square]?.type !== before[square]?.type) {
298
+ difference.removed[square] = before[square];
299
+ }
300
+ });
301
+
302
+ // Added.
303
+ (Object.keys(after) as Array<keyof typeof after>).forEach((square) => {
304
+ if (before[square]?.type !== after[square]?.type) {
305
+ difference.added[square] = after[square];
306
+ } else {
307
+ after[square] = before[square];
308
+ }
309
+ });
310
+
311
+ // Preserve IDs.
312
+ for (const piece of Object.values(difference.added)) {
313
+ const previous = Object.values(difference.removed).find((p) => p.type === piece.type);
314
+ if (previous) {
315
+ piece.id = previous.id;
316
+ }
317
+ }
318
+
319
+ return after;
320
+ };
321
+
322
+ const createDate = (date = new Date()) => date.toISOString().slice(0, 10).replace(/-/g, '.'); // e.g., "2025.08.05"
@@ -0,0 +1,140 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
+ import { createContext } from '@radix-ui/react-context';
7
+ import React, { type PropsWithChildren, forwardRef, useCallback, useEffect, useState } from 'react';
8
+
9
+ import { log } from '@dxos/log';
10
+ import { type ThemedClassName } from '@dxos/react-ui';
11
+ import { mx } from '@dxos/react-ui-theme';
12
+
13
+ import { Piece, type PieceProps } from './Piece';
14
+ import { Square, type SquareProps } from './Square';
15
+ import { type GameboardModel, type Move, type PieceRecord, isLocation, isPiece } from './types';
16
+
17
+ export type GameboardContextValue<M extends GameboardModel> = {
18
+ model: M;
19
+ dragging?: boolean; // TODO(burdon): Change to PieceRecord.
20
+ promoting?: PieceRecord;
21
+ onPromotion: (move: Move) => void;
22
+ };
23
+
24
+ const [GameboardContextProvider, useRadixGameboardContext] = createContext<GameboardContextValue<any>>('Gameboard');
25
+
26
+ const useGameboardContext = <M extends GameboardModel>(consumerName: string): GameboardContextValue<M> => {
27
+ return useRadixGameboardContext(consumerName);
28
+ };
29
+
30
+ //
31
+ // Root
32
+ //
33
+
34
+ type GameboardRootProps<M extends GameboardModel> = PropsWithChildren<{
35
+ model?: M;
36
+ moveNumber?: number;
37
+ onDrop?: (move: Move) => boolean;
38
+ }>;
39
+
40
+ /**
41
+ * Generic board container.
42
+ */
43
+ const GameboardRoot = <M extends GameboardModel>({ children, model, moveNumber, onDrop }: GameboardRootProps<M>) => {
44
+ const [dragging, setDragging] = useState(false);
45
+ const [promoting, setPromoting] = useState<PieceRecord | undefined>();
46
+
47
+ const handlePromotion = useCallback<GameboardContextValue<M>['onPromotion']>((move) => {
48
+ log('onPromotion', { move });
49
+ setPromoting(undefined);
50
+ onDrop?.(move);
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (!model) {
55
+ return;
56
+ }
57
+
58
+ // TODO(burdon): Should target specific container.
59
+ return monitorForElements({
60
+ onDragStart: ({ source }) => {
61
+ log('onDragStart', { source });
62
+ setDragging(true);
63
+ },
64
+ onDrop: ({ source, location }) => {
65
+ log('onDrop', { source, location });
66
+ const target = location.current.dropTargets[0];
67
+ if (!target) {
68
+ return;
69
+ }
70
+
71
+ const targetLocation = target.data.location;
72
+ const piece = source.data.piece;
73
+ if (!isLocation(targetLocation) || !isPiece(piece)) {
74
+ return;
75
+ }
76
+
77
+ const move: Move = { from: piece.location, to: targetLocation, piece: piece.type };
78
+ if (model.isValidMove(move)) {
79
+ if (model.canPromote?.(move)) {
80
+ setPromoting({ ...piece, location: targetLocation });
81
+ } else {
82
+ onDrop?.(move);
83
+ }
84
+ }
85
+
86
+ setDragging(false);
87
+ },
88
+ });
89
+ }, [model]);
90
+
91
+ return (
92
+ <GameboardContextProvider model={model} dragging={dragging} promoting={promoting} onPromotion={handlePromotion}>
93
+ {children}
94
+ </GameboardContextProvider>
95
+ );
96
+ };
97
+
98
+ GameboardRoot.displayName = 'Gameboard.Root';
99
+
100
+ //
101
+ // Content
102
+ //
103
+
104
+ type GameboardContentProps = ThemedClassName<PropsWithChildren<{ grow?: boolean; contain?: boolean }>>;
105
+
106
+ const GameboardContent = forwardRef<HTMLDivElement, GameboardContentProps>(
107
+ ({ children, classNames, grow, contain }, forwardedRef) => {
108
+ return (
109
+ <div
110
+ role='none'
111
+ className={mx(grow && 'grid is-full bs-full size-container place-content-center', classNames)}
112
+ ref={forwardedRef}
113
+ >
114
+ {contain ? <div className='is-[min(100cqw,100cqh)] bs-[min(100cqw,100cqh)]'>{children}</div> : children}
115
+ </div>
116
+ );
117
+ },
118
+ );
119
+
120
+ GameboardContent.displayName = 'Gameboard.Content';
121
+
122
+ //
123
+ // Gameboard
124
+ //
125
+
126
+ export const Gameboard = {
127
+ Root: GameboardRoot,
128
+ Content: GameboardContent,
129
+ Piece,
130
+ Square,
131
+ };
132
+
133
+ export { useGameboardContext };
134
+
135
+ export type {
136
+ GameboardRootProps,
137
+ GameboardContentProps,
138
+ PieceProps as GameboardPieceProps,
139
+ SquareProps as GameboardSquareProps,
140
+ };
@@ -3,34 +3,32 @@
3
3
  //
4
4
 
5
5
  import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
- // import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source';
7
6
  import { centerUnderPointer } from '@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer';
8
7
  import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
9
- import React, { useState, useRef, useEffect, type FC, type SVGProps, memo } from 'react';
8
+ import React, { type FC, type SVGProps, memo, useEffect, useRef, useState } from 'react';
10
9
  import { createPortal } from 'react-dom';
11
10
 
12
11
  import { invariant } from '@dxos/invariant';
13
12
  import { log } from '@dxos/log';
14
- import { useDynamicRef, useTrackProps, type ThemedClassName } from '@dxos/react-ui';
13
+ import { type ThemedClassName, useDynamicRef, useTrackProps } from '@dxos/react-ui';
15
14
  import { mx } from '@dxos/react-ui-theme';
16
15
 
17
- import { useBoardContext } from './context';
18
- import { isEqualLocation, isLocation, type Location, type PieceRecord, type Player } from './types';
16
+ import { useGameboardContext } from './Gameboard';
17
+ import { type Location, type PieceRecord, type Player, isEqualLocation, isLocation } from './types';
19
18
  import { type DOMRectBounds } from './util';
20
19
 
21
20
  export type PieceProps = ThemedClassName<{
21
+ Component: FC<SVGProps<SVGSVGElement>>;
22
22
  piece: PieceRecord;
23
23
  bounds: DOMRectBounds;
24
24
  label?: string;
25
25
  orientation?: Player;
26
- Component: FC<SVGProps<SVGSVGElement>>;
26
+ onClick?: () => void;
27
27
  }>;
28
28
 
29
- export const Piece = memo(({ classNames, piece, orientation, bounds, label, Component }: PieceProps) => {
30
- useTrackProps({ classNames, piece, orientation, bounds, label, Component }, Piece.displayName, false);
31
- const { model } = useBoardContext();
32
-
33
- const { dragging: isDragging, promoting } = useBoardContext();
29
+ export const Piece = memo(({ classNames, Component, piece, orientation, bounds, label, onClick }: PieceProps) => {
30
+ useTrackProps({ classNames, Component, piece, orientation, bounds, label }, Piece.displayName, false);
31
+ const { model, dragging: isDragging, promoting } = useGameboardContext(Piece.displayName!);
34
32
  const promotingRef = useDynamicRef(promoting);
35
33
  const [dragging, setDragging] = useState(false);
36
34
  const [preview, setPreview] = useState<HTMLElement>();
@@ -40,20 +38,20 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
40
38
 
41
39
  const ref = useRef<HTMLDivElement>(null);
42
40
  useEffect(() => {
41
+ if (!model) {
42
+ return;
43
+ }
44
+
43
45
  const el = ref.current;
44
46
  invariant(el);
45
47
 
46
48
  return draggable({
47
49
  element: el,
48
50
  getInitialData: () => ({ piece }),
49
- onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => {
51
+ onGenerateDragPreview: ({ nativeSetDragImage, source }) => {
50
52
  log('onGenerateDragPreview', { source: source.data });
51
53
  setCustomNativeDragPreview({
52
54
  getOffset: centerUnderPointer,
53
- // getOffset: preserveOffsetOnSource({
54
- // element: source.element,
55
- // input: location.current.input,
56
- // }),
57
55
  render: ({ container }) => {
58
56
  setPreview(container);
59
57
  const { width, height } = el.getBoundingClientRect();
@@ -66,12 +64,17 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
66
64
  nativeSetDragImage,
67
65
  });
68
66
  },
69
- canDrag: () => !promotingRef.current && model?.turn === piece.side,
67
+ canDrag: () => !promotingRef.current && !model.readonly && model.turn === piece.side,
70
68
  onDragStart: () => setDragging(true),
71
69
  onDrop: ({ location: { current } }) => {
72
- const location = current.dropTargets[0].data.location;
73
- if (isLocation(location)) {
74
- setCurrent((current) => ({ ...current, location }));
70
+ // TODO(burdon): Create wrapper function to catch errors.
71
+ try {
72
+ const location = current.dropTargets[0]?.data.location;
73
+ if (isLocation(location)) {
74
+ setCurrent((current) => ({ ...current, location }));
75
+ }
76
+ } catch {
77
+ // Ignore.
75
78
  }
76
79
 
77
80
  setDragging(false);
@@ -88,7 +91,7 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
88
91
 
89
92
  // Check if piece moved.
90
93
  if (!current.location || !isEqualLocation(current.location, piece.location)) {
91
- ref.current.style.transition = 'top 400ms ease-out, left 400ms ease-out';
94
+ ref.current.style.transition = 'top 250ms ease-out, left 250ms ease-out';
92
95
  ref.current.style.top = bounds.top + 'px';
93
96
  ref.current.style.left = bounds.left + 'px';
94
97
  setCurrent({ location: piece.location, bounds });
@@ -112,10 +115,10 @@ export const Piece = memo(({ classNames, piece, orientation, bounds, label, Comp
112
115
  className={mx(
113
116
  'absolute',
114
117
  classNames,
115
- // orientation === 'black' && '_rotate-180',
116
118
  dragging && 'opacity-20', // Must not unmount component while dragging.
117
119
  isDragging && 'pointer-events-none', // Don't block the square's drop target.
118
120
  )}
121
+ onClick={onClick}
119
122
  >
120
123
  <Component className='grow' />
121
124
  {label && <div className='absolute inset-1 text-xs text-black'>{label}</div>}
@@ -3,15 +3,15 @@
3
3
  //
4
4
 
5
5
  import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
6
- import React, { useRef, useState, useEffect, memo } from 'react';
6
+ import React, { memo, useEffect, useRef, useState } from 'react';
7
7
 
8
8
  import { invariant } from '@dxos/invariant';
9
9
  import { log } from '@dxos/log';
10
10
  import { type ThemedClassName } from '@dxos/react-ui';
11
11
  import { mx } from '@dxos/react-ui-theme';
12
12
 
13
- import { useBoardContext } from './context';
14
- import { isPiece, type Location } from './types';
13
+ import { useGameboardContext } from './Gameboard';
14
+ import { type Location, isPiece } from './types';
15
15
  import { type DOMRectBounds } from './util';
16
16
 
17
17
  type HoveredState = 'idle' | 'validMove' | 'invalidMove';
@@ -25,7 +25,7 @@ export type SquareProps = ThemedClassName<{
25
25
  export const Square = memo(({ location, bounds, label, classNames }: SquareProps) => {
26
26
  const ref = useRef<HTMLDivElement>(null);
27
27
  const [state, setState] = useState<HoveredState>('idle');
28
- const { model } = useBoardContext();
28
+ const { model } = useGameboardContext(Square.displayName!);
29
29
 
30
30
  useEffect(() => {
31
31
  const el = ref.current;
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './types';
6
+ export * from './util';
7
+
8
+ export * from './Gameboard';
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { type ReadonlySignal } from '@preact/signals-core';
6
6
 
7
+ // TODO(burdon): Don't make this assumption.
7
8
  export type Player = 'black' | 'white';
8
9
 
9
10
  export type Location = [number, number];
@@ -50,8 +51,10 @@ export const isEqualLocation = (l1: Location, l2: Location): boolean => l1[0] ==
50
51
  /**
51
52
  * Generic board model.
52
53
  */
53
- export interface BoardModel<T extends PieceType = PieceType> {
54
+ export interface GameboardModel<T extends PieceType = PieceType> {
55
+ readonly: boolean;
54
56
  turn: Player;
57
+ /** @reactive */
55
58
  pieces: ReadonlySignal<PieceMap<T>>;
56
59
  isValidMove: (move: Move) => boolean;
57
60
  canPromote?: (move: Move) => boolean;
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './Gameboard';
6
+ export * from './Chessboard';
package/src/index.ts CHANGED
@@ -2,5 +2,4 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './Board';
6
- export * from './Chessboard';
5
+ export * from './components';