scrabble-solver
Version:
Scrabble Solver 2 - Free, open-source, cross-platform, multi-language analysis tool for Scrabble, Scrabble Duel, Super Scrabble, Letter League, Crossplay, Literaki, and Kelimelik. Quickly find the top-scoring words using the given board and tiles.
377 lines (313 loc) • 12.3 kB
text/typescript
/* eslint-disable max-lines, max-statements */
import { type PayloadAction } from '@reduxjs/toolkit';
import { BLANK, EMPTY_CELL } from '@scrabble-solver/constants';
import { Board, type Cell } from '@scrabble-solver/types';
import {
type ChangeEvent,
type ChangeEventHandler,
type ClipboardEventHandler,
type KeyboardEventHandler,
type RefObject,
createRef,
useCallback,
useMemo,
useState,
} from 'react';
import { useDispatch } from 'react-redux';
import { useLatest } from '@/hooks';
import { LOCALE_FEATURES } from '@/i18n';
import { createKeyboardNavigation, extractCharacters, extractInputValue, isCtrl } from '@/lib';
import { boardSlice, cellFiltersSlice, selectConfig, selectLocale, useTypedSelector } from '@/state';
import { type Direction, type Point } from '@/types';
import { createGrid, getPositionInGrid } from '../lib';
const toggleDirection = (direction: Direction) => (direction === 'vertical' ? 'horizontal' : 'vertical');
interface State {
activeIndex: Point;
direction: Direction;
inputRefs: RefObject<HTMLInputElement | null>[][];
}
interface Actions {
insertValue: (position: Point, value: string) => void;
onChange: ChangeEventHandler<HTMLInputElement>;
onDirectionToggle: () => void;
onFocus: (x: number, y: number) => void;
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
onPaste: ClipboardEventHandler<HTMLInputElement>;
}
export const useGrid = (rows: Cell[][]): [State, Actions] => {
const height = rows.length;
const width = rows[0].length;
const dispatch = useDispatch();
const config = useTypedSelector(selectConfig);
const locale = useTypedSelector(selectLocale);
const inputRefs = useMemo(
() => createGrid<RefObject<HTMLInputElement | null>>(width, height, () => createRef()),
[width, height],
);
const [activeIndex, setActiveIndex] = useState<Point>({ x: 0, y: 0 });
const [direction, setLastDirection] = useState<Direction>('horizontal');
const directionRef = useLatest(direction);
const safeActiveIndex = useMemo(
() => ({
x: Math.min(activeIndex.x, width - 1),
y: Math.min(activeIndex.y, height - 1),
}),
[activeIndex, width, height],
);
const changeActiveIndex = useCallback(
(offsetX: number, offsetY: number) => {
const x = Math.min(Math.max(safeActiveIndex.x + offsetX, 0), width - 1);
const y = Math.min(Math.max(safeActiveIndex.y + offsetY, 0), height - 1);
setActiveIndex({ x, y });
inputRefs[y][x].current?.focus();
},
[safeActiveIndex, height, inputRefs, width],
);
const getInputRefPosition = useCallback(
(inputRef: HTMLInputElement): Point | undefined => {
return getPositionInGrid(inputRefs, (ref) => ref.current === inputRef);
},
[inputRefs],
);
const moveFocus = useCallback(
(offset: number) => {
if (directionRef.current === 'horizontal') {
changeActiveIndex(offset, 0);
} else {
changeActiveIndex(0, offset);
}
},
[changeActiveIndex, directionRef],
);
const insertValue = useCallback(
(position: Point, value: string) => {
const characters = value ? extractCharacters(config, value).filter((character) => character !== BLANK) : [BLANK];
const actions: PayloadAction<unknown>[] = [];
let board = new Board({ rows: rows.map((row) => row.map((cell) => cell.clone())) });
let { x, y } = position;
const scheduleMoveFocus = () => {
if (directionRef.current === 'horizontal') {
++x;
} else {
++y;
}
};
characters.forEach((character) => {
if (x >= config.boardWidth || y >= config.boardHeight) {
return;
}
const canCheckUp = y - 1 > 0;
const canCheckLeft = x > 0;
const canCheckRight = x + 1 < width;
const canCheckDown = y + 1 < height;
if (canCheckUp) {
const cellUp = board.rows[y - 1][x];
const twoCharacterCandidate = cellUp.tile.character + character;
if (!cellUp.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
const action = boardSlice.actions.changeCellValue({ x, y: y - 1, value: twoCharacterCandidate });
board = boardSlice.reducer(board, action);
actions.push(action);
return;
}
}
if (canCheckDown) {
const cellDown = board.rows[y + 1][x];
const twoCharacterCandidate = character + cellDown.tile.character;
if (!cellDown.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
const action1 = boardSlice.actions.changeCellValue({ x, y, value: character });
const action2 = boardSlice.actions.changeCellValue({ x, y: y + 1, value: EMPTY_CELL });
board = boardSlice.reducer(boardSlice.reducer(board, action1), action2);
actions.push(action1, action2);
scheduleMoveFocus();
return;
}
}
if (canCheckLeft) {
const cellLeft = board.rows[y][x - 1];
const twoCharacterCandidate = cellLeft.tile.character + character;
if (!cellLeft.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
const action = boardSlice.actions.changeCellValue({ x: x - 1, y, value: twoCharacterCandidate });
board = boardSlice.reducer(board, action);
actions.push(action);
return;
}
}
if (canCheckRight) {
const cellRight = board.rows[y][x + 1];
const twoCharacterCandidate = character + cellRight.tile.character;
if (!cellRight.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
const action1 = boardSlice.actions.changeCellValue({ x, y, value: character });
const action2 = boardSlice.actions.changeCellValue({ x: x + 1, y, value: EMPTY_CELL });
board = boardSlice.reducer(boardSlice.reducer(board, action1), action2);
actions.push(action1, action2);
scheduleMoveFocus();
return;
}
}
if (!canCheckDown || !canCheckRight) {
const cell = board.rows[y][x];
const twoCharacterCandidate = cell.tile.character + character;
if (!cell.tile.isBlank && config.twoCharacterTiles.includes(twoCharacterCandidate)) {
const action = boardSlice.actions.changeCellValue({ x, y, value: twoCharacterCandidate });
board = boardSlice.reducer(board, action);
actions.push(action);
return;
}
}
const action = boardSlice.actions.changeCellValue({ x, y, value: character });
board = boardSlice.reducer(board, action);
actions.push(action);
scheduleMoveFocus();
});
moveFocus(Math.abs(position.x - x) + Math.abs(position.y - y));
actions.forEach(dispatch);
},
[config, directionRef, dispatch, height, moveFocus, rows, width],
);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const position = getInputRefPosition(event.target);
if (!position) {
return;
}
const value = extractInputValue(event.target);
if (!value) {
dispatch(boardSlice.actions.changeCellValue({ ...position, value: EMPTY_CELL }));
moveFocus(-1);
return;
}
if (value === EMPTY_CELL) {
const { x, y } = position;
const cell = rows[y][x];
if (cell.hasTile()) {
dispatch(boardSlice.actions.toggleCellIsBlank(position));
return;
}
}
insertValue(position, value);
},
[dispatch, insertValue, moveFocus, rows, getInputRefPosition],
);
const onDirectionToggle = useCallback(() => setLastDirection(toggleDirection), []);
const onFocus = useCallback((x: number, y: number) => {
setActiveIndex({ x, y });
}, []);
const onKeyDown = useMemo(() => {
return createKeyboardNavigation({
onArrowDown: (event) => {
event.preventDefault();
if (direction === 'horizontal') {
onDirectionToggle();
} else {
changeActiveIndex(0, 1);
}
},
onArrowLeft: (event) => {
event.preventDefault();
if (direction === 'vertical') {
onDirectionToggle();
changeActiveIndex(LOCALE_FEATURES[locale].direction === 'ltr' ? -1 : 0, 0);
} else {
changeActiveIndex(LOCALE_FEATURES[locale].direction === 'ltr' ? -1 : 1, 0);
}
},
onArrowRight: (event) => {
event.preventDefault();
if (direction === 'vertical') {
onDirectionToggle();
changeActiveIndex(LOCALE_FEATURES[locale].direction === 'ltr' ? 0 : -1, 0);
} else {
changeActiveIndex(LOCALE_FEATURES[locale].direction === 'ltr' ? 1 : -1, 0);
}
},
onArrowUp: (event) => {
event.preventDefault();
if (direction === 'horizontal') {
onDirectionToggle();
}
changeActiveIndex(0, -1);
},
onBackspace: (event) => {
const position = getInputRefPosition(event.target as HTMLInputElement);
if (!position) {
return;
}
event.preventDefault();
dispatch(boardSlice.actions.changeCellValue({ ...position, value: EMPTY_CELL }));
moveFocus(-1);
},
onDelete: (event) => {
const position = getInputRefPosition(event.target as HTMLInputElement);
if (!position) {
return;
}
event.preventDefault();
dispatch(boardSlice.actions.changeCellValue({ ...position, value: EMPTY_CELL }));
moveFocus(1);
},
onKeyDown: (event) => {
const position = getInputRefPosition(event.target as HTMLInputElement);
if (!position) {
return;
}
const { x, y } = position;
const character = event.key.toLocaleLowerCase();
const twoCharacterTile = config.getTwoCharacterTileByPrefix(character);
if (isCtrl(event) && twoCharacterTile) {
event.preventDefault();
dispatch(boardSlice.actions.changeCellValue({ x, y, value: twoCharacterTile }));
moveFocus(1);
return;
}
const cell = rows[y][x];
if (isCtrl(event) && character === 'g') {
event.preventDefault();
if (!cell.hasTile()) {
dispatch(cellFiltersSlice.actions.toggle(position));
}
return;
}
const twoCharacterCandidate = cell.tile.character + character;
if (config.twoCharacterTiles.includes(twoCharacterCandidate)) {
event.preventDefault();
dispatch(boardSlice.actions.changeCellValue({ ...position, value: twoCharacterCandidate }));
moveFocus(1);
return;
}
if (event.target instanceof HTMLInputElement && event.target.value.toLocaleLowerCase() === character) {
// change event did not fire because the same character was typed over the current one
// but we still want to move the caret
event.preventDefault();
moveFocus(1);
}
},
onSpace: (event) => {
const position = getInputRefPosition(event.target as HTMLInputElement);
if (!position) {
return;
}
event.preventDefault();
dispatch(boardSlice.actions.toggleCellIsBlank(position));
},
});
}, [changeActiveIndex, config, direction, dispatch, getInputRefPosition, locale, moveFocus, onDirectionToggle, rows]);
const onPaste = useCallback<ClipboardEventHandler>(
(event) => {
if (!(event.target instanceof HTMLInputElement)) {
return;
}
const position = getInputRefPosition(event.target);
if (!position) {
return;
}
event.preventDefault();
const value = event.clipboardData.getData('text/plain').toLocaleLowerCase();
insertValue(position, value);
},
[getInputRefPosition, insertValue],
);
return [
{ activeIndex: safeActiveIndex, direction, inputRefs },
{ insertValue, onChange, onDirectionToggle, onFocus, onKeyDown, onPaste },
];
};