@bedard/hexboard 0.0.1

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.
Files changed (38) hide show
  1. package/.github/workflows/build.yml +65 -0
  2. package/.github/workflows/release.yml +0 -0
  3. package/.vscode/settings.json +5 -0
  4. package/LICENSE +21 -0
  5. package/README.md +8 -0
  6. package/dist/index.d.ts +127 -0
  7. package/dist/index.js +1056 -0
  8. package/eslint.config.js +85 -0
  9. package/index.html +22 -0
  10. package/package.json +58 -0
  11. package/src/lib/components/hexboard/Hexboard.vue +865 -0
  12. package/src/lib/components/hexboard/constants.ts +389 -0
  13. package/src/lib/components/hexboard/dom.ts +19 -0
  14. package/src/lib/components/hexboard/geometry.ts +59 -0
  15. package/src/lib/components/hexboard/haptics.ts +56 -0
  16. package/src/lib/components/hexboard/pieces/Celtic.vue +22 -0
  17. package/src/lib/components/hexboard/pieces/Fantasy.vue +22 -0
  18. package/src/lib/components/hexboard/pieces/Gioco.vue +22 -0
  19. package/src/lib/components/hexboard/pieces/Spatial.vue +22 -0
  20. package/src/lib/components/hexboard/pieces/index.ts +4 -0
  21. package/src/lib/components/hexboard/types.ts +28 -0
  22. package/src/lib/index.ts +1 -0
  23. package/src/sandbox/App.vue +28 -0
  24. package/src/sandbox/components/Button.vue +8 -0
  25. package/src/sandbox/components/icons/Github.vue +3 -0
  26. package/src/sandbox/components/icons/Menu.vue +3 -0
  27. package/src/sandbox/components/icons/X.vue +3 -0
  28. package/src/sandbox/index.ts +5 -0
  29. package/src/sandbox/tailwind.css +59 -0
  30. package/src/sandbox/views/HomeToolbar.vue +80 -0
  31. package/src/tests/example.test.tsx +18 -0
  32. package/src/tests/hexboard.test.tsx +832 -0
  33. package/src/tests/utils.ts +26 -0
  34. package/tsconfig.json +30 -0
  35. package/tsconfig.node.json +10 -0
  36. package/vite.config.ts +42 -0
  37. package/vite.sandbox.config.ts +21 -0
  38. package/vitest.config.ts +35 -0
@@ -0,0 +1,865 @@
1
+ <template>
2
+ <div>
3
+ <svg
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ ref="svgEl"
6
+ :style="{ cursor }"
7
+ :viewBox="`0 0 ${box} ${box}`"
8
+ >
9
+ <!-- backdrop -->
10
+ <path
11
+ :d="d(perimeter)"
12
+ :fill="normalizedOptions.colors[1]"
13
+ :style="{ pointerEvents: 'none' }"
14
+ />
15
+
16
+ <!-- positions -->
17
+ <path
18
+ v-for="(pos, index) in board"
19
+ v-bind="
20
+ active
21
+ ? {
22
+ onClick: (evt) => onClickPosition(index, evt),
23
+ onMouseenter: () => onMouseenter(index),
24
+ onMouseleave: () => onMouseleave(),
25
+ onPointerdown: (evt) => onPointerdownPosition(index, evt),
26
+ onPointerup: (evt) => onPointerupPosition(index, evt),
27
+ }
28
+ : {}
29
+ "
30
+ :d="d(flipped ? pos[4] : pos[3])"
31
+ :data-hexboard-position="index"
32
+ :data-testid="`position-${indexToPosition(index)}`"
33
+ :fill="normalizedOptions.colors[board[index][0]]"
34
+ :key="index"
35
+ />
36
+
37
+ <!-- highlighted positions -->
38
+ <path
39
+ v-for="highlightIndex in highlight"
40
+ :d="d(flipped ? board[highlightIndex][4] : board[highlightIndex][3])"
41
+ :data-testid="`highlight-${indexToPosition(highlightIndex)}`"
42
+ :fill="normalizedOptions.highlightColor"
43
+ :key="`highlight-${highlightIndex}`"
44
+ :style="{ pointerEvents: 'none' }"
45
+ />
46
+
47
+ <!-- selected position -->
48
+ <path
49
+ v-if="typeof currentSelected === 'number'"
50
+ :d="d(flipped ? board[currentSelected][4] : board[currentSelected][3])"
51
+ :data-testid="`selected-${indexToPosition(currentSelected)}`"
52
+ :fill="normalizedOptions.selectedColor"
53
+ ref="selectedEl"
54
+ :style="{ pointerEvents: 'none' }"
55
+ />
56
+
57
+ <!-- labels -->
58
+ <template v-if="normalizedOptions.labels">
59
+ <text
60
+ v-for="([text, p, positionFlipped], i) in labels"
61
+ v-text="text"
62
+ dominant-baseline="central"
63
+ text-anchor="middle"
64
+ :data-testid="`label-${text}`"
65
+ :key="`label-${i}`"
66
+ :style="{
67
+ fill: getLabelFill(text),
68
+ fontSize: '.5px',
69
+ pointerEvents: 'none',
70
+ userSelect: 'none',
71
+ }"
72
+ :x="x(flipped ? positionFlipped[0] : p[0])"
73
+ :y="y(flipped ? positionFlipped[1] : p[1])"
74
+ />
75
+ </template>
76
+
77
+ <!-- pieces -->
78
+ <template v-if="currentHexchess">
79
+ <Component
80
+ v-for="{ piece, index } in currentPieces"
81
+ :data-piece-type="piece"
82
+ :data-testid="`piece-${indexToPosition(index)}`"
83
+ :height="pieceSize"
84
+ :is="pieces"
85
+ :key="`piece-${indexToPosition(index)}`"
86
+ :style="{ pointerEvents: 'none' }"
87
+ :type="piece"
88
+ :width="pieceSize"
89
+ :x="x(board[index][flipped ? 2 : 1][0] - pieceSize / 2)"
90
+ :y="y(board[index][flipped ? 2 : 1][1] + pieceSize / 2)"
91
+ />
92
+ </template>
93
+
94
+ <!-- targets -->
95
+ <circle
96
+ v-for="targetIndex in currentTargets"
97
+ :cx="x(board[targetIndex][flipped ? 2 : 1][0])"
98
+ :cy="y(board[targetIndex][flipped ? 2 : 1][1])"
99
+ :data-testid="`target-${indexToPosition(targetIndex)}`"
100
+ :fill="normalizedOptions.targetColor"
101
+ :key="`target-${indexToPosition(targetIndex)}`"
102
+ :r="0.3"
103
+ :style="{ pointerEvents: 'none' }"
104
+ />
105
+ </svg>
106
+
107
+ <!-- draggable piece -->
108
+ <svg
109
+ v-if="dragPiece"
110
+ data-testid="drag-piece"
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ :style="{
113
+ height: svgRect.height + 'px',
114
+ left: '0px',
115
+ pointerEvents: 'none',
116
+ position: 'fixed',
117
+ top: '0px',
118
+ transform: `translate(${dragCoords.x}px, ${dragCoords.y}px) scale(1.1)`,
119
+ width: svgRect.width + 'px',
120
+ willChange: 'transform',
121
+ }"
122
+ :viewBox="`0 0 ${box} ${box}`"
123
+ >
124
+ <Component
125
+ :height="pieceSize"
126
+ :is="pieces"
127
+ :style="{ pointerEvents: 'none' }"
128
+ :type="dragPiece"
129
+ :width="pieceSize"
130
+ :x="x(pieceSize / -2)"
131
+ :y="y(pieceSize / 2)"
132
+ />
133
+ </svg>
134
+
135
+ <!-- promotion -->
136
+ <div
137
+ v-if="typeof staging.selected === 'number'"
138
+ :style="{
139
+ height: promotionRect.height + 'px',
140
+ left: promotionRect.left + 'px',
141
+ position: 'fixed',
142
+ top: promotionRect.top + 'px',
143
+ width: promotionRect.width + 'px',
144
+ }"
145
+ @pointerup.stop
146
+ >
147
+ <slot
148
+ name="promotion"
149
+ :b="promotionPieces.b"
150
+ :cancel="cancelPromotion"
151
+ :file="indexToPosition(staging.selected)[0]"
152
+ :n="promotionPieces.n"
153
+ :promote
154
+ :q="promotionPieces.q"
155
+ :r="promotionPieces.r"
156
+ :rank="Number(indexToPosition(staging.selected).slice(1))"
157
+ />
158
+ </div>
159
+ </div>
160
+ </template>
161
+
162
+ <script lang="ts" setup>
163
+ import {
164
+ type Color,
165
+ Hexchess,
166
+ isPromotionPosition,
167
+ type Piece,
168
+ position as indexToPosition,
169
+ San,
170
+ } from '@bedard/hexchess'
171
+ import {
172
+ type Component,
173
+ computed,
174
+ h,
175
+ onMounted,
176
+ onUnmounted,
177
+ shallowRef,
178
+ useTemplateRef,
179
+ watch,
180
+ } from 'vue'
181
+
182
+ import {
183
+ board,
184
+ box,
185
+ defaultOptions,
186
+ initialPosition,
187
+ labels,
188
+ perimeter,
189
+ pieceSize,
190
+ } from './constants'
191
+ import { d } from './dom'
192
+ import { x, y } from './geometry'
193
+ import { hapticConfirm } from './haptics'
194
+ import GiocoPieces from './pieces/Gioco.vue'
195
+ import type { HexboardOptions } from './types'
196
+
197
+ //
198
+ // props
199
+ //
200
+
201
+ const props = withDefaults(
202
+ defineProps<{
203
+ active?: boolean
204
+ autoselect?: boolean
205
+ flipped?: boolean
206
+ hexchess?: Hexchess
207
+ highlight?: number[]
208
+ ignoreTurn?: boolean
209
+ options?: Partial<HexboardOptions>
210
+ pieces?: Component
211
+ playing?: Color | boolean
212
+ position?: string
213
+ }>(),
214
+ {
215
+ active: false,
216
+ autoselect: false,
217
+ flipped: false,
218
+ hexchess: () => Hexchess.init(),
219
+ highlight: () => [],
220
+ ignoreTurn: false,
221
+ options: () => ({}),
222
+ pieces: () => GiocoPieces,
223
+ playing: false,
224
+ position: initialPosition,
225
+ },
226
+ )
227
+
228
+ //
229
+ // events
230
+ //
231
+
232
+ const emit = defineEmits<{
233
+ clickPosition: [position: number]
234
+ move: [san: San]
235
+ }>()
236
+
237
+ //
238
+ // models
239
+ //
240
+
241
+ const mouseoverPosition = defineModel<number | null>('mouseover-position', {
242
+ default: null,
243
+ required: false,
244
+ })
245
+
246
+ const selected = defineModel<number | null>('selected', {
247
+ default: null,
248
+ required: false,
249
+ })
250
+
251
+ const targets = defineModel<number[]>('targets', {
252
+ default: () => [],
253
+ required: false,
254
+ })
255
+
256
+ //
257
+ // state
258
+ //
259
+
260
+ /** current pointer coordinates */
261
+ const pointerCoords = shallowRef({ x: 0, y: 0 })
262
+
263
+ /** fen position of pointerdown */
264
+ const pointerdownPosition = shallowRef<number | null>(null)
265
+
266
+ /** rect of promotion anchor element */
267
+ const promotionRect = shallowRef<DOMRect>(new DOMRect())
268
+
269
+ /** staging display data */
270
+ const staging = shallowRef<{
271
+ hexchess: Hexchess | null
272
+ promotionEl: Element | null
273
+ promotionFrom: number | null
274
+ promotionTo: number | null
275
+ selected: number | null
276
+ }>({
277
+ hexchess: null,
278
+ promotionEl: null,
279
+ promotionFrom: null,
280
+ promotionTo: null,
281
+ selected: null,
282
+ })
283
+
284
+ /** svg rect */
285
+ const svgEl = useTemplateRef('svgEl')
286
+
287
+ /** rect of svg element on pointerdown */
288
+ const svgRect = shallowRef<DOMRect>(new DOMRect())
289
+
290
+ /** flag to skip click handling after promotion cancel */
291
+ let skipNextClick = false
292
+
293
+ //
294
+ // computed
295
+ //
296
+
297
+ /** current targets */
298
+ const currentTargets = computed(() => {
299
+ if (staging.value.hexchess) {
300
+ return []
301
+ }
302
+
303
+ return targets.value
304
+ })
305
+
306
+ /** current hexchess state */
307
+ const currentHexchess = computed(() => {
308
+ if (staging.value.hexchess) {
309
+ return staging.value.hexchess
310
+ }
311
+
312
+ if (props.hexchess) {
313
+ return props.hexchess
314
+ }
315
+
316
+ return Hexchess.init()
317
+ })
318
+
319
+ /** current pieces */
320
+ const currentPieces = computed(() => {
321
+ return currentHexchess.value.board.reduce<{ piece: Piece, index: number }[]>(
322
+ (acc, piece, index) => {
323
+ if (piece && index !== pointerdownPosition.value) {
324
+ acc.push({ piece, index })
325
+ }
326
+
327
+ return acc
328
+ },
329
+ [],
330
+ )
331
+ })
332
+
333
+ /** current selected position */
334
+ const currentSelected = computed(() => {
335
+ if (typeof staging.value.selected === 'number') {
336
+ return null
337
+ }
338
+
339
+ return selected.value
340
+ })
341
+
342
+ /** cursor type */
343
+ const cursor = computed(() => {
344
+ if (dragPiece.value) {
345
+ return 'grabbing' // global cursor
346
+ }
347
+
348
+ if (
349
+ !props.active
350
+ || mouseoverPosition.value === null
351
+ || staging.value.hexchess
352
+ ) {
353
+ return undefined
354
+ }
355
+
356
+ // If piece is selected and hovering over a target, show pointer
357
+ if (
358
+ selected.value !== null
359
+ && targets.value.includes(mouseoverPosition.value)
360
+ ) {
361
+ const selectedPiece = currentHexchess.value?.board[selected.value]
362
+ if (selectedPiece) {
363
+ const selectedPieceColor: Color
364
+ = selectedPiece === selectedPiece.toLowerCase() ? 'b' : 'w'
365
+ const isSelectedTurn = currentHexchess.value?.turn === selectedPieceColor
366
+
367
+ // Allow moving if playing both colors, or if it's the selected piece's turn
368
+ if (
369
+ (props.playing === true || isSelectedTurn)
370
+ && isPlayingPosition(selected.value)
371
+ ) {
372
+ return 'pointer'
373
+ }
374
+ }
375
+ }
376
+
377
+ if (!mouseoverPiece.value) {
378
+ return undefined
379
+ }
380
+
381
+ // When playing both colors, any piece is draggable
382
+ if (props.playing === true) {
383
+ return 'grab'
384
+ }
385
+
386
+ // When playing a single color, check if piece is draggable (must be their turn)
387
+ if (
388
+ props.playing
389
+ && mouseoverColor.value === currentHexchess.value?.turn
390
+ && props.playing === mouseoverColor.value
391
+ ) {
392
+ return 'grab'
393
+ }
394
+
395
+ return 'pointer'
396
+ })
397
+
398
+ /** coordinates of drag transformation */
399
+ const dragCoords = computed(() => {
400
+ return {
401
+ x: pointerCoords.value.x - svgRect.value.width / 2,
402
+ y: pointerCoords.value.y - svgRect.value.height / 2,
403
+ }
404
+ })
405
+
406
+ /** piece being dragged */
407
+ const dragPiece = computed(() => {
408
+ if (
409
+ !props.hexchess
410
+ || staging.value.hexchess
411
+ || pointerdownPosition.value === null
412
+ ) {
413
+ return null
414
+ }
415
+
416
+ return props.hexchess.board[pointerdownPosition.value]
417
+ })
418
+
419
+ /** normalized options */
420
+ const normalizedOptions = computed(() => {
421
+ return { ...defaultOptions, ...props.options }
422
+ })
423
+
424
+ /** color of piece at mouseover position */
425
+ const mouseoverColor = computed<Color | null>(() => {
426
+ if (!mouseoverPiece.value) {
427
+ return null
428
+ }
429
+
430
+ return mouseoverPiece.value === mouseoverPiece.value.toLowerCase()
431
+ ? 'b'
432
+ : 'w'
433
+ })
434
+
435
+ /** piece at mouseover position */
436
+ const mouseoverPiece = computed(() => {
437
+ if (mouseoverPosition.value === null) {
438
+ return null
439
+ }
440
+
441
+ return currentHexchess.value?.board[mouseoverPosition.value] ?? null
442
+ })
443
+
444
+ /** promotion piece components for the promoting color */
445
+ const promotionPieces = computed(() => {
446
+ const piece = staging.value.hexchess?.board[staging.value.selected ?? -1]
447
+ const isWhite = piece === piece?.toUpperCase()
448
+
449
+ const createPiece = (type: string) => {
450
+ return (attrs: Record<string, unknown>) =>
451
+ h(props.pieces, { ...attrs, type })
452
+ }
453
+
454
+ return {
455
+ n: createPiece(isWhite ? 'N' : 'n'),
456
+ b: createPiece(isWhite ? 'B' : 'b'),
457
+ r: createPiece(isWhite ? 'R' : 'r'),
458
+ q: createPiece(isWhite ? 'Q' : 'q'),
459
+ }
460
+ })
461
+
462
+ //
463
+ // lifecycle
464
+ //
465
+
466
+ onMounted(() => {
467
+ if (props.active) {
468
+ listen()
469
+ }
470
+ })
471
+
472
+ onUnmounted(unlisten)
473
+
474
+ //
475
+ // watchers
476
+ //
477
+
478
+ watch(cursor, (val) => {
479
+ document.body.style.setProperty(
480
+ 'cursor',
481
+ val === 'grabbing' ? 'grabbing' : null,
482
+ )
483
+ })
484
+
485
+ watch(
486
+ () => props.active,
487
+ val => (val ? listen() : unlisten()),
488
+ )
489
+
490
+ //
491
+ // methods
492
+ //
493
+
494
+ /** attempt to move piece from source to target position */
495
+ function attemptMove(san: San, evt?: MouseEvent) {
496
+ // Check if target is valid
497
+ if (!targets.value.includes(san.to)) {
498
+ return
499
+ }
500
+
501
+ const piece = props.hexchess?.board[san.from]
502
+
503
+ if (!piece) {
504
+ return
505
+ }
506
+
507
+ const pieceColor = piece === piece.toLowerCase() ? 'b' : 'w'
508
+
509
+ const isCurrentTurn = props.hexchess?.turn === pieceColor
510
+
511
+ // Check if this is a pawn promotion move
512
+ if (
513
+ props.hexchess
514
+ && (piece === 'p' || piece === 'P')
515
+ && isPromotionPosition(san.to, pieceColor)
516
+ ) {
517
+ const clone = props.hexchess.clone()
518
+ clone.board[san.from] = null
519
+ clone.board[san.to] = piece
520
+ staging.value = {
521
+ hexchess: clone,
522
+ promotionEl: evt?.target instanceof Element ? evt.target : null,
523
+ promotionFrom: san.from,
524
+ promotionTo: san.to,
525
+ selected: san.to,
526
+ }
527
+
528
+ if (evt?.target instanceof Element) {
529
+ promotionRect.value = evt.target.getBoundingClientRect()
530
+ }
531
+
532
+ return
533
+ }
534
+
535
+ // Only call onPieceMove if playing this color and it's their turn (or ignoreTurn is true)
536
+ if (isPlayingPosition(san.from) && (props.ignoreTurn || isCurrentTurn)) {
537
+ onPieceMove(san)
538
+ }
539
+ }
540
+
541
+ /** check if user is playing the color at a position */
542
+ function isPlayingPosition(index: number): boolean {
543
+ const piece = props.hexchess?.board[index]
544
+
545
+ if (!piece) {
546
+ return false
547
+ }
548
+
549
+ const pieceColor: Color = piece === piece.toLowerCase() ? 'b' : 'w'
550
+
551
+ return props.playing === true || props.playing === pieceColor
552
+ }
553
+
554
+ /** get fill color of label */
555
+ function getLabelFill(text: string) {
556
+ if (mouseoverPosition.value === null) {
557
+ return normalizedOptions.value.labelColor
558
+ }
559
+
560
+ if (
561
+ indexToPosition(mouseoverPosition.value)?.startsWith(text)
562
+ || indexToPosition(mouseoverPosition.value)?.endsWith(text)
563
+ ) {
564
+ return normalizedOptions.value.labelActiveColor
565
+ }
566
+
567
+ return normalizedOptions.value.labelInactiveColor
568
+ }
569
+
570
+ /** listen for events */
571
+ function listen() {
572
+ pointerCoords.value = { x: 0, y: 0 }
573
+
574
+ window.addEventListener('keyup', onKeyupWindow)
575
+ window.addEventListener('pointermove', onPointermoveWindow)
576
+ window.addEventListener('pointerup', onPointerupWindow)
577
+ window.addEventListener('resize', measurePromotionRect)
578
+ window.addEventListener('scroll', measurePromotionRect)
579
+ window.addEventListener('touchmove', onTouchmoveWindow, { passive: false })
580
+ }
581
+
582
+ /** measure promotion element rect */
583
+ function measurePromotionRect() {
584
+ promotionRect.value
585
+ = staging.value.promotionEl?.getBoundingClientRect() ?? new DOMRect()
586
+ }
587
+
588
+ /** click position */
589
+ function onClickPosition(index: number, evt: MouseEvent) {
590
+ if (!props.active) {
591
+ return
592
+ }
593
+
594
+ // Skip this click if we just canceled a promotion (handled by pointerup)
595
+ if (skipNextClick) {
596
+ skipNextClick = false
597
+ return
598
+ }
599
+
600
+ // If staging a promotion, cancel it unless clicking on the promotion position
601
+ if (staging.value.hexchess) {
602
+ if (staging.value.selected !== index) {
603
+ cancelPromotion()
604
+ }
605
+ return
606
+ }
607
+
608
+ // If there's a selected piece and clicking a target, attempt to move
609
+ if (selected.value !== null && targets.value.includes(index)) {
610
+ const san = new San({ from: selected.value, to: index })
611
+ attemptMove(san, evt)
612
+ return
613
+ }
614
+
615
+ // If autoselect is enabled and clicking an unoccupied position, deselect
616
+ if (props.autoselect && !props.hexchess.board[index]) {
617
+ selected.value = null
618
+ targets.value = []
619
+ }
620
+
621
+ emit('clickPosition', index)
622
+ }
623
+
624
+ /** keyup window */
625
+ function onKeyupWindow(evt: KeyboardEvent) {
626
+ if (evt.key === 'Escape') {
627
+ // If staging a promotion, cancel it
628
+ if (staging.value.hexchess) {
629
+ cancelPromotion()
630
+ return
631
+ }
632
+
633
+ // Otherwise deselect if autoselect is enabled
634
+ if (props.autoselect) {
635
+ selected.value = null
636
+ targets.value = []
637
+ }
638
+ }
639
+ }
640
+
641
+ /** handle piece move */
642
+ function onPieceMove(san: San) {
643
+ emit('move', san)
644
+
645
+ resetState()
646
+ }
647
+
648
+ /** pointerup position */
649
+ function onPointerupPosition(index: number, evt: PointerEvent) {
650
+ evt.stopPropagation()
651
+
652
+ // Check if we're dropping a piece on a valid target (drag and drop)
653
+ if (pointerdownPosition.value !== null) {
654
+ // On touch devices, pointerup fires on the element where touch started, not where it ended.
655
+ // Use elementFromPoint to find the actual target position.
656
+ let targetIndex = index
657
+ const elementUnderPointer = document.elementFromPoint(
658
+ evt.clientX,
659
+ evt.clientY,
660
+ )
661
+ const posAttr = elementUnderPointer?.getAttribute('data-hexboard-position')
662
+
663
+ if (posAttr !== null) {
664
+ targetIndex = Number(posAttr)
665
+ }
666
+
667
+ const san = new San({ from: pointerdownPosition.value, to: targetIndex })
668
+ attemptMove(san, evt)
669
+
670
+ // If staging a promotion, don't reset
671
+ if (staging.value.hexchess) {
672
+ return
673
+ }
674
+
675
+ // Keep selection but reset drag state
676
+ pointerdownPosition.value = null
677
+ svgRect.value = new DOMRect()
678
+ return
679
+ }
680
+
681
+ // Check if clicking on a target while a piece is selected (click to move)
682
+ if (selected.value !== null && targets.value.includes(index)) {
683
+ const san = new San({ from: selected.value, to: index })
684
+ attemptMove(san, evt)
685
+
686
+ // If staging a promotion, don't reset
687
+ if (staging.value.hexchess) {
688
+ return
689
+ }
690
+
691
+ // Move was made, reset state
692
+ return
693
+ }
694
+
695
+ // If staging a promotion and clicking on a non-target, cancel the promotion
696
+ if (staging.value.hexchess) {
697
+ cancelPromotion()
698
+ return
699
+ }
700
+
701
+ // If clicking on any piece, keep the selection (it was set in pointerdown)
702
+ if (props.hexchess?.board[index]) {
703
+ pointerdownPosition.value = null
704
+ svgRect.value = new DOMRect()
705
+ return
706
+ }
707
+
708
+ resetState()
709
+ }
710
+
711
+ /** cancel promotion and restore original selection */
712
+ function cancelPromotion() {
713
+ const from = staging.value.promotionFrom
714
+
715
+ staging.value = {
716
+ hexchess: null,
717
+ promotionEl: null,
718
+ promotionFrom: null,
719
+ promotionTo: null,
720
+ selected: null,
721
+ }
722
+
723
+ // Keep the original piece selected
724
+ if (typeof from === 'number') {
725
+ selected.value = from
726
+ targets.value = props.hexchess.movesFrom(from).map(san => san.to) ?? []
727
+ }
728
+
729
+ pointerdownPosition.value = null
730
+ skipNextClick = true
731
+ }
732
+
733
+ /** pointerdown on position */
734
+ function onPointerdownPosition(index: number, evt: PointerEvent) {
735
+ evt.preventDefault()
736
+
737
+ hapticConfirm()
738
+
739
+ // Don't start new interactions during promotion
740
+ if (staging.value.hexchess) {
741
+ return
742
+ }
743
+
744
+ const piece = props.hexchess?.board[index]
745
+
746
+ if (!piece) {
747
+ return
748
+ }
749
+
750
+ if (props.autoselect) {
751
+ selected.value = index
752
+ targets.value = props.hexchess?.movesFrom(index).map(san => san.to) ?? []
753
+
754
+ if (normalizedOptions.value.haptics) {
755
+ hapticConfirm()
756
+ }
757
+ }
758
+
759
+ if (!isPlayingPosition(index)) {
760
+ return
761
+ }
762
+
763
+ // Only allow dragging if it's the piece's turn (or ignoreTurn is true)
764
+ const pieceColor: Color = piece === piece.toLowerCase() ? 'b' : 'w'
765
+ const isCurrentTurn = props.hexchess?.turn === pieceColor
766
+
767
+ if (!props.ignoreTurn && !isCurrentTurn) {
768
+ return
769
+ }
770
+
771
+ pointerdownPosition.value = index
772
+ pointerCoords.value = { x: evt.clientX, y: evt.clientY }
773
+
774
+ if (svgEl.value instanceof Element) {
775
+ svgRect.value = svgEl.value.getBoundingClientRect()
776
+ }
777
+ }
778
+
779
+ /** mouseenter position */
780
+ function onMouseenter(index: number) {
781
+ mouseoverPosition.value = index
782
+ }
783
+
784
+ /** mouseleave position */
785
+ function onMouseleave() {
786
+ mouseoverPosition.value = null
787
+ }
788
+
789
+ /** pointermove window */
790
+ function onPointermoveWindow(evt: MouseEvent) {
791
+ if (!props.active) {
792
+ return
793
+ }
794
+
795
+ pointerCoords.value = { x: evt.clientX, y: evt.clientY }
796
+ }
797
+
798
+ /** touchmove window - prevent scrolling while dragging */
799
+ function onTouchmoveWindow(evt: TouchEvent) {
800
+ if (pointerdownPosition.value !== null) {
801
+ evt.preventDefault()
802
+ }
803
+ }
804
+
805
+ /** pointerup window */
806
+ function onPointerupWindow() {
807
+ // If staging a promotion, cancel it but keep the original piece selected
808
+ if (staging.value.hexchess) {
809
+ cancelPromotion()
810
+ return
811
+ }
812
+
813
+ // If dragging a piece, keep the selection but reset drag state
814
+ if (pointerdownPosition.value !== null) {
815
+ pointerdownPosition.value = null
816
+ svgRect.value = new DOMRect()
817
+ return
818
+ }
819
+
820
+ resetState()
821
+ }
822
+
823
+ /** promote piece */
824
+ function promote(promotion: 'n' | 'b' | 'r' | 'q') {
825
+ if (
826
+ typeof staging.value.promotionFrom === 'number'
827
+ && isPlayingPosition(staging.value.promotionFrom)
828
+ ) {
829
+ const san = new San({
830
+ from: staging.value.promotionFrom ?? 0,
831
+ to: staging.value.promotionTo ?? 0,
832
+ promotion: promotion,
833
+ })
834
+
835
+ onPieceMove(san)
836
+ }
837
+ }
838
+
839
+ /** reset state */
840
+ function resetState() {
841
+ document.body.style.setProperty('cursor', null)
842
+ pointerdownPosition.value = null
843
+ selected.value = null
844
+ staging.value = {
845
+ hexchess: null,
846
+ promotionEl: null,
847
+ promotionFrom: null,
848
+ promotionTo: null,
849
+ selected: null,
850
+ }
851
+ svgRect.value = new DOMRect()
852
+ targets.value = []
853
+ }
854
+
855
+ /** stop listening for events */
856
+ function unlisten() {
857
+ resetState()
858
+ window.removeEventListener('keyup', onKeyupWindow)
859
+ window.removeEventListener('pointermove', onPointermoveWindow)
860
+ window.removeEventListener('pointerup', onPointerupWindow)
861
+ window.removeEventListener('resize', measurePromotionRect)
862
+ window.removeEventListener('scroll', measurePromotionRect)
863
+ window.removeEventListener('touchmove', onTouchmoveWindow)
864
+ }
865
+ </script>