Merge pull request #33 from ibs0d/codex/add-zoom-persistence-for-camera-grid-cards

Persist per-camera zoom for live grid cards
This commit is contained in:
ibs0d 2026-03-09 11:37:41 +11:00 committed by GitHub
commit 6af6468899
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 111 additions and 0 deletions

View File

@ -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<CameraZoomPersistedState>;
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));
}

View File

@ -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<string, CameraZoomRuntimeTransform>
>({});
const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>(
{},
);
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({
>
<div
className="size-full overflow-hidden rounded-lg md:rounded-2xl"
ref={(node) => {
cameraZoomViewportRefs.current[camera.name] = node;
if (node) {
hydrateCameraZoomFromStorage(camera.name);
}
}}
onWheel={(event) => handleCardWheelZoom(camera.name, event)}
>
<div