diff --git a/web/src/components/__tests__/CameraImage.test.jsx b/web/src/components/__tests__/CameraImage.test.jsx
new file mode 100644
index 000000000..7228a1137
--- /dev/null
+++ b/web/src/components/__tests__/CameraImage.test.jsx
@@ -0,0 +1,36 @@
+import { h } from 'preact';
+import * as Api from '../../api';
+import * as Hooks from '../../hooks';
+import CameraImage from '../CameraImage';
+import { render, screen } from '@testing-library/preact';
+
+jest.mock('../../api/baseUrl');
+
+describe('CameraImage', () => {
+ beforeEach(() => {
+ jest.spyOn(Api, 'useConfig').mockImplementation(() => {
+ return { data: { cameras: { front: { name: 'front', width: 1280, height: 720 } } } };
+ });
+ jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
+ jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
+ });
+
+ test('renders an activity indicator while loading', async () => {
+ render(
);
+ expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
+ });
+
+ test('creates a scaled canvas using the available width & height, preserving camera aspect ratio', async () => {
+ jest.spyOn(Hooks, 'useResizeObserver').mockReturnValueOnce([{ width: 720 }]);
+
+ render(
);
+ expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
+ expect(screen.queryByTestId('cameraimage-canvas')).toMatchInlineSnapshot(`
+
+ `);
+ });
+});
diff --git a/web/src/hooks/index.jsx b/web/src/hooks/index.jsx
new file mode 100644
index 000000000..385313cba
--- /dev/null
+++ b/web/src/hooks/index.jsx
@@ -0,0 +1,30 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+
+export function useResizeObserver(...refs) {
+ const [dimensions, setDimensions] = useState(
+ new Array(refs.length).fill({ width: 0, height: 0, x: -Infinity, y: -Infinity })
+ );
+ const resizeObserver = useMemo(
+ () =>
+ new ResizeObserver((entries) => {
+ window.requestAnimationFrame(() => {
+ setDimensions(entries.map((entry) => entry.contentRect));
+ });
+ }),
+ []
+ );
+
+ useEffect(() => {
+ refs.forEach((ref) => {
+ resizeObserver.observe(ref.current);
+ });
+
+ return () => {
+ refs.forEach((ref) => {
+ resizeObserver.unobserve(ref.current);
+ });
+ };
+ }, [refs, resizeObserver]);
+
+ return dimensions;
+}
diff --git a/web/src/routes/CameraMap.jsx b/web/src/routes/CameraMap.jsx
index d169f26a0..58b2e64bd 100644
--- a/web/src/routes/CameraMap.jsx
+++ b/web/src/routes/CameraMap.jsx
@@ -3,14 +3,14 @@ import Card from '../components/Card.jsx';
import Button from '../components/Button.jsx';
import Heading from '../components/Heading.jsx';
import Switch from '../components/Switch.jsx';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
+import { useResizeObserver } from '../hooks';
+import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import { useApiHost, useConfig } from '../api';
export default function CameraMasks({ camera, url }) {
const { data: config } = useConfig();
const apiHost = useApiHost();
const imageRef = useRef(null);
- const [imageScale, setImageScale] = useState(1);
const [snap, setSnap] = useState(true);
const cameraConfig = config.cameras[camera];
@@ -22,26 +22,8 @@ export default function CameraMasks({ camera, url }) {
zones,
} = cameraConfig;
- const resizeObserver = useMemo(
- () =>
- new ResizeObserver((entries) => {
- window.requestAnimationFrame(() => {
- if (Array.isArray(entries) && entries.length) {
- const scaledWidth = entries[0].contentRect.width;
- const scale = scaledWidth / width;
- setImageScale(scale);
- }
- });
- }),
- [width, setImageScale]
- );
-
- useEffect(() => {
- if (!imageRef.current) {
- return;
- }
- resizeObserver.observe(imageRef.current);
- }, [resizeObserver, imageRef]);
+ const [{ width: scaledWidth }] = useResizeObserver(imageRef);
+ const imageScale = scaledWidth / width;
const [motionMaskPoints, setMotionMaskPoints] = useState(
Array.isArray(motionMask)