1443 lines
44 KiB
TypeScript
1443 lines
44 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type KeyboardEvent,
|
||
type PointerEvent,
|
||
} from "react";
|
||
import {
|
||
allCandidates,
|
||
classicRegions,
|
||
createEmptyPuzzle,
|
||
findConflicts,
|
||
isSolved,
|
||
normalizePuzzle,
|
||
validatePuzzle,
|
||
type NormalizedPuzzle,
|
||
type PuzzleDefinition,
|
||
} from "../domain";
|
||
import {
|
||
decodePuzzleHash,
|
||
fromDomainPuzzle,
|
||
toDomainPuzzle,
|
||
type SudokuDocument,
|
||
} from "../formats";
|
||
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
|
||
import type {
|
||
DifficultyAssessment,
|
||
ExactSolveResult,
|
||
GeneratedVariantPuzzle,
|
||
GenerateVariantOptions,
|
||
LogicalSolveResult,
|
||
} from "../solver";
|
||
import {
|
||
createProjectLibrary,
|
||
createProjectRecord,
|
||
type ProjectLibraryMode,
|
||
type SudokuProgress,
|
||
type SudokuProjectRecord,
|
||
type SudokuProjectSummary,
|
||
} from "../storage";
|
||
import {
|
||
createSession,
|
||
enterSelection,
|
||
eraseSelection,
|
||
maskValues,
|
||
restoreSnapshot,
|
||
snapshotSession,
|
||
valueForKey,
|
||
type EntryMode,
|
||
type PlaySession,
|
||
type PlaySnapshot,
|
||
} from "../state/session";
|
||
import {
|
||
digitCompletions,
|
||
matchingDigitCells,
|
||
toggledDigitHighlight,
|
||
} from "../state/gameplayHelpers";
|
||
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
|
||
import { ConstraintEditor } from "./ConstraintEditor";
|
||
import { DigitCompletionBar } from "./DigitCompletionBar";
|
||
import { GeneratorWorkspace } from "./GeneratorWorkspace";
|
||
import { HelpersWorkspace } from "./HelpersWorkspace";
|
||
import { ImportExportDialog } from "./ImportExportDialog";
|
||
import { LibraryDialog } from "./LibraryDialog";
|
||
import { NumberPad } from "./NumberPad";
|
||
import { SolveWorkspace } from "./SolveWorkspace";
|
||
import { SudokuBoard } from "./SudokuBoard";
|
||
|
||
type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
|
||
type FeedbackKind = "info" | "success" | "error";
|
||
|
||
interface Feedback {
|
||
readonly kind: FeedbackKind;
|
||
readonly message: string;
|
||
}
|
||
|
||
interface HistoryEntry {
|
||
readonly puzzle: PuzzleDefinition;
|
||
readonly session: PlaySnapshot;
|
||
}
|
||
|
||
const library = createProjectLibrary();
|
||
const MAX_HISTORY = 100;
|
||
|
||
function valuesToMask(values: readonly number[] | undefined): number {
|
||
return (values ?? []).reduce((mask, value) => mask | (1 << (value - 1)), 0);
|
||
}
|
||
|
||
function sessionFromDocument(
|
||
puzzle: PuzzleDefinition,
|
||
progress?: Pick<
|
||
SudokuDocument,
|
||
| "values"
|
||
| "cornerMarks"
|
||
| "centerMarks"
|
||
| "candidates"
|
||
| "colors"
|
||
| "elapsedMs"
|
||
>,
|
||
): PlaySession {
|
||
const session = createSession(puzzle.givens);
|
||
if (progress?.values?.length === puzzle.size * puzzle.size) {
|
||
session.values = [...progress.values];
|
||
}
|
||
if (progress?.cornerMarks?.length === puzzle.size * puzzle.size) {
|
||
session.cornerMarks = progress.cornerMarks.map(valuesToMask);
|
||
}
|
||
const centerMarks = progress?.centerMarks ?? progress?.candidates;
|
||
if (centerMarks?.length === puzzle.size * puzzle.size) {
|
||
session.centerMarks = centerMarks.map(valuesToMask);
|
||
}
|
||
if (progress?.colors?.length === puzzle.size * puzzle.size) {
|
||
session.colors = [...progress.colors];
|
||
}
|
||
session.elapsedSeconds = Math.floor((progress?.elapsedMs ?? 0) / 1_000);
|
||
return session;
|
||
}
|
||
|
||
function sessionFromProgress(
|
||
puzzle: PuzzleDefinition,
|
||
progress: SudokuProgress | undefined,
|
||
): PlaySession {
|
||
const session = createSession(puzzle.givens);
|
||
if (progress === undefined) return session;
|
||
session.values = [...progress.values];
|
||
session.cornerMarks = (progress.cornerMarks ?? []).length
|
||
? progress.cornerMarks!.map(valuesToMask)
|
||
: puzzle.givens.map(() => 0);
|
||
session.centerMarks = (progress.centerMarks ?? progress.candidates ?? [])
|
||
.length
|
||
? (progress.centerMarks ?? progress.candidates)!.map(valuesToMask)
|
||
: puzzle.givens.map(() => 0);
|
||
session.colors = progress.colors
|
||
? [...progress.colors]
|
||
: puzzle.givens.map(() => 0);
|
||
session.elapsedSeconds = Math.floor((progress.elapsedMs ?? 0) / 1_000);
|
||
return session;
|
||
}
|
||
|
||
function progressFromSession(
|
||
puzzle: PuzzleDefinition,
|
||
session: PlaySession,
|
||
completed: boolean,
|
||
): SudokuProgress {
|
||
return {
|
||
version: 1,
|
||
values: [...session.values],
|
||
cornerMarks: session.cornerMarks.map((mask) =>
|
||
maskValues(mask, puzzle.size),
|
||
),
|
||
centerMarks: session.centerMarks.map((mask) =>
|
||
maskValues(mask, puzzle.size),
|
||
),
|
||
colors: [...session.colors],
|
||
elapsedMs: session.elapsedSeconds * 1_000,
|
||
completed,
|
||
};
|
||
}
|
||
|
||
function safeNormalize(puzzle: PuzzleDefinition): {
|
||
readonly puzzle: NormalizedPuzzle;
|
||
readonly error?: string;
|
||
} {
|
||
try {
|
||
return { puzzle: normalizePuzzle(puzzle) };
|
||
} catch (error) {
|
||
const size = Number.isInteger(puzzle.size) ? puzzle.size : 9;
|
||
const cellCount = size * size;
|
||
const givens =
|
||
puzzle.givens.length === cellCount
|
||
? [...puzzle.givens]
|
||
: new Array<number>(cellCount).fill(0);
|
||
const regions =
|
||
puzzle.regions?.length === cellCount
|
||
? [...puzzle.regions]
|
||
: classicRegions(size);
|
||
return {
|
||
puzzle: {
|
||
...puzzle,
|
||
size,
|
||
givens,
|
||
regions,
|
||
constraints: puzzle.constraints ?? [],
|
||
},
|
||
error:
|
||
error instanceof Error
|
||
? error.message
|
||
: "The puzzle definition is invalid.",
|
||
};
|
||
}
|
||
}
|
||
|
||
function initialProject(): {
|
||
readonly puzzle: PuzzleDefinition;
|
||
readonly session: PlaySession;
|
||
} {
|
||
if (typeof location !== "undefined" && location.hash.includes("sudoku=")) {
|
||
try {
|
||
const document = decodePuzzleHash(location.href);
|
||
const puzzle = normalizePuzzle(toDomainPuzzle(document));
|
||
return { puzzle, session: sessionFromDocument(puzzle, document) };
|
||
} catch {
|
||
// An invalid hash is non-fatal; the import dialog can report details.
|
||
}
|
||
}
|
||
const puzzle = normalizePuzzle(CLASSIC_SAMPLE);
|
||
return { puzzle, session: createSession(puzzle.givens) };
|
||
}
|
||
|
||
function formatTime(seconds: number): string {
|
||
const hours = Math.floor(seconds / 3_600);
|
||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||
const rest = seconds % 60;
|
||
return [hours, minutes, rest]
|
||
.map((part) => String(part).padStart(2, "0"))
|
||
.join(":");
|
||
}
|
||
|
||
function cellLabel(cell: number, size: number): string {
|
||
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||
}
|
||
|
||
function cloneSample(sample: PuzzleDefinition): PuzzleDefinition {
|
||
return normalizePuzzle(sample);
|
||
}
|
||
|
||
function downloadJson(filename: string, value: unknown): void {
|
||
const url = URL.createObjectURL(
|
||
new Blob([`${JSON.stringify(value, null, 2)}\n`], {
|
||
type: "application/json",
|
||
}),
|
||
);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function errorMessage(error: unknown): string {
|
||
return error instanceof Error ? error.message : "The operation failed.";
|
||
}
|
||
|
||
function constraintLabel(type: string): string {
|
||
if (type === "xv") return "Sum pairs (5/10)";
|
||
return type
|
||
.split("-")
|
||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||
.join(" ");
|
||
}
|
||
|
||
export function Workbench() {
|
||
const boot = useMemo(() => initialProject(), []);
|
||
const [workspace, setWorkspace] = useState<Workspace>("play");
|
||
const [puzzle, setPuzzle] = useState<PuzzleDefinition>(boot.puzzle);
|
||
const [session, setSession] = useState<PlaySession>(boot.session);
|
||
const [selection, setSelection] = useState<number[]>([0]);
|
||
const [activeCell, setActiveCell] = useState(0);
|
||
const [entryMode, setEntryMode] = useState<EntryMode>("value");
|
||
const [showCandidates, setShowCandidates] = useState(false);
|
||
const [showConflicts, setShowConflicts] = useState(true);
|
||
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
|
||
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
|
||
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
|
||
const [past, setPast] = useState<HistoryEntry[]>([]);
|
||
const [future, setFuture] = useState<HistoryEntry[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
const [feedback, setFeedback] = useState<Feedback>();
|
||
const [logical, setLogical] = useState<LogicalSolveResult>();
|
||
const [exact, setExact] = useState<ExactSolveResult>();
|
||
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
|
||
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
|
||
const [solveError, setSolveError] = useState<string>();
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||
const [summaries, setSummaries] = useState<readonly SudokuProjectSummary[]>(
|
||
[],
|
||
);
|
||
const [libraryMode, setLibraryMode] =
|
||
useState<ProjectLibraryMode>("indexeddb");
|
||
const [libraryBusy, setLibraryBusy] = useState(false);
|
||
const [libraryFeedback, setLibraryFeedback] = useState<string>();
|
||
const [currentProjectId, setCurrentProjectId] = useState<string>();
|
||
const workerRef = useRef<SolverWorkerClient | null>(null);
|
||
const draggingRef = useRef(false);
|
||
const workbenchRef = useRef<HTMLElement>(null);
|
||
|
||
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
|
||
const selectedSet = useMemo(() => new Set(selection), [selection]);
|
||
const completionData = useMemo(
|
||
() => digitCompletions(puzzle.size, session.values),
|
||
[puzzle.size, session.values],
|
||
);
|
||
const highlightedCells = useMemo(
|
||
() =>
|
||
highlightedDigit === null
|
||
? new Set<number>()
|
||
: new Set(
|
||
matchingDigitCells(puzzle.size, session.values, highlightedDigit),
|
||
),
|
||
[highlightedDigit, puzzle.size, session.values],
|
||
);
|
||
const candidateMasks = useMemo(() => {
|
||
if (normalized.error) return session.values.map(() => 0);
|
||
try {
|
||
return allCandidates(normalized.puzzle, session.values).map((values) =>
|
||
valuesToMask(values),
|
||
);
|
||
} catch {
|
||
return session.values.map(() => 0);
|
||
}
|
||
}, [normalized, session.values]);
|
||
const conflictData = useMemo(() => {
|
||
if (normalized.error) return [];
|
||
try {
|
||
return findConflicts(normalized.puzzle, session.values);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, [normalized, session.values]);
|
||
const conflictCells = useMemo(
|
||
() =>
|
||
showConflicts
|
||
? new Set(conflictData.flatMap((conflict) => conflict.cells))
|
||
: new Set<number>(),
|
||
[conflictData, showConflicts],
|
||
);
|
||
const solved = useMemo(() => {
|
||
if (normalized.error) return false;
|
||
try {
|
||
return isSolved(normalized.puzzle, session.values);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, [normalized, session.values]);
|
||
|
||
const clearAnalysis = useCallback(() => {
|
||
setLogical(undefined);
|
||
setExact(undefined);
|
||
setDifficulty(undefined);
|
||
setGeneration(undefined);
|
||
setSolveError(undefined);
|
||
}, []);
|
||
|
||
const currentHistory = useCallback(
|
||
(): HistoryEntry => ({ puzzle, session: snapshotSession(session) }),
|
||
[puzzle, session],
|
||
);
|
||
|
||
const commit = useCallback(
|
||
(nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => {
|
||
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
|
||
setFuture([]);
|
||
setPuzzle(nextPuzzle);
|
||
setSession(nextSession);
|
||
clearAnalysis();
|
||
setFeedback(undefined);
|
||
},
|
||
[clearAnalysis, currentHistory],
|
||
);
|
||
|
||
const loadPuzzle = useCallback(
|
||
(
|
||
nextPuzzle: PuzzleDefinition,
|
||
progress?: Pick<
|
||
SudokuDocument,
|
||
| "values"
|
||
| "cornerMarks"
|
||
| "centerMarks"
|
||
| "candidates"
|
||
| "colors"
|
||
| "elapsedMs"
|
||
>,
|
||
projectId?: string,
|
||
): void => {
|
||
const valid = normalizePuzzle(nextPuzzle);
|
||
setPuzzle(valid);
|
||
setSession(sessionFromDocument(valid, progress));
|
||
setSelection([0]);
|
||
setActiveCell(0);
|
||
setHighlightedDigit(null);
|
||
setPast([]);
|
||
setFuture([]);
|
||
setCurrentProjectId(projectId);
|
||
clearAnalysis();
|
||
setFeedback({ kind: "success", message: "Puzzle loaded locally." });
|
||
},
|
||
[clearAnalysis],
|
||
);
|
||
|
||
const restoreProject = useCallback(
|
||
(record: SudokuProjectRecord): void => {
|
||
const valid = normalizePuzzle(toDomainPuzzle(record.puzzle));
|
||
setPuzzle(valid);
|
||
setSession(sessionFromProgress(valid, record.progress));
|
||
setSelection([0]);
|
||
setActiveCell(0);
|
||
setHighlightedDigit(null);
|
||
setPast([]);
|
||
setFuture([]);
|
||
setCurrentProjectId(record.id);
|
||
clearAnalysis();
|
||
setFeedback({
|
||
kind: "success",
|
||
message: `Opened “${record.title || "Untitled puzzle"}”.`,
|
||
});
|
||
},
|
||
[clearAnalysis],
|
||
);
|
||
|
||
const refreshLibrary = useCallback(async (): Promise<void> => {
|
||
setLibraryMode(await library.ready());
|
||
setSummaries(await library.list());
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
workerRef.current = createSolverWorkerClient();
|
||
return () => {
|
||
workerRef.current?.terminate();
|
||
workerRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const stopDragging = () => {
|
||
draggingRef.current = false;
|
||
};
|
||
window.addEventListener("pointerup", stopDragging);
|
||
window.addEventListener("pointercancel", stopDragging);
|
||
return () => {
|
||
window.removeEventListener("pointerup", stopDragging);
|
||
window.removeEventListener("pointercancel", stopDragging);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (workspace !== "play" || session.paused || solved) return;
|
||
const timer = window.setInterval(
|
||
() =>
|
||
setSession((current) => ({
|
||
...current,
|
||
elapsedSeconds: current.elapsedSeconds + 1,
|
||
})),
|
||
1_000,
|
||
);
|
||
return () => window.clearInterval(timer);
|
||
}, [session.paused, solved, workspace]);
|
||
|
||
const undo = useCallback(() => {
|
||
const target = past.at(-1);
|
||
if (target === undefined) return;
|
||
setFuture((entries) =>
|
||
[currentHistory(), ...entries].slice(0, MAX_HISTORY),
|
||
);
|
||
setPast(past.slice(0, -1));
|
||
setPuzzle(target.puzzle);
|
||
setSession((current) => restoreSnapshot(current, target.session));
|
||
clearAnalysis();
|
||
}, [clearAnalysis, currentHistory, past]);
|
||
|
||
const redo = useCallback(() => {
|
||
const target = future[0];
|
||
if (target === undefined) return;
|
||
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
|
||
setFuture(future.slice(1));
|
||
setPuzzle(target.puzzle);
|
||
setSession((current) => restoreSnapshot(current, target.session));
|
||
clearAnalysis();
|
||
}, [clearAnalysis, currentHistory, future]);
|
||
|
||
const changeSelection = useCallback(
|
||
(cell: number, additive: boolean, toggle: boolean): void => {
|
||
setActiveCell(cell);
|
||
setSelection((current) => {
|
||
if (!additive) return [cell];
|
||
if (toggle && current.includes(cell)) {
|
||
const remaining = current.filter((item) => item !== cell);
|
||
return remaining.length ? remaining : [cell];
|
||
}
|
||
return current.includes(cell) ? current : [...current, cell];
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handlePointerDown = useCallback(
|
||
(cell: number, event: PointerEvent<HTMLButtonElement>) => {
|
||
if (session.paused) return;
|
||
event.preventDefault();
|
||
event.currentTarget.focus();
|
||
const command = event.ctrlKey || event.metaKey;
|
||
if (
|
||
workspace === "play" &&
|
||
enableDigitHighlight &&
|
||
command &&
|
||
session.values[cell]
|
||
) {
|
||
draggingRef.current = false;
|
||
changeSelection(cell, false, false);
|
||
setHighlightedDigit((current) =>
|
||
toggledDigitHighlight(puzzle.size, session.values, cell, current),
|
||
);
|
||
return;
|
||
}
|
||
if (!command) setHighlightedDigit(null);
|
||
draggingRef.current = true;
|
||
const additive = event.shiftKey || command;
|
||
changeSelection(cell, additive, command);
|
||
},
|
||
[
|
||
changeSelection,
|
||
enableDigitHighlight,
|
||
puzzle.size,
|
||
session.paused,
|
||
session.values,
|
||
workspace,
|
||
],
|
||
);
|
||
|
||
const handlePointerEnter = useCallback(
|
||
(cell: number, event: PointerEvent<HTMLButtonElement>) => {
|
||
if (session.paused || !draggingRef.current || (event.buttons & 1) === 0)
|
||
return;
|
||
changeSelection(cell, true, false);
|
||
},
|
||
[changeSelection, session.paused],
|
||
);
|
||
|
||
const enterValue = useCallback(
|
||
(value: number): void => {
|
||
if (session.paused) return;
|
||
if (workspace === "set" && entryMode === "value") {
|
||
const givens = [...puzzle.givens];
|
||
const values = [...session.values];
|
||
const cornerMarks = [...session.cornerMarks];
|
||
const centerMarks = [...session.centerMarks];
|
||
for (const cell of selectedSet) {
|
||
const next = givens[cell] === value ? 0 : value;
|
||
givens[cell] = next;
|
||
values[cell] = next;
|
||
cornerMarks[cell] = 0;
|
||
centerMarks[cell] = 0;
|
||
}
|
||
commit(
|
||
{ ...puzzle, givens },
|
||
{ ...session, values, cornerMarks, centerMarks },
|
||
);
|
||
return;
|
||
}
|
||
commit(
|
||
puzzle,
|
||
enterSelection(session, selectedSet, entryMode, value, puzzle.givens),
|
||
);
|
||
},
|
||
[commit, entryMode, puzzle, selectedSet, session, workspace],
|
||
);
|
||
|
||
const erase = useCallback((): void => {
|
||
if (session.paused) return;
|
||
if (workspace === "set" && entryMode === "value") {
|
||
const givens = [...puzzle.givens];
|
||
const values = [...session.values];
|
||
for (const cell of selectedSet) {
|
||
givens[cell] = 0;
|
||
values[cell] = 0;
|
||
}
|
||
commit({ ...puzzle, givens }, { ...session, values });
|
||
return;
|
||
}
|
||
commit(
|
||
puzzle,
|
||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||
);
|
||
}, [commit, entryMode, puzzle, selectedSet, session, workspace]);
|
||
|
||
const moveActive = useCallback(
|
||
(deltaRow: number, deltaColumn: number, extend: boolean): void => {
|
||
const row = Math.floor(activeCell / puzzle.size);
|
||
const column = activeCell % puzzle.size;
|
||
const nextRow = Math.max(0, Math.min(puzzle.size - 1, row + deltaRow));
|
||
const nextColumn = Math.max(
|
||
0,
|
||
Math.min(puzzle.size - 1, column + deltaColumn),
|
||
);
|
||
const next = nextRow * puzzle.size + nextColumn;
|
||
changeSelection(next, extend, false);
|
||
requestAnimationFrame(() => {
|
||
workbenchRef.current
|
||
?.querySelector<HTMLButtonElement>(`[data-cell="${String(next)}"]`)
|
||
?.focus();
|
||
});
|
||
},
|
||
[activeCell, changeSelection, puzzle.size],
|
||
);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||
const command = event.ctrlKey || event.metaKey;
|
||
if (command && event.key.toLowerCase() === "z") {
|
||
event.preventDefault();
|
||
if (event.shiftKey) redo();
|
||
else undo();
|
||
return;
|
||
}
|
||
if (command && event.key.toLowerCase() === "y") {
|
||
event.preventDefault();
|
||
redo();
|
||
return;
|
||
}
|
||
if (command && event.key.toLowerCase() === "a") {
|
||
event.preventDefault();
|
||
const all = Array.from(
|
||
{ length: puzzle.size * puzzle.size },
|
||
(_, cell) => cell,
|
||
);
|
||
setSelection(
|
||
event.shiftKey ? all.filter((cell) => !selectedSet.has(cell)) : all,
|
||
);
|
||
return;
|
||
}
|
||
const movement: Record<string, readonly [number, number]> = {
|
||
ArrowUp: [-1, 0],
|
||
ArrowDown: [1, 0],
|
||
ArrowLeft: [0, -1],
|
||
ArrowRight: [0, 1],
|
||
};
|
||
const delta = movement[event.key];
|
||
if (delta !== undefined) {
|
||
event.preventDefault();
|
||
moveActive(delta[0], delta[1], event.shiftKey);
|
||
return;
|
||
}
|
||
if (
|
||
event.key === "Backspace" ||
|
||
event.key === "Delete" ||
|
||
event.key === "0"
|
||
) {
|
||
event.preventDefault();
|
||
erase();
|
||
return;
|
||
}
|
||
if (!command) {
|
||
const modes: Record<string, EntryMode> = {
|
||
z: "value",
|
||
x: "corner",
|
||
c: "center",
|
||
v: "color",
|
||
};
|
||
const nextMode = modes[event.key.toLowerCase()];
|
||
if (nextMode !== undefined) {
|
||
event.preventDefault();
|
||
setEntryMode(nextMode);
|
||
return;
|
||
}
|
||
const value = valueForKey(event.key, puzzle.size);
|
||
if (value !== null) {
|
||
event.preventDefault();
|
||
enterValue(value);
|
||
}
|
||
}
|
||
},
|
||
[enterValue, erase, moveActive, puzzle.size, redo, selectedSet, undo],
|
||
);
|
||
|
||
const runLogical = useCallback(async () => {
|
||
if (normalized.error || workerRef.current === null) {
|
||
setSolveError(normalized.error ?? "Solver worker is not ready yet.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setSolveError(undefined);
|
||
try {
|
||
const result = (await workerRef.current.request(
|
||
{
|
||
kind: "logical",
|
||
puzzle: normalized.puzzle,
|
||
options: { values: session.values },
|
||
},
|
||
{ timeoutMs: 60_000 },
|
||
)) as LogicalSolveResult;
|
||
setLogical(result);
|
||
} catch (error) {
|
||
setSolveError(errorMessage(error));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [normalized, session.values]);
|
||
|
||
const runExact = useCallback(async () => {
|
||
if (normalized.error || workerRef.current === null) {
|
||
setSolveError(normalized.error ?? "Solver worker is not ready yet.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setSolveError(undefined);
|
||
try {
|
||
const result = (await workerRef.current.request(
|
||
{
|
||
kind: "solve",
|
||
puzzle: normalized.puzzle,
|
||
options: {
|
||
values: session.values,
|
||
maxSolutions: 2,
|
||
maxNodes: 5_000_000,
|
||
timeoutMs: 30_000,
|
||
},
|
||
},
|
||
{ timeoutMs: 40_000 },
|
||
)) as ExactSolveResult;
|
||
setExact(result);
|
||
} catch (error) {
|
||
setSolveError(errorMessage(error));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [normalized, session.values]);
|
||
|
||
const requestHint = useCallback(async () => {
|
||
if (normalized.error || workerRef.current === null) {
|
||
setFeedback({
|
||
kind: "error",
|
||
message: normalized.error ?? "Solver worker is not ready yet.",
|
||
});
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const result = (await workerRef.current.request(
|
||
{
|
||
kind: "logical",
|
||
puzzle: normalized.puzzle,
|
||
options: { values: session.values, maxSteps: 1 },
|
||
},
|
||
{ timeoutMs: 15_000 },
|
||
)) as LogicalSolveResult;
|
||
const step = result.steps[0];
|
||
if (step === undefined) {
|
||
setFeedback({
|
||
kind: result.status === "solved" ? "success" : "info",
|
||
message:
|
||
result.status === "solved"
|
||
? "The grid is already solved."
|
||
: "No supported logical next step was found.",
|
||
});
|
||
} else {
|
||
if (step.focusCells.length) {
|
||
setSelection([...step.focusCells]);
|
||
setActiveCell(step.focusCells[0]!);
|
||
}
|
||
setFeedback({ kind: "info", message: step.explanation });
|
||
}
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [normalized, session.values]);
|
||
|
||
const checkDefinition = useCallback(async () => {
|
||
const validation = validatePuzzle(puzzle);
|
||
if (!validation.valid) {
|
||
setFeedback({
|
||
kind: "error",
|
||
message: validation.issues
|
||
.slice(0, 4)
|
||
.map((issue) => `${issue.path}: ${issue.message}`)
|
||
.join(" · "),
|
||
});
|
||
return;
|
||
}
|
||
if (workerRef.current === null) return;
|
||
setBusy(true);
|
||
try {
|
||
const result = (await workerRef.current.request(
|
||
{
|
||
kind: "solve",
|
||
puzzle,
|
||
options: {
|
||
maxSolutions: 2,
|
||
maxNodes: 5_000_000,
|
||
timeoutMs: 30_000,
|
||
},
|
||
},
|
||
{ timeoutMs: 40_000 },
|
||
)) as ExactSolveResult;
|
||
setFeedback({
|
||
kind:
|
||
result.count === 1 && !result.truncated
|
||
? "success"
|
||
: result.count === 0 || result.count >= 2
|
||
? "error"
|
||
: "info",
|
||
message:
|
||
result.count === 0
|
||
? "The definition has no solution."
|
||
: result.count >= 2
|
||
? "The definition has multiple solutions."
|
||
: result.truncated
|
||
? "A solution was found, but uniqueness was not established before a safety limit."
|
||
: `The definition is valid and uniquely solvable (${result.nodes.toLocaleString()} search nodes).`,
|
||
});
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [puzzle]);
|
||
|
||
const generateConfigured = useCallback(
|
||
async (options: GenerateVariantOptions) => {
|
||
if (workerRef.current === null) return;
|
||
setBusy(true);
|
||
setFeedback({
|
||
kind: "info",
|
||
message:
|
||
"Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…",
|
||
});
|
||
try {
|
||
const generated = (await workerRef.current.request(
|
||
{ kind: "generate-variant", options },
|
||
{ timeoutMs: 240_000 },
|
||
)) as GeneratedVariantPuzzle;
|
||
loadPuzzle(generated.puzzle);
|
||
setGeneration(generated);
|
||
setDifficulty(generated.difficulty);
|
||
setWorkspace("generate");
|
||
setFeedback({
|
||
kind: "success",
|
||
message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||
});
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
},
|
||
[loadPuzzle],
|
||
);
|
||
|
||
const rateCurrentPuzzle = useCallback(async () => {
|
||
if (workerRef.current === null) return;
|
||
if (normalized.error) {
|
||
setFeedback({ kind: "error", message: normalized.error });
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setFeedback({
|
||
kind: "info",
|
||
message: "Checking uniqueness and rating the current puzzle locally…",
|
||
});
|
||
try {
|
||
const assessment = (await workerRef.current.request(
|
||
{ kind: "difficulty", puzzle: normalized.puzzle },
|
||
{ timeoutMs: 60_000 },
|
||
)) as DifficultyAssessment;
|
||
setGeneration(undefined);
|
||
setDifficulty(assessment);
|
||
setFeedback({
|
||
kind: assessment.uniqueness === "unique" ? "success" : "info",
|
||
message: assessment.summary,
|
||
});
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, [normalized]);
|
||
|
||
const checkBoard = useCallback(() => {
|
||
if (normalized.error) {
|
||
setFeedback({ kind: "error", message: normalized.error });
|
||
} else if (conflictData.length > 0) {
|
||
setFeedback({ kind: "error", message: conflictData[0]!.message });
|
||
} else if (solved) {
|
||
setFeedback({
|
||
kind: "success",
|
||
message: `Solved in ${formatTime(session.elapsedSeconds)}.`,
|
||
});
|
||
} else {
|
||
const remaining = session.values.filter((value) => value === 0).length;
|
||
setFeedback({
|
||
kind: "info",
|
||
message: `No conflicts found. ${String(remaining)} cell${remaining === 1 ? " remains" : "s remain"}.`,
|
||
});
|
||
}
|
||
}, [
|
||
conflictData,
|
||
normalized.error,
|
||
session.elapsedSeconds,
|
||
session.values,
|
||
solved,
|
||
]);
|
||
|
||
const saveCurrent = useCallback(async () => {
|
||
setLibraryBusy(true);
|
||
setLibraryFeedback(undefined);
|
||
try {
|
||
const now = Date.now();
|
||
const document = fromDomainPuzzle(puzzle);
|
||
const progress = progressFromSession(puzzle, session, solved);
|
||
let record: SudokuProjectRecord;
|
||
const existing = currentProjectId
|
||
? await library.get(currentProjectId)
|
||
: undefined;
|
||
if (existing !== undefined) {
|
||
record = {
|
||
...existing,
|
||
title: puzzle.title?.trim() || "Untitled puzzle",
|
||
updatedAt: now,
|
||
puzzle: document,
|
||
progress,
|
||
};
|
||
} else {
|
||
record = createProjectRecord(document, {
|
||
title: puzzle.title?.trim() || "Untitled puzzle",
|
||
progress,
|
||
now,
|
||
});
|
||
}
|
||
const saved = await library.put(record);
|
||
setCurrentProjectId(saved.id);
|
||
await refreshLibrary();
|
||
setLibraryFeedback("Current puzzle and progress saved locally.");
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
}, [currentProjectId, puzzle, refreshLibrary, session, solved]);
|
||
|
||
const openProject = useCallback(
|
||
async (id: string) => {
|
||
setLibraryBusy(true);
|
||
try {
|
||
const record = await library.get(id);
|
||
if (record === undefined)
|
||
throw new Error("The saved puzzle no longer exists.");
|
||
restoreProject(record);
|
||
setLibraryOpen(false);
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
},
|
||
[restoreProject],
|
||
);
|
||
|
||
const deleteProject = useCallback(
|
||
async (id: string) => {
|
||
if (!window.confirm("Delete this saved puzzle from this browser?"))
|
||
return;
|
||
setLibraryBusy(true);
|
||
try {
|
||
await library.delete(id);
|
||
if (currentProjectId === id) setCurrentProjectId(undefined);
|
||
await refreshLibrary();
|
||
setLibraryFeedback("Saved puzzle deleted.");
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
},
|
||
[currentProjectId, refreshLibrary],
|
||
);
|
||
|
||
const clearLibrary = useCallback(async () => {
|
||
if (
|
||
!window.confirm(
|
||
"Clear every saved Sudoku project from this browser? Export first if you need a backup.",
|
||
)
|
||
)
|
||
return;
|
||
setLibraryBusy(true);
|
||
try {
|
||
await library.clear();
|
||
setCurrentProjectId(undefined);
|
||
await refreshLibrary();
|
||
setLibraryFeedback("Local library cleared.");
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
}, [refreshLibrary]);
|
||
|
||
const exportLibrary = useCallback(async () => {
|
||
setLibraryBusy(true);
|
||
try {
|
||
downloadJson("sudoku-tools-library.json", await library.exportAll());
|
||
setLibraryFeedback("Library backup downloaded.");
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
}, []);
|
||
|
||
const importLibrary = useCallback(
|
||
async (file: File) => {
|
||
setLibraryBusy(true);
|
||
try {
|
||
if (file.size > 64 * 1_048_576)
|
||
throw new Error("The library file exceeds the 64 MiB import limit.");
|
||
const count = await library.importAll(
|
||
JSON.parse(await file.text()) as unknown,
|
||
);
|
||
await refreshLibrary();
|
||
setLibraryFeedback(
|
||
`${String(count)} project${count === 1 ? "" : "s"} imported.`,
|
||
);
|
||
} catch (error) {
|
||
setLibraryFeedback(errorMessage(error));
|
||
} finally {
|
||
setLibraryBusy(false);
|
||
}
|
||
},
|
||
[refreshLibrary],
|
||
);
|
||
|
||
const applyValues = useCallback(
|
||
(values: readonly number[]) => {
|
||
commit(puzzle, { ...session, values: [...values] });
|
||
},
|
||
[commit, puzzle, session],
|
||
);
|
||
|
||
const focusCells = useCallback((cells: readonly number[]) => {
|
||
if (cells.length === 0) return;
|
||
setSelection([...cells]);
|
||
setActiveCell(cells[0]!);
|
||
}, []);
|
||
|
||
const activeConstraints = useMemo(
|
||
() => [...new Set(normalized.puzzle.constraints.map((item) => item.type))],
|
||
[normalized.puzzle.constraints],
|
||
);
|
||
|
||
return (
|
||
<main className="sudoku-workbench" ref={workbenchRef}>
|
||
<header className="workbench-hero">
|
||
<div className="hero-copy">
|
||
<p className="eyebrow">Local puzzle studio</p>
|
||
<h1>{puzzle.title || "Untitled Sudoku"}</h1>
|
||
<p>
|
||
{puzzle.author ? `Set by ${puzzle.author}. ` : ""}
|
||
Set, play, explain and explore without uploading a puzzle.
|
||
</p>
|
||
</div>
|
||
<div className="hero-actions" aria-label="Puzzle actions">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
loadPuzzle(createEmptyPuzzle(9, { title: "Untitled Sudoku" }));
|
||
setWorkspace("set");
|
||
setEntryMode("value");
|
||
}}
|
||
>
|
||
New blank
|
||
</button>
|
||
<label className="select-wrap">
|
||
<span className="sr-only">Open built-in puzzle</span>
|
||
<select
|
||
defaultValue=""
|
||
onChange={(event) => {
|
||
const sample = SAMPLE_CATALOG.find(
|
||
({ id }) => id === event.target.value,
|
||
);
|
||
if (sample !== undefined) {
|
||
loadPuzzle(cloneSample(sample.puzzle));
|
||
setWorkspace("play");
|
||
}
|
||
event.target.value = "";
|
||
}}
|
||
>
|
||
<option value="" disabled>
|
||
Built-in examples…
|
||
</option>
|
||
{SAMPLE_CATALOG.map((sample) => (
|
||
<option key={sample.id} value={sample.id}>
|
||
{sample.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" onClick={() => setImportOpen(true)}>
|
||
Import / export
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setLibraryOpen(true);
|
||
void refreshLibrary();
|
||
}}
|
||
>
|
||
Library
|
||
</button>
|
||
<button type="button" onClick={() => window.print()}>
|
||
Print
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<nav className="workspace-tabs" aria-label="Sudoku workspace">
|
||
{(
|
||
[
|
||
["play", "Play"],
|
||
["set", "Set"],
|
||
["generate", "Generate"],
|
||
["solve", "Solve"],
|
||
["helpers", "Helpers"],
|
||
] as const
|
||
).map(([id, label]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
className={workspace === id ? "is-active" : ""}
|
||
aria-current={workspace === id ? "page" : undefined}
|
||
onClick={() => {
|
||
setWorkspace(id);
|
||
if (id === "set") setEntryMode("value");
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="workbench-status">
|
||
<div className="toolbar-group">
|
||
<span className={`status-pill${solved ? " status-solved" : ""}`}>
|
||
{solved
|
||
? "Solved"
|
||
: conflictData.length
|
||
? `${String(conflictData.length)} conflict${conflictData.length === 1 ? "" : "s"}`
|
||
: "In progress"}
|
||
</span>
|
||
<span>
|
||
{puzzle.size} × {puzzle.size}
|
||
</span>
|
||
<span>
|
||
{puzzle.givens.filter(Boolean).length} given
|
||
{puzzle.givens.filter(Boolean).length === 1 ? "" : "s"}
|
||
</span>
|
||
</div>
|
||
<time
|
||
className="timer-display"
|
||
dateTime={`PT${String(session.elapsedSeconds)}S`}
|
||
>
|
||
{formatTime(session.elapsedSeconds)}
|
||
</time>
|
||
</div>
|
||
|
||
{feedback && (
|
||
<p
|
||
className={`feedback-callout feedback-${feedback.kind}`}
|
||
role={feedback.kind === "error" ? "alert" : "status"}
|
||
>
|
||
{feedback.message}
|
||
</p>
|
||
)}
|
||
|
||
<div
|
||
className={`workbench-grid${workspace === "helpers" ? " workbench-grid--helpers" : ""}`}
|
||
>
|
||
<section className="board-column" aria-label="Puzzle board">
|
||
<div className="board-toolbar">
|
||
<div className="toolbar-group">
|
||
<button type="button" disabled={!past.length} onClick={undo}>
|
||
Undo
|
||
</button>
|
||
<button type="button" disabled={!future.length} onClick={redo}>
|
||
Redo
|
||
</button>
|
||
</div>
|
||
<span>
|
||
{selection.length === 1
|
||
? cellLabel(selection[0]!, puzzle.size)
|
||
: `${String(selection.length)} cells selected`}
|
||
</span>
|
||
</div>
|
||
<div className={`board-surface${session.paused ? " is-paused" : ""}`}>
|
||
<SudokuBoard
|
||
puzzle={normalized.puzzle}
|
||
values={session.values}
|
||
cornerMarks={session.cornerMarks}
|
||
centerMarks={session.centerMarks}
|
||
colors={session.colors}
|
||
candidates={candidateMasks}
|
||
selected={selectedSet}
|
||
highlighted={highlightedCells}
|
||
conflicts={conflictCells}
|
||
activeCell={activeCell}
|
||
showCandidates={showCandidates}
|
||
onCellPointerDown={handlePointerDown}
|
||
onCellPointerEnter={handlePointerEnter}
|
||
onKeyDown={handleKeyDown}
|
||
/>
|
||
{session.paused && (
|
||
<button
|
||
type="button"
|
||
className="paused-cover"
|
||
onClick={() =>
|
||
setSession((current) => ({ ...current, paused: false }))
|
||
}
|
||
>
|
||
<strong>Paused</strong>
|
||
<span>Resume puzzle</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
{workspace === "play" && showDigitCompletion && (
|
||
<DigitCompletionBar
|
||
size={puzzle.size}
|
||
completions={completionData}
|
||
highlightedDigit={highlightedDigit}
|
||
highlightingEnabled={enableDigitHighlight}
|
||
onHighlight={(digit) =>
|
||
setHighlightedDigit((current) =>
|
||
current === digit ? null : digit,
|
||
)
|
||
}
|
||
/>
|
||
)}
|
||
<div className="board-meta">
|
||
<section className="rule-card">
|
||
<p className="eyebrow">Rules</p>
|
||
<p>
|
||
{puzzle.rules ||
|
||
`Place 1–${String(puzzle.size)} exactly once in every row, column and outlined region.`}
|
||
</p>
|
||
{activeConstraints.length > 0 && (
|
||
<div
|
||
className="constraint-chips"
|
||
aria-label="Active constraints"
|
||
>
|
||
{activeConstraints.map((type) => (
|
||
<span key={type}>{constraintLabel(type)}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="side-panel">
|
||
{workspace === "play" && (
|
||
<div className="play-panel stack">
|
||
<div>
|
||
<p className="eyebrow">Play locally</p>
|
||
<h2>Enter your solve</h2>
|
||
<p className="muted">
|
||
Select one or several cells, then use the keypad or keyboard.
|
||
</p>
|
||
</div>
|
||
<NumberPad
|
||
size={puzzle.size}
|
||
mode={entryMode}
|
||
onMode={setEntryMode}
|
||
onValue={enterValue}
|
||
onErase={erase}
|
||
/>
|
||
<section className="panel-section">
|
||
<div className="action-row">
|
||
<button type="button" disabled={busy} onClick={requestHint}>
|
||
Hint
|
||
</button>
|
||
<button type="button" onClick={checkBoard}>
|
||
Check
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setSession((current) => ({
|
||
...current,
|
||
paused: !current.paused,
|
||
}))
|
||
}
|
||
>
|
||
{session.paused ? "Resume" : "Pause"}
|
||
</button>
|
||
</div>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showCandidates}
|
||
onChange={(event) =>
|
||
setShowCandidates(event.target.checked)
|
||
}
|
||
/>
|
||
Show automatically calculated candidates
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showConflicts}
|
||
onChange={(event) => setShowConflicts(event.target.checked)}
|
||
/>
|
||
Highlight conflicts immediately
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showDigitCompletion}
|
||
onChange={(event) =>
|
||
setShowDigitCompletion(event.target.checked)
|
||
}
|
||
/>
|
||
Show digit completion bar
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={enableDigitHighlight}
|
||
onChange={(event) => {
|
||
const enabled = event.target.checked;
|
||
setEnableDigitHighlight(enabled);
|
||
if (!enabled) setHighlightedDigit(null);
|
||
}}
|
||
/>
|
||
Enable matching-digit highlighting (Ctrl/⌘-click or bar)
|
||
</label>
|
||
</section>
|
||
<section className="panel-section">
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
onClick={() => commit(puzzle, createSession(puzzle.givens))}
|
||
>
|
||
Restart puzzle
|
||
</button>
|
||
</section>
|
||
<p className="muted shortcut-note">
|
||
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/⌘ Z
|
||
undoes · Ctrl/⌘-click a placed digit highlights its matches.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{workspace === "set" && (
|
||
<div className="setter-workspace stack">
|
||
<section className="panel-section">
|
||
<p className="eyebrow">Grid clues</p>
|
||
<h2>Enter givens</h2>
|
||
<p className="muted">
|
||
In Value mode, digits become fixed clues. Notes and colours
|
||
remain play-test annotations.
|
||
</p>
|
||
<NumberPad
|
||
size={puzzle.size}
|
||
mode={entryMode}
|
||
onMode={setEntryMode}
|
||
onValue={enterValue}
|
||
onErase={erase}
|
||
/>
|
||
</section>
|
||
<ConstraintEditor
|
||
puzzle={puzzle}
|
||
selection={selection}
|
||
busy={busy}
|
||
onChange={(next) => commit(next, session)}
|
||
onNewGrid={(size) =>
|
||
loadPuzzle(
|
||
createEmptyPuzzle(size, {
|
||
title: puzzle.title ?? "Untitled Sudoku",
|
||
}),
|
||
)
|
||
}
|
||
onCheck={() => void checkDefinition()}
|
||
onGenerate={() => setWorkspace("generate")}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{workspace === "generate" && (
|
||
<GeneratorWorkspace
|
||
busy={busy}
|
||
assessment={difficulty}
|
||
generation={generation}
|
||
onGenerate={(options) => void generateConfigured(options)}
|
||
onRate={() => void rateCurrentPuzzle()}
|
||
/>
|
||
)}
|
||
|
||
{workspace === "solve" && (
|
||
<SolveWorkspace
|
||
size={puzzle.size}
|
||
busy={busy}
|
||
logical={logical}
|
||
exact={exact}
|
||
error={solveError}
|
||
onLogical={() => void runLogical()}
|
||
onExact={() => void runExact()}
|
||
onApplyValues={applyValues}
|
||
onFocusCells={focusCells}
|
||
/>
|
||
)}
|
||
|
||
{workspace === "helpers" && (
|
||
<HelpersWorkspace
|
||
size={puzzle.size}
|
||
selectedCells={selection}
|
||
candidateMasks={candidateMasks}
|
||
/>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
|
||
<ImportExportDialog
|
||
open={importOpen}
|
||
puzzle={puzzle}
|
||
session={session}
|
||
onClose={() => setImportOpen(false)}
|
||
onImport={(next, progress) => {
|
||
try {
|
||
loadPuzzle(next, progress);
|
||
setWorkspace("play");
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
}
|
||
}}
|
||
/>
|
||
<LibraryDialog
|
||
open={libraryOpen}
|
||
summaries={summaries}
|
||
mode={libraryMode}
|
||
busy={libraryBusy}
|
||
feedback={libraryFeedback}
|
||
onClose={() => setLibraryOpen(false)}
|
||
onSave={() => void saveCurrent()}
|
||
onOpen={(id) => void openProject(id)}
|
||
onDelete={(id) => void deleteProject(id)}
|
||
onClear={() => void clearLibrary()}
|
||
onExport={() => void exportLibrary()}
|
||
onImport={(file) => void importLibrary(file)}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|