react-chess-explorer 0.0.3 → 0.0.5

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.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Robert Blackwell
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Blackwell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,63 +1,65 @@
1
- # react-chess-explorer
2
-
3
- ```bash
4
- npm install
5
- npm run build
6
- ```
7
-
8
- React components for **browsing and replaying chess games**. This package depends on **`react-chess-core` only** (board + engine primitives), not `react-chess-puzzle-kit`.
9
-
10
- **Status:** Phase 5 scaffold — paste requirements into `docs/REQUIREMENTS.md` (or issue) before implementing replay UI.
11
-
12
- ---
13
-
14
- ## Local setup
15
-
16
- ```bash
17
- # Build core first
18
- cd ../react-chess-core && npm run build
19
-
20
- cd ../react-chess-explorer
21
- npm install
22
- npm run build
23
- ```
24
-
25
- **Peer dependencies:** `react`, `react-chessboard`, `chess.js`, **`react-chess-core`**
26
-
27
- ```bash
28
- npm install react-chess-explorer react-chess-core
29
- ```
30
-
31
- ---
32
-
33
- ## Exports (scaffold)
34
-
35
- | Export | Role |
36
- |--------|------|
37
- | **`ExplorerPlaceholder`** | Themed board at start position + scaffold label |
38
- | **`EXPLORER_START_FEN`** | Default FEN constant |
39
-
40
- ---
41
-
42
- ## Requirements
43
-
44
- Add product/API requirements here when ready:
45
-
46
- - `docs/REQUIREMENTS.md` (create when you paste the doc)
47
-
48
- Planned scope (from migration plan): move list, ply navigation, optional engine — wired to EndChess `/games` routes.
49
-
50
- ---
51
-
52
- ## Related packages
53
-
54
- | Package | Role |
55
- |---------|------|
56
- | [react-chess-core](https://github.com/reblackwell3/react-chess-core) | Board theme, highlights, Stockfish |
57
- | [react-chess-puzzle-kit](https://github.com/reblackwell3/react-chess-puzzle-kit) | Puzzles (separate; not a dependency) |
58
-
59
- ---
60
-
61
- ## License
62
-
63
- MIT © Robert Blackwell
1
+ # react-chess-explorer
2
+
3
+ ```bash
4
+ npm install
5
+ npm run build
6
+ ```
7
+
8
+ React components for **browsing and replaying chess games**. This package depends on **`react-chess-core` only** (board + engine primitives), not `react-chess-puzzle-kit`.
9
+
10
+ Used in production at [endchess.com](https://endchess.com).
11
+
12
+ **Status:** Phase 5 scaffold — paste requirements into `docs/REQUIREMENTS.md` (or issue) before implementing replay UI.
13
+
14
+ ---
15
+
16
+ ## Local setup
17
+
18
+ ```bash
19
+ # Build core first
20
+ cd ../react-chess-core && npm run build
21
+
22
+ cd ../react-chess-explorer
23
+ npm install
24
+ npm run build
25
+ ```
26
+
27
+ **Peer dependencies:** `react`, `react-chessboard`, `chess.js`, **`react-chess-core`**
28
+
29
+ ```bash
30
+ npm install react-chess-explorer react-chess-core
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Exports (scaffold)
36
+
37
+ | Export | Role |
38
+ |--------|------|
39
+ | **`ExplorerPlaceholder`** | Themed board at start position + scaffold label |
40
+ | **`EXPLORER_START_FEN`** | Default FEN constant |
41
+
42
+ ---
43
+
44
+ ## Requirements
45
+
46
+ Add product/API requirements here when ready:
47
+
48
+ - `docs/REQUIREMENTS.md` (create when you paste the doc)
49
+
50
+ Planned scope (from migration plan): move list, ply navigation, optional engine — wired to EndChess `/games` routes.
51
+
52
+ ---
53
+
54
+ ## Related packages
55
+
56
+ | Package | Role |
57
+ |---------|------|
58
+ | [react-chess-core](https://github.com/reblackwell3/react-chess-core) | Board theme, highlights, Stockfish |
59
+ | [react-chess-puzzle-kit](https://github.com/reblackwell3/react-chess-puzzle-kit) | Puzzles (separate; not a dependency) |
60
+
61
+ ---
62
+
63
+ ## License
64
+
65
+ MIT © Robert Blackwell
@@ -16,6 +16,7 @@ export declare function useGameReplayTraining({ gameId, startFen, fetchGame, }:
16
16
  error: string | null;
17
17
  feedback: GameReplayFeedback;
18
18
  lastExpectedSan: string | null;
19
+ lastMoveUci: any;
19
20
  expectedSan: string | undefined;
20
21
  handlePieceDrop: (sourceSquare: string, targetSquare: string, piece: string) => boolean;
21
22
  revealMove: () => void;
@@ -2,6 +2,8 @@ export type PositionHistoryEntry = {
2
2
  fen: string;
3
3
  /** SAN of the move played from the previous entry to reach this FEN. */
4
4
  lastSan?: string;
5
+ /** UCI of the move played from the previous entry to reach this FEN. */
6
+ lastUci?: string;
5
7
  };
6
8
  export declare function initialHistoryState(initialFen: string, initialLineSans?: string[]): {
7
9
  history: PositionHistoryEntry[];
@@ -13,14 +15,17 @@ export declare function usePositionHistory(initialFen: string, initialLineSans?:
13
15
  lineSans: string[];
14
16
  forwardSans: string[];
15
17
  currentFen: string;
16
- pushEntry: (fen: string, lastSan?: string) => PositionHistoryEntry;
18
+ lastMoveUci: string | null;
19
+ pushEntry: (fen: string, lastSan?: string, lastUci?: string) => PositionHistoryEntry;
17
20
  pushEntries: (entries: {
18
21
  fen: string;
19
22
  lastSan: string;
23
+ lastUci?: string;
20
24
  }[]) => void;
21
25
  replaceLineEntries: (startFen: string, entries: {
22
26
  fen: string;
23
27
  lastSan: string;
28
+ lastUci?: string;
24
29
  }[]) => void;
25
30
  goBack: () => PositionHistoryEntry | null;
26
31
  goForward: () => PositionHistoryEntry | null;
@@ -28,6 +28,7 @@ export declare function usePositionReferenceData({ fenProp, onFenChange, initial
28
28
  canGoForward: boolean;
29
29
  forwardSans: string[];
30
30
  selectedVariationKey: string | undefined;
31
+ lastMoveUci: string | null;
31
32
  setSources: import("react").Dispatch<import("react").SetStateAction<GameSource[]>>;
32
33
  setGamesMoveFilterUci: import("react").Dispatch<import("react").SetStateAction<string | undefined>>;
33
34
  handleMoveSelect: (move: PositionMoveApiDto) => void;
@@ -10,6 +10,7 @@ export declare function applyBoardMove(fen: string, sourceSquare: string, target
10
10
  export type LineSansEntry = {
11
11
  fen: string;
12
12
  lastSan: string;
13
+ lastUci: string;
13
14
  };
14
15
  /** Play a SAN sequence from a start FEN; returns null if any move is illegal. */
15
16
  export declare function applyLineSans(startFen: string, sans: string[]): {
package/dist/index.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Chess } from 'chess.js';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
- import { ThemeProvider, HighlightChessboard, usePositionKeyboardNav, ChessboardDnDProvider } from 'react-chess-core';
3
+ import { ThemeProvider, HighlightChessboard, usePositionKeyboardNav, ChessboardDnDProvider, lastMoveUciAtPly } from 'react-chess-core';
4
4
  import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
5
5
 
6
6
  /** Standard start position — placeholder until game replay is implemented. */
@@ -39,6 +39,7 @@ function applyBoardMove(fen, sourceSquare, targetSquare, piece) {
39
39
  }
40
40
  /** Play a SAN sequence from a start FEN; returns null if any move is illegal. */
41
41
  function applyLineSans(startFen, sans) {
42
+ var _a;
42
43
  const chess = new Chess(startFen);
43
44
  const entries = [];
44
45
  for (const san of sans) {
@@ -46,9 +47,10 @@ function applyLineSans(startFen, sans) {
46
47
  const move = chess.move(san);
47
48
  if (!move)
48
49
  return null;
49
- entries.push({ fen: chess.fen(), lastSan: move.san });
50
+ const lastUci = `${move.from}${move.to}${(_a = move.promotion) !== null && _a !== void 0 ? _a : ""}`;
51
+ entries.push({ fen: chess.fen(), lastSan: move.san, lastUci });
50
52
  }
51
- catch (_a) {
53
+ catch (_b) {
52
54
  return null;
53
55
  }
54
56
  }
@@ -432,30 +434,31 @@ function useExplorerPrefetch({ fen, moves, positionReady, sources, fetchPosition
432
434
  return;
433
435
  }
434
436
  const sourcesParam = sources.length < ALL_GAME_SOURCES.length ? sources : undefined;
435
- for (const move of moves.slice(0, childCount)) {
437
+ const prefetchMove = (move) => {
436
438
  const childFen = fenAfterUci(fen, move.uci);
437
439
  if (!childFen) {
438
- continue;
440
+ return Promise.resolve();
439
441
  }
440
442
  const gamesKey = gamesSessionKey({
441
443
  fen: childFen,
442
444
  sources: sourcesParam,
443
445
  });
444
- if (!peekSessionGames(gamesKey)) {
445
- void fetchPositionGames({ fen: childFen, sources: sourcesParam })
446
+ const gamesPromise = peekSessionGames(gamesKey)
447
+ ? Promise.resolve()
448
+ : fetchPositionGames({ fen: childFen, sources: sourcesParam })
446
449
  .then((games) => {
447
450
  if (!cancelled) {
448
451
  setSessionGames(gamesKey, games);
449
452
  }
450
453
  })
451
454
  .catch(() => undefined);
452
- }
453
455
  if (!fetchPositionVariations) {
454
- continue;
456
+ return gamesPromise;
455
457
  }
456
458
  const variationsKey = variationsSessionKey(childFen);
457
- if (!peekSessionVariations(variationsKey)) {
458
- void fetchPositionVariations({
459
+ const variationsPromise = peekSessionVariations(variationsKey)
460
+ ? Promise.resolve()
461
+ : fetchPositionVariations({
459
462
  fen: childFen,
460
463
  mode: "variations",
461
464
  lineCount: EXPLORER_DEFAULT_VARIATION_LINE_COUNT,
@@ -468,8 +471,17 @@ function useExplorerPrefetch({ fen, moves, positionReady, sources, fetchPosition
468
471
  }
469
472
  })
470
473
  .catch(() => undefined);
474
+ return Promise.all([gamesPromise, variationsPromise]).then(() => undefined);
475
+ };
476
+ void (() => __awaiter(this, void 0, void 0, function* () {
477
+ const movesToPrefetch = moves.slice(0, childCount);
478
+ for (let index = 0; index < movesToPrefetch.length; index += 2) {
479
+ if (cancelled) {
480
+ return;
481
+ }
482
+ yield Promise.all(movesToPrefetch.slice(index, index + 2).map(prefetchMove));
471
483
  }
472
- }
484
+ }))();
473
485
  };
474
486
  if (typeof window.requestIdleCallback === "function") {
475
487
  idleHandle = window.requestIdleCallback(prefetchChildren, {
@@ -513,19 +525,19 @@ function initialHistoryState(initialFen, initialLineSans) {
513
525
  };
514
526
  }
515
527
  function usePositionHistory(initialFen, initialLineSans) {
516
- var _a, _b;
528
+ var _a, _b, _c, _d;
517
529
  const [history, setHistory] = useState(() => initialHistoryState(initialFen, initialLineSans).history);
518
530
  const [historyIndex, setHistoryIndex] = useState(() => initialHistoryState(initialFen, initialLineSans).historyIndex);
519
531
  const canGoBack = historyIndex > 0;
520
532
  const canGoForward = historyIndex < history.length - 1;
521
- const pushEntry = useCallback((fen, lastSan) => {
533
+ const pushEntry = useCallback((fen, lastSan, lastUci) => {
522
534
  setHistory((prev) => {
523
535
  const trimmed = prev.slice(0, historyIndex + 1);
524
- const next = [...trimmed, { fen, lastSan }];
536
+ const next = [...trimmed, { fen, lastSan, lastUci }];
525
537
  setHistoryIndex(next.length - 1);
526
538
  return next;
527
539
  });
528
- return { fen, lastSan };
540
+ return { fen, lastSan, lastUci };
529
541
  }, [historyIndex]);
530
542
  const goBack = useCallback(() => {
531
543
  if (historyIndex <= 0)
@@ -586,12 +598,14 @@ function usePositionHistory(initialFen, initialLineSans) {
586
598
  setHistory([{ fen: startFen }, ...entries]);
587
599
  setHistoryIndex(entries.length);
588
600
  }, []);
601
+ const lastMoveUci = historyIndex > 0 ? (_d = (_c = history[historyIndex]) === null || _c === void 0 ? void 0 : _c.lastUci) !== null && _d !== void 0 ? _d : null : null;
589
602
  return {
590
603
  canGoBack,
591
604
  canGoForward,
592
605
  lineSans,
593
606
  forwardSans,
594
607
  currentFen,
608
+ lastMoveUci,
595
609
  pushEntry,
596
610
  pushEntries,
597
611
  replaceLineEntries,
@@ -636,7 +650,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
636
650
  }
637
651
  isAnimatingVariationRef.current = false;
638
652
  }, []);
639
- const { canGoBack, canGoForward, lineSans, forwardSans, currentFen, pushEntry, pushEntries, replaceLineEntries, goBack, goForward, goFirst, goLast, resetHistory, } = usePositionHistory(initialFen, initialLineSans);
653
+ const { canGoBack, canGoForward, lineSans, forwardSans, currentFen, pushEntry, pushEntries, replaceLineEntries, goBack, goForward, goFirst, goLast, resetHistory, lastMoveUci: historyLastMoveUci, } = usePositionHistory(initialFen, initialLineSans);
640
654
  /** FEN used for explorer API queries — follows move history, not animation frames. */
641
655
  const queryFen = currentFen;
642
656
  const initialLineKey = useMemo(() => { var _a; return (_a = initialLineSans === null || initialLineSans === void 0 ? void 0 : initialLineSans.join("|")) !== null && _a !== void 0 ? _a : ""; }, [initialLineSans]);
@@ -671,6 +685,10 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
671
685
  }), [sources]);
672
686
  const lastAppliedLineKeyRef = useRef(initialLineKey);
673
687
  useEffect(() => () => cancelVariationAnimation(), [cancelVariationAnimation]);
688
+ // Hold URL line callbacks until history catches up after an external line change.
689
+ useEffect(() => {
690
+ readyForLineSyncRef.current = false;
691
+ }, [initialLineKey]);
674
692
  // Apply URL line changes only when the external line key changes — not when the
675
693
  // user clicks moves (internal lineSans updates must not re-sync from stale props).
676
694
  useEffect(() => {
@@ -881,7 +899,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
881
899
  const nextFen = fenAfterUci(queryFen, move.uci);
882
900
  if (!nextFen)
883
901
  return;
884
- pushEntry(nextFen, move.san);
902
+ pushEntry(nextFen, move.san, move.uci);
885
903
  setSelectedVariationKey(undefined);
886
904
  applyNavigation(nextFen, true);
887
905
  }, [queryFen, pushEntry, applyNavigation, cancelVariationAnimation]);
@@ -895,7 +913,11 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
895
913
  const nextFen = fenAfterUci(lineFen, line.uciPath[i]);
896
914
  if (!nextFen)
897
915
  return;
898
- entries.push({ fen: nextFen, lastSan: line.moves[i].san });
916
+ entries.push({
917
+ fen: nextFen,
918
+ lastSan: line.moves[i].san,
919
+ lastUci: line.uciPath[i],
920
+ });
899
921
  lineFen = nextFen;
900
922
  }
901
923
  if (entries.length === 0)
@@ -930,7 +952,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
930
952
  const result = applyBoardMove(queryFen, sourceSquare, targetSquare, piece);
931
953
  if (!result)
932
954
  return false;
933
- pushEntry(result.fen, result.san);
955
+ pushEntry(result.fen, result.san, result.uci);
934
956
  setSelectedVariationKey(undefined);
935
957
  applyNavigation(result.fen, true);
936
958
  return true;
@@ -1007,6 +1029,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
1007
1029
  canGoForward,
1008
1030
  forwardSans,
1009
1031
  selectedVariationKey,
1032
+ lastMoveUci: historyLastMoveUci,
1010
1033
  setSources,
1011
1034
  setGamesMoveFilterUci,
1012
1035
  handleMoveSelect,
@@ -1105,7 +1128,7 @@ const PositionReferenceExplorerCore = ({ fen: fenProp, onFenChange, initialLineS
1105
1128
  fetchPositionGames,
1106
1129
  fetchPositionVariations,
1107
1130
  });
1108
- const { fen, boardFen, games, sources, lineSans, loading, showPositionLoading, gamesLoading, positionReady, displayMoves, error, lineLabel, canGoBack, canGoForward, forwardSans, selectedVariationKey, setSources, handleMoveSelect, handleLineSelect, handlePieceDrop, handleBack, handleForward, handleFirst, handleLast, } = referenceData;
1131
+ const { fen, boardFen, games, sources, lineSans, loading, showPositionLoading, gamesLoading, positionReady, displayMoves, error, lineLabel, canGoBack, canGoForward, forwardSans, selectedVariationKey, lastMoveUci, setSources, handleMoveSelect, handleLineSelect, handlePieceDrop, handleBack, handleForward, handleFirst, handleLast, } = referenceData;
1109
1132
  usePositionKeyboardNav({
1110
1133
  enabled: keyboardNav,
1111
1134
  canPrev: canGoBack,
@@ -1169,7 +1192,7 @@ const PositionReferenceExplorerCore = ({ fen: fenProp, onFenChange, initialLineS
1169
1192
  overflow: fillHeight ? "hidden" : "visible",
1170
1193
  boxSizing: "border-box",
1171
1194
  };
1172
- const board = (jsxs(Fragment, { children: [jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, position: boardFen, boardOrientation: boardOrientation, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }, boardOrientation) }), renderBoardNav(boardNavProps)] }));
1195
+ const board = (jsxs(Fragment, { children: [jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, position: boardFen, boardOrientation: boardOrientation, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, lastMoveUci: lastMoveUci, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }, boardOrientation) }), renderBoardNav(boardNavProps)] }));
1173
1196
  const referencePanel = (jsx(DefaultReferencePanel, { theme: theme, fillHeight: fillHeight, status: renderStatus({
1174
1197
  error,
1175
1198
  loading: showPositionLoading,
@@ -1291,6 +1314,7 @@ function useGameReplayTraining({ gameId, startFen, fetchGame, }) {
1291
1314
  const complete = game ? plyIndex >= game.movesUci.length : false;
1292
1315
  const expectedUci = game === null || game === void 0 ? void 0 : game.movesUci[plyIndex];
1293
1316
  const expectedSan = game === null || game === void 0 ? void 0 : game.movesSan[plyIndex];
1317
+ const lastMoveUci = useMemo(() => (game ? lastMoveUciAtPly(game.movesUci, plyIndex) : null), [game, plyIndex]);
1294
1318
  const handlePieceDrop = useCallback((sourceSquare, targetSquare, piece) => {
1295
1319
  if (!game || complete || !expectedUci)
1296
1320
  return false;
@@ -1324,6 +1348,7 @@ function useGameReplayTraining({ gameId, startFen, fetchGame, }) {
1324
1348
  error,
1325
1349
  feedback,
1326
1350
  lastExpectedSan,
1351
+ lastMoveUci,
1327
1352
  expectedSan,
1328
1353
  handlePieceDrop,
1329
1354
  revealMove,
@@ -1344,7 +1369,7 @@ const defaultButtonStyle = {
1344
1369
  cursor: "pointer",
1345
1370
  };
1346
1371
  const GameReplayTrainer = ({ gameId, startFen, fetchGame, onExit, theme = "dark", boardWidth = DEFAULT_REFERENCE_LAYOUT.boardWidth, renderStatus, }) => {
1347
- const { game, fen, plyIndex, totalPlies, complete, loading, error, feedback, lastExpectedSan, handlePieceDrop, revealMove, } = useGameReplayTraining({ gameId, startFen, fetchGame });
1372
+ const { game, fen, plyIndex, totalPlies, complete, loading, error, feedback, lastExpectedSan, lastMoveUci, handlePieceDrop, revealMove, } = useGameReplayTraining({ gameId, startFen, fetchGame });
1348
1373
  const status = renderStatus === null || renderStatus === void 0 ? void 0 : renderStatus({
1349
1374
  loading,
1350
1375
  error,
@@ -1364,7 +1389,7 @@ const GameReplayTrainer = ({ gameId, startFen, fetchGame, onExit, theme = "dark"
1364
1389
  !error &&
1365
1390
  !complete &&
1366
1391
  feedback === "incorrect" &&
1367
- lastExpectedSan && (jsxs("span", { style: { color: "#ef6c00" }, children: ["Expected ", lastExpectedSan] })), !loading && !error && !complete && feedback === null && (jsxs("span", { children: ["Guess move ", plyIndex + 1, " of ", totalPlies] }))] })), jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, position: fen, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }) }), !complete && !loading && !error && (jsx("button", { type: "button", style: defaultButtonStyle, onClick: revealMove, children: "Show move" }))] }) }));
1392
+ lastExpectedSan && (jsxs("span", { style: { color: "#ef6c00" }, children: ["Expected ", lastExpectedSan] })), !loading && !error && !complete && feedback === null && (jsxs("span", { children: ["Guess move ", plyIndex + 1, " of ", totalPlies] }))] })), jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, position: fen, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, lastMoveUci: lastMoveUci, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }) }), !complete && !loading && !error && (jsx("button", { type: "button", style: defaultButtonStyle, onClick: revealMove, children: "Show move" }))] }) }));
1368
1393
  };
1369
1394
 
1370
1395
  const mockPosition = {
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ function applyBoardMove(fen, sourceSquare, targetSquare, piece) {
41
41
  }
42
42
  /** Play a SAN sequence from a start FEN; returns null if any move is illegal. */
43
43
  function applyLineSans(startFen, sans) {
44
+ var _a;
44
45
  const chess = new chess_js.Chess(startFen);
45
46
  const entries = [];
46
47
  for (const san of sans) {
@@ -48,9 +49,10 @@ function applyLineSans(startFen, sans) {
48
49
  const move = chess.move(san);
49
50
  if (!move)
50
51
  return null;
51
- entries.push({ fen: chess.fen(), lastSan: move.san });
52
+ const lastUci = `${move.from}${move.to}${(_a = move.promotion) !== null && _a !== void 0 ? _a : ""}`;
53
+ entries.push({ fen: chess.fen(), lastSan: move.san, lastUci });
52
54
  }
53
- catch (_a) {
55
+ catch (_b) {
54
56
  return null;
55
57
  }
56
58
  }
@@ -434,30 +436,31 @@ function useExplorerPrefetch({ fen, moves, positionReady, sources, fetchPosition
434
436
  return;
435
437
  }
436
438
  const sourcesParam = sources.length < ALL_GAME_SOURCES.length ? sources : undefined;
437
- for (const move of moves.slice(0, childCount)) {
439
+ const prefetchMove = (move) => {
438
440
  const childFen = fenAfterUci(fen, move.uci);
439
441
  if (!childFen) {
440
- continue;
442
+ return Promise.resolve();
441
443
  }
442
444
  const gamesKey = gamesSessionKey({
443
445
  fen: childFen,
444
446
  sources: sourcesParam,
445
447
  });
446
- if (!peekSessionGames(gamesKey)) {
447
- void fetchPositionGames({ fen: childFen, sources: sourcesParam })
448
+ const gamesPromise = peekSessionGames(gamesKey)
449
+ ? Promise.resolve()
450
+ : fetchPositionGames({ fen: childFen, sources: sourcesParam })
448
451
  .then((games) => {
449
452
  if (!cancelled) {
450
453
  setSessionGames(gamesKey, games);
451
454
  }
452
455
  })
453
456
  .catch(() => undefined);
454
- }
455
457
  if (!fetchPositionVariations) {
456
- continue;
458
+ return gamesPromise;
457
459
  }
458
460
  const variationsKey = variationsSessionKey(childFen);
459
- if (!peekSessionVariations(variationsKey)) {
460
- void fetchPositionVariations({
461
+ const variationsPromise = peekSessionVariations(variationsKey)
462
+ ? Promise.resolve()
463
+ : fetchPositionVariations({
461
464
  fen: childFen,
462
465
  mode: "variations",
463
466
  lineCount: EXPLORER_DEFAULT_VARIATION_LINE_COUNT,
@@ -470,8 +473,17 @@ function useExplorerPrefetch({ fen, moves, positionReady, sources, fetchPosition
470
473
  }
471
474
  })
472
475
  .catch(() => undefined);
476
+ return Promise.all([gamesPromise, variationsPromise]).then(() => undefined);
477
+ };
478
+ void (() => __awaiter(this, void 0, void 0, function* () {
479
+ const movesToPrefetch = moves.slice(0, childCount);
480
+ for (let index = 0; index < movesToPrefetch.length; index += 2) {
481
+ if (cancelled) {
482
+ return;
483
+ }
484
+ yield Promise.all(movesToPrefetch.slice(index, index + 2).map(prefetchMove));
473
485
  }
474
- }
486
+ }))();
475
487
  };
476
488
  if (typeof window.requestIdleCallback === "function") {
477
489
  idleHandle = window.requestIdleCallback(prefetchChildren, {
@@ -515,19 +527,19 @@ function initialHistoryState(initialFen, initialLineSans) {
515
527
  };
516
528
  }
517
529
  function usePositionHistory(initialFen, initialLineSans) {
518
- var _a, _b;
530
+ var _a, _b, _c, _d;
519
531
  const [history, setHistory] = react.useState(() => initialHistoryState(initialFen, initialLineSans).history);
520
532
  const [historyIndex, setHistoryIndex] = react.useState(() => initialHistoryState(initialFen, initialLineSans).historyIndex);
521
533
  const canGoBack = historyIndex > 0;
522
534
  const canGoForward = historyIndex < history.length - 1;
523
- const pushEntry = react.useCallback((fen, lastSan) => {
535
+ const pushEntry = react.useCallback((fen, lastSan, lastUci) => {
524
536
  setHistory((prev) => {
525
537
  const trimmed = prev.slice(0, historyIndex + 1);
526
- const next = [...trimmed, { fen, lastSan }];
538
+ const next = [...trimmed, { fen, lastSan, lastUci }];
527
539
  setHistoryIndex(next.length - 1);
528
540
  return next;
529
541
  });
530
- return { fen, lastSan };
542
+ return { fen, lastSan, lastUci };
531
543
  }, [historyIndex]);
532
544
  const goBack = react.useCallback(() => {
533
545
  if (historyIndex <= 0)
@@ -588,12 +600,14 @@ function usePositionHistory(initialFen, initialLineSans) {
588
600
  setHistory([{ fen: startFen }, ...entries]);
589
601
  setHistoryIndex(entries.length);
590
602
  }, []);
603
+ const lastMoveUci = historyIndex > 0 ? (_d = (_c = history[historyIndex]) === null || _c === void 0 ? void 0 : _c.lastUci) !== null && _d !== void 0 ? _d : null : null;
591
604
  return {
592
605
  canGoBack,
593
606
  canGoForward,
594
607
  lineSans,
595
608
  forwardSans,
596
609
  currentFen,
610
+ lastMoveUci,
597
611
  pushEntry,
598
612
  pushEntries,
599
613
  replaceLineEntries,
@@ -638,7 +652,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
638
652
  }
639
653
  isAnimatingVariationRef.current = false;
640
654
  }, []);
641
- const { canGoBack, canGoForward, lineSans, forwardSans, currentFen, pushEntry, pushEntries, replaceLineEntries, goBack, goForward, goFirst, goLast, resetHistory, } = usePositionHistory(initialFen, initialLineSans);
655
+ const { canGoBack, canGoForward, lineSans, forwardSans, currentFen, pushEntry, pushEntries, replaceLineEntries, goBack, goForward, goFirst, goLast, resetHistory, lastMoveUci: historyLastMoveUci, } = usePositionHistory(initialFen, initialLineSans);
642
656
  /** FEN used for explorer API queries — follows move history, not animation frames. */
643
657
  const queryFen = currentFen;
644
658
  const initialLineKey = react.useMemo(() => { var _a; return (_a = initialLineSans === null || initialLineSans === void 0 ? void 0 : initialLineSans.join("|")) !== null && _a !== void 0 ? _a : ""; }, [initialLineSans]);
@@ -673,6 +687,10 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
673
687
  }), [sources]);
674
688
  const lastAppliedLineKeyRef = react.useRef(initialLineKey);
675
689
  react.useEffect(() => () => cancelVariationAnimation(), [cancelVariationAnimation]);
690
+ // Hold URL line callbacks until history catches up after an external line change.
691
+ react.useEffect(() => {
692
+ readyForLineSyncRef.current = false;
693
+ }, [initialLineKey]);
676
694
  // Apply URL line changes only when the external line key changes — not when the
677
695
  // user clicks moves (internal lineSans updates must not re-sync from stale props).
678
696
  react.useEffect(() => {
@@ -883,7 +901,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
883
901
  const nextFen = fenAfterUci(queryFen, move.uci);
884
902
  if (!nextFen)
885
903
  return;
886
- pushEntry(nextFen, move.san);
904
+ pushEntry(nextFen, move.san, move.uci);
887
905
  setSelectedVariationKey(undefined);
888
906
  applyNavigation(nextFen, true);
889
907
  }, [queryFen, pushEntry, applyNavigation, cancelVariationAnimation]);
@@ -897,7 +915,11 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
897
915
  const nextFen = fenAfterUci(lineFen, line.uciPath[i]);
898
916
  if (!nextFen)
899
917
  return;
900
- entries.push({ fen: nextFen, lastSan: line.moves[i].san });
918
+ entries.push({
919
+ fen: nextFen,
920
+ lastSan: line.moves[i].san,
921
+ lastUci: line.uciPath[i],
922
+ });
901
923
  lineFen = nextFen;
902
924
  }
903
925
  if (entries.length === 0)
@@ -932,7 +954,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
932
954
  const result = applyBoardMove(queryFen, sourceSquare, targetSquare, piece);
933
955
  if (!result)
934
956
  return false;
935
- pushEntry(result.fen, result.san);
957
+ pushEntry(result.fen, result.san, result.uci);
936
958
  setSelectedVariationKey(undefined);
937
959
  applyNavigation(result.fen, true);
938
960
  return true;
@@ -1009,6 +1031,7 @@ function usePositionReferenceData({ fenProp, onFenChange, initialLineSans, onLin
1009
1031
  canGoForward,
1010
1032
  forwardSans,
1011
1033
  selectedVariationKey,
1034
+ lastMoveUci: historyLastMoveUci,
1012
1035
  setSources,
1013
1036
  setGamesMoveFilterUci,
1014
1037
  handleMoveSelect,
@@ -1107,7 +1130,7 @@ const PositionReferenceExplorerCore = ({ fen: fenProp, onFenChange, initialLineS
1107
1130
  fetchPositionGames,
1108
1131
  fetchPositionVariations,
1109
1132
  });
1110
- const { fen, boardFen, games, sources, lineSans, loading, showPositionLoading, gamesLoading, positionReady, displayMoves, error, lineLabel, canGoBack, canGoForward, forwardSans, selectedVariationKey, setSources, handleMoveSelect, handleLineSelect, handlePieceDrop, handleBack, handleForward, handleFirst, handleLast, } = referenceData;
1133
+ const { fen, boardFen, games, sources, lineSans, loading, showPositionLoading, gamesLoading, positionReady, displayMoves, error, lineLabel, canGoBack, canGoForward, forwardSans, selectedVariationKey, lastMoveUci, setSources, handleMoveSelect, handleLineSelect, handlePieceDrop, handleBack, handleForward, handleFirst, handleLast, } = referenceData;
1111
1134
  reactChessCore.usePositionKeyboardNav({
1112
1135
  enabled: keyboardNav,
1113
1136
  canPrev: canGoBack,
@@ -1171,7 +1194,7 @@ const PositionReferenceExplorerCore = ({ fen: fenProp, onFenChange, initialLineS
1171
1194
  overflow: fillHeight ? "hidden" : "visible",
1172
1195
  boxSizing: "border-box",
1173
1196
  };
1174
- const board = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactChessCore.ChessboardDnDProvider, { children: jsxRuntime.jsx(reactChessCore.HighlightChessboard, { boardWidth: boardWidth, position: boardFen, boardOrientation: boardOrientation, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }, boardOrientation) }), renderBoardNav(boardNavProps)] }));
1197
+ const board = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactChessCore.ChessboardDnDProvider, { children: jsxRuntime.jsx(reactChessCore.HighlightChessboard, { boardWidth: boardWidth, position: boardFen, boardOrientation: boardOrientation, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, lastMoveUci: lastMoveUci, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }, boardOrientation) }), renderBoardNav(boardNavProps)] }));
1175
1198
  const referencePanel = (jsxRuntime.jsx(DefaultReferencePanel, { theme: theme, fillHeight: fillHeight, status: renderStatus({
1176
1199
  error,
1177
1200
  loading: showPositionLoading,
@@ -1293,6 +1316,7 @@ function useGameReplayTraining({ gameId, startFen, fetchGame, }) {
1293
1316
  const complete = game ? plyIndex >= game.movesUci.length : false;
1294
1317
  const expectedUci = game === null || game === void 0 ? void 0 : game.movesUci[plyIndex];
1295
1318
  const expectedSan = game === null || game === void 0 ? void 0 : game.movesSan[plyIndex];
1319
+ const lastMoveUci = react.useMemo(() => (game ? reactChessCore.lastMoveUciAtPly(game.movesUci, plyIndex) : null), [game, plyIndex]);
1296
1320
  const handlePieceDrop = react.useCallback((sourceSquare, targetSquare, piece) => {
1297
1321
  if (!game || complete || !expectedUci)
1298
1322
  return false;
@@ -1326,6 +1350,7 @@ function useGameReplayTraining({ gameId, startFen, fetchGame, }) {
1326
1350
  error,
1327
1351
  feedback,
1328
1352
  lastExpectedSan,
1353
+ lastMoveUci,
1329
1354
  expectedSan,
1330
1355
  handlePieceDrop,
1331
1356
  revealMove,
@@ -1346,7 +1371,7 @@ const defaultButtonStyle = {
1346
1371
  cursor: "pointer",
1347
1372
  };
1348
1373
  const GameReplayTrainer = ({ gameId, startFen, fetchGame, onExit, theme = "dark", boardWidth = DEFAULT_REFERENCE_LAYOUT.boardWidth, renderStatus, }) => {
1349
- const { game, fen, plyIndex, totalPlies, complete, loading, error, feedback, lastExpectedSan, handlePieceDrop, revealMove, } = useGameReplayTraining({ gameId, startFen, fetchGame });
1374
+ const { game, fen, plyIndex, totalPlies, complete, loading, error, feedback, lastExpectedSan, lastMoveUci, handlePieceDrop, revealMove, } = useGameReplayTraining({ gameId, startFen, fetchGame });
1350
1375
  const status = renderStatus === null || renderStatus === void 0 ? void 0 : renderStatus({
1351
1376
  loading,
1352
1377
  error,
@@ -1366,7 +1391,7 @@ const GameReplayTrainer = ({ gameId, startFen, fetchGame, onExit, theme = "dark"
1366
1391
  !error &&
1367
1392
  !complete &&
1368
1393
  feedback === "incorrect" &&
1369
- lastExpectedSan && (jsxRuntime.jsxs("span", { style: { color: "#ef6c00" }, children: ["Expected ", lastExpectedSan] })), !loading && !error && !complete && feedback === null && (jsxRuntime.jsxs("span", { children: ["Guess move ", plyIndex + 1, " of ", totalPlies] }))] })), jsxRuntime.jsx(reactChessCore.ChessboardDnDProvider, { children: jsxRuntime.jsx(reactChessCore.HighlightChessboard, { boardWidth: boardWidth, position: fen, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }) }), !complete && !loading && !error && (jsxRuntime.jsx("button", { type: "button", style: defaultButtonStyle, onClick: revealMove, children: "Show move" }))] }) }));
1394
+ lastExpectedSan && (jsxRuntime.jsxs("span", { style: { color: "#ef6c00" }, children: ["Expected ", lastExpectedSan] })), !loading && !error && !complete && feedback === null && (jsxRuntime.jsxs("span", { children: ["Guess move ", plyIndex + 1, " of ", totalPlies] }))] })), jsxRuntime.jsx(reactChessCore.ChessboardDnDProvider, { children: jsxRuntime.jsx(reactChessCore.HighlightChessboard, { boardWidth: boardWidth, position: fen, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, lastMoveUci: lastMoveUci, onPieceDrop: handlePieceDrop, promotionDialogVariant: "modal" }) }), !complete && !loading && !error && (jsxRuntime.jsx("button", { type: "button", style: defaultButtonStyle, onClick: revealMove, children: "Show move" }))] }) }));
1370
1395
  };
1371
1396
 
1372
1397
  const mockPosition = {
package/package.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "name": "react-chess-explorer",
3
- "version": "0.0.3",
4
- "description": "React components for browsing and replaying chess games (depends on react-chess-core only)",
5
- "license": "MIT",
6
- "author": "Robert Blackwell",
7
- "main": "dist/index.js",
8
- "module": "dist/index.esm.js",
9
- "types": "dist/index.d.ts",
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/reblackwell3/react-chess-explorer.git"
13
- },
14
- "files": [
15
- "dist"
16
- ],
17
- "scripts": {
18
- "build": "rollup -c",
19
- "prepublishOnly": "npm run build",
20
- "storybook": "storybook dev -p 6008",
21
- "build-storybook": "storybook build"
22
- },
23
- "dependencies": {
24
- "rollup": "^4.22.2",
25
- "rollup-plugin-peer-deps-external": "^2.2.4",
26
- "typescript": "^5.6.2"
27
- },
28
- "peerDependencies": {
29
- "chess.js": "^1.0.0-beta.8",
30
- "react": "^18.3.1",
31
- "react-chess-core": "^0.1.1",
32
- "react-chessboard": "^4.7.1"
33
- },
34
- "devDependencies": {
35
- "@chromatic-com/storybook": "^1.9.0",
36
- "@rollup/plugin-commonjs": "^26.0.1",
37
- "@rollup/plugin-node-resolve": "^15.2.3",
38
- "@rollup/plugin-typescript": "^12.3.0",
39
- "@storybook/addon-essentials": "^8.2.9",
40
- "@storybook/addon-interactions": "^8.2.9",
41
- "@storybook/addon-links": "^8.2.9",
42
- "@storybook/addon-onboarding": "^8.2.9",
43
- "@storybook/blocks": "^8.2.9",
44
- "@storybook/preset-typescript": "^3.0.0",
45
- "@storybook/react": "^8.2.9",
46
- "@storybook/react-vite": "^8.2.9",
47
- "@storybook/test": "^8.2.9",
48
- "@types/react": "^18.3.12",
49
- "@types/react-dom": "^18.3.1",
50
- "@vitejs/plugin-react": "^4.3.1",
51
- "chess.js": "^1.0.0-beta.8",
52
- "react": "^18.3.1",
53
- "react-chess-core": "^0.1.1",
54
- "react-chessboard": "^4.7.1",
55
- "react-dom": "^18.3.1",
56
- "storybook": "^8.2.9",
57
- "tslib": "^2.8.1"
58
- }
59
- }
1
+ {
2
+ "name": "react-chess-explorer",
3
+ "version": "0.0.5",
4
+ "description": "React components for browsing and replaying chess games (depends on react-chess-core only)",
5
+ "license": "MIT",
6
+ "author": "Robert Blackwell",
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.esm.js",
9
+ "types": "dist/index.d.ts",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/reblackwell3/react-chess-explorer.git"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "rollup -c",
19
+ "prepublishOnly": "npm run build",
20
+ "storybook": "storybook dev -p 6008",
21
+ "build-storybook": "storybook build"
22
+ },
23
+ "dependencies": {
24
+ "rollup": "^4.22.2",
25
+ "rollup-plugin-peer-deps-external": "^2.2.4",
26
+ "typescript": "^5.6.2"
27
+ },
28
+ "peerDependencies": {
29
+ "chess.js": "^1.0.0-beta.8",
30
+ "react": "^18.3.1",
31
+ "react-chess-core": "^0.1.1",
32
+ "react-chessboard": "^4.7.1"
33
+ },
34
+ "devDependencies": {
35
+ "@chromatic-com/storybook": "^1.9.0",
36
+ "@rollup/plugin-commonjs": "^26.0.1",
37
+ "@rollup/plugin-node-resolve": "^15.2.3",
38
+ "@rollup/plugin-typescript": "^12.3.0",
39
+ "@storybook/addon-essentials": "^8.2.9",
40
+ "@storybook/addon-interactions": "^8.2.9",
41
+ "@storybook/addon-links": "^8.2.9",
42
+ "@storybook/addon-onboarding": "^8.2.9",
43
+ "@storybook/blocks": "^8.2.9",
44
+ "@storybook/preset-typescript": "^3.0.0",
45
+ "@storybook/react": "^8.2.9",
46
+ "@storybook/react-vite": "^8.2.9",
47
+ "@storybook/test": "^8.2.9",
48
+ "@types/react": "^18.3.12",
49
+ "@types/react-dom": "^18.3.1",
50
+ "@vitejs/plugin-react": "^4.3.1",
51
+ "chess.js": "^1.0.0-beta.8",
52
+ "react": "^18.3.1",
53
+ "react-chess-core": "^0.1.1",
54
+ "react-chessboard": "^4.7.1",
55
+ "react-dom": "^18.3.1",
56
+ "storybook": "^8.2.9",
57
+ "tslib": "^2.8.1"
58
+ }
59
+ }