UNPKG

cm-chessboard

Version:

A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.

488 lines (451 loc) 20.1 kB
/** * Author and copyright: Stefan Haack (https://shaack.com) * Repository: https://github.com/shaack/cm-chessboard * License: MIT, see file 'LICENSE' */ import {VisualMoveInput} from "./VisualMoveInput.js" import {Position} from "../model/Position.js" import {EXTENSION_POINT} from "../model/Extension.js" import {Svg} from "../lib/Svg.js" import {Utils} from "../lib/Utils.js" export const COLOR = { white: "w", black: "b" } export const INPUT_EVENT_TYPE = { moveInputStarted: "moveInputStarted", movingOverSquare: "movingOverSquare", // while dragging or hover after click validateMoveInput: "validateMoveInput", moveInputCanceled: "moveInputCanceled", moveInputFinished: "moveInputFinished" } export const POINTER_EVENTS = { pointercancel: "pointercancel", pointerdown: "pointerdown", pointerenter: "pointerenter", pointerleave: "pointerleave", pointermove: "pointermove", pointerout: "pointerout", pointerover: "pointerover", pointerup: "pointerup" } export const BORDER_TYPE = { none: "none", // no border thin: "thin", // thin border frame: "frame" // wide border with coordinates in it } export class ChessboardView { constructor(chessboard) { this.chessboard = chessboard this.visualMoveInput = new VisualMoveInput(this) if (chessboard.props.assetsCache) { this.cacheSpriteToDiv("cm-chessboard-sprite", this.getSpriteUrl()) } this.container = document.createElement("div") this.chessboard.context.appendChild(this.container) if (chessboard.props.responsive) { if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(() => { // Defer via setTimeout to avoid "ResizeObserver loop // completed with undelivered notifications." The timeout // id is tracked so destroy() can cancel a pending call // and avoid running handleResize on a destroyed board. this.resizeTimeout = setTimeout(() => { this.resizeTimeout = null this.handleResize() }) }) this.resizeObserver.observe(this.chessboard.context) } else { this.resizeListener = this.handleResize.bind(this) window.addEventListener("resize", this.resizeListener) } } this.positionsAnimationTask = Promise.resolve() this.pointerDownListener = this.pointerDownHandler.bind(this) this.container.addEventListener("mousedown", this.pointerDownListener) this.container.addEventListener("touchstart", this.pointerDownListener, {passive: false}) this.createSvgAndGroups() this.handleResize() } pointerDownHandler(e) { this.visualMoveInput.onPointerDown(e) } destroy() { this.visualMoveInput.destroy() if (this.resizeObserver) { this.resizeObserver.unobserve(this.chessboard.context) } // Cancel any pending handleResize that the ResizeObserver callback // has already scheduled via setTimeout. unobserve() stops new // notifications but does not clear timeouts we scheduled ourselves. if (this.resizeTimeout) { clearTimeout(this.resizeTimeout) this.resizeTimeout = null } if (this.resizeListener) { window.removeEventListener("resize", this.resizeListener) } this.chessboard.context.removeEventListener("mousedown", this.pointerDownListener) this.chessboard.context.removeEventListener("touchstart", this.pointerDownListener) Svg.removeElement(this.svg) this.container.remove() } // Sprite // cacheSpriteToDiv(wrapperId, url) { if (!document.getElementById(wrapperId)) { const wrapper = document.createElement("div") wrapper.style.transform = "scale(0)" wrapper.style.position = "absolute" wrapper.setAttribute("aria-hidden", "true") wrapper.id = wrapperId document.body.appendChild(wrapper) const xhr = new XMLHttpRequest() xhr.open("GET", url, true) xhr.onload = function () { wrapper.insertAdjacentHTML('afterbegin', xhr.response) } xhr.send() } } createSvgAndGroups() { this.svg = Svg.createSvg(this.container) // let description = document.createElement("description") // description.innerText = "Chessboard" // description.id = "svg-description" // this.svg.appendChild(description) let cssClass = this.chessboard.props.style.cssClass ? this.chessboard.props.style.cssClass : "default" this.svg.setAttribute("class", "cm-chessboard border-type-" + this.chessboard.props.style.borderType + " " + cssClass) // this.svg.setAttribute("aria-describedby", "svg-description") this.svg.setAttribute("role", "img") this.updateMetrics() this.boardGroup = Svg.addElement(this.svg, "g", {class: "board"}) this.coordinatesGroup = Svg.addElement(this.svg, "g", {class: "coordinates", "aria-hidden": "true"}) this.markersLayer = Svg.addElement(this.svg, "g", {class: "markers-layer"}) this.piecesLayer = Svg.addElement(this.svg, "g", {class: "pieces-layer"}) this.piecesGroup = Svg.addElement(this.piecesLayer, "g", {class: "pieces"}) this.markersTopLayer = Svg.addElement(this.svg, "g", {class: "markers-top-layer"}) this.interactiveTopLayer = Svg.addElement(this.svg, "g", {class: "interactive-top-layer"}) } updateMetrics() { const piecesTileSize = this.chessboard.props.style.pieces.tileSize this.width = this.container.clientWidth this.height = this.container.clientWidth * (this.chessboard.props.style.aspectRatio || 1) if (this.chessboard.props.style.borderType === BORDER_TYPE.frame) { this.borderSize = this.width / 25 } else if (this.chessboard.props.style.borderType === BORDER_TYPE.thin) { this.borderSize = this.width / 320 } else { this.borderSize = 0 } this.innerWidth = this.width - 2 * this.borderSize this.innerHeight = this.height - 2 * this.borderSize this.squareWidth = this.innerWidth / 8 this.squareHeight = this.innerHeight / 8 this.scalingX = this.squareWidth / piecesTileSize this.scalingY = this.squareHeight / piecesTileSize this.pieceXTranslate = (this.squareWidth / 2 - piecesTileSize * this.scalingY / 2) } handleResize() { // Skip if the board has already been destroyed. The resizeObserver // or window resize listener may fire a callback whose deferred work // is still pending when destroy() runs. if (!this.chessboard || !this.chessboard.state) { return } this.container.style.width = (this.chessboard.context.clientWidth) + "px" this.container.style.height = (this.chessboard.context.clientWidth * this.chessboard.props.style.aspectRatio) + "px" if (this.container.clientWidth !== this.width || this.container.clientHeight !== this.height) { this.updateMetrics() this.redrawBoard() this.redrawPieces() } this.svg.setAttribute("width", "100%") this.svg.setAttribute("height", "100%") } redrawBoard() { this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.beforeRedrawBoard) this.redrawSquares() this.drawCoordinates() this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.afterRedrawBoard) this.visualizeInputState() } // Board // redrawSquares() { while (this.boardGroup.firstChild) { this.boardGroup.removeChild(this.boardGroup.lastChild) } let boardBorder = Svg.addElement(this.boardGroup, "rect", {width: this.width, height: this.height}) boardBorder.setAttribute("class", "border") if (this.chessboard.props.style.borderType === BORDER_TYPE.frame) { const innerPos = this.borderSize let borderInner = Svg.addElement(this.boardGroup, "rect", { x: innerPos, y: innerPos, width: this.width - innerPos * 2, height: this.height - innerPos * 2 }) borderInner.setAttribute("class", "border-inner") } for (let i = 0; i < 64; i++) { const index = this.chessboard.state.orientation === COLOR.white ? i : 63 - i const squareColor = ((9 * index) & 8) === 0 ? 'black' : 'white' const fieldClass = `square ${squareColor}` const point = this.squareToPoint(Position.indexToSquare(index)) const squareRect = Svg.addElement(this.boardGroup, "rect", { x: point.x, y: point.y, width: this.squareWidth, height: this.squareHeight }) squareRect.setAttribute("class", fieldClass) squareRect.setAttribute("data-square", Position.indexToSquare(index)) } } drawCoordinates() { if (!this.chessboard.props.style.showCoordinates) { return } while (this.coordinatesGroup.firstChild) { this.coordinatesGroup.removeChild(this.coordinatesGroup.lastChild) } const inline = this.chessboard.props.style.borderType !== BORDER_TYPE.frame for (let file = 0; file < 8; file++) { let x = this.borderSize + (17 + this.chessboard.props.style.pieces.tileSize * file) * this.scalingX let y = this.height - this.scalingY * 3.5 let cssClass = "coordinate file" if (inline) { x = x + this.scalingX * 15.5 cssClass += file % 2 ? " white" : " black" } const textElement = Svg.addElement(this.coordinatesGroup, "text", { class: cssClass, x: x, y: y, style: `font-size: ${this.scalingY * 10}px` }) if (this.chessboard.state.orientation === COLOR.white) { textElement.textContent = String.fromCharCode(97 + file) } else { textElement.textContent = String.fromCharCode(104 - file) } } for (let rank = 0; rank < 8; rank++) { let x = (this.borderSize / 3.7) let y = this.borderSize + 25 * this.scalingY + rank * this.squareHeight let cssClass = "coordinate rank" if (inline) { cssClass += rank % 2 ? " black" : " white" if (this.chessboard.props.style.borderType === BORDER_TYPE.frame) { x = x + this.scalingX * 10 y = y - this.scalingY * 15 } else { x = x + this.scalingX * 2 y = y - this.scalingY * 15 } } const textElement = Svg.addElement(this.coordinatesGroup, "text", { class: cssClass, x: x, y: y, style: `font-size: ${this.scalingY * 10}px` }) if (this.chessboard.state.orientation === COLOR.white) { textElement.textContent = "" + (8 - rank) } else { textElement.textContent = "" + (1 + rank) } } } // Pieces // redrawPieces(squares = this.chessboard.state.position.squares) { const childNodes = Array.from(this.piecesGroup.childNodes) const isDragging = this.visualMoveInput.isDragging() for (let i = 0; i < 64; i++) { const pieceName = squares[i] if (pieceName) { const square = Position.indexToSquare(i) this.drawPieceOnSquare(square, pieceName, isDragging && square === this.visualMoveInput.fromSquare) } } for (const childNode of childNodes) { this.piecesGroup.removeChild(childNode) } } drawPiece(parentGroup, pieceName, point) { const pieceGroup = Svg.addElement(parentGroup, "g", {}) pieceGroup.setAttribute("data-piece", pieceName) const transform = (this.svg.createSVGTransform()) transform.setTranslate(point.x, point.y) pieceGroup.transform.baseVal.appendItem(transform) const spriteUrl = this.chessboard.props.assetsCache ? "" : this.getSpriteUrl() const pieceUse = Svg.addElement(pieceGroup, "use", { href: `${spriteUrl}#${pieceName}`, class: "piece" }) const transformScale = (this.svg.createSVGTransform()) transformScale.setScale(this.scalingY, this.scalingY) pieceUse.transform.baseVal.appendItem(transformScale) return pieceGroup } drawPieceOnSquare(square, pieceName, hidden = false) { const pieceGroup = Svg.addElement(this.piecesGroup, "g", {}) pieceGroup.setAttribute("data-piece", pieceName) pieceGroup.setAttribute("data-square", square) if (hidden) { pieceGroup.setAttribute("visibility", "hidden") } const point = this.squareToPoint(square) const transform = (this.svg.createSVGTransform()) transform.setTranslate(point.x, point.y) pieceGroup.transform.baseVal.appendItem(transform) const spriteUrl = this.chessboard.props.assetsCache ? "" : this.getSpriteUrl() const pieceUse = Svg.addElement(pieceGroup, "use", { href: `${spriteUrl}#${pieceName}`, class: "piece" }) // center on square const transformTranslate = (this.svg.createSVGTransform()) transformTranslate.setTranslate(this.pieceXTranslate, 0) pieceUse.transform.baseVal.appendItem(transformTranslate) // scale const transformScale = (this.svg.createSVGTransform()) transformScale.setScale(this.scalingY, this.scalingY) pieceUse.transform.baseVal.appendItem(transformScale) return pieceGroup } setPieceVisibility(square, visible = true) { const piece = this.getPieceElement(square) if (piece) { if (visible) { piece.setAttribute("visibility", "visible") } else { piece.setAttribute("visibility", "hidden") } } else { console.warn("no piece on", square) } } getPieceElement(square) { if (!square || square.length < 2) { console.warn("invalid square", square) return null } const piece = this.piecesGroup.querySelector(`g[data-square='${square}']`) if (!piece) { console.warn("no piece on", square) return null } return piece } // enable and disable move input // enableMoveInput(eventHandler, color = null) { if (this.chessboard.state.moveInputCallback) { throw Error("moveInput already enabled") } if (color === COLOR.white) { this.chessboard.state.inputWhiteEnabled = true } else if (color === COLOR.black) { this.chessboard.state.inputBlackEnabled = true } else { this.chessboard.state.inputWhiteEnabled = true this.chessboard.state.inputBlackEnabled = true } this.chessboard.state.moveInputCallback = eventHandler this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInputToggled, {enabled: true, color: color}) this.visualizeInputState() } disableMoveInput() { this.chessboard.state.inputWhiteEnabled = false this.chessboard.state.inputBlackEnabled = false this.chessboard.state.moveInputCallback = null this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInputToggled, {enabled: false}) this.visualizeInputState() } // callbacks // moveInputStartedCallback(square) { const data = { chessboard: this.chessboard, type: INPUT_EVENT_TYPE.moveInputStarted, square: square, /** square is deprecated, use squareFrom (2023-05-22) */ squareFrom: square, piece: this.chessboard.getPiece(square) } if (this.chessboard.state.moveInputCallback) { data.moveInputCallbackResult = this.chessboard.state.moveInputCallback(data) } this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data) return data.moveInputCallbackResult } movingOverSquareCallback(squareFrom, squareTo) { const data = { chessboard: this.chessboard, type: INPUT_EVENT_TYPE.movingOverSquare, squareFrom: squareFrom, squareTo: squareTo, piece: this.chessboard.getPiece(squareFrom) } if (this.chessboard.state.moveInputCallback) { data.moveInputCallbackResult = this.chessboard.state.moveInputCallback(data) } this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data) } validateMoveInputCallback(squareFrom, squareTo) { const data = { chessboard: this.chessboard, type: INPUT_EVENT_TYPE.validateMoveInput, squareFrom: squareFrom, squareTo: squareTo, piece: this.chessboard.getPiece(squareFrom) } if (this.chessboard.state.moveInputCallback) { data.moveInputCallbackResult = this.chessboard.state.moveInputCallback(data) } this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data) return data.moveInputCallbackResult } moveInputCanceledCallback(squareFrom, squareTo, reason) { const data = { chessboard: this.chessboard, type: INPUT_EVENT_TYPE.moveInputCanceled, reason: reason, squareFrom: squareFrom, squareTo: squareTo } if (this.chessboard.state.moveInputCallback) { this.chessboard.state.moveInputCallback(data) } this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data) } moveInputFinishedCallback(squareFrom, squareTo, legalMove) { const data = { chessboard: this.chessboard, type: INPUT_EVENT_TYPE.moveInputFinished, squareFrom: squareFrom, squareTo: squareTo, legalMove: legalMove } if (this.chessboard.state.moveInputCallback) { this.chessboard.state.moveInputCallback(data) } this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data) } // Helpers // visualizeInputState() { if (this.chessboard.state) { // fix https://github.com/shaack/cm-chessboard/issues/47 if (this.chessboard.state.inputWhiteEnabled || this.chessboard.state.inputBlackEnabled) { this.boardGroup.setAttribute("class", "board input-enabled") } else { this.boardGroup.setAttribute("class", "board") } } } indexToPoint(index) { let x, y if (this.chessboard.state.orientation === COLOR.white) { x = this.borderSize + (index % 8) * this.squareWidth y = this.borderSize + (7 - Math.floor(index / 8)) * this.squareHeight } else { x = this.borderSize + (7 - index % 8) * this.squareWidth y = this.borderSize + (Math.floor(index / 8)) * this.squareHeight } return {x: x, y: y} } squareToPoint(square) { const index = Position.squareToIndex(square) return this.indexToPoint(index) } getSpriteUrl() { if (Utils.isAbsoluteUrl(this.chessboard.props.style.pieces.file)) { return this.chessboard.props.style.pieces.file } else { return this.chessboard.props.assetsUrl + this.chessboard.props.style.pieces.file } } }