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