feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user