feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+183
View File
@@ -0,0 +1,183 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import {
BOARD_SCALE_MAX,
BOARD_SCALE_MIN,
BOARD_SCALE_STEP,
normalizeBoardScale,
readStoredBoardScale,
writeStoredBoardScale,
} from "../state/uiPreferences";
interface PanOrigin {
readonly pointerId: number;
readonly clientX: number;
readonly clientY: number;
readonly scrollLeft: number;
readonly scrollTop: number;
}
export function BoardViewport({
children,
onPanModeChange,
}: {
readonly children: ReactNode;
readonly onPanModeChange?: (enabled: boolean) => void;
}) {
const [scale, setScale] = useState(readStoredBoardScale);
const [panMode, setPanMode] = useState(false);
const scrollerRef = useRef<HTMLDivElement>(null);
const panOriginRef = useRef<PanOrigin | undefined>(undefined);
useEffect(() => writeStoredBoardScale(scale), [scale]);
const updateScale = (value: number) => setScale(normalizeBoardScale(value));
const fitBoard = () => {
updateScale(1);
if (scrollerRef.current !== null) {
scrollerRef.current.scrollLeft = 0;
scrollerRef.current.scrollTop = 0;
}
};
const onKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (["+", "="].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
updateScale(scale + BOARD_SCALE_STEP);
} else if (event.key === "-") {
event.preventDefault();
event.stopPropagation();
updateScale(scale - BOARD_SCALE_STEP);
} else if (event.key === "0") {
event.preventDefault();
event.stopPropagation();
fitBoard();
}
};
const beginPan = (event: PointerEvent<HTMLDivElement>) => {
if (!panMode || (event.pointerType === "mouse" && event.button !== 0))
return;
const scroller = scrollerRef.current;
if (scroller === null) return;
event.preventDefault();
scroller.setPointerCapture?.(event.pointerId);
panOriginRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
scrollLeft: scroller.scrollLeft,
scrollTop: scroller.scrollTop,
};
};
const continuePan = (event: PointerEvent<HTMLDivElement>) => {
const origin = panOriginRef.current;
const scroller = scrollerRef.current;
if (
!panMode ||
origin === undefined ||
origin.pointerId !== event.pointerId ||
scroller === null
) {
return;
}
event.preventDefault();
scroller.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX);
scroller.scrollTop = origin.scrollTop - (event.clientY - origin.clientY);
};
const endPan = (event: PointerEvent<HTMLDivElement>) => {
if (panOriginRef.current?.pointerId !== event.pointerId) return;
scrollerRef.current?.releasePointerCapture?.(event.pointerId);
panOriginRef.current = undefined;
};
const percentage = Math.round(scale * 100);
return (
<section
className={`board-viewport${panMode ? " is-pan-mode" : ""}`}
aria-label="Board zoom and pan"
onKeyDownCapture={onKeyDownCapture}
>
<div className="board-viewport__controls">
<div
className="board-zoom-buttons"
role="group"
aria-label="Board zoom"
>
<button
type="button"
aria-label="Zoom board out"
disabled={scale <= BOARD_SCALE_MIN}
onClick={() => updateScale(scale - BOARD_SCALE_STEP)}
>
</button>
<output aria-live="polite" aria-label="Board zoom level">
{percentage}%
</output>
<button
type="button"
aria-label="Zoom board in"
disabled={scale >= BOARD_SCALE_MAX}
onClick={() => updateScale(scale + BOARD_SCALE_STEP)}
>
+
</button>
<button type="button" onClick={fitBoard}>
Fit board
</button>
</div>
<button
type="button"
className={panMode ? "is-active" : ""}
aria-pressed={panMode}
onClick={() =>
setPanMode((current) => {
const enabled = !current;
panOriginRef.current = undefined;
onPanModeChange?.(enabled);
return enabled;
})
}
>
{panMode ? "Stop panning" : "Pan board"}
</button>
</div>
{panMode && (
<p className="board-pan-notice" role="status">
Pan mode: drag the board to move it. Cell taps are paused.
</p>
)}
<div
ref={scrollerRef}
className="board-viewport__scroller"
tabIndex={0}
aria-label={
panMode
? "Scrollable puzzle board; pan mode is on"
: "Scrollable puzzle board"
}
onPointerDown={beginPan}
onPointerMove={continuePan}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="board-viewport__canvas"
style={{
width: `${String(percentage)}%`,
maxWidth: `${String(48 * scale)}rem`,
}}
>
{children}
</div>
</div>
</section>
);
}