@astronox/core 0.5.9 → 0.5.10
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/package.json +1 -1
- package/src/client/scroll.ts +14 -1
- package/src/components/data-display/IconList/IconList.astro +20 -2
- package/src/components/data-display/MetricTile/MetricTileContent.astro +6 -1
- package/src/components/feedback/Tooltip/Tooltip.css +9 -1
- package/src/components/feedback/Tooltip/tooltipController.ts +12 -9
- package/src/components/media/EmbedFrame/EmbedFrame.astro +9 -0
- package/src/components/media/EmbedFrame/EmbedFrame.css +5 -0
- package/src/components/media/EmbedFrame/embedFrameController.ts +84 -1
- package/src/display/contracts.ts +1 -0
- package/src/patterns/CircuitPuzzle/CircuitPuzzle.astro +3 -0
- package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
- package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +1 -0
- package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +50 -64
- package/src/patterns/PuzzleGrid/PuzzleGrid.astro +2 -0
- package/src/patterns/PuzzleGrid/keyboard.ts +220 -0
- package/src/patterns/PuzzleGrid/movement.css +4 -0
- package/src/patterns/PuzzleGrid/movement.ts +67 -3
- package/src/patterns/PuzzleGrid/selectionDismissal.ts +2 -2
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +1 -0
- package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
- package/src/patterns/Table/TableCell.astro +1 -1
- package/src/patterns/Table/TableCellValue.astro +6 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
animatePieceMovement,
|
|
3
3
|
cancelDragFrame,
|
|
4
|
+
clearPuzzleMovementPreview,
|
|
4
5
|
findDirectionalDropDestination,
|
|
6
|
+
getPuzzleMoveTarget,
|
|
5
7
|
hasLeftOriginCell,
|
|
6
8
|
renderPathStream,
|
|
7
9
|
scheduleDragFrame,
|
|
@@ -12,6 +14,10 @@ import {
|
|
|
12
14
|
setPuzzleGridState,
|
|
13
15
|
} from '../PuzzleGrid/state';
|
|
14
16
|
import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
|
|
17
|
+
import {
|
|
18
|
+
initPuzzleKeyboard,
|
|
19
|
+
type PuzzleKeyboard,
|
|
20
|
+
} from '../PuzzleGrid/keyboard';
|
|
15
21
|
import type {
|
|
16
22
|
MagneticFieldDirection,
|
|
17
23
|
MagneticFieldPolarity,
|
|
@@ -33,6 +39,7 @@ type MagneticFieldInstance = {
|
|
|
33
39
|
selectedPieceId: string | null;
|
|
34
40
|
drag: PuzzleDrag | null;
|
|
35
41
|
ignoreNextClick: boolean;
|
|
42
|
+
keyboard?: PuzzleKeyboard;
|
|
36
43
|
};
|
|
37
44
|
|
|
38
45
|
const dragThreshold = 12;
|
|
@@ -48,19 +55,14 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
|
|
|
48
55
|
});
|
|
49
56
|
initPuzzleSelectionDismissal(root, {
|
|
50
57
|
dismiss: () => selectPiece(root, null),
|
|
51
|
-
isSelectionTarget: (target) => {
|
|
58
|
+
isSelectionTarget: (target, event) => {
|
|
52
59
|
const panel = getActivePanel(root);
|
|
53
60
|
if (!panel || panel.dataset.puzzleStatus === 'valid') return false;
|
|
54
61
|
|
|
55
62
|
const piece = target.closest<HTMLElement>('[data-field-piece]');
|
|
56
63
|
if (piece && panel.contains(piece)) return true;
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
'[data-field-destination]',
|
|
60
|
-
);
|
|
61
|
-
return Boolean(
|
|
62
|
-
destination && panel.contains(destination) && !destination.disabled,
|
|
63
|
-
);
|
|
65
|
+
return Boolean(getPuzzleMoveTarget(panel, event));
|
|
64
66
|
},
|
|
65
67
|
});
|
|
66
68
|
|
|
@@ -79,14 +81,9 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
|
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (destination && !destination.disabled) {
|
|
86
|
-
const pieceId = destination.dataset.destinationPiece;
|
|
87
|
-
const direction = destination.dataset.destinationDirection as
|
|
88
|
-
MagneticFieldDirection | undefined;
|
|
89
|
-
if (pieceId && direction) movePiece(root, pieceId, direction);
|
|
84
|
+
const movement = getPuzzleMoveTarget(getActivePanel(root), event);
|
|
85
|
+
if (movement) {
|
|
86
|
+
movePiece(root, movement.pieceId, movement.direction);
|
|
90
87
|
return;
|
|
91
88
|
}
|
|
92
89
|
|
|
@@ -102,12 +99,11 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
|
|
|
102
99
|
const pieceId = piece?.dataset.fieldPiece;
|
|
103
100
|
if (!piece || !pieceId || !instance) return;
|
|
104
101
|
const panel = getActivePanel(root);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: null;
|
|
102
|
+
if (!panel || panel.dataset.puzzleStatus === 'valid') return;
|
|
103
|
+
const originCell = findCell(panel, {
|
|
104
|
+
row: Number(piece.dataset.pieceRow),
|
|
105
|
+
column: Number(piece.dataset.pieceColumn),
|
|
106
|
+
});
|
|
111
107
|
if (!originCell) return;
|
|
112
108
|
const pieceBounds = piece.getBoundingClientRect();
|
|
113
109
|
|
|
@@ -155,24 +151,20 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
|
|
|
155
151
|
finishDrag(root, event, false);
|
|
156
152
|
});
|
|
157
153
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
piece
|
|
171
|
-
|
|
172
|
-
undefined;
|
|
173
|
-
if (!pieceId) return;
|
|
174
|
-
event.preventDefault();
|
|
175
|
-
movePiece(root, pieceId, direction);
|
|
154
|
+
const instance = instances.get(root)!;
|
|
155
|
+
instance.keyboard = initPuzzleKeyboard(root, {
|
|
156
|
+
getPanel: () => getActivePanel(root),
|
|
157
|
+
getSelected: () =>
|
|
158
|
+
getActivePanel(root)?.querySelector<HTMLButtonElement>(
|
|
159
|
+
'[data-field-piece][data-piece-selected]',
|
|
160
|
+
) ?? null,
|
|
161
|
+
select: (piece) => {
|
|
162
|
+
if (!piece) cancelDrag(instance);
|
|
163
|
+
selectPiece(root, piece?.dataset.fieldPiece ?? null);
|
|
164
|
+
},
|
|
165
|
+
move: (piece, direction) =>
|
|
166
|
+
movePiece(root, piece.dataset.fieldPiece!, direction),
|
|
167
|
+
isLocked: () => instance.drag !== null,
|
|
176
168
|
});
|
|
177
169
|
}
|
|
178
170
|
|
|
@@ -286,11 +278,11 @@ function updateDestinations(root: HTMLElement): void {
|
|
|
286
278
|
for (const movement of getMagneticFieldMoves(puzzle, state, pieceId)) {
|
|
287
279
|
const directionLabel = readDirectionLabel(labels, movement.direction);
|
|
288
280
|
const board = panel.querySelector<HTMLElement>('[data-field-board]');
|
|
289
|
-
if (board) renderPathStream(board, movement);
|
|
281
|
+
if (board) renderPathStream(board, movement, pieceId);
|
|
290
282
|
|
|
291
283
|
const destination = findCell(panel, movement.to);
|
|
292
284
|
if (!destination) continue;
|
|
293
|
-
destination.
|
|
285
|
+
destination.tabIndex = -1;
|
|
294
286
|
destination.setAttribute('data-field-destination', '');
|
|
295
287
|
destination.setAttribute('data-puzzle-cell-interactive', '');
|
|
296
288
|
destination.dataset.destinationPiece = pieceId;
|
|
@@ -350,13 +342,15 @@ function initializeActivePuzzle(root: HTMLElement): void {
|
|
|
350
342
|
const initial = createMagneticFieldInitialState(puzzle);
|
|
351
343
|
|
|
352
344
|
instance.selectedPieceId = null;
|
|
353
|
-
instance
|
|
345
|
+
cancelDrag(instance);
|
|
354
346
|
instance.ignoreNextClick = false;
|
|
355
347
|
panel.dataset.puzzleStatus = 'incomplete';
|
|
356
348
|
const grid = panel.querySelector<HTMLElement>('[data-puzzle-grid]');
|
|
357
349
|
if (grid) setPuzzleGridState(grid, null);
|
|
358
350
|
renderState(panel, puzzle, initial);
|
|
351
|
+
syncPieceSelection(panel, null);
|
|
359
352
|
hideMovePreviews(panel);
|
|
353
|
+
instance.keyboard?.reset();
|
|
360
354
|
emitChange(root, puzzle.id, initial);
|
|
361
355
|
}
|
|
362
356
|
|
|
@@ -458,14 +452,7 @@ function finishDrag(
|
|
|
458
452
|
? findDirectionalDropDestination(drag, deltaX, deltaY)
|
|
459
453
|
: null;
|
|
460
454
|
|
|
461
|
-
|
|
462
|
-
drag.element.removeAttribute('data-piece-dragging');
|
|
463
|
-
drag.element.style.removeProperty('--piece-drag-x');
|
|
464
|
-
drag.element.style.removeProperty('--piece-drag-y');
|
|
465
|
-
if (drag.element.hasPointerCapture(event.pointerId)) {
|
|
466
|
-
drag.element.releasePointerCapture(event.pointerId);
|
|
467
|
-
}
|
|
468
|
-
instance.drag = null;
|
|
455
|
+
cancelDrag(instance);
|
|
469
456
|
|
|
470
457
|
if (commit && drag.moved) {
|
|
471
458
|
instance.ignoreNextClick = true;
|
|
@@ -492,12 +479,20 @@ function getDropDestinations(root: HTMLElement): HTMLButtonElement[] {
|
|
|
492
479
|
];
|
|
493
480
|
}
|
|
494
481
|
|
|
482
|
+
function cancelDrag(instance: MagneticFieldInstance): void {
|
|
483
|
+
const drag = instance.drag;
|
|
484
|
+
if (!drag) return;
|
|
485
|
+
instance.drag = null;
|
|
486
|
+
cancelDragFrame(drag);
|
|
487
|
+
drag.element.removeAttribute('data-piece-dragging');
|
|
488
|
+
drag.element.style.removeProperty('--piece-drag-x');
|
|
489
|
+
drag.element.style.removeProperty('--piece-drag-y');
|
|
490
|
+
if (drag.element.hasPointerCapture(drag.pointerId))
|
|
491
|
+
drag.element.releasePointerCapture(drag.pointerId);
|
|
492
|
+
}
|
|
493
|
+
|
|
495
494
|
function hideMovePreviews(panel: HTMLElement): void {
|
|
496
|
-
|
|
497
|
-
'[data-puzzle-path-stream]',
|
|
498
|
-
)) {
|
|
499
|
-
stream.remove();
|
|
500
|
-
}
|
|
495
|
+
clearPuzzleMovementPreview(panel);
|
|
501
496
|
|
|
502
497
|
for (const destination of panel.querySelectorAll<HTMLButtonElement>(
|
|
503
498
|
'[data-field-destination]',
|
|
@@ -657,12 +652,3 @@ function formatTemplate(
|
|
|
657
652
|
function capitalize(value: string): string {
|
|
658
653
|
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
659
654
|
}
|
|
660
|
-
|
|
661
|
-
const arrowDirections: Readonly<
|
|
662
|
-
Partial<Record<string, MagneticFieldDirection>>
|
|
663
|
-
> = {
|
|
664
|
-
arrowup: 'north',
|
|
665
|
-
arrowright: 'east',
|
|
666
|
-
arrowdown: 'south',
|
|
667
|
-
arrowleft: 'west',
|
|
668
|
-
};
|
|
@@ -16,6 +16,8 @@ const { class: className, label, rows, columns, ...attrs }: Props = Astro.props;
|
|
|
16
16
|
role="group"
|
|
17
17
|
aria-label={label}
|
|
18
18
|
data-puzzle-grid
|
|
19
|
+
data-puzzle-rows={rows}
|
|
20
|
+
data-puzzle-columns={columns}
|
|
19
21
|
style={`--puzzle-columns: ${columns}; --puzzle-rows: ${rows};`}
|
|
20
22
|
>
|
|
21
23
|
<slot />
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPuzzleMoveTarget,
|
|
3
|
+
puzzleArrowDirections,
|
|
4
|
+
type PuzzleDirection,
|
|
5
|
+
} from './movement';
|
|
6
|
+
|
|
7
|
+
type PuzzleKeyboardOptions = {
|
|
8
|
+
getPanel: () => HTMLElement | undefined;
|
|
9
|
+
getSelected: () => HTMLButtonElement | null;
|
|
10
|
+
select: (control: HTMLButtonElement | null) => void;
|
|
11
|
+
move: (control: HTMLButtonElement, direction: PuzzleDirection) => void;
|
|
12
|
+
isLocked?: () => boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PuzzleKeyboard = {
|
|
16
|
+
reset: () => void;
|
|
17
|
+
destroy: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function initPuzzleKeyboard(
|
|
21
|
+
root: HTMLElement,
|
|
22
|
+
{
|
|
23
|
+
getPanel,
|
|
24
|
+
getSelected,
|
|
25
|
+
select,
|
|
26
|
+
move,
|
|
27
|
+
isLocked = () => false,
|
|
28
|
+
}: PuzzleKeyboardOptions,
|
|
29
|
+
): PuzzleKeyboard {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const options = { signal: controller.signal };
|
|
32
|
+
let engaged = false;
|
|
33
|
+
let released = false;
|
|
34
|
+
let pointerFocus = false;
|
|
35
|
+
let focusing = false;
|
|
36
|
+
let entry: HTMLButtonElement | null = null;
|
|
37
|
+
const getBoard = (): HTMLElement | null =>
|
|
38
|
+
getPanel()?.querySelector<HTMLElement>('[data-puzzle-grid]') ?? null;
|
|
39
|
+
const isComplete = (): boolean =>
|
|
40
|
+
getPanel()?.dataset.puzzleStatus === 'valid';
|
|
41
|
+
const getControls = (): HTMLButtonElement[] => [
|
|
42
|
+
...(getBoard()?.querySelectorAll<HTMLButtonElement>(
|
|
43
|
+
'[data-puzzle-control]:not(:disabled)',
|
|
44
|
+
) ?? []),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const sync = (): void => {
|
|
48
|
+
const controls = getControls();
|
|
49
|
+
const selected = getSelected();
|
|
50
|
+
entry =
|
|
51
|
+
(selected && controls.includes(selected) ? selected : null) ??
|
|
52
|
+
(entry && controls.includes(entry) ? entry : null) ??
|
|
53
|
+
controls[0] ??
|
|
54
|
+
null;
|
|
55
|
+
if (!selected || isComplete()) engaged = false;
|
|
56
|
+
const board = getBoard();
|
|
57
|
+
if (board) board.tabIndex = released && !isComplete() ? 0 : -1;
|
|
58
|
+
for (const control of controls) {
|
|
59
|
+
control.tabIndex =
|
|
60
|
+
!released && !isComplete() && control === entry ? 0 : -1;
|
|
61
|
+
control.setAttribute(
|
|
62
|
+
'aria-keyshortcuts',
|
|
63
|
+
'ArrowUp ArrowRight ArrowDown ArrowLeft Tab Shift+Tab Escape Enter Space',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const focus = (control: HTMLElement): void => {
|
|
69
|
+
focusing = true;
|
|
70
|
+
control.focus({ preventScroll: true });
|
|
71
|
+
focusing = false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const activate = (control: HTMLButtonElement): void => {
|
|
75
|
+
released = false;
|
|
76
|
+
entry = control;
|
|
77
|
+
if (getSelected() !== control) select(control);
|
|
78
|
+
sync();
|
|
79
|
+
engaged = true;
|
|
80
|
+
focus(control);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
root.addEventListener(
|
|
84
|
+
'pointerdown',
|
|
85
|
+
() => {
|
|
86
|
+
pointerFocus = true;
|
|
87
|
+
},
|
|
88
|
+
{ ...options, capture: true },
|
|
89
|
+
);
|
|
90
|
+
root.addEventListener(
|
|
91
|
+
'pointercancel',
|
|
92
|
+
() => {
|
|
93
|
+
pointerFocus = false;
|
|
94
|
+
},
|
|
95
|
+
options,
|
|
96
|
+
);
|
|
97
|
+
root.addEventListener(
|
|
98
|
+
'focusin',
|
|
99
|
+
(event) => {
|
|
100
|
+
if (focusing || pointerFocus || isLocked() || isComplete()) return;
|
|
101
|
+
if (!(event.target instanceof HTMLButtonElement)) return;
|
|
102
|
+
if (getControls().includes(event.target)) activate(event.target);
|
|
103
|
+
},
|
|
104
|
+
options,
|
|
105
|
+
);
|
|
106
|
+
root.addEventListener(
|
|
107
|
+
'focusout',
|
|
108
|
+
(event) => {
|
|
109
|
+
if (
|
|
110
|
+
event.relatedTarget instanceof Node &&
|
|
111
|
+
getBoard()?.contains(event.relatedTarget)
|
|
112
|
+
)
|
|
113
|
+
return;
|
|
114
|
+
engaged = false;
|
|
115
|
+
released = false;
|
|
116
|
+
pointerFocus = false;
|
|
117
|
+
select(null);
|
|
118
|
+
sync();
|
|
119
|
+
},
|
|
120
|
+
options,
|
|
121
|
+
);
|
|
122
|
+
root.addEventListener(
|
|
123
|
+
'click',
|
|
124
|
+
(event) => {
|
|
125
|
+
if (!(event.target instanceof Element) || isLocked()) return;
|
|
126
|
+
const selected = getSelected();
|
|
127
|
+
if (selected && getPuzzleMoveTarget(getPanel(), event)) focus(selected);
|
|
128
|
+
},
|
|
129
|
+
{ ...options, capture: true },
|
|
130
|
+
);
|
|
131
|
+
root.addEventListener(
|
|
132
|
+
'click',
|
|
133
|
+
(event) => {
|
|
134
|
+
pointerFocus = false;
|
|
135
|
+
if (
|
|
136
|
+
!(event.target instanceof Node) ||
|
|
137
|
+
!getBoard()?.contains(event.target)
|
|
138
|
+
)
|
|
139
|
+
return;
|
|
140
|
+
const selected = getSelected();
|
|
141
|
+
if (selected && !isComplete()) {
|
|
142
|
+
released = false;
|
|
143
|
+
engaged = true;
|
|
144
|
+
sync();
|
|
145
|
+
focus(selected);
|
|
146
|
+
} else if (getBoard()?.contains(document.activeElement)) {
|
|
147
|
+
released = true;
|
|
148
|
+
sync();
|
|
149
|
+
const board = getBoard();
|
|
150
|
+
if (board) focus(board);
|
|
151
|
+
} else sync();
|
|
152
|
+
},
|
|
153
|
+
options,
|
|
154
|
+
);
|
|
155
|
+
root.addEventListener(
|
|
156
|
+
'keydown',
|
|
157
|
+
(event) => {
|
|
158
|
+
pointerFocus = false;
|
|
159
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
160
|
+
const board = getBoard();
|
|
161
|
+
if (!(event.target instanceof Node) || !board?.contains(event.target))
|
|
162
|
+
return;
|
|
163
|
+
if (event.key === 'Escape') {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
engaged = false;
|
|
167
|
+
released = true;
|
|
168
|
+
select(null);
|
|
169
|
+
sync();
|
|
170
|
+
focus(board);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (isComplete()) return;
|
|
174
|
+
const direction = puzzleArrowDirections[event.key];
|
|
175
|
+
if (event.key === 'Tab') {
|
|
176
|
+
if (!engaged) return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
if (isLocked()) return;
|
|
179
|
+
const controls = getControls();
|
|
180
|
+
if (!controls.length) return;
|
|
181
|
+
const index = controls.indexOf(getSelected() ?? entry ?? controls[0]);
|
|
182
|
+
const next =
|
|
183
|
+
(index + (event.shiftKey ? -1 : 1) + controls.length) %
|
|
184
|
+
controls.length;
|
|
185
|
+
activate(controls[next]);
|
|
186
|
+
} else if (isLocked()) {
|
|
187
|
+
if (direction || event.key === 'Enter' || event.key === ' ')
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
} else if (direction) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
const selected = getSelected() ?? entry ?? getControls()[0];
|
|
192
|
+
if (!selected) return;
|
|
193
|
+
activate(selected);
|
|
194
|
+
move(selected, direction);
|
|
195
|
+
sync();
|
|
196
|
+
} else if (
|
|
197
|
+
event.target === board &&
|
|
198
|
+
(event.key === 'Enter' || event.key === ' ')
|
|
199
|
+
) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
const selected = entry ?? getControls()[0];
|
|
202
|
+
if (selected) activate(selected);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
options,
|
|
206
|
+
);
|
|
207
|
+
root.addEventListener('puzzle:change', sync, options);
|
|
208
|
+
root.addEventListener('puzzle:complete', sync, options);
|
|
209
|
+
sync();
|
|
210
|
+
return {
|
|
211
|
+
reset: () => {
|
|
212
|
+
engaged = false;
|
|
213
|
+
released = false;
|
|
214
|
+
entry = null;
|
|
215
|
+
pointerFocus = false;
|
|
216
|
+
sync();
|
|
217
|
+
},
|
|
218
|
+
destroy: () => controller.abort(),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -14,7 +14,60 @@ export type PuzzleDrag = {
|
|
|
14
14
|
moved: boolean;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
type
|
|
17
|
+
export type PuzzleDirection = 'north' | 'east' | 'south' | 'west';
|
|
18
|
+
|
|
19
|
+
export const puzzleArrowDirections: Readonly<
|
|
20
|
+
Partial<Record<string, PuzzleDirection>>
|
|
21
|
+
> = {
|
|
22
|
+
ArrowUp: 'north',
|
|
23
|
+
ArrowRight: 'east',
|
|
24
|
+
ArrowDown: 'south',
|
|
25
|
+
ArrowLeft: 'west',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function getPuzzleMoveTarget(
|
|
29
|
+
panel: HTMLElement | undefined,
|
|
30
|
+
event: MouseEvent,
|
|
31
|
+
): { pieceId: string; direction: PuzzleDirection } | null {
|
|
32
|
+
const target = event.target;
|
|
33
|
+
if (!(target instanceof Element) || !panel?.contains(target)) return null;
|
|
34
|
+
let cell = target.closest<HTMLButtonElement>('[data-puzzle-move-direction]');
|
|
35
|
+
if (
|
|
36
|
+
!cell &&
|
|
37
|
+
target instanceof HTMLElement &&
|
|
38
|
+
target.matches('[data-puzzle-grid]')
|
|
39
|
+
) {
|
|
40
|
+
const bounds = target.getBoundingClientRect();
|
|
41
|
+
const x = event.clientX - bounds.left - target.clientLeft;
|
|
42
|
+
const y = event.clientY - bounds.top - target.clientTop;
|
|
43
|
+
if (x < 0 || y < 0 || x >= target.clientWidth || y >= target.clientHeight)
|
|
44
|
+
return null;
|
|
45
|
+
const column = Math.floor(
|
|
46
|
+
(x * Number(target.dataset.puzzleColumns)) / target.clientWidth,
|
|
47
|
+
);
|
|
48
|
+
const row = Math.floor(
|
|
49
|
+
(y * Number(target.dataset.puzzleRows)) / target.clientHeight,
|
|
50
|
+
);
|
|
51
|
+
cell = target.querySelector<HTMLButtonElement>(
|
|
52
|
+
`[data-puzzle-move-direction][data-cell-row="${row}"][data-cell-column="${column}"]`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!cell || cell.disabled) return null;
|
|
56
|
+
const pieceId = cell.dataset.puzzleMovePiece;
|
|
57
|
+
const direction = cell.dataset.puzzleMoveDirection as PuzzleDirection;
|
|
58
|
+
return pieceId ? { pieceId, direction } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearPuzzleMovementPreview(panel: HTMLElement): void {
|
|
62
|
+
for (const stream of panel.querySelectorAll('[data-puzzle-path-stream]'))
|
|
63
|
+
stream.remove();
|
|
64
|
+
for (const cell of panel.querySelectorAll<HTMLElement>(
|
|
65
|
+
'[data-puzzle-move-direction]',
|
|
66
|
+
)) {
|
|
67
|
+
delete cell.dataset.puzzleMoveDirection;
|
|
68
|
+
delete cell.dataset.puzzleMovePiece;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
18
71
|
|
|
19
72
|
const pathChevronsPerCell = 3;
|
|
20
73
|
const pathDurationPerCellMs = 2100;
|
|
@@ -78,7 +131,7 @@ export function findDirectionalDropDestination(
|
|
|
78
131
|
deltaX: number,
|
|
79
132
|
deltaY: number,
|
|
80
133
|
): HTMLButtonElement | null {
|
|
81
|
-
let direction:
|
|
134
|
+
let direction: PuzzleDirection;
|
|
82
135
|
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
|
83
136
|
direction = deltaX >= 0 ? 'east' : 'west';
|
|
84
137
|
} else {
|
|
@@ -99,12 +152,23 @@ export function renderPathStream(
|
|
|
99
152
|
movement: {
|
|
100
153
|
from: { row: number; column: number };
|
|
101
154
|
to: { row: number; column: number };
|
|
102
|
-
direction:
|
|
155
|
+
direction: PuzzleDirection;
|
|
103
156
|
},
|
|
157
|
+
pieceId: string,
|
|
104
158
|
): void {
|
|
105
159
|
const rowDistance = movement.to.row - movement.from.row;
|
|
106
160
|
const columnDistance = movement.to.column - movement.from.column;
|
|
107
161
|
const cellDistance = Math.abs(rowDistance) + Math.abs(columnDistance);
|
|
162
|
+
for (let step = 1; step <= cellDistance; step += 1) {
|
|
163
|
+
const row = movement.from.row + Math.sign(rowDistance) * step;
|
|
164
|
+
const column = movement.from.column + Math.sign(columnDistance) * step;
|
|
165
|
+
const cell = board.querySelector<HTMLButtonElement>(
|
|
166
|
+
`[data-puzzle-cell][data-cell-row="${row}"][data-cell-column="${column}"]`,
|
|
167
|
+
);
|
|
168
|
+
if (!cell || cell.disabled) continue;
|
|
169
|
+
cell.dataset.puzzleMoveDirection = movement.direction;
|
|
170
|
+
cell.dataset.puzzleMovePiece = pieceId;
|
|
171
|
+
}
|
|
108
172
|
const chevronCount = pathChevronsPerCell * cellDistance;
|
|
109
173
|
const stream = document.createElement('span');
|
|
110
174
|
stream.className = 'puzzle-grid__path-stream';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
type PuzzleSelectionDismissalOptions = {
|
|
2
2
|
dismiss: () => void;
|
|
3
|
-
isSelectionTarget: (target: Element) => boolean;
|
|
3
|
+
isSelectionTarget: (target: Element, event: PointerEvent) => boolean;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
6
|
export function initPuzzleSelectionDismissal(
|
|
@@ -15,7 +15,7 @@ export function initPuzzleSelectionDismissal(
|
|
|
15
15
|
const target = event.target;
|
|
16
16
|
if (!(target instanceof Node)) return;
|
|
17
17
|
const element = target instanceof Element ? target : target.parentElement;
|
|
18
|
-
if (element && isSelectionTarget(element)) return;
|
|
18
|
+
if (element && isSelectionTarget(element, event)) return;
|
|
19
19
|
|
|
20
20
|
dismiss();
|
|
21
21
|
const focused = document.activeElement;
|