| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { Classification, ClasifCss } from '../../classification/MoveClassifier.js'; |
|
|
| export const WHITE = 'w'; |
| export const BLACK = 'b'; |
|
|
| export const PAWN = 'p'; |
| export const KNIGHT = 'n'; |
| export const BISHOP = 'b'; |
| export const ROOK = 'r'; |
| export const QUEEN = 'q'; |
| export const KING = 'k'; |
|
|
| export const Sound = { |
| MOVE: 'move', |
| CAPTURE: 'capture', |
| CHECK: 'check', |
| CASTLE: 'castle', |
| PROMOTE: 'promote' |
| }; |
|
|
| export const Css = { |
| HIGHLIGHT: 'highlight', |
| SELECTED: 'selected-square', |
| DROPPABLE: 'ui-droppable-active', |
| JUST_MOVED: 'just-moved', |
| DROPPABLE_HOVER: 'ui-droppable-hover' |
| }; |
|
|
| export class DOMUtils { |
| static $(selector, context = document) { |
| return typeof selector === 'string' |
| ? selector.startsWith('#') ? context.getElementById(selector.slice(1)) : context.querySelector(selector) |
| : selector; |
| } |
|
|
| static $$(selector, context = document) { |
| return Array.from(context.querySelectorAll(selector)); |
| } |
|
|
| static addClass(element, className) { |
| if (!element) return; |
| const elements = Array.isArray(element) ? element : [element]; |
| elements.forEach(el => el.classList.add(...className.split(' '))); |
| } |
|
|
| static removeClass(element, className) { |
| if (!element) return; |
| const elements = Array.isArray(element) ? element : [element]; |
| elements.forEach(el => el.classList.remove(...className.split(' '))); |
| } |
|
|
| static toggleClass(element, className) { |
| element?.classList.toggle(className); |
| } |
|
|
| static hasClass(element, className) { |
| return element?.classList.contains(className); |
| } |
|
|
| static setStyle(element, styles) { |
| if (element && typeof styles === 'object') Object.assign(element.style, styles); |
| } |
|
|
| static getOffset(element) { |
| const rect = element.getBoundingClientRect(); |
| return { |
| top: rect.top + window.scrollY, |
| left: rect.left + window.scrollX, |
| width: rect.width, |
| height: rect.height |
| }; |
| } |
|
|
| static createElement(tag, options = {}) { |
| const element = document.createElement(tag); |
| Object.entries(options).forEach(([key, value]) => { |
| if (key === 'attributes') Object.entries(value).forEach(([k, v]) => element.setAttribute(k, v)); |
| else if (key === 'styles') Object.assign(element.style, value); |
| else element[key] = value; |
| }); |
| return element; |
| } |
|
|
| static on(element, events, handler, options = {}) { |
| if (element) events.split(' ').forEach(event => element.addEventListener(event, handler, options)); |
| } |
|
|
| static off(element, events, handler) { |
| if (element) events.split(' ').forEach(event => element.removeEventListener(event, handler)); |
| } |
|
|
| static empty(element) { |
| if (element) element.innerHTML = ''; |
| } |
|
|
| static append(parent, child) { |
| if (!parent) return; |
| typeof child === 'string' ? parent.insertAdjacentHTML('beforeend', child) : parent.appendChild(child); |
| } |
|
|
| static remove(element) { |
| element?.parentNode?.removeChild(element); |
| } |
|
|
| static find(element, selector) { |
| return element?.querySelector(selector) ?? null; |
| } |
| |
| static findAll(element, selector) { |
| return element?.querySelectorAll ? Array.from(element.querySelectorAll(selector)) : []; |
| } |
|
|
| static createStyleSheet(id) { |
| document.getElementById(id)?.remove(); |
| const style = document.createElement('style'); |
| style.id = id; |
| document.head.appendChild(style); |
| return style.sheet; |
| } |
|
|
| static addCSSRule(sheet, selector, rules) { |
| const ruleText = Object.entries(rules).map(([prop, val]) => `${prop}: ${val}`).join('; '); |
| sheet.insertRule(`${selector} { ${ruleText} }`, sheet.cssRules.length); |
| } |
|
|
| static injectKeyframes(sheet, name, keyframes) { |
| const keyframeText = Object.entries(keyframes) |
| .map(([key, rules]) => `${key} { ${Object.entries(rules).map(([prop, val]) => `${prop}: ${val}`).join('; ')} }`) |
| .join(' '); |
| sheet.insertRule(`@keyframes ${name} { ${keyframeText} }`, sheet.cssRules.length); |
| } |
| } |
|
|
| export class Chessboard { |
| |
| |
| |
| |
| |
| |
| constructor(selector, settings = {}, handler) { |
| |
| this.selector = selector; |
| this.id = Date.now().toString(36); |
| this.flipped = false; |
| this.selectedPiece = undefined; |
| this.pendingPromotion = null; |
| this.squareSize = 0; |
|
|
| |
| this.pieceCache = {}; |
| this.audioContext = new AudioContext(); |
| this.volumeNode = this.audioContext.createGain(); |
| this.audioBuffers = {}; |
| this.eventListeners = []; |
| this.canvasContext = null; |
| this.canvas = null; |
| this.isDestroyed = false; |
| this.styleSheet = null; |
|
|
| |
| this.arrows = []; |
| this.highlights = []; |
| this.squares = []; |
| this.events = {}; |
| this.chess = handler; |
|
|
| |
| this.boundOnResize = this._onResize.bind(this); |
| this.boundContextMenu = (event) => { event.preventDefault(); return false; }; |
| this.boundDocumentMouseMove = this._onDocumentMouseMove.bind(this); |
| this.boundDocumentMouseUp = this._onDocumentMouseUp.bind(this); |
| this.boundOnMouseDown = this._onMouseDown.bind(this); |
| this.boundOnMouseMove = this._onMouseMove.bind(this); |
| this.boundOnMouseUp = this._onMouseUp.bind(this); |
|
|
| this.settings = { |
| theme: { |
| boardLightSquareColor: 'rgba(224, 224, 224, 1)', |
| boardDarkSquareColor: 'rgba(110, 161, 118, 1)', |
| boardBackgroundPath: '', |
| boardImageBackground: false, |
| pieceFoldersPath: 'assets/pieces/', |
| pieceFolderName: 'cburnett', |
| pieceFormat: 'lichess', |
| soundFoldersPath: 'assets/sounds/', |
| soundFolderName: 'default' |
| }, |
| styling: { |
| fontFamily: 'Arial, sans-serif', |
| selectedSquareColor: 'rgba(255, 233, 38, 0.27)', |
| justMovedColor: 'rgba(255, 208, 0, 0.36)', |
| highlightColor: 'rgba(255, 82, 82, 0.71)', |
| arrowColor: 'rgba(223, 145, 0, 0.59)', |
| droppableIndicatorColor: 'rgba(0, 0, 0, 0.15)', |
| droppableHoverBorderColor: 'rgba(255, 255, 255, 0.781)', |
| droppableHoverBorderWidth: '5px', |
| captureIndicatorSize: 'calc(1.1vmin)', |
| captureIndicatorColor: 'rgba(0, 0, 0, 0.15)', |
| draggedPieceScale: 1.1, |
| hoverCursor: 'grab', |
| grabCursor: 'grabbing', |
| promotionPanelBackground: 'rgba(61, 61, 61, 0.75)', |
| promotionPanelShadow: '0 4px 8px rgba(0, 0, 0, 0.2)', |
| promotionPieceHoverColor: 'rgba(0, 0, 0, 0.1)', |
| promotionCancelBackground: 'rgb(29, 29, 29)', |
| promotionCancelHoverBackground: 'rgb(44, 44, 44)', |
| promotionCancelHeight: '40px', |
| classificationSize: '50%', |
| classificationOffsetX: '150%', |
| classificationOffsetY: '-50%', |
| classificationBorderOffsetX: '110%', |
| classificationBorderOffsetY: '-10%', |
| notationFontSize: 'calc(2.5vmin)', |
| notationFontWeight: '600', |
| notationOffset: '5px' |
| }, |
| callbacks: { |
| onMove: null, onMoveCancelled: null, onCheck: null, onCheckmate: null, |
| onDraw: null, onStalemate: null, onDragStart: null, onDragMove: null, |
| onDrop: null, onPieceClick: null, onPromotion: null, onPromotionStart: null, |
| onPromotionComplete: null, onPositionChange: null, onOrientationChange: null, |
| onClear: null, onDestroy: null, onHighlight: null, onArrowCreate: null, |
| onUserMove: null, |
| }, |
|
|
| audioEnabled: true, |
| isInteractive: true, |
| showBoardLabels: true, |
|
|
| draggingEnabled: true, |
| clickingEnabled: true, |
|
|
| pieceDragThreshold: -1, |
| pieceClickThreshold: 40, |
|
|
| pieceAnimationDuration: 0.1, |
| pieceAnimationEasing: 'ease-out', |
| |
| pieceRevertDuration: 0.1, |
| pieceRevertEasing: 'ease-out' |
| }; |
|
|
| if (settings && typeof settings === 'object') this._mergeSettings(settings); |
| this._initializeCallbacks(); |
| this.init(); |
| } |
|
|
| |
| |
| |
| |
| init() { |
| if (this.isDestroyed) return; |
| |
| this._createStyleSheet(); |
| const container = DOMUtils.$(this.selector); |
|
|
| DOMUtils.append(container, ` |
| <div id="${this.id}" class='chessboard'> |
| <canvas id='overlay-${this.id}' data-chessboard-id='${this.id}' class='board-overlay'></canvas> |
| <div id='squares-${this.id}' data-chessboard-id='${this.id}' class='board-squares'></div> |
| </div> |
| `); |
|
|
| const chessboard = DOMUtils.$('#squares-' + this.id); |
| for (let i = 0; i < 64; i++) { |
| const color = (Math.floor(i / 8) + i) % 2 ? 'dark' : 'light'; |
| const square = DOMUtils.createElement('div', { |
| className: `square ${color}`, |
| attributes: { 'data-square': i.toString() } |
| }); |
| DOMUtils.append(chessboard, square); |
| this.squares.push(square); |
| } |
|
|
| if (!this.settings.isInteractive) DOMUtils.setStyle(container, { 'pointer-events': 'none' }); |
|
|
| this._cachePieces(); |
| this._cacheAudio(); |
| this._addTrackedEventListener(window, 'resize', this.boundOnResize); |
| this._addTrackedEventListener(container, 'contextmenu', this.boundContextMenu); |
| this.fen(); |
| this._initializeInput(); |
| this._render(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| destroy() { |
| if (this.isDestroyed) return; |
| |
| |
| this.emit('destroy'); |
| |
| |
| this.isDestroyed = true; |
| |
| |
| if (this.styleSheet) { |
| const styleElement = document.getElementById(`chessboard-styles-${this.id}`); |
| if (styleElement) { |
| styleElement.remove(); |
| } |
| this.styleSheet = null; |
| } |
| |
| |
| this.eventListeners.forEach(({ element, eventName, handler }) => { |
| try { |
| element.removeEventListener(eventName, handler); |
| } catch (error) { |
| console.warn('Error removing event listener:', error); |
| } |
| }); |
| this.eventListeners = []; |
| |
| |
| if (this.audioContext) { |
| try { |
| |
| if (this.volumeNode) { |
| this.volumeNode.disconnect(); |
| this.volumeNode = null; |
| } |
| |
| |
| if (this.audioContext.state !== 'closed') { |
| this.audioContext.close(); |
| } |
| this.audioContext = null; |
| } catch (error) { |
| console.warn('Error cleaning up AudioContext:', error); |
| } |
| } |
| |
| |
| this.audioBuffers = {}; |
| |
| |
| this.pieceCache = {}; |
| |
| |
| this.canvasContext = null; |
| this.canvas = null; |
| |
| |
| this.selectedPiece = undefined; |
| this.pendingPromotion = null; |
| this.dragPiece = undefined; |
| this.hoveredSquare = undefined; |
| this.dragStartXY = null; |
| this.startDragIndex = null; |
|
|
| |
| this.arrows = []; |
| this.highlights = []; |
| this.squares = []; |
| |
| |
| try { |
| const boardElement = DOMUtils.$(`#${this.id}`); |
| if (boardElement) { |
| DOMUtils.remove(boardElement); |
| } |
| } catch (error) { |
| console.warn('Error removing DOM elements:', error); |
| } |
| |
| |
| this.boundOnResize = null; |
| this.boundContextMenu = null; |
| this.boundDocumentMouseMove = null; |
| this.boundDocumentMouseUp = null; |
| this.boundOnMouseDown = null; |
| this.boundOnMouseMove = null; |
| this.boundOnMouseUp = null; |
| |
| |
| this.chess = null; |
| } |
|
|
| |
| |
| |
| |
| _canInteract() { |
| return !this.isDestroyed && this.settings.isInteractive; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async setOption(keyOrOptions, value) { |
| const updates = typeof keyOrOptions === 'string' |
| ? { [keyOrOptions]: value } |
| : this._flattenOptions(keyOrOptions); |
| |
| let stylingChanged = false; |
| let piecesChanged = false; |
| let audioChanged = false; |
| |
| for (const [key, val] of Object.entries(updates)) { |
| this._setNestedOption(key, val); |
| |
| |
| if (key.startsWith('styling.') || key.startsWith('theme.board') || key === 'showBoardLabels') { |
| stylingChanged = true; |
| } |
| |
| |
| if (key.startsWith('theme.piece') || key.startsWith('theme.customPieceUrls')) { |
| piecesChanged = true; |
| } |
| |
| |
| if (key.startsWith('theme.sound') || key.startsWith('theme.customSoundUrls') || key === 'audioEnabled') { |
| audioChanged = true; |
| } |
| } |
| |
| |
| if (piecesChanged) { |
| this.pieceCache = {}; |
| await this._cachePieces(); |
| } |
| |
| if (audioChanged) { |
| this.audioBuffers = {}; |
| this._cacheAudio(); |
| } |
| |
| if (stylingChanged && this.styleSheet) { |
| this._createStyleSheet(); |
| } |
|
|
| this.refresh(true); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| getOption(path) { |
| const keys = path.split('.'); |
| let current = this.settings; |
| for (const key of keys) { |
| if (!(key in current)) throw new Error(`Invalid option: ${path}`); |
| current = current[key]; |
| } |
| return current; |
| } |
|
|
| |
| |
| |
| |
| |
| _mergeSettings(userSettings) { |
| const merge = (target, source) => { |
| for (const key in source) { |
| if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { |
| if (!target[key] || typeof target[key] !== 'object') target[key] = {}; |
| merge(target[key], source[key]); |
| } else { |
| target[key] = source[key]; |
| } |
| } |
| }; |
| merge(this.settings, userSettings); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _setNestedOption(path, value) { |
| const parts = path.split('.'); |
| const lastPart = parts.pop(); |
| const target = parts.reduce((obj, key) => obj[key] = obj[key] || {}, this.settings); |
| target[lastPart] = value; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _flattenOptions(obj, prefix = '') { |
| return Object.entries(obj).reduce((acc, [key, value]) => { |
| const newKey = prefix ? `${prefix}.${key}` : key; |
| return value && typeof value === 'object' && !Array.isArray(value) |
| ? { ...acc, ...this._flattenOptions(value, newKey) } |
| : { ...acc, [newKey]: value }; |
| }, {}); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async setCustomPieceUrl(color, type, url) { |
| if (!['w', 'b'].includes(color)) throw new Error("Color must be 'w' or 'b'"); |
| if (!['k', 'q', 'r', 'b', 'n', 'p'].includes(type)) throw new Error("Type must be one of: 'k', 'q', 'r', 'b', 'n', 'p'"); |
| |
| this.settings.theme.customPieceUrls[`${color}_${type}`] = url; |
| this.pieceCache = {}; |
| await this._cachePieces(); |
| this.refresh(true); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async setCustomSoundUrl(sound, url) { |
| if (!Object.values(Sound).includes(sound)) throw new Error(`Invalid sound: ${sound}`); |
| this.settings.theme.customSoundUrls[sound] = url; |
| this.audioBuffers = {}; |
| await this._cacheAudio(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async removeCustomPieceUrl(color, type) { |
| delete this.settings.theme.customPieceUrls[`${color}_${type}`]; |
| this.pieceCache = {}; |
| await this._cachePieces(); |
| this.refresh(true); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async removeCustomSoundUrl(sound) { |
| delete this.settings.theme.customSoundUrls[sound]; |
| this.audioBuffers = {}; |
| await this._cacheAudio(); |
| } |
|
|
| |
| |
| |
| |
| |
| on(event, callback) { |
| if (!this.events[event]) this.events[event] = []; |
| this.events[event].push(callback); |
| return this; |
| } |
|
|
| |
| |
| |
| |
| |
| off(event, callback) { |
| if (!this.events[event]) return this; |
| if (callback) { |
| this.events[event] = this.events[event].filter(cb => cb !== callback); |
| } else { |
| delete this.events[event]; |
| } |
| return this; |
| } |
|
|
| |
| |
| |
| |
| |
| once(event, callback) { |
| const onceWrapper = (...args) => { |
| callback(...args); |
| this.off(event, onceWrapper); |
| }; |
| return this.on(event, onceWrapper); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| emit(event, ...args) { |
| if (!this.events[event]) return this; |
| let result = undefined; |
| this.events[event].forEach(callback => { |
| try { |
| const callbackResult = callback(...args); |
| if (callbackResult !== undefined) result = callbackResult; |
| } catch (error) { |
| console.error(`Error in event handler for '${event}':`, error); |
| } |
| }); |
| return result !== undefined ? result : this; |
| } |
|
|
| |
| |
| |
| removeAllListeners() { |
| this.events = {}; |
| return this; |
| } |
|
|
| |
| |
| |
| |
| _initializeCallbacks() { |
| const callbacks = this.settings.callbacks; |
| if (!callbacks) return; |
| Object.entries(callbacks).forEach(([eventName, callback]) => { |
| if (typeof callback === 'function') { |
| const event = eventName.replace(/^on/, '').toLowerCase(); |
| this.on(event, callback); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _addTrackedEventListener(element, events, handler, options = {}) { |
| if (this.isDestroyed) return; |
| events.split(' ').forEach(eventName => { |
| element.addEventListener(eventName, handler, options); |
| this.eventListeners.push({ element, eventName, handler, options }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _removeTrackedEventListener(element, events, handler) { |
| events.split(' ').forEach(eventName => { |
| element.removeEventListener(eventName, handler); |
| this.eventListeners = this.eventListeners.filter(listener => |
| !(listener.element === element && listener.eventName === eventName && listener.handler === handler) |
| ); |
| }); |
| } |
|
|
| |
| |
| |
| |
| getState() { |
| return { |
| fen: this.chess._currentFen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', |
| flipped: this.flipped, |
| selectedPiece: this.selectedPiece, |
| arrows: [...this.arrows], |
| highlights: [...this.highlights], |
| history: this.chess.history ? this.chess.history({ verbose: true }) : [], |
| turn: this.chess.turn(), |
| }; |
| } |
|
|
| |
| |
| |
| |
| setState(state) { |
| if (state.fen) { |
| this.fen(state.fen); |
| } |
| if (typeof state.flipped === 'boolean' && state.flipped !== this.flipped) { |
| this.flip(); |
| } |
| if (state.arrows) { |
| this.arrows = [...state.arrows]; |
| } |
| if (state.highlights) { |
| this.highlights = [...state.highlights]; |
| |
| this.highlights.forEach(highlight => { |
| const index = this.flipped ? 63 - highlight : highlight; |
| const square = this.getSquare(index); |
| square.classList.add(Css.HIGHLIGHT); |
| }); |
| } |
| this._render(); |
| this.emit('positionchange', state.fen); |
| } |
|
|
| |
| |
| |
| |
| |
| fen(fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1') { |
| this.chess.load(fen); |
| this.refresh(); |
| this.clearBoardElements(); |
| this.clearBoardHighlights(); |
| } |
|
|
| |
| |
| |
| flip() { |
| this.flipped = !this.flipped; |
| this.refresh(true); |
| this.clearBoardHighlights(); |
|
|
| this.highlights.forEach(highlight => { |
| const index = this.flipped ? 63 - highlight : highlight; |
| this.getSquare(index).classList.add(Css.HIGHLIGHT); |
| }); |
|
|
| this.arrows.forEach(arrow => { |
| arrow.forEach((_, j) => arrow[j] = 63 - arrow[j]); |
| }); |
| |
| |
| this._updateBoardLabels(); |
| |
| this._render(); |
| this.emit('orientationchange', this.flipped); |
| } |
|
|
| |
| |
| |
| |
| refresh(clearHighlights = false) { |
| this.selectedPiece = undefined; |
| if (!clearHighlights) { |
| this.clearBoardElements(); |
| this.clearBoardHighlights(); |
| } |
| DOMUtils.$$(`#squares-${this.id} .square`).forEach(DOMUtils.empty); |
| this._load(); |
| } |
|
|
| |
| |
| |
| |
| getPossibleMoves() { |
| if (this.chess.moves) { |
| return this.chess.moves({ verbose: true }); |
| } |
| return []; |
| } |
|
|
| |
| |
| |
| |
| |
| getMoveHistory(options = {}) { |
| if (this.chess.history) { |
| return this.chess.history(options); |
| } |
| return []; |
| } |
|
|
| |
| |
| |
| |
| |
| getSquareInfo(square) { |
| const piece = this.chess.get ? this.chess.get(square) : null; |
| const moves = this.chess.moves ? this.chess.moves({ square, verbose: true }) : []; |
| |
| return { |
| square, |
| piece, |
| legalMoves: moves, |
| isAttacked: false, |
| isDefended: false, |
| }; |
| } |
|
|
| |
| |
| |
| |
| exportPGN() { |
| if (this.chess.pgn) { |
| return this.chess.pgn(); |
| } |
| |
| const history = this.getMoveHistory(); |
| return history.join(' '); |
| } |
|
|
| |
| |
| |
| |
| |
| importPGN(pgn) { |
| try { |
| if (this.chess.loadPgn) { |
| const success = this.chess.loadPgn(pgn); |
| if (success) { |
| this.refresh(); |
| this.emit('positionchange', this.chess._currentFen); |
| } |
| return success; |
| } |
| return false; |
| } catch (error) { |
| console.error('Error importing PGN:', error); |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| move(move, animate = false, classification = undefined, fenBefore = null, showPromotionUI = true, promotedPiece = null, wasUserMove = false) { |
| |
| if (this.pendingPromotion) return null; |
|
|
| const fromIdx = this.algebraicToIndex(move.from, this.flipped); |
| const toIdx = this.algebraicToIndex(move.to, this.flipped); |
| |
| const currentSquare = this.getSquare(fromIdx); |
| const targetSquare = this.getSquare(toIdx); |
|
|
| |
| this.selectedPiece = fromIdx; |
| |
| |
| if (showPromotionUI && this._isPawnPromotion(move.from, move.to)) { |
| this.pendingPromotion = { |
| from: move.from, |
| to: move.to, |
| fromIdx: fromIdx, |
| toIdx: toIdx, |
| animate: animate, |
| fenBefore: fenBefore |
| }; |
| |
| |
| this.emit('promotionstart', move.to); |
| |
| this._showPromotionPanel(fromIdx, toIdx); |
| return null; |
| } else if (this._isPawnPromotion(move.from, move.to)) { |
| this.pendingPromotion = { |
| from: move.from, |
| to: move.to, |
| fromIdx: fromIdx, |
| toIdx: toIdx, |
| animate: animate, |
| fenBefore: fenBefore |
| }; |
| this._completePromotion(promotedPiece, false); |
| if (classification) this.addClassification(classification, currentSquare, targetSquare); |
| return null; |
| } |
| |
| if (fenBefore) this.chess.load(fenBefore); |
| const piece = DOMUtils.$('img', currentSquare); |
|
|
| if (!currentSquare || !piece) { |
| return console.error(`Invalid move: No piece at square ${move.from}`); |
| } |
|
|
| this._updateBoard(currentSquare, targetSquare, piece); |
|
|
| const moveResult = this.chess.move({ |
| from: move.from, |
| to: move.to, |
| promotion: promotedPiece |
| }); |
| |
| if (!moveResult) { |
| return console.error(`Illegal move from ${move.from} to ${move.to}`); |
| } |
|
|
| |
| if (animate) { |
| this._animatePiece(piece, currentSquare); |
| } |
|
|
| |
| const handled = this._handleSpecialMoves(moveResult, move.to, classification); |
| if (classification) { |
| this.addClassification(classification, currentSquare, targetSquare); |
| } |
| if (!handled) this._playSoundBasedOnOutcome(moveResult); |
| |
| |
| this.emit('move', moveResult); |
| this.emit('positionchange', this.chess._currentFen); |
|
|
| if (wasUserMove) this.emit('usermove', moveResult); |
| |
| |
| if (this.chess.inCheck && this.chess.inCheck()) { |
| this.emit('check', this.chess.turn()); |
| } |
| |
| return moveResult; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| unmove(animate = false, lastMove = null, fenBefore = null) { |
| |
| if (!lastMove) { |
| |
| const history = this.chess.history({ verbose: true }); |
| if (history.length === 0) { |
| return null; |
| } |
| |
| |
| lastMove = history[history.length - 1]; |
| } |
| |
| |
| const fromIndex = this.algebraicToIndex(lastMove.from, this.flipped); |
| const toIndex = this.algebraicToIndex(lastMove.to, this.flipped); |
| |
| |
| const isPromotion = !!lastMove.promotion; |
| |
| |
| const fromSquare = this.getSquare(fromIndex); |
| const toSquare = this.getSquare(toIndex); |
| |
| |
| |
| if (!isPromotion) { |
| const piece = DOMUtils.$('img', toSquare); |
| |
| if (!toSquare || !piece) { |
| console.error(`Invalid unmove: No piece at square ${toIndex}`); |
| return null; |
| } |
| |
| |
| this._updateBoard(toSquare, fromSquare, piece, true); |
| if (animate) this._animatePiece(piece, toSquare); |
| } else { |
| |
| DOMUtils.empty(toSquare); |
| } |
|
|
| |
| if (this._handleSpecialUnmoves(lastMove, animate)) { |
| |
| } else { |
| |
| if (lastMove.captured) { |
| const capturedPieceColor = lastMove.color === WHITE ? BLACK : WHITE; |
| this._createPiece(toIndex, lastMove.captured, capturedPieceColor); |
| this._playSound(Sound.CAPTURE); |
| } else { |
| this._playSound(Sound.MOVE); |
| } |
| } |
| |
| |
| if (fenBefore) { |
| this.chess.load(fenBefore); |
| } else { |
| this.chess.undo(); |
| } |
| |
| return lastMove; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| createArrow(start, end) { |
| const startIndex = this.getSquareIndex(start); |
| const endIndex = this.getSquareIndex(end); |
|
|
| |
| const existingArrow = this.arrows.find(([s, e]) => s === startIndex && e === endIndex); |
| |
| |
| if (existingArrow) { |
| this.arrows = this.arrows.filter(arrow => arrow !== existingArrow); |
| } else { |
| this.arrows.push([startIndex, endIndex]); |
| } |
| |
| |
| this._render(); |
| |
| |
| const fromSquare = this.indexToAlgebraic(startIndex, this.flipped); |
| const toSquare = this.indexToAlgebraic(endIndex, this.flipped); |
| this.emit('arrowcreate', fromSquare, toSquare); |
| } |
|
|
| |
| |
| |
| clearBoardElements() { |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), Css.HIGHLIGHT); |
| this.arrows = []; |
| this.highlights = []; |
| this._render(); |
| this.emit('clear'); |
| } |
|
|
| |
| |
| |
| clearBoardHighlights() { |
| const classifications = DOMUtils.findAll(DOMUtils.$(`#squares-${this.id}`), '.classification'); |
| for (const classification of classifications) { |
| DOMUtils.remove(classification); |
| } |
|
|
| const cssClassifications = DOMUtils.findAll(DOMUtils.$(`#squares-${this.id}`), '.board-classification'); |
| for (const classification of cssClassifications) { |
| DOMUtils.removeClass(classification, 'board-classification'); |
| } |
| |
| const allClasses = Object.values(Css); |
| for (const className of allClasses) { |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), className); |
| } |
|
|
| const allClassifications = Object.values(ClasifCss); |
| for (const className of allClassifications) { |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), className); |
| } |
| } |
|
|
| |
| |
| |
| |
| _attemptMove(fromIndex, toIndex, animate = false) { |
| const legalDestinations = this._getLegalDestinations(fromIndex); |
| if (!legalDestinations.includes(toIndex) || this.pendingPromotion !== null) { |
| |
| const fromSquare = this.indexToAlgebraic(fromIndex, this.flipped); |
| const toSquare = this.indexToAlgebraic(toIndex, this.flipped); |
| this.emit('movecancelled', { from: fromSquare, to: toSquare }); |
| return false; |
| } |
| |
| this.move({ |
| from: this.indexToAlgebraic(fromIndex, this.flipped), |
| to: this.indexToAlgebraic(toIndex, this.flipped) |
| }, animate, undefined, undefined, true, undefined, true); |
| return true; |
| } |
|
|
| |
| |
| |
| |
| _getLegalDestinations(fromIndex) { |
| if (this.pendingPromotion) return []; |
|
|
| const fromAlgebraic = this.indexToAlgebraic(fromIndex, this.flipped); |
| const moves = this.chess.moves({ square: fromAlgebraic, verbose: true }); |
| |
| return moves.map(move => this.algebraicToIndex(move.to, this.flipped)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _handleSpecialMoves(moveResult, targetSquareIndex, classification) { |
| if (this._handleCastling(moveResult)) return true; |
| if (this._handleEnPassant(moveResult)) return true; |
| if (this._handlePromotion(moveResult, targetSquareIndex, classification)) return true; |
| |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _handleCastling(moveResult) { |
| const castleType = moveResult.isKingsideCastle() |
| ? 'kingside' |
| : moveResult.isQueensideCastle() |
| ? 'queenside' |
| : null; |
| |
| if (!castleType) return false; |
| |
| const castleMap = { |
| w: {kingside: ['h8', 'f8'], queenside: ['a8', 'd8']}, |
| b: {kingside: ['h1', 'f1'], queenside: ['a1', 'd1']} |
| }; |
| |
| const opponentColor = moveResult.color === WHITE ? BLACK : WHITE; |
| const [from, to] = castleMap[opponentColor][castleType]; |
| const fromSquare = this.getSquare(this.algebraicToIndex(from, this.flipped)); |
| const toSquare = this.getSquare(this.algebraicToIndex(to, this.flipped)); |
| const piece = DOMUtils.$('img', fromSquare); |
| |
| this._updateBoard(fromSquare, toSquare, piece, false); |
| this._animatePiece(piece, fromSquare); |
| this._playSound(Sound.CASTLE); |
| |
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _handleEnPassant(moveResult) { |
| if (moveResult.isEnPassant()) { |
| const direction = this.chess.turn() === WHITE ? -1 : 1; |
| const offset = this.flipped ? -8 : 8; |
| const capturedSquareIndex = |
| this.algebraicToIndex(moveResult.to, this.flipped) + offset * direction; |
| const capturedSquare = this.getSquare(capturedSquareIndex); |
| if(capturedSquare) DOMUtils.empty(capturedSquare); |
| this._playSound(Sound.CAPTURE); |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _handleSpecialUnmoves(lastMove, animate = false) { |
| if (this._handleUndoCastling(lastMove, animate)) return true; |
| if (this._handleUndoPromotion(lastMove, animate)) return true; |
| if (this._handleUndoEnPassant(lastMove)) return true; |
| |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _handleUndoCastling(moveResult, animate = false) { |
| const castleType = moveResult.isKingsideCastle() |
| ? 'kingside' |
| : moveResult.isQueensideCastle() |
| ? 'queenside' |
| : null; |
| |
| if (!castleType) return false; |
|
|
| |
| const castleMap = { |
| w: {kingside: ['f8', 'h8'], queenside: ['d8', 'a8']}, |
| b: {kingside: ['f1', 'h1'], queenside: ['d1', 'a1']} |
| }; |
| |
| const opponentColor = moveResult.color === WHITE ? BLACK : WHITE; |
| const [from, to] = castleMap[opponentColor][castleType]; |
| const fromSquare = this.getSquare(this.algebraicToIndex(from, this.flipped)); |
| const toSquare = this.getSquare(this.algebraicToIndex(to, this.flipped)); |
| const piece = DOMUtils.$('img', fromSquare); |
| |
| this._updateBoard(fromSquare, toSquare, piece, false); |
| if (animate) this._animatePiece(piece, fromSquare); |
| this._playSound(Sound.CASTLE); |
| |
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _handleUndoEnPassant(moveResult) { |
| if (moveResult.isEnPassant()) { |
| const direction = this.chess.turn() === WHITE ? -1 : 1; |
| const offset = this.flipped ? -8 : 8; |
| const capturedSquare = this.algebraicToIndex(moveResult.to, this.flipped) + offset * direction; |
|
|
| this._createPiece(capturedSquare, moveResult.captured, this.chess.turn()); |
| this._playSound(Sound.CAPTURE); |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _handlePromotion(moveResult, targetSquareIndex, classification) { |
| if (moveResult.isPromotion()) { |
| |
| const color = this.chess.turn() === WHITE ? BLACK : WHITE; |
| const promoted = moveResult.promotion || QUEEN; |
|
|
| |
| if (!this.pendingPromotion) { |
| const targetSquare = this.getSquare(targetSquareIndex); |
| |
| setTimeout(() => { |
| DOMUtils.empty(targetSquare); |
| this._createPiece(targetSquareIndex, promoted, color); |
| this._playSound(Sound.PROMOTE); |
|
|
| |
| if (classification) { |
| this.addClassification(classification, targetSquare, targetSquare); |
| } |
| }, 50); |
| } |
| |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _isPawnPromotion(fromAlg, toAlg) { |
| const piece = this.chess.get(fromAlg); |
| |
| |
| if (!piece || piece.type !== PAWN) return false; |
| |
| |
| const toRank = parseInt(toAlg[1]); |
| return (piece.color === WHITE && toRank === 8) || |
| (piece.color === BLACK && toRank === 1); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _showPromotionPanel(fromSquareId, toSquareId) { |
| const board = this; |
| DOMUtils.remove(DOMUtils.$(`#promotion-panel-${this.id}`)); |
|
|
| |
| this.clearBoardElements(); |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), Css.DROPPABLE); |
|
|
| const target = this.getSquare(toSquareId); |
| const color = this.chess.turn(); |
| const bottom = Math.floor(toSquareId / 8) > 4; |
|
|
| const panel = DOMUtils.createElement('div', { id: `promotion-panel-${this.id}`, className: 'promotion-panel' }); |
| DOMUtils.append(target, panel); |
| |
| if (bottom) { |
| DOMUtils.addClass(panel, 'bottom'); |
| DOMUtils.setStyle(panel, { bottom: '0px' }); |
| } else { |
| DOMUtils.setStyle(panel, { top: '0px' }); |
| } |
|
|
| |
| const pieces = [QUEEN, ROOK, BISHOP, KNIGHT]; |
| for (const type of pieces) { |
| const cacheKey = `${color}_${type}`; |
| const pieceDiv = DOMUtils.createElement('div', { |
| className: 'promotion-piece', |
| attributes: { 'data-piece': type } |
| }); |
| |
| if (this.pieceCache?.[cacheKey]) { |
| const originalImg = this.pieceCache[cacheKey]; |
| if (originalImg) { |
| const clone = originalImg.cloneNode(true); |
| clone.alt = type; |
| DOMUtils.append(pieceDiv, clone); |
| } |
| } else { |
| |
| const url = this._getPieceUrl(color, type); |
| DOMUtils.append(pieceDiv, `<img src='${url}' alt='${type}'>`); |
| } |
| |
| DOMUtils.append(panel, pieceDiv); |
| } |
| |
| const cancelDiv = DOMUtils.createElement('div', { className: 'promotion-cancel', textContent: '✕' }); |
| DOMUtils.append(panel, cancelDiv); |
| |
| |
| const handlePieceSelection = (e) => { |
| const promotionPiece = e.target.closest('.promotion-piece'); |
| if (promotionPiece) { |
| board._completePromotion(promotionPiece.dataset.piece); |
| return; |
| } |
|
|
| const cancelEl = e.target.closest('.promotion-cancel'); |
| if (cancelEl) { |
| board._cancelPromotion(fromSquareId); |
| } |
| }; |
|
|
| |
| DOMUtils.on(panel, 'click touchend', e => { |
| if (e.type === 'touchend') { |
| e.preventDefault(); |
| } |
| handlePieceSelection(e); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _completePromotion(pieceType, usermove = true) { |
| if (!this.pendingPromotion) return; |
|
|
| const {from, to, fromIdx, toIdx, animate, fenBefore} = this.pendingPromotion; |
| const fromSquare = this.getSquare(fromIdx); |
| const toSquare = this.getSquare(toIdx); |
| const piece = DOMUtils.$('img', fromSquare); |
| |
| DOMUtils.remove(DOMUtils.$(`#promotion-panel-${this.id}`)); |
| this._updateBoard(fromSquare, toSquare, piece); |
| if (fenBefore) this.chess.load(fenBefore); |
| |
| const move = this.chess.move({from, to, promotion: pieceType}); |
| if (!move) { |
| console.error(`Illegal promotion move from ${from} to ${to}`); |
| this.pendingPromotion = null; |
| return null; |
| } |
|
|
| DOMUtils.empty(toSquare); |
| this._createPiece(toIdx, pieceType, this.chess.turn() === WHITE ? BLACK : WHITE); |
| |
| if (animate) this._animatePiece(DOMUtils.$('img', toSquare), fromSquare); |
| this._playSound(Sound.PROMOTE); |
|
|
| |
| this.emit('promotioncomplete', pieceType); |
| this.onPromotionComplete?.(move); |
| this.pendingPromotion = null; |
|
|
| if (usermove) this.emit('usermove', move); |
|
|
| return move; |
| } |
|
|
| |
| |
| |
| |
| |
| _cancelPromotion(fromSquareId) { |
| |
| DOMUtils.remove(DOMUtils.$(`#promotion-panel-${this.id}`)); |
| |
| |
| if (typeof this.onPromotionComplete === 'function') { |
| this.onPromotionComplete(null); |
| } |
|
|
| const currentSquare = this.getSquare(fromSquareId); |
| const piece = DOMUtils.$('img', currentSquare); |
| if(piece) DOMUtils.setStyle(piece, { top: '0px', left: '0px' }); |
|
|
| this.pendingPromotion = null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _handleUndoPromotion(moveResult, animate = false) { |
| if (moveResult.promotion) { |
| |
| const fromIndex = this.algebraicToIndex(moveResult.from, this.flipped); |
| const toIndex = this.algebraicToIndex(moveResult.to, this.flipped); |
| |
| const fromSquare = this.getSquare(fromIndex); |
| const toSquare = this.getSquare(toIndex); |
| |
| |
| if (animate) { |
| |
| this._createPiece(toIndex, PAWN, moveResult.color); |
| const pawn = DOMUtils.find(toSquare, 'img'); |
| |
| |
| this._updateBoard(toSquare, fromSquare, pawn, false); |
| this._animatePiece(pawn, toSquare); |
| } else { |
| |
| DOMUtils.empty(fromSquare); |
| DOMUtils.empty(toSquare); |
| |
| |
| this._createPiece(fromIndex, PAWN, moveResult.color); |
| } |
| |
| |
| if (moveResult.captured) { |
| if (animate) { |
| DOMUtils.empty(toSquare); |
| } |
| const capturedPieceColor = moveResult.color === WHITE ? BLACK : WHITE; |
| this._createPiece(toIndex, moveResult.captured, capturedPieceColor); |
| this._playSound(Sound.CAPTURE); |
| } |
| |
| return true; |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| _load() { |
| for (const row of this.chess.board()) { |
| for (const square of row) { |
| if (square) { |
| const index = this.algebraicToIndex(square.square, this.flipped); |
| this._createPiece(index, square.type, square.color); |
| } |
| } |
| } |
| this._onResize(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _createPiece(index, type, color) { |
| const square = this.getSquare(index); |
| const cacheKey = `${color}_${type}`; |
| |
| if (this.pieceCache?.[cacheKey]) { |
| const originalImg = this.pieceCache[cacheKey]; |
| if (originalImg) { |
| DOMUtils.append(square, originalImg.cloneNode(true)); |
| } else { |
| console.error(`Piece not found in cache for ${color}_${type}`); |
| } |
| } else { |
| DOMUtils.append(square, `<img class='ui-widget-content' src='${this._getPieceUrl(color, type)}' alt='${type}'/>`); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _updateBoard(from, to, piece, classes = true) { |
| DOMUtils.empty(to); |
| DOMUtils.append(to, piece); |
| DOMUtils.setStyle(piece, { top: '0px', left: '0px' }); |
| this.clearBoardElements(); |
| this.clearBoardHighlights(); |
| if (classes) { |
| DOMUtils.addClass(to, Css.JUST_MOVED); |
| DOMUtils.addClass(from, Css.JUST_MOVED); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _animatePiece(piece, from, duration = this.settings.pieceAnimationDuration, easing = this.settings.pieceAnimationEasing) { |
| let offsetTop = 0; |
| let offsetLeft = 0; |
|
|
| if (from instanceof HTMLElement) { |
| const fromRect = from.getBoundingClientRect(); |
| const pieceRect = piece.getBoundingClientRect(); |
| offsetTop = fromRect.top - pieceRect.top; |
| offsetLeft = fromRect.left - pieceRect.left; |
| } else { |
| offsetTop = from.top; |
| offsetLeft = from.left; |
| } |
|
|
| piece.style.transition = 'none'; |
| piece.style.transform = `translate(${offsetLeft}px, ${offsetTop}px)`; |
| piece.offsetHeight; |
| piece.style.transition = `transform ${duration}s ${easing}`; |
| piece.style.transform = 'translate(0, 0)'; |
| } |
|
|
| |
| |
| |
| |
| _render() { |
| if (this.isDestroyed) return; |
| |
| if (!this.canvas) { |
| const canvas = DOMUtils.$(`#overlay-${this.id}`); |
| if (!canvas) return; |
|
|
| this.canvas = canvas; |
| } |
|
|
| |
| if (!this.canvasContext) { |
| this.canvasContext = this.canvas.getContext('2d'); |
| } |
| |
| const ctx = this.canvasContext; |
| const squaresContainer = DOMUtils.$('#squares-' + this.id); |
| const squareSize = squaresContainer.clientWidth / 8; |
| const halfSquare = squareSize / 2; |
|
|
| this.canvas.width = this.canvas.clientWidth; |
| this.canvas.height = this.canvas.clientHeight; |
|
|
| for (const [from, to] of this.arrows) { |
| this._drawArrow( |
| this.canvas, ctx, |
| |
| (from % 8) * squareSize + halfSquare, Math.floor(from / 8) * squareSize + halfSquare, |
| (to % 8) * squareSize + halfSquare, Math.floor(to / 8) * squareSize + halfSquare |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| _onResize() { |
| if (this.isDestroyed) return; |
| |
| const board = DOMUtils.$("#squares-" + this.id); |
| this.squareSize = DOMUtils.getOffset(board).width / 8; |
| } |
|
|
| |
| |
| |
| |
| |
| _highlight(square) { |
| DOMUtils.toggleClass(square, 'highlight'); |
|
|
| const index = this.getSquareIndex(square); |
| const highlightIndex = this.highlights.indexOf(index); |
| |
| if (highlightIndex !== -1) { |
| this.highlights.splice(highlightIndex, 1); |
| } else { |
| this.highlights.push(index); |
| } |
| |
| const squareNotation = this.indexToAlgebraic(index, this.flipped); |
| this.emit('highlight', squareNotation); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _drawArrow(canvas, ctx, fromX, fromY, toX, toY) { |
| if (!canvas || !ctx) return; |
| |
| const s = canvas.width; |
| const headLength = s / 16; |
| ctx.lineWidth = s / 48; |
| ctx.fillStyle = ctx.strokeStyle = this.settings.styling.arrowColor; |
|
|
| |
| const threshold = 0.05; |
| const knightRatio = Math.abs((fromX - toX) / (fromY - toY)); |
| const length = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2); |
| if ((length / s < 0.35) && |
| (Math.abs(knightRatio - 0.5) < threshold || |
| Math.abs(knightRatio - 2) < threshold)) { |
| return this._drawKnightArrow(ctx, fromX, fromY, toX, toY, s, headLength); |
| } |
|
|
| const f = 0.865 * headLength; |
| const angle = Math.atan2(toY - fromY, toX - fromX); |
| const xOff = f * Math.cos(angle); |
| const yOff = f * Math.sin(angle); |
|
|
| const x1 = fromX + (s / 22) * Math.cos(angle); |
| const y1 = fromY + (s / 22) * Math.sin(angle); |
| const x2 = toX - xOff; |
| const y2 = toY - yOff; |
| |
| ctx.beginPath(); |
| ctx.moveTo(x1, y1); |
| ctx.lineTo(x2, y2); |
| ctx.stroke(); |
|
|
| this._drawArrowhead(ctx, toX, toY, angle, headLength); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _drawArrowhead(ctx, toX, toY, angle, length) { |
| if (!ctx) return; |
|
|
| ctx.beginPath(); |
| ctx.moveTo(toX, toY); |
| for (const a of [-Math.PI / 6, Math.PI / 6]) { |
| ctx.lineTo( |
| toX - length * Math.cos(angle + a), |
| toY - length * Math.sin(angle + a)); |
| } |
| ctx.closePath(); |
| ctx.fill(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _drawKnightArrow(ctx, fromX, fromY, toX, toY, s, headLength) { |
| if (!ctx) return; |
| |
| const dx = toX - fromX; |
| const dy = toY - fromY; |
| const horizontalFirst = Math.abs(dx) > Math.abs(dy); |
| const cornerX = horizontalFirst ? toX : fromX; |
| const cornerY = horizontalFirst ? fromY : toY; |
| |
| const dirX = Math.sign(dx) * (horizontalFirst ? -1 : 1); |
| const dirY = Math.sign(dy) * (horizontalFirst ? -1 : 1); |
|
|
| const f = 0.865 * headLength; |
| const angle = Math.atan2(toY - cornerY, toX - cornerX); |
| const xOff = f * Math.cos(angle); |
| const yOff = f * Math.sin(angle); |
| const offsetX = (s / 100) * dirX; |
| const offsetY = (s / 100) * dirY; |
|
|
| |
| const x1 = fromX - (horizontalFirst ? (s / 22) * dirX : 0); |
| const y1 = fromY + (!horizontalFirst ? (s / 22) * dirY : 0); |
| const x2 = !horizontalFirst ? cornerX : cornerX + offsetX; |
| const y2 = horizontalFirst ? cornerY : cornerY + offsetY; |
| const x3 = horizontalFirst ? cornerX : cornerX + offsetX; |
| const y3 = !horizontalFirst ? cornerY : cornerY + offsetY; |
| |
| ctx.beginPath(); |
| ctx.moveTo(x1, y1); |
| ctx.lineTo(x2, y2); |
| ctx.stroke(); |
| |
| ctx.beginPath(); |
| ctx.moveTo(x3, y3); |
| ctx.lineTo(toX - xOff, toY - yOff); |
| ctx.stroke(); |
|
|
| this._drawArrowhead(ctx, toX, toY, angle, headLength); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| addClassification(classification, from, to) { |
| let trueClassification; |
| if (typeof classification === 'object') { |
| trueClassification = classification; |
| } else { |
| trueClassification = Classification[classification.toUpperCase()]; |
| } |
| if (!trueClassification) return; |
|
|
| DOMUtils.addClass(to, `board-classification ${trueClassification.class}`); |
| DOMUtils.addClass(from, `board-classification ${trueClassification.class}`); |
|
|
| const index = this.getSquareIndex(to); |
| const col = index % 8; |
| const onBorder = col === 7 || index < 8; |
|
|
| if (trueClassification?.cachedImg) { |
| const clone = trueClassification.cachedImg.cloneNode(true); |
| DOMUtils.addClass(clone, 'classification'); |
| DOMUtils.removeClass(clone, 'move-icon'); |
| DOMUtils.addClass(clone, onBorder ? 'border' : ''); |
| DOMUtils.append(to, clone); |
| } else { |
| DOMUtils.append(to, `<img class='classification ${onBorder ? 'border' : ''}' src='${trueClassification.src}'>`); |
| } |
| } |
|
|
| |
| |
| |
| |
| _initializeInput() { |
| if (!this.settings.isInteractive || this.isDestroyed) return; |
|
|
| const board = DOMUtils.$(`#squares-${this.id}`); |
| if (!board) return; |
|
|
| |
| this._addTrackedEventListener(board, 'mousedown', this.boundOnMouseDown); |
| this._addTrackedEventListener(board, 'mousemove', this.boundOnMouseMove); |
| this._addTrackedEventListener(board, 'mouseup', this.boundOnMouseUp); |
| this._addTrackedEventListener(board, 'touchstart', this.boundOnMouseDown, { passive: false }); |
| this._addTrackedEventListener(board, 'touchmove', this.boundOnMouseMove, { passive: false }); |
| this._addTrackedEventListener(board, 'touchend', this.boundOnMouseUp); |
| } |
|
|
| |
| |
| |
| _onMouseDown(event) { |
| if (!this._canInteract()) return; |
|
|
| const { x, y } = this._getEventCoordinates(event); |
| const square = this.getSquareFromPosition(x, y); |
| if (!square) return; |
|
|
| this.dragStarted = false; |
| this.dragStartXY = { x, y }; |
|
|
| |
| if (!square.firstChild) { |
| |
| return; |
| }; |
|
|
| event.preventDefault(); |
|
|
| if (event.type === 'touchstart' || event.button === 0) { |
| this._startDrag(square, event); |
| } |
| } |
|
|
| |
| |
| |
| _onMouseMove(event, fromDocument = false) { |
| if (!this._canInteract()) return; |
|
|
| const { x, y } = this._getEventCoordinates(event); |
| this._continueDrag(x, y); |
| } |
|
|
| |
| |
| |
| _onMouseUp(event, fromDocument = false) { |
| if (!this._canInteract()) return; |
|
|
| const { x, y } = this._getEventCoordinates(event); |
|
|
| if (event.type === 'touchend' || event.button === 0) { |
| const wasClick = this._stopDrag(x, y); |
| if (wasClick && !fromDocument) this._handleClickLogic(event); |
| } else if (event.button === 2 && !fromDocument) { |
| const startSquare = this.getSquareFromPosition(this.dragStartXY?.x, this.dragStartXY?.y); |
| const endSquare = this.getSquareFromPosition(x, y); |
| if (!startSquare || !endSquare) return; |
|
|
| if (startSquare.dataset.square === endSquare.dataset.square) { |
| this._highlight(startSquare); |
| } else { |
| this.createArrow(startSquare, endSquare); |
| } |
| } |
| } |
|
|
| |
| |
| |
| _onDocumentMouseMove(event) { |
| this._onMouseMove(event, true); |
| } |
|
|
| |
| |
| |
| _onDocumentMouseUp(event) { |
| const { x, y } = this._getEventCoordinates(event); |
| this._stopDrag(x, y); |
| } |
|
|
| |
| |
| |
| _startDrag(square, event) { |
| if (!this.settings.isInteractive || this.isDestroyed) return; |
|
|
| const index = this.getSquareIndex(square); |
| const legalDestinations = this._getLegalDestinations(index); |
| |
| |
| const piece = square.firstChild; |
| const squareNotation = this.indexToAlgebraic(index, this.flipped); |
| const allowDrag = this.emit('dragstart', piece, squareNotation); |
| |
| |
| if (allowDrag === false) { |
| return; |
| } |
| |
| if (legalDestinations.length > 0) { |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), Css.DROPPABLE); |
| this.clearBoardElements(); |
| legalDestinations.forEach(index => DOMUtils.addClass(this.getSquare(index), Css.DROPPABLE)); |
| } |
|
|
| this.dragPiece = square.firstChild; |
| this.startDragIndex = index; |
|
|
| const { x, y } = this._getEventCoordinates(event); |
| this._continueDrag(x, y); |
|
|
| |
| ['mousemove', 'touchmove'].forEach(type => this._addTrackedEventListener(document, type, this.boundDocumentMouseMove, { passive: false })); |
| ['mouseup', 'touchend'].forEach(type => this._addTrackedEventListener(document, type, this.boundDocumentMouseUp)); |
| } |
|
|
| |
| |
| |
| _continueDrag(x, y) { |
| if (!this.settings.isInteractive || this.isDestroyed || !this.dragPiece) return; |
| |
| const start = this.dragStartXY; |
| const dragDistance = Math.sqrt((x - start.x) ** 2 + (y - start.y) ** 2); |
|
|
| if (dragDistance > this.settings.pieceDragThreshold) this.dragStarted = true; |
| if (!this.dragStarted) return; |
|
|
| |
| const boardRect = DOMUtils.$(`#squares-${this.id}`).getBoundingClientRect(); |
| const constrainedX = Math.max(boardRect.left, Math.min(x, boardRect.right)); |
| const constrainedY = Math.max(boardRect.top, Math.min(y, boardRect.bottom)); |
|
|
| this._movePieceToCursor(this.dragPiece, constrainedX, constrainedY); |
|
|
| const square = this.getSquareFromPosition(constrainedX, constrainedY); |
| if (this.hoveredSquare === square) return; |
|
|
| DOMUtils.removeClass(this.hoveredSquare, Css.DROPPABLE_HOVER); |
| DOMUtils.addClass(square, Css.DROPPABLE_HOVER); |
| this.hoveredSquare = square; |
| |
| |
| if (square) { |
| const squareNotation = this.indexToAlgebraic(this.getSquareIndex(square), this.flipped); |
| this.emit('dragmove', this.dragPiece, squareNotation); |
| } |
| } |
|
|
| |
| |
| |
| _stopDrag(x, y) { |
| if (!this.settings.isInteractive || this.isDestroyed || !this.dragPiece) return true; |
|
|
| |
| ['mousemove', 'touchmove'].forEach(type => this._removeTrackedEventListener(document, type, this.boundDocumentMouseMove)); |
| ['mouseup', 'touchend'].forEach(type => this._removeTrackedEventListener(document, type, this.boundDocumentMouseUp)); |
|
|
| const square = this.getSquareFromPosition(x, y); |
| if (!square) { |
| |
| const fromSquare = this.getSquare(this.startDragIndex); |
| this._animatePiece(this.dragPiece, { |
| top: this.dragPiece.offsetTop - fromSquare.offsetTop, |
| left: this.dragPiece.offsetLeft - fromSquare.offsetLeft |
| }, this.settings.pieceRevertDuration, this.settings.pieceRevertEasing); |
| |
| this._deselectPiece(); |
| } |
|
|
| const dragDistance = Math.sqrt((x - this.dragStartXY.x) ** 2 + (y - this.dragStartXY.y) ** 2); |
|
|
| if (this.dragStarted && dragDistance > this.settings.pieceClickThreshold && square) { |
| const toIndex = this.getSquareIndex(square); |
| const fromSquare = this.indexToAlgebraic(this.startDragIndex, this.flipped); |
| const toSquare = this.indexToAlgebraic(toIndex, this.flipped); |
| |
| |
| this.emit('drop', this.dragPiece, fromSquare, toSquare); |
| |
| if (this._attemptMove(this.startDragIndex, toIndex)) { |
| this._deselectPiece(); |
| } |
| } |
|
|
| |
| DOMUtils.setStyle(this.dragPiece, { height: 'unset', width: 'unset', top: '0px', left: '0px' }); |
| DOMUtils.removeClass(this.hoveredSquare, Css.DROPPABLE_HOVER); |
| DOMUtils.removeClass(this.dragPiece, 'being-dragged'); |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), Css.DROPPABLE); |
|
|
| this.dragPiece = undefined; |
| this.hoveredSquare = undefined; |
|
|
| return dragDistance <= this.settings.pieceClickThreshold; |
| } |
|
|
| |
| |
| |
| _movePieceToCursor(piece, x, y) { |
| const squareSize = this.squareSize; |
| const offset = piece.offsetWidth / 2; |
|
|
| DOMUtils.addClass(piece, 'being-dragged'); |
| DOMUtils.setStyle(piece, { |
| height: `${squareSize}px`, |
| width: `${squareSize}px`, |
| top: `${y - offset}px`, |
| left: `${x - offset}px` |
| }); |
| } |
|
|
| |
| |
| |
| _deselectPiece() { |
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), `${Css.DROPPABLE} ${Css.SELECTED}`); |
| this.selectedPiece = undefined; |
| } |
|
|
| |
| |
| |
| |
| _getEventCoordinates(event) { |
| const { clientX = 0, clientY = 0 } = event.touches?.[0] || event.changedTouches?.[0] || event; |
| return { x: clientX, y: clientY }; |
| } |
|
|
| |
| |
| |
| |
| _handleClickLogic(event) { |
| if (!this._canInteract() || this.pendingPromotion !== null) return; |
|
|
| const { x, y } = this._getEventCoordinates(event); |
| const square = this.getSquareFromPosition(x, y); |
| if (!square) return; |
| |
| const squareIndex = this.getSquareIndex(square); |
| this.clearBoardElements(); |
|
|
| |
| if (this.selectedPiece === squareIndex) { |
| return this._deselectPiece(); |
| } |
|
|
| |
| if (this.selectedPiece !== undefined && this._attemptMove(this.selectedPiece, squareIndex, true)) { |
| return this._deselectPiece(); |
| } |
|
|
| |
| this._selectPieceIfValid(square, squareIndex); |
| } |
|
|
| |
| |
| |
| |
| _selectPieceIfValid(square, squareIndex) { |
| if (this.isDestroyed || !DOMUtils.find(square, 'img')) { |
| return this._deselectPiece(); |
| } |
|
|
| DOMUtils.removeClass(DOMUtils.$$(`#squares-${this.id} .square`), `${Css.HIGHLIGHT} ${Css.DROPPABLE} ${Css.SELECTED}`); |
| |
| const legalDestinations = this._getLegalDestinations(squareIndex); |
| if (legalDestinations.length === 0) { |
| return this._deselectPiece(); |
| } |
| |
| DOMUtils.addClass(square, Css.SELECTED); |
| legalDestinations.forEach(index => { |
| const destSquare = this.getSquare(index); |
| if (destSquare) DOMUtils.addClass(destSquare, Css.DROPPABLE); |
| }); |
| |
| this.selectedPiece = squareIndex; |
| |
| |
| const piece = DOMUtils.find(square, 'img'); |
| const squareNotation = this.indexToAlgebraic(squareIndex, this.flipped); |
| this.emit('piececlick', piece, squareNotation); |
| } |
|
|
| |
| |
| |
| |
| |
| async _cachePieces() { |
| const loadPromises = []; |
| for (const color of [WHITE, BLACK]) { |
| for (const type of [KING, QUEEN, ROOK, BISHOP, KNIGHT, PAWN]) { |
| const key = `${color}_${type}`; |
| const url = this._getPieceUrl(color, type); |
| |
| loadPromises.push( |
| fetch(url) |
| .then(response => response.ok ? response.text() : Promise.reject(response.status)) |
| .then(svgText => { |
| const dataUri = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`; |
| this.pieceCache[key] = DOMUtils.createElement('img', { |
| attributes: { src: dataUri, alt: type } |
| }); |
| }) |
| .catch(error => console.error(`Failed to load ${url}:`, error)) |
| ); |
| } |
| } |
| await Promise.allSettled(loadPromises); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async _cacheAudio() { |
| this.volumeNode.gain.value = 0.5; |
| this.volumeNode.connect(this.audioContext.destination); |
| |
| for (const sound of Object.values(Sound)) { |
| const soundUrl = this.settings.theme.customSoundUrls?.[sound] || |
| `${this.settings.theme.soundFoldersPath}/${this.settings.theme.soundFolderName}/${sound}.mp3`; |
| |
| try { |
| const resp = await fetch(soundUrl); |
| if (!resp.ok) throw new Error(`HTTP error! status: ${resp.status}`); |
| const array = await resp.arrayBuffer(); |
| this.audioBuffers[sound] = await this.audioContext.decodeAudioData(array); |
| } catch (error) { |
| console.error(`Failed to load sound ${sound} from ${soundUrl}:`, error); |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _getPieceUrl(color, type) { |
| const pieceKey = `${color}_${type}`; |
| if (this.settings.theme.customPieceUrls?.[pieceKey]) { |
| return this.settings.theme.customPieceUrls[pieceKey]; |
| } |
| |
| const { pieceFoldersPath, pieceFolderName, pieceFormat } = this.settings.theme; |
| return pieceFormat === 'standard' |
| ? `${pieceFoldersPath}/${pieceFolderName}/${color}/${type}.svg` |
| : `${pieceFoldersPath}/${pieceFolderName}/${color}${type.toUpperCase()}.svg`; |
| } |
|
|
| |
| |
| |
| |
| |
| _playSound(sound) { |
| if (this.isDestroyed || !this.settings.audioEnabled) return; |
| |
| if (this.audioContext.state === 'suspended') this.audioContext.resume(); |
| if (!this.audioBuffers[sound]) return; |
| const src = this.audioContext.createBufferSource(); |
| |
| src.buffer = this.audioBuffers[sound]; |
| src.connect(this.volumeNode); |
| src.start(0); |
| } |
|
|
| |
| |
| |
| |
| |
| _playSoundBasedOnOutcome(moveResult) { |
| if (this.isDestroyed) return; |
| |
| if (this.chess.inCheck()) { |
| this._playSound(Sound.CHECK); |
| } else if (moveResult.isCapture()) { |
| this._playSound(Sound.CAPTURE); |
| } else { |
| this._playSound(Sound.MOVE); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| getSquare(squareIndex) { |
| return this.squares?.[squareIndex] || |
| DOMUtils.$(`#squares-${this.id} .square[data-square='${squareIndex}']`); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| getSquareIndex(square) { |
| return square ? parseInt(square.dataset.square, 10) : -1; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| getSquareFromPosition(x, y) { |
| if (!x || !y) return null; |
| const squaresContainer = DOMUtils.$(`#squares-${this.id}`); |
| if (!squaresContainer) return null; |
|
|
| for (const square of DOMUtils.$$('.square', squaresContainer)) { |
| const bounds = square.getBoundingClientRect(); |
| if (x >= bounds.left && x <= bounds.left + bounds.width && |
| y >= bounds.top && y <= bounds.top + bounds.height) { |
| return square; |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| indexToAlgebraic(index, flip = false) { |
| if (index < 0 || index > 63) throw new Error('Invalid index. Index must be between 0 and 63.'); |
| if (flip) index = 63 - index; |
| return String.fromCharCode(97 + (index % 8)) + (8 - Math.floor(index / 8)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| algebraicToIndex(notation, flip = false) { |
| if (!/^[a-h][1-8]$/.test(notation)) { |
| throw new Error('Invalid chess notation. Expected format: [a-h][1-8]'); |
| } |
| const column = notation.charCodeAt(0) - 97; |
| const row = 8 - parseInt(notation.charAt(1), 10); |
| const final = row * 8 + column; |
| return flip ? 63 - final : final; |
| } |
|
|
| |
| |
| |
| |
| _createStyleSheet() { |
| this.styleSheet = DOMUtils.createStyleSheet(`chessboard-styles-${this.id}`); |
| this._injectBaseStyles(); |
| this._injectAnimations(); |
| } |
|
|
| |
| |
| |
| |
| _injectBaseStyles() { |
| const s = this.settings.styling; |
| const theme = this.settings.theme; |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#${this.id}`, { |
| 'display': 'flex', |
| 'flex-direction': 'row', |
| 'align-items': 'center', |
| 'position': 'relative', |
| 'font-family': s.fontFamily |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id}`, { |
| 'width': '100%', |
| 'height': '100%', |
| 'aspect-ratio': '1 / 1', |
| 'max-width': '100%', |
| 'max-height': '100%', |
| 'display': 'grid', |
| 'grid-template-columns': 'repeat(8, minmax(0, 5fr))', |
| 'grid-template-rows': 'repeat(8, minmax(0, 5fr))', |
| 'padding': '0px', |
| 'margin': '0px', |
| 'overflow': 'hidden', |
| 'background-image': theme.boardImageBackground ? `url("${theme.boardBackgroundPath}")` : 'none', |
| 'background-size': 'cover', |
| 'background-repeat': 'no-repeat', |
| 'background-position': 'center center', |
| 'user-select': 'none', |
| '-webkit-touch-callout': 'none', |
| '-webkit-text-size-adjust': 'none', |
| '-webkit-user-select': 'none', |
| 'font-family': s.fontFamily |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#overlay-${this.id}`, { |
| 'position': 'absolute', |
| 'display': 'block', |
| 'width': '100%', |
| 'height': '100%', |
| 'aspect-ratio': '1 / 1', |
| 'max-width': '100%', |
| 'max-height': '100%', |
| 'pointer-events': 'none', |
| 'z-index': '10' |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square`, { |
| 'width': '100%', |
| 'height': '100%', |
| 'display': 'flex', |
| 'position': 'relative' |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square.dark`, { |
| 'background-color': theme.boardDarkSquareColor |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square.light`, { |
| 'background-color': theme.boardLightSquareColor |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square:has(> img)`, { |
| 'cursor': s.hoverCursor |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id}:has(img.being-dragged) .square`, { |
| 'cursor': s.grabCursor |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square img`, { |
| 'pointer-events': 'none', |
| 'z-index': '2' |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square .being-dragged`, { |
| 'position': 'fixed', |
| 'scale': s.draggedPieceScale.toString(), |
| 'z-index': '100' |
| }); |
|
|
| |
| this._injectBoardStates(); |
|
|
| |
| this._injectPromotionStyles(); |
|
|
| |
| this._injectClassificationStyles(); |
|
|
| |
| this._injectBoardLabelStyles(); |
| } |
|
|
| |
| |
| |
| |
| _injectBoardStates() { |
| const s = this.settings.styling; |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .selected-square, #squares-${this.id} .square:has(> img.being-dragged)`, { |
| 'background-image': `linear-gradient(${s.selectedSquareColor} 100%, ${s.selectedSquareColor} 0%)`, |
| 'cursor': s.grabCursor |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id}.just-moved .square`, { |
| 'background-image': `linear-gradient(${s.justMovedColor} 100%, ${s.justMovedColor} 0%)` |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .ui-droppable-active:not(.board-classification)`, { |
| 'background-image': `radial-gradient(${s.droppableIndicatorColor} 23%, transparent 23%)` |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .ui-droppable-active:has(> img)::before`, { |
| 'content': '""', |
| 'position': 'absolute', |
| 'top': '0', |
| 'left': '0', |
| 'right': '0', |
| 'bottom': '0', |
| 'border': `${s.captureIndicatorSize} solid ${s.captureIndicatorColor}`, |
| 'border-radius': '50%', |
| 'z-index': '2', |
| 'pointer-events': 'none' |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .ui-droppable-hover`, { |
| 'box-shadow': `0 0 0 min(${s.droppableHoverBorderWidth}, ${s.droppableHoverBorderWidth}) ${s.droppableHoverBorderColor} inset` |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .highlight`, { |
| 'background-image': `linear-gradient(${s.highlightColor} 100%, ${s.highlightColor} 0%)` |
| }); |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .ui-draggable`, { |
| 'user-select': 'none', |
| 'z-index': '9' |
| }); |
| } |
|
|
| |
| |
| |
| |
| _injectPromotionStyles() { |
| const s = this.settings.styling; |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id}`, { |
| 'position': 'absolute', |
| 'background-color': s.promotionPanelBackground, |
| 'box-shadow': s.promotionPanelShadow, |
| 'display': 'flex', |
| 'flex-direction': 'column', |
| 'z-index': '15', |
| 'color': 'white', |
| 'backdrop-filter': 'blur(3px)', |
| 'overflow': 'hidden', |
| 'animation': 'fadeIn 0.2s ease-out', |
| 'width': '100%', |
| 'user-select': 'none', |
| '-webkit-touch-callout': 'none', |
| '-webkit-text-size-adjust': 'none', |
| '-webkit-user-select': 'none' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id}.bottom`, { |
| 'flex-direction': 'column-reverse' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id} .promotion-piece`, { |
| 'display': 'flex', |
| 'align-items': 'center', |
| 'justify-content': 'center', |
| 'cursor': 'pointer', |
| 'transition': 'background-color 0.2s' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id} .promotion-piece:hover`, { |
| 'background-color': s.promotionPieceHoverColor |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id} .promotion-piece img`, { |
| 'width': '100%', |
| 'height': '100%', |
| 'pointer-events': 'none' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id} .promotion-cancel`, { |
| 'height': s.promotionCancelHeight, |
| 'display': 'flex', |
| 'align-items': 'center', |
| 'justify-content': 'center', |
| 'cursor': 'pointer', |
| 'background-color': s.promotionCancelBackground, |
| 'transition': 'background-color 0.2s', |
| 'font-size': '18px', |
| 'font-weight': 'bold' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#promotion-panel-${this.id} .promotion-cancel:hover`, { |
| 'background-color': s.promotionCancelHoverBackground |
| }); |
| } |
|
|
| |
| |
| |
| |
| _injectClassificationStyles() { |
| const s = this.settings.styling; |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .classification`, { |
| 'position': 'absolute', |
| 'width': s.classificationSize, |
| 'height': s.classificationSize, |
| 'transform': `translate(${s.classificationOffsetX}, ${s.classificationOffsetY})`, |
| 'z-index': '11', |
| 'pointer-events': 'none' |
| }); |
|
|
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .classification.border`, { |
| 'transform': `translate(${s.classificationBorderOffsetX}, ${s.classificationBorderOffsetY})` |
| }); |
| } |
|
|
| |
| |
| |
| |
| _injectAnimations() { |
| DOMUtils.injectKeyframes(this.styleSheet, 'fadeIn', { |
| 'from': { 'opacity': '0' }, |
| 'to': { 'opacity': '1' } |
| }); |
| } |
|
|
| |
| |
| |
| |
| _injectBoardLabelStyles() { |
| if (!this.settings.showBoardLabels) return; |
|
|
| const s = this.settings.styling; |
| const theme = this.settings.theme; |
|
|
| |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square::after`, { |
| 'position': 'absolute', |
| 'font-size': s.notationFontSize, |
| 'font-weight': s.notationFontWeight, |
| 'color': theme.boardLightSquareColor |
| }); |
|
|
| |
| const rankSquares = Array.from({length: 8}, (_, i) => `#squares-${this.id} .square[data-square="${i * 8}"]::after`).join(', '); |
| DOMUtils.addCSSRule(this.styleSheet, rankSquares, { |
| 'left': s.notationOffset, |
| 'top': s.notationOffset |
| }); |
|
|
| |
| const fileSquares = Array.from({length: 8}, (_, i) => `#squares-${this.id} .square[data-square="${56 + i}"]::after`).join(', '); |
| DOMUtils.addCSSRule(this.styleSheet, fileSquares, { |
| 'right': '4px', |
| 'bottom': s.notationOffset |
| }); |
|
|
| |
| const aFileLabel = this.flipped ? 'h' : 'a'; |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square[data-square="56"]::before`, { |
| 'content': `"${aFileLabel}"`, |
| 'position': 'absolute', |
| 'right': s.notationOffset, |
| 'bottom': s.notationOffset, |
| 'font-size': s.notationFontSize, |
| 'font-weight': s.notationFontWeight, |
| 'color': theme.boardLightSquareColor |
| }); |
|
|
| |
| const ranks = this.flipped ? ['1', '2', '3', '4', '5', '6', '7', '8'] : ['8', '7', '6', '5', '4', '3', '2', '1']; |
| const files = this.flipped ? ['g', 'f', 'e', 'd', 'c', 'b', 'a'] : ['b', 'c', 'd', 'e', 'f', 'g', 'h']; |
|
|
| ranks.forEach((rank, i) => { |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square[data-square="${i * 8}"]::after`, { |
| 'content': `"${rank}"`, |
| 'color': i % 2 === 0 ? theme.boardDarkSquareColor : theme.boardLightSquareColor |
| }); |
| }); |
|
|
| files.forEach((file, i) => { |
| DOMUtils.addCSSRule(this.styleSheet, `#squares-${this.id} .square[data-square="${57 + i}"]::after`, { |
| 'content': `"${file}"`, |
| 'color': i % 2 === 0 ? theme.boardDarkSquareColor : theme.boardLightSquareColor |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| _updateBoardLabels() { |
| if (!this.settings.showBoardLabels) return; |
| |
| |
| this._createStyleSheet(); |
| } |
| } |