Skip to content

Commit

Permalink
Add autoplay (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
yo35 committed Apr 22, 2024
1 parent 6201521 commit 56a6ad1
Show file tree
Hide file tree
Showing 27 changed files with 309 additions and 10 deletions.
7 changes: 7 additions & 0 deletions doc_src/demo/PageNavigationBoardBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface PageState {
moveArrowVisible: boolean;
moveArrowColor: AnnotationColor;
animated: boolean;
playButtonVisible: boolean;
flipButtonVisible: boolean;
}

Expand All @@ -73,6 +74,7 @@ export default class Page extends React.Component<object, PageState> {
moveArrowVisible: true,
moveArrowColor: 'b',
animated: true,
playButtonVisible: true,
flipButtonVisible: true,
};
}
Expand All @@ -90,6 +92,9 @@ export default class Page extends React.Component<object, PageState> {
private renderControls() {
return (<>
<Stack direction="row" spacing={2} alignItems="center">
<FormControlLabel label="Show play/stop button"
control={<Switch checked={this.state.playButtonVisible} onChange={() => this.setState({ playButtonVisible: !this.state.playButtonVisible })} color="primary" />}
/>
<FormControlLabel label="Show flip button"
control={<Switch checked={this.state.flipButtonVisible} onChange={() => this.setState({ flipButtonVisible: !this.state.flipButtonVisible })} color="primary" />}
/>
Expand Down Expand Up @@ -160,6 +165,7 @@ export default class Page extends React.Component<object, PageState> {
moveArrowVisible={this.state.moveArrowVisible}
moveArrowColor={this.state.moveArrowColor}
animated={this.state.animated}
playButtonVisible={this.state.playButtonVisible}
flipButtonVisible={this.state.flipButtonVisible}
/>
</Box>
Expand All @@ -180,6 +186,7 @@ export default class Page extends React.Component<object, PageState> {
attributes.push(`moveArrowColor="${this.state.moveArrowColor}"`);
}
attributes.push(`animated={${this.state.animated}}`);
attributes.push(`playButtonVisible={${this.state.playButtonVisible}}`);
attributes.push(`flipButtonVisible={${this.state.flipButtonVisible}}`);
const pgnDeclaration = 'const pgn = `\n' + pgn.trim() + '`;\n\n';
return <pre className="kokopu-demoCode">{pgnDeclaration + buildComponentDemoCode('NavigationBoard', attributes)}</pre>;
Expand Down
2 changes: 1 addition & 1 deletion doc_src/examples/NavigationBoard.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ const pgn = `
1.e4 Nc6 2.Nf3 d5 3.Bd3 Nf6 4.exd5 Qxd5 5.Nc3 Qh5 6.O-O Bg4
7.h3 Ne5 8.hxg4 Nfxg4 9.Nxe5 Qh2# 0-1`;

<NavigationBoard game={pgn} initialNodeId="end" />
<NavigationBoard game={pgn} initialNodeId="end" playButtonVisible />
```
1 change: 1 addition & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export let TOOLTIP_GO_FIRST = 'Go to the beginning of the game';
export let TOOLTIP_GO_PREVIOUS = 'Go to the previous move';
export let TOOLTIP_GO_NEXT = 'Go to the next move';
export let TOOLTIP_GO_LAST = 'Go to the end of the game';
export let TOOLTIP_PLAY_STOP = 'Play/stop the game';
export let TOOLTIP_FLIP = 'Flip the board';

// Movetext
Expand Down
91 changes: 82 additions & 9 deletions src/navigationboard/NavigationBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ import { DynamicBoardGraphicProps, defaultDynamicBoardProps } from '../chessboar
import { Chessboard } from '../chessboard/Chessboard';
import { parseGame } from '../errorbox/parsing';
import { NavigationField, firstNodeId, previousNodeId, nextNodeId, lastNodeId } from '../navigationboard/NavigationField';
import { GO_FIRST_ICON_PATH, GO_PREVIOUS_ICON_PATH, GO_NEXT_ICON_PATH, GO_LAST_ICON_PATH, FLIP_ICON_PATH } from './iconPaths';
import { GO_FIRST_ICON_PATH, GO_PREVIOUS_ICON_PATH, GO_NEXT_ICON_PATH, GO_LAST_ICON_PATH, PLAY_ICON_PATH, STOP_ICON_PATH, FLIP_ICON_PATH } from './iconPaths';
import { NavigationButton, NavigationButtonList, isNavigationButton } from './NavigationButton';
import { NavigationToolbar } from './NavigationToolbar';


const INTER_MOVE_DURATION = 1000;


export interface NavigationBoardProps extends DynamicBoardGraphicProps {

/**
Expand Down Expand Up @@ -77,6 +80,25 @@ export interface NavigationBoardProps extends DynamicBoardGraphicProps {
*/
onNodeIdChanged?: (nodeId: string) => void;

/**
* Whether auto-play is initially enabled or not.
* Ignored if the `isPlaying` attribute is provided.
*/
initialIsPlaying: boolean;

/**
* Whether auto-play is enabled or not.
* If provided (i.e. if the is-playing state is controlled), the attribute `onIsPlayingChanged` must be provided as well.
*/
isPlaying?: boolean;

/**
* Callback invoked in controlled-is-playing-state mode, when the user clicks on the play/stop button.
*
* @param isPlaying - New is-playing state.
*/
onIsPlayingChanged?: (isPlaying: boolean) => void;

/**
* Whether the board is initially flipped (i.e. seen from Black's point of view) or not, when the flip state is uncontrolled.
* Ignored if the `flipped` attribute is provided.
Expand All @@ -96,6 +118,11 @@ export interface NavigationBoardProps extends DynamicBoardGraphicProps {
*/
onFlippedChanged?: (flipped: boolean) => void;

/**
* Whether the play/stop button is visible or not in the toolbar.
*/
playButtonVisible: boolean;

/**
* Whether the flip button is visible or not in the toolbar.
*/
Expand All @@ -110,6 +137,7 @@ export interface NavigationBoardProps extends DynamicBoardGraphicProps {

interface NavigationBoardState {
nodeIdAsUncontrolled: string;
isPlayingAsUncontrolled: boolean;
flippedAsUncontrolled: boolean;
}

Expand All @@ -124,22 +152,31 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
game: new Game(),
gameIndex: 0,
initialNodeId: 'start',
initialIsPlaying: false,
initialFlipped: false,
playButtonVisible: false,
flipButtonVisible: true,
additionalButtons: [],
};

private navigationFieldRef: React.RefObject<NavigationField> = React.createRef();
private timeoutId?: number;

constructor(props: NavigationBoardProps) {
super(props);
this.state = {
nodeIdAsUncontrolled: sanitizeString(props.initialNodeId),
isPlayingAsUncontrolled: sanitizeBoolean(props.initialIsPlaying),
flippedAsUncontrolled: sanitizeBoolean(props.initialFlipped),
};
}

componentWillUnmount() {
this.cancelCurrentTimeout();
}

render() {
this.cancelCurrentTimeout();

// Validate the game and game-index attributes.
const info = parseGame(this.props.game, this.props.gameIndex, 'NavigationBoard');
Expand All @@ -151,18 +188,19 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
const currentNodeId = sanitizeOptional(this.props.nodeId, sanitizeString) ?? this.state.nodeIdAsUncontrolled;
const currentNode = info.game.findById(currentNodeId) ?? info.game.mainVariation();

// Flip state.
// State flags.
const isPlaying = sanitizeOptional(this.props.isPlaying, sanitizeBoolean) ?? this.state.isPlayingAsUncontrolled;
const flipped = sanitizeOptional(this.props.flipped, sanitizeBoolean) ?? this.state.flippedAsUncontrolled;

return (
<div className="kokopu-navigationBoard">
{this.renderBoard(info.game, currentNode, flipped)}
{this.renderBoard(info.game, currentNode, isPlaying, flipped)}
{this.renderNavigationField(info.game, currentNode.id())}
</div>
);
}

private renderBoard(game: Game, node: GameNode | Variation, flipped: boolean) {
private renderBoard(game: Game, node: GameNode | Variation, isPlaying: boolean, flipped: boolean) {
const position = node instanceof GameNode ? node.positionBefore() : node.initialPosition();
const move = node instanceof GameNode ? node.notation() : undefined;
return <Chessboard
Expand All @@ -177,7 +215,7 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
moveArrowVisible={this.props.moveArrowVisible}
moveArrowColor={this.props.moveArrowColor}
animated={this.props.animated}
bottomComponent={({ squareSize }) => this.renderToolbar(game, node, squareSize)}
bottomComponent={({ squareSize }) => this.renderToolbar(game, node, squareSize, isPlaying)}
/>;
}

Expand All @@ -190,7 +228,7 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
/>;
}

private renderToolbar(game: Game, node: GameNode | Variation, squareSize: number) {
private renderToolbar(game: Game, node: GameNode | Variation, squareSize: number, isPlaying: boolean) {
const buttons: NavigationButtonList = [];

// Core navigation buttons
Expand All @@ -199,14 +237,28 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
const hasNext = (node instanceof Variation ? node.first() : node.next()) !== undefined;
buttons.push({ iconPath: GO_FIRST_ICON_PATH, tooltip: i18n.TOOLTIP_GO_FIRST, enabled: hasPrevious, onClick: () => this.handleNavClicked(firstNodeId(game, currentNodeId)) });
buttons.push({ iconPath: GO_PREVIOUS_ICON_PATH, tooltip: i18n.TOOLTIP_GO_PREVIOUS, enabled: hasPrevious, onClick: () => this.handleNavClicked(previousNodeId(game, currentNodeId)) });
if (sanitizeBoolean(this.props.playButtonVisible)) {
buttons.push({ iconPath: isPlaying ? STOP_ICON_PATH : PLAY_ICON_PATH, tooltip: i18n.TOOLTIP_PLAY_STOP, enabled: hasNext, onClick: () => this.handlePlayStopClicked(!isPlaying) });
}
buttons.push({ iconPath: GO_NEXT_ICON_PATH, tooltip: i18n.TOOLTIP_GO_NEXT, enabled: hasNext, onClick: () => this.handleNavClicked(nextNodeId(game, currentNodeId)) });
buttons.push({ iconPath: GO_LAST_ICON_PATH, tooltip: i18n.TOOLTIP_GO_LAST, enabled: hasNext, onClick: () => this.handleNavClicked(lastNodeId(game, currentNodeId)) });
buttons.push('spacer');
if (this.props.flipButtonVisible) {
if (sanitizeBoolean(this.props.flipButtonVisible)) {
buttons.push({ iconPath: FLIP_ICON_PATH, tooltip: i18n.TOOLTIP_FLIP, onClick: () => this.handleFlipButtonClicked() });
}
buttons.push('spacer');

// Schedule the next transition if auto-play is enabled.
if (isPlaying) {
if (hasNext) {
this.timeoutId = window.setTimeout(() => this.handleNavClicked(nextNodeId(game, currentNodeId), false), INTER_MOVE_DURATION);
}
else {
// ... or stop the auto-play if at the end of the game.
this.timeoutId = window.setTimeout(() => this.handlePlayStopClicked(false, false), 0);
}
}

// Additional buttons.
const additionalButtons = sanitizeNavigationButtonList(this.props.additionalButtons, () => new IllegalArgument('NavigationBoard', 'additionalButtons'));
for (const button of additionalButtons) {
Expand All @@ -216,8 +268,10 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
return <NavigationToolbar squareSize={squareSize} buttons={buttons} />;
}

private handleNavClicked(targetNodeId: string | undefined) {
this.focus();
private handleNavClicked(targetNodeId: string | undefined, forceFocus = true) {
if (forceFocus) {
this.focus();
}
if (targetNodeId === undefined) {
return;
}
Expand All @@ -230,6 +284,18 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
}
}

private handlePlayStopClicked(targetIsPlaying: boolean, forceFocus = true) {
if (forceFocus) {
this.focus();
}
if (this.props.isPlaying === undefined) { // uncontrolled is-playing state
this.setState({ isPlayingAsUncontrolled: targetIsPlaying });
}
else if (this.props.onIsPlayingChanged) { // controlled is-playing state
this.props.onIsPlayingChanged(targetIsPlaying);
}
}

private handleFlipButtonClicked() {
this.focus();
if (this.props.flipped === undefined) { // uncontrolled flip state
Expand All @@ -240,6 +306,13 @@ export class NavigationBoard extends React.Component<NavigationBoardProps, Navig
}
}

private cancelCurrentTimeout() {
if (this.timeoutId !== undefined) {
window.clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
}

/**
* Set the focus to the current component.
*
Expand Down
2 changes: 2 additions & 0 deletions src/navigationboard/iconPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,6 @@ export const GO_FIRST_ICON_PATH = chevronPath(14, 16, -1) + ' ' + chevronPath(10
export const GO_PREVIOUS_ICON_PATH = chevronPath(12, 16, -1);
export const GO_NEXT_ICON_PATH = chevronPath(20, 16, 1);
export const GO_LAST_ICON_PATH = chevronPath(18, 16, 1) + ' ' + chevronPath(22, 16, 1);
export const PLAY_ICON_PATH = 'M 23 16 L 12 23 V 9 Z';
export const STOP_ICON_PATH = 'M 11 11 H 21 V 21 H 11 Z';
export const FLIP_ICON_PATH = arrowPath(11, 25, 1) + ' ' + arrowPath(21, 7, -1);
100 changes: 100 additions & 0 deletions test/14_navigation_board_autoplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*!
* -------------------------------------------------------------------------- *
* *
* Kokopu-React - A React-based library of chess-related components. *
* <https://www.npmjs.com/package/kokopu-react> *
* Copyright (C) 2021-2024 Yoann Le Montagner <yo35 -at- melix.net> *
* *
* Kokopu-React is free software: you can redistribute it and/or *
* modify it under the terms of the GNU Lesser General Public License *
* as published by the Free Software Foundation, either version 3 of *
* the License, or (at your option) any later version. *
* *
* Kokopu-React is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General *
* Public License along with this program. If not, see *
* <http://www.gnu.org/licenses/>. *
* *
* -------------------------------------------------------------------------- */


const { describeWithBrowser, itCustom, setSandbox, compareSandbox, takeScreenshot, compareScreenshot, itChecksScreenshots, waitForAutoplay } = require('./common/graphic');
const { By } = require('selenium-webdriver');


const PLAY_STOP_BUTTON_TITLE = 'Play/stop the game';


describeWithBrowser('Navigation board auto-play - Uncontrolled behavior', browserContext => {

itChecksScreenshots(browserContext, '14_navigation_board_autoplay/uncontrolled', [
'initial state default',
'initial state close to end',
]);

function itCheckClickWaitClick(itemIndex, label, nbMoves) {
itCustom(browserContext, '14_navigation_board_autoplay/uncontrolled', itemIndex, label, async element => {
const buttonElement = await element.findElement(By.xpath(`.//div[@title='${PLAY_STOP_BUTTON_TITLE}']`));
await buttonElement.click();
await waitForAutoplay(nbMoves);
await takeScreenshot(browserContext, `${label} before stop`, element);
await buttonElement.click();
await takeScreenshot(browserContext, `${label} after stop`, element);
await compareScreenshot(browserContext, `${label} before stop`);
await compareScreenshot(browserContext, `${label} after stop`);
});
}

itCheckClickWaitClick(0, 'after 2 moves from beginning', 2);
itCheckClickWaitClick(1, 'after reaching the end', 1);
});


describeWithBrowser('Navigation board auto-play - Controlled behavior', browserContext => {

itChecksScreenshots(browserContext, '14_navigation_board_autoplay/controlled', [
'initial state not acknowledging node changes',
'initial state acknowledging node changes',
'initial state close to end',
'initial state not acknowledging is-playing changes',
'initial state acknowledging is-playing changes',
'initial state at the end',
]);

function itCheckClickWaitClick(itemIndex, label, nbMoves, expectedSandboxAfterClick1, expectedSandboxAfterClick2) {
itCustom(browserContext, '14_navigation_board_autoplay/controlled', itemIndex, label, async element => {
await setSandbox(browserContext, '');
const buttonElement = await element.findElement(By.xpath(`.//div[@title='${PLAY_STOP_BUTTON_TITLE}']`));
await buttonElement.click();
await compareSandbox(browserContext, expectedSandboxAfterClick1);
await waitForAutoplay(nbMoves);
await buttonElement.click();
await takeScreenshot(browserContext, label, element);
await compareSandbox(browserContext, expectedSandboxAfterClick2);
await compareScreenshot(browserContext, label);
});
}

itCheckClickWaitClick(0, 'after 2 moves not acknowledging node changes', 2, '', 'Node ID changed: 3b');
itCheckClickWaitClick(1, 'after 2 moves acknowledging node changes', 2, '', 'Node ID changed: 3b\nNode ID changed: 4w');
itCheckClickWaitClick(2, 'after reaching the end', 1, '', 'Node ID changed: 20b');
itCheckClickWaitClick(3, 'after 1 move not acknowledging is-playing changes', 1, 'is-playing flag changed: true', 'is-playing flag changed: true');
itCheckClickWaitClick(4, 'after 1 move acknowledging is-playing changes', 1, 'is-playing flag changed: true', 'is-playing flag changed: false');

function itCheckClick(itemIndex, label, expectedSandboxAfterClick) {
itCustom(browserContext, '14_navigation_board_autoplay/controlled', itemIndex, label, async element => {
await setSandbox(browserContext, '');
const buttonElement = await element.findElement(By.xpath(`.//div[@title='${PLAY_STOP_BUTTON_TITLE}']`));
await buttonElement.click();
await takeScreenshot(browserContext, label, element);
await compareSandbox(browserContext, expectedSandboxAfterClick);
await compareScreenshot(browserContext, label);
});
}

itCheckClick(5, 'already at the end', '');
});
9 changes: 9 additions & 0 deletions test/common/graphic.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const UNREACHABLE_TEST_CLIENT_MESSAGE =


const ANIMATION_DELAY = 200;
const AUTOPLAY_DELAY = 1000;


/**
Expand Down Expand Up @@ -151,6 +152,14 @@ const waitForAnimation = exports.waitForAnimation = async function() {
};


/**
* Wait until the given number of moves have been auto-played.
*/
exports.waitForAutoplay = async function(nbMoves) {
await new Promise(resolve => setTimeout(resolve, (nbMoves + 0.5) * AUTOPLAY_DELAY));
};


/**
* Take a screenshot of the element identified by the given CSS target.
*/
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 56a6ad1

Please sign in to comment.