@astronox/core 0.5.5 → 0.5.7

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.
@@ -0,0 +1,802 @@
1
+ import {
2
+ setPuzzleGridCellState,
3
+ setPuzzleGridState,
4
+ } from '../PuzzleGrid/state';
5
+ import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
6
+ import {
7
+ animatePieceMovement,
8
+ cancelDragFrame,
9
+ findDirectionalDropDestination,
10
+ hasLeftOriginCell,
11
+ renderPathStream,
12
+ scheduleDragFrame,
13
+ type PuzzleDrag,
14
+ } from '../PuzzleGrid/movement';
15
+ import type {
16
+ SlidingDirection,
17
+ SlidingMovement,
18
+ SlidingPosition,
19
+ SlidingPuzzleChangeDetail,
20
+ SlidingPuzzleLabels,
21
+ SlidingPuzzleModel,
22
+ SlidingPuzzleStatus,
23
+ SlidingPuzzleState,
24
+ } from './contracts';
25
+ import {
26
+ getSlidingMoves,
27
+ isSlidingPuzzleComplete,
28
+ simulateSlidingMove,
29
+ createSlidingPuzzleState,
30
+ } from './simulation';
31
+
32
+ type Instance = {
33
+ activeIndex: number;
34
+ state: SlidingPuzzleState;
35
+ selected: boolean;
36
+ drag: PuzzleDrag | null;
37
+ terrainFrame: number | null;
38
+ motion: AbortController | null;
39
+ ignoreNextClick: boolean;
40
+ abort: AbortController;
41
+ dismiss: () => void;
42
+ };
43
+
44
+ const instances = new WeakMap<HTMLElement, Instance>();
45
+ const directions: Readonly<Partial<Record<string, SlidingDirection>>> = {
46
+ ArrowUp: 'north',
47
+ ArrowRight: 'east',
48
+ ArrowDown: 'south',
49
+ ArrowLeft: 'west',
50
+ };
51
+
52
+ export function initSlidingPuzzle(root: HTMLElement): void {
53
+ if (instances.has(root)) return;
54
+ const first = panels(root)[0];
55
+ if (!first) return;
56
+ const instance: Instance = {
57
+ activeIndex: 0,
58
+ state: createSlidingPuzzleState(model(first)),
59
+ selected: false,
60
+ drag: null,
61
+ terrainFrame: null,
62
+ motion: null,
63
+ ignoreNextClick: false,
64
+ abort: new AbortController(),
65
+ dismiss: () => {},
66
+ };
67
+ instances.set(root, instance);
68
+ const options = { signal: instance.abort.signal };
69
+ instance.dismiss = initPuzzleSelectionDismissal(root, {
70
+ dismiss: () => select(root, false),
71
+ isSelectionTarget: (target) => {
72
+ const panel = activePanel(root);
73
+ return Boolean(
74
+ panel &&
75
+ panel.dataset.puzzleStatus !== 'valid' &&
76
+ panel.contains(target) &&
77
+ target.closest('[data-sliding-piece], [data-sliding-destination]'),
78
+ );
79
+ },
80
+ });
81
+
82
+ root.addEventListener(
83
+ 'click',
84
+ (event) => {
85
+ if (!(event.target instanceof Element)) return;
86
+ if (instance.ignoreNextClick) {
87
+ instance.ignoreNextClick = false;
88
+ return;
89
+ }
90
+ if (event.target.closest('[data-sliding-piece]')) {
91
+ select(root, !instance.selected);
92
+ return;
93
+ }
94
+ const destination = event.target.closest<HTMLButtonElement>(
95
+ '[data-sliding-destination]',
96
+ );
97
+ const direction = destination?.dataset.destinationDirection as
98
+ SlidingDirection | undefined;
99
+ if (destination && !destination.disabled && direction)
100
+ void move(root, direction);
101
+ else select(root, false);
102
+ },
103
+ options,
104
+ );
105
+
106
+ root.addEventListener(
107
+ 'keydown',
108
+ (event) => {
109
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
110
+ if (event.key === 'Escape') {
111
+ cancelDrag(instance);
112
+ select(root, false);
113
+ return;
114
+ }
115
+ const direction = directions[event.key];
116
+ if (!direction || !(event.target instanceof Element)) return;
117
+ if (!instance.selected && !event.target.closest('[data-sliding-piece]'))
118
+ return;
119
+ event.preventDefault();
120
+ void move(root, direction);
121
+ },
122
+ options,
123
+ );
124
+
125
+ root.addEventListener(
126
+ 'dragstart',
127
+ (event) => event.preventDefault(),
128
+ options,
129
+ );
130
+ root.addEventListener(
131
+ 'pointerdown',
132
+ (event) => {
133
+ if (
134
+ !event.isPrimary ||
135
+ event.button !== 0 ||
136
+ !(event.target instanceof Element)
137
+ )
138
+ return;
139
+ const panel = activePanel(root);
140
+ const piece = event.target.closest<HTMLButtonElement>(
141
+ '[data-sliding-piece]',
142
+ );
143
+ if (
144
+ !panel ||
145
+ !piece ||
146
+ panel.dataset.puzzleStatus === 'valid' ||
147
+ instance.motion ||
148
+ isMoving(piece)
149
+ )
150
+ return;
151
+ const origin = cell(panel, instance.state.position);
152
+ if (!origin) return;
153
+ const bounds = piece.getBoundingClientRect();
154
+ piece.setPointerCapture(event.pointerId);
155
+ instance.drag = {
156
+ pointerId: event.pointerId,
157
+ pieceId: 'piece',
158
+ element: piece,
159
+ startX: event.clientX,
160
+ startY: event.clientY,
161
+ currentX: event.clientX,
162
+ currentY: event.clientY,
163
+ frameId: null,
164
+ originCellBounds: origin.getBoundingClientRect(),
165
+ originCenterX: (bounds.left + bounds.right) / 2,
166
+ originCenterY: (bounds.top + bounds.bottom) / 2,
167
+ destinations: null,
168
+ moved: false,
169
+ };
170
+ },
171
+ options,
172
+ );
173
+
174
+ root.addEventListener(
175
+ 'pointermove',
176
+ (event) => {
177
+ const drag = instance.drag;
178
+ if (!drag || drag.pointerId !== event.pointerId) return;
179
+ if (!drag.moved) {
180
+ drag.moved =
181
+ Math.hypot(
182
+ event.clientX - drag.startX,
183
+ event.clientY - drag.startY,
184
+ ) >= 12;
185
+ if (!drag.moved) return;
186
+ drag.element.setAttribute('data-piece-dragging', '');
187
+ select(root, true);
188
+ }
189
+ event.preventDefault();
190
+ drag.destinations ??= [
191
+ ...(activePanel(root)?.querySelectorAll<HTMLButtonElement>(
192
+ '[data-sliding-destination]',
193
+ ) ?? []),
194
+ ];
195
+ drag.currentX = event.clientX;
196
+ drag.currentY = event.clientY;
197
+ scheduleDragFrame(drag, () => instance.drag === drag);
198
+ },
199
+ options,
200
+ );
201
+
202
+ const finish = (event: PointerEvent, commit: boolean): void => {
203
+ const drag = instance.drag;
204
+ if (!drag || drag.pointerId !== event.pointerId) return;
205
+ const dx = event.clientX - drag.startX;
206
+ const dy = event.clientY - drag.startY;
207
+ const destination =
208
+ commit && drag.moved && hasLeftOriginCell(drag, dx, dy)
209
+ ? findDirectionalDropDestination(drag, dx, dy)
210
+ : null;
211
+ cancelDrag(instance);
212
+ if (commit && drag.moved) {
213
+ instance.ignoreNextClick = true;
214
+ window.setTimeout(() => {
215
+ instance.ignoreNextClick = false;
216
+ }, 0);
217
+ const direction = destination?.dataset.destinationDirection as
218
+ SlidingDirection | undefined;
219
+ if (direction) void move(root, direction, false);
220
+ }
221
+ if (drag.moved) select(root, false);
222
+ };
223
+ root.addEventListener('pointerup', (event) => finish(event, true), options);
224
+ root.addEventListener(
225
+ 'pointercancel',
226
+ (event) => finish(event, false),
227
+ options,
228
+ );
229
+ root.addEventListener(
230
+ 'lostpointercapture',
231
+ () => cancelDrag(instance),
232
+ options,
233
+ );
234
+ }
235
+
236
+ export function destroySlidingPuzzle(root: HTMLElement): void {
237
+ const instance = instances.get(root);
238
+ if (!instance) return;
239
+ cancelDrag(instance);
240
+ cancelMotion(instance);
241
+ cancelTerrainFrame(instance);
242
+ instance.dismiss();
243
+ instance.abort.abort();
244
+ instances.delete(root);
245
+ }
246
+
247
+ export function selectSlidingPuzzle(root: HTMLElement, index: number): void {
248
+ initSlidingPuzzle(root);
249
+ const instance = instances.get(root);
250
+ if (!instance) return;
251
+ const entries = panels(root);
252
+ instance.activeIndex = Math.max(0, Math.min(index, entries.length - 1));
253
+ entries.forEach((panel, i) => {
254
+ panel.hidden = i !== instance.activeIndex;
255
+ });
256
+ resetSlidingPuzzle(root);
257
+ }
258
+
259
+ export function resetSlidingPuzzle(root: HTMLElement): void {
260
+ initSlidingPuzzle(root);
261
+ const panel = activePanel(root);
262
+ const instance = instances.get(root);
263
+ if (!panel || !instance) return;
264
+ cancelDrag(instance);
265
+ cancelMotion(instance);
266
+ cancelTerrainFrame(instance);
267
+ instance.selected = false;
268
+ instance.ignoreNextClick = false;
269
+ instance.state = createSlidingPuzzleState(model(panel));
270
+ setSlidingPuzzleStatus(root, panel.dataset.puzzleId ?? '', 'incomplete');
271
+ render(root);
272
+ previews(root);
273
+ emitChange(root);
274
+ }
275
+
276
+ export function setSlidingPuzzleStatus(
277
+ root: HTMLElement,
278
+ puzzleId: string,
279
+ status: SlidingPuzzleStatus,
280
+ ): void {
281
+ const panel = panels(root).find(
282
+ (entry) => entry.dataset.puzzleId === puzzleId,
283
+ );
284
+ if (!panel) return;
285
+ const previous = panel.dataset.puzzleStatus;
286
+ panel.dataset.puzzleStatus = status;
287
+ const board = panel.querySelector<HTMLElement>('[data-puzzle-grid]');
288
+ if (board) setPuzzleGridState(board, status === 'valid' ? 'valid' : null);
289
+ if (status === 'valid') {
290
+ const instance = instances.get(root);
291
+ if (instance && panel === activePanel(root)) instance.selected = false;
292
+ clearPreviews(panel);
293
+ const piece = panel.querySelector<HTMLButtonElement>(
294
+ '[data-sliding-piece]',
295
+ );
296
+ piece?.removeAttribute('data-piece-selected');
297
+ piece?.setAttribute('aria-pressed', 'false');
298
+ }
299
+ if (status === 'valid' && previous !== status)
300
+ root.dispatchEvent(
301
+ new CustomEvent('puzzle:complete', {
302
+ bubbles: true,
303
+ detail: { kind: 'sliding', puzzleId },
304
+ }),
305
+ );
306
+ }
307
+
308
+ function select(root: HTMLElement, selected: boolean): void {
309
+ const instance = instances.get(root);
310
+ const panel = activePanel(root);
311
+ if (
312
+ !instance ||
313
+ !panel ||
314
+ instance.motion ||
315
+ panel.dataset.puzzleStatus === 'valid'
316
+ )
317
+ return;
318
+ instance.selected = selected;
319
+ previews(root);
320
+ }
321
+
322
+ async function move(
323
+ root: HTMLElement,
324
+ direction: SlidingDirection,
325
+ retainSelection = true,
326
+ ): Promise<void> {
327
+ const instance = instances.get(root);
328
+ const panel = activePanel(root);
329
+ const piece = panel?.querySelector<HTMLButtonElement>('[data-sliding-piece]');
330
+ if (
331
+ !instance ||
332
+ !panel ||
333
+ !piece ||
334
+ instance.drag ||
335
+ instance.motion ||
336
+ isMoving(piece) ||
337
+ panel.dataset.puzzleStatus === 'valid'
338
+ )
339
+ return;
340
+ const puzzle = model(panel);
341
+ const movement = simulateSlidingMove(puzzle, instance.state, direction);
342
+ if (!movement) return;
343
+ const motion = new AbortController();
344
+ instance.motion = motion;
345
+ piece.setAttribute('aria-busy', 'true');
346
+ const clearBusy = (): void => piece.removeAttribute('aria-busy');
347
+ motion.signal.addEventListener('abort', clearBusy, { once: true });
348
+ cancelTerrainFrame(instance);
349
+ const previousState = instance.state;
350
+ renderTerrain(panel, previousState);
351
+ const bounds = piece.getBoundingClientRect();
352
+ instance.state = movement.state;
353
+ instance.selected =
354
+ retainSelection && !isSlidingPuzzleComplete(puzzle, movement.state);
355
+ render(root, false, movement.slideTo);
356
+ if (!movement.teleported || !instance.selected) previews(root);
357
+ try {
358
+ if (
359
+ !(await animateSlide(
360
+ panel,
361
+ piece,
362
+ instance,
363
+ movement,
364
+ previousState,
365
+ bounds,
366
+ motion.signal,
367
+ ))
368
+ )
369
+ return;
370
+ if (movement.teleported) {
371
+ clearPreviews(panel);
372
+ if (
373
+ !(await animateHoleTransfer(
374
+ root,
375
+ piece,
376
+ puzzle,
377
+ movement,
378
+ motion.signal,
379
+ ))
380
+ )
381
+ return;
382
+ }
383
+ if (motion.signal.aborted) return;
384
+ instance.motion = null;
385
+ render(root);
386
+ if (movement.teleported) previews(root);
387
+ emitChange(root);
388
+ } finally {
389
+ motion.signal.removeEventListener('abort', clearBusy);
390
+ if (instance.motion === motion) instance.motion = null;
391
+ if (!motion.signal.aborted) clearBusy();
392
+ }
393
+ }
394
+
395
+ async function animateHoleTransfer(
396
+ root: HTMLElement,
397
+ piece: HTMLButtonElement,
398
+ puzzle: SlidingPuzzleModel,
399
+ movement: SlidingMovement,
400
+ signal: AbortSignal,
401
+ ): Promise<boolean> {
402
+ if (signal.aborted) return false;
403
+ const clear = (): void => piece.removeAttribute('data-piece-transferring');
404
+ piece.setAttribute('data-piece-transferring', '');
405
+ signal.addEventListener('abort', clear, { once: true });
406
+ try {
407
+ if (!(await animateTransferPhase(piece, 'fall', signal))) return false;
408
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
409
+ return true;
410
+ const hole = puzzle.holes.find(
411
+ ({ position }) =>
412
+ position.row === movement.slideTo.row &&
413
+ position.column === movement.slideTo.column,
414
+ )!;
415
+ render(root, false, hole.exit);
416
+ const panel = activePanel(root);
417
+ if (panel) renderExitMarkers(panel, movement.state);
418
+ if (!(await animateTransferPhase(piece, 'arrive', signal))) return false;
419
+ if (!(await animateLanding(piece, signal))) return false;
420
+ const exitBounds = piece.getBoundingClientRect();
421
+ render(root, false);
422
+ const landingBounds = piece.getBoundingClientRect();
423
+ return await animatePieceTransition(
424
+ piece,
425
+ [
426
+ {
427
+ transform: `translate3d(${exitBounds.left - landingBounds.left}px, ${exitBounds.top - landingBounds.top}px, 0)`,
428
+ },
429
+ { transform: 'translate3d(0, 0, 0)' },
430
+ ],
431
+ 120,
432
+ signal,
433
+ );
434
+ } finally {
435
+ signal.removeEventListener('abort', clear);
436
+ clear();
437
+ }
438
+ }
439
+
440
+ function animateSlide(
441
+ panel: HTMLElement,
442
+ piece: HTMLButtonElement,
443
+ instance: Instance,
444
+ movement: SlidingMovement,
445
+ previousState: SlidingPuzzleState,
446
+ bounds: DOMRect,
447
+ signal: AbortSignal,
448
+ ): Promise<boolean> {
449
+ const finalBounds = piece.getBoundingClientRect();
450
+ animatePieceMovement(piece, bounds);
451
+ const horizontal = movement.from.column !== movement.slideTo.column;
452
+ const start = horizontal ? bounds.left : bounds.top;
453
+ const travel = (horizontal ? finalBounds.left : finalBounds.top) - start;
454
+ const distance =
455
+ Math.abs(movement.slideTo.row - movement.from.row) +
456
+ Math.abs(movement.slideTo.column - movement.from.column);
457
+ let revealedCount = previousState.collapsedCells.length;
458
+ let revealedHoles = previousState.openHoles.length;
459
+ return new Promise((resolve) => {
460
+ const stop = (): void => {
461
+ cancelTerrainFrame(instance);
462
+ piece.getAnimations().forEach((animation) => animation.cancel());
463
+ signal.removeEventListener('abort', stop);
464
+ resolve(false);
465
+ };
466
+ signal.addEventListener('abort', stop, { once: true });
467
+ const revealTerrain = (): void => {
468
+ instance.terrainFrame = null;
469
+ if (signal.aborted) {
470
+ stop();
471
+ return;
472
+ }
473
+ const moving = isMoving(piece);
474
+ const currentBounds = piece.getBoundingClientRect();
475
+ const progress =
476
+ moving && travel !== 0
477
+ ? ((horizontal ? currentBounds.left : currentBounds.top) - start) /
478
+ travel
479
+ : 1;
480
+ const collapsedCells = movement.state.collapsedCells.filter(
481
+ (position) => {
482
+ if (
483
+ previousState.collapsedCells.some(
484
+ (previous) =>
485
+ previous.row === position.row &&
486
+ previous.column === position.column,
487
+ )
488
+ )
489
+ return true;
490
+ const steps =
491
+ Math.abs(position.row - movement.from.row) +
492
+ Math.abs(position.column - movement.from.column);
493
+ return progress >= (steps - 0.5) / distance;
494
+ },
495
+ );
496
+ const openHoles =
497
+ movement.teleported && progress >= (distance - 0.5) / distance
498
+ ? movement.state.openHoles
499
+ : previousState.openHoles;
500
+ if (
501
+ collapsedCells.length !== revealedCount ||
502
+ openHoles.length !== revealedHoles ||
503
+ !moving
504
+ ) {
505
+ renderTerrain(panel, {
506
+ position: movement.slideTo,
507
+ collapsedCells,
508
+ openHoles,
509
+ });
510
+ revealedCount = collapsedCells.length;
511
+ revealedHoles = openHoles.length;
512
+ }
513
+ if (moving)
514
+ instance.terrainFrame = window.requestAnimationFrame(revealTerrain);
515
+ else {
516
+ signal.removeEventListener('abort', stop);
517
+ resolve(true);
518
+ }
519
+ };
520
+ revealTerrain();
521
+ });
522
+ }
523
+
524
+ function animateTransferPhase(
525
+ piece: HTMLButtonElement,
526
+ phase: 'fall' | 'arrive',
527
+ signal: AbortSignal,
528
+ ): Promise<boolean> {
529
+ const hidden = { opacity: 0, transform: 'scale(0.15)' };
530
+ const visible = { opacity: 1, transform: 'scale(1)' };
531
+ return animatePieceTransition(
532
+ piece,
533
+ phase === 'fall' ? [visible, hidden] : [hidden, visible],
534
+ 160,
535
+ signal,
536
+ phase === 'fall' ? 'ease-out' : 'ease-in',
537
+ );
538
+ }
539
+
540
+ function animateLanding(
541
+ piece: HTMLButtonElement,
542
+ signal: AbortSignal,
543
+ ): Promise<boolean> {
544
+ return animatePieceTransition(
545
+ piece,
546
+ [
547
+ { transform: 'scale(1)', transformOrigin: '50% 90%' },
548
+ {
549
+ transform: 'scale(1.04, 0.94)',
550
+ transformOrigin: '50% 90%',
551
+ offset: 0.35,
552
+ },
553
+ { transform: 'scale(1)', transformOrigin: '50% 90%' },
554
+ ],
555
+ 150,
556
+ signal,
557
+ );
558
+ }
559
+
560
+ function animatePieceTransition(
561
+ piece: HTMLButtonElement,
562
+ keyframes: Keyframe[],
563
+ duration: number,
564
+ signal: AbortSignal,
565
+ easing: 'ease-out' | 'ease-in' = 'ease-out',
566
+ ): Promise<boolean> {
567
+ if (signal.aborted) return Promise.resolve(false);
568
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
569
+ return Promise.resolve(true);
570
+ const animation = piece.animate(keyframes, {
571
+ duration,
572
+ easing,
573
+ fill: 'forwards',
574
+ });
575
+ const cancel = (): void => animation.cancel();
576
+ signal.addEventListener('abort', cancel, { once: true });
577
+ return animation.finished
578
+ .then(
579
+ () => !signal.aborted,
580
+ () => false,
581
+ )
582
+ .finally(() => {
583
+ signal.removeEventListener('abort', cancel);
584
+ animation.cancel();
585
+ });
586
+ }
587
+
588
+ function render(
589
+ root: HTMLElement,
590
+ updateTerrain = true,
591
+ position?: SlidingPosition,
592
+ ): void {
593
+ const instance = instances.get(root);
594
+ const panel = activePanel(root);
595
+ const piece = panel?.querySelector<HTMLButtonElement>('[data-sliding-piece]');
596
+ if (!instance || !panel || !piece) return;
597
+ piece.getAnimations().forEach((animation) => animation.cancel());
598
+ piece.removeAttribute('data-piece-moved');
599
+ piece.style.setProperty(
600
+ '--piece-row',
601
+ String((position ?? instance.state.position).row + 1),
602
+ );
603
+ piece.style.setProperty(
604
+ '--piece-column',
605
+ String((position ?? instance.state.position).column + 1),
606
+ );
607
+ if (updateTerrain) {
608
+ renderTerrain(panel, instance.state);
609
+ renderExitMarkers(panel, instance.state);
610
+ }
611
+ const target = panel.querySelector<HTMLElement>('[data-sliding-target]');
612
+ if (target && updateTerrain)
613
+ setPuzzleGridCellState(
614
+ target,
615
+ isSlidingPuzzleComplete(model(panel), instance.state) ? 'valid' : null,
616
+ );
617
+ }
618
+
619
+ function renderExitMarkers(
620
+ panel: HTMLElement,
621
+ state: SlidingPuzzleState,
622
+ ): void {
623
+ const holes = model(panel).holes;
624
+ const discoveredExits = holes
625
+ .filter((hole) =>
626
+ state.openHoles.some(
627
+ (position) =>
628
+ position.row === hole.position.row &&
629
+ position.column === hole.position.column,
630
+ ),
631
+ )
632
+ .map((hole) => hole.exit);
633
+ for (const { exit } of holes) {
634
+ const discovered = discoveredExits.some(
635
+ (position) =>
636
+ position.row === exit.row && position.column === exit.column,
637
+ );
638
+ cell(panel, exit)?.toggleAttribute(
639
+ 'data-sliding-exit-discovered',
640
+ discovered,
641
+ );
642
+ }
643
+ }
644
+
645
+ function renderTerrain(panel: HTMLElement, state: SlidingPuzzleState): void {
646
+ const puzzle = model(panel);
647
+ const labels = JSON.parse(
648
+ panel.dataset.slidingLabels ?? '{}',
649
+ ) as SlidingPuzzleLabels;
650
+ for (const fragile of puzzle.fragileCells) {
651
+ const element = cell(panel, fragile);
652
+ if (!element) continue;
653
+ const collapsed = state.collapsedCells.some(
654
+ (position) =>
655
+ position.row === fragile.row && position.column === fragile.column,
656
+ );
657
+ element.disabled = collapsed;
658
+ element.toggleAttribute('data-sliding-collapsed', collapsed);
659
+ element.toggleAttribute('data-puzzle-cell-disabled', collapsed);
660
+ element.dataset.cellLabel = collapsed
661
+ ? labels.collapsedCell
662
+ : labels.fragileCell;
663
+ if (!element.hasAttribute('data-sliding-destination'))
664
+ element.setAttribute('aria-label', element.dataset.cellLabel);
665
+ }
666
+ for (const hole of puzzle.holes) {
667
+ const element = cell(panel, hole.position);
668
+ if (!element) continue;
669
+ const open = state.openHoles.some(
670
+ (position) =>
671
+ position.row === hole.position.row &&
672
+ position.column === hole.position.column,
673
+ );
674
+ element.toggleAttribute('data-sliding-hole-open', open);
675
+ element.dataset.cellLabel = open ? labels.hole : labels.fragileCell;
676
+ if (!element.hasAttribute('data-sliding-destination'))
677
+ element.setAttribute('aria-label', element.dataset.cellLabel);
678
+ }
679
+ }
680
+
681
+ function previews(root: HTMLElement): void {
682
+ const instance = instances.get(root);
683
+ const panel = activePanel(root);
684
+ if (!instance || !panel) return;
685
+ clearPreviews(panel);
686
+ const piece = panel.querySelector<HTMLElement>('[data-sliding-piece]');
687
+ piece?.toggleAttribute('data-piece-selected', instance.selected);
688
+ piece?.setAttribute('aria-pressed', String(instance.selected));
689
+ if (!instance.selected) return;
690
+ const board = panel.querySelector<HTMLElement>('[data-puzzle-grid]');
691
+ const labels = JSON.parse(
692
+ panel.dataset.slidingLabels ?? '{}',
693
+ ) as SlidingPuzzleLabels;
694
+ const previewPuzzle = { ...model(panel), holes: [] };
695
+ for (const movement of getSlidingMoves(previewPuzzle, instance.state)) {
696
+ if (board) renderPathStream(board, { ...movement, to: movement.slideTo });
697
+ const destination = cell(panel, movement.slideTo);
698
+ if (!destination) continue;
699
+ destination.removeAttribute('tabindex');
700
+ destination.setAttribute('data-sliding-destination', '');
701
+ destination.setAttribute('data-puzzle-cell-interactive', '');
702
+ destination.dataset.destinationPiece = 'piece';
703
+ destination.dataset.destinationDirection = movement.direction;
704
+ destination.setAttribute(
705
+ 'aria-label',
706
+ labels.moveTemplate
707
+ .replaceAll('{piece}', labels.piece)
708
+ .replaceAll('{direction}', labels.directions[movement.direction]),
709
+ );
710
+ }
711
+ }
712
+
713
+ function clearPreviews(panel: HTMLElement): void {
714
+ for (const stream of panel.querySelectorAll('[data-puzzle-path-stream]'))
715
+ stream.remove();
716
+ for (const destination of panel.querySelectorAll<HTMLButtonElement>(
717
+ '[data-sliding-destination]',
718
+ )) {
719
+ destination.tabIndex = -1;
720
+ destination.removeAttribute('data-sliding-destination');
721
+ destination.removeAttribute('data-puzzle-cell-interactive');
722
+ delete destination.dataset.destinationDirection;
723
+ delete destination.dataset.destinationPiece;
724
+ if (destination.dataset.cellLabel)
725
+ destination.setAttribute('aria-label', destination.dataset.cellLabel);
726
+ else destination.removeAttribute('aria-label');
727
+ }
728
+ }
729
+
730
+ function cancelDrag(instance: Instance): void {
731
+ const drag = instance.drag;
732
+ if (!drag) return;
733
+ instance.drag = null;
734
+ cancelDragFrame(drag);
735
+ drag.element.removeAttribute('data-piece-dragging');
736
+ drag.element.style.removeProperty('--piece-drag-x');
737
+ drag.element.style.removeProperty('--piece-drag-y');
738
+ if (drag.element.hasPointerCapture(drag.pointerId))
739
+ drag.element.releasePointerCapture(drag.pointerId);
740
+ }
741
+
742
+ function cancelTerrainFrame(instance: Instance): void {
743
+ if (instance.terrainFrame === null) return;
744
+ window.cancelAnimationFrame(instance.terrainFrame);
745
+ instance.terrainFrame = null;
746
+ }
747
+
748
+ function cancelMotion(instance: Instance): void {
749
+ instance.motion?.abort();
750
+ instance.motion = null;
751
+ }
752
+
753
+ function isMoving(piece: HTMLElement): boolean {
754
+ return piece
755
+ .getAnimations()
756
+ .some((animation) => animation.playState === 'running');
757
+ }
758
+
759
+ function emitChange(root: HTMLElement): void {
760
+ const panel = activePanel(root);
761
+ const instance = instances.get(root);
762
+ if (!panel || !instance) return;
763
+ root.dispatchEvent(
764
+ new CustomEvent<SlidingPuzzleChangeDetail>('puzzle:change', {
765
+ bubbles: true,
766
+ detail: {
767
+ kind: 'sliding',
768
+ puzzleId: model(panel).id,
769
+ state: {
770
+ position: { ...instance.state.position },
771
+ collapsedCells: instance.state.collapsedCells.map((position) => ({
772
+ ...position,
773
+ })),
774
+ openHoles: instance.state.openHoles.map((position) => ({
775
+ ...position,
776
+ })),
777
+ },
778
+ },
779
+ }),
780
+ );
781
+ }
782
+
783
+ function model(panel: HTMLElement): SlidingPuzzleModel {
784
+ return JSON.parse(panel.dataset.slidingModel ?? '{}') as SlidingPuzzleModel;
785
+ }
786
+
787
+ function panels(root: HTMLElement): HTMLElement[] {
788
+ return [...root.querySelectorAll<HTMLElement>(':scope > [data-puzzle-id]')];
789
+ }
790
+
791
+ function activePanel(root: HTMLElement): HTMLElement | undefined {
792
+ return panels(root)[instances.get(root)?.activeIndex ?? 0];
793
+ }
794
+
795
+ function cell(
796
+ panel: HTMLElement,
797
+ position: SlidingPosition,
798
+ ): HTMLButtonElement | null {
799
+ return panel.querySelector(
800
+ `[data-sliding-cell][data-cell-row="${position.row}"][data-cell-column="${position.column}"]`,
801
+ );
802
+ }