@alepot55/chessboardjs 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +125 -401
  2. package/assets/themes/alepot/theme.json +42 -0
  3. package/assets/themes/default/theme.json +42 -0
  4. package/dist/chessboard.cjs.js +11785 -0
  5. package/dist/chessboard.css +243 -0
  6. package/dist/chessboard.esm.js +11716 -0
  7. package/dist/chessboard.iife.js +11791 -0
  8. package/dist/chessboard.umd.js +11791 -0
  9. package/package.json +33 -3
  10. package/{chessboard.move.js → src/components/Move.js} +3 -3
  11. package/src/components/Piece.js +771 -0
  12. package/{chessboard.square.js → src/components/Square.js} +61 -8
  13. package/src/constants/index.js +15 -0
  14. package/src/constants/positions.js +62 -0
  15. package/src/core/Chessboard.js +2346 -0
  16. package/src/core/ChessboardConfig.js +707 -0
  17. package/src/core/ChessboardFactory.js +385 -0
  18. package/src/core/index.js +141 -0
  19. package/src/errors/ChessboardError.js +133 -0
  20. package/src/errors/index.js +15 -0
  21. package/src/errors/messages.js +189 -0
  22. package/src/index.js +103 -0
  23. package/src/services/AnimationService.js +180 -0
  24. package/src/services/BoardService.js +156 -0
  25. package/src/services/CoordinateService.js +355 -0
  26. package/src/services/EventService.js +955 -0
  27. package/src/services/MoveService.js +567 -0
  28. package/src/services/PieceService.js +339 -0
  29. package/src/services/PositionService.js +237 -0
  30. package/src/services/ValidationService.js +673 -0
  31. package/src/services/index.js +14 -0
  32. package/src/styles/animations.css +46 -0
  33. package/{chessboard.css → src/styles/board.css} +30 -7
  34. package/src/styles/index.css +4 -0
  35. package/src/styles/pieces.css +70 -0
  36. package/src/utils/animations.js +37 -0
  37. package/{chess.js → src/utils/chess.js} +16 -16
  38. package/src/utils/coordinates.js +62 -0
  39. package/src/utils/cross-browser.js +150 -0
  40. package/src/utils/logger.js +422 -0
  41. package/src/utils/performance.js +311 -0
  42. package/src/utils/validation.js +458 -0
  43. package/.babelrc +0 -4
  44. package/chessboard.bundle.js +0 -3422
  45. package/chessboard.config.js +0 -147
  46. package/chessboard.js +0 -979
  47. package/chessboard.piece.js +0 -115
  48. package/jest.config.js +0 -7
  49. package/rollup.config.js +0 -11
  50. package/test/chessboard.test.js +0 -128
  51. /package/{alepot_theme → assets/themes/alepot}/bb.svg +0 -0
  52. /package/{alepot_theme → assets/themes/alepot}/bw.svg +0 -0
  53. /package/{alepot_theme → assets/themes/alepot}/kb.svg +0 -0
  54. /package/{alepot_theme → assets/themes/alepot}/kw.svg +0 -0
  55. /package/{alepot_theme → assets/themes/alepot}/nb.svg +0 -0
  56. /package/{alepot_theme → assets/themes/alepot}/nw.svg +0 -0
  57. /package/{alepot_theme → assets/themes/alepot}/pb.svg +0 -0
  58. /package/{alepot_theme → assets/themes/alepot}/pw.svg +0 -0
  59. /package/{alepot_theme → assets/themes/alepot}/qb.svg +0 -0
  60. /package/{alepot_theme → assets/themes/alepot}/qw.svg +0 -0
  61. /package/{alepot_theme → assets/themes/alepot}/rb.svg +0 -0
  62. /package/{alepot_theme → assets/themes/alepot}/rw.svg +0 -0
  63. /package/{default_pieces → assets/themes/default}/bb.svg +0 -0
  64. /package/{default_pieces → assets/themes/default}/bw.svg +0 -0
  65. /package/{default_pieces → assets/themes/default}/kb.svg +0 -0
  66. /package/{default_pieces → assets/themes/default}/kw.svg +0 -0
  67. /package/{default_pieces → assets/themes/default}/nb.svg +0 -0
  68. /package/{default_pieces → assets/themes/default}/nw.svg +0 -0
  69. /package/{default_pieces → assets/themes/default}/pb.svg +0 -0
  70. /package/{default_pieces → assets/themes/default}/pw.svg +0 -0
  71. /package/{default_pieces → assets/themes/default}/qb.svg +0 -0
  72. /package/{default_pieces → assets/themes/default}/qw.svg +0 -0
  73. /package/{default_pieces → assets/themes/default}/rb.svg +0 -0
  74. /package/{default_pieces → assets/themes/default}/rw.svg +0 -0
@@ -0,0 +1,955 @@
1
+ /**
2
+ * Service for managing events and user interactions
3
+ * @module services/EventService
4
+ * @since 2.0.0
5
+ */
6
+
7
+ import { rafThrottle } from '../utils/performance.js';
8
+ import { DragOptimizations } from '../utils/cross-browser.js';
9
+ import { ValidationError } from '../errors/ChessboardError.js';
10
+ import Move from '../components/Move.js';
11
+ import Piece from '../components/Piece.js';
12
+ import { logger } from '../utils/logger.js';
13
+
14
+ /**
15
+ * Service responsible for event handling and user interactions
16
+ * @class
17
+ */
18
+ export class EventService {
19
+ /**
20
+ * Creates a new EventService instance
21
+ * @param {ChessboardConfig} config - Board configuration
22
+ * @param {BoardService} boardService - Board service instance
23
+ * @param {MoveService} moveService - Move service instance
24
+ * @param {CoordinateService} coordinateService - Coordinate service instance
25
+ * @param {Chessboard} chessboard - Chessboard instance
26
+ */
27
+ constructor(config, boardService, moveService, coordinateService, chessboard) {
28
+ this.config = config;
29
+ this.boardService = boardService;
30
+ this.moveService = moveService;
31
+ this.coordinateService = coordinateService;
32
+ this.chessboard = chessboard;
33
+
34
+ this.clicked = null;
35
+ this.isDragging = false;
36
+ this.isAnimating = false;
37
+ this.promoting = false;
38
+ this._isProcessingDrop = false;
39
+
40
+ this.eventListeners = new Map();
41
+ }
42
+
43
+ /**
44
+ * Adds event listeners to all squares
45
+ * @param {Function} onSquareClick - Callback for square clicks
46
+ * @param {Function} onPieceHover - Callback for piece hover
47
+ * @param {Function} onPieceLeave - Callback for piece leave
48
+ */
49
+ addListeners(onSquareClick, onPieceHover, onPieceLeave) {
50
+ // Remove existing listeners to avoid duplicates
51
+ this.removeListeners();
52
+
53
+ const squares = this.boardService.getAllSquares();
54
+
55
+ Object.values(squares).forEach(square => {
56
+ this._addSquareListeners(square, onSquareClick, onPieceHover, onPieceLeave);
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Adds event listeners to a specific square
62
+ * @private
63
+ * @param {Square} square - Square to add listeners to
64
+ * @param {Function} onSquareClick - Click callback
65
+ * @param {Function} onPieceHover - Hover callback
66
+ * @param {Function} onPieceLeave - Leave callback
67
+ */
68
+ _addSquareListeners(square, onSquareClick, onPieceHover, onPieceLeave) {
69
+ const listeners = [];
70
+
71
+ // Throttled hover handlers for performance
72
+ const throttledHover = rafThrottle((e) => {
73
+ if (!this.clicked && this.config.hints) {
74
+ onPieceHover(square);
75
+ }
76
+ });
77
+
78
+ const throttledLeave = rafThrottle((e) => {
79
+ if (!this.clicked && this.config.hints) {
80
+ onPieceLeave(square);
81
+ }
82
+ });
83
+
84
+ // Click handler
85
+ const handleClick = (e) => {
86
+ e.stopPropagation();
87
+ if (this.config.clickable && !this.isAnimating) {
88
+ // Cancel any pending drag operations on pieces in this square
89
+ if (square.piece && square.piece._dragTimeout) {
90
+ clearTimeout(square.piece._dragTimeout);
91
+ square.piece._dragTimeout = null;
92
+ }
93
+ onSquareClick(square);
94
+ }
95
+ };
96
+
97
+ // Add listeners
98
+ square.element.addEventListener('mouseover', throttledHover);
99
+ square.element.addEventListener('mouseout', throttledLeave);
100
+ square.element.addEventListener('click', handleClick);
101
+ square.element.addEventListener('touchstart', handleClick);
102
+
103
+ // Store listeners for cleanup
104
+ listeners.push(
105
+ { element: square.element, type: 'mouseover', handler: throttledHover },
106
+ { element: square.element, type: 'mouseout', handler: throttledLeave },
107
+ { element: square.element, type: 'click', handler: handleClick },
108
+ { element: square.element, type: 'touchstart', handler: handleClick }
109
+ );
110
+
111
+ this.eventListeners.set(square.id, listeners);
112
+ }
113
+
114
+ /**
115
+ * Creates a drag function for a piece
116
+ * @param {Square} square - Square containing the piece
117
+ * @param {Piece} piece - Piece to create drag function for
118
+ * @param {Function} onDragStart - Drag start callback
119
+ * @param {Function} onDragMove - Drag move callback
120
+ * @param {Function} onDrop - Drop callback
121
+ * @param {Function} onSnapback - Snapback callback
122
+ * @param {Function} onMove - Move execution callback
123
+ * @param {Function} onRemove - Remove piece callback
124
+ * @returns {Function} Drag event handler
125
+ */
126
+ createDragFunction(square, piece, onDragStart, onDragMove, onDrop, onSnapback, onMove, onRemove) {
127
+ console.log('Creating drag function for:', square.id, piece ? `${piece.color}${piece.type}` : 'null');
128
+
129
+ return (event) => {
130
+ event.preventDefault();
131
+
132
+ if (!this.config.draggable || !piece || this.isAnimating || this.isDragging) {
133
+ return;
134
+ }
135
+
136
+ // Set dragging state immediately
137
+ this.isDragging = true;
138
+
139
+ const originalFrom = square;
140
+ let isDragging = false;
141
+ let from = originalFrom;
142
+ let to = square;
143
+ let previousHighlight = null;
144
+
145
+ const img = piece.element;
146
+
147
+ if (!this.moveService.canMove(from)) {
148
+ return;
149
+ }
150
+
151
+ // Track initial position for drag threshold
152
+ const startX = event.clientX || (event.touches && event.touches[0]?.clientX) || 0;
153
+ const startY = event.clientY || (event.touches && event.touches[0]?.clientY) || 0;
154
+
155
+ const moveAt = (event) => {
156
+ const boardElement = this.boardService.element;
157
+ const squareSize = boardElement.offsetWidth / 8;
158
+
159
+ // Get mouse coordinates
160
+ let clientX, clientY;
161
+ if (event.touches && event.touches[0]) {
162
+ clientX = event.touches[0].clientX;
163
+ clientY = event.touches[0].clientY;
164
+ } else {
165
+ clientX = event.clientX;
166
+ clientY = event.clientY;
167
+ }
168
+
169
+ // Calculate position relative to board
170
+ const boardRect = boardElement.getBoundingClientRect();
171
+ const x = clientX - boardRect.left - (squareSize / 2);
172
+ const y = clientY - boardRect.top - (squareSize / 2);
173
+
174
+ img.style.left = x + 'px';
175
+ img.style.top = y + 'px';
176
+
177
+ return true;
178
+ };
179
+
180
+ const onMouseMove = (event) => {
181
+ const currentX = event.clientX || 0;
182
+ const currentY = event.clientY || 0;
183
+ const deltaX = Math.abs(currentX - startX);
184
+ const deltaY = Math.abs(currentY - startY);
185
+
186
+ // Start dragging if mouse moved enough
187
+ if (!isDragging && (deltaX > 3 || deltaY > 3)) {
188
+ isDragging = true;
189
+
190
+ // During drag, we don't need to manage clicked state
191
+ // The drag operation is independent of click state
192
+
193
+ // Visual feedback
194
+ if (this.config.clickable) {
195
+ from.select();
196
+ // Show hints would be handled by the main class
197
+ }
198
+
199
+ // Prepare piece for dragging
200
+ // First, preserve the current computed dimensions
201
+ const computedStyle = window.getComputedStyle(img);
202
+ const currentWidth = computedStyle.width;
203
+ const currentHeight = computedStyle.height;
204
+
205
+ img.style.position = 'absolute';
206
+ img.style.zIndex = '100';
207
+ img.classList.add('dragging');
208
+
209
+ // Explicitly set the dimensions to maintain size during drag
210
+ img.style.width = currentWidth;
211
+ img.style.height = currentHeight;
212
+
213
+ DragOptimizations.enableForDrag(img);
214
+
215
+ // Call drag start callback
216
+ if (!onDragStart(square, piece)) {
217
+ return;
218
+ }
219
+ }
220
+
221
+ if (!isDragging) return;
222
+
223
+ if (!moveAt(event)) return;
224
+
225
+ // Update target square
226
+ const boardElement = this.boardService.element;
227
+ const boardRect = boardElement.getBoundingClientRect();
228
+ const x = event.clientX - boardRect.left;
229
+ const y = event.clientY - boardRect.top;
230
+
231
+ let newTo = null;
232
+ if (x >= 0 && x <= boardRect.width && y >= 0 && y <= boardRect.height) {
233
+ const squareId = this.coordinateService.pixelToSquareID(x, y, boardElement);
234
+ newTo = squareId ? this.boardService.getSquare(squareId) : null;
235
+ }
236
+
237
+ to = newTo;
238
+ onDragMove(from, to, piece);
239
+
240
+ // Update visual feedback
241
+ if (to !== previousHighlight) {
242
+ to?.highlight();
243
+ previousHighlight?.dehighlight();
244
+ previousHighlight = to;
245
+ }
246
+ };
247
+
248
+ const onMouseUp = () => {
249
+ // Clean up visual feedback
250
+ previousHighlight?.dehighlight();
251
+
252
+ // CRITICAL FIX: Remove ALL event listeners immediately to prevent interference
253
+ document.removeEventListener('mousemove', onMouseMove);
254
+ window.removeEventListener('mouseup', onMouseUp);
255
+ img.removeEventListener('mouseup', onMouseUp);
256
+
257
+ // If this was just a click, don't interfere
258
+ if (!isDragging) {
259
+ return;
260
+ }
261
+
262
+ console.log('onMouseUp: Handling drag completion for piece at', originalFrom.id);
263
+
264
+ // Clean up drag state
265
+ img.style.zIndex = '20';
266
+ img.classList.remove('dragging');
267
+ img.style.willChange = 'auto';
268
+
269
+ // Clean up drag optimizations that might interfere with click
270
+ DragOptimizations.cleanupAfterDrag(img);
271
+
272
+ // Reset drag state immediately to allow subsequent clicks
273
+ this.isDragging = false;
274
+
275
+ // Ensure clicked state is clean before handling drop
276
+ // This prevents drag operations from interfering with subsequent clicks
277
+ const previousClicked = this.clicked;
278
+ this.clicked = null;
279
+
280
+ // Handle drop
281
+ const dropResult = onDrop(originalFrom, to, piece);
282
+ const isTrashDrop = !to && (this.config.dropOffBoard === 'trash' || dropResult === 'trash');
283
+
284
+ if (isTrashDrop) {
285
+ this._handleTrashDrop(originalFrom, onRemove);
286
+ } else if (!to) {
287
+ // Reset piece position instantly for snapback
288
+ img.style.position = '';
289
+ img.style.left = '';
290
+ img.style.top = '';
291
+ img.style.transform = '';
292
+ img.style.width = '';
293
+ img.style.height = '';
294
+
295
+ // Clean up drag optimizations
296
+ DragOptimizations.cleanupAfterDrag(img);
297
+
298
+ this._handleSnapback(originalFrom, piece, onSnapback);
299
+ this._cleanupAfterFailedMove(originalFrom);
300
+ } else {
301
+ // Handle drop like a click - simple and reliable
302
+ this._handleDrop(originalFrom, to, piece, onMove, onSnapback);
303
+ }
304
+ };
305
+
306
+ // Attach event listeners
307
+ window.addEventListener('mouseup', onMouseUp, { once: true });
308
+ document.addEventListener('mousemove', onMouseMove);
309
+ img.addEventListener('mouseup', onMouseUp, { once: true });
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Handles trash drop (piece removal)
315
+ * @private
316
+ * @param {Square} fromSquare - Source square
317
+ * @param {Function} onRemove - Callback to remove piece
318
+ */
319
+ _handleTrashDrop(fromSquare, onRemove) {
320
+ // Clear visual states
321
+ this.boardService.applyToAllSquares('unmoved');
322
+ this.boardService.applyToAllSquares('removeHint');
323
+ fromSquare.deselect();
324
+
325
+ // Reset clicked state
326
+ this.clicked = null;
327
+
328
+ if (onRemove) {
329
+ onRemove(fromSquare.getId());
330
+ }
331
+
332
+ logger.debug('EventService: Trash drop executed, state cleaned up');
333
+ }
334
+
335
+ /**
336
+ * Handles snapback animation
337
+ * @private
338
+ * @param {Square} fromSquare - Source square
339
+ * @param {Piece} piece - Piece to snapback
340
+ * @param {Function} onSnapback - Snapback callback
341
+ */
342
+ _handleSnapback(fromSquare, piece, onSnapback) {
343
+ if (fromSquare && fromSquare.piece) {
344
+ if (onSnapback) {
345
+ onSnapback(fromSquare, piece);
346
+ }
347
+ }
348
+
349
+ // Always cleanup state after snapback
350
+ logger.debug('EventService: Snapback executed, cleaning up state');
351
+ }
352
+
353
+ /**
354
+ * Handles successful drop
355
+ * @private
356
+ * @param {Square} fromSquare - Source square
357
+ * @param {Square} toSquare - Target square
358
+ * @param {Piece} piece - Piece being dropped
359
+ * @param {Function} onMove - Move callback
360
+ * @param {Function} onSnapback - Snapback callback
361
+ */
362
+ _handleDrop(fromSquare, toSquare, piece, onMove, onSnapback) {
363
+ console.log('=== _handleDrop CALLED ===');
364
+ console.log('From:', fromSquare.id, 'To:', toSquare.id);
365
+ console.log('Piece:', piece ? `${piece.color}${piece.type}` : 'null');
366
+ console.log('Stack trace:', new Error().stack.split('\n')[1]);
367
+ console.log('Caller:', new Error().stack.split('\n')[2]);
368
+
369
+ // CRITICAL FIX: Prevent multiple simultaneous drop operations
370
+ if (this.isAnimating || this._isProcessingDrop) {
371
+ console.log('Drop ignored - already processing or animating');
372
+ return;
373
+ }
374
+
375
+ // Set processing flag to prevent interference
376
+ this._isProcessingDrop = true;
377
+
378
+ const cleanup = () => {
379
+ this._isProcessingDrop = false;
380
+ };
381
+
382
+ // The core issue is state management. A drop action should be atomic.
383
+ // It either succeeds or fails, and the state should be cleaned completely afterward.
384
+ // We should not be setting `this.clicked` here at all.
385
+
386
+ try {
387
+ // Check for promotion first, as it's a special case.
388
+ if (this.moveService.requiresPromotion(new Move(fromSquare, toSquare))) {
389
+ logger.debug('Drag move requires promotion:', fromSquare.id, '->', toSquare.id);
390
+
391
+ this.moveService.setupPromotion(
392
+ new Move(fromSquare, toSquare),
393
+ this.boardService.squares,
394
+ (selectedPromotion) => {
395
+ logger.debug('Drag promotion selected:', selectedPromotion);
396
+ this.boardService.applyToAllSquares('removePromotion');
397
+ this.boardService.applyToAllSquares('removeCover');
398
+
399
+ // Attempt the move with promotion.
400
+ const moveResult = onMove(fromSquare, toSquare, selectedPromotion, true);
401
+ if (moveResult) {
402
+ this._schedulePromotionPieceReplacement(toSquare, selectedPromotion);
403
+ this._cleanupAfterSuccessfulMove(fromSquare);
404
+ } else {
405
+ this._handleSnapback(fromSquare, piece, onSnapback);
406
+ this._cleanupAfterFailedMove(fromSquare);
407
+ }
408
+ cleanup();
409
+ },
410
+ () => {
411
+ logger.debug('Drag promotion cancelled');
412
+ this.boardService.applyToAllSquares('removePromotion');
413
+ this.boardService.applyToAllSquares('removeCover');
414
+ this._handleSnapback(fromSquare, piece, onSnapback);
415
+ this._cleanupAfterFailedMove(fromSquare);
416
+ cleanup();
417
+ }
418
+ );
419
+ } else {
420
+ // Regular move.
421
+ const moveSuccess = onMove(fromSquare, toSquare, null, true);
422
+
423
+ if (moveSuccess) {
424
+ this._cleanupAfterSuccessfulMove(fromSquare);
425
+ } else {
426
+ this._handleSnapback(fromSquare, piece, onSnapback);
427
+ this._cleanupAfterFailedMove(fromSquare);
428
+ }
429
+ cleanup();
430
+ }
431
+ } catch (error) {
432
+ console.error('Error in _handleDrop:', error);
433
+ cleanup();
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Cleans up visual state after a SUCCESSFUL move
439
+ * @private
440
+ * @param {Square} fromSquare - Source square
441
+ */
442
+ _cleanupAfterSuccessfulMove(fromSquare) {
443
+ // Always reset interaction state after any successful move (drag or click)
444
+ this.clicked = null;
445
+ this.isDragging = false;
446
+
447
+ // Clear specific visual states related to the move
448
+ if (fromSquare) {
449
+ fromSquare.deselect();
450
+ }
451
+
452
+ // Clean up ALL visual elements after a successful move
453
+ // This includes removing old move highlights to keep the board clean
454
+ this.boardService.applyToAllSquares('removeHint');
455
+ this.boardService.applyToAllSquares('dehighlight');
456
+ this.boardService.applyToAllSquares('removePromotion');
457
+ this.boardService.applyToAllSquares('removeCover');
458
+
459
+ logger.debug('EventService: All visual state cleaned up after SUCCESSFUL move');
460
+ }
461
+
462
+ /**
463
+ * Cleans up visual state after a FAILED move (snapback, invalid move)
464
+ * @private
465
+ * @param {Square} fromSquare - Source square
466
+ */
467
+ _cleanupAfterFailedMove(fromSquare) {
468
+ // Reset interaction state but be more conservative with visual cleanup
469
+ this.clicked = null;
470
+ this.isDragging = false;
471
+
472
+ // Clean specific visual elements related to failed operation
473
+ this.boardService.applyToAllSquares('removePromotion');
474
+ this.boardService.applyToAllSquares('removeCover');
475
+ this.boardService.applyToAllSquares('removeHint');
476
+
477
+ // Only deselect the specific square that failed
478
+ if (fromSquare) {
479
+ fromSquare.deselect();
480
+ }
481
+
482
+ // Don't aggressively clear all highlights - preserve move history
483
+ // this.boardService.applyToAllSquares('dehighlight');
484
+
485
+ logger.debug('EventService: Conservative cleanup after FAILED move');
486
+ }
487
+
488
+ /**
489
+ * Animates piece to center of target square (visual only)
490
+ * @private
491
+ * @param {Piece} piece - Piece to animate
492
+ * @param {Square} targetSquare - Target square
493
+ * @param {Function} callback - Callback when animation completes
494
+ */
495
+ _animatePieceToCenter(piece, targetSquare, callback = null) {
496
+ if (!piece || !targetSquare) {
497
+ if (callback) callback();
498
+ return;
499
+ }
500
+
501
+ const duration = this.config.dropCenterTime;
502
+
503
+ // Get current position of piece element
504
+ const sourceRect = piece.element.getBoundingClientRect();
505
+ const targetRect = targetSquare.element.getBoundingClientRect();
506
+
507
+ const x_start = sourceRect.left + sourceRect.width / 2;
508
+ const y_start = sourceRect.top + sourceRect.height / 2;
509
+ const x_end = targetRect.left + targetRect.width / 2;
510
+ const y_end = targetRect.top + targetRect.height / 2;
511
+ const dx = x_end - x_start;
512
+ const dy = y_end - y_start;
513
+
514
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
515
+ // Already centered, just reset styles
516
+ piece.element.style.position = '';
517
+ piece.element.style.left = '';
518
+ piece.element.style.top = '';
519
+ piece.element.style.transform = '';
520
+ piece.element.style.zIndex = '';
521
+ if (callback) callback();
522
+ return;
523
+ }
524
+
525
+ const keyframes = [
526
+ { transform: 'translate(0, 0)' },
527
+ { transform: `translate(${dx}px, ${dy}px)` }
528
+ ];
529
+
530
+ if (piece.element.animate) {
531
+ const animation = piece.element.animate(keyframes, {
532
+ duration: duration,
533
+ easing: 'ease',
534
+ fill: 'none' // Don't keep the final position
535
+ });
536
+
537
+ animation.onfinish = () => {
538
+ // Defensive: check if element still exists
539
+ if (!piece.element) {
540
+ if (callback) callback();
541
+ return;
542
+ }
543
+ // Reset all drag-related styles to let default CSS handle positioning
544
+ piece.element.style.position = '';
545
+ piece.element.style.left = '';
546
+ piece.element.style.top = '';
547
+ piece.element.style.transform = '';
548
+ piece.element.style.zIndex = '';
549
+ piece.element.style.transition = '';
550
+
551
+ if (callback) callback();
552
+ };
553
+ } else {
554
+ // Fallback for browsers without Web Animations API
555
+ piece.element.style.transition = `transform ${duration}ms ease`;
556
+ piece.element.style.transform = `translate(${dx}px, ${dy}px)`;
557
+
558
+ setTimeout(() => {
559
+ // Defensive: check if element still exists
560
+ if (!piece.element) {
561
+ if (callback) callback();
562
+ return;
563
+ }
564
+ // After animation, reset ALL positioning styles and let CSS handle centering
565
+ piece.element.style.position = 'relative';
566
+ piece.element.style.left = '0';
567
+ piece.element.style.top = '0';
568
+ piece.element.style.transform = 'translate(-50%, -50%)';
569
+ piece.element.style.zIndex = '20';
570
+ piece.element.style.transition = 'none';
571
+
572
+ if (callback) callback();
573
+ }, duration);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Handles square click events
579
+ * @param {Square} square - Clicked square
580
+ * @param {Function} onMove - Move callback
581
+ * @param {Function} onSelect - Select callback
582
+ * @param {Function} onDeselect - Deselect callback
583
+ * @param {boolean} [animate=true] - Whether to animate the move
584
+ * @param {boolean} [dragged=false] - Whether this was triggered by drag
585
+ * @returns {boolean} True if move was successful
586
+ */
587
+ onClick(square, onMove, onSelect, onDeselect, animate = true, dragged = false) {
588
+ // Always ensure we're not in dragging state during click operations
589
+ this.isDragging = false;
590
+
591
+ // If we're animating, ignore the click to prevent state corruption
592
+ if (this.isAnimating) {
593
+ logger.debug('EventService.onClick: Ignoring click during animation');
594
+ return false;
595
+ }
596
+
597
+ logger.debug('=== EventService.onClick START ===');
598
+ logger.debug('EventService.onClick: square =', square.id, 'clicked =', this.clicked?.id || 'none', 'isAnimating =', this.isAnimating);
599
+ logger.debug('EventService.onClick: Square piece =', square.piece ? `${square.piece.color}${square.piece.type}` : 'empty');
600
+ logger.debug('EventService.onClick: Clicked square piece =', this.clicked?.piece ? `${this.clicked.piece.color}${this.clicked.piece.type}` : (this.clicked ? 'empty' : 'none'));
601
+
602
+ let from = this.clicked;
603
+ let promotion = null;
604
+
605
+ // Handle promotion state
606
+ if (this.promoting) {
607
+ if (this.promoting === 'none') {
608
+ from = null;
609
+ } else {
610
+ promotion = this.promoting;
611
+ }
612
+
613
+ this.promoting = false;
614
+ this.boardService.applyToAllSquares('removePromotion');
615
+ this.boardService.applyToAllSquares('removeCover');
616
+ }
617
+
618
+ // No source square selected
619
+ if (!from) {
620
+ logger.debug('EventService.onClick: No source square, checking if can move:', square.id);
621
+ if (this.moveService.canMove(square)) {
622
+ logger.debug('EventService.onClick: Can move! Setting clicked to:', square.id);
623
+ this.clicked = square;
624
+ if (this.config.clickable) {
625
+ // Ensure visual selection happens after state is set
626
+ onSelect(square);
627
+ logger.debug('EventService.onClick: Square selected visually:', square.id);
628
+ }
629
+ logger.debug('EventService.onClick: After setting, clicked =', this.clicked?.id || 'none');
630
+ return false;
631
+ } else {
632
+ logger.debug('EventService.onClick: Cannot move square:', square.id);
633
+ return false;
634
+ }
635
+ }
636
+
637
+ logger.debug('EventService.onClick: We have a source square:', from.id, 'target:', square.id);
638
+
639
+ // Clicking same square - deselect
640
+ if (this.clicked === square) {
641
+ onDeselect(square);
642
+ this.clicked = null;
643
+ return false;
644
+ }
645
+
646
+ // Check if move requires promotion
647
+ if (!promotion && this.moveService.requiresPromotion(new Move(from, square))) {
648
+ logger.debug('Move requires promotion:', from.id, '->', square.id);
649
+
650
+ // Set up promotion UI
651
+ this.moveService.setupPromotion(
652
+ new Move(from, square),
653
+ this.boardService.squares,
654
+ (selectedPromotion) => {
655
+ logger.debug('Promotion selected:', selectedPromotion);
656
+
657
+ // Clear promotion UI first
658
+ this.boardService.applyToAllSquares('removePromotion');
659
+ this.boardService.applyToAllSquares('removeCover');
660
+
661
+ // Execute the move with promotion
662
+ const moveResult = onMove(from, square, selectedPromotion, animate);
663
+
664
+ if (moveResult) {
665
+ // After a successful promotion move, we need to replace the piece
666
+ // after the drop animation completes
667
+ this._schedulePromotionPieceReplacement(square, selectedPromotion);
668
+
669
+ onDeselect(from);
670
+ this.clicked = null;
671
+ }
672
+ },
673
+ () => {
674
+ logger.debug('Promotion cancelled');
675
+
676
+ // Clear promotion UI on cancel
677
+ this.boardService.applyToAllSquares('removePromotion');
678
+ this.boardService.applyToAllSquares('removeCover');
679
+
680
+ onDeselect(from);
681
+ this.clicked = null;
682
+ }
683
+ );
684
+ return false;
685
+ }
686
+
687
+ // Attempt to make move
688
+ logger.debug('EventService.onClick: Attempting move from', from.id, 'to', square.id);
689
+ logger.debug('EventService.onClick: Current game state before move attempt');
690
+ const moveResult = onMove(from, square, promotion, animate);
691
+
692
+ if (moveResult) {
693
+ // Move successful
694
+ logger.debug('EventService.onClick: Move successful');
695
+ onDeselect(from);
696
+ this.clicked = null;
697
+ logger.debug('=== EventService.onClick END (move successful) ===');
698
+ return true;
699
+ } else {
700
+ // Move failed - deselect current and try to select new piece
701
+ logger.debug('EventService.onClick: Move failed from', from.id, 'to', square.id, '- resetting state');
702
+
703
+ // Always deselect the current piece first
704
+ onDeselect(from);
705
+ this.clicked = null;
706
+ this.isDragging = false;
707
+
708
+ // Clear move-related visual states but preserve highlights
709
+ this.boardService.applyToAllSquares('removeHint');
710
+
711
+ // Now check if the target square has a piece we can move and select it
712
+ if (this.moveService.canMove(square)) {
713
+ logger.debug('EventService.onClick: Can select new piece at', square.id);
714
+
715
+ this.clicked = square;
716
+ if (this.config.clickable) {
717
+ onSelect(square);
718
+ logger.debug('EventService.onClick: New piece selected visually:', square.id);
719
+ }
720
+ logger.debug('EventService.onClick: New piece selected at', square.id);
721
+ } else {
722
+ logger.debug('EventService.onClick: Cannot select piece at', square.id, '- staying deselected');
723
+ }
724
+
725
+ logger.debug('=== EventService.onClick END (move failed) ===');
726
+ return false;
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Schedules piece replacement after promotion animation
732
+ * @private
733
+ * @param {Square} square - Target square
734
+ * @param {string} promotionPiece - Piece to promote to
735
+ */
736
+ _schedulePromotionPieceReplacement(square, promotionPiece) {
737
+ // Mark that we're doing a promotion to prevent interference
738
+ this.chessboard._isPromoting = true;
739
+
740
+ // Use a more robust approach: poll for the piece to be present
741
+ this._waitForPieceAndReplace(square, promotionPiece, 0);
742
+ }
743
+
744
+ /**
745
+ * Waits for piece to be present and then replaces it
746
+ * @private
747
+ * @param {Square} square - Target square
748
+ * @param {string} promotionPiece - Piece to promote to
749
+ * @param {number} attempt - Current attempt number
750
+ */
751
+ _waitForPieceAndReplace(square, promotionPiece, attempt) {
752
+ const maxAttempts = 20; // Maximum 1 second of waiting (20 * 50ms)
753
+ const targetSquare = this.boardService.getSquare(square.id);
754
+
755
+ if (!targetSquare) {
756
+ logger.warn('Target square not found:', square.id);
757
+ this.chessboard._isPromoting = false;
758
+ return;
759
+ }
760
+
761
+ // Check if piece is present and ready
762
+ if (targetSquare.piece && targetSquare.piece.element) {
763
+ logger.debug('Piece found on', square.id, 'after', attempt, 'attempts');
764
+ this._replacePromotionPiece(square, promotionPiece);
765
+
766
+ // Allow normal updates again after transformation
767
+ setTimeout(() => {
768
+ this.chessboard._isPromoting = false;
769
+ logger.debug('Promotion protection ended');
770
+ // Force a board update to ensure everything is correctly synchronized
771
+ this.chessboard._updateBoardPieces(false);
772
+ }, 400); // Wait for transformation animation to complete
773
+
774
+ return;
775
+ }
776
+
777
+ // If piece not found and we haven't exceeded max attempts, try again
778
+ if (attempt < maxAttempts) {
779
+ setTimeout(() => {
780
+ this._waitForPieceAndReplace(square, promotionPiece, attempt + 1);
781
+ }, 50);
782
+ } else {
783
+ logger.warn('Failed to find piece for promotion after', maxAttempts, 'attempts');
784
+ this.chessboard._isPromoting = false;
785
+
786
+ // Force a board update to recover from the failed promotion
787
+ this.chessboard._updateBoardPieces(false);
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Replaces the piece on the square with the promotion piece
793
+ * @private
794
+ * @param {Square} square - Target square
795
+ * @param {string} promotionPiece - Piece to promote to
796
+ */
797
+ _replacePromotionPiece(square, promotionPiece) {
798
+ logger.debug('Replacing piece on', square.id, 'with', promotionPiece);
799
+
800
+ // Get the target square from the board service
801
+ const targetSquare = this.boardService.getSquare(square.id);
802
+ if (!targetSquare) {
803
+ logger.debug('Target square not found:', square.id);
804
+ return;
805
+ }
806
+
807
+ // Get the game state to determine the correct piece color
808
+ const gameState = this.chessboard.positionService.getGame();
809
+ const gamePiece = gameState.get(targetSquare.id);
810
+
811
+ if (!gamePiece) {
812
+ logger.debug('No piece found in game state for', targetSquare.id);
813
+ return;
814
+ }
815
+
816
+ // Get the current piece on the square
817
+ const currentPiece = targetSquare.piece;
818
+
819
+ if (!currentPiece) {
820
+ logger.warn('No piece found on target square for promotion');
821
+
822
+ // Try to recover by creating a new piece
823
+ const pieceId = promotionPiece + gamePiece.color;
824
+ const piecePath = this.chessboard.pieceService.getPiecePath(pieceId);
825
+
826
+ const newPiece = new Piece(
827
+ gamePiece.color,
828
+ promotionPiece,
829
+ piecePath
830
+ );
831
+
832
+ // Place the new piece on the square
833
+ targetSquare.putPiece(newPiece);
834
+
835
+ // Set up drag functionality
836
+ const dragFunction = this.chessboard._createDragFunction.bind(this.chessboard);
837
+ newPiece.setDrag(dragFunction(targetSquare, newPiece));
838
+
839
+ logger.debug('Created new promotion piece:', pieceId, 'on', targetSquare.id);
840
+ return;
841
+ }
842
+
843
+ // Create the piece ID and get the path
844
+ const pieceId = promotionPiece + gamePiece.color;
845
+ const piecePath = this.chessboard.pieceService.getPiecePath(pieceId);
846
+
847
+ logger.debug('Transforming piece to:', pieceId, 'with path:', piecePath);
848
+
849
+ // Use the new smooth transformation animation
850
+ currentPiece.transformTo(
851
+ promotionPiece,
852
+ piecePath,
853
+ 300, // Duration of the transformation animation
854
+ () => {
855
+ // After transformation, set up drag functionality
856
+ const dragFunction = this.chessboard._createDragFunction.bind(this.chessboard);
857
+ currentPiece.setDrag(dragFunction(targetSquare, currentPiece));
858
+
859
+ // Ensure hints are properly updated after promotion
860
+ if (this.config.hints && this.chessboard.moveService) {
861
+ setTimeout(() => {
862
+ this.chessboard.moveService.clearCache();
863
+ }, 100);
864
+ }
865
+
866
+ logger.debug('Successfully transformed piece on', targetSquare.id, 'to', pieceId);
867
+ }
868
+ );
869
+ }
870
+
871
+ /**
872
+ * Sets the clicked square
873
+ * @param {Square|null} square - Square to set as clicked
874
+ */
875
+ setClicked(square) {
876
+ this.clicked = square;
877
+ }
878
+
879
+ /**
880
+ * Gets the currently clicked square
881
+ * @returns {Square|null} Currently clicked square
882
+ */
883
+ getClicked() {
884
+ return this.clicked;
885
+ }
886
+
887
+ /**
888
+ * Sets the promotion state
889
+ * @param {string|boolean} promotion - Promotion piece type or false
890
+ */
891
+ setPromoting(promotion) {
892
+ this.promoting = promotion;
893
+ }
894
+
895
+ /**
896
+ * Gets the promotion state
897
+ * @returns {string|boolean} Current promotion state
898
+ */
899
+ getPromoting() {
900
+ return this.promoting;
901
+ }
902
+
903
+ /**
904
+ * Sets the animation state
905
+ * @param {boolean} isAnimating - Whether animations are in progress
906
+ */
907
+ setAnimating(isAnimating) {
908
+ this.isAnimating = isAnimating;
909
+ }
910
+
911
+ /**
912
+ * Gets the animation state
913
+ * @returns {boolean} Whether animations are in progress
914
+ */
915
+ getAnimating() {
916
+ return this.isAnimating;
917
+ }
918
+
919
+ /**
920
+ * Removes all existing event listeners
921
+ */
922
+ removeListeners() {
923
+ this.eventListeners.forEach((listeners, squareId) => {
924
+ listeners.forEach(({ element, type, handler }) => {
925
+ element.removeEventListener(type, handler);
926
+ });
927
+ });
928
+
929
+ this.eventListeners.clear();
930
+ }
931
+
932
+ /**
933
+ * Removes all event listeners
934
+ */
935
+ removeAllListeners() {
936
+ this.eventListeners.forEach((listeners, squareId) => {
937
+ listeners.forEach(({ element, type, handler }) => {
938
+ element.removeEventListener(type, handler);
939
+ });
940
+ });
941
+
942
+ this.eventListeners.clear();
943
+ }
944
+
945
+ /**
946
+ * Cleans up resources
947
+ */
948
+ destroy() {
949
+ this.removeAllListeners();
950
+ this.clicked = null;
951
+ this.promoting = false;
952
+ this.isAnimating = false;
953
+ this.isDragging = false;
954
+ }
955
+ }