@connectorvol/tree 4.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,17 +35,37 @@
35
35
  </script>
36
36
 
37
37
  <span class="inline-flex items-baseline">
38
- {#if parsedMove.isCastling || parsedMove.pieceType === PieceType.PAWN}
39
- <!-- Для рокировки показываем только текст -->
38
+ {#if parsedMove.promotionPieceType}
39
+ {#if parsedMove.isCapture}
40
+ <span>{parsedMove.pawnFile}x{parsedMove.destination}</span>
41
+ {:else}
42
+ <span>{parsedMove.destination}</span>
43
+ {/if}
44
+ <PieceIcon
45
+ pieceType={parsedMove.promotionPieceType}
46
+ color={parsedMove.color}
47
+ size={iconSize}
48
+ {pieceSet}
49
+ />
50
+ {#if parsedMove.isCheck}
51
+ <span>+</span>
52
+ {:else if parsedMove.isCheckmate}
53
+ <span>#</span>
54
+ {/if}
55
+ {:else if parsedMove.isCastling || parsedMove.pieceType === PieceType.PAWN}
56
+ <!-- Для рокировки и обычных ходов пешки показываем только текст -->
40
57
  <span>{san}</span>
41
58
  {:else}
42
- <!-- Для фигур показываем иконку + взятие + координаты -->
59
+ <!-- Для фигур показываем иконку + уточнение + взятие + координаты -->
43
60
  <PieceIcon
44
61
  pieceType={parsedMove.pieceType}
45
62
  color={parsedMove.color}
46
63
  size={iconSize}
47
64
  {pieceSet}
48
65
  />
66
+ {#if parsedMove.disambiguation}
67
+ <span>{parsedMove.disambiguation}</span>
68
+ {/if}
49
69
  {#if parsedMove.isCapture}
50
70
  <span>x</span>
51
71
  {/if}
@@ -920,6 +920,7 @@
920
920
  bind:this={variantDropdownNavigator}
921
921
  variants={chessTree.currentNode?.children ?? []}
922
922
  anchorSelector={`.${ACTIVE_MOVE_CLASS}`}
923
+ {pieceSet}
923
924
  onActivateVariant={(node) => {
924
925
  panelNav.selectChildNode(node);
925
926
  panelNav.highlightButton("next");
@@ -1,173 +1,177 @@
1
- <script lang="ts">
2
- import type { ChessTreeNode } from "../(models)/chessTreeNode.js";
3
- import NagBadges from "./NagBadges.svelte";
4
- type Props = {
5
- variants: ChessTreeNode[];
6
- onActivateVariant: (node: ChessTreeNode) => void;
7
- /**
8
- * Представляет CSS-селектор элемента, относительно которого нужно позиционировать dropdown.
9
- */
10
- anchorSelector?: string;
11
- };
12
-
13
- const { variants, onActivateVariant, anchorSelector = "" }: Props = $props();
14
-
15
- let requestedOpen = $state(false);
16
- let activeIndex = $state(0);
17
- let anchorRect = $state<DOMRect | null>(null);
18
-
19
- const isOpen = $derived(requestedOpen && variants.length > 1);
20
-
21
- const safeActiveIndex = $derived(
22
- variants.length === 0
23
- ? 0
24
- : Math.min(Math.max(activeIndex, 0), variants.length - 1),
25
- );
26
-
27
- function readAnchorRect(): void {
28
- if (!anchorSelector) {
29
- anchorRect = null;
30
- return;
31
- }
32
- const el = document.querySelector(anchorSelector);
33
- anchorRect = el instanceof HTMLElement ? el.getBoundingClientRect() : null;
34
- }
35
-
36
- function close(): void {
37
- requestedOpen = false;
38
- }
39
-
40
- export function forceClose(): void {
41
- close();
42
- }
43
-
44
- export function open(): void {
45
- if (variants.length <= 1) return;
46
- requestedOpen = true;
47
- activeIndex = 0;
48
- readAnchorRect();
49
- }
50
-
51
- export function handleKeyDown(e: KeyboardEvent): boolean {
52
- if (!isOpen) return false;
53
-
54
- if (e.key === "Escape") {
55
- close();
56
- return true;
57
- }
58
-
59
- if (e.key === "ArrowDown") {
60
- if (variants.length <= 1) return true;
61
- activeIndex = Math.min(safeActiveIndex + 1, variants.length - 1);
62
- return true;
63
- }
64
-
65
- if (e.key === "ArrowUp") {
66
- if (variants.length <= 1) return true;
67
- activeIndex = Math.max(safeActiveIndex - 1, 0);
68
- return true;
69
- }
70
-
71
- if (e.key === "ArrowRight") {
72
- const node = variants[safeActiveIndex];
73
- if (!node) return true;
74
- onActivateVariant(node);
75
- close();
76
- return true;
77
- }
78
-
79
- return false;
80
- }
81
-
82
- function onWindowMouseDown(e: MouseEvent): void {
83
- if (!isOpen) return;
84
- const path = e.composedPath?.() ?? [];
85
- const clickedInside = path.some(
86
- (t) => t instanceof HTMLElement && t.dataset?.["variantDropdown"] === "1",
87
- );
88
- if (!clickedInside) close();
89
- }
90
-
91
- $effect(() => {
92
- if (!isOpen) return;
93
-
94
- readAnchorRect();
95
-
96
- const onScrollOrResize = () => readAnchorRect();
97
- window.addEventListener("scroll", onScrollOrResize, true);
98
- window.addEventListener("resize", onScrollOrResize, { passive: true });
99
- return () => {
100
- window.removeEventListener("scroll", onScrollOrResize, true);
101
- window.removeEventListener("resize", onScrollOrResize);
102
- };
103
- });
104
-
105
- const position = $derived(
106
- !isOpen || !anchorRect
107
- ? null
108
- : (() => {
109
- const gap = 8;
110
- const minW = 224; // ~14rem
111
- const viewportW = window.innerWidth;
112
- const viewportH = window.innerHeight;
113
-
114
- // Prefer to the right of anchor; fallback to left if overflowing.
115
- let left = anchorRect.right + gap;
116
- if (left + minW > viewportW - gap) {
117
- left = Math.max(gap, anchorRect.left - gap - minW);
118
- }
119
-
120
- // Align tops, but keep in viewport.
121
- let top = anchorRect.top;
122
- top = Math.min(Math.max(gap, top), viewportH - gap);
123
-
124
- return { left, top };
125
- })(),
126
- );
127
-
128
- function getMovePrefix(node: ChessTreeNode): string {
129
- const fm = node.data.fullMoves;
130
- if (!fm) return "";
131
- return node.data.ply % 2 === 1 ? `${fm}.` : `${fm - 1}...`;
132
- }
133
- </script>
134
-
135
- <svelte:window onmousedown={onWindowMouseDown} />
136
-
137
- {#if isOpen}
138
- <div
139
- data-variant-dropdown="1"
140
- class="z-50 min-w-[14rem] rounded-md border bg-white p-1 shadow-md outline-none"
141
- role="menu"
142
- aria-label="Варианты следующего хода"
143
- style={position
144
- ? `position: fixed; left: ${position.left}px; top: ${position.top}px;`
145
- : "position: fixed;"}
146
- >
147
- {#each variants as variant, index (variant.id)}
148
- <button
149
- type="button"
150
- data-variant-dropdown="1"
151
- class={{
152
- "flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none": true,
153
- "bg-accent text-accent-foreground": index === safeActiveIndex,
154
- "hover:bg-accent hover:text-accent-foreground": true,
155
- }}
156
- onclick={(e) => {
157
- e.preventDefault();
158
- e.stopPropagation();
159
- onActivateVariant(variant);
160
- close();
161
- }}
162
- >
163
- <span class="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
164
- {#if getMovePrefix(variant)}
165
- <span>{getMovePrefix(variant)}</span>
166
- {/if}
167
- <span>{variant.data.san}</span>
168
- <NagBadges nags={variant.data.nags} class="!ml-0" />
169
- </span>
170
- </button>
171
- {/each}
172
- </div>
173
- {/if}
1
+ <script lang="ts">
2
+ import type { ChessTreeNode } from "../(models)/chessTreeNode.js";
3
+ import type { PieceSet } from "@connectorvol/shared";
4
+ import NagBadges from "./NagBadges.svelte";
5
+ import MoveWithIcon from "./MoveWithIcon.svelte";
6
+ type Props = {
7
+ variants: ChessTreeNode[];
8
+ onActivateVariant: (node: ChessTreeNode) => void;
9
+ /**
10
+ * Представляет CSS-селектор элемента, относительно которого нужно позиционировать dropdown.
11
+ */
12
+ anchorSelector?: string;
13
+ /** Возвращает набор фигур для иконок SAN. */
14
+ pieceSet: PieceSet;
15
+ };
16
+
17
+ const { variants, onActivateVariant, anchorSelector = "", pieceSet }: Props = $props();
18
+
19
+ let requestedOpen = $state(false);
20
+ let activeIndex = $state(0);
21
+ let anchorRect = $state<DOMRect | null>(null);
22
+
23
+ const isOpen = $derived(requestedOpen && variants.length > 1);
24
+
25
+ const safeActiveIndex = $derived(
26
+ variants.length === 0
27
+ ? 0
28
+ : Math.min(Math.max(activeIndex, 0), variants.length - 1),
29
+ );
30
+
31
+ function readAnchorRect(): void {
32
+ if (!anchorSelector) {
33
+ anchorRect = null;
34
+ return;
35
+ }
36
+ const el = document.querySelector(anchorSelector);
37
+ anchorRect = el instanceof HTMLElement ? el.getBoundingClientRect() : null;
38
+ }
39
+
40
+ function close(): void {
41
+ requestedOpen = false;
42
+ }
43
+
44
+ export function forceClose(): void {
45
+ close();
46
+ }
47
+
48
+ export function open(): void {
49
+ if (variants.length <= 1) return;
50
+ requestedOpen = true;
51
+ activeIndex = 0;
52
+ readAnchorRect();
53
+ }
54
+
55
+ export function handleKeyDown(e: KeyboardEvent): boolean {
56
+ if (!isOpen) return false;
57
+
58
+ if (e.key === "Escape") {
59
+ close();
60
+ return true;
61
+ }
62
+
63
+ if (e.key === "ArrowDown") {
64
+ if (variants.length <= 1) return true;
65
+ activeIndex = Math.min(safeActiveIndex + 1, variants.length - 1);
66
+ return true;
67
+ }
68
+
69
+ if (e.key === "ArrowUp") {
70
+ if (variants.length <= 1) return true;
71
+ activeIndex = Math.max(safeActiveIndex - 1, 0);
72
+ return true;
73
+ }
74
+
75
+ if (e.key === "ArrowRight") {
76
+ const node = variants[safeActiveIndex];
77
+ if (!node) return true;
78
+ onActivateVariant(node);
79
+ close();
80
+ return true;
81
+ }
82
+
83
+ return false;
84
+ }
85
+
86
+ function onWindowMouseDown(e: MouseEvent): void {
87
+ if (!isOpen) return;
88
+ const path = e.composedPath?.() ?? [];
89
+ const clickedInside = path.some(
90
+ (t) => t instanceof HTMLElement && t.dataset?.["variantDropdown"] === "1",
91
+ );
92
+ if (!clickedInside) close();
93
+ }
94
+
95
+ $effect(() => {
96
+ if (!isOpen) return;
97
+
98
+ readAnchorRect();
99
+
100
+ const onScrollOrResize = () => readAnchorRect();
101
+ window.addEventListener("scroll", onScrollOrResize, true);
102
+ window.addEventListener("resize", onScrollOrResize, { passive: true });
103
+ return () => {
104
+ window.removeEventListener("scroll", onScrollOrResize, true);
105
+ window.removeEventListener("resize", onScrollOrResize);
106
+ };
107
+ });
108
+
109
+ const position = $derived(
110
+ !isOpen || !anchorRect
111
+ ? null
112
+ : (() => {
113
+ const gap = 8;
114
+ const minW = 224; // ~14rem
115
+ const viewportW = window.innerWidth;
116
+ const viewportH = window.innerHeight;
117
+
118
+ // Prefer to the right of anchor; fallback to left if overflowing.
119
+ let left = anchorRect.right + gap;
120
+ if (left + minW > viewportW - gap) {
121
+ left = Math.max(gap, anchorRect.left - gap - minW);
122
+ }
123
+
124
+ // Align tops, but keep in viewport.
125
+ let top = anchorRect.top;
126
+ top = Math.min(Math.max(gap, top), viewportH - gap);
127
+
128
+ return { left, top };
129
+ })(),
130
+ );
131
+
132
+ function getMovePrefix(node: ChessTreeNode): string {
133
+ const fm = node.data.fullMoves;
134
+ if (!fm) return "";
135
+ return node.data.ply % 2 === 1 ? `${fm}.` : `${fm - 1}...`;
136
+ }
137
+ </script>
138
+
139
+ <svelte:window onmousedown={onWindowMouseDown} />
140
+
141
+ {#if isOpen}
142
+ <div
143
+ data-variant-dropdown="1"
144
+ class="z-50 min-w-[14rem] rounded-md border bg-white p-1 shadow-md outline-none"
145
+ role="menu"
146
+ aria-label="Варианты следующего хода"
147
+ style={position
148
+ ? `position: fixed; left: ${position.left}px; top: ${position.top}px;`
149
+ : "position: fixed;"}
150
+ >
151
+ {#each variants as variant, index (variant.id)}
152
+ <button
153
+ type="button"
154
+ data-variant-dropdown="1"
155
+ class={{
156
+ "flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none": true,
157
+ "bg-accent text-accent-foreground": index === safeActiveIndex,
158
+ "hover:bg-accent hover:text-accent-foreground": true,
159
+ }}
160
+ onclick={(e) => {
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+ onActivateVariant(variant);
164
+ close();
165
+ }}
166
+ >
167
+ <span class="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
168
+ {#if getMovePrefix(variant)}
169
+ <span>{getMovePrefix(variant)}</span>
170
+ {/if}
171
+ <MoveWithIcon san={variant.data.san} ply={variant.data.ply} {pieceSet} />
172
+ <NagBadges nags={variant.data.nags} class="!ml-0" />
173
+ </span>
174
+ </button>
175
+ {/each}
176
+ </div>
177
+ {/if}
@@ -1,4 +1,5 @@
1
1
  import type { ChessTreeNode } from "../(models)/chessTreeNode.js";
2
+ import type { PieceSet } from "@connectorvol/shared";
2
3
  type Props = {
3
4
  variants: ChessTreeNode[];
4
5
  onActivateVariant: (node: ChessTreeNode) => void;
@@ -6,6 +7,8 @@ type Props = {
6
7
  * Представляет CSS-селектор элемента, относительно которого нужно позиционировать dropdown.
7
8
  */
8
9
  anchorSelector?: string;
10
+ /** Возвращает набор фигур для иконок SAN. */
11
+ pieceSet: PieceSet;
9
12
  };
10
13
  declare const VariantDropdownNavigator: import("svelte").Component<Props, {
11
14
  forceClose: () => void;
@@ -17,6 +17,12 @@ export interface ParsedSanMove {
17
17
  isCheckmate: boolean;
18
18
  /** Возвращает координаты назначения (например, "f3", "e4") */
19
19
  destination?: string;
20
+ /** Возвращает уточнение (файл или ранг) при неоднозначности хода (например, "d" для Ndb5, "4" для N4b6) */
21
+ disambiguation?: string;
22
+ /** Возвращает тип фигуры превращения */
23
+ promotionPieceType?: PieceType;
24
+ /** Возвращает файл пешки при взятии (например, "e" для exd5) */
25
+ pawnFile?: string;
20
26
  }
21
27
  /**
22
28
  * Представляет функцию для парсинга SAN нотации в информацию о ходе
@@ -55,30 +55,63 @@ export function parseSanMove(san, ply) {
55
55
  // Ход пешки
56
56
  pieceType = PieceType.PAWN;
57
57
  }
58
+ // Определяем файл пешки при взятии
59
+ let pawnFile;
60
+ if (pieceType === PieceType.PAWN && cleanSan[1] === "x") {
61
+ pawnFile = cleanSan[0];
62
+ }
63
+ // Обрабатываем превращение пешки
64
+ let promotionPieceType;
65
+ if (moveNotation.includes("=")) {
66
+ const [moveWithoutPromotion, promotionChar] = moveNotation.split("=");
67
+ moveNotation = moveWithoutPromotion;
68
+ switch (promotionChar) {
69
+ case "Q":
70
+ promotionPieceType = PieceType.QUEEN;
71
+ break;
72
+ case "R":
73
+ promotionPieceType = PieceType.ROOK;
74
+ break;
75
+ case "B":
76
+ promotionPieceType = PieceType.BISHOP;
77
+ break;
78
+ case "N":
79
+ promotionPieceType = PieceType.KNIGHT;
80
+ break;
81
+ }
82
+ }
58
83
  // Определяем взятие
59
84
  const isCapture = moveNotation.includes("x");
60
- // Извлекаем координаты назначения
85
+ // Извлекаем координаты назначения и уточнение
61
86
  let destination = "";
87
+ let disambiguation;
62
88
  if (isCapture) {
63
- // После 'x' идут координаты назначения
89
+ // После 'x' идут координаты назначения, до 'x' — уточнение
64
90
  const xIndex = moveNotation.indexOf("x");
65
91
  destination = moveNotation.slice(xIndex + 1);
92
+ const prefix = moveNotation.slice(0, xIndex);
93
+ if (prefix.length > 0) {
94
+ disambiguation = prefix;
95
+ }
66
96
  }
67
97
  else {
68
- // Последние 2 символа - координаты назначения
98
+ // Последние 2 символа - координаты назначения, остальное — уточнение
69
99
  destination = moveNotation.slice(-2);
70
- }
71
- // Обрабатываем превращение пешки
72
- if (destination.includes("=")) {
73
- destination = destination.split("=")[0];
100
+ const prefix = moveNotation.slice(0, -2);
101
+ if (prefix.length > 0) {
102
+ disambiguation = prefix;
103
+ }
74
104
  }
75
105
  return {
76
106
  pieceType,
77
107
  color,
78
108
  destination,
109
+ disambiguation,
79
110
  isCastling: false,
80
111
  isCapture,
81
112
  isCheck,
82
113
  isCheckmate,
114
+ promotionPieceType,
115
+ pawnFile,
83
116
  };
84
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connectorvol/tree",
3
- "version": "4.2.0",
3
+ "version": "5.0.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",
@@ -49,7 +49,7 @@
49
49
  "tailwind-variants": "^3.2.2"
50
50
  },
51
51
  "devDependencies": {
52
- "@connectorvol/chessboard": "4.1.0",
52
+ "@connectorvol/chessboard": "4.3.0",
53
53
  "@ianvs/prettier-plugin-sort-imports": "4.5.1",
54
54
  "@internationalized/date": "^3.12.0",
55
55
  "@lucide/svelte": "^1.16.0",