static/src/javascripts/projects/common/modules/crosswords/crossword.js (692 lines of code) (raw):
import React, { Component, findDOMNode, useEffect } from 'preact/compat';
import fastdom from 'fastdom';
import $ from 'lib/$';
import { mediator } from 'lib/mediator';
import { isIOS, isBreakpoint } from 'lib/detect';
import { scrollTo } from 'lib/scroller';
import { AnagramHelper } from 'common/modules/crosswords/anagram-helper/main';
import debounce from 'lodash/debounce';
import zip from 'lodash/zip';
import { Clues } from 'common/modules/crosswords/clues';
import { Controls } from 'common/modules/crosswords/controls';
import { HiddenInput } from 'common/modules/crosswords/hidden-input';
import { Grid } from 'common/modules/crosswords/grid';
import {
buildClueMap,
buildGrid,
otherDirection,
entryHasCell,
cluesFor,
mapGrid,
getClearableCellsForClue,
getLastCellInClue,
getPreviousClueInGroup,
isFirstCellInClue,
getNextClueInGroup,
isLastCellInClue,
gridSize,
checkClueHasBeenAnswered,
buildSeparatorMap,
cellsForEntry,
} from 'common/modules/crosswords/helpers';
import { keycodes } from 'common/modules/crosswords/keycodes';
import {
saveGridState,
loadGridState,
} from 'common/modules/crosswords/persistence';
import { classNames } from 'common/modules/crosswords/classNames';
class Crossword extends Component {
constructor(props) {
super(props);
const dimensions = this.props.data.dimensions;
this.columns = dimensions.cols;
this.rows = dimensions.rows;
this.clueMap = buildClueMap(this.props.data.entries);
this.state = {
grid: buildGrid(
dimensions.rows,
dimensions.cols,
this.props.data.entries,
loadGridState(this.props.data.id),
),
cellInFocus: null,
directionOfEntry: null,
showAnagramHelper: false,
};
}
componentDidMount() {
// Sticky clue
const $stickyClueWrapper = $(findDOMNode(this.stickyClueWrapper));
const $grid = $(findDOMNode(this.grid));
const $game = $(findDOMNode(this.game));
mediator.on(
'window:resize',
debounce(this.setGridHeight.bind(this), 200),
);
mediator.on(
'window:orientationchange',
debounce(this.setGridHeight.bind(this), 200),
);
this.setGridHeight();
mediator.on('window:throttledScroll', () => {
const gridOffset = $grid.offset();
const gameOffset = $game.offset();
const stickyClueWrapperOffset = $stickyClueWrapper.offset();
const scrollY = window.scrollY;
fastdom.mutate(() => {
// Clear previous state
$stickyClueWrapper
.css('top', '')
.css('bottom', '')
.removeClass('is-fixed');
const scrollYPastGame = scrollY - gameOffset.top;
if (scrollYPastGame >= 0) {
const gridOffsetBottom = gridOffset.top + gridOffset.height;
if (
scrollY >
gridOffsetBottom - stickyClueWrapperOffset.height
) {
$stickyClueWrapper.css('top', 'auto').css('bottom', 0);
} else if (isIOS()) {
// iOS doesn't support sticky things when the keyboard
// is open, so we use absolute positioning and
// programatically update the value of top
$stickyClueWrapper.css('top', scrollYPastGame);
} else {
$stickyClueWrapper.addClass('is-fixed');
}
}
});
});
}
componentDidUpdate(prevProps, prevState) {
// return focus to active cell after exiting anagram helper
if (
!this.state.showAnagramHelper &&
this.state.showAnagramHelper !== prevState.showAnagramHelper
) {
this.focusCurrentCell();
}
}
onKeyDown(event) {
const cell = this.state.cellInFocus;
if (event.keyCode === keycodes.tab) {
event.preventDefault();
if (event.shiftKey) {
this.focusPreviousClue();
} else {
this.focusNextClue();
}
} else if (!event.metaKey && !event.ctrlKey && !event.altKey) {
if (
event.keyCode === keycodes.backspace ||
event.keyCode === keycodes.delete
) {
event.preventDefault();
if (cell) {
if (this.cellIsEmpty(cell.x, cell.y)) {
this.focusPrevious();
} else {
this.setCellValue(cell.x, cell.y, '');
this.save();
}
}
} else if (event.keyCode === keycodes.left) {
event.preventDefault();
this.moveFocus(-1, 0);
} else if (event.keyCode === keycodes.up) {
event.preventDefault();
this.moveFocus(0, -1);
} else if (event.keyCode === keycodes.right) {
event.preventDefault();
this.moveFocus(1, 0);
} else if (event.keyCode === keycodes.down) {
event.preventDefault();
this.moveFocus(0, 1);
}
}
}
// called when cell is selected (by click or programtically focussed)
onSelect(x, y) {
const cellInFocus = this.state.cellInFocus;
const clue = cluesFor(this.clueMap, x, y);
const focussedClue = this.clueInFocus();
let newDirection;
const isInsideFocussedClue = () =>
focussedClue ? entryHasCell(focussedClue, x, y) : false;
if (
cellInFocus &&
cellInFocus.x === x &&
cellInFocus.y === y &&
this.state.directionOfEntry
) {
/** User has clicked again on the highlighted cell, meaning we ought to swap direction */
newDirection = otherDirection(this.state.directionOfEntry);
if (clue[newDirection]) {
this.focusClue(x, y, newDirection);
}
} else if (isInsideFocussedClue() && this.state.directionOfEntry) {
/**
* If we've clicked inside the currently highlighted clue, then we ought to just shift the cursor
* to the new cell, not change direction or anything funny.
*/
this.focusClue(x, y, this.state.directionOfEntry);
} else {
this.state.cellInFocus = {
x,
y,
};
const isStartOfClue = (sourceClue) =>
!!sourceClue &&
sourceClue.position.x === x &&
sourceClue.position.y === y;
/**
* If the user clicks on the start of a down clue midway through an across clue, we should
* prefer to highlight the down clue.
*/
if (!isStartOfClue(clue.across) && isStartOfClue(clue.down)) {
newDirection = 'down';
} else if (clue.across) {
/** Across is the default focus otherwise */
newDirection = 'across';
} else {
newDirection = 'down';
}
this.focusClue(x, y, newDirection);
}
}
onCheat() {
this.allHighlightedClues().forEach((clue) => this.cheat(clue));
this.save();
}
onCheck() {
// 'Check this' checks single and grouped clues
this.allHighlightedClues().forEach((clue) => this.check(clue));
this.save();
}
onSolution() {
this.props.data.entries.forEach((clue) => this.cheat(clue));
this.save();
}
onCheckAll() {
this.props.data.entries.forEach((clue) => this.check(clue));
this.save();
}
onClearAll() {
this.setState({
grid: mapGrid(this.state.grid, (cell) => {
cell.value = '';
return cell;
}),
});
this.save();
}
onClearSingle() {
const clueInFocus = this.clueInFocus();
if (clueInFocus) {
// Merge arrays of cells from all highlighted clues
// const cellsInFocus = _.flatten(_.map(this.allHighlightedClues(), helpers.cellsForEntry, this));
const cellsInFocus = getClearableCellsForClue(
this.state.grid,
this.clueMap,
this.props.data.entries,
clueInFocus,
);
this.setState({
grid: mapGrid(this.state.grid, (cell, gridX, gridY) => {
if (
cellsInFocus.some((c) => c.x === gridX && c.y === gridY)
) {
cell.value = '';
}
return cell;
}),
});
this.save();
}
}
onToggleAnagramHelper() {
// only show anagram helper if a clue is active
if (!this.state.showAnagramHelper) {
if (this.clueInFocus()) {
this.setState({
showAnagramHelper: true,
});
}
} else {
this.setState({
showAnagramHelper: false,
});
}
}
onClickHiddenInput(event) {
const focussed = this.state.cellInFocus;
if (focussed) {
this.onSelect(focussed.x, focussed.y);
}
/* We need to handle touch seperately as touching an input on iPhone does not fire the
click event - listen for a touchStart and preventDefault to avoid calling onSelect twice on
devices that fire click AND touch events. The click event doesn't fire only when the input is already focused */
if (event.type === 'touchstart') {
event.preventDefault();
}
}
setGridHeight() {
if (!this.$gridWrapper) {
this.$gridWrapper = $(findDOMNode(this.gridWrapper));
}
if (
isBreakpoint({
max: 'tablet',
})
) {
fastdom.measure(() => {
// Our grid is a square, set the height of the grid wrapper
// to the width of the grid wrapper
fastdom.mutate(() => {
this.$gridWrapper.css(
'height',
`${this.$gridWrapper.offset().width}px`,
);
});
this.gridHeightIsSet = true;
});
} else if (this.gridHeightIsSet) {
// Remove inline style if tablet and wider
this.$gridWrapper.attr('style', '');
}
}
setCellValue(x, y, value) {
this.setState({
grid: mapGrid(this.state.grid, (cell, gridX, gridY) => {
if (gridX === x && gridY === y) {
cell.value = value;
cell.isError = false;
}
return cell;
}),
});
}
getCellValue(x, y) {
return this.state.grid[x][y].value;
}
setReturnPosition(position) {
this.returnPosition = position;
}
insertCharacter(character) {
const characterUppercase = character.toUpperCase();
const cell = this.state.cellInFocus;
if (
/[A-Za-zÀ-ÿ0-9]/.test(characterUppercase) &&
characterUppercase.length === 1 &&
cell
) {
this.setCellValue(cell.x, cell.y, characterUppercase);
this.save();
this.focusNext();
}
}
cellIsEmpty(x, y) {
return !this.getCellValue(x, y);
}
goToReturnPosition() {
if (
isBreakpoint({
max: 'mobile',
})
) {
if (this.returnPosition) {
scrollTo(this.returnPosition, 250, 'easeOutQuad');
}
this.returnPosition = null;
}
}
indexOfClueInFocus() {
return this.props.data.entries.indexOf(this.clueInFocus());
}
focusPreviousClue() {
const i = this.indexOfClueInFocus();
const entries = this.props.data.entries;
if (i !== -1) {
const newClue = entries[i === 0 ? entries.length - 1 : i - 1];
this.focusClue(
newClue.position.x,
newClue.position.y,
newClue.direction,
);
}
}
focusNextClue() {
const i = this.indexOfClueInFocus();
const entries = this.props.data.entries;
if (i !== -1) {
const newClue = entries[i === entries.length - 1 ? 0 : i + 1];
this.focusClue(
newClue.position.x,
newClue.position.y,
newClue.direction,
);
}
}
moveFocus(deltaX, deltaY) {
const cell = this.state.cellInFocus;
if (!cell) {
return;
}
const x = cell.x + deltaX;
const y = cell.y + deltaY;
let direction = 'down';
if (
this.state.grid[x] &&
this.state.grid[x][y] &&
this.state.grid[x][y].isEditable
) {
if (deltaY !== 0) {
direction = 'down';
} else if (deltaX !== 0) {
direction = 'across';
}
this.focusClue(x, y, direction);
}
}
isAcross() {
return this.state.directionOfEntry === 'across';
}
focusPrevious() {
const cell = this.state.cellInFocus;
const clue = this.clueInFocus();
if (cell && clue) {
if (isFirstCellInClue(cell, clue)) {
const newClue = getPreviousClueInGroup(
this.props.data.entries,
clue,
);
if (newClue) {
const newCell = getLastCellInClue(newClue);
this.focusClue(newCell.x, newCell.y, newClue.direction);
}
} else if (this.isAcross()) {
this.moveFocus(-1, 0);
} else {
this.moveFocus(0, -1);
}
}
}
focusNext() {
const cell = this.state.cellInFocus;
const clue = this.clueInFocus();
if (cell && clue) {
if (isLastCellInClue(cell, clue)) {
const newClue = getNextClueInGroup(
this.props.data.entries,
clue,
);
if (newClue) {
this.focusClue(
newClue.position.x,
newClue.position.y,
newClue.direction,
);
}
} else if (this.isAcross()) {
this.moveFocus(1, 0);
} else {
this.moveFocus(0, 1);
}
}
}
asPercentage(x, y) {
const width = gridSize(this.columns);
const height = gridSize(this.rows);
return {
x: (100 * x) / width,
y: (100 * y) / height,
};
}
focusHiddenInput(x, y) {
const wrapper = findDOMNode(this.hiddenInputComponent.wrapper);
const left = gridSize(x);
const top = gridSize(y);
const position = this.asPercentage(left, top);
/** This has to be done before focus to move viewport accordingly */
wrapper.style.left = `${position.x}%`;
wrapper.style.top = `${position.y}%`;
const hiddenInputNode = findDOMNode(this.hiddenInputComponent.input);
if (document.activeElement !== hiddenInputNode) {
hiddenInputNode.focus();
}
}
// Focus corresponding clue for a given cell
focusClue(x, y, direction) {
const clues = cluesFor(this.clueMap, x, y);
const clue = clues[direction];
if (clues && clue) {
this.focusHiddenInput(x, y);
this.setState({
grid: this.state.grid,
cellInFocus: {
x,
y,
},
directionOfEntry: direction,
});
// Side effect
window.history.replaceState(
undefined,
document.title,
`#${clue.id}`,
);
}
}
// Focus first cell in given clue
focusFirstCellInClue(entry) {
this.focusClue(entry.position.x, entry.position.y, entry.direction);
}
focusCurrentCell() {
if (this.state.cellInFocus) {
this.focusHiddenInput(
this.state.cellInFocus.x,
this.state.cellInFocus.y,
);
}
}
clueInFocus() {
if (this.state.cellInFocus) {
const cluesForCell = cluesFor(
this.clueMap,
this.state.cellInFocus.x,
this.state.cellInFocus.y,
);
if (this.state.directionOfEntry) {
return cluesForCell[this.state.directionOfEntry];
}
}
return null;
}
allHighlightedClues() {
return this.props.data.entries.filter((clue) =>
this.clueIsInFocusGroup(clue),
);
}
clueIsInFocusGroup(clue) {
if (this.state.cellInFocus) {
const cluesForCell = cluesFor(
this.clueMap,
this.state.cellInFocus.x,
this.state.cellInFocus.y,
);
if (
this.state.directionOfEntry &&
cluesForCell[this.state.directionOfEntry]
) {
return cluesForCell[this.state.directionOfEntry].group.includes(
clue.id,
);
}
}
return false;
}
cluesData() {
return this.props.data.entries.map((entry) => {
const hasAnswered = checkClueHasBeenAnswered(
this.state.grid,
entry,
);
return {
entry,
hasAnswered,
isSelected: this.clueIsInFocusGroup(entry),
};
});
}
save() {
saveGridState(this.props.data.id, this.state.grid);
}
cheat(entry) {
const cells = cellsForEntry(entry);
if (entry.solution) {
this.setState({
grid: mapGrid(this.state.grid, (cell, x, y) => {
if (cells.some((c) => c.x === x && c.y === y)) {
const n =
entry.direction === 'across'
? x - entry.position.x
: y - entry.position.y;
cell.value = entry.solution[n];
}
return cell;
}),
});
}
}
check(entry) {
const cells = cellsForEntry(entry);
if (entry.solution) {
const badCells = zip(cells, entry.solution.split(''))
.filter((cellAndSolution) => {
const coords = cellAndSolution[0];
const cell = this.state.grid[coords.x][coords.y];
const solution = cellAndSolution[1];
return (
/^[A-Z]$/.test(cell.value) && cell.value !== solution
);
})
.map((cellAndSolution) => cellAndSolution[0]);
this.setState({
grid: mapGrid(this.state.grid, (cell, gridX, gridY) => {
if (
badCells.some(
(bad) => bad.x === gridX && bad.y === gridY,
)
) {
cell.isError = true;
cell.value = '';
}
return cell;
}),
});
setTimeout(() => {
this.setState({
grid: mapGrid(this.state.grid, (cell, gridX, gridY) => {
if (
badCells.some(
(bad) => bad.x === gridX && bad.y === gridY,
)
) {
cell.isError = false;
cell.value = '';
}
return cell;
}),
});
}, 150);
}
}
hiddenInputValue() {
const cell = this.state.cellInFocus;
let currentValue;
if (cell) {
currentValue = this.state.grid[cell.x][cell.y].value;
}
return currentValue || '';
}
hasSolutions() {
return 'solution' in this.props.data.entries[0];
}
isHighlighted(x, y) {
const focused = this.clueInFocus();
return focused
? focused.group.some((id) => {
const entry = this.props.data.entries.find(
(e) => e.id === id,
);
return entryHasCell(entry, x, y);
})
: false;
}
render() {
const focused = this.clueInFocus();
const anagramHelper = this.state.showAnagramHelper && (
<AnagramHelper
crossword={this}
focussedEntry={focused}
entries={this.props.data.entries}
grid={this.state.grid}
close={this.onToggleAnagramHelper}
/>
);
const gridProps = {
rows: this.rows,
columns: this.columns,
cells: this.state.grid,
separators: buildSeparatorMap(this.props.data.entries),
crossword: this,
focussedCell: this.state.cellInFocus,
ref: (grid) => {
this.grid = grid;
},
};
return (
<div
className={`crossword__container crossword__container--${this.props.data.dimensions.cols}cell crossword__container--react`}
data-link-name="Crosswords"
>
<div
className="crossword__container__game"
ref={(game) => {
this.game = game;
}}
>
<div
className="crossword__sticky-clue-wrapper"
ref={(stickyClueWrapper) => {
this.stickyClueWrapper = stickyClueWrapper;
}}
>
<div
className={classNames({
'crossword__sticky-clue': true,
'is-hidden': !focused,
})}
>
{focused && (
<div className="crossword__sticky-clue__inner">
<div className="crossword__sticky-clue__inner__inner">
<strong>
{focused.number}{' '}
<span className="crossword__sticky-clue__direction">
{focused.direction}{' '}
</span>
</strong>
<span
dangerouslySetInnerHTML={{
__html: focused.clue,
}}
/>
</div>
</div>
)}
</div>
</div>
<div
className="crossword__container__grid-wrapper"
ref={(gridWrapper) => {
this.gridWrapper = gridWrapper;
}}
>
{Grid(gridProps)}
<HiddenInput
crossword={this}
value={this.hiddenInputValue()}
ref={(hiddenInputComponent) => {
this.hiddenInputComponent =
hiddenInputComponent;
}}
/>
{anagramHelper}
</div>
</div>
<Controls
hasSolutions={this.hasSolutions()}
clueInFocus={focused}
crossword={this}
/>
<Clues
clues={this.cluesData()}
focussed={focused}
setReturnPosition={this.setReturnPosition.bind(this)}
/>
</div>
);
}
}
export default Crossword;