184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
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${scale > 1 ? " is-zoomed-in" : ""}${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>
|
||
);
|
||
}
|