@connectorvol/chess-widgets 1.0.0

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 (35) hide show
  1. package/dist/button-variants.d.ts +73 -0
  2. package/dist/button-variants.js +31 -0
  3. package/dist/constants/editable-board-settings.d.ts +26 -0
  4. package/dist/constants/editable-board-settings.js +27 -0
  5. package/dist/index.d.ts +12 -0
  6. package/dist/index.js +8 -0
  7. package/dist/position-editor/EditFen.svelte +164 -0
  8. package/dist/position-editor/EditFen.svelte.d.ts +16 -0
  9. package/dist/position-editor/EditMove.svelte +180 -0
  10. package/dist/position-editor/EditMove.svelte.d.ts +9 -0
  11. package/dist/position-editor/EditPanel.svelte +164 -0
  12. package/dist/position-editor/EditPanel.svelte.d.ts +8 -0
  13. package/dist/position-editor/fen.svelte.d.ts +26 -0
  14. package/dist/position-editor/fen.svelte.js +177 -0
  15. package/dist/puzzle/puzzleCreatedPayload.d.ts +17 -0
  16. package/dist/puzzle/puzzleCreatedPayload.js +24 -0
  17. package/dist/puzzle/puzzleData.d.ts +32 -0
  18. package/dist/puzzle/puzzleData.js +51 -0
  19. package/dist/puzzle/puzzleSolverForkAnnotations.d.ts +14 -0
  20. package/dist/puzzle/puzzleSolverForkAnnotations.js +94 -0
  21. package/dist/puzzle/puzzleStepPreviewSolver.d.ts +39 -0
  22. package/dist/puzzle/puzzleStepPreviewSolver.js +87 -0
  23. package/dist/puzzle-creation/PuzzleCreationWizard.svelte +247 -0
  24. package/dist/puzzle-creation/PuzzleCreationWizard.svelte.d.ts +5 -0
  25. package/dist/puzzle-creation/StepMoves.svelte +225 -0
  26. package/dist/puzzle-creation/StepMoves.svelte.d.ts +15 -0
  27. package/dist/puzzle-creation/StepPosition.svelte +210 -0
  28. package/dist/puzzle-creation/StepPosition.svelte.d.ts +11 -0
  29. package/dist/puzzle-creation/StepPreview.svelte +589 -0
  30. package/dist/puzzle-creation/StepPreview.svelte.d.ts +23 -0
  31. package/dist/puzzle-creation/types.d.ts +27 -0
  32. package/dist/puzzle-creation/types.js +1 -0
  33. package/dist/utils.d.ts +15 -0
  34. package/dist/utils.js +8 -0
  35. package/package.json +76 -0
@@ -0,0 +1,589 @@
1
+ <script lang="ts">
2
+ import type {
3
+ ChessboardTheme,
4
+ IChessBoardActions,
5
+ } from "@connectorvol/chessboard";
6
+ import type { Square } from "@connectorvol/shared";
7
+
8
+ import {
9
+ Chessboard,
10
+ CHESSBOARD_THEMES,
11
+ createBoardApi,
12
+ DEFAULT_BOARD_SETTINGS,
13
+ Draggable,
14
+ } from "@connectorvol/chessboard";
15
+ import {
16
+ ChessTree,
17
+ TreeViewer,
18
+ transformPgnToChessNode,
19
+ } from "@connectorvol/tree";
20
+ import { Popover } from "bits-ui";
21
+ import { buttonVariants } from "../button-variants.js";
22
+ import { cn } from "../utils.js";
23
+
24
+ import { untrack } from "svelte";
25
+ import { INITIAL_FEN } from "@connectorvol/chessops/fen";
26
+ import { PgnOps } from "@connectorvol/chessops/pgnOps.svelte";
27
+ import { Color, DEFAULT_PIECE_SET, type Move } from "@connectorvol/shared";
28
+
29
+ import {
30
+ createEmptyTreeFromFen,
31
+ type PuzzleData,
32
+ } from "../puzzle/puzzleData.js";
33
+ import {
34
+ puzzlePreviewClassifySolverMove,
35
+ puzzlePreviewDupSubtreeUnderStudentCursor,
36
+ puzzlePreviewIsSolvedPosition,
37
+ puzzlePreviewSideToMoveFromFen,
38
+ puzzlePreviewNodeAtPath,
39
+ type TSolverMoveOutcome,
40
+ } from "../puzzle/puzzleStepPreviewSolver.js";
41
+ import {
42
+ puzzlePartsFromFullPgn,
43
+ type TPuzzleCreatedPayload,
44
+ } from "../puzzle/puzzleCreatedPayload.js";
45
+ import type { TChessboardAppearanceSettings } from "./types.js";
46
+
47
+ /** Сообщение «нет такого продолжения» показывается только через кнопку «!» в шапке (как на шаге 2). */
48
+ const PREVIEW_WRONG_NO_VARIANT_MESSAGE =
49
+ "Такого продолжения нет среди вариантов задачи.";
50
+
51
+ interface Props {
52
+ /** Возвращает данные задачи (стартовый FEN и главная линия SAN для совместимости). */
53
+ puzzleData: PuzzleData;
54
+ /** Возвращает полное дерево задачи в виде строки PGN после шага 2. */
55
+ solutionPgn: string;
56
+ /** Возвращает переход назад на шаг построения линии. */
57
+ onBack: () => void;
58
+ /**
59
+ * Возвращает колбэк сохранения задачи с FEN и строкой ходов (кнопка «Готово» показывается после решения превью).
60
+ */
61
+ onPuzzleCreated?: (payload: TPuzzleCreatedPayload) => void;
62
+ /** Возвращает тему оформления шахматной доски (по умолчанию `CHESSBOARD_THEMES.blue`). */
63
+ boardTheme?: ChessboardTheme;
64
+ /** Возвращает дополнительные визуальные настройки шахматной доски (кроме `boardSize`, `orientation`, `draggable`). */
65
+ boardAppearanceSettings?: TChessboardAppearanceSettings;
66
+ }
67
+
68
+ const props: Props = $props();
69
+
70
+ const solverColor = $derived(
71
+ props.puzzleData.initialFen.trim().split(/\s+/)[1] === "b"
72
+ ? Color.BLACK
73
+ : Color.WHITE,
74
+ );
75
+
76
+ let cursorPath = $state<number[]>([]);
77
+ let solved = $state(false);
78
+ let wrongFeedback = $state<string | null>(null);
79
+
80
+ /** Высота блока с шахматной доской (px), чтобы выровнять высоту TreeViewer по доске на lg. */
81
+ let chessboardBlockHeight = $state(0);
82
+
83
+ let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
84
+ let advanceLineTimer: ReturnType<typeof setTimeout> | null = null;
85
+
86
+ let previewChess = new PgnOps(INITIAL_FEN, "chess");
87
+
88
+ const studentPreviewTree = new ChessTree(createEmptyTreeFromFen(INITIAL_FEN));
89
+
90
+ let solutionPreviewTree = $state<ChessTree | null>(null);
91
+
92
+ /**
93
+ * Представляет синхронизацию доступности перетаскивания фигур с очередью хода и состоянием «решено».
94
+ */
95
+ function syncBoardDragFromChessTurn(): void {
96
+ chessboard.draggable = solved
97
+ ? Draggable.NONE
98
+ : previewChess.turn() === solverColor
99
+ ? solverColor === Color.WHITE
100
+ ? Draggable.WHITE
101
+ : Draggable.BLACK
102
+ : Draggable.NONE;
103
+ }
104
+
105
+ /**
106
+ * Представляет добавление (если отсутствует) пути в TreeViewer ученика без очистки уже пройденных линий.
107
+ */
108
+ function ensureStudentPreviewTreeHasPath(path: number[]): void {
109
+ const solution = solutionPreviewTree;
110
+ if (!solution) return;
111
+
112
+ studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
113
+
114
+ let src = solution.rootNode.moves;
115
+ for (const idx of path) {
116
+ const next = src.children[idx];
117
+ studentPreviewTree.addNodeToCurrent({
118
+ id: "",
119
+ children: [],
120
+ data: { ...next.data },
121
+ });
122
+ src = next;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Представляет обработку ошибочного хода с показом неверной ветки или сообщения без варианта.
128
+ */
129
+ function handleWrongOutcome(outcome: TSolverMoveOutcome): void {
130
+ const solution = solutionPreviewTree;
131
+ if (!solution) return;
132
+
133
+ if (wrongRevealTimer !== null) {
134
+ clearTimeout(wrongRevealTimer);
135
+ wrongRevealTimer = null;
136
+ }
137
+ if (advanceLineTimer !== null) {
138
+ clearTimeout(advanceLineTimer);
139
+ advanceLineTimer = null;
140
+ }
141
+
142
+ if (outcome.kind === "wrong_marked_variant") {
143
+ wrongFeedback =
144
+ "Неверно. Открываю введённый вариант в дереве, ход отменён.";
145
+ // Ход на доске не применяется (мы возвращаем undefined), но в TreeViewer
146
+ // добавляем ветку неверного ответа, чтобы её можно было изучить.
147
+ ensureStudentPreviewTreeHasPath(cursorPath);
148
+ puzzlePreviewDupSubtreeUnderStudentCursor(
149
+ studentPreviewTree,
150
+ outcome.wrongBranchRoot,
151
+ );
152
+ return;
153
+ }
154
+
155
+ wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
156
+ }
157
+
158
+ /**
159
+ * Представляет установку позиции и дерева по пути из корня.
160
+ */
161
+ function setStateFromPath(path: number[]): void {
162
+ const solution = solutionPreviewTree;
163
+ if (!solution) return;
164
+
165
+ previewChess.setFen(props.puzzleData.initialFen);
166
+ let node = solution.rootNode.moves;
167
+ for (const idx of path) {
168
+ const child = node.children[idx]!;
169
+ previewChess.makeSanMove(child.data.san);
170
+ node = child;
171
+ }
172
+
173
+ cursorPath = path;
174
+ ensureStudentPreviewTreeHasPath(cursorPath);
175
+ chessboard.fen = previewChess.fen();
176
+ syncBoardDragFromChessTurn();
177
+ }
178
+
179
+ /**
180
+ * Представляет автоматический ход соперника по основной линии (первый вариант), если сейчас ход соперника.
181
+ * Возвращает новый путь.
182
+ */
183
+ function applyOpponentMainLineMove(pathAfterSolver: number[]): number[] {
184
+ const solution = solutionPreviewTree;
185
+ if (!solution) return pathAfterSolver;
186
+
187
+ if (puzzlePreviewSideToMoveFromFen(previewChess.fen()) === solverColor) {
188
+ return pathAfterSolver;
189
+ }
190
+
191
+ const node = puzzlePreviewNodeAtPath(
192
+ solution.rootNode.moves,
193
+ pathAfterSolver,
194
+ );
195
+ if (node.children.length === 0) return pathAfterSolver;
196
+
197
+ previewChess.makeSanMove(node.children[0]!.data.san);
198
+ return [...pathAfterSolver, 0];
199
+ }
200
+
201
+ /**
202
+ * Представляет поиск следующей линии по развилкам соперника (DFS: чем глубже развилка — тем раньше возврат).
203
+ */
204
+ function findNextOpponentLine(path: number[]): number[] | null {
205
+ const solution = solutionPreviewTree;
206
+ if (!solution) return null;
207
+
208
+ let node = solution.rootNode.moves;
209
+ for (let depth = 0; depth <= path.length; depth++) {
210
+ // depth === path.length соответствует текущей позиции (узел node)
211
+ const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
212
+ const isOpponentToMove = sideToMove !== solverColor;
213
+ const isFork = node.children.length > 1;
214
+
215
+ if (isOpponentToMove && isFork && depth < path.length) {
216
+ const chosenIdx = path[depth]!;
217
+ if (chosenIdx < node.children.length - 1) {
218
+ // кандидат на "следующую линию" с этого форка
219
+ // но продолжаем вниз, чтобы взять самый глубокий
220
+ }
221
+ }
222
+
223
+ if (depth === path.length) break;
224
+ node = node.children[path[depth]!]!;
225
+ }
226
+
227
+ // Второй проход: выбираем самый глубокий форк соперника, где есть непройденные ответы.
228
+ node = solution.rootNode.moves;
229
+ let bestForkDepth: number | null = null;
230
+ let bestNextIdx = 0;
231
+ for (let depth = 0; depth < path.length; depth++) {
232
+ const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
233
+ const isOpponentToMove = sideToMove !== solverColor;
234
+ if (isOpponentToMove && node.children.length > 1) {
235
+ const chosenIdx = path[depth]!;
236
+ if (chosenIdx < node.children.length - 1) {
237
+ bestForkDepth = depth;
238
+ bestNextIdx = chosenIdx + 1;
239
+ }
240
+ }
241
+ node = node.children[path[depth]!]!;
242
+ }
243
+
244
+ if (bestForkDepth === null) return null;
245
+ return [...path.slice(0, bestForkDepth), bestNextIdx];
246
+ }
247
+
248
+ /**
249
+ * Представляет переход на следующую линию (следующий ответ соперника), если она есть.
250
+ */
251
+ function maybeAdvanceToNextOpponentLine(): boolean {
252
+ const nextBase = findNextOpponentLine(cursorPath);
253
+ if (!nextBase) return false;
254
+
255
+ solved = false;
256
+ wrongFeedback = null;
257
+
258
+ // После выбора нового ответа соперника нужно довести позицию до актуальной точки:
259
+ // ставим состояние на basePath (до хода соперника), затем делаем выбранный ход соперника (он уже в пути).
260
+ setStateFromPath(nextBase);
261
+ return true;
262
+ }
263
+
264
+ /**
265
+ * Представляет попытку применить SAN хода решателя с автответами соперника по дереву.
266
+ */
267
+ function tryApplySolverSan(san: string): Move | undefined {
268
+ const solution = solutionPreviewTree;
269
+ if (!solution || solved) return undefined;
270
+
271
+ const cursorNode = puzzlePreviewNodeAtPath(
272
+ solution.rootNode.moves,
273
+ cursorPath,
274
+ );
275
+
276
+ const outcome = puzzlePreviewClassifySolverMove(
277
+ cursorNode,
278
+ previewChess.fen(),
279
+ san,
280
+ solverColor,
281
+ );
282
+
283
+ if (outcome.kind !== "correct") {
284
+ handleWrongOutcome(outcome);
285
+ return undefined;
286
+ }
287
+
288
+ try {
289
+ let path = [...cursorPath, outcome.childIndex];
290
+ const moveRecord = previewChess.makeSanMove(san);
291
+ path = applyOpponentMainLineMove(path);
292
+ setStateFromPath(path);
293
+
294
+ const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, cursorPath);
295
+ if (puzzlePreviewIsSolvedPosition(previewChess, leaf, solverColor)) {
296
+ solved = true;
297
+ if (advanceLineTimer !== null) {
298
+ clearTimeout(advanceLineTimer);
299
+ advanceLineTimer = null;
300
+ }
301
+ advanceLineTimer = setTimeout(() => {
302
+ // Если есть ещё линии по развилкам соперника — переключаемся на следующую.
303
+ if (maybeAdvanceToNextOpponentLine()) {
304
+ // продолжаем в режиме решения
305
+ }
306
+ advanceLineTimer = null;
307
+ }, 250);
308
+ }
309
+
310
+ wrongFeedback = null;
311
+ syncBoardDragFromChessTurn();
312
+
313
+ return moveRecord;
314
+ } catch {
315
+ wrongFeedback = "Ход недопустим в этой позиции.";
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ const actions: IChessBoardActions = {
321
+ game: {
322
+ possibleMovesOnSquare: (square: Square) => previewChess.moves(square),
323
+ beforePieceMoveSan(san: string) {
324
+ const move = tryApplySolverSan(san);
325
+ if (!move) return undefined;
326
+ return {
327
+ move,
328
+ fen: previewChess.fen(),
329
+ turn: previewChess.turn(),
330
+ };
331
+ },
332
+ afterPieceMoveSan: () => {
333
+ syncBoardDragFromChessTurn();
334
+ },
335
+ beforePieceMove: (from, to, promotion) => {
336
+ const san = previewChess.getSanForMove({ from, to, promotion });
337
+ const move = tryApplySolverSan(san);
338
+ if (!move) return "";
339
+ return previewChess.fen();
340
+ },
341
+ afterPieceMove: () => {
342
+ syncBoardDragFromChessTurn();
343
+ },
344
+ },
345
+ };
346
+
347
+ let chessboard = $derived(
348
+ createBoardApi({
349
+ fen: INITIAL_FEN,
350
+ settings: {
351
+ ...DEFAULT_BOARD_SETTINGS,
352
+ ...(props.boardAppearanceSettings ?? {}),
353
+ boardSize: 38,
354
+ isResizable: false,
355
+ orientation: Color.WHITE,
356
+ draggable: Draggable.WHITE,
357
+ },
358
+ actions,
359
+ theme: props.boardTheme ?? CHESSBOARD_THEMES.blue,
360
+ }),
361
+ );
362
+
363
+ $effect(() => {
364
+ const pgn = props.solutionPgn;
365
+ const initialFen = props.puzzleData.initialFen;
366
+ const orientation = solverColor;
367
+
368
+ if (!pgn.trim()) {
369
+ untrack(() => {
370
+ solutionPreviewTree = null;
371
+ previewChess.setFen(initialFen);
372
+ studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(initialFen));
373
+ studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
374
+ chessboard.fen = initialFen;
375
+ chessboard.orientation = orientation;
376
+ syncBoardDragFromChessTurn();
377
+ });
378
+ return () => {
379
+ if (wrongRevealTimer !== null) {
380
+ clearTimeout(wrongRevealTimer);
381
+ wrongRevealTimer = null;
382
+ }
383
+ if (advanceLineTimer !== null) {
384
+ clearTimeout(advanceLineTimer);
385
+ advanceLineTimer = null;
386
+ }
387
+ };
388
+ }
389
+
390
+ untrack(() => {
391
+ const { rootNode } = transformPgnToChessNode(pgn);
392
+ solutionPreviewTree = new ChessTree(rootNode);
393
+
394
+ cursorPath = [];
395
+ solved = false;
396
+ wrongFeedback = null;
397
+ previewChess.setFen(initialFen);
398
+ studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(initialFen));
399
+ studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
400
+ chessboard.fen = initialFen;
401
+ chessboard.orientation = orientation;
402
+ syncBoardDragFromChessTurn();
403
+ });
404
+
405
+ return () => {
406
+ if (wrongRevealTimer !== null) {
407
+ clearTimeout(wrongRevealTimer);
408
+ wrongRevealTimer = null;
409
+ }
410
+ if (advanceLineTimer !== null) {
411
+ clearTimeout(advanceLineTimer);
412
+ advanceLineTimer = null;
413
+ }
414
+ };
415
+ });
416
+
417
+ /**
418
+ * Представляет заглушку синхронизации движка при выборе узла в дереве превью (доска решается отдельно).
419
+ */
420
+ function noopChessFen(_fen: string): void {}
421
+
422
+ /**
423
+ * Представляет заглушку обновления доски TreeViewer на шаге превью.
424
+ */
425
+ function noopChessboardFen(_animationTime?: number): void {}
426
+
427
+ /**
428
+ * Представляет пустые колбэки навигации TreeViewer без побочных эффектов.
429
+ */
430
+ function noopOnSelectNode(): void {}
431
+
432
+ /**
433
+ * Представляет пустой колбэк после удаления варианта в TreeViewer.
434
+ */
435
+ function noopOnDeleteVariant(): void {}
436
+ </script>
437
+
438
+ <div class="flex flex-col gap-3 pt-2 pb-0">
439
+ <div class="flex flex-wrap items-center gap-2">
440
+ <h2 class="text-xl font-semibold">Шаг 3: Превью для ученика</h2>
441
+ <Popover.Root>
442
+ <Popover.Trigger type="button">
443
+ {#snippet child({ props })}
444
+ <button
445
+ {...props}
446
+ type="button"
447
+ class={cn(
448
+ buttonVariants({ variant: "outline", size: "icon-sm" }),
449
+ "size-7 shrink-0 rounded-full text-sm font-semibold",
450
+ )}
451
+ aria-label="Справка: превью для ученика"
452
+ >
453
+ ?
454
+ </button>
455
+ {/snippet}
456
+ </Popover.Trigger>
457
+ <Popover.Portal>
458
+ <Popover.Content
459
+ side="bottom"
460
+ align="start"
461
+ sideOffset={8}
462
+ class={cn(
463
+ "bg-popover text-popover-foreground border-border z-50 max-h-[min(70vh,32rem)] w-[min(calc(100vw-2rem),28rem)] overflow-y-auto rounded-lg border p-4 text-sm shadow-md outline-none",
464
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
465
+ "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
466
+ )}
467
+ >
468
+ <div class="space-y-3 text-muted-foreground leading-relaxed">
469
+ <p>
470
+ Так puzzle будет выглядеть для ученика. Делайте ходы за
471
+ {solverColor === Color.WHITE ? "белых" : "чёрных"}. При правильном
472
+ ходе соперник ответит автоматически (первый вариант на развилках
473
+ соперника). На развилке решателя достаточно одного верного
474
+ продолжения.
475
+ </p>
476
+ <p>
477
+ Сначала в дереве отображается только стартовая позиция; по мере
478
+ верных ходов появляются линии; при неверном отмеченном ответе кратко
479
+ показывается введённый неверный вариант.
480
+ </p>
481
+ <p>
482
+ После решения показывается всё дерево ходов из PGN, введённое на
483
+ шаге 2.
484
+ </p>
485
+ </div>
486
+ </Popover.Content>
487
+ </Popover.Portal>
488
+ </Popover.Root>
489
+ {#if wrongFeedback === PREVIEW_WRONG_NO_VARIANT_MESSAGE}
490
+ <Popover.Root>
491
+ <Popover.Trigger type="button">
492
+ {#snippet child({ props })}
493
+ <button
494
+ {...props}
495
+ type="button"
496
+ class={cn(
497
+ buttonVariants({ variant: "outline", size: "icon-sm" }),
498
+ "size-7 shrink-0 rounded-full border-amber-600/60 text-sm font-semibold text-amber-600 dark:border-amber-400/60 dark:text-amber-400",
499
+ )}
500
+ aria-label="Предупреждение по превью"
501
+ >
502
+ !
503
+ </button>
504
+ {/snippet}
505
+ </Popover.Trigger>
506
+ <Popover.Portal>
507
+ <Popover.Content
508
+ side="bottom"
509
+ align="start"
510
+ sideOffset={8}
511
+ class={cn(
512
+ "bg-popover text-popover-foreground border-border z-50 max-h-[min(70vh,32rem)] w-[min(calc(100vw-2rem),28rem)] overflow-y-auto rounded-lg border p-4 text-sm shadow-md outline-none",
513
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
514
+ "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
515
+ )}
516
+ >
517
+ <p class="leading-relaxed text-amber-700 dark:text-amber-400">
518
+ {PREVIEW_WRONG_NO_VARIANT_MESSAGE}
519
+ </p>
520
+ </Popover.Content>
521
+ </Popover.Portal>
522
+ </Popover.Root>
523
+ {/if}
524
+ </div>
525
+
526
+ <div
527
+ class="flex min-h-0 flex-col justify-center gap-4 lg:flex-row lg:items-start lg:gap-4"
528
+ >
529
+ <div class="flex w-full max-w-[38rem] shrink-0 flex-col gap-2">
530
+ <div class="w-full shrink-0" bind:clientHeight={chessboardBlockHeight}>
531
+ <Chessboard facade={chessboard} />
532
+ </div>
533
+ {#if wrongFeedback && wrongFeedback !== PREVIEW_WRONG_NO_VARIANT_MESSAGE}
534
+ <p class="text-sm font-medium text-amber-600 dark:text-amber-400">
535
+ {wrongFeedback}
536
+ </p>
537
+ {/if}
538
+ </div>
539
+
540
+ <div class="flex min-h-0 w-full flex-1 flex-col gap-2 lg:min-w-0">
541
+ <div
542
+ class={cn(
543
+ "flex min-w-0 flex-col overflow-hidden rounded-md border border-border",
544
+ chessboardBlockHeight > 0
545
+ ? "min-h-0 shrink-0"
546
+ : "min-h-[12rem] max-h-[min(65vh,36rem)]",
547
+ )}
548
+ style:height={chessboardBlockHeight > 0
549
+ ? `${chessboardBlockHeight}px`
550
+ : undefined}
551
+ >
552
+ <TreeViewer
553
+ chessTree={solved && solutionPreviewTree
554
+ ? solutionPreviewTree
555
+ : studentPreviewTree}
556
+ onSelectNode={noopOnSelectNode}
557
+ onDeleteVariant={noopOnDeleteVariant}
558
+ setChessFen={noopChessFen}
559
+ setChessboardFen={noopChessboardFen}
560
+ pieceSet={DEFAULT_PIECE_SET}
561
+ className="min-h-0 flex-1 border-x-0 border-t-0"
562
+ editMode={false}
563
+ selectable={false}
564
+ />
565
+ </div>
566
+ </div>
567
+ </div>
568
+
569
+ <div class="flex justify-between gap-2">
570
+ <button
571
+ type="button"
572
+ class={cn(buttonVariants({ variant: "outline" }))}
573
+ onclick={props.onBack}
574
+ >
575
+ Назад
576
+ </button>
577
+ {#if props.onPuzzleCreated && solved}
578
+ <button
579
+ type="button"
580
+ class={cn(buttonVariants({ variant: "default" }))}
581
+ onclick={() =>
582
+ props.onPuzzleCreated?.(puzzlePartsFromFullPgn(props.solutionPgn))}
583
+ disabled={!props.solutionPgn.trim()}
584
+ >
585
+ Готово
586
+ </button>
587
+ {/if}
588
+ </div>
589
+ </div>
@@ -0,0 +1,23 @@
1
+ import type { ChessboardTheme } from "@connectorvol/chessboard";
2
+ import { type PuzzleData } from "../puzzle/puzzleData.js";
3
+ import { type TPuzzleCreatedPayload } from "../puzzle/puzzleCreatedPayload.js";
4
+ import type { TChessboardAppearanceSettings } from "./types.js";
5
+ interface Props {
6
+ /** Возвращает данные задачи (стартовый FEN и главная линия SAN для совместимости). */
7
+ puzzleData: PuzzleData;
8
+ /** Возвращает полное дерево задачи в виде строки PGN после шага 2. */
9
+ solutionPgn: string;
10
+ /** Возвращает переход назад на шаг построения линии. */
11
+ onBack: () => void;
12
+ /**
13
+ * Возвращает колбэк сохранения задачи с FEN и строкой ходов (кнопка «Готово» показывается после решения превью).
14
+ */
15
+ onPuzzleCreated?: (payload: TPuzzleCreatedPayload) => void;
16
+ /** Возвращает тему оформления шахматной доски (по умолчанию `CHESSBOARD_THEMES.blue`). */
17
+ boardTheme?: ChessboardTheme;
18
+ /** Возвращает дополнительные визуальные настройки шахматной доски (кроме `boardSize`, `orientation`, `draggable`). */
19
+ boardAppearanceSettings?: TChessboardAppearanceSettings;
20
+ }
21
+ declare const StepPreview: import("svelte").Component<Props, {}, "">;
22
+ type StepPreview = ReturnType<typeof StepPreview>;
23
+ export default StepPreview;
@@ -0,0 +1,27 @@
1
+ import type { TPuzzleCreatedPayload } from "../puzzle/puzzleCreatedPayload.js";
2
+ import type { ChessBoardSettings, ChessboardTheme } from "@connectorvol/chessboard";
3
+ /**
4
+ * Представляет тип визуальных настроек шахматной доски для мастера создания задачи.
5
+ */
6
+ export type TChessboardAppearanceSettings = Omit<Partial<ChessBoardSettings>, "boardSize" | "orientation" | "draggable">;
7
+ /**
8
+ * Представляет свойства компонента мастера создания шахматной задачи.
9
+ */
10
+ export interface TPuzzleCreationWizardProps {
11
+ /**
12
+ * Возвращает функцию, вызываемую при завершении мастера с FEN и строкой ходов.
13
+ */
14
+ onPuzzleCreated: (payload: TPuzzleCreatedPayload) => void;
15
+ /**
16
+ * Возвращает начальный полный FEN для шага 1; если не задан — пустая доска.
17
+ */
18
+ fen?: string;
19
+ /**
20
+ * Возвращает тему оформления шахматной доски (по умолчанию `CHESSBOARD_THEMES.blue`).
21
+ */
22
+ boardTheme?: ChessboardTheme;
23
+ /**
24
+ * Возвращает дополнительные визуальные настройки шахматной доски (кроме `boardSize`, `orientation`, `draggable`).
25
+ */
26
+ boardAppearanceSettings?: TChessboardAppearanceSettings;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { type ClassValue } from "clsx";
2
+ /**
3
+ * Представляет объединение классов Tailwind с разрешением конфликтов.
4
+ */
5
+ export declare function cn(...inputs: ClassValue[]): string;
6
+ export type WithoutChild<T> = T extends {
7
+ child?: any;
8
+ } ? Omit<T, "child"> : T;
9
+ export type WithoutChildren<T> = T extends {
10
+ children?: any;
11
+ } ? Omit<T, "children"> : T;
12
+ export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13
+ export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
14
+ ref?: U | null;
15
+ };
package/dist/utils.js ADDED
@@ -0,0 +1,8 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+ /**
4
+ * Представляет объединение классов Tailwind с разрешением конфликтов.
5
+ */
6
+ export function cn(...inputs) {
7
+ return twMerge(clsx(inputs));
8
+ }