@connectorvol/tree 8.0.1 → 9.0.1

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 (43) hide show
  1. package/dist/(components)/BoardSettingsTrigger.svelte +10 -0
  2. package/dist/(components)/BoardSettingsTrigger.svelte.d.ts +18 -0
  3. package/dist/(components)/DrillBreadcrumbs.svelte +6 -8
  4. package/dist/(components)/DrillBreadcrumbs.svelte.d.ts +0 -3
  5. package/dist/(components)/DrillForkSanLabel.svelte +2 -5
  6. package/dist/(components)/DrillForkSanLabel.svelte.d.ts +0 -3
  7. package/dist/(components)/DrillVariationList.svelte +29 -15
  8. package/dist/(components)/DrillVariationList.svelte.d.ts +0 -3
  9. package/dist/(components)/Move.svelte +348 -349
  10. package/dist/(components)/Move.svelte.d.ts +0 -3
  11. package/dist/(components)/MoveComment.svelte +31 -24
  12. package/dist/(components)/MoveComment.svelte.d.ts +2 -0
  13. package/dist/(components)/MoveSanWithMenu.svelte +4 -7
  14. package/dist/(components)/MoveSanWithMenu.svelte.d.ts +0 -3
  15. package/dist/(components)/MoveWithIcon.svelte +60 -65
  16. package/dist/(components)/MoveWithIcon.svelte.d.ts +1 -4
  17. package/dist/(components)/PieceIcon.svelte +27 -44
  18. package/dist/(components)/PieceIcon.svelte.d.ts +3 -6
  19. package/dist/(components)/TreeViewer.svelte +957 -907
  20. package/dist/(components)/TreeViewer.svelte.d.ts +10 -3
  21. package/dist/(components)/VariantDropdownNavigator.svelte +22 -6
  22. package/dist/(components)/VariantDropdownNavigator.svelte.d.ts +8 -3
  23. package/dist/(components)/VariationGroup.svelte +85 -89
  24. package/dist/(constants)/treeViewerThemes.d.ts +12 -0
  25. package/dist/(constants)/treeViewerThemes.js +19 -0
  26. package/dist/(models)/treeViewerTheme.d.ts +17 -0
  27. package/dist/(models)/treeViewerTheme.js +1 -0
  28. package/dist/(utils)/treeViewerPanelNavigation.svelte.d.ts +2 -2
  29. package/dist/(utils)/treeViewerPanelNavigation.svelte.js +12 -5
  30. package/dist/components/ui/context-menu/context-menu-content.svelte +24 -20
  31. package/dist/components/ui/context-menu/context-menu-sub-content.svelte +14 -14
  32. package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +26 -22
  33. package/dist/components/ui/toggle/index.d.ts +1 -1
  34. package/dist/components/ui/toggle/index.js +1 -1
  35. package/dist/components/ui/toggle/toggle-variants.d.ts +35 -0
  36. package/dist/components/ui/toggle/toggle-variants.js +19 -0
  37. package/dist/components/ui/toggle/toggle.svelte +1 -27
  38. package/dist/components/ui/toggle/toggle.svelte.d.ts +1 -35
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +2 -1
  41. package/dist/utils/overlayPortalTarget.d.ts +6 -0
  42. package/dist/utils/overlayPortalTarget.js +15 -0
  43. package/package.json +15 -14
@@ -1,967 +1,1017 @@
1
1
  <script lang="ts">
2
- import type { ClassValue } from "svelte/elements";
3
- import { ChessTree } from "../(classes)/chessTree.svelte.js";
4
- import type { ChessTreeNode } from "../(models)/chessTreeNode.js";
5
-
6
- import Move from "./Move.svelte";
7
- import DrillBreadcrumbs from "./DrillBreadcrumbs.svelte";
8
- import DrillVariationList from "./DrillVariationList.svelte";
9
- import VariantDropdownNavigator from "./VariantDropdownNavigator.svelte";
10
- import MoveComment from "./MoveComment.svelte";
11
- import {
12
- CHESS_TREE_CONTEXT_KEY,
13
- type GameContext,
14
- } from "../(utils)/context.js";
15
- import {
16
- type Comment,
17
- makeComment,
18
- parseComment,
19
- } from "@connectorvol/chessops/pgn";
20
- import type { Move as TSharedMove, PieceSet } from "@connectorvol/shared";
21
- import {
22
- isMoveEvaluationNag,
23
- isPuzzleBranchNag,
24
- NOVELTY_NAG_ID,
25
- nagBadgeBackground,
26
- transformNag as transformNagId,
27
- } from "@connectorvol/shared";
28
- import {
29
- isPositionEvaluationNag,
30
- nagDescription,
31
- NAG_CATALOG_SECTIONS,
32
- SELECTABLE_NAG_IDS_ORDERED,
33
- } from "../(models)/nagCatalog.js";
34
- import { setContext } from "svelte";
35
- import type { Attachment } from "svelte/attachments";
36
- import {
37
- ACTIVE_MOVE_CLASS,
38
- scrollToActiveMoveIfNeeded,
39
- } from "../(utils)/scrollToActiveMove.js";
40
- import {
41
- getOrCreateTreeViewerPanelNavigation,
42
- type TVariantNavigatorHandle,
43
- } from "../(utils)/treeViewerPanelNavigation.svelte.js";
44
- import { createPgnFromTree } from "../(utils)/createPgnFromTree.js";
45
- import { transformPgnToChessNode } from "../(utils)/transformPgnToChessNode.js";
46
- import Redo2Icon from "@lucide/svelte/icons/redo-2";
47
- import Undo2Icon from "@lucide/svelte/icons/undo-2";
48
-
49
- /** Представляет снимок PGN и пути варианта к выбранной ноде для undo/redo. */
50
- type TPgnHistoryEntry = {
51
- /** Возвращает полную строку PGN партии. */
52
- pgn: string;
53
- /** Возвращает индексы детей от `rootNode.moves` к текущей ноде (пустой — узел начала партии). */
54
- variationPath: number[];
55
- };
56
-
57
- type TPreviewOnHoverChessNodeSettings = {
58
- /** Возвращает функцию для установки FEN превью при наведении (null скрывает); второй аргумент — подсветка последнего хода. */
59
- setPreviewFen?: (
60
- fen: string | null,
61
- lastMove?: TSharedMove | null,
62
- ) => void;
63
- /** Возвращает задержку (мс) до показа превью-позиции при наведении на ноду, либо `off` для отключения. */
64
- delayMs?: number | "off";
2
+ import type { ClassValue } from "svelte/elements";
3
+ import { ChessTree } from "../(classes)/chessTree.svelte.js";
4
+ import type { ChessTreeNode } from "../(models)/chessTreeNode.js";
5
+
6
+ import Move from "./Move.svelte";
7
+ import DrillBreadcrumbs from "./DrillBreadcrumbs.svelte";
8
+ import DrillVariationList from "./DrillVariationList.svelte";
9
+ import VariantDropdownNavigator from "./VariantDropdownNavigator.svelte";
10
+ import MoveComment from "./MoveComment.svelte";
11
+ import {
12
+ CHESS_TREE_CONTEXT_KEY,
13
+ type GameContext,
14
+ } from "../(utils)/context.js";
15
+ import {
16
+ type Comment,
17
+ makeComment,
18
+ parseComment,
19
+ } from "@connectorvol/chessops/pgn";
20
+ import type { Move as TSharedMove } from "@connectorvol/shared";
21
+ import {
22
+ isMoveEvaluationNag,
23
+ isPuzzleBranchNag,
24
+ NOVELTY_NAG_ID,
25
+ PUZZLE_BRANCH_NAG_IDS,
26
+ nagBadgeBackground,
27
+ transformNag as transformNagId,
28
+ } from "@connectorvol/shared";
29
+ import {
30
+ isPositionEvaluationNag,
31
+ nagDescription,
32
+ NAG_CATALOG_SECTIONS,
33
+ SELECTABLE_NAG_IDS_ORDERED,
34
+ } from "../(models)/nagCatalog.js";
35
+ import { setContext } from "svelte";
36
+ import type { Attachment } from "svelte/attachments";
37
+ import {
38
+ ACTIVE_MOVE_CLASS,
39
+ scrollToActiveMoveIfNeeded,
40
+ } from "../(utils)/scrollToActiveMove.js";
41
+ import {
42
+ getOrCreateTreeViewerPanelNavigation,
43
+ type TVariantNavigatorHandle,
44
+ } from "../(utils)/treeViewerPanelNavigation.svelte.js";
45
+ import { createPgnFromTree } from "../(utils)/createPgnFromTree.js";
46
+ import { transformPgnToChessNode } from "../(utils)/transformPgnToChessNode.js";
47
+ import Redo2Icon from "@lucide/svelte/icons/redo-2";
48
+ import Undo2Icon from "@lucide/svelte/icons/undo-2";
49
+ import { cn } from "../utils.js";
50
+ import type { TTreeViewerTheme } from "../(models)/treeViewerTheme.js";
51
+ import { TREE_VIEWER_LIGHT_THEME } from "../(constants)/treeViewerThemes.js";
52
+
53
+ /** Представляет снимок PGN и пути варианта к выбранной ноде для undo/redo. */
54
+ type TPgnHistoryEntry = {
55
+ /** Возвращает полную строку PGN партии. */
56
+ pgn: string;
57
+ /** Возвращает индексы детей от `rootNode.moves` к текущей ноде (пустой — узел начала партии). */
58
+ variationPath: number[];
59
+ };
60
+
61
+ type TPreviewOnHoverChessNodeSettings = {
62
+ /** Возвращает функцию для установки FEN превью при наведении (null скрывает); второй аргумент — подсветка последнего хода. */
63
+ setPreviewFen?: (fen: string | null, lastMove?: TSharedMove | null) => void;
64
+ /** Возвращает задержку (мс) до показа превью-позиции при наведении на ноду, либо `off` для отключения. */
65
+ delayMs?: number | "off";
66
+ };
67
+
68
+ type TTreeViewerProps = {
69
+ /** Возвращает дерево партии. */
70
+ chessTree: ChessTree;
71
+ /** Возвращает дополнительные классы корневого контейнера. */
72
+ className?: ClassValue;
73
+ /** Возвращает колбэк после выбора узла в дереве. */
74
+ onSelectNode: () => void;
75
+ /** Возвращает колбэк после удаления варианта. */
76
+ onDeleteVariant: () => void;
77
+ /** Возвращает колбэк после выбора узла (после `onSelectNode`), передаётся актуальный `currentNode`. */
78
+ onChessNodeSelected?: (node: ChessTreeNode) => void;
79
+ /** Возвращает функцию установки FEN основной партии. */
80
+ setChessFen: (fen: string) => void;
81
+ /** Возвращает функцию обновления доски. */
82
+ setChessboardFen: (animationTime?: number) => void;
83
+ /** Возвращает true, если клик по ходу переключает текущий узел и доску. По умолчанию true. */
84
+ selectable?: boolean;
85
+ /** Возвращает настройки превью-доски при наведении на ноду. */
86
+ previewOnHoverChessNodeSettings?: TPreviewOnHoverChessNodeSettings;
87
+ /** Возвращает функцию для установки FEN превью при наведении (null скрывает); второй аргумент — подсветка последнего хода. */
88
+ setPreviewFen?: (fen: string | null, lastMove?: TSharedMove | null) => void;
89
+ /** Возвращает задержку (мс) до показа превью-позиции при наведении на ноду, либо `off` для отключения. */
90
+ previewHoverDelayMs?: number | "off";
91
+ /** Возвращает true, если под деревом показываются комментарий и выбор NAG для текущей ноды. */
92
+ editMode?: boolean;
93
+ /** Возвращает true, если в режиме правки показывать группу NAG «Задача (развилка)». По умолчанию true. */
94
+ showPuzzleBranchNags?: boolean;
95
+ /** Возвращает функцию для установки признака несохранённости дерева. */
96
+ onChangeDirty?: (setIsDirty: (value: boolean) => void) => void;
97
+ /**
98
+ * Возвращает настройки оформления: фон, границы и цвет текста.
99
+ * Обязателен: без явного `theme` стили падают на CSS-дефолт и сетка
100
+ * выглядит бледной. Если нужно подставить готовую тему — импортируйте
101
+ * `TREE_VIEWER_LIGHT_THEME` или `TREE_VIEWER_DARK_THEME` из
102
+ * `@connectorvol/tree`.
103
+ */
104
+ theme: TTreeViewerTheme;
105
+ };
106
+
107
+ let {
108
+ className,
109
+ onSelectNode,
110
+ onDeleteVariant,
111
+ onChessNodeSelected,
112
+ setChessFen,
113
+ setChessboardFen,
114
+ chessTree,
115
+ selectable = true,
116
+ previewOnHoverChessNodeSettings,
117
+ setPreviewFen,
118
+ previewHoverDelayMs = 700,
119
+ editMode = false,
120
+ showPuzzleBranchNags = false,
121
+ onChangeDirty,
122
+ theme = TREE_VIEWER_LIGHT_THEME,
123
+ }: TTreeViewerProps = $props();
124
+
125
+ const previewSettings = $derived(previewOnHoverChessNodeSettings);
126
+ const previewFenSetter = $derived(
127
+ previewSettings?.setPreviewFen ?? setPreviewFen,
128
+ );
129
+ const previewDelayMs = $derived(
130
+ previewSettings?.delayMs ?? previewHoverDelayMs,
131
+ );
132
+
133
+ setContext<GameContext>(CHESS_TREE_CONTEXT_KEY, {
134
+ get chessTree() {
135
+ return chessTree;
136
+ },
137
+ get onSelectNode() {
138
+ return onSelectNode;
139
+ },
140
+ get onDeleteVariant() {
141
+ return onDeleteVariant;
142
+ },
143
+ get onChessNodeSelected() {
144
+ return onChessNodeSelected;
145
+ },
146
+ get setChessFen() {
147
+ return setChessFen;
148
+ },
149
+ get setChessboardFen() {
150
+ return setChessboardFen;
151
+ },
152
+ get setPreviewFen() {
153
+ // ponytail: вынесли стрелочную функцию из getter'а — oxc/rolldown SSR-парсер
154
+ // не понимает TS-аннотации параметров внутри getter-литералов, а vite-plugin-checker
155
+ // не умеет их там стрипать. Явная типизация через переменную работает в обеих фазах.
156
+ const setter = previewFenSetter;
157
+ const bound: GameContext["setPreviewFen"] = setter
158
+ ? (fen, lastMove) => setter(fen, lastMove)
159
+ : undefined;
160
+ return bound;
161
+ },
162
+ get previewHoverDelayMs() {
163
+ return previewDelayMs;
164
+ },
165
+ get selectable() {
166
+ return selectable;
167
+ },
168
+ });
169
+
170
+ const panelNav = $derived.by(() => {
171
+ const nav = getOrCreateTreeViewerPanelNavigation(chessTree);
172
+ nav.bindCallbacks(setChessFen, setChessboardFen);
173
+ return nav;
174
+ });
175
+
176
+ let variantDropdownNavigator = $state<TVariantNavigatorHandle | null>(null);
177
+
178
+ $effect(() => {
179
+ const nav = panelNav;
180
+ nav.setVariantNavigator(variantDropdownNavigator);
181
+ return () => {
182
+ nav.setVariantNavigator(null);
65
183
  };
184
+ });
66
185
 
67
- type TTreeViewerProps = {
68
- /** Возвращает дерево партии. */
69
- chessTree: ChessTree;
70
- /** Возвращает дополнительные классы корневого контейнера. */
71
- className?: ClassValue;
72
- /** Возвращает колбэк после выбора узла в дереве. */
73
- onSelectNode: () => void;
74
- /** Возвращает колбэк после удаления варианта. */
75
- onDeleteVariant: () => void;
76
- /** Возвращает колбэк после выбора узла (после `onSelectNode`), передаётся актуальный `currentNode`. */
77
- onChessNodeSelected?: (node: ChessTreeNode) => void;
78
- /** Возвращает функцию установки FEN основной партии. */
79
- setChessFen: (fen: string) => void;
80
- /** Возвращает функцию обновления доски. */
81
- setChessboardFen: (animationTime?: number) => void;
82
- /** Возвращает набор фигур для иконок SAN. */
83
- pieceSet: PieceSet;
84
- /** Возвращает true, если клик по ходу переключает текущий узел и доску. По умолчанию true. */
85
- selectable?: boolean;
86
- /** Возвращает настройки превью-доски при наведении на ноду. */
87
- previewOnHoverChessNodeSettings?: TPreviewOnHoverChessNodeSettings;
88
- /** Возвращает функцию для установки FEN превью при наведении (null скрывает); второй аргумент — подсветка последнего хода. */
89
- setPreviewFen?: (
90
- fen: string | null,
91
- lastMove?: TSharedMove | null,
92
- ) => void;
93
- /** Возвращает задержку (мс) до показа превью-позиции при наведении на ноду, либо `off` для отключения. */
94
- previewHoverDelayMs?: number | "off";
95
- /** Возвращает true, если под деревом показываются комментарий и выбор NAG для текущей ноды. */
96
- editMode?: boolean;
97
- /** Возвращает true, если в режиме правки показывать группу NAG «Задача (развилка)». По умолчанию true. */
98
- showPuzzleBranchNags?: boolean;
99
- /** Возвращает функцию для установки признака несохранённости дерева. */
100
- onChangeDirty?: (setIsDirty: (value: boolean) => void) => void;
186
+ $effect(() => {
187
+ const nav = panelNav;
188
+ return () => {
189
+ nav.dispose();
101
190
  };
102
-
103
- let {
104
- className,
105
- onSelectNode,
106
- onDeleteVariant,
107
- onChessNodeSelected,
108
- setChessFen,
109
- setChessboardFen,
110
- chessTree,
111
- pieceSet,
112
- selectable = true,
113
- previewOnHoverChessNodeSettings,
114
- setPreviewFen,
115
- previewHoverDelayMs = 700,
116
- editMode = false,
117
- showPuzzleBranchNags = false,
118
- onChangeDirty,
119
- }: TTreeViewerProps = $props();
120
-
121
- const previewSettings = $derived(previewOnHoverChessNodeSettings);
122
- const previewFenSetter = $derived(
123
- previewSettings?.setPreviewFen ?? setPreviewFen,
124
- );
125
- const previewDelayMs = $derived(
126
- previewSettings?.delayMs ?? previewHoverDelayMs,
127
- );
128
-
129
- setContext<GameContext>(CHESS_TREE_CONTEXT_KEY, {
130
- get chessTree() {
131
- return chessTree;
132
- },
133
- get onSelectNode() {
134
- return onSelectNode;
135
- },
136
- get onDeleteVariant() {
137
- return onDeleteVariant;
138
- },
139
- get onChessNodeSelected() {
140
- return onChessNodeSelected;
141
- },
142
- get setChessFen() {
143
- return setChessFen;
144
- },
145
- get setChessboardFen() {
146
- return setChessboardFen;
147
- },
148
- get setPreviewFen() {
149
- const setter = previewFenSetter;
150
- return setter
151
- ? (fen: string | null, lastMove?: TSharedMove | null) =>
152
- setter(fen, lastMove)
153
- : undefined;
154
- },
155
- get previewHoverDelayMs() {
156
- return previewDelayMs;
157
- },
158
- get selectable() {
159
- return selectable;
160
- },
161
- });
162
-
163
- const panelNav = $derived.by(() => {
164
- const nav = getOrCreateTreeViewerPanelNavigation(chessTree);
165
- nav.bindCallbacks(setChessFen, setChessboardFen);
166
- return nav;
167
- });
168
-
169
- let variantDropdownNavigator = $state<TVariantNavigatorHandle | null>(null);
170
-
171
- $effect(() => {
172
- const nav = panelNav;
173
- nav.setVariantNavigator(variantDropdownNavigator);
174
- return () => {
175
- nav.setVariantNavigator(null);
176
- };
177
- });
178
-
179
- $effect(() => {
180
- const nav = panelNav;
181
- return () => {
182
- nav.dispose();
183
- };
184
- });
185
-
186
- $effect(() => {
187
- void chessTree.currentNode.id;
188
- scrollToActiveMoveIfNeeded();
189
- });
190
-
191
- /** Представляет true, если доступно редактирование комментария и NAG (есть интерактивный выбор ноды). */
192
- const canEditAnnotations = $derived(editMode && selectable);
193
-
194
- /** Представляет видимые секции NAG с учётом настройки показа группы «Задача (развилка)». */
195
- const visibleNagSections = $derived(
196
- NAG_CATALOG_SECTIONS.filter((section) =>
197
- showPuzzleBranchNags ? true : section.key !== "puzzle-branch",
198
- ),
199
- );
200
-
201
- let cachedPgn: string | null = $state(null);
202
- let cachedPgnVersion = $state(-1);
203
-
204
- function getSerializedPgn(): string {
205
- if (
206
- cachedPgnVersion === chessTree.mutationVersion &&
207
- cachedPgn !== null
208
- ) {
209
- return cachedPgn;
210
- }
211
- cachedPgn = createPgnFromTree(chessTree.rootNode);
212
- cachedPgnVersion = chessTree.mutationVersion;
213
- return cachedPgn;
191
+ });
192
+
193
+ $effect(() => {
194
+ void chessTree.currentNode.id;
195
+ scrollToActiveMoveIfNeeded();
196
+ });
197
+
198
+ /** Представляет true, если доступно редактирование комментария и NAG (есть интерактивный выбор ноды). */
199
+ const canEditAnnotations = $derived(editMode && selectable);
200
+
201
+ /** Представляет родителя текущей ноды (null для корня дерева ходов). */
202
+ const currentNodeParent = $derived(
203
+ chessTree.currentNode.id !== chessTree.rootNode.moves.id
204
+ ? chessTree.getNodeParent(chessTree.currentNode)
205
+ : null,
206
+ );
207
+
208
+ /** Представляет true, если текущая нода — ход из развилки (у родителя несколько детей). */
209
+ const isCurrentNodeAtFork = $derived(
210
+ canEditAnnotations &&
211
+ showPuzzleBranchNags &&
212
+ currentNodeParent !== null &&
213
+ currentNodeParent.children.length > 1,
214
+ );
215
+
216
+ /** Представляет видимые секции NAG (группа «Задача» выводится отдельно рядом с undo/redo). */
217
+ const visibleNagSections = $derived(
218
+ NAG_CATALOG_SECTIONS.filter((section) => section.key !== "puzzle-branch"),
219
+ );
220
+
221
+ let cachedPgn: string | null = $state(null);
222
+ let cachedPgnVersion = $state(-1);
223
+
224
+ function getSerializedPgn(): string {
225
+ if (cachedPgnVersion === chessTree.mutationVersion && cachedPgn !== null) {
226
+ return cachedPgn;
214
227
  }
215
-
216
- /** Представляет признак: текущий PGN отличается от снимка при монтировании дерева в просмотрщике. */
217
- let isDirty = $state(false);
218
-
219
- /** Представляет строку PGN при первом отслеживании (эталон «несохранённости»). */
220
- let initialPgnSnapshot = $state<string | null>(null);
221
-
222
- /** Представляет предыдущее значение `serializedPgn` для обнаружения мутаций. */
223
- let previousSerializedPgn = $state<string | null>(null);
224
-
225
- /** Представляет максимум сохранённых полных PGN в истории undo для режима правки. */
226
- const MAX_PGN_EDIT_HISTORY = 20;
227
-
228
- /** Представляет стек предыдущих состояний PGN и выбора ноды (до текущего). */
229
- let pastPgnEntries = $state<TPgnHistoryEntry[]>([]);
230
-
231
- /** Представляет стек отменённых состояний для redo. */
232
- let redoPgnEntries = $state<TPgnHistoryEntry[]>([]);
233
-
234
- /** Представляет путь варианта, соответствующий последнему зафиксированному `serializedPgn`. */
235
- let variationPathSyncedWithPreviousSerializedPgn = $state<number[]>([]);
236
-
237
- /** Представляет true, пока к дереву применяется PGN из истории (без добавления записи в стек). */
238
- let suppressPgnHistoryPush = $state(false);
239
-
240
- /** Представляет true, пока фокус в поле комментария (не пишем историю PGN на каждый символ). */
241
- let commentTextareaHasFocus = $state(false);
242
-
243
- /** Представляет снимок PGN и выбора до начала текущей сессии правки комментария (фиксируется при фокусе). */
244
- let pendingCommentUndoEntry = $state<TPgnHistoryEntry | null>(null);
245
-
246
- /** Представляет проверку: целевая нода лежит в поддереве предка (включая совпадение). */
247
- function treeBranchContainsNode(
248
- ancestor: ChessTreeNode,
249
- target: ChessTreeNode,
250
- ): boolean {
251
- if (ancestor.id === target.id) return true;
252
- return ancestor.children.some((c) => treeBranchContainsNode(c, target));
228
+ cachedPgn = createPgnFromTree(chessTree.rootNode);
229
+ cachedPgnVersion = chessTree.mutationVersion;
230
+ return cachedPgn;
231
+ }
232
+
233
+ /** Представляет признак: текущий PGN отличается от снимка при монтировании дерева в просмотрщике. */
234
+ let isDirty = $state(false);
235
+
236
+ /** Представляет строку PGN при первом отслеживании (эталон «несохранённости»). */
237
+ let initialPgnSnapshot = $state<string | null>(null);
238
+
239
+ /** Представляет предыдущее значение `serializedPgn` для обнаружения мутаций. */
240
+ let previousSerializedPgn = $state<string | null>(null);
241
+
242
+ /** Представляет максимум сохранённых полных PGN в истории undo для режима правки. */
243
+ const MAX_PGN_EDIT_HISTORY = 20;
244
+
245
+ /** Представляет стек предыдущих состояний PGN и выбора ноды (до текущего). */
246
+ let pastPgnEntries = $state<TPgnHistoryEntry[]>([]);
247
+
248
+ /** Представляет стек отменённых состояний для redo. */
249
+ let redoPgnEntries = $state<TPgnHistoryEntry[]>([]);
250
+
251
+ /** Представляет путь варианта, соответствующий последнему зафиксированному `serializedPgn`. */
252
+ let variationPathSyncedWithPreviousSerializedPgn = $state<number[]>([]);
253
+
254
+ /** Представляет true, пока к дереву применяется PGN из истории (без добавления записи в стек). */
255
+ let suppressPgnHistoryPush = $state(false);
256
+
257
+ /** Представляет true, пока фокус в поле комментария (не пишем историю PGN на каждый символ). */
258
+ let commentTextareaHasFocus = $state(false);
259
+
260
+ /** Представляет снимок PGN и выбора до начала текущей сессии правки комментария (фиксируется при фокусе). */
261
+ let pendingCommentUndoEntry = $state<TPgnHistoryEntry | null>(null);
262
+
263
+ /** Представляет проверку: целевая нода лежит в поддереве предка (включая совпадение). */
264
+ function treeBranchContainsNode(
265
+ ancestor: ChessTreeNode,
266
+ target: ChessTreeNode,
267
+ ): boolean {
268
+ if (ancestor.id === target.id) return true;
269
+ return ancestor.children.some((c) => treeBranchContainsNode(c, target));
270
+ }
271
+
272
+ /** Представляет индексы детей от корня ходов до указанной ноды. */
273
+ function variationPathFromRootToNode(
274
+ rootMoves: ChessTreeNode,
275
+ target: ChessTreeNode,
276
+ ): number[] {
277
+ const path: number[] = [];
278
+ let node = rootMoves;
279
+ while (node.id !== target.id) {
280
+ const idx = node.children.findIndex((c) =>
281
+ treeBranchContainsNode(c, target),
282
+ );
283
+ if (idx === -1) break;
284
+ path.push(idx);
285
+ node = node.children[idx]!;
253
286
  }
254
-
255
- /** Представляет индексы детей от корня ходов до указанной ноды. */
256
- function variationPathFromRootToNode(
257
- rootMoves: ChessTreeNode,
258
- target: ChessTreeNode,
259
- ): number[] {
260
- const path: number[] = [];
261
- let node = rootMoves;
262
- while (node.id !== target.id) {
263
- const idx = node.children.findIndex((c) =>
264
- treeBranchContainsNode(c, target),
265
- );
266
- if (idx === -1) break;
267
- path.push(idx);
268
- node = node.children[idx]!;
269
- }
270
- return path;
271
- }
272
-
273
- /** Представляет узел по пути варианта от корня ходов (устойчив к новым id после парсинга). */
274
- function nodeAtVariationPathFromRoot(
275
- rootMoves: ChessTreeNode,
276
- variationPath: number[],
277
- ): ChessTreeNode {
278
- let node = rootMoves;
279
- for (const idx of variationPath) {
280
- const next = node.children[idx];
281
- if (!next) break;
282
- node = next;
283
- }
284
- return node;
285
- }
286
-
287
- /** Представляет актуальный путь варианта к текущей ноде в дереве. */
288
- function variationPathForChessTreeSelection(tree: ChessTree): number[] {
289
- return variationPathFromRootToNode(
290
- tree.rootNode.moves,
291
- tree.currentNode,
292
- );
293
- }
294
-
295
- /** Представляет запись истории: текущий PGN и выбор ноды. */
296
- function capturePgnHistoryEntry(tree: ChessTree): TPgnHistoryEntry {
297
- return {
298
- pgn: createPgnFromTree(tree.rootNode),
299
- variationPath: variationPathForChessTreeSelection(tree),
300
- };
287
+ return path;
288
+ }
289
+
290
+ /** Представляет узел по пути варианта от корня ходов (устойчив к новым id после парсинга). */
291
+ function nodeAtVariationPathFromRoot(
292
+ rootMoves: ChessTreeNode,
293
+ variationPath: number[],
294
+ ): ChessTreeNode {
295
+ let node = rootMoves;
296
+ for (const idx of variationPath) {
297
+ const next = node.children[idx];
298
+ if (!next) break;
299
+ node = next;
301
300
  }
302
-
303
- const setIsDirty = (value: boolean) => {
304
- isDirty = value;
301
+ return node;
302
+ }
303
+
304
+ /** Представляет актуальный путь варианта к текущей ноде в дереве. */
305
+ function variationPathForChessTreeSelection(tree: ChessTree): number[] {
306
+ return variationPathFromRootToNode(tree.rootNode.moves, tree.currentNode);
307
+ }
308
+
309
+ /** Представляет запись истории: текущий PGN и выбор ноды. */
310
+ function capturePgnHistoryEntry(tree: ChessTree): TPgnHistoryEntry {
311
+ return {
312
+ pgn: createPgnFromTree(tree.rootNode),
313
+ variationPath: variationPathForChessTreeSelection(tree),
305
314
  };
306
-
307
- $effect(() => {
308
- if (!editMode) {
309
- pastPgnEntries = [];
310
- redoPgnEntries = [];
311
- pendingCommentUndoEntry = null;
312
- commentTextareaHasFocus = false;
313
- }
314
- });
315
-
316
- /** Представляет одну запись undo для завершённой сессии правки комментария (до правки vs после). */
317
- function flushPendingCommentUndoEntry(): void {
318
- if (pendingCommentUndoEntry === null) return;
319
- const pending = pendingCommentUndoEntry;
320
- pendingCommentUndoEntry = null;
321
- const nowPgn = getSerializedPgn();
322
- if (pending.pgn === nowPgn) return;
323
- const last = pastPgnEntries[pastPgnEntries.length - 1];
324
- if (
325
- last !== undefined &&
326
- last.pgn === pending.pgn &&
327
- JSON.stringify(last.variationPath) ===
328
- JSON.stringify(pending.variationPath)
329
- ) {
330
- return;
331
- }
332
- pastPgnEntries = [...pastPgnEntries, pending].slice(
333
- -MAX_PGN_EDIT_HISTORY,
334
- );
335
- redoPgnEntries = [];
315
+ }
316
+
317
+ const setIsDirty = (value: boolean) => {
318
+ isDirty = value;
319
+ };
320
+
321
+ $effect(() => {
322
+ if (!editMode) {
323
+ pastPgnEntries = [];
324
+ redoPgnEntries = [];
325
+ pendingCommentUndoEntry = null;
326
+ commentTextareaHasFocus = false;
336
327
  }
337
-
338
- $effect(() => {
339
- void chessTree.mutationVersion;
340
- // Пропускаем пересчёт PGN во время набора комментария — дифф будет при blur
341
- if (commentTextareaHasFocus) return;
342
-
343
- const next = getSerializedPgn();
344
- const pathNow = variationPathForChessTreeSelection(chessTree);
345
- const skipPushForInFlightCommentEdit =
346
- editMode &&
347
- commentTextareaHasFocus &&
348
- pendingCommentUndoEntry !== null;
349
- if (previousSerializedPgn === null) {
350
- initialPgnSnapshot = next;
351
- previousSerializedPgn = next;
352
- variationPathSyncedWithPreviousSerializedPgn = pathNow;
353
- isDirty = false;
354
- return;
355
- }
356
- if (next !== previousSerializedPgn) {
357
- if (
358
- editMode &&
359
- !suppressPgnHistoryPush &&
360
- !skipPushForInFlightCommentEdit
361
- ) {
362
- pastPgnEntries = [
363
- ...pastPgnEntries,
364
- {
365
- pgn: previousSerializedPgn,
366
- variationPath:
367
- variationPathSyncedWithPreviousSerializedPgn,
368
- },
369
- ].slice(-MAX_PGN_EDIT_HISTORY);
370
- redoPgnEntries = [];
371
- }
372
- previousSerializedPgn = next;
373
- onChangeDirty?.(setIsDirty);
374
- }
375
- variationPathSyncedWithPreviousSerializedPgn = pathNow;
376
- isDirty = next !== initialPgnSnapshot;
377
- });
378
-
379
- /** Представляет применение сохранённого PGN и восстановление активной ноды по пути варианта. */
380
- function applyPgnHistoryEntry(entry: TPgnHistoryEntry): void {
381
- pendingCommentUndoEntry = null;
382
- commentTextareaHasFocus = false;
383
- suppressPgnHistoryPush = true;
384
- try {
385
- const { rootNode } = transformPgnToChessNode(entry.pgn);
386
- chessTree.replaceRootTree(rootNode);
387
- chessTree.mutationVersion++;
388
- chessTree.currentNode = nodeAtVariationPathFromRoot(
389
- chessTree.rootNode.moves,
390
- entry.variationPath,
391
- );
392
- onSelectNode();
393
- onChessNodeSelected?.(chessTree.currentNode);
394
- setTimeout(() => scrollToActiveMoveIfNeeded(), 50);
395
- } finally {
396
- setTimeout(() => {
397
- suppressPgnHistoryPush = false;
398
- }, 0);
399
- }
328
+ });
329
+
330
+ /** Представляет одну запись undo для завершённой сессии правки комментария (до правки vs после). */
331
+ function flushPendingCommentUndoEntry(): void {
332
+ if (pendingCommentUndoEntry === null) return;
333
+ const pending = pendingCommentUndoEntry;
334
+ pendingCommentUndoEntry = null;
335
+ const nowPgn = getSerializedPgn();
336
+ if (pending.pgn === nowPgn) return;
337
+ const last = pastPgnEntries[pastPgnEntries.length - 1];
338
+ if (
339
+ last !== undefined &&
340
+ last.pgn === pending.pgn &&
341
+ JSON.stringify(last.variationPath) ===
342
+ JSON.stringify(pending.variationPath)
343
+ ) {
344
+ return;
400
345
  }
401
-
402
- /** Представляет откат к предыдущему сохранённому состоянию PGN и выбору ноды. */
403
- function undoPgnEdit(): void {
404
- if (pastPgnEntries.length === 0) return;
405
- const prev = pastPgnEntries[pastPgnEntries.length - 1]!;
406
- pastPgnEntries = pastPgnEntries.slice(0, -1);
407
- redoPgnEntries = [
408
- ...redoPgnEntries,
409
- capturePgnHistoryEntry(chessTree),
410
- ].slice(-MAX_PGN_EDIT_HISTORY);
411
- applyPgnHistoryEntry(prev);
346
+ pastPgnEntries = [...pastPgnEntries, pending].slice(-MAX_PGN_EDIT_HISTORY);
347
+ redoPgnEntries = [];
348
+ }
349
+
350
+ $effect(() => {
351
+ void chessTree.mutationVersion;
352
+ // Пропускаем пересчёт PGN во время набора комментария — дифф будет при blur
353
+ if (commentTextareaHasFocus) return;
354
+
355
+ const next = getSerializedPgn();
356
+ const pathNow = variationPathForChessTreeSelection(chessTree);
357
+ const skipPushForInFlightCommentEdit =
358
+ editMode && commentTextareaHasFocus && pendingCommentUndoEntry !== null;
359
+ if (previousSerializedPgn === null) {
360
+ initialPgnSnapshot = next;
361
+ previousSerializedPgn = next;
362
+ variationPathSyncedWithPreviousSerializedPgn = pathNow;
363
+ isDirty = false;
364
+ return;
412
365
  }
413
-
414
- /** Представляет повтор последнего отменённого состояния PGN и выбор ноды. */
415
- function redoPgnEdit(): void {
416
- if (redoPgnEntries.length === 0) return;
417
- const nextEntry = redoPgnEntries[redoPgnEntries.length - 1]!;
418
- redoPgnEntries = redoPgnEntries.slice(0, -1);
366
+ if (next !== previousSerializedPgn) {
367
+ if (
368
+ editMode &&
369
+ !suppressPgnHistoryPush &&
370
+ !skipPushForInFlightCommentEdit
371
+ ) {
419
372
  pastPgnEntries = [
420
- ...pastPgnEntries,
421
- capturePgnHistoryEntry(chessTree),
373
+ ...pastPgnEntries,
374
+ {
375
+ pgn: previousSerializedPgn,
376
+ variationPath: variationPathSyncedWithPreviousSerializedPgn,
377
+ },
422
378
  ].slice(-MAX_PGN_EDIT_HISTORY);
423
- applyPgnHistoryEntry(nextEntry);
379
+ redoPgnEntries = [];
380
+ }
381
+ previousSerializedPgn = next;
382
+ onChangeDirty?.(setIsDirty);
424
383
  }
425
-
426
- /** Представляет доступность undo по истории PGN. */
427
- const canUndoPgnEdit = $derived(editMode && pastPgnEntries.length > 0);
428
-
429
- /** Представляет доступность redo по истории PGN. */
430
- const canRedoPgnEdit = $derived(editMode && redoPgnEntries.length > 0);
431
-
432
- /** Представляет глобальную обработку Ctrl/Cmd+Z и Shift+Ctrl/Cmd+Z для undo/redo PGN в режиме правки. */
433
- function onTreeViewerWindowKeyDown(e: KeyboardEvent): void {
434
- if (
435
- editMode &&
436
- (e.ctrlKey || e.metaKey) &&
437
- !e.altKey &&
438
- e.key.toLowerCase() === "z"
439
- ) {
440
- if (e.shiftKey) {
441
- if (canRedoPgnEdit) {
442
- e.preventDefault();
443
- redoPgnEdit();
444
- return;
445
- }
446
- } else if (canUndoPgnEdit) {
447
- e.preventDefault();
448
- undoPgnEdit();
449
- return;
450
- }
451
- }
452
- panelNav.onWindowKeyDown(e);
384
+ variationPathSyncedWithPreviousSerializedPgn = pathNow;
385
+ isDirty = next !== initialPgnSnapshot;
386
+ });
387
+
388
+ /** Представляет применение сохранённого PGN и восстановление активной ноды по пути варианта. */
389
+ function applyPgnHistoryEntry(entry: TPgnHistoryEntry): void {
390
+ pendingCommentUndoEntry = null;
391
+ commentTextareaHasFocus = false;
392
+ suppressPgnHistoryPush = true;
393
+ try {
394
+ const { rootNode } = transformPgnToChessNode(entry.pgn);
395
+ chessTree.replaceRootTree(rootNode);
396
+ chessTree.mutationVersion++;
397
+ chessTree.currentNode = nodeAtVariationPathFromRoot(
398
+ chessTree.rootNode.moves,
399
+ entry.variationPath,
400
+ );
401
+ onSelectNode();
402
+ onChessNodeSelected?.(chessTree.currentNode);
403
+ setTimeout(() => scrollToActiveMoveIfNeeded(), 50);
404
+ } finally {
405
+ setTimeout(() => {
406
+ suppressPgnHistoryPush = false;
407
+ }, 0);
453
408
  }
454
-
455
- /** Представляет true, если выбран главный первый ход партии — комментарий партии до дерева ходов (`rootNode.comments`). */
456
- const isAtMoveTreeRoot = $derived(
457
- chessTree.currentNode.id === chessTree.rootNode.moves.id,
458
- );
459
-
460
- const drillForkCrumbs = $derived(chessTree.drillForkBreadcrumbs);
461
- let isTreeViewerContainerOverflowing = $state(false);
462
-
463
- /** Представляет черновой редактируемый текст первого PGN-комментария без блоков `[%…]` (часы, eval, стрелки и т.д.). */
464
- let commentDraft = $state("");
465
- /** Представляет id ноды, для которой загружен `commentDraft` (без затирания во время набора). */
466
- let commentDraftSyncedNodeId = $state<string | null>(null);
467
- $effect(() => {
468
- if (!editMode) return;
469
- const id = chessTree.currentNode.id;
470
- if (
471
- commentDraftSyncedNodeId !== null &&
472
- commentDraftSyncedNodeId !== id
473
- ) {
474
- if (pendingCommentUndoEntry !== null) {
475
- flushPendingCommentUndoEntry();
476
- }
477
- persistCommentDraftForNodeId(commentDraftSyncedNodeId);
409
+ }
410
+
411
+ /** Представляет откат к предыдущему сохранённому состоянию PGN и выбору ноды. */
412
+ function undoPgnEdit(): void {
413
+ if (pastPgnEntries.length === 0) return;
414
+ const prev = pastPgnEntries[pastPgnEntries.length - 1]!;
415
+ pastPgnEntries = pastPgnEntries.slice(0, -1);
416
+ redoPgnEntries = [
417
+ ...redoPgnEntries,
418
+ capturePgnHistoryEntry(chessTree),
419
+ ].slice(-MAX_PGN_EDIT_HISTORY);
420
+ applyPgnHistoryEntry(prev);
421
+ }
422
+
423
+ /** Представляет повтор последнего отменённого состояния PGN и выбор ноды. */
424
+ function redoPgnEdit(): void {
425
+ if (redoPgnEntries.length === 0) return;
426
+ const nextEntry = redoPgnEntries[redoPgnEntries.length - 1]!;
427
+ redoPgnEntries = redoPgnEntries.slice(0, -1);
428
+ pastPgnEntries = [
429
+ ...pastPgnEntries,
430
+ capturePgnHistoryEntry(chessTree),
431
+ ].slice(-MAX_PGN_EDIT_HISTORY);
432
+ applyPgnHistoryEntry(nextEntry);
433
+ }
434
+
435
+ /** Представляет доступность undo по истории PGN. */
436
+ const canUndoPgnEdit = $derived(editMode && pastPgnEntries.length > 0);
437
+
438
+ /** Представляет доступность redo по истории PGN. */
439
+ const canRedoPgnEdit = $derived(editMode && redoPgnEntries.length > 0);
440
+
441
+ /** Представляет глобальную обработку Ctrl/Cmd+Z и Shift+Ctrl/Cmd+Z для undo/redo PGN в режиме правки. */
442
+ function onTreeViewerWindowKeyDown(e: KeyboardEvent): void {
443
+ if (
444
+ editMode &&
445
+ (e.ctrlKey || e.metaKey) &&
446
+ !e.altKey &&
447
+ e.key.toLowerCase() === "z"
448
+ ) {
449
+ if (e.shiftKey) {
450
+ if (canRedoPgnEdit) {
451
+ e.preventDefault();
452
+ redoPgnEdit();
453
+ return;
478
454
  }
479
- if (commentDraftSyncedNodeId !== id) {
480
- commentDraftSyncedNodeId = id;
481
- const rawFirst =
482
- id === chessTree.rootNode.moves.id
483
- ? chessTree.rootNode.comments?.[0]
484
- : chessTree.currentNode.data.comments?.[0];
485
- commentDraft =
486
- rawFirst !== undefined ? parseComment(rawFirst).text : "";
487
- }
488
- });
489
-
490
- /** Представляет объединение строки из textarea с неизменной мета-разметкой первого сегмента (`[%clk]`, `[%eval]`, `[%csl]` …). */
491
- function mergeEditableCommentWithPreservedMeta(
492
- editableText: string,
493
- rawFirstSegment: string | undefined,
494
- ): string {
495
- const base: Comment =
496
- rawFirstSegment !== undefined
497
- ? parseComment(rawFirstSegment)
498
- : { text: "", shapes: [] };
499
- return makeComment({ ...base, text: editableText });
455
+ } else if (canUndoPgnEdit) {
456
+ e.preventDefault();
457
+ undoPgnEdit();
458
+ return;
459
+ }
500
460
  }
501
-
502
- /** Представляет нормализацию текста комментария для сравнения «набрано vs уже в дереве». */
503
- function normalizeCommentTextForCompare(text: string): string {
504
- return text.replace(/\r\n/g, "\n");
461
+ panelNav.onWindowKeyDown(e);
462
+ }
463
+
464
+ /** Представляет true, если выбран главный первый ход партии — комментарий партии до дерева ходов (`rootNode.comments`). */
465
+ const isAtMoveTreeRoot = $derived(
466
+ chessTree.currentNode.id === chessTree.rootNode.moves.id,
467
+ );
468
+
469
+ const drillForkCrumbs = $derived(chessTree.drillForkBreadcrumbs);
470
+ let isTreeViewerContainerOverflowing = $state(false);
471
+
472
+ /** Представляет черновой редактируемый текст первого PGN-комментария без блоков `[%…]` (часы, eval, стрелки и т.д.). */
473
+ let commentDraft = $state("");
474
+ /** Представляет id ноды, для которой загружен `commentDraft` (без затирания во время набора). */
475
+ let commentDraftSyncedNodeId = $state<string | null>(null);
476
+ $effect(() => {
477
+ if (!editMode) return;
478
+ const id = chessTree.currentNode.id;
479
+ if (commentDraftSyncedNodeId !== null && commentDraftSyncedNodeId !== id) {
480
+ if (pendingCommentUndoEntry !== null) {
481
+ flushPendingCommentUndoEntry();
482
+ }
483
+ persistCommentDraftForNodeId(commentDraftSyncedNodeId);
505
484
  }
506
-
507
- /** Представляет проверку совпадения массивов PGN-комментариев поэлементно (включая оба undefined). */
508
- function commentStringArraysEqual(
509
- nextComments: string[] | undefined,
510
- currentComments: string[] | undefined,
511
- ): boolean {
512
- if (nextComments === undefined && currentComments === undefined) {
513
- return true;
514
- }
515
- if (nextComments === undefined || currentComments === undefined) {
516
- return false;
517
- }
518
- if (nextComments.length !== currentComments.length) return false;
519
- for (let i = 0; i < nextComments.length; i++) {
520
- if (nextComments[i] !== currentComments[i]) return false;
521
- }
522
- return true;
485
+ if (commentDraftSyncedNodeId !== id) {
486
+ commentDraftSyncedNodeId = id;
487
+ const rawFirst =
488
+ id === chessTree.rootNode.moves.id
489
+ ? chessTree.rootNode.comments?.[0]
490
+ : chessTree.currentNode.data.comments?.[0];
491
+ commentDraft = rawFirst !== undefined ? parseComment(rawFirst).text : "";
523
492
  }
524
-
525
- /** Представляет запись черновика в первый сегмент для узла с указанным id или вступления у корня партии. */
526
- function persistCommentDraftForNodeId(targetNodeId: string): void {
527
- if (!canEditAnnotations) return;
528
- const isIntroComment = targetNodeId === chessTree.rootNode.moves.id;
529
-
530
- let tail: string[];
531
- let rawFirst: string | undefined;
532
- let targetMoveNode: ChessTreeNode | null = null;
533
-
534
- if (isIntroComment) {
535
- tail = chessTree.rootNode.comments?.slice(1) ?? [];
536
- rawFirst = chessTree.rootNode.comments?.[0];
537
- } else {
538
- const node = chessTree.getNodeById(targetNodeId);
539
- if (!node) return;
540
- targetMoveNode = node;
541
- tail = node.data.comments?.slice(1) ?? [];
542
- rawFirst = node.data.comments?.[0];
543
- }
544
-
545
- const storedEditable =
546
- rawFirst !== undefined ? parseComment(rawFirst).text : "";
547
- if (
548
- normalizeCommentTextForCompare(storedEditable) ===
549
- normalizeCommentTextForCompare(commentDraft)
550
- ) {
551
- return;
552
- }
553
-
554
- const rebuiltFirst = mergeEditableCommentWithPreservedMeta(
555
- commentDraft,
556
- rawFirst,
557
- );
558
- const nextComments =
559
- rebuiltFirst.trim() === ""
560
- ? tail.length === 0
561
- ? undefined
562
- : tail
563
- : [rebuiltFirst, ...tail];
564
-
565
- if (isIntroComment) {
566
- if (
567
- commentStringArraysEqual(
568
- nextComments,
569
- chessTree.rootNode.comments,
570
- )
571
- ) {
572
- return;
573
- }
574
- chessTree.rootNode.comments = nextComments;
575
- chessTree.mutationVersion++;
576
- return;
577
- }
578
-
579
- if (targetMoveNode === null) return;
580
- if (
581
- commentStringArraysEqual(nextComments, targetMoveNode.data.comments)
582
- ) {
583
- return;
584
- }
585
- targetMoveNode.data.comments = nextComments;
586
- chessTree.mutationVersion++;
493
+ });
494
+
495
+ /** Представляет объединение строки из textarea с неизменной мета-разметкой первого сегмента (`[%clk]`, `[%eval]`, `[%csl]` …). */
496
+ function mergeEditableCommentWithPreservedMeta(
497
+ editableText: string,
498
+ rawFirstSegment: string | undefined,
499
+ ): string {
500
+ const base: Comment =
501
+ rawFirstSegment !== undefined
502
+ ? parseComment(rawFirstSegment)
503
+ : { text: "", shapes: [] };
504
+ return makeComment({ ...base, text: editableText });
505
+ }
506
+
507
+ /** Представляет нормализацию текста комментария для сравнения «набрано vs уже в дереве». */
508
+ function normalizeCommentTextForCompare(text: string): string {
509
+ return text.replace(/\r\n/g, "\n");
510
+ }
511
+
512
+ /** Представляет проверку совпадения массивов PGN-комментариев поэлементно (включая оба undefined). */
513
+ function commentStringArraysEqual(
514
+ nextComments: string[] | undefined,
515
+ currentComments: string[] | undefined,
516
+ ): boolean {
517
+ if (nextComments === undefined && currentComments === undefined) {
518
+ return true;
587
519
  }
588
-
589
- /** Представляет запись черновика в первый сегмент `{...}` текущего узла или вступления партии у корня, сохраняя хвост и `[%…]` метаданные. */
590
- function persistCommentDraftToCurrentNode(): void {
591
- if (!canEditAnnotations) return;
592
- persistCommentDraftForNodeId(chessTree.currentNode.id);
520
+ if (nextComments === undefined || currentComments === undefined) {
521
+ return false;
593
522
  }
594
-
595
- /** Представляет символы «оценки хода» с взаимоисключением (новинка `NOVELTY_NAG_ID` не входит). */
596
- function isExclusiveMoveEvaluationGlyph(nag: number): boolean {
597
- return isMoveEvaluationNag(nag) && nag !== NOVELTY_NAG_ID;
523
+ if (nextComments.length !== currentComments.length) return false;
524
+ for (let i = 0; i < nextComments.length; i++) {
525
+ if (nextComments[i] !== currentComments[i]) return false;
598
526
  }
599
-
600
- /** Представляет переключение NAG: для !/?/… — не более одного; новинка независима; для позиции — не более одного. */
601
- function toggleNagAtCurrentNode(nagId: number): void {
602
- if (!canEditAnnotations) return;
603
- const node = chessTree.currentNode;
604
- let next = [...(node.data.nags ?? [])];
605
- const idx = next.indexOf(nagId);
606
-
607
- if (nagId === NOVELTY_NAG_ID) {
608
- if (idx !== -1) next.splice(idx, 1);
609
- else next.push(nagId);
610
- } else if (isExclusiveMoveEvaluationGlyph(nagId)) {
611
- if (idx !== -1) next.splice(idx, 1);
612
- else {
613
- next = next.filter((n) => !isExclusiveMoveEvaluationGlyph(n));
614
- next.push(nagId);
615
- }
616
- } else if (isPositionEvaluationNag(nagId)) {
617
- if (idx !== -1) next.splice(idx, 1);
618
- else {
619
- next = next.filter((n) => !isPositionEvaluationNag(n));
620
- next.push(nagId);
621
- }
622
- } else if (isPuzzleBranchNag(nagId)) {
623
- if (idx !== -1) next.splice(idx, 1);
624
- else {
625
- next = next.filter((n) => !isPuzzleBranchNag(n));
626
- next.push(nagId);
627
- }
628
- } else if (idx !== -1) next.splice(idx, 1);
629
- else next.push(nagId);
630
-
631
- node.data.nags = next.length > 0 ? next : undefined;
632
- chessTree.mutationVersion++;
633
- onSelectNode();
527
+ return true;
528
+ }
529
+
530
+ /** Представляет запись черновика в первый сегмент для узла с указанным id или вступления у корня партии. */
531
+ function persistCommentDraftForNodeId(targetNodeId: string): void {
532
+ if (!canEditAnnotations) return;
533
+ const isIntroComment = targetNodeId === chessTree.rootNode.moves.id;
534
+
535
+ let tail: string[];
536
+ let rawFirst: string | undefined;
537
+ let targetMoveNode: ChessTreeNode | null = null;
538
+
539
+ if (isIntroComment) {
540
+ tail = chessTree.rootNode.comments?.slice(1) ?? [];
541
+ rawFirst = chessTree.rootNode.comments?.[0];
542
+ } else {
543
+ const node = chessTree.getNodeById(targetNodeId);
544
+ if (!node) return;
545
+ targetMoveNode = node;
546
+ tail = node.data.comments?.slice(1) ?? [];
547
+ rawFirst = node.data.comments?.[0];
634
548
  }
635
549
 
636
- /** Представляет обновление признака вертикального переполнения дерева ходов. */
637
- function updateTreeViewerContainerOverflow(element: HTMLDivElement): void {
638
- isTreeViewerContainerOverflowing =
639
- element.scrollHeight > element.clientHeight + 1;
550
+ const storedEditable =
551
+ rawFirst !== undefined ? parseComment(rawFirst).text : "";
552
+ if (
553
+ normalizeCommentTextForCompare(storedEditable) ===
554
+ normalizeCommentTextForCompare(commentDraft)
555
+ ) {
556
+ return;
640
557
  }
641
558
 
642
- /** Представляет последний узел главной линии от указанного узла. */
643
- function getMainLineTailNode(node: ChessTreeNode): ChessTreeNode {
644
- let currentNode = node;
645
-
646
- while (currentNode.children[0]) {
647
- currentNode = currentNode.children[0];
648
- }
649
-
650
- return currentNode;
559
+ const rebuiltFirst = mergeEditableCommentWithPreservedMeta(
560
+ commentDraft,
561
+ rawFirst,
562
+ );
563
+ const nextComments =
564
+ rebuiltFirst.trim() === ""
565
+ ? tail.length === 0
566
+ ? undefined
567
+ : tail
568
+ : [rebuiltFirst, ...tail];
569
+
570
+ if (isIntroComment) {
571
+ if (commentStringArraysEqual(nextComments, chessTree.rootNode.comments)) {
572
+ return;
573
+ }
574
+ chessTree.rootNode.comments = nextComments;
575
+ chessTree.mutationVersion++;
576
+ return;
651
577
  }
652
578
 
653
- const treeViewerOverflowAttachment: Attachment<HTMLDivElement> = (
654
- element,
655
- ) => {
656
- const updateOverflow = () => updateTreeViewerContainerOverflow(element);
657
- const contentElement = element.firstElementChild;
658
-
659
- updateOverflow();
579
+ if (targetMoveNode === null) return;
580
+ if (commentStringArraysEqual(nextComments, targetMoveNode.data.comments)) {
581
+ return;
582
+ }
583
+ targetMoveNode.data.comments = nextComments;
584
+ chessTree.mutationVersion++;
585
+ }
586
+
587
+ /** Представляет запись черновика в первый сегмент `{...}` текущего узла или вступления партии у корня, сохраняя хвост и `[%…]` метаданные. */
588
+ function persistCommentDraftToCurrentNode(): void {
589
+ if (!canEditAnnotations) return;
590
+ persistCommentDraftForNodeId(chessTree.currentNode.id);
591
+ }
592
+
593
+ /** Представляет символы «оценки хода» с взаимоисключением (новинка `NOVELTY_NAG_ID` не входит). */
594
+ function isExclusiveMoveEvaluationGlyph(nag: number): boolean {
595
+ return isMoveEvaluationNag(nag) && nag !== NOVELTY_NAG_ID;
596
+ }
597
+
598
+ /** Представляет переключение NAG: для !/?/… — не более одного; новинка независима; для позиции — не более одного. */
599
+ function toggleNagAtCurrentNode(nagId: number): void {
600
+ if (!canEditAnnotations) return;
601
+ const node = chessTree.currentNode;
602
+ let next = [...(node.data.nags ?? [])];
603
+ const idx = next.indexOf(nagId);
604
+
605
+ if (nagId === NOVELTY_NAG_ID) {
606
+ if (idx !== -1) next.splice(idx, 1);
607
+ else next.push(nagId);
608
+ } else if (isExclusiveMoveEvaluationGlyph(nagId)) {
609
+ if (idx !== -1) next.splice(idx, 1);
610
+ else {
611
+ next = next.filter((n) => !isExclusiveMoveEvaluationGlyph(n));
612
+ next.push(nagId);
613
+ }
614
+ } else if (isPositionEvaluationNag(nagId)) {
615
+ if (idx !== -1) next.splice(idx, 1);
616
+ else {
617
+ next = next.filter((n) => !isPositionEvaluationNag(n));
618
+ next.push(nagId);
619
+ }
620
+ } else if (isPuzzleBranchNag(nagId)) {
621
+ if (idx !== -1) next.splice(idx, 1);
622
+ else {
623
+ next = next.filter((n) => !isPuzzleBranchNag(n));
624
+ next.push(nagId);
625
+ }
626
+ } else if (idx !== -1) next.splice(idx, 1);
627
+ else next.push(nagId);
628
+
629
+ node.data.nags = next.length > 0 ? next : undefined;
630
+ chessTree.mutationVersion++;
631
+ onSelectNode();
632
+ }
633
+
634
+ /** Представляет обновление признака вертикального переполнения дерева ходов. */
635
+ function updateTreeViewerContainerOverflow(element: HTMLDivElement): void {
636
+ isTreeViewerContainerOverflowing =
637
+ element.scrollHeight > element.clientHeight + 1;
638
+ }
639
+
640
+ /** Представляет последний узел главной линии от указанного узла. */
641
+ function getMainLineTailNode(node: ChessTreeNode): ChessTreeNode {
642
+ let currentNode = node;
643
+
644
+ while (currentNode.children[0]) {
645
+ currentNode = currentNode.children[0];
646
+ }
660
647
 
661
- const resizeObserver = new ResizeObserver(updateOverflow);
662
- const mutationObserver = new MutationObserver(updateOverflow);
648
+ return currentNode;
649
+ }
663
650
 
664
- resizeObserver.observe(element);
665
- if (contentElement) {
666
- resizeObserver.observe(contentElement);
667
- }
668
- mutationObserver.observe(element, {
669
- childList: true,
670
- });
671
-
672
- return () => {
673
- resizeObserver.disconnect();
674
- mutationObserver.disconnect();
675
- };
676
- };
651
+ const treeViewerOverflowAttachment: Attachment<HTMLDivElement> = (
652
+ element,
653
+ ) => {
654
+ const updateOverflow = () => updateTreeViewerContainerOverflow(element);
655
+ const contentElement = element.firstElementChild;
677
656
 
678
- /**
679
- * Представляет признак: развилка drill-down совпадает с развилкой для акцентных направляющих
680
- * при текущем выборе; тогда в боковой панели рисуются акцентные линии.
681
- */
682
- const isDrillForkGuideForSelection = $derived(
683
- chessTree.drillForkParent !== null &&
684
- chessTree.drillForkParent.children.length > 1 &&
685
- chessTree.currentGuideHighlightForkParent?.id ===
686
- chessTree.drillForkParent.id,
687
- );
657
+ updateOverflow();
688
658
 
689
- /**
690
- * Представляет true, когда последняя строка основного варианта заполнена двумя полуходами
691
- * и ей нужна общая нижняя граница без риска нарисовать линию под пустой чёрной ячейкой.
692
- */
693
- const isFullMainLineTailRow = $derived.by(() => {
694
- if (
695
- chessTree.drillForkParent &&
696
- chessTree.drillForkParent.children.length > 0
697
- ) {
698
- return false;
699
- }
659
+ const resizeObserver = new ResizeObserver(updateOverflow);
660
+ const mutationObserver = new MutationObserver(updateOverflow);
700
661
 
701
- return getMainLineTailNode(chessTree.rootNode.moves).data.ply % 2 === 0;
662
+ resizeObserver.observe(element);
663
+ if (contentElement) {
664
+ resizeObserver.observe(contentElement);
665
+ }
666
+ mutationObserver.observe(element, {
667
+ childList: true,
702
668
  });
703
669
 
704
- /** Представляет выход на уровень выше по drill-down относительно активной крошки. */
705
- function navigateToParentFromHeaderIndex(currentIndex: number): void {
706
- const crumbs = drillForkCrumbs;
707
- const currentCrumb = crumbs[currentIndex];
708
- if (!currentCrumb) return;
709
-
710
- const parent = chessTree.getNodeParent(currentCrumb);
711
- if (!parent) return;
670
+ return () => {
671
+ resizeObserver.disconnect();
672
+ mutationObserver.disconnect();
673
+ };
674
+ };
675
+
676
+ /**
677
+ * Представляет признак: развилка drill-down совпадает с развилкой для акцентных направляющих
678
+ * при текущем выборе; тогда в боковой панели рисуются акцентные линии.
679
+ */
680
+ const isDrillForkGuideForSelection = $derived(
681
+ chessTree.drillForkParent !== null &&
682
+ chessTree.drillForkParent.children.length > 1 &&
683
+ chessTree.currentGuideHighlightForkParent?.id ===
684
+ chessTree.drillForkParent.id,
685
+ );
686
+
687
+ /**
688
+ * Представляет true, когда последняя строка основного варианта заполнена двумя полуходами
689
+ * и ей нужна общая нижняя граница без риска нарисовать линию под пустой чёрной ячейкой.
690
+ * Для корня без ходов (`ply === 0`) — false, иначе на пустой сетке появляется лишняя линия.
691
+ */
692
+ const isFullMainLineTailRow = $derived.by(() => {
693
+ if (
694
+ chessTree.drillForkParent &&
695
+ chessTree.drillForkParent.children.length > 0
696
+ ) {
697
+ return false;
698
+ }
712
699
 
713
- if (currentIndex <= 0) {
714
- chessTree.exitDrillToFullTreeWithoutSelectingNode();
715
- } else {
716
- chessTree.navigateDrillBreadcrumb(currentIndex - 1);
717
- }
700
+ const tailPly = getMainLineTailNode(chessTree.rootNode.moves).data.ply;
701
+ return tailPly > 0 && tailPly % 2 === 0;
702
+ });
718
703
 
719
- chessTree.currentNode = parent;
720
- onSelectNode();
721
- onChessNodeSelected?.(chessTree.currentNode);
722
- scrollToActiveMoveIfNeeded();
723
- }
704
+ /** Представляет выход на уровень выше по drill-down относительно активной крошки. */
705
+ function navigateToParentFromHeaderIndex(currentIndex: number): void {
706
+ const crumbs = drillForkCrumbs;
707
+ const currentCrumb = crumbs[currentIndex];
708
+ if (!currentCrumb) return;
724
709
 
725
- /** Представляет переход по «крошке» drill-down и синхронизацию доски. */
726
- function navigateDrillCrumb(targetForkIndex: number): void {
727
- chessTree.navigateDrillBreadcrumb(targetForkIndex);
728
- onSelectNode();
729
- onChessNodeSelected?.(chessTree.currentNode);
730
- scrollToActiveMoveIfNeeded();
731
- }
710
+ const parent = chessTree.getNodeParent(currentCrumb);
711
+ if (!parent) return;
732
712
 
733
- /** Представляет выход к полному дереву по домику без смены текущего узла и без синхронизации доски. */
734
- function exitDrillViaHome(): void {
735
- chessTree.exitDrillToFullTreeWithoutSelectingNode();
736
- scrollToActiveMoveIfNeeded();
713
+ if (currentIndex <= 0) {
714
+ chessTree.exitDrillToFullTreeWithoutSelectingNode();
715
+ } else {
716
+ chessTree.navigateDrillBreadcrumb(currentIndex - 1);
737
717
  }
738
718
 
739
- const ROOT_COMMENT_BLOCK_CLASS = "px-3 py-2 !bg-gray-50/95";
719
+ chessTree.currentNode = parent;
720
+ onSelectNode();
721
+ onChessNodeSelected?.(chessTree.currentNode);
722
+ scrollToActiveMoveIfNeeded();
723
+ }
724
+
725
+ /** Представляет переход по «крошке» drill-down и синхронизацию доски. */
726
+ function navigateDrillCrumb(targetForkIndex: number): void {
727
+ chessTree.navigateDrillBreadcrumb(targetForkIndex);
728
+ onSelectNode();
729
+ onChessNodeSelected?.(chessTree.currentNode);
730
+ scrollToActiveMoveIfNeeded();
731
+ }
732
+
733
+ /** Представляет выход к полному дереву по домику без смены текущего узла и без синхронизации доски. */
734
+ function exitDrillViaHome(): void {
735
+ chessTree.exitDrillToFullTreeWithoutSelectingNode();
736
+ scrollToActiveMoveIfNeeded();
737
+ }
738
+
739
+ const ROOT_COMMENT_BLOCK_CLASS = "px-3 py-2";
740
740
  </script>
741
741
 
742
742
  <div
743
- class={[
744
- "relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-l-sm border-x border-t border-gray-300",
745
- className,
746
- ]}
743
+ class={cn(
744
+ "tree-viewer relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-x border-t",
745
+ className,
746
+ )}
747
+ style:--tree-viewer-bg={theme.background}
748
+ style:--tree-viewer-border={theme.border}
749
+ style:--tree-viewer-text={theme.text}
750
+ style:border-color="var(--tree-viewer-border)"
747
751
  >
752
+ <div
753
+ id="tree-viewer-container"
754
+ class={cn(
755
+ "min-h-0 flex-1 overflow-x-hidden overflow-y-auto",
756
+ editMode && "border-b",
757
+ isTreeViewerContainerOverflowing && "tree-viewer-container--overflowing",
758
+ )}
759
+ style:border-color="var(--tree-viewer-border)"
760
+ role="region"
761
+ aria-label="Chess move tree"
762
+ oncontextmenu={(e) => e.preventDefault()}
763
+ {@attach treeViewerOverflowAttachment}
764
+ >
765
+ <div
766
+ class={{
767
+ "tree-viewer-grid grid min-w-0 grid-cols-[60px_1fr_1fr] gap-px select-none [&>*]:min-w-0": true,
768
+ "border-b": !isTreeViewerContainerOverflowing && isFullMainLineTailRow,
769
+ "border-r": isTreeViewerContainerOverflowing,
770
+ }}
771
+ style:background-color="var(--tree-viewer-border)"
772
+ style:border-color="var(--tree-viewer-border)"
773
+ >
774
+ {#if chessTree.drillForkParent && chessTree.drillForkParent.children.length > 0}
775
+ <DrillBreadcrumbs
776
+ {chessTree}
777
+ {drillForkCrumbs}
778
+ onNavigateCrumb={navigateDrillCrumb}
779
+ onNavigateToParentFromCrumb={navigateToParentFromHeaderIndex}
780
+ onExitToFullTree={exitDrillViaHome}
781
+ />
782
+ <DrillVariationList
783
+ drillForkParent={chessTree.drillForkParent}
784
+ {isDrillForkGuideForSelection}
785
+ isBranchOnCurrentPath={(n) => chessTree.isCurrentOnPathFromNode(n)}
786
+ />
787
+ {:else}
788
+ {#if chessTree.rootNode.comments}
789
+ {#each chessTree.rootNode.comments as firstComment, commentIndex (`root-${commentIndex}`)}
790
+ <MoveComment
791
+ text={parseComment(firstComment).text}
792
+ isHighestLevel={true}
793
+ spaceBlockClass={ROOT_COMMENT_BLOCK_CLASS}
794
+ showBottomBorder={commentIndex ===
795
+ chessTree.rootNode.comments.length - 1 &&
796
+ chessTree.rootNode.moves.children.length === 0}
797
+ />
798
+ {/each}
799
+ {/if}
800
+ <Move
801
+ chessNode={chessTree.rootNode.moves}
802
+ depth={1}
803
+ shouldReserveEmptyMoveCell={false}
804
+ parentNode={null}
805
+ />
806
+ {/if}
807
+ </div>
808
+ </div>
809
+ {#if editMode}
748
810
  <div
749
- id="tree-viewer-container"
750
- class={{
751
- "min-h-0 flex-1 overflow-x-hidden overflow-y-auto border-b border-gray-300": true,
752
- "tree-viewer-container--overflowing":
753
- isTreeViewerContainerOverflowing,
754
- }}
755
- role="region"
756
- aria-label="Chess move tree"
757
- oncontextmenu={(e) => e.preventDefault()}
758
- {@attach treeViewerOverflowAttachment}
811
+ class="flex shrink-0 flex-col gap-2 border-b p-3"
812
+ style:background-color="var(--tree-viewer-bg)"
813
+ style:border-color="var(--tree-viewer-border)"
759
814
  >
760
- <div
815
+ <div
816
+ class="flex justify-center"
817
+ role="toolbar"
818
+ aria-label="Отмена и повтор правок PGN"
819
+ >
820
+ <div class="flex items-center gap-1">
821
+ <button
822
+ type="button"
823
+ disabled={!canUndoPgnEdit}
824
+ onclick={() => undoPgnEdit()}
825
+ title="Отменить"
761
826
  class={{
762
- "grid min-w-0 grid-cols-[60px_1fr_1fr] gap-px bg-gray-300 select-none [&>*]:min-w-0 [&>*]:bg-white": true,
763
- "border-b border-gray-300":
764
- !isTreeViewerContainerOverflowing && isFullMainLineTailRow,
765
- "border-r border-gray-300": isTreeViewerContainerOverflowing,
827
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-gray-700 shadow-sm outline-none hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus-visible:ring-gray-500 dark:focus-visible:ring-offset-gray-900": true,
766
828
  }}
767
- >
768
- {#if chessTree.drillForkParent && chessTree.drillForkParent.children.length > 0}
769
- <DrillBreadcrumbs
770
- {chessTree}
771
- {drillForkCrumbs}
772
- {pieceSet}
773
- onNavigateCrumb={navigateDrillCrumb}
774
- onNavigateToParentFromCrumb={navigateToParentFromHeaderIndex}
775
- onExitToFullTree={exitDrillViaHome}
776
- />
777
- <DrillVariationList
778
- drillForkParent={chessTree.drillForkParent}
779
- {pieceSet}
780
- {isDrillForkGuideForSelection}
781
- isBranchOnCurrentPath={(n) =>
782
- chessTree.isCurrentOnPathFromNode(n)}
783
- />
784
- {:else}
785
- {#if chessTree.rootNode.comments}
786
- {#each chessTree.rootNode.comments as firstComment, commentIndex (`root-${commentIndex}`)}
787
- <MoveComment
788
- text={parseComment(firstComment).text}
789
- isHighestLevel={true}
790
- spaceBlockClass={ROOT_COMMENT_BLOCK_CLASS}
791
- />
792
- {/each}
793
- {/if}
794
- <Move
795
- chessNode={chessTree.rootNode.moves}
796
- depth={1}
797
- shouldReserveEmptyMoveCell={false}
798
- parentNode={null}
799
- {pieceSet}
800
- />
801
- {/if}
802
- </div>
803
- </div>
804
- {#if editMode}
805
- <div
806
- class="flex shrink-0 flex-col gap-2 border-b border-gray-300 bg-gray-50/95 p-3"
807
- >
808
- <div
809
- class="flex justify-center"
810
- role="toolbar"
811
- aria-label="Отмена и повтор правок PGN"
812
- >
813
- <div class="flex items-center gap-1">
814
- <button
815
- type="button"
816
- disabled={!canUndoPgnEdit}
817
- onclick={() => undoPgnEdit()}
818
- title="Отменить"
819
- class={{
820
- "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 shadow-sm outline-none hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40": true,
821
- }}
822
- aria-label="Отменить изменение PGN"
823
- >
824
- <Undo2Icon class="size-4" aria-hidden="true" />
825
- </button>
826
- <button
827
- type="button"
828
- disabled={!canRedoPgnEdit}
829
- onclick={() => redoPgnEdit()}
830
- title="Повторить"
831
- class={{
832
- "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 shadow-sm outline-none hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40": true,
833
- }}
834
- aria-label="Повторить изменение PGN"
835
- >
836
- <Redo2Icon class="size-4" aria-hidden="true" />
837
- </button>
838
- </div>
839
- </div>
840
- <label class="flex flex-col gap-1">
841
- <span class="sr-only">Комментарий к текущей ноде</span>
842
- <textarea
843
- class="min-h-[4.5rem] w-full resize-y rounded-md border border-gray-300 bg-white px-3 py-2 font-sans text-sm text-gray-900 shadow-sm outline-none placeholder:text-gray-400 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500"
844
- placeholder={isAtMoveTreeRoot
845
- ? "Комментарий перед первым ходом"
846
- : "Комментарий первого блока после хода"}
847
- autocomplete="off"
848
- disabled={!canEditAnnotations}
849
- bind:value={commentDraft}
850
- oninput={() => persistCommentDraftToCurrentNode()}
851
- onfocusin={() => {
852
- if (!canEditAnnotations) return;
853
- commentTextareaHasFocus = true;
854
- pendingCommentUndoEntry =
855
- capturePgnHistoryEntry(chessTree);
856
- }}
857
- onfocusout={() => {
858
- commentTextareaHasFocus = false;
859
- flushPendingCommentUndoEntry();
860
- const syncId = commentDraftSyncedNodeId;
861
- if (syncId !== null) {
862
- persistCommentDraftForNodeId(syncId);
863
- }
864
- }}
865
- onkeydown={(e) => {
866
- if (
867
- editMode &&
868
- (e.ctrlKey || e.metaKey) &&
869
- !e.altKey &&
870
- e.key.toLowerCase() === "z"
871
- ) {
872
- return;
873
- }
874
- e.stopPropagation();
875
- }}
876
- ></textarea>
877
- </label>
829
+ style:background-color="var(--tree-viewer-bg)"
830
+ style:border-color="var(--tree-viewer-border)"
831
+ aria-label="Отменить изменение PGN"
832
+ >
833
+ <Undo2Icon class="size-4" aria-hidden="true" />
834
+ </button>
835
+ <button
836
+ type="button"
837
+ disabled={!canRedoPgnEdit}
838
+ onclick={() => redoPgnEdit()}
839
+ title="Повторить"
840
+ class={{
841
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-gray-700 shadow-sm outline-none hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus-visible:ring-gray-500 dark:focus-visible:ring-offset-gray-900": true,
842
+ }}
843
+ style:background-color="var(--tree-viewer-bg)"
844
+ style:border-color="var(--tree-viewer-border)"
845
+ aria-label="Повторить изменение PGN"
846
+ >
847
+ <Redo2Icon class="size-4" aria-hidden="true" />
848
+ </button>
849
+ {#if isCurrentNodeAtFork}
878
850
  <div
879
- class="relative flex flex-wrap gap-1.5 pb-0.5 pr-0.5"
880
- >
881
- {#each visibleNagSections as section, index (section.key)}
882
- {#if index > 0}
883
- <div
884
- class="mx-1.5 w-px self-stretch bg-gray-300"
885
- aria-hidden="true"
886
- ></div>
887
- {/if}
888
- <div
889
- class="flex flex-wrap gap-1.5"
890
- role="group"
891
- aria-label={section.title}
892
- >
893
- {#each section.items as entry (entry.id)}
894
- {@const nagId = entry.id}
895
- {@const label = transformNagId(nagId)}
896
- {@const nagOnNode =
897
- chessTree.currentNode.data.nags?.includes(nagId) ??
898
- false}
899
- {@const shadedActive = canEditAnnotations && nagOnNode}
900
- {#if label}
901
- <button
902
- type="button"
903
- disabled={!canEditAnnotations}
904
- title={nagDescription(nagId)}
905
- class={{
906
- "inline-flex min-h-[1.5rem] min-w-[1.5rem] shrink-0 items-center justify-center rounded-full border px-2 py-0.5 text-center font-serif text-[0.625rem] font-semibold leading-none text-white shadow-[0_1px_2px_rgba(0,0,0,0.25)]": true,
907
- "cursor-pointer": canEditAnnotations,
908
- "cursor-not-allowed border-gray-300 bg-gray-300 text-gray-600 shadow-none":
909
- !canEditAnnotations,
910
- "border-white/25": canEditAnnotations,
911
- }}
912
- style:background={canEditAnnotations
913
- ? shadedActive
914
- ? nagBadgeBackground(nagId)
915
- : "rgb(243 244 246)"
916
- : undefined}
917
- style:color={canEditAnnotations && !shadedActive
918
- ? "#374151"
919
- : undefined}
920
- aria-pressed={nagOnNode}
921
- onclick={() => toggleNagAtCurrentNode(nagId)}
922
- >
923
- {label}
924
- </button>
925
- {/if}
926
- {/each}
927
- </div>
928
- {/each}
929
- {#if canEditAnnotations}
930
- <span
931
- class={{
932
- "pointer-events-none absolute bottom-0 right-0 size-2 rounded-full shadow-sm ring-1": true,
933
- "bg-red-600 ring-red-900/25": isDirty,
934
- "bg-green-600 ring-green-900/25": !isDirty,
935
- }}
936
- aria-label={isDirty
937
- ? "Несохранённые изменения PGN"
938
- : "Изменения сохранены"}
939
- title={isDirty
940
- ? "Дерево партии изменено"
941
- : "Несохранённых изменений нет"}
942
- ></span>
943
- {/if}
944
- </div>
851
+ class="mx-1.5 h-5 w-px self-center"
852
+ style:background-color="var(--tree-viewer-border)"
853
+ aria-hidden="true"
854
+ ></div>
855
+ {#each PUZZLE_BRANCH_NAG_IDS as nagId}
856
+ {@const label = transformNagId(nagId)}
857
+ {@const nagOnNode =
858
+ chessTree.currentNode.data.nags?.includes(nagId) ?? false}
859
+ <button
860
+ type="button"
861
+ title={nagDescription(nagId)}
862
+ class={{
863
+ "inline-flex min-h-[1.5rem] min-w-[1.5rem] shrink-0 items-center justify-center rounded-full border px-2 py-0.5 text-center font-serif text-[0.625rem] font-semibold leading-none shadow-[0_1px_2px_rgba(0,0,0,0.25)]": true,
864
+ "border-white/25 text-white": nagOnNode,
865
+ "border-white/25 text-gray-700 dark:text-gray-300":
866
+ !nagOnNode,
867
+ }}
868
+ style:background={nagOnNode
869
+ ? nagBadgeBackground(nagId)
870
+ : "var(--tree-viewer-bg)"}
871
+ aria-pressed={nagOnNode}
872
+ onclick={() => toggleNagAtCurrentNode(nagId)}
873
+ >
874
+ {label}
875
+ </button>
876
+ {/each}
877
+ {/if}
945
878
  </div>
946
- {/if}
879
+ </div>
880
+ <label class="flex flex-col gap-1">
881
+ <span class="sr-only">Комментарий к текущей ноде</span>
882
+ <textarea
883
+ class="min-h-[4.5rem] w-full resize-y rounded-md border px-3 py-2 font-sans text-sm text-gray-900 shadow-sm outline-none placeholder:text-gray-400 focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus-visible:ring-gray-500 dark:focus-visible:ring-offset-gray-900 dark:disabled:bg-gray-900 dark:disabled:text-gray-500"
884
+ style:background-color="var(--tree-viewer-bg)"
885
+ style:border-color="var(--tree-viewer-border)"
886
+ placeholder={isAtMoveTreeRoot
887
+ ? "Комментарий перед первым ходом"
888
+ : "Комментарий первого блока после хода"}
889
+ autocomplete="off"
890
+ disabled={!canEditAnnotations}
891
+ bind:value={commentDraft}
892
+ oninput={() => persistCommentDraftToCurrentNode()}
893
+ onfocusin={() => {
894
+ if (!canEditAnnotations) return;
895
+ commentTextareaHasFocus = true;
896
+ pendingCommentUndoEntry = capturePgnHistoryEntry(chessTree);
897
+ }}
898
+ onfocusout={() => {
899
+ commentTextareaHasFocus = false;
900
+ flushPendingCommentUndoEntry();
901
+ const syncId = commentDraftSyncedNodeId;
902
+ if (syncId !== null) {
903
+ persistCommentDraftForNodeId(syncId);
904
+ }
905
+ }}
906
+ onkeydown={(e) => {
907
+ if (
908
+ editMode &&
909
+ (e.ctrlKey || e.metaKey) &&
910
+ !e.altKey &&
911
+ e.key.toLowerCase() === "z"
912
+ ) {
913
+ return;
914
+ }
915
+ e.stopPropagation();
916
+ }}
917
+ ></textarea>
918
+ </label>
919
+ <div class="relative flex flex-wrap gap-1.5 pb-0.5 pr-0.5">
920
+ {#each visibleNagSections as section, index (section.key)}
921
+ {#if index > 0}
922
+ <div
923
+ class="mx-1.5 w-px self-stretch"
924
+ style:background-color="var(--tree-viewer-border)"
925
+ aria-hidden="true"
926
+ ></div>
927
+ {/if}
928
+ <div
929
+ class="flex flex-wrap gap-1.5"
930
+ role="group"
931
+ aria-label={section.title}
932
+ >
933
+ {#each section.items as entry (entry.id)}
934
+ {@const nagId = entry.id}
935
+ {@const label = transformNagId(nagId)}
936
+ {@const nagOnNode =
937
+ chessTree.currentNode.data.nags?.includes(nagId) ?? false}
938
+ {@const shadedActive = canEditAnnotations && nagOnNode}
939
+ {#if label}
940
+ <button
941
+ type="button"
942
+ disabled={!canEditAnnotations}
943
+ title={nagDescription(nagId)}
944
+ class={{
945
+ "inline-flex min-h-[1.5rem] min-w-[1.5rem] shrink-0 items-center justify-center rounded-full border px-2 py-0.5 text-center font-serif text-[0.625rem] font-semibold leading-none shadow-[0_1px_2px_rgba(0,0,0,0.25)]": true,
946
+ "cursor-pointer": canEditAnnotations,
947
+ "cursor-not-allowed text-gray-600 shadow-none dark:text-gray-400":
948
+ !canEditAnnotations,
949
+ "border-white/25 text-white":
950
+ canEditAnnotations && shadedActive,
951
+ "border-white/25 text-gray-700 dark:text-gray-300":
952
+ canEditAnnotations && !shadedActive,
953
+ }}
954
+ style:background={!canEditAnnotations
955
+ ? "var(--tree-viewer-border)"
956
+ : shadedActive
957
+ ? nagBadgeBackground(nagId)
958
+ : "var(--tree-viewer-bg)"}
959
+ style:border-color={!canEditAnnotations
960
+ ? "var(--tree-viewer-border)"
961
+ : undefined}
962
+ aria-pressed={nagOnNode}
963
+ onclick={() => toggleNagAtCurrentNode(nagId)}
964
+ >
965
+ {label}
966
+ </button>
967
+ {/if}
968
+ {/each}
969
+ </div>
970
+ {/each}
971
+ {#if canEditAnnotations}
972
+ <span
973
+ class={{
974
+ "pointer-events-none absolute bottom-0 right-0 size-2 rounded-full shadow-sm ring-1": true,
975
+ "bg-red-600 ring-red-900/25 dark:ring-red-200/30": isDirty,
976
+ "bg-green-600 ring-green-900/25 dark:ring-green-200/30": !isDirty,
977
+ }}
978
+ aria-label={isDirty
979
+ ? "Несохранённые изменения PGN"
980
+ : "Изменения сохранены"}
981
+ title={isDirty
982
+ ? "Дерево партии изменено"
983
+ : "Несохранённых изменений нет"}
984
+ ></span>
985
+ {/if}
986
+ </div>
987
+ </div>
988
+ {/if}
947
989
  </div>
948
990
 
949
991
  <VariantDropdownNavigator
950
- bind:this={variantDropdownNavigator}
951
- variants={chessTree.currentNode?.children ?? []}
952
- anchorSelector={`.${ACTIVE_MOVE_CLASS}`}
953
- {pieceSet}
954
- onActivateVariant={(node) => {
955
- panelNav.selectChildNode(node);
956
- panelNav.highlightButton("next");
957
- }}
992
+ bind:this={variantDropdownNavigator}
993
+ variants={chessTree.currentNode?.children ?? []}
994
+ anchorSelector={`.${ACTIVE_MOVE_CLASS}`}
995
+ {theme}
996
+ onActivateVariant={(node) => {
997
+ panelNav.selectChildNode(node);
998
+ panelNav.highlightButton("next");
999
+ }}
958
1000
  />
959
1001
 
960
1002
  <svelte:window onkeydown={onTreeViewerWindowKeyDown} />
961
1003
 
962
1004
  <style>
963
- .tree-viewer-container--overflowing
964
- :global(.tree-viewer-overflow-tail-border) {
965
- border-bottom-width: 0;
966
- }
1005
+ .tree-viewer-container--overflowing
1006
+ :global(.tree-viewer-overflow-tail-border) {
1007
+ border-bottom-width: 0;
1008
+ }
1009
+
1010
+ /* Фон ячеек сетки — 1px-зазор между ними виден как цвет границы.
1011
+ ponytail: оборачиваем весь селектор в :global(), иначе Svelte скоупит
1012
+ `> *` к TreeViewer, и дети из `Move`/`MoveComment` не матчатся. */
1013
+ :global(.tree-viewer-grid > *),
1014
+ :global(.tree-viewer-grid > * > *) {
1015
+ background-color: var(--tree-viewer-bg);
1016
+ }
967
1017
  </style>