From 7969930d21264c8fc04d3468238d9329b502ace6 Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:37:28 +1100 Subject: [PATCH] Persist per-camera live grid zoom state --- web/src/utils/cameraZoom.ts | 42 +++++++++++++ web/src/views/live/DraggableGridLayout.tsx | 69 ++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/web/src/utils/cameraZoom.ts b/web/src/utils/cameraZoom.ts index c69335a01..6067d89d5 100644 --- a/web/src/utils/cameraZoom.ts +++ b/web/src/utils/cameraZoom.ts @@ -31,6 +31,24 @@ export type CameraZoomPersistedState = { focusY: number; }; +export function isCameraZoomPersistedState( + value: unknown, +): value is CameraZoomPersistedState { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.normalizedScale === "number" && + Number.isFinite(candidate.normalizedScale) && + typeof candidate.focusX === "number" && + Number.isFinite(candidate.focusX) && + typeof candidate.focusY === "number" && + Number.isFinite(candidate.focusY) + ); +} + export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } @@ -160,3 +178,27 @@ export function getNextScaleFromWheelDelta( export function getCameraZoomStorageKey(cameraName: string): string { return `live:grid-card:zoom:${cameraName}`; } + +export function loadPersistedCameraZoomState( + cameraName: string, +): CameraZoomPersistedState | undefined { + const serialized = localStorage.getItem(getCameraZoomStorageKey(cameraName)); + + if (!serialized) { + return undefined; + } + + try { + const parsed = JSON.parse(serialized); + return isCameraZoomPersistedState(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function savePersistedCameraZoomState( + cameraName: string, + state: CameraZoomPersistedState, +): void { + localStorage.setItem(getCameraZoomStorageKey(cameraName), JSON.stringify(state)); +} diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 24e3ac7f8..819644fce 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -53,8 +53,12 @@ import { CAMERA_ZOOM_MIN_SCALE, CameraZoomRuntimeTransform, clampScale, + fromPersistedCameraZoomState, getCursorRelativeZoomTransform, getNextScaleFromWheelDelta, + loadPersistedCameraZoomState, + savePersistedCameraZoomState, + toPersistedCameraZoomState, } from "@/utils/cameraZoom"; type DraggableGridLayoutProps = { @@ -434,6 +438,49 @@ export default function DraggableGridLayout({ const [cameraZoomStates, setCameraZoomStates] = useState< Record >({}); + const cameraZoomViewportRefs = useRef>( + {}, + ); + + const getCardZoomDimensions = useCallback((cameraName: string) => { + const viewport = cameraZoomViewportRefs.current[cameraName]; + const content = viewport?.firstElementChild as HTMLElement | null; + const viewportWidth = viewport?.clientWidth ?? 0; + const viewportHeight = viewport?.clientHeight ?? 0; + + return { + viewportWidth, + viewportHeight, + contentWidth: content?.clientWidth ?? viewportWidth, + contentHeight: content?.clientHeight ?? viewportHeight, + }; + }, []); + + const hydrateCameraZoomFromStorage = useCallback( + (cameraName: string) => { + setCameraZoomStates((prev) => { + if (prev[cameraName]) { + return prev; + } + + const persisted = loadPersistedCameraZoomState(cameraName); + if (!persisted) { + return prev; + } + + const dimensions = getCardZoomDimensions(cameraName); + if (!dimensions.viewportWidth || !dimensions.viewportHeight) { + return prev; + } + + return { + ...prev, + [cameraName]: fromPersistedCameraZoomState(persisted, dimensions), + }; + }); + }, + [getCardZoomDimensions], + ); const getDefaultZoomTransform = useCallback( (): CameraZoomRuntimeTransform => ({ @@ -467,6 +514,15 @@ export default function DraggableGridLayout({ cursorX, cursorY, ); + const content = event.currentTarget.firstElementChild as HTMLElement | null; + const persisted = toPersistedCameraZoomState(next, { + viewportWidth: bounds.width, + viewportHeight: bounds.height, + contentWidth: content?.clientWidth ?? bounds.width, + contentHeight: content?.clientHeight ?? bounds.height, + }); + + savePersistedCameraZoomState(cameraName, persisted); return { ...prev, @@ -477,6 +533,12 @@ export default function DraggableGridLayout({ [getDefaultZoomTransform], ); + useEffect(() => { + cameras.forEach((camera) => { + hydrateCameraZoomFromStorage(camera.name); + }); + }, [cameras, hydrateCameraZoomFromStorage]); + useEffect(() => { const initialStreamStatsState = getStreamStatsFromStorage(); setGlobalStreamStatsEnabled(initialStreamStatsState); @@ -716,6 +778,13 @@ export default function DraggableGridLayout({ >
{ + cameraZoomViewportRefs.current[camera.name] = node; + + if (node) { + hydrateCameraZoomFromStorage(camera.name); + } + }} onWheel={(event) => handleCardWheelZoom(camera.name, event)} >