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(null); const panOriginRef = useRef(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) => { 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) => { 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) => { 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) => { if (panOriginRef.current?.pointerId !== event.pointerId) return; scrollerRef.current?.releasePointerCapture?.(event.pointerId); panOriginRef.current = undefined; }; const percentage = Math.round(scale * 100); return (
1 ? " is-zoomed-in" : ""}${panMode ? " is-pan-mode" : ""}`} aria-label="Board zoom and pan" onKeyDownCapture={onKeyDownCapture} >
{percentage}%
{panMode && (

Pan mode: drag the board to move it. Cell taps are paused.

)}
{children}
); }