379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { useRef, type CSSProperties, type KeyboardEvent } from "react";
|
|
import {
|
|
aidMemoireCellDescription,
|
|
clearAidMemoireEntries,
|
|
compactAidMemoireColumns,
|
|
configureAidMemoire,
|
|
enterAidMemoireCell,
|
|
eraseAidMemoireCell,
|
|
labelAidMemoireCell,
|
|
MAX_AID_MEMOIRE_CELLS,
|
|
MAX_AID_MEMOIRE_COLUMNS,
|
|
resetAidMemoire,
|
|
setAidMemoireEnabled,
|
|
type AidMemoireState,
|
|
} from "../state/aidMemoire";
|
|
import {
|
|
maskValues,
|
|
symbolFor,
|
|
valueForKey,
|
|
type EntryMode,
|
|
} from "../state/session";
|
|
|
|
interface AidMemoireProps {
|
|
readonly size: number;
|
|
readonly state: AidMemoireState;
|
|
readonly selectedCell: number;
|
|
readonly active: boolean;
|
|
readonly readOnly?: boolean;
|
|
readonly mode: EntryMode;
|
|
readonly onStateChange: (state: AidMemoireState) => void;
|
|
readonly onSelect: (cell: number) => void;
|
|
readonly onMode: (mode: EntryMode) => void;
|
|
}
|
|
|
|
const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [
|
|
{ mode: "value", label: "Value", key: "Z" },
|
|
{ mode: "corner", label: "Corner", key: "X" },
|
|
{ mode: "center", label: "Centre", key: "C" },
|
|
{ mode: "color", label: "Colour", key: "V" },
|
|
];
|
|
|
|
export function AidMemoire({
|
|
size,
|
|
state,
|
|
selectedCell,
|
|
active,
|
|
readOnly = false,
|
|
mode,
|
|
onStateChange,
|
|
onSelect,
|
|
onMode,
|
|
}: AidMemoireProps) {
|
|
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell));
|
|
|
|
const selectAndFocus = (cell: number) => {
|
|
const next = Math.max(0, Math.min(state.cells.length - 1, cell));
|
|
onSelect(next);
|
|
cellRefs.current[next]?.focus();
|
|
};
|
|
|
|
const handleKeyDown = (
|
|
cell: number,
|
|
event: KeyboardEvent<HTMLButtonElement>,
|
|
) => {
|
|
const row = Math.floor(cell / state.columns);
|
|
const column = cell % state.columns;
|
|
const rowStart = row * state.columns;
|
|
const rowEnd = Math.min(
|
|
state.cells.length - 1,
|
|
rowStart + state.columns - 1,
|
|
);
|
|
let destination: number | undefined;
|
|
if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1);
|
|
if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1);
|
|
if (event.key === "ArrowUp") {
|
|
destination = cell - state.columns >= 0 ? cell - state.columns : cell;
|
|
}
|
|
if (event.key === "ArrowDown") {
|
|
destination =
|
|
cell + state.columns < state.cells.length ? cell + state.columns : cell;
|
|
}
|
|
if (event.key === "Home") {
|
|
destination = event.ctrlKey || event.metaKey ? 0 : rowStart;
|
|
}
|
|
if (event.key === "End") {
|
|
destination =
|
|
event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd;
|
|
}
|
|
if (event.key === "PageUp") destination = column;
|
|
if (event.key === "PageDown") {
|
|
const lastRowStart =
|
|
Math.floor((state.cells.length - 1) / state.columns) * state.columns;
|
|
destination = Math.min(state.cells.length - 1, lastRowStart + column);
|
|
}
|
|
if (destination !== undefined) {
|
|
event.preventDefault();
|
|
selectAndFocus(destination);
|
|
return;
|
|
}
|
|
if (readOnly) return;
|
|
if (
|
|
event.key === "Backspace" ||
|
|
event.key === "Delete" ||
|
|
event.key === "0"
|
|
) {
|
|
event.preventDefault();
|
|
onStateChange(eraseAidMemoireCell(state, cell, mode));
|
|
return;
|
|
}
|
|
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
const modeKey: Record<string, EntryMode> = {
|
|
z: "value",
|
|
x: "corner",
|
|
c: "center",
|
|
v: "color",
|
|
};
|
|
const nextMode = modeKey[event.key.toLowerCase()];
|
|
if (nextMode !== undefined) {
|
|
event.preventDefault();
|
|
onMode(nextMode);
|
|
return;
|
|
}
|
|
const value = valueForKey(event.key, mode === "color" ? 8 : size);
|
|
if (value !== null) {
|
|
event.preventDefault();
|
|
onStateChange(enterAidMemoireCell(state, cell, mode, value, size));
|
|
}
|
|
}
|
|
};
|
|
|
|
const configure = (cellCount: number, columns: number) => {
|
|
const next = configureAidMemoire(state, size, cellCount, columns);
|
|
onStateChange(next);
|
|
if (selected >= next.cells.length) onSelect(next.cells.length - 1);
|
|
};
|
|
|
|
return (
|
|
<section className="aid-memoire" aria-labelledby="aid-memoire-title">
|
|
<header className="section-heading aid-memoire__heading">
|
|
<div>
|
|
<p className="eyebrow">Optional scratch cells</p>
|
|
<h2 id="aid-memoire-title">Aid-mémoire</h2>
|
|
<p className="muted">
|
|
Track contextual sets, pseudo-digits or mappings. These cells do not
|
|
constrain the Sudoku.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="text-button"
|
|
disabled={readOnly}
|
|
onClick={() => onStateChange(setAidMemoireEnabled(state, false))}
|
|
>
|
|
Hide
|
|
</button>
|
|
</header>
|
|
|
|
<div
|
|
className="aid-memoire__scroller"
|
|
aria-label="Scrollable aid-mémoire area"
|
|
>
|
|
<div
|
|
className="aid-memoire__grid"
|
|
role="grid"
|
|
aria-label={`${String(state.cells.length)} aid-mémoire scratch cells`}
|
|
aria-colcount={state.columns}
|
|
aria-rowcount={Math.ceil(state.cells.length / state.columns)}
|
|
aria-readonly={readOnly}
|
|
style={{ "--aid-columns": state.columns } as CSSProperties}
|
|
>
|
|
{Array.from(
|
|
{ length: Math.ceil(state.cells.length / state.columns) },
|
|
(_, rowIndex) => (
|
|
<div
|
|
key={rowIndex}
|
|
className="aid-memoire__row"
|
|
role="row"
|
|
aria-rowindex={rowIndex + 1}
|
|
>
|
|
{state.cells
|
|
.slice(
|
|
rowIndex * state.columns,
|
|
(rowIndex + 1) * state.columns,
|
|
)
|
|
.map((cell, columnIndex) => {
|
|
const index = rowIndex * state.columns + columnIndex;
|
|
const cornerMarks = maskValues(cell.cornerMarks, size);
|
|
const centerMarks = maskValues(cell.centerMarks, size);
|
|
return (
|
|
<button
|
|
key={index}
|
|
ref={(element) => {
|
|
cellRefs.current[index] = element;
|
|
}}
|
|
type="button"
|
|
role="gridcell"
|
|
className={`aid-memoire__cell${active && selected === index ? " is-selected" : ""}${cell.color ? ` has-color-${String(cell.color)}` : ""}`}
|
|
aria-label={aidMemoireCellDescription(
|
|
cell,
|
|
index,
|
|
size,
|
|
)}
|
|
aria-colindex={columnIndex + 1}
|
|
aria-selected={active && selected === index}
|
|
tabIndex={selected === index ? 0 : -1}
|
|
onClick={() => {
|
|
if (!readOnly) onSelect(index);
|
|
}}
|
|
onFocus={() => {
|
|
if (!readOnly) onSelect(index);
|
|
}}
|
|
onKeyDown={(event) => handleKeyDown(index, event)}
|
|
>
|
|
<span className="aid-memoire__label" aria-hidden="true">
|
|
{cell.label || `#${String(index + 1)}`}
|
|
</span>
|
|
{cell.value ? (
|
|
<span
|
|
className="aid-memoire__value"
|
|
aria-hidden="true"
|
|
>
|
|
{symbolFor(cell.value, size)}
|
|
</span>
|
|
) : (
|
|
<>
|
|
<span
|
|
className="aid-memoire__corner"
|
|
aria-hidden="true"
|
|
>
|
|
{cornerMarks
|
|
.map((mark) => symbolFor(mark, size))
|
|
.join(" ")}
|
|
</span>
|
|
<span
|
|
className="aid-memoire__centre"
|
|
aria-hidden="true"
|
|
>
|
|
{centerMarks
|
|
.map((mark) => symbolFor(mark, size))
|
|
.join("")}
|
|
</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="mode-switcher aid-memoire__modes"
|
|
role="group"
|
|
aria-label="Aid-mémoire entry mode"
|
|
>
|
|
{modes.map((item) => (
|
|
<button
|
|
key={item.mode}
|
|
type="button"
|
|
disabled={readOnly}
|
|
className={mode === item.mode ? "is-active" : ""}
|
|
aria-pressed={mode === item.mode}
|
|
title={`${item.label} mode (${item.key})`}
|
|
onClick={() => onMode(item.mode)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="aid-memoire__selected">
|
|
<label>
|
|
Selected cell label
|
|
<input
|
|
value={state.cells[selected]?.label ?? ""}
|
|
maxLength={40}
|
|
disabled={readOnly}
|
|
onChange={(event) =>
|
|
onStateChange(
|
|
labelAidMemoireCell(state, selected, event.target.value),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<p className="muted">
|
|
The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V,
|
|
Delete and arrow keys also work directly in the scratch grid.
|
|
</p>
|
|
</div>
|
|
|
|
<details className="aid-memoire__settings">
|
|
<summary>Configure scratch layout</summary>
|
|
<div className="aid-memoire__settings-body">
|
|
<div className="inline-fields">
|
|
<label>
|
|
Cells
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={MAX_AID_MEMOIRE_CELLS}
|
|
value={state.cells.length}
|
|
disabled={readOnly}
|
|
onChange={(event) =>
|
|
configure(Number(event.target.value), state.columns)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Columns
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={Math.min(MAX_AID_MEMOIRE_COLUMNS, state.cells.length)}
|
|
value={state.columns}
|
|
disabled={readOnly}
|
|
onChange={(event) =>
|
|
configure(state.cells.length, Number(event.target.value))
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="action-row">
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() => configure(state.cells.length, state.cells.length)}
|
|
>
|
|
Single row
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() =>
|
|
configure(
|
|
state.cells.length,
|
|
compactAidMemoireColumns(state.cells.length),
|
|
)
|
|
}
|
|
>
|
|
Compact grid
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
onClick={() => {
|
|
if (window.confirm("Clear every aid-mémoire entry?")) {
|
|
onStateChange(clearAidMemoireEntries(state));
|
|
}
|
|
}}
|
|
>
|
|
Clear entries
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="danger"
|
|
disabled={readOnly}
|
|
onClick={() => {
|
|
if (
|
|
window.confirm(
|
|
"Reset the aid-mémoire layout, labels and entries?",
|
|
)
|
|
) {
|
|
onStateChange(resetAidMemoire(state, size));
|
|
onSelect(0);
|
|
}
|
|
}}
|
|
>
|
|
Reset all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
);
|
|
}
|