@astronox/core 0.5.5 → 0.5.6

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,703 @@
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
+ // Ordinary slides expose the next moves immediately, like the magnetic
357
+ // puzzle. A transfer keeps the current preview until the piece reappears,
358
+ // so outgoing paths do not disclose its hidden landing position early.
359
+ if (!movement.teleported || !instance.selected) previews(root);
360
+ try {
361
+ if (
362
+ !(await animateSlide(
363
+ panel,
364
+ piece,
365
+ instance,
366
+ movement,
367
+ previousState,
368
+ bounds,
369
+ motion.signal,
370
+ ))
371
+ )
372
+ return;
373
+ if (movement.teleported) {
374
+ if (!(await animateTransfer(piece, 'fall', motion.signal))) return;
375
+ render(root, false);
376
+ previews(root);
377
+ if (!(await animateTransfer(piece, 'arrive', motion.signal))) return;
378
+ }
379
+ if (motion.signal.aborted) return;
380
+ instance.motion = null;
381
+ render(root);
382
+ emitChange(root);
383
+ } finally {
384
+ motion.signal.removeEventListener('abort', clearBusy);
385
+ if (instance.motion === motion) instance.motion = null;
386
+ if (!motion.signal.aborted) clearBusy();
387
+ }
388
+ }
389
+
390
+ function animateSlide(
391
+ panel: HTMLElement,
392
+ piece: HTMLButtonElement,
393
+ instance: Instance,
394
+ movement: SlidingMovement,
395
+ previousState: SlidingPuzzleState,
396
+ bounds: DOMRect,
397
+ signal: AbortSignal,
398
+ ): Promise<boolean> {
399
+ const finalBounds = piece.getBoundingClientRect();
400
+ animatePieceMovement(piece, bounds);
401
+ // Follow the actual slide so each tile changes as the piece enters it,
402
+ // including the tile where it stops. No delayed collapse on departure.
403
+ const horizontal = movement.from.column !== movement.slideTo.column;
404
+ const start = horizontal ? bounds.left : bounds.top;
405
+ const travel = (horizontal ? finalBounds.left : finalBounds.top) - start;
406
+ const distance =
407
+ Math.abs(movement.slideTo.row - movement.from.row) +
408
+ Math.abs(movement.slideTo.column - movement.from.column);
409
+ let revealedCount = previousState.collapsedCells.length;
410
+ let revealedHoles = previousState.openHoles.length;
411
+ return new Promise((resolve) => {
412
+ const stop = (): void => {
413
+ cancelTerrainFrame(instance);
414
+ piece.getAnimations().forEach((animation) => animation.cancel());
415
+ signal.removeEventListener('abort', stop);
416
+ resolve(false);
417
+ };
418
+ signal.addEventListener('abort', stop, { once: true });
419
+ const revealTerrain = (): void => {
420
+ instance.terrainFrame = null;
421
+ if (signal.aborted) {
422
+ stop();
423
+ return;
424
+ }
425
+ const moving = isMoving(piece);
426
+ const currentBounds = piece.getBoundingClientRect();
427
+ const progress =
428
+ moving && travel !== 0
429
+ ? ((horizontal ? currentBounds.left : currentBounds.top) - start) /
430
+ travel
431
+ : 1;
432
+ const collapsedCells = movement.state.collapsedCells.filter(
433
+ (position) => {
434
+ if (
435
+ previousState.collapsedCells.some(
436
+ (previous) =>
437
+ previous.row === position.row &&
438
+ previous.column === position.column,
439
+ )
440
+ )
441
+ return true;
442
+ const steps =
443
+ Math.abs(position.row - movement.from.row) +
444
+ Math.abs(position.column - movement.from.column);
445
+ return progress >= (steps - 0.5) / distance;
446
+ },
447
+ );
448
+ const openHoles =
449
+ movement.teleported && progress >= (distance - 0.5) / distance
450
+ ? movement.state.openHoles
451
+ : previousState.openHoles;
452
+ if (
453
+ collapsedCells.length !== revealedCount ||
454
+ openHoles.length !== revealedHoles ||
455
+ !moving
456
+ ) {
457
+ renderTerrain(panel, {
458
+ position: movement.slideTo,
459
+ collapsedCells,
460
+ openHoles,
461
+ });
462
+ revealedCount = collapsedCells.length;
463
+ revealedHoles = openHoles.length;
464
+ }
465
+ if (moving)
466
+ instance.terrainFrame = window.requestAnimationFrame(revealTerrain);
467
+ else {
468
+ signal.removeEventListener('abort', stop);
469
+ resolve(true);
470
+ }
471
+ };
472
+ revealTerrain();
473
+ });
474
+ }
475
+
476
+ function animateTransfer(
477
+ piece: HTMLButtonElement,
478
+ phase: 'fall' | 'arrive',
479
+ signal: AbortSignal,
480
+ ): Promise<boolean> {
481
+ if (signal.aborted) return Promise.resolve(false);
482
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
483
+ return Promise.resolve(true);
484
+ const hidden = { opacity: 0, transform: 'scale(0.15)' };
485
+ const visible = { opacity: 1, transform: 'scale(1)' };
486
+ const animation = piece.animate(
487
+ phase === 'fall' ? [visible, hidden] : [hidden, visible],
488
+ {
489
+ duration: 160,
490
+ easing: 'ease-out',
491
+ fill: 'forwards',
492
+ },
493
+ );
494
+ const cancel = (): void => animation.cancel();
495
+ signal.addEventListener('abort', cancel, { once: true });
496
+ return animation.finished
497
+ .then(
498
+ () => !signal.aborted,
499
+ () => false,
500
+ )
501
+ .finally(() => {
502
+ signal.removeEventListener('abort', cancel);
503
+ animation.cancel();
504
+ });
505
+ }
506
+
507
+ function render(
508
+ root: HTMLElement,
509
+ updateTerrain = true,
510
+ position?: SlidingPosition,
511
+ ): void {
512
+ const instance = instances.get(root);
513
+ const panel = activePanel(root);
514
+ const piece = panel?.querySelector<HTMLButtonElement>('[data-sliding-piece]');
515
+ if (!instance || !panel || !piece) return;
516
+ piece.getAnimations().forEach((animation) => animation.cancel());
517
+ piece.removeAttribute('data-piece-moved');
518
+ piece.style.setProperty(
519
+ '--piece-row',
520
+ String((position ?? instance.state.position).row + 1),
521
+ );
522
+ piece.style.setProperty(
523
+ '--piece-column',
524
+ String((position ?? instance.state.position).column + 1),
525
+ );
526
+ if (updateTerrain) renderTerrain(panel, instance.state);
527
+ const target = panel.querySelector<HTMLElement>('[data-sliding-target]');
528
+ if (target && updateTerrain)
529
+ setPuzzleGridCellState(
530
+ target,
531
+ isSlidingPuzzleComplete(model(panel), instance.state) ? 'valid' : null,
532
+ );
533
+ }
534
+
535
+ function renderTerrain(panel: HTMLElement, state: SlidingPuzzleState): void {
536
+ const labels = JSON.parse(
537
+ panel.dataset.slidingLabels ?? '{}',
538
+ ) as SlidingPuzzleLabels;
539
+ for (const fragile of model(panel).fragileCells) {
540
+ const element = cell(panel, fragile);
541
+ if (!element) continue;
542
+ const collapsed = state.collapsedCells.some(
543
+ (position) =>
544
+ position.row === fragile.row && position.column === fragile.column,
545
+ );
546
+ element.disabled = collapsed;
547
+ element.toggleAttribute('data-sliding-collapsed', collapsed);
548
+ element.toggleAttribute('data-puzzle-cell-disabled', collapsed);
549
+ element.dataset.cellLabel = collapsed
550
+ ? labels.collapsedCell
551
+ : labels.fragileCell;
552
+ if (!element.hasAttribute('data-sliding-destination'))
553
+ element.setAttribute('aria-label', element.dataset.cellLabel);
554
+ }
555
+ for (const hole of model(panel).holes) {
556
+ const element = cell(panel, hole.position);
557
+ if (!element) continue;
558
+ const open = state.openHoles.some(
559
+ (position) =>
560
+ position.row === hole.position.row &&
561
+ position.column === hole.position.column,
562
+ );
563
+ element.toggleAttribute('data-sliding-hole-open', open);
564
+ element.dataset.cellLabel = open ? labels.hole : labels.fragileCell;
565
+ if (!element.hasAttribute('data-sliding-destination'))
566
+ element.setAttribute('aria-label', element.dataset.cellLabel);
567
+ }
568
+ }
569
+
570
+ function previews(root: HTMLElement): void {
571
+ const instance = instances.get(root);
572
+ const panel = activePanel(root);
573
+ if (!instance || !panel) return;
574
+ clearPreviews(panel);
575
+ const piece = panel.querySelector<HTMLElement>('[data-sliding-piece]');
576
+ piece?.toggleAttribute('data-piece-selected', instance.selected);
577
+ piece?.setAttribute('aria-pressed', String(instance.selected));
578
+ if (!instance.selected) return;
579
+ const board = panel.querySelector<HTMLElement>('[data-puzzle-grid]');
580
+ const labels = JSON.parse(
581
+ panel.dataset.slidingLabels ?? '{}',
582
+ ) as SlidingPuzzleLabels;
583
+ // Before discovery, preview ordinary ice. Do not expose the hidden hole
584
+ // through path lengths, destination markers or accessible move labels.
585
+ const puzzle = model(panel);
586
+ const previewPuzzle = {
587
+ ...puzzle,
588
+ holes: puzzle.holes.filter((hole) =>
589
+ instance.state.openHoles.some(
590
+ (position) =>
591
+ position.row === hole.position.row &&
592
+ position.column === hole.position.column,
593
+ ),
594
+ ),
595
+ };
596
+ for (const movement of getSlidingMoves(previewPuzzle, instance.state)) {
597
+ if (board) renderPathStream(board, { ...movement, to: movement.slideTo });
598
+ const destination = cell(panel, movement.slideTo);
599
+ if (!destination) continue;
600
+ destination.removeAttribute('tabindex');
601
+ destination.setAttribute('data-sliding-destination', '');
602
+ destination.setAttribute('data-puzzle-cell-interactive', '');
603
+ destination.dataset.destinationPiece = 'piece';
604
+ destination.dataset.destinationDirection = movement.direction;
605
+ destination.setAttribute(
606
+ 'aria-label',
607
+ labels.moveTemplate
608
+ .replaceAll('{piece}', labels.piece)
609
+ .replaceAll('{direction}', labels.directions[movement.direction]),
610
+ );
611
+ }
612
+ }
613
+
614
+ function clearPreviews(panel: HTMLElement): void {
615
+ for (const stream of panel.querySelectorAll('[data-puzzle-path-stream]'))
616
+ stream.remove();
617
+ for (const destination of panel.querySelectorAll<HTMLButtonElement>(
618
+ '[data-sliding-destination]',
619
+ )) {
620
+ destination.tabIndex = -1;
621
+ destination.removeAttribute('data-sliding-destination');
622
+ destination.removeAttribute('data-puzzle-cell-interactive');
623
+ delete destination.dataset.destinationDirection;
624
+ delete destination.dataset.destinationPiece;
625
+ if (destination.dataset.cellLabel)
626
+ destination.setAttribute('aria-label', destination.dataset.cellLabel);
627
+ else destination.removeAttribute('aria-label');
628
+ }
629
+ }
630
+
631
+ function cancelDrag(instance: Instance): void {
632
+ const drag = instance.drag;
633
+ if (!drag) return;
634
+ instance.drag = null;
635
+ cancelDragFrame(drag);
636
+ drag.element.removeAttribute('data-piece-dragging');
637
+ drag.element.style.removeProperty('--piece-drag-x');
638
+ drag.element.style.removeProperty('--piece-drag-y');
639
+ if (drag.element.hasPointerCapture(drag.pointerId))
640
+ drag.element.releasePointerCapture(drag.pointerId);
641
+ }
642
+
643
+ function cancelTerrainFrame(instance: Instance): void {
644
+ if (instance.terrainFrame === null) return;
645
+ window.cancelAnimationFrame(instance.terrainFrame);
646
+ instance.terrainFrame = null;
647
+ }
648
+
649
+ function cancelMotion(instance: Instance): void {
650
+ instance.motion?.abort();
651
+ instance.motion = null;
652
+ }
653
+
654
+ function isMoving(piece: HTMLElement): boolean {
655
+ return piece
656
+ .getAnimations()
657
+ .some((animation) => animation.playState === 'running');
658
+ }
659
+
660
+ function emitChange(root: HTMLElement): void {
661
+ const panel = activePanel(root);
662
+ const instance = instances.get(root);
663
+ if (!panel || !instance) return;
664
+ root.dispatchEvent(
665
+ new CustomEvent<SlidingPuzzleChangeDetail>('puzzle:change', {
666
+ bubbles: true,
667
+ detail: {
668
+ kind: 'sliding',
669
+ puzzleId: model(panel).id,
670
+ state: {
671
+ position: { ...instance.state.position },
672
+ collapsedCells: instance.state.collapsedCells.map((position) => ({
673
+ ...position,
674
+ })),
675
+ openHoles: instance.state.openHoles.map((position) => ({
676
+ ...position,
677
+ })),
678
+ },
679
+ },
680
+ }),
681
+ );
682
+ }
683
+
684
+ function model(panel: HTMLElement): SlidingPuzzleModel {
685
+ return JSON.parse(panel.dataset.slidingModel ?? '{}') as SlidingPuzzleModel;
686
+ }
687
+
688
+ function panels(root: HTMLElement): HTMLElement[] {
689
+ return [...root.querySelectorAll<HTMLElement>(':scope > [data-puzzle-id]')];
690
+ }
691
+
692
+ function activePanel(root: HTMLElement): HTMLElement | undefined {
693
+ return panels(root)[instances.get(root)?.activeIndex ?? 0];
694
+ }
695
+
696
+ function cell(
697
+ panel: HTMLElement,
698
+ position: SlidingPosition,
699
+ ): HTMLButtonElement | null {
700
+ return panel.querySelector(
701
+ `[data-sliding-cell][data-cell-row="${position.row}"][data-cell-column="${position.column}"]`,
702
+ );
703
+ }
@@ -10,6 +10,10 @@ export { default as Modal } from './Modal/Modal.astro';
10
10
  export { default as ModalClose } from './Modal/ModalClose.astro';
11
11
  export { default as ModalOpen } from './Modal/ModalOpen.astro';
12
12
  export { default as PuzzleGrid } from './PuzzleGrid/PuzzleGrid.astro';
13
+ export { default as SlidingPuzzle } from './SlidingPuzzle/SlidingPuzzle.astro';
14
+ export * from './SlidingPuzzle/contracts';
15
+ export * from './SlidingPuzzle/simulation';
16
+ export * from './SlidingPuzzle/slidingPuzzleController';
13
17
  export { default as SpriteProjectilePreview } from './SpriteProjectilePreview/SpriteProjectilePreview.astro';
14
18
  export { default as StageFlow } from './StageFlow/StageFlow.astro';
15
19
  export { default as Table } from './Table/Table.astro';