feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user