/* eslint-disable no-console */
import React, {
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import PropTypes from 'prop-types';
import produce from 'immer';
import { ThemeContext, ThemeProvider } from 'styled-components';
import { CrosswordContext, CrosswordContextType } from './context';
import {
AnswerTuple,
CluesData,
CluesInput,
cluesInputShapeOriginal,
Direction,
EnhancedProps,
FocusHandler,
GridPosition,
GridData,
UsedCellData,
CellData,
UnusedCellData,
} from './types';
import {
bothDirections,
clearGuesses,
createGridData,
isAcross,
loadGuesses,
otherDirection,
saveGuesses,
} from './util';
const defaultStorageKey = 'guesses';
export const crosswordProviderPropTypes = {
/**
* clue/answer data; see Clue
* input format for details.
*/
data: cluesInputShapeOriginal.isRequired,
/** presentation values for the crossword; these override any values coming from a parent ThemeProvider context. */
theme: PropTypes.shape({
/**
* whether to allow a non-square rendering
* @since 5.1.0
*/
allowNonSquare: PropTypes.bool,
/** browser-width at which the clues go from showing beneath the grid to showing beside the grid */
columnBreakpoint: PropTypes.string,
/** overall background color (fill) for the crossword grid; can be `'transparent'` to show through a page background image */
gridBackground: PropTypes.string,
/** background for an answer cell */
cellBackground: PropTypes.string,
/** border for an answer cell */
cellBorder: PropTypes.string,
/** color for answer text (entered by the player) */
textColor: PropTypes.string,
/** color for the across/down numbers in the grid */
numberColor: PropTypes.string,
/** background color for the cell with focus, the one that the player is typing into */
focusBackground: PropTypes.string,
/**
* background color for the cells in the answer the player is working on,
* helps indicate in which direction focus will be moving; also used as a
* background on the active clue
*/
highlightBackground: PropTypes.string,
}),
/** whether to use browser storage to persist the player's work-in-progress */
useStorage: PropTypes.bool,
/**
* a custom storage key to use for persistence; defaults to "guesses" when not
* provided
*/
storageKey: PropTypes.string,
/**
* callback function that fires when a player completes an answer, whether
* correct or not; called with `(direction, number, correct, answer)`
* arguments, where `direction` is `'across'` or `'down'`, `number` is the
* clue number as text (like `'1'`), `correct` is whether the guessed answer
* is correct and `answer` is the (actual and correct) answer itself
*
* @since 4.3.0
*/
onAnswerComplete: PropTypes.func,
/**
* callback function that fires when a player answers a clue correctly; called
* with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the answer itself
*
* @since 4.3.0; replacing `onCorrect` (to reduce ambiguity)
*/
onAnswerCorrect: PropTypes.func,
/**
* callback function that fires when a player answers a clue correctly; called
* with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the answer itself
*
* @deprecated 4.3.0; being replaced by `onAnswerCorrect` (to reduce
* ambiguity)
*/
onCorrect: PropTypes.func,
/**
* callback function that fires when a player answers a clue *in*correctly;
* called with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the (actual and correct) answer itself
*
* @since 4.3.0
*/
onAnswerIncorrect: PropTypes.func,
/**
* callback function that's called when a crossword is loaded, to batch up
* correct answers loaded from storage; passed an array of the same values
* that `onCorrect` would recieve
*/
onLoadedCorrect: PropTypes.func,
/**
* callback function that's called when the overall crossword is complete,
* whether correct or not; called with `(correct)` argument, a boolean which
* indicates whether the crossword is correct or not.
*/
onCrosswordComplete: PropTypes.func,
/**
* callback function that's called when the overall crossword is completely
* correct (or not)
*
* NOTE: this will be deprecated for `onCrosswordComplete` in the future.
*/
onCrosswordCorrect: PropTypes.func,
/**
* callback function called when a cell changes (e.g. when the user types a
* letter); called with `(row, col, char)` arguments, where the `row` and
* `column` are the 0-based position of the cell, and `char` is the character
* typed (already massaged into upper-case)
*/
onCellChange: PropTypes.func,
/**
* callback function called when a cell changes (e.g. when the user tap cell); called with `(row, col)` arguments, where the `row` and
* `column` are the 0-based position of the cell, and `char` is the character
* typed (already massaged into upper-case)
*/
onCellFocus: PropTypes.func,
/**
* callback function called when a clue is selected
*/
onClueSelected: PropTypes.func,
children: PropTypes.node,
};
export type CrosswordProviderProps = EnhancedProps<
typeof crosswordProviderPropTypes,
{
/**
* clue/answer data; see Clue
* input format for details.
*/
data: CluesInput;
/**
* callback function that fires when a player completes an answer, whether
* correct or not; called with `(direction, number, correct, answer)`
* arguments, where `direction` is `'across'` or `'down'`, `number` is the
* clue number as text (like `'1'`), `correct` is whether the guessed answer
* is correct and `answer` is the (actual and correct) answer itself
*
* @since 4.3.0
*/
onAnswerComplete?: (
direction: Direction,
number: string,
correct: boolean,
answer: string
) => void;
/**
* callback function that fires when a player answers a clue correctly;
* called with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the answer itself
*
* @since 4.3.0; replacing `onCorrect` (to reduce ambiguity)
*/
onAnswerCorrect?: (
direction: Direction,
number: string,
answer: string
) => void;
/**
* callback function that fires when a player answers a clue correctly;
* called with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the answer itself
*
* NOTE: this is the original/previous name for what is now being called
* `onAnswerCorrect` (to reduce ambiguity). It will be deprecated in the
* future.
*/
onCorrect?: (direction: Direction, number: string, answer: string) => void;
/**
* callback function that fires when a player answers a clue *in*correctly;
* called with `(direction, number, answer)` arguments, where `direction` is
* `'across'` or `'down'`, `number` is the clue number as text (like `'1'`),
* and `answer` is the (actual and correct) answer itself
*
* @since 4.3.0
*/
onAnswerIncorrect?: (
direction: Direction,
number: string,
answer: string
) => void;
/**
* callback function that's called when a crossword is loaded, to batch up
* correct answers loaded from storage; passed an array of the same values
* that `onCorrect` would recieve
*/
onLoadedCorrect?: (loaded: AnswerTuple[]) => void;
/**
* callback function that's called when the overall crossword is complete,
* whether correct or not; called with `(correct)` argument, a boolean which
* indicates whether the crossword is correct or not.
*/
onCrosswordComplete?: (correct: boolean) => void;
/**
* callback function that's called when the overall crossword is completely
* correct (or not)
*
* NOTE: this will be deprecated for `onCrosswordComplete` in the future.
*/
onCrosswordCorrect?: (isCorrect: boolean) => void;
/**
* callback function called when a cell changes (e.g. when the user types a
* letter); called with `(row, col, char)` arguments, where the `row` and
* `column` are the 0-based position of the cell, and `char` is the
* character typed (already massaged into upper-case)
*/
onCellChange?: (row: number, col: number, char: string) => void;
/**
* callback function called when a cell changes (e.g. when the user tap cell); called with `(row, col)` arguments, where the `row` and
* `column` are the 0-based position of the cell, and `char` is the character
* typed (already massaged into upper-case)
*/
onCellFocus?: (selectedCell: any) => void;
/**
* callback function called when a clue is selected
*/
onClueSelected?: (direction: Direction, number: string) => void;
}
>;
export interface CrosswordProviderImperative {
/**
* Sets focus to the crossword component.
*/
focus: () => void;
/**
* Resets the entire crossword; clearing all answers in the grid and
* also any persisted data.
*/
reset: () => void;
/**
* Fills all the answers in the grid and calls the `onLoadedCorrect`
* callback with _**every**_ answer.
*/
fillAllAnswers: () => void;
/**
* Returns whether the crossword is entirely correct or not.
*/
isCrosswordCorrect: () => boolean;
/**
* Sets the “guess” character for a specific grid position.
*
* @since 4.1.0
*/
setGuess: (row: number, col: number, guess: string) => void;
}
const defaultTheme: CrosswordProviderProps['theme'] = {
allowNonSquare: false,
columnBreakpoint: '768px',
gridBackground: 'rgb(0,0,0)',
cellBackground: 'rgb(255,255,255)',
cellBorder: 'rgb(0,0,0)',
textColor: 'rgb(0,0,0)',
numberColor: 'rgba(0,0,0, 0.25)',
focusBackground: 'rgb(255,255,0)',
highlightBackground: 'rgb(255,255,204)',
};
/**
* The fundamental logic and data management component for react-crossword.
* Prior to 4.0, puzzle management was built into the `Crossword` component. As
* of 4.0, the logic implementation has been refactored such that `Crossword`
* leverages `CrosswordProvider` to do the heavy lifting.
*
* @since 4.0
*/
const CrosswordProvider = React.forwardRef<
CrosswordProviderImperative,
CrosswordProviderProps
>(
(
{
data,
theme,
onAnswerComplete,
onAnswerCorrect,
onCorrect,
onAnswerIncorrect,
onLoadedCorrect,
onCrosswordComplete,
onCrosswordCorrect,
onCellChange,
onCellFocus,
onClueSelected,
useStorage,
storageKey,
children,
},
ref
) => {
const contextTheme =
useContext(ThemeContext);
// The final theme is the merger of three values: the "theme" property
// passed to the component (which takes precedence), any values from
// ThemeContext, and finally the "defaultTheme" values fill in for any
// needed ones that are missing. (We create this in standard last-one-wins
// order in Javascript, of course.)
const finalTheme = useMemo(
() => ({ ...defaultTheme, ...contextTheme, ...theme }),
[contextTheme, theme]
);
// The original Crossword implementation used separate state to track size
// and grid data, and conflated the clues-input-data-based grid data and the
// player input guesses. Let's see if we can keep the clues-input and
// player data segregated.
const {
rows,
cols,
gridData: masterGridData,
clues: masterClues,
} = useMemo(
() => createGridData(data, finalTheme.allowNonSquare ?? false),
[data, finalTheme.allowNonSquare]
);
const [gridData, setGridData] = useState([]);
const [clues, setClues] = useState();
// We can't seem to use state to track the registeredFocusHandler, because
// there seems to be a delay in 'focus' being usable after it's set. We use
// a Ref instead.
const registeredFocusHandler = useRef(null);
// interactive player state
const [focused, setFocused] = useState(false);
const [focusedRow, setFocusedRow] = useState(0); // rename to selectedRow?
const [focusedCol, setFocusedCol] = useState(0);
const [currentDirection, setCurrentDirection] =
useState('across');
const [currentNumber, setCurrentNumber] = useState('1');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [bulkChange, setBulkChange] = useState(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [checkQueue, setCheckQueue] = useState([]);
// This *internal* getCellData assumes that it's only ever asked for a valid
// cell (one that's used).
const getCellData = useCallback(
(row: number, col: number) => {
if (row >= 0 && row < rows && col >= 0 && col < cols) {
return gridData[row][col];
}
// fake cellData to represent "out of bounds"
return { row, col, used: false, outOfBounds: true } as GridPosition &
UnusedCellData;
},
[cols, gridData, rows]
);
const setCellCharacter = useCallback(
(row: number, col: number, char: string) => {
const cell = getCellData(row, col);
if (!cell.used) {
throw new Error('unexpected setCellCharacter call');
}
// If the character is already the cell's guess, there's nothing to do.
// if (cell.guess === char) {
// return;
// }
// update the gridData with the guess
setGridData(
produce((draft) => {
(draft[row][col] as UsedCellData).guess = char;
})
);
// push the row/col for checking!
setCheckQueue(
produce((draft) => {
draft.push({ row, col });
})
);
if (onCellChange) {
onCellChange(row, col, char);
}
},
[getCellData, onCellChange]
);
const notifyAnswerComplete = useCallback(
(
direction: Direction,
number: string,
correct: boolean,
answer: string
) => {
if (onAnswerComplete) {
onAnswerComplete(direction, number, correct, answer);
}
if (correct) {
if (onAnswerCorrect) {
onAnswerCorrect(direction, number, answer);
}
// NOTE: onCorrect to be (eventually) deprecated
if (onCorrect) {
onCorrect(direction, number, answer);
}
} else if (onAnswerIncorrect) {
onAnswerIncorrect(direction, number, answer);
}
},
[onAnswerComplete, onAnswerCorrect, onAnswerIncorrect, onCorrect]
);
const checkCorrectness = useCallback(
(row: number, col: number) => {
const cell = getCellData(row, col);
if (!cell.used) {
// Because this is in an internal callback, and we only call it with a
// valid cell (row/col), the throw line isn't testable... so we ignore
// it.
/* istanbul ignore next */
throw new Error('unexpected unused cell');
}
// check all the cells for both across and down answers that use this
// cell
bothDirections.forEach((direction) => {
const across = isAcross(direction);
const number = cell[direction];
if (!number) {
return;
}
const info = data[direction][number];
// We send correct/incorrect messages, but *only* if every cell in the
// answer is filled out; there's no point in reporting "incorrect"
// when the answer is simply incomplete.
let complete = true;
let correct = true;
for (let i = 0; i < info.answer.length; i++) {
const checkCell = getCellData(
info.row + (across ? 0 : i),
info.col + (across ? i : 0)
) as UsedCellData;
if (!checkCell.guess) {
complete = false;
correct = false;
break;
}
if (checkCell.guess !== checkCell.answer) {
correct = false;
}
}
// update the clue state
setClues(
produce((draft) => {
if (draft) {
const clueInfo = draft[direction].find(
(i) => i.number === number
);
if (clueInfo) {
clueInfo.complete = complete;
clueInfo.correct = correct;
}
}
})
);
if (complete) {
notifyAnswerComplete(direction, number, correct, info.answer);
}
});
},
[data, getCellData, notifyAnswerComplete]
);
// Any time the checkQueue changes, call checkCorrectness!
useEffect(() => {
if (checkQueue.length === 0) {
return;
}
checkQueue.forEach(({ row, col }) => checkCorrectness(row, col));
setCheckQueue([]);
}, [checkQueue, checkCorrectness]);
// Any time the clues change, determine if they are all complete/correct or
// not.
const { crosswordComplete, crosswordCorrect } = useMemo(() => {
const complete = !!(
clues &&
bothDirections.every((direction) =>
clues[direction].every((clueInfo) => clueInfo.complete)
)
);
const correct =
complete &&
!!(
clues &&
bothDirections.every((direction) =>
clues[direction].every((clueInfo) => clueInfo.correct)
)
);
// console.log('setting crossword correct', { clues, correct });
return { crosswordComplete: complete, crosswordCorrect: correct };
}, [clues]);
// Let the consumer know everything's correct (or not) if they've asked to
// be informed.
useEffect(() => {
if (crosswordComplete) {
if (onCrosswordComplete) {
onCrosswordComplete(crosswordCorrect);
}
if (onCrosswordCorrect) {
onCrosswordCorrect(crosswordCorrect);
}
}
}, [
crosswordComplete,
crosswordCorrect,
onCrosswordComplete,
onCrosswordCorrect,
]);
// focus and movement
const focus = useCallback(() => {
// console.log('CrosswordProvider.focus() called...');
// If there's a registered focus handler, use it!
if (registeredFocusHandler.current) {
// console.log('calling registered focus handler...');
registeredFocusHandler.current();
setFocused(true);
} else {
console.warn(
'CrosswordProvider: focus() has no registered handler to call!'
);
}
}, []);
const moveTo = useCallback(
(row: number, col: number, directionOverride?: Direction) => {
let direction = directionOverride ?? currentDirection;
const candidate = getCellData(row, col);
if (!candidate.used) {
return false;
}
// If we try to move to a cell with a direction it doesn't support,
// switch to the other direction. There is no codepath that can test
// this, though, as this callback isn't exposed, and we only call it in
// ways that guarantee that direction is valid.
if (!candidate[direction]) {
/* istanbul ignore next */
direction = otherDirection(direction);
}
setFocusedRow(row);
setFocusedCol(col);
setCurrentDirection(direction);
setCurrentNumber(candidate[direction] ?? '');
return candidate;
},
[currentDirection, getCellData]
);
const moveRelative = useCallback(
(dRow: number, dCol: number) => {
// We expect *only* one of dRow or dCol to have a non-zero value, and
// that's the direction we will "prefer". If *both* are set (or zero),
// we don't change the direction.
let direction: Direction | undefined;
if (dRow !== 0 && dCol === 0) {
direction = 'down';
} else if (dRow === 0 && dCol !== 0) {
direction = 'across';
}
const cell = moveTo(focusedRow + dRow, focusedCol + dCol, direction);
return cell;
},
[focusedRow, focusedCol, moveTo]
);
const moveForward = useCallback(() => {
const across = isAcross(currentDirection);
moveRelative(across ? 0 : 1, across ? 1 : 0);
}, [currentDirection, moveRelative]);
const moveBackward = useCallback(() => {
const across = isAcross(currentDirection);
moveRelative(across ? 0 : -1, across ? -1 : 0);
}, [currentDirection, moveRelative]);
// keyboard handling
const handleSingleCharacter = useCallback(
(char: string) => {
setCellCharacter(focusedRow, focusedCol, char.toUpperCase());
moveForward();
},
[focusedRow, focusedCol, setCellCharacter, moveForward]
);
// We use the keydown event for control/arrow keys, but not for textual
// input, because it's hard to suss out when a key is "regular" or not.
const handleInputKeyDown = useCallback<
React.KeyboardEventHandler
>(
(event) => {
// if ctrl, alt, or meta are down, ignore the event (let it bubble)
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
let preventDefault = true;
const { key } = event;
// console.log('CROSSWORD KEYDOWN', event.key);
// FUTURE: should we "jump" over black space? That might help some for
// keyboard users.
switch (key) {
case 'ArrowUp':
moveRelative(-1, 0);
break;
case 'ArrowDown':
moveRelative(1, 0);
break;
case 'ArrowLeft':
moveRelative(0, -1);
break;
case 'ArrowRight':
moveRelative(0, 1);
break;
case ' ': // treat space like tab?
case 'Tab': {
const other = otherDirection(currentDirection);
const cellData = getCellData(
focusedRow,
focusedCol
) as UsedCellData;
if (cellData[other]) {
setCurrentDirection(other);
setCurrentNumber(cellData[other] ?? '');
}
break;
}
// Backspace: delete the current cell, and move to the previous cell
// Delete: delete the current cell, but don't move
case 'Backspace':
case 'Delete': {
setCellCharacter(focusedRow, focusedCol, '');
if (key === 'Backspace') {
moveBackward();
}
break;
}
case 'Home':
case 'End': {
// move to beginning/end of this entry?
const info = data[currentDirection][currentNumber];
const {
answer: { length },
} = info;
let { row, col } = info;
if (key === 'End') {
const across = isAcross(currentDirection);
if (across) {
col += length - 1;
} else {
row += length - 1;
}
}
moveTo(row, col);
break;
}
default:
// It would be nice to handle "regular" characters with onInput, but
// that is still experimental, so we can't count on it. Instead, we
// assume that only "length 1" values are regular.
if (key.length !== 1) {
preventDefault = false;
break;
}
handleSingleCharacter(key);
break;
}
if (preventDefault) {
event.preventDefault();
}
},
[
moveRelative,
handleSingleCharacter,
currentDirection,
getCellData,
focusedRow,
focusedCol,
setCellCharacter,
moveBackward,
data,
currentNumber,
moveTo,
]
);
const handleInputChange = useCallback<
React.ChangeEventHandler
>((event) => {
event.preventDefault();
setBulkChange(event.target.value);
}, []);
useEffect(() => {
if (!bulkChange) {
return;
}
// handle bulkChange by updating a character at a time (this lets us
// leverage the existing character-entry logic).
handleSingleCharacter(bulkChange[0]);
setBulkChange(bulkChange.length === 1 ? null : bulkChange.substring(1));
}, [bulkChange, handleSingleCharacter]);
// When the clues *input* data changes, reset/reload the player data
useEffect(() => {
// deep-clone the grid data...
const newGridData = masterGridData.map((row) =>
row.map((cell) => ({ ...cell }))
);
// deep-clone the clue data...
const newCluesData: CluesData = {
across: masterClues.across.map((clue) => ({ ...clue })),
down: masterClues.down.map((clue) => ({ ...clue })),
};
if (useStorage) {
loadGuesses(newGridData, storageKey || defaultStorageKey);
}
setClues(newCluesData);
setGridData(newGridData);
// Check all of the clues to see if any were correct... but only if we
// loaded guesses. Since the current implementation relies on state, we
// leverage the checkQueue to run through all the clues/guesses.
//
// Really, the ideal thing to do would be to write the checking-logic in a
// way that it doesn't assume the data is already in state... that would
// allow us to check everything directly, and simply set the same state
// that checkCorrectness() does, *and* properly call onLoadedCorrect(). As
// it is, this implementation can cause some answers to mentioned in
// onCorrect() more than once (any time an across answer starts inside a
// down answer, or vice versa.)
if (useStorage) {
setCheckQueue(
bothDirections.flatMap((dir) =>
// simply use the row/col that starts each answer.
newCluesData[dir].map(({ row, col }) => ({ row, col }))
)
);
}
// Should we start with 1-across highlighted/focused?
// TODO: track input-field focus so we don't draw highlight when we're not
// really focused, *and* use first actual clue (whether across or down?)
setFocusedRow(0);
setFocusedCol(0);
setCurrentDirection('across');
setCurrentNumber('1');
}, [masterClues, masterGridData, storageKey, useStorage]);
// save the guesses any time they change...
useEffect(() => {
if (gridData === null || !useStorage) {
return;
}
saveGuesses(gridData, storageKey || defaultStorageKey);
}, [gridData, storageKey, useStorage]);
const handleCellClick = useCallback(
(cellData: CellData) => {
if (cellData.used) {
const { row, col } = cellData;
const other = otherDirection(currentDirection);
// should this use moveTo?
setFocusedRow(row);
setFocusedCol(col);
let direction = currentDirection;
const cellDataAll = { ...data.across, ...data.down };
const cellAccross = cellData.across ?? '';
const cellDown = cellData.down ?? '';
const selectedCell = cellDataAll[cellAccross || cellDown];
// CHORE: when across and down it is not empty, then the default is across, make sure first?
// We switch to the "other" direction if (a) the current direction
// isn't available in the clicked cell, or (b) we're already focused
// and the clicked cell is the focused cell, *and* the other direction
// is available.
if (
!cellData[currentDirection] ||
(focused &&
row === focusedRow &&
col === focusedCol &&
cellData[other])
) {
setCurrentDirection(other);
direction = other;
}
setCurrentNumber(cellData[direction] ?? '');
if (onCellFocus) {
onCellFocus({
...selectedCell,
currentRow: row,
currentCol: col,
});
}
}
focus();
},
[currentDirection, focus, focused, focusedCol, focusedRow]
);
const handleInputClick = useCallback<
React.MouseEventHandler
>(
(/* event */) => {
// *don't* event.preventDefault(), because we want the input to actually
// take focus
// Like general cell-clicks, cliking on the input can change direction.
// Unlike cell clicks, we *know* we're clicking on the already-focused
// cell!
const other = otherDirection(currentDirection);
const cellData = getCellData(focusedRow, focusedCol) as UsedCellData;
let direction = currentDirection;
if (focused && cellData[other]) {
setCurrentDirection(other);
direction = other;
}
setCurrentNumber(cellData[direction] ?? '');
focus();
},
[currentDirection, focus, focused, focusedCol, focusedRow, getCellData]
);
const handleClueSelected = useCallback(
(direction: Direction, number: string) => {
const info = clues?.[direction].find((clue) => clue.number === number);
if (!info) {
return;
}
// console.log('CrosswordProvider.handleClueSelected', { info });
// TODO: sanity-check info?
moveTo(info.row, info.col, direction);
focus();
if (onClueSelected) {
onClueSelected(direction, number);
}
},
[clues, focus, moveTo, onClueSelected]
);
const registerFocusHandler = useCallback(
(focusHandler: FocusHandler | null) => {
// console.log('CrosswordProvider.registerFocusHandler() called', {
// name: focusHandler?.name ?? '(NULL)',
// focusHandler,
// });
// *If* registeredFocusHandler is implemented as state, realize that we
// can't simply pass it to the setter... the useState React setter would
// *invoke* the function and take the return value! So, we would have
// to wrap it in a functional setter (setState(() => focusHandler)).
// But, since we're using a Ref, this is just a simple assignment!
registeredFocusHandler.current = focusHandler;
},
[]
);
// imperative commands...
useImperativeHandle(
ref,
() => ({
/**
* Sets focus to the crossword component.
*/
focus,
/**
* Resets the entire crossword; clearing all answers in the grid and
* also any persisted data.
*/
reset: () => {
setGridData(
produce((draft) => {
draft.forEach((rowData) => {
rowData.forEach((cellData) => {
if (cellData.used) {
cellData.guess = '';
}
});
});
})
);
setClues(
produce((draft) => {
bothDirections.forEach((direction) => {
draft?.[direction]?.forEach((clueInfo) => {
delete clueInfo.complete;
delete clueInfo.correct;
});
});
})
);
if (useStorage) {
clearGuesses(storageKey || defaultStorageKey);
}
},
/**
* Fills all the answers in the grid and calls the `onLoadedCorrect`
* callback with _**every**_ answer.
*/
fillAllAnswers: () => {
setGridData(
produce((draft) => {
draft.forEach((rowData) => {
rowData.forEach((cellData) => {
if (cellData.used) {
cellData.guess = cellData.answer;
}
});
});
})
);
setClues(
produce((draft) => {
bothDirections.forEach((direction) => {
draft?.[direction].forEach((clueInfo) => {
clueInfo.complete = true;
clueInfo.correct = true;
});
});
})
);
// trigger onLoadedCorrect with every clue!
if (onLoadedCorrect) {
const loadedCorrect: AnswerTuple[] = [];
bothDirections.forEach((direction) => {
clues?.[direction].forEach(({ number, answer }) => {
loadedCorrect.push([direction, number, answer]);
});
});
onLoadedCorrect(loadedCorrect);
}
},
/**
* Returns whether the crossword is entirely correct or not.
*/
isCrosswordCorrect: () => crosswordCorrect,
/**
* Sets the “guess” character for a specific grid position.
*
* @since 4.1.0
*/
setGuess: (row: number, col: number, guess: string) => {
// REVIEW: should we force-case this?
setCellCharacter(row, col, guess.toUpperCase());
},
}),
[
clues,
crosswordCorrect,
focus,
onLoadedCorrect,
setCellCharacter,
storageKey,
useStorage,
]
);
const crosswordContext = useMemo(
() => ({
rows,
cols,
gridData,
clues,
handleInputKeyDown,
handleInputChange,
handleCellClick,
handleInputClick,
handleClueSelected,
registerFocusHandler,
focused,
selectedPosition: { row: focusedRow, col: focusedCol },
selectedDirection: currentDirection,
selectedNumber: currentNumber,
crosswordCorrect,
}),
[
rows,
cols,
gridData,
clues,
handleInputKeyDown,
handleInputChange,
handleCellClick,
handleInputClick,
handleClueSelected,
registerFocusHandler,
focused,
focusedRow,
focusedCol,
currentDirection,
currentNumber,
crosswordCorrect,
]
);
return (
{children}
);
}
);
export default CrosswordProvider;
CrosswordProvider.displayName = 'CrosswordProvider';
CrosswordProvider.propTypes = crosswordProviderPropTypes;
CrosswordProvider.defaultProps = {
theme: undefined,
useStorage: true,
storageKey: undefined,
onAnswerComplete: undefined,
onAnswerCorrect: undefined,
onCorrect: undefined,
onAnswerIncorrect: undefined,
onLoadedCorrect: undefined,
onCrosswordComplete: undefined,
onCrosswordCorrect: undefined,
onCellChange: undefined,
onCellFocus: undefined,
onClueSelected: undefined,
children: undefined,
};