cm-chessboard 8.12.18 → 8.13.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.
package/README.md CHANGED
@@ -247,6 +247,12 @@ chessboard.enableMoveInput((event) => {
247
247
 
248
248
  Disables moves via user input.
249
249
 
250
+ ### cancelMoveInput()
251
+
252
+ Cancels a move input that is currently in progress (a piece picked up or a square selected), without disabling move
253
+ input. Fires a `moveInputCanceled` event with `event.reason === "canceled"`. It is a no-op when no move input is in
254
+ progress. Useful to abort a move from your own UI, e.g. on an "escape" key press or a cancel button.
255
+
250
256
  ### isMoveInputEnabled()
251
257
 
252
258
  Returns `true` if move input is currently enabled for white or black.
@@ -0,0 +1,53 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>cm-chessboard</title>
6
+ <meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0"/>
7
+ <link rel="stylesheet" href="../styles/examples.css"/>
8
+ <link rel="stylesheet" href="../../assets/chessboard.css"/>
9
+ </head>
10
+ <body>
11
+ <h1><a href="../../">cm-chessboard</a></h1>
12
+ <h2>Example: PieceRotation Extension</h2>
13
+ <p>Rotates the piece glyphs in place, by any angle, optionally animated — either all pieces or only those
14
+ of one color. Useful for over-the-board play on a tablet lying flat between two players. Moving pieces
15
+ stays enabled, the rotation survives moves and position changes.</p>
16
+ <div class="board" id="board"></div>
17
+ <div style="clear: both"></div>
18
+ <p>
19
+ <select id="color">
20
+ <option value="">all pieces</option>
21
+ <option value="w">white only</option>
22
+ <option value="b">black only</option>
23
+ </select>
24
+ <button onclick="window.rotate(0)">0&deg;</button>
25
+ <button onclick="window.rotate(90)">90&deg;</button>
26
+ <button onclick="window.rotate(180)">180&deg;</button>
27
+ <button onclick="window.rotate(270)">270&deg;</button>
28
+ <label><input type="checkbox" id="animated" checked> animated</label>
29
+ </p>
30
+ <script type="module">
31
+ import {Chessboard} from "../../src/Chessboard.js"
32
+ import {FEN} from "../../src/model/Position.js"
33
+ import {PieceRotation} from "../../src/extensions/piece-rotation/PieceRotation.js"
34
+
35
+ const board = new Chessboard(document.getElementById("board"), {
36
+ position: FEN.start,
37
+ assetsCache: false,
38
+ assetsUrl: "../../assets/",
39
+ style: {aspectRatio: 0.98},
40
+ extensions: [{class: PieceRotation}]
41
+ })
42
+ board.enableMoveInput(() => {
43
+ return true // allow all moves in this demo
44
+ })
45
+ window.rotate = (angle) => {
46
+ board.setPiecesRotation(angle, {
47
+ color: document.getElementById("color").value || undefined,
48
+ animated: document.getElementById("animated").checked
49
+ })
50
+ }
51
+ </script>
52
+ </body>
53
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-chessboard",
3
- "version": "8.12.18",
3
+ "version": "8.13.0",
4
4
  "description": "A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.",
5
5
  "keywords": [
6
6
  "chess",
package/src/Chessboard.js CHANGED
@@ -77,6 +77,7 @@ export class Chessboard {
77
77
  async setPiece(square, piece, animated = false) {
78
78
  const positionFrom = this.state.position.clone()
79
79
  this.state.position.setPiece(square, piece)
80
+ this.view.visualMoveInput.positionChanged()
80
81
  this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
81
82
  return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
82
83
  }
@@ -84,6 +85,7 @@ export class Chessboard {
84
85
  async movePiece(squareFrom, squareTo, animated = false) {
85
86
  const positionFrom = this.state.position.clone()
86
87
  this.state.position.movePiece(squareFrom, squareTo)
88
+ this.view.visualMoveInput.positionChanged()
87
89
  this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
88
90
  return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
89
91
  }
@@ -93,6 +95,7 @@ export class Chessboard {
93
95
  const positionTo = new Position(fen)
94
96
  if (positionFrom.getFen() !== positionTo.getFen()) {
95
97
  this.state.position.setFen(fen)
98
+ this.view.visualMoveInput.positionChanged()
96
99
  this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
97
100
  }
98
101
  return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
@@ -131,6 +134,11 @@ export class Chessboard {
131
134
  this.view.disableMoveInput()
132
135
  }
133
136
 
137
+ // Cancel a move input that is currently in progress (leaves move input enabled).
138
+ cancelMoveInput() {
139
+ this.view.visualMoveInput.cancelMoveInput()
140
+ }
141
+
134
142
  isMoveInputEnabled() {
135
143
  return this.state.inputWhiteEnabled || this.state.inputBlackEnabled
136
144
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ */
6
+ import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
7
+ import {COLOR} from "../../Chessboard.js"
8
+
9
+ /**
10
+ * Rotates the piece glyphs in place, by any angle, optionally animated —
11
+ * either all pieces or only those of one color. The squares and the position
12
+ * stay untouched, only the glyphs turn around their own center. Useful for
13
+ * over-the-board play on a tablet lying flat between two players: rotate the
14
+ * pieces by 180 degrees towards the player to move.
15
+ *
16
+ * The rotation is implemented as an additional SVGTransform appended to each
17
+ * piece's `use` element, after the translate and scale the view sets there.
18
+ * Appended last, it rotates in sprite coordinates around the tile center, so
19
+ * it composes with any piece position and survives the move animations, which
20
+ * only touch the translate of the surrounding group. A MutationObserver
21
+ * re-applies the angles to freshly drawn pieces, so the rotation survives
22
+ * redraws and position changes from any code path.
23
+ *
24
+ * Registers two methods on the chessboard:
25
+ *
26
+ * // rotate all pieces, animated
27
+ * chessboard.setPiecesRotation(180)
28
+ * // rotate only the black pieces, not animated
29
+ * chessboard.setPiecesRotation(180, {color: COLOR.black, animated: false})
30
+ * // returns a Promise, resolved when the rotation is shown
31
+ * await chessboard.setPiecesRotation(0)
32
+ *
33
+ * chessboard.getPiecesRotation(COLOR.white) // current target angle
34
+ */
35
+ export class PieceRotation extends Extension {
36
+
37
+ constructor(chessboard, props = {}) {
38
+ super(chessboard)
39
+ this.props = {
40
+ angle: 0, // the initial rotation angle in degrees, for all pieces
41
+ animationDuration: 300 // duration of an animated rotation in milliseconds
42
+ }
43
+ Object.assign(this.props, props)
44
+ this.angles = {w: this.props.angle, b: this.props.angle}
45
+ this.targetAngles = {w: this.props.angle, b: this.props.angle}
46
+ this.animations = {w: null, b: null}
47
+
48
+ // Redraws and animations replace the piece elements with fresh ones
49
+ // without the rotation, and they do so from several code paths — some
50
+ // of them after all extension points have fired (redrawPieces runs in
51
+ // the animation queue's callback). A MutationObserver on the pieces
52
+ // group catches every new element, no matter which path created it.
53
+ this.observer = new MutationObserver(() => {
54
+ this.applyAngles()
55
+ })
56
+ this.observer.observe(chessboard.view.piecesGroup, {childList: true, subtree: true})
57
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
58
+ this.observer.disconnect()
59
+ this.cancelAnimation(COLOR.white)
60
+ this.cancelAnimation(COLOR.black)
61
+ })
62
+
63
+ chessboard.setPiecesRotation = this.setPiecesRotation.bind(this)
64
+ chessboard.getPiecesRotation = this.getPiecesRotation.bind(this)
65
+ if (this.props.angle !== 0) {
66
+ this.applyAngles()
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Rotate pieces to `angle` degrees. Returns a Promise which resolves when
72
+ * the rotation is shown, after the animation if animated.
73
+ *
74
+ * @param angle target angle in degrees
75
+ * @param props {color: COLOR.white|COLOR.black|undefined, animated: true}
76
+ * without `color` all pieces are rotated
77
+ */
78
+ setPiecesRotation(angle, props = {}) {
79
+ const {color = undefined, animated = true} = props
80
+ const colors = color ? [color] : [COLOR.white, COLOR.black]
81
+ const promises = colors.map((rotatedColor) => this.rotateColor(rotatedColor, angle, animated))
82
+ return Promise.all(promises)
83
+ }
84
+
85
+ /**
86
+ * The current target angle of a color's pieces.
87
+ */
88
+ getPiecesRotation(color = COLOR.white) {
89
+ return this.targetAngles[color]
90
+ }
91
+
92
+ // -- internal ------------------------------------------------------------
93
+
94
+ rotateColor(color, angle, animated) {
95
+ this.targetAngles[color] = angle
96
+ this.cancelAnimation(color)
97
+ if (!animated || this.props.animationDuration <= 0 || angle === this.angles[color]) {
98
+ this.angles[color] = angle
99
+ this.applyAngles()
100
+ return Promise.resolve()
101
+ }
102
+ return new Promise((resolve) => {
103
+ const startAngle = this.angles[color]
104
+ let startTime = null
105
+ const animation = {resolve}
106
+ const step = (time) => {
107
+ if (!this.chessboard.state) { // board was destroyed
108
+ return
109
+ }
110
+ if (startTime === null) {
111
+ startTime = time
112
+ }
113
+ const t = Math.min(1, (time - startTime) / this.props.animationDuration)
114
+ const progress = t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t // easeInOut
115
+ this.angles[color] = startAngle + (angle - startAngle) * progress
116
+ this.applyAngles()
117
+ if (t < 1) {
118
+ animation.frameHandle = requestAnimationFrame(step)
119
+ } else {
120
+ this.animations[color] = null
121
+ resolve()
122
+ }
123
+ }
124
+ animation.frameHandle = requestAnimationFrame(step)
125
+ this.animations[color] = animation
126
+ })
127
+ }
128
+
129
+ /**
130
+ * A superseded animation stops where it is, its promise resolves. The new
131
+ * rotation starts from the currently shown angle.
132
+ */
133
+ cancelAnimation(color) {
134
+ const animation = this.animations[color]
135
+ if (animation) {
136
+ cancelAnimationFrame(animation.frameHandle)
137
+ animation.resolve()
138
+ this.animations[color] = null
139
+ }
140
+ }
141
+
142
+ applyAngles() {
143
+ const center = this.chessboard.props.style.pieces.tileSize / 2
144
+ const groups = this.chessboard.view.piecesGroup.querySelectorAll("g[data-piece]")
145
+ for (const group of groups) {
146
+ const color = group.getAttribute("data-piece").charAt(0)
147
+ const piece = group.querySelector("use.piece")
148
+ if (piece) {
149
+ this.rotateElement(piece, this.angles[color], center)
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The rotation is the transform appended after the ones the view sets, its
156
+ * index in the list is stored on the element. The item is addressed by
157
+ * position, not by a retained object reference: WebKit returns fresh
158
+ * wrapper objects from `getItem`, an identity check would report the
159
+ * transform as lost on every call and stale rotations would pile up.
160
+ */
161
+ rotateElement(element, angle, center) {
162
+ const list = element.transform.baseVal
163
+ let index = parseInt(element.getAttribute("data-rotation-index"), 10)
164
+ if (isNaN(index) || index >= list.numberOfItems) {
165
+ index = list.numberOfItems
166
+ list.appendItem(element.ownerSVGElement.createSVGTransform())
167
+ element.setAttribute("data-rotation-index", index)
168
+ }
169
+ list.getItem(index).setRotate(angle, center, center)
170
+ }
171
+ }
@@ -0,0 +1,39 @@
1
+ # PieceRotation extension
2
+
3
+ Rotates the piece glyphs in place, by any angle, optionally animated — either all pieces or only those of one color. The squares and the position stay untouched, only the glyphs turn around their own center.
4
+
5
+ The main use case is over-the-board play on a tablet lying flat between two players: rotate the pieces by 180 degrees towards the player to move, so both players see their pieces upright.
6
+
7
+ ## Usage
8
+
9
+ ```js
10
+ import {PieceRotation} from "cm-chessboard/src/extensions/piece-rotation/PieceRotation.js"
11
+
12
+ const board = new Chessboard(context, {
13
+ position: FEN.start,
14
+ assetsUrl: "./assets/",
15
+ extensions: [{class: PieceRotation, props: {animationDuration: 300}}]
16
+ })
17
+
18
+ // rotate all pieces, animated, returns a Promise
19
+ await board.setPiecesRotation(180)
20
+
21
+ // rotate only the black pieces, not animated
22
+ board.setPiecesRotation(180, {color: COLOR.black, animated: false})
23
+
24
+ // the current target angle of a color (default COLOR.white)
25
+ board.getPiecesRotation(COLOR.black)
26
+ ```
27
+
28
+ ## Props
29
+
30
+ | Prop | Default | Description |
31
+ |---------------------|---------|--------------------------------------------------------|
32
+ | `angle` | `0` | Initial rotation angle in degrees, for all pieces |
33
+ | `animationDuration` | `300` | Duration of an animated rotation in milliseconds |
34
+
35
+ ## How it works
36
+
37
+ The rotation is an additional `SVGTransform` appended to each piece's `use` element, after the translate and scale the view sets there. Appended last, it rotates in sprite coordinates around the tile center, so it composes with any piece position and survives the move animations, which only touch the translate of the surrounding group. A `MutationObserver` on the pieces group re-applies the angles to freshly drawn pieces, so the rotation survives redraws and position changes from any code path.
38
+
39
+ The rotation is independent of move input. The dragged ghost piece following the pointer is intentionally not rotated.
@@ -24,7 +24,9 @@ export const MOVE_CANCELED_REASON = {
24
24
  movedOutOfBoard: "movedOutOfBoard",
25
25
  draggedBack: "draggedBack", // dragged to the start square
26
26
  clickedAnotherPiece: "clickedAnotherPiece", // of the same color
27
- touchCanceled: "touchCanceled"
27
+ touchCanceled: "touchCanceled",
28
+ movedPieceChanged: "movedPieceChanged", // the held piece changed on the board, most likely got captured
29
+ canceled: "canceled" // cancelled programmatically via chessboard.cancelMoveInput()
28
30
  }
29
31
 
30
32
  const DRAG_THRESHOLD = 4
@@ -37,6 +39,7 @@ export class VisualMoveInput {
37
39
  this.moveInputState = null
38
40
  this.fromSquare = null
39
41
  this.toSquare = null
42
+ this.movedPiece = null
40
43
 
41
44
  this.setMoveInputState(MOVE_INPUT_STATE.waitForInputStart)
42
45
  }
@@ -432,6 +435,40 @@ export class VisualMoveInput {
432
435
  this.moveInputCanceledCallback(this.fromSquare, null, MOVE_CANCELED_REASON.secondaryClick)
433
436
  }
434
437
 
438
+ // Cancel a move input that is currently in progress. No-op when idle.
439
+ cancelMoveInput() {
440
+ if (this.moveInputState !== MOVE_INPUT_STATE.waitForInputStart) {
441
+ const moveStartSquare = this.fromSquare
442
+ this.view.redrawPieces()
443
+ this.setMoveInputState(MOVE_INPUT_STATE.reset)
444
+ this.moveInputCanceledCallback(moveStartSquare, null, MOVE_CANCELED_REASON.canceled)
445
+ }
446
+ }
447
+
448
+ // Called after the board position changed (setPosition/movePiece/setPiece).
449
+ // If the piece we are holding changed on its square — most likely captured
450
+ // by an external position update — cancel the in-progress move input.
451
+ positionChanged() {
452
+ // Only cancel while actually holding a piece. Never in moveDone/reset:
453
+ // completing a move itself goes through movePiece() and must not self-cancel.
454
+ const holdingStates = [
455
+ MOVE_INPUT_STATE.pieceClickedThreshold,
456
+ MOVE_INPUT_STATE.clickTo,
457
+ MOVE_INPUT_STATE.secondClickThreshold,
458
+ MOVE_INPUT_STATE.dragTo,
459
+ MOVE_INPUT_STATE.clickDragTo
460
+ ]
461
+ if (this.fromSquare && holdingStates.includes(this.moveInputState) &&
462
+ this.chessboard.getPiece(this.fromSquare) !== this.movedPiece) {
463
+ const moveStartSquare = this.fromSquare
464
+ // drop the stale held piece so the reset branch does not stamp it
465
+ // back onto the square, overwriting the piece just placed there
466
+ this.movedPiece = null
467
+ this.setMoveInputState(MOVE_INPUT_STATE.reset)
468
+ this.moveInputCanceledCallback(moveStartSquare, null, MOVE_CANCELED_REASON.movedPieceChanged)
469
+ }
470
+ }
471
+
435
472
  isDragging() {
436
473
  return this.moveInputState === MOVE_INPUT_STATE.dragTo || this.moveInputState === MOVE_INPUT_STATE.clickDragTo
437
474
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ */
6
+
7
+ import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
8
+ import {Chessboard, COLOR} from "../src/Chessboard.js"
9
+ import {FEN} from "../src/model/Position.js"
10
+ import {PieceRotation} from "../src/extensions/piece-rotation/PieceRotation.js"
11
+
12
+ function makeBoard(props = {}) {
13
+ return new Chessboard(document.getElementById("TestPieceRotation"), {
14
+ position: FEN.start,
15
+ assetsUrl: "../assets/",
16
+ ...props
17
+ })
18
+ }
19
+
20
+ function rotationOf(useElement) {
21
+ // the appended rotation is the last transform in the list
22
+ const list = useElement.transform.baseVal
23
+ const item = list.getItem(list.numberOfItems - 1)
24
+ return item.type === SVGTransform.SVG_TRANSFORM_ROTATE ? item.angle : null
25
+ }
26
+
27
+ function piecesOf(chessboard, color) {
28
+ return chessboard.view.piecesGroup.querySelectorAll(`g[data-piece^="${color}"] use.piece`)
29
+ }
30
+
31
+ // The MutationObserver in the extension applies the angles to freshly drawn
32
+ // pieces in a microtask, wait one tick before asserting.
33
+ function nextTick() {
34
+ return new Promise((resolve) => setTimeout(resolve))
35
+ }
36
+
37
+ describe("TestPieceRotation", () => {
38
+
39
+ it("should rotate all pieces instantly and back", async () => {
40
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
41
+ assert.equal(chessboard.getPiecesRotation(), 0)
42
+ await chessboard.setPiecesRotation(180, {animated: false})
43
+ assert.equal(chessboard.getPiecesRotation(COLOR.white), 180)
44
+ assert.equal(chessboard.getPiecesRotation(COLOR.black), 180)
45
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
46
+ assert.equal(pieces.length, 32)
47
+ for (const piece of pieces) {
48
+ assert.equal(rotationOf(piece), 180)
49
+ }
50
+ await chessboard.setPiecesRotation(0, {animated: false})
51
+ for (const piece of pieces) {
52
+ assert.equal(rotationOf(piece), 0)
53
+ }
54
+ chessboard.destroy()
55
+ })
56
+
57
+ it("should rotate only one color", async () => {
58
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
59
+ await chessboard.setPiecesRotation(180, {color: COLOR.black, animated: false})
60
+ assert.equal(chessboard.getPiecesRotation(COLOR.black), 180)
61
+ assert.equal(chessboard.getPiecesRotation(COLOR.white), 0)
62
+ for (const piece of piecesOf(chessboard, "b")) {
63
+ assert.equal(rotationOf(piece), 180)
64
+ }
65
+ for (const piece of piecesOf(chessboard, "w")) {
66
+ assert.equal(rotationOf(piece), 0)
67
+ }
68
+ chessboard.destroy()
69
+ })
70
+
71
+ it("should animate the rotation to the target angle", async () => {
72
+ const chessboard = makeBoard({
73
+ extensions: [{class: PieceRotation, props: {animationDuration: 50}}]
74
+ })
75
+ await chessboard.setPiecesRotation(90)
76
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
77
+ assert.equal(pieces.length, 32)
78
+ for (const piece of pieces) {
79
+ assert.equal(rotationOf(piece), 90)
80
+ }
81
+ chessboard.destroy()
82
+ })
83
+
84
+ it("should keep the rotation on pieces after a position change", async () => {
85
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
86
+ await chessboard.setPiecesRotation(180, {animated: false})
87
+ await chessboard.movePiece("e2", "e4", false)
88
+ await nextTick()
89
+ const moved = chessboard.view.piecesGroup.querySelector('g[data-square="e4"] use.piece')
90
+ assert.true(moved !== null)
91
+ assert.equal(rotationOf(moved), 180)
92
+ // all the other pieces keep it too
93
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
94
+ for (const piece of pieces) {
95
+ assert.equal(rotationOf(piece), 180)
96
+ }
97
+ chessboard.destroy()
98
+ })
99
+
100
+ it("should keep the rotation after an animated move", async () => {
101
+ const chessboard = makeBoard({
102
+ style: {animationDuration: 50},
103
+ extensions: [{class: PieceRotation}]
104
+ })
105
+ await chessboard.setPiecesRotation(180, {animated: false})
106
+ await chessboard.movePiece("b1", "c3", true)
107
+ await nextTick()
108
+ const moved = chessboard.view.piecesGroup.querySelector('g[data-square="c3"] use.piece')
109
+ assert.true(moved !== null)
110
+ assert.equal(rotationOf(moved), 180)
111
+ chessboard.destroy()
112
+ })
113
+
114
+ it("should apply an initial angle from the props", async () => {
115
+ const chessboard = makeBoard({
116
+ extensions: [{class: PieceRotation, props: {angle: 180}}]
117
+ })
118
+ await nextTick()
119
+ for (const piece of chessboard.view.piecesGroup.querySelectorAll("use.piece")) {
120
+ assert.equal(rotationOf(piece), 180)
121
+ }
122
+ chessboard.destroy()
123
+ })
124
+
125
+ it("should supersede a running animation", async () => {
126
+ const chessboard = makeBoard({
127
+ extensions: [{class: PieceRotation, props: {animationDuration: 1000}}]
128
+ })
129
+ chessboard.setPiecesRotation(180) // not awaited, will be superseded
130
+ await chessboard.setPiecesRotation(45, {animated: false})
131
+ assert.equal(chessboard.getPiecesRotation(), 45)
132
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
133
+ for (const piece of pieces) {
134
+ assert.equal(rotationOf(piece), 45)
135
+ }
136
+ chessboard.destroy()
137
+ })
138
+ })
@@ -231,4 +231,131 @@ describe("TestVisualMoveInput", () => {
231
231
  board.destroy()
232
232
  })
233
233
 
234
+ // Regression for https://github.com/shaack/cm-chessboard/pull/165
235
+ it("should cancel and keep the new position when the held piece is captured via setPosition", async () => {
236
+ const board = newBoard()
237
+ let canceled = null
238
+ board.enableMoveInput((e) => {
239
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceled = {square: e.squareFrom, reason: e.reason}
240
+ return true
241
+ }, COLOR.white)
242
+ const vmi = board.view.visualMoveInput
243
+ vmi.onPointerDown(mouseDown("e2"))
244
+ vmi.onPointerUp(mouseUp("e2"))
245
+ assert.equal(vmi.moveInputState, STATE_CLICK_TO)
246
+
247
+ // the opponent captures the held e2 pawn (e2 now holds a black knight)
248
+ const capturedFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPnPPP/RNBQKBNR"
249
+ await board.setPosition(capturedFen)
250
+
251
+ assert.equal(canceled && canceled.reason, MOVE_CANCELED_REASON.movedPieceChanged)
252
+ assert.equal(canceled && canceled.square, "e2") // the real start square, not null
253
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
254
+ // the position must not be corrupted back to the white pawn
255
+ assert.equal("" + board.getPosition(), capturedFen)
256
+ await tick()
257
+ board.destroy()
258
+ })
259
+
260
+ it("should not cancel a normal move completion", async () => {
261
+ const board = newBoard()
262
+ let canceledReason
263
+ let finished = 0
264
+ board.enableMoveInput((e) => {
265
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
266
+ if (e.type === INPUT_EVENT_TYPE.moveInputFinished) finished++
267
+ return true
268
+ }, COLOR.white)
269
+ const vmi = board.view.visualMoveInput
270
+ vmi.onPointerDown(mouseDown("e2"))
271
+ vmi.onPointerUp(mouseUp("e2"))
272
+ vmi.onPointerDown(mouseDown("e4")) // completes via moveDone -> movePiece()
273
+ await tick()
274
+ assert.equal(board.getPiece("e4"), "wp")
275
+ assert.equal(canceledReason, undefined) // the guard must prevent a spurious cancel
276
+ assert.equal(finished, 1)
277
+ board.destroy()
278
+ })
279
+
280
+ it("should cancel when the held piece is captured via movePiece or setPiece", async () => {
281
+ // via setPiece
282
+ let board = newBoard()
283
+ let reason
284
+ board.enableMoveInput((e) => {
285
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) reason = e.reason
286
+ return true
287
+ }, COLOR.white)
288
+ let vmi = board.view.visualMoveInput
289
+ vmi.onPointerDown(mouseDown("e2"))
290
+ vmi.onPointerUp(mouseUp("e2"))
291
+ await board.setPiece("e2", "bn")
292
+ assert.equal(reason, MOVE_CANCELED_REASON.movedPieceChanged)
293
+ await tick()
294
+ board.destroy()
295
+
296
+ // via movePiece (a black pawn captures onto e2)
297
+ board = newBoard()
298
+ reason = undefined
299
+ board.enableMoveInput((e) => {
300
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) reason = e.reason
301
+ return true
302
+ }, COLOR.white)
303
+ vmi = board.view.visualMoveInput
304
+ vmi.onPointerDown(mouseDown("e2"))
305
+ vmi.onPointerUp(mouseUp("e2"))
306
+ await board.movePiece("d7", "e2")
307
+ assert.equal(reason, MOVE_CANCELED_REASON.movedPieceChanged)
308
+ await tick()
309
+ board.destroy()
310
+ })
311
+
312
+ it("should cancel an in-progress click selection via cancelMoveInput()", async () => {
313
+ const board = newBoard()
314
+ let canceled = null
315
+ board.enableMoveInput((e) => {
316
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceled = {square: e.squareFrom, reason: e.reason}
317
+ return true
318
+ }, COLOR.white)
319
+ const vmi = board.view.visualMoveInput
320
+ vmi.onPointerDown(mouseDown("e2"))
321
+ vmi.onPointerUp(mouseUp("e2"))
322
+ assert.equal(vmi.moveInputState, STATE_CLICK_TO)
323
+
324
+ board.cancelMoveInput()
325
+ assert.equal(canceled && canceled.reason, MOVE_CANCELED_REASON.canceled)
326
+ assert.equal(canceled && canceled.square, "e2")
327
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
328
+ await tick()
329
+ board.destroy()
330
+ })
331
+
332
+ it("should cancel a drag in progress via cancelMoveInput() without moving the piece", async () => {
333
+ const board = newBoard()
334
+ board.enableMoveInput(() => true, COLOR.white)
335
+ const vmi = board.view.visualMoveInput
336
+ vmi.onPointerDown(mouseDown("e2", 100, 100))
337
+ vmi.onPointerMove(mouseMove("e4", 100, 140)) // -> dragTo
338
+ assert.equal(vmi.moveInputState, STATE_DRAG_TO)
339
+
340
+ board.cancelMoveInput()
341
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
342
+ assert.equal(board.getPiece("e2"), "wp")
343
+ assert.equal(board.getPiece("e4"), null)
344
+ await tick()
345
+ board.destroy()
346
+ })
347
+
348
+ it("should be a no-op when cancelMoveInput() is called with no move in progress", () => {
349
+ const board = newBoard()
350
+ let canceledCount = 0
351
+ board.enableMoveInput((e) => {
352
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledCount++
353
+ return true
354
+ }, COLOR.white)
355
+ board.cancelMoveInput()
356
+ assert.equal(canceledCount, 0)
357
+ assert.equal(board.view.visualMoveInput.moveInputState, STATE_WAIT_FOR_INPUT_START)
358
+ board.destroy()
359
+ })
360
+
234
361
  })
package/test/index.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <div class="board" id="TestPosition"></div>
22
22
  <div class="board" id="TestMarkers"></div>
23
23
  <div class="board" id="TestArrows"></div>
24
+ <div class="board" id="TestPieceRotation"></div>
24
25
  <script type="module">
25
26
  import {teevi} from "../node_modules/teevi/src/teevi.js"
26
27
  import "./TestChessboard.js"
@@ -29,6 +30,7 @@
29
30
  import "./TestArrows.js"
30
31
  import "./TestPosition.js"
31
32
  import "./TestVisualMoveInput.js"
33
+ import "./TestPieceRotation.js"
32
34
  teevi.run()
33
35
  </script>
34
36
  </body>