diff --git a/.eslintrc b/.eslintrc index dfe9cc47..de6dcc4b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,8 +9,6 @@ // "hardcore/ts", "react-app", "react-app/jest", - "plugin:flowtype/recommended", - // "plugin:import/recommended", "plugin:jsx-a11y/strict", "plugin:jest/recommended", "plugin:jest/style", @@ -32,8 +30,6 @@ "project": "./tsconfig.json" }, "plugins": [ - "flowtype", - // "import", "jsx-a11y", "jest", "jest-dom", @@ -60,9 +56,6 @@ "prefer-spread": "warn", - "import/no-unused-modules": "off", - - "jest/no-commented-out-tests": "warn", "jest/no-identical-title": "warn", "jest/prefer-called-with": "warn", @@ -86,6 +79,7 @@ "@typescript-eslint/no-invalid-this": "warn", "@typescript-eslint/no-invalid-void-type": "warn", + "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"}], "@typescript-eslint/prefer-enum-initializers": "warn", "@typescript-eslint/prefer-for-of": "warn", "@typescript-eslint/prefer-function-type": "warn", diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ea33cb..b92e2f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ Note: Many earlier versions are not specified, that's too much work. When a `@types` dependency updates, they almost always don't affect anything. +## v0.35.0 + +- (use) Multiselectable cells! Select multiple cells at the same time with Ctrl+Click + - Instead of being turned off, a selection will be disabled when clicking off. This is to prepare for the on-screen keyboard later. +- (use) Diagonal keyboard movement!!! Holding Up and Left at the same time will now go both directions! +- (use) When editing a cell: the functions of Backspace and + (Shift or Ctrl)+Backspace have been switched. + - Previously, Backspace would delete all candidates. Now it adds back all candidates. + - _Additionally_ it sets `pretend` to `true`, so it will still change to a single digit when typing. + - (Shift or Ctrl)+Backspace used to add back all candidates; + now it deletes all candidates. +- (use-bug) Previously, if a bunch of keypresses happen to cause a cell to end up at 9 candidates again, + it was not a condition to `pretend` as described above. Now it is. Personally, despite my extensive usage, + this edge-case has not been run into yet so I do not know if this should be reimplemented. +- (temp-docs) The way the Tabs and the Cells handle multiple keypresses at the same time is different. + Specifically, Cells use a global event handler, while Tabs only have a local event handler. + This means that for example, holding down two keys then focusing the Tabs, the Tabs would only have one key stored on repeat. + ## v0.34.0 - (use) Add "explanations" for some strategies. diff --git a/package.json b/package.json index 496e2255..625cf253 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solver", - "version": "0.34.0", + "version": "0.35.0", "private": true, "homepage": "https://icecream17.github.io/solver", "dependencies": { diff --git a/src/Api/Spaces/PureSudoku.ts b/src/Api/Spaces/PureSudoku.ts index 5127c3a1..eb3c0184 100644 --- a/src/Api/Spaces/PureSudoku.ts +++ b/src/Api/Spaces/PureSudoku.ts @@ -1,4 +1,3 @@ -// @flow import { ALL_CANDIDATES, GrpTyp, IndexToNine, INDICES_TO_NINE, SudokuDigits, ThreeDimensionalArray } from "../../Types" import { boxAt, CellID, id, to9by9 } from "../Utils" diff --git a/src/Api/Spaces/Sudoku.ts b/src/Api/Spaces/Sudoku.ts index d79015db..8d1edbfd 100644 --- a/src/Api/Spaces/Sudoku.ts +++ b/src/Api/Spaces/Sudoku.ts @@ -2,6 +2,13 @@ import Cell from "../../Elems/MainElems/Cell" import { IndexToNine, SudokuDigits, TwoDimensionalArray } from "../../Types" import PureSudoku from "./PureSudoku" +/** + * Wraps PureSudoku, to sync the data with the Cell components. + * + * It is also used by the Sudoku component to indirectly access the Cell components. -.- + * + * For sanity, this class should be kept very simple. + */ export default class Sudoku extends PureSudoku { cells: TwoDimensionalArray constructor (...args: ConstructorParameters) { @@ -32,9 +39,16 @@ export default class Sudoku extends PureSudoku { } } + override clearCell(x: IndexToNine, y: IndexToNine) { + this.data[x][y] = [1, 2, 3, 4, 5, 6, 7, 8, 9] + this.cells[x][y]?.clearCandidates() + } + /** - * Used for initialization but could also update things - * That's pretty complicated + * Used for Cell initialization + * + * An alternate possibility is having the cell reflect the data, but + * that allows inconsistencies between the visuals and the data. */ addCell(cell: Cell) { this.cells[cell.props.row][cell.props.column] = cell @@ -45,9 +59,4 @@ export default class Sudoku extends PureSudoku { this.cells[cell.props.row][cell.props.column] = undefined // this.data[cell.props.row][cell.props.column] = cell.state.candidates } - - clearCell(x: IndexToNine, y: IndexToNine) { - this.data[x][y] = [1, 2, 3, 4, 5, 6, 7, 8, 9] - this.cells[x][y]?.clearCandidates() - } } diff --git a/src/Api/Strategies/pairsTriplesAndQuads.ts b/src/Api/Strategies/pairsTriplesAndQuads.ts index 2f0ddad1..6b5daffc 100644 --- a/src/Api/Strategies/pairsTriplesAndQuads.ts +++ b/src/Api/Strategies/pairsTriplesAndQuads.ts @@ -1,4 +1,4 @@ -// @flow + import { AlertType, SudokuDigits } from "../../Types"; import { convertArrayToEnglishList } from "../../utils"; diff --git a/src/Api/Types.ts b/src/Api/Types.ts index b69f293c..d07cae42 100644 --- a/src/Api/Types.ts +++ b/src/Api/Types.ts @@ -1,5 +1,3 @@ -// @flow - import { SudokuDigits } from "../Types"; import PureSudoku from "./Spaces/PureSudoku"; import { CellID } from "./Utils"; diff --git a/src/Api/Utils.ts b/src/Api/Utils.ts index c6fdc4e6..7d083fad 100644 --- a/src/Api/Utils.ts +++ b/src/Api/Utils.ts @@ -1,5 +1,3 @@ -// @flow - import { AlgebraicName, BoxName, BOX_NAMES, COLUMN_NAMES, IndexTo81, IndexToNine, INDICES_TO_NINE, ROW_NAMES, SudokuDigits, ThreeDimensionalArray, TwoDimensionalArray, GrpTyp } from "../Types"; export function algebraic (row: IndexToNine, column: IndexToNine): AlgebraicName { diff --git a/src/Api/boards.ts b/src/Api/boards.ts index 1ef72d15..ed438395 100644 --- a/src/Api/boards.ts +++ b/src/Api/boards.ts @@ -211,5 +211,17 @@ export default { .....4.2. ..85..7.. ....2...1 + `, + + "impossible": ` + .....9... + .41....8. + .5..27... + .......5. + 3..2..... + .2..6.4.. + ....5.... + 81......4 + ...8..2.7 ` } as const diff --git a/src/App.tsx b/src/App.tsx index 785c23a7..9083a973 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import GithubCorner from './Elems/GithubCorner' import Cell from './Elems/MainElems/Cell' import Title from './Elems/Title' import Version from './Elems/Version' +import { SudokuProps } from './Elems/MainElems/Sudoku' declare global { interface Window { @@ -22,8 +23,8 @@ declare global { } interface Set { - has (value: unknown): CouldAIsB - delete (value: unknown): CouldAIsB + has(value: unknown): CouldAIsB + delete(value: unknown): CouldAIsB } // eslint-disable-next-line @typescript-eslint/no-unused-vars --- I can't prefix this with an underscore, _typescript_ @@ -52,7 +53,7 @@ type AppState = { class App extends React.Component<_UnusedProps, AppState> { sudoku: SudokuData solver: Solver - propsPassedDown: { whenCellMounts: (cell: Cell) => void; whenCellUnmounts: (cell: Cell) => void; whenCellUpdates: (cell: Cell, candidates: SudokuDigits[]) => void } + sudokuProps: SudokuProps constructor(props: _UnusedProps) { super(props) @@ -80,7 +81,8 @@ class App extends React.Component<_UnusedProps, AppState> { this.whenCellUnmounts = this.whenCellUnmounts.bind(this) this.whenCellUpdates = this.whenCellUpdates.bind(this) this.finishNotice = this.finishNotice.bind(this) - this.propsPassedDown = { + this.sudokuProps = { + sudoku: this.sudoku, whenCellMounts: this.whenCellMounts, whenCellUnmounts: this.whenCellUnmounts, whenCellUpdates: this.whenCellUpdates, @@ -103,7 +105,7 @@ class App extends React.Component<_UnusedProps, AppState> { <Version /> </header> - <Main propsPassedDown={this.propsPassedDown} /> + <Main sudokuProps={this.sudokuProps} /> <Aside sudoku={this.sudoku} solver={this.solver} /> <GithubCorner /> diff --git a/src/Elems/AsideElems/StrategyItem.tsx b/src/Elems/AsideElems/StrategyItem.tsx index 3316a5a6..0153e970 100644 --- a/src/Elems/AsideElems/StrategyItem.tsx +++ b/src/Elems/AsideElems/StrategyItem.tsx @@ -1,5 +1,3 @@ -// @flow - import './StrategyItem.css' import React from 'react'; import StrategyLabel, { StrategyLabelProps } from './StrategyLabel'; diff --git a/src/Elems/AsideElems/StrategyLabel.tsx b/src/Elems/AsideElems/StrategyLabel.tsx index bbdc1cac..fc267f20 100644 --- a/src/Elems/AsideElems/StrategyLabel.tsx +++ b/src/Elems/AsideElems/StrategyLabel.tsx @@ -1,5 +1,3 @@ -// @flow - import React from 'react'; import ExternalLink from '../ExternalLink'; diff --git a/src/Elems/AsideElems/StrategyStatus.tsx b/src/Elems/AsideElems/StrategyStatus.tsx index c1c2852c..1ccb57f9 100644 --- a/src/Elems/AsideElems/StrategyStatus.tsx +++ b/src/Elems/AsideElems/StrategyStatus.tsx @@ -1,5 +1,3 @@ -// @flow - import React from 'react'; import { SuccessError } from '../../Api/Types'; diff --git a/src/Elems/AsideElems/StrategyTogglerLabel.tsx b/src/Elems/AsideElems/StrategyTogglerLabel.tsx index defc1ca2..d23dc638 100644 --- a/src/Elems/AsideElems/StrategyTogglerLabel.tsx +++ b/src/Elems/AsideElems/StrategyTogglerLabel.tsx @@ -1,5 +1,3 @@ -// @flow - import React from 'react'; export type StrategyTogglerLabelProps = Readonly<{ diff --git a/src/Elems/Main.tsx b/src/Elems/Main.tsx index 563e5d0a..42d004f7 100644 --- a/src/Elems/Main.tsx +++ b/src/Elems/Main.tsx @@ -1,10 +1,10 @@ import './Main.css' import React from 'react' -import Sudoku, { BaseSudokuProps } from './MainElems/Sudoku' +import Sudoku, { SudokuProps } from './MainElems/Sudoku' type MainProps = Readonly<{ - propsPassedDown: BaseSudokuProps + sudokuProps: SudokuProps }> /** @@ -14,7 +14,7 @@ type MainProps = Readonly<{ export default function Main (props: MainProps) { return ( <main className="App-main"> - <Sudoku {...props.propsPassedDown} /> + <Sudoku {...props.sudokuProps} /> </main> - ); + ) } diff --git a/src/Elems/MainElems/Cell.tsx b/src/Elems/MainElems/Cell.tsx index 670414a1..545501be 100644 --- a/src/Elems/MainElems/Cell.tsx +++ b/src/Elems/MainElems/Cell.tsx @@ -1,7 +1,5 @@ -// @flow - import React from 'react'; -import { algebraic } from '../../Api/Utils'; +import { algebraic, id } from '../../Api/Utils'; import { IndexToNine, SudokuDigits, ZeroToNine, _Callback } from '../../Types'; import { arraysAreEqual } from '../../utils'; @@ -14,6 +12,8 @@ export type BaseCellProps = Readonly<{ whenNewCandidates: (cell: Cell, candidates: SudokuDigits[]) => void whenCellKeydown: (cell: Cell, event: React.KeyboardEvent) => void + whenCellBlur: (cell: Cell, event: React.FocusEvent) => void + whenCellFocus: (cell: Cell, event: React.FocusEvent) => void whenCellMounts: _Callback whenCellUnmounts: _Callback @@ -21,8 +21,11 @@ export type BaseCellProps = Readonly<{ type CellProps = BaseCellProps +// null = not selected +// false = selection inactive +// true = selection active type _UserDisplayState = - { active: false; pretend: false } | + { active: false | null; pretend: false } | { active: true; pretend: boolean } type _TrackCandidateState = @@ -41,12 +44,12 @@ type _TrackCandidateState = highlighted: false } -type CellState = Readonly<( +type CellState = Readonly< { candidates: SudokuDigits[] error: boolean } & _UserDisplayState & _TrackCandidateState -)> +> /** @@ -106,8 +109,12 @@ export default class Cell extends React.Component<CellProps, CellState> { /** Whether the candidates array is empty */ error: false, - /** If this is currently focused by the user - set by whenFocus and whenBlur */ - active: false, + /** + * If the cell is included in the selection and the cell is currently active. + * + * If the cell is included, this is a boolean, false is the selection is deactivated. + */ + active: null, /** * This boolean controls pretending. @@ -121,6 +128,7 @@ export default class Cell extends React.Component<CellProps, CellState> { /** * Whether a candidate is being highlighted + * If so, we display in all-candidates mode even if there's only one */ highlighted: false, } @@ -130,6 +138,9 @@ export default class Cell extends React.Component<CellProps, CellState> { this.whenKeyDown = this.whenKeyDown.bind(this) } + // This is the key. If visuals and data are inconsistent, data is changed to reflect visuals. + // Otherwise they are modified in tandem. This callback allows indirect access to the data, + // and originate from App.tsx componentDidMount() { this.props.whenCellMounts(this) } @@ -138,6 +149,10 @@ export default class Cell extends React.Component<CellProps, CellState> { this.props.whenCellUnmounts(this) } + get id() { + return id(this.props.row, this.props.column) + } + /** How many candidates are left, this.state.candidates.length */ get numCandidates() { return this.state.candidates.length as ZeroToNine @@ -264,7 +279,7 @@ export default class Cell extends React.Component<CellProps, CellState> { if (this.state.explaining && !arraysAreEqual(this.state.previousCandidates.sort(), this.state.candidates.sort())) { content = <CandidatesDiff previous={this.state.previousCandidates} current={this.state.candidates} classes={this.state.candidateClasses} /> } else if (this.state.active || this.state.highlighted || (this.numCandidates > 1 && this.numCandidates < 9)) { - // Also show candidates when editing a cell + // Also show candidates when editing a cell (actively) // Also show candidates as fallback when numCandidates is in [2, 8] content = <Candidates data={this.state.candidates} classes={this.state.candidateClasses} /> } else if (this.numCandidates === 0) { @@ -290,8 +305,8 @@ export default class Cell extends React.Component<CellProps, CellState> { role='button' title={Cell.title(this.props.row, this.props.column)} aria-label={Cell.labelAt(this.props.row, this.props.column)} - data-error={this.state.error ? "true" : undefined} - data-active={this.state.active ? "true" : undefined} + data-error={this.state.error || undefined} + data-active={this.state.active ?? undefined} tabIndex={0} onFocus={this.whenFocus} onBlur={this.whenBlur} @@ -301,21 +316,33 @@ export default class Cell extends React.Component<CellProps, CellState> { ) } - whenFocus(_event?: React.FocusEvent) { - this.setState( - state => ({ - active: true, - pretend: state.candidates.length === 9 - }) as const - ) + updateSelectionStatus(isSelected: boolean, status: null | boolean) { + const active = isSelected ? status : null + this.setState((state: CellState) => ({ + active, + pretend: active ? state.candidates.length === 9 : false + })) + } + + // setState for both of these is called in the when handlers through updateSelectionStatus, + // but should be consistent with the commented out code + whenFocus(event: React.FocusEvent) { + this.props.whenCellFocus(this, event) + // this.setState( + // state => ({ + // active: true, + // pretend: state.candidates.length === 9 + // }) as const + // ) } - whenBlur(_event?: React.FocusEvent) { + whenBlur(event: React.FocusEvent) { this.props.whenNewCandidates(this, this.state.candidates) - this.setState({ - active: false, - pretend: false // See notes about state.pretend - }) + this.props.whenCellBlur(this, event) + // this.setState({ + // active: false, + // pretend: false // See notes about state.pretend + // }) } /** @@ -333,29 +360,12 @@ export default class Cell extends React.Component<CellProps, CellState> { * TODO: Arrow key navigation */ whenKeyDown(event: React.KeyboardEvent) { - const target = event.target as HTMLTableCellElement - if ('123456789'.includes(event.key)) { - const candidate = Number(event.key) as SudokuDigits - this.toggleCandidate(candidate) - } else if (['Backspace', 'Delete', 'Clear'].includes(event.key)) { - if (event.shiftKey || event.altKey) { - this.setState({ - candidates: [1, 2, 3, 4, 5, 6, 7, 8, 9], - error: false - }) - } else { - this.setState({ - candidates: [], - error: true - }) - } - } else if (event.key === 'Escape') { - target.blur() - } else { - this.props.whenCellKeydown(this, event) // keyboard controls - } + this.props.whenCellKeydown(this, event) // keyboard controls // Something happened, (see "pretend" docs above) - this.setState({ pretend: false }) + + // TODO: See Changelog v0.35.0 + // Uncommenting this wouldn't work though, since it only affects one cell not the whole selection. + // this.setState({ pretend: false }) } } diff --git a/src/Elems/MainElems/Sudoku.css b/src/Elems/MainElems/Sudoku.css index d5604cb8..b7eea653 100644 --- a/src/Elems/MainElems/Sudoku.css +++ b/src/Elems/MainElems/Sudoku.css @@ -94,6 +94,10 @@ div.Cell[data-active="true"] { background-color: #555; } +div.Cell[data-active="false"] { + background: repeating-linear-gradient(-45deg, #d35e5e, #44476b, #333); +} + /* Section moved to Candidates.css */ /* For the lazy loaded props */ diff --git a/src/Elems/MainElems/Sudoku.tsx b/src/Elems/MainElems/Sudoku.tsx index 8de85968..2bc66de3 100644 --- a/src/Elems/MainElems/Sudoku.tsx +++ b/src/Elems/MainElems/Sudoku.tsx @@ -1,11 +1,12 @@ -// @flow - -import React from 'react'; - import './Sudoku.css' -import Row from './Row'; -import { IndexToNine, _Callback } from '../../Types'; -import Cell from './Cell'; +import React from 'react' + +import Row from './Row' +import Cell from './Cell' +import { CouldAIsB, IndexToNine, SudokuDigits, _Callback } from '../../Types' +import { CellID, id } from '../../Api/Utils' +import SudokuData from '../../Api/Spaces/Sudoku' +import { keysPressed } from '../../keyboardListener' const keyboardMappings = { 'ArrowUp': { vRow: -1, vColumn: 0 }, @@ -18,18 +19,31 @@ const keyboardMappings = { 'KeyD': { vRow: 0, vColumn: 1 }, } -export type BaseSudokuProps = Readonly<{ +type CellCallbackProps = { whenCellMounts: _Callback whenCellUnmounts: _Callback whenCellUpdates: _Callback -}> +} -type SudokuProps = BaseSudokuProps +export type SudokuProps = Readonly< + { + sudoku: SudokuData + } & CellCallbackProps +> /** * The main sudoku!!! * The sudoku board state is sent back all the way to `App.js` * + * ***!important*** \ + * It is imperative the cells props do not change. If they do, by default the candidates would be 1 to 9, basically + * deleting progress and losing data. Additionally, the cell handles highlighting and interactions. It would be a large + * effort to lift all that for the sudoku to handle. + * + * So instead of changing the candidates through props, we change the candidate's state by accessing the cells + * through the SudokuData, and calling methods on those cells. It is quite indirect and feels hacky, and it establishes + * the SudokuData api as _required_ and immutable. + * * Handles keyboard interactions. * TODO: Handle selecting cells, including selecting multiple cells. (And set aria-selected and aria-multiselectable) * @@ -38,11 +52,20 @@ type SudokuProps = BaseSudokuProps * <Sudoku whenUpdate={callback} /> */ export default class Sudoku extends React.Component<SudokuProps> { - tbodyElement: HTMLTableSectionElement | null; - setTbodyElement: (element: HTMLTableSectionElement | null) => HTMLTableSectionElement | null; + cellsSelected: Set<CellID> + selectionStatus: null | boolean // null = unselected, false = inactive, true = active selection + + tbodyElement: HTMLTableSectionElement | null + setTbodyElement: (element: HTMLTableSectionElement | null) => HTMLTableSectionElement | null constructor(props: SudokuProps) { super(props) + this.cellsSelected = new Set<CellID>() + this.selectionStatus = null + this.listener = this.listener.bind(this) + + this.whenCellBlur = this.whenCellBlur.bind(this) + this.whenCellFocus = this.whenCellFocus.bind(this) this.whenCellKeydown = this.whenCellKeydown.bind(this) this.tbodyElement = null @@ -58,6 +81,8 @@ export default class Sudoku extends React.Component<SudokuProps> { whenCellMounts: this.props.whenCellMounts, whenCellUnmounts: this.props.whenCellUnmounts, whenNewCandidates: this.props.whenCellUpdates, + whenCellBlur: this.whenCellBlur, + whenCellFocus: this.whenCellFocus, whenCellKeydown: this.whenCellKeydown, } as const } as const @@ -65,7 +90,7 @@ export default class Sudoku extends React.Component<SudokuProps> { return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role --- https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/817 - <table className='Sudoku' id='Sudoku' title='Sudoku' aria-label='Sudoku' role='grid'> + <table className='Sudoku' id='Sudoku' title='Sudoku' aria-label='Sudoku' aria-multiselectable={true} role='grid'> <tbody ref={this.setTbodyElement}> <Row {...getRepeatedProps()} /> <Row {...getRepeatedProps()} /> @@ -81,6 +106,21 @@ export default class Sudoku extends React.Component<SudokuProps> { ) } + listener() { + // TODO + } + + syncSelectionStatus() { + for (const row of this.props.sudoku.cells) { + for (const cell of row) { + if (cell != null) { + const isSelected = this.cellsSelected.has(cell.id) + cell.updateSelectionStatus(isSelected, this.selectionStatus) + } + } + } + } + /** * Implicitly blurs the previously focused cell * INCOMPLETELY DOCUMENTED BUG: This focuses the element, but often the @@ -89,18 +129,30 @@ export default class Sudoku extends React.Component<SudokuProps> { this.getCellElement(row, column).focus() } + focusIfTargetMovedAndAddToSelection(row: IndexToNine, column: IndexToNine, targetMoved: boolean, newSelected: Set<CellID>) { + if (targetMoved) { + this.focusCell(row, column) + } + newSelected.add(id(row, column)) + } + + isCellElement(element: null | Element): CouldAIsB<typeof element, HTMLButtonElement> { + // button, td, row, tbody + return element?.parentElement?.parentElement?.parentElement === this.tbodyElement + } + /** * Gets the cell _element_ at the row and column. * Nowadays, the cell is a <td> containing a <button> for a11y reasons. * So this returns the <button> since that's what's actually important. */ - getCellElement(row: IndexToNine, column: IndexToNine): HTMLElement { - return this.getTableCellElement(row, column).children[0] as HTMLElement + getCellElement(row: IndexToNine, column: IndexToNine): HTMLButtonElement { + return this.getTableCellElement(row, column).children[0] as HTMLButtonElement } /** Gets the cell _element_ at the row and column. */ - getTableCellElement(row: IndexToNine, column: IndexToNine): HTMLElement { - return this.getRowElement(row).children[column] as HTMLElement + getTableCellElement(row: IndexToNine, column: IndexToNine): HTMLTableCellElement { + return this.getRowElement(row).children[column] as HTMLTableCellElement } /** Gets the row _element_ at the index provided */ @@ -113,12 +165,43 @@ export default class Sudoku extends React.Component<SudokuProps> { return this.tbodyElement.children[index] } + // TODO: Support Shift + // The effect of Shift is to change the selection to include all cells from A to B + // The effect of Ctrl is to add (or remove) only one cell to the selection. + + whenCellFocus(cell: Cell, _event: React.FocusEvent) { + const ctrlMultiselect = keysPressed.has('Control') && !keysPressed.has('Tab') + if (!ctrlMultiselect) { + this.cellsSelected.clear() + } + + this.cellsSelected.add(cell.id) + this.selectionStatus = true + this.syncSelectionStatus() + } + + whenCellBlur(cell: Cell, event: React.FocusEvent) { + const toAnotherElement = this.isCellElement(event.relatedTarget) + const ctrlMultiselect = keysPressed.has('Control') && !keysPressed.has('Tab') + + if (toAnotherElement) { + if (!ctrlMultiselect) { + this.cellsSelected.delete(cell.id) + } + // this.selectionStatus = true + } else { + this.selectionStatus = false + } + + this.syncSelectionStatus() + } + /** * Keyboard controls as described by https://w3c.github.io/aria-practices/#grid * TODO: https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex * Edit: I'm not sure if I should actually follow that section */ - whenCellKeydown(cell: Cell, event: React.KeyboardEvent) { + whenCellKeydown(targetCell: Cell, event: React.KeyboardEvent) { // Use default behavior when tabbing if (event.key === 'Tab') { return @@ -126,30 +209,86 @@ export default class Sudoku extends React.Component<SudokuProps> { event.preventDefault() - // TODO: Diagonal steps, use onkeyup and more state - if (event.key in keyboardMappings) { - const step = keyboardMappings[(event.key as keyof typeof keyboardMappings)]; - - this.focusCell( - (cell.props.row + 9 + step.vRow) % 9 as IndexToNine, - (cell.props.column + 9 + step.vColumn) % 9 as IndexToNine - ) - } else if (event.key === 'Home') { - if (event.ctrlKey) { - this.focusCell(0, 0) - } else { - this.focusCell(cell.props.row, 0) - } - } else if (event.key === 'End') { - if (event.ctrlKey) { - this.focusCell(8, 8) - } else { - this.focusCell(cell.props.row, 8) + // It is possible that "keysPressed" has not been updated yet. It will be added. + // This has no effect on the listeners. SAFETY + keysPressed.add(event.key) + + const newSelected = new Set<CellID>() + + const target = event.target as HTMLDivElement + const shiftHeld = keysPressed.has('Shift') + const ctrlHeld = keysPressed.has('Ctrl') + + for (let {row, column} of this.cellsSelected) { + let cell = this.props.sudoku.cells[row][column] + const wasTarget = cell === targetCell + + for (const key of keysPressed) { + if (cell == null) { + break + } + + // Candidate changes + if ('123456789'.includes(key)) { + const candidate = Number(key) as SudokuDigits + cell.toggleCandidate(candidate) + } else if (['Backspace', 'Delete', 'Clear'].includes(key)) { + if (shiftHeld || ctrlHeld) { + cell.setState({ + candidates: [], + error: true + }) + } else { + cell.setState({ + candidates: [1, 2, 3, 4, 5, 6, 7, 8, 9], + error: false, + pretend: true + }) + } + } else if (key === 'Escape') { + this.cellsSelected.clear() + this.selectionStatus = null + target.blur() + return + } else { + // Keyboard movements + if (key in keyboardMappings) { + const step = keyboardMappings[(key as keyof typeof keyboardMappings)]; + + row = (row + 9 + step.vRow) % 9 as IndexToNine + column = (column + 9 + step.vColumn) % 9 as IndexToNine + } else if (key === 'Home') { + if (event.ctrlKey) { + row = 0 + column = 0 + } else { + column = 0 + } + } else if (key === 'End') { + if (event.ctrlKey) { + row = 8 + column = 8 + } else { + column = 8 + } + } else if (key === 'PageUp' && row !== 0) { + row = Math.max(row - 3, 0) as IndexToNine + } else if (key === 'PageDown' && row !== 8) { + row = Math.min(row + 3, 8) as IndexToNine + } + + // If row and column changed, update to match, otherwise do nothing + // Reduces code duplication + cell = this.props.sudoku.cells[row][column] + } } - } else if (event.key === 'PageUp' && cell.props.row !== 0) { - this.focusCell(Math.max(cell.props.row - 3, 0) as IndexToNine, cell.props.column) - } else if (event.key === 'PageDown' && cell.props.row !== 8) { - this.focusCell(Math.min(cell.props.row + 3, 8) as IndexToNine, cell.props.column) + + // Due to keyboard movements, the original location may have moved to a new location. + // This new location is reflected by row and column + this.focusIfTargetMovedAndAddToSelection(row, column, wasTarget && cell !== targetCell, newSelected) } + + this.cellsSelected = newSelected + this.syncSelectionStatus() } } diff --git a/src/Elems/Version.tsx b/src/Elems/Version.tsx index 84496779..facc341e 100644 --- a/src/Elems/Version.tsx +++ b/src/Elems/Version.tsx @@ -9,7 +9,7 @@ import StaticComponent from './StaticComponent'; * <Version /> */ function Version () { - return <span className="Version">v0.34.0</span> + return <span className="Version">v0.35.0</span> } export default StaticComponent(Version) diff --git a/src/ErrorNotice.tsx b/src/ErrorNotice.tsx index ebf09f19..f2917840 100644 --- a/src/ErrorNotice.tsx +++ b/src/ErrorNotice.tsx @@ -9,27 +9,35 @@ type ErrorNoticeState = Readonly<{ error: Error errorInfo: React.ErrorInfo } | { - error: undefined - errorInfo: undefined + error: false + errorInfo: "" }> export default class ErrorNotice extends React.Component<ErrorNoticeProps, ErrorNoticeState> { constructor (props: ErrorNoticeProps) { super(props) this.state = { - error: undefined, - errorInfo: undefined, + error: false, + errorInfo: "", } + this.reset = this.reset.bind(this) + } + + reset() { + this.setState({ error: false, errorInfo: "" }) } render() { if (this.state.error) { - return <p className="ErrorNotice"> - Something went wrong <br /> - Please post this error on github: <br /> - {this.state.error.name}: {this.state.error.message}<br /> - {this.state.errorInfo.componentStack ?? '<No stack trace>'} - </p> + return <> + <p className="ErrorNotice"> + Something went wrong <br /> + Please post this error on github: <br /> + {this.state.error.name}: {this.state.error.message}<br /> + {this.state.errorInfo.componentStack ?? '<No stack trace>'} + </p> + <button type="button" onClick={this.reset}>Close error notice</button> + </> } return this.props.children } diff --git a/src/Types.tsx b/src/Types.tsx index 150add2e..097e9fbc 100644 --- a/src/Types.tsx +++ b/src/Types.tsx @@ -1,6 +1,4 @@ -// @flow - -import React from 'react'; +import React from 'react' //////////// // Sudoku types diff --git a/src/eventRegistry.ts b/src/eventRegistry.ts index 086b0f7d..412e8b2d 100644 --- a/src/eventRegistry.ts +++ b/src/eventRegistry.ts @@ -1,4 +1,4 @@ -type Listener = (...args: any[]) => void +export type Listener = (...args: any[]) => void /** * A class for event handling diff --git a/src/index.tsx b/src/index.tsx index 85e5cf6d..25afe3ae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,9 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; -import reportWebVitals from './reportWebVitals'; +// import reportWebVitals from './reportWebVitals'; import deprecate from './deprecate'; import ErrorNotice from './ErrorNotice'; +import { addListener as _unused_for_side_effect } from './keyboardListener'; window.alert = deprecate(window.alert, "Use window._custom.alert instead") window.prompt = deprecate(window.prompt, "Use window._custom.prompt instead") @@ -22,4 +23,7 @@ ReactDOM.render( // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(console.info); + +// if (process.env.NODE_ENV === 'development') { +// reportWebVitals(console.info); +// } diff --git a/src/keyboardListener.ts b/src/keyboardListener.ts new file mode 100644 index 00000000..bc5a6981 --- /dev/null +++ b/src/keyboardListener.ts @@ -0,0 +1,42 @@ +/// Listen for key presses + +import EventRegistry, { Listener } from "./eventRegistry" + +export const keysPressed = new Set<string>() + +const listenerHandler = new EventRegistry<''>() +const EMPTY_SET = new Set<never>() + +const cancel: <E extends Event>(e: E) => void = e => { + console.log('cancel') + for (const key of keysPressed) { + listenerHandler.notify('', key, 'cancel', EMPTY_SET, e) + } +} + +document.body.addEventListener('keydown', e => { + keysPressed.add(e.key) + if (e.repeat) { + listenerHandler.notify('', e.key, 'repeat', keysPressed, e) + } else { + listenerHandler.notify('', e.key, 'down', keysPressed, e) + } +}) + +document.body.addEventListener('keyup', e => { + keysPressed.delete(e.key) + listenerHandler.notify('', e.key, 'up', keysPressed, e) +}) + +document.body.addEventListener('focusout', cancel) +document.body.addEventListener('contextmenu', cancel) + +/** Remember to cleanup with removeListener! */ +export const addListener: (f: Listener) => void = f => { + listenerHandler.addEventListener('', f) +} + +export const removeListener: (f: Listener) => boolean = f => + listenerHandler.removeEventListener('', f) + +addListener(console.debug) diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts index 105809ff..80086d93 100644 --- a/src/reportWebVitals.ts +++ b/src/reportWebVitals.ts @@ -1,17 +1,14 @@ -import { ReportHandler } from 'web-vitals'; +// This file is currently unused +import { ReportCallback, onCLS, onINP, onFCP, onLCP, onTTFB } from 'web-vitals' -const reportWebVitals = (onPerfEntry: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }).catch((error: unknown) => { - throw error - }); +const reportWebVitals = (onPerfEntry?: ReportCallback) => { + if (onPerfEntry instanceof Function) { + onCLS(onPerfEntry) + onINP(onPerfEntry) + onFCP(onPerfEntry) + onLCP(onPerfEntry) + onTTFB(onPerfEntry) } -}; +} -export default reportWebVitals; +export default reportWebVitals diff --git a/yarn.lock b/yarn.lock index 8b40ff05..3a2f99b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5191,9 +5191,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001259, caniuse-lite@npm:^1.0.30001272, caniuse-lite@npm:^1.0.30001280": - version: 1.0.30001473 - resolution: "caniuse-lite@npm:1.0.30001473" - checksum: 007ad17463612d38080fc59b5fa115ccb1016a1aff8daab92199a7cf8eb91cf987e85e7015cb0bca830ee2ef45f252a016c29a98a6497b334cceb038526b73f1 + version: 1.0.30001587 + resolution: "caniuse-lite@npm:1.0.30001587" + checksum: fb50aa9beaaae42f9feae92ce038f6ff71e97510f024ef1bef2666f3adcfd36d6c59e5675442e5fe795575193f71bc826cb7721d4b0f6d763e82d193bea57863 languageName: node linkType: hard