From 870253eee5e314bb38fc27edebcec3a84ef43a57 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 22:45:40 -0700 Subject: [PATCH] fix: refit mobile maps after viewport changes --- .../src/hooks/useLocationMapViewControls.js | 2 + frontend/src/hooks/useMobileMapAutoFit.js | 47 +++++++++++++++- frontend/src/pages/LocationMapManager.jsx | 1 + frontend/tests/location-map-manager.spec.ts | 55 +++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useLocationMapViewControls.js b/frontend/src/hooks/useLocationMapViewControls.js index 9f7fd2a..a55280f 100644 --- a/frontend/src/hooks/useLocationMapViewControls.js +++ b/frontend/src/hooks/useLocationMapViewControls.js @@ -22,6 +22,7 @@ export default function useLocationMapViewControls({ setZoom, svgRef, syncMapDraftFromState, + zoom, }) { useEffect(() => { if (!selectedObjectKey) return; @@ -38,6 +39,7 @@ export default function useLocationMapViewControls({ mode, setZoom, svgRef, + zoom, }); const handleUndo = useCallback(() => { diff --git a/frontend/src/hooks/useMobileMapAutoFit.js b/frontend/src/hooks/useMobileMapAutoFit.js index c20bb5b..16aeba4 100644 --- a/frontend/src/hooks/useMobileMapAutoFit.js +++ b/frontend/src/hooks/useMobileMapAutoFit.js @@ -34,16 +34,25 @@ export default function useMobileMapAutoFit({ mode, setZoom, svgRef, + zoom, }) { const lastAutoFitKeyRef = useRef(null); + const lastFittedZoomRef = useRef(null); + const latestZoomRef = useRef(zoom); const autoFitKey = useMemo( () => getAutoFitKey(mapState, mapSize), [mapSize, mapState] ); + useEffect(() => { + latestZoomRef.current = zoom; + }, [zoom]); + const fitMapToCanvas = useCallback(() => { const scrollElement = svgRef.current?.closest(".location-map-scroll"); - setZoom(getFitZoom(mapSize, scrollElement)); + const nextZoom = getFitZoom(mapSize, scrollElement); + lastFittedZoomRef.current = nextZoom; + setZoom(nextZoom); scrollElement?.scrollTo({ left: 0, top: 0 }); }, [mapSize, setZoom, svgRef]); @@ -62,5 +71,41 @@ export default function useMobileMapAutoFit({ return () => window.cancelAnimationFrame(frameId); }, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]); + useEffect(() => { + if (typeof window === "undefined") return undefined; + if (!mapState || mode === "setup") return undefined; + + let frameId = null; + const mediaQuery = window.matchMedia(COMPACT_MAP_LAYOUT_QUERY); + const shouldPreserveManualZoom = () => { + const lastFittedZoom = lastFittedZoomRef.current; + return lastFittedZoom !== null && Math.abs(latestZoomRef.current - lastFittedZoom) > 0.01; + }; + const scheduleFit = () => { + if (!mediaQuery.matches || shouldPreserveManualZoom()) return; + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + frameId = window.requestAnimationFrame(() => { + frameId = null; + fitMapToCanvas(); + }); + }; + + window.addEventListener("resize", scheduleFit); + window.addEventListener("orientationchange", scheduleFit); + window.visualViewport?.addEventListener("resize", scheduleFit); + scheduleFit(); + + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + window.removeEventListener("resize", scheduleFit); + window.removeEventListener("orientationchange", scheduleFit); + window.visualViewport?.removeEventListener("resize", scheduleFit); + }; + }, [fitMapToCanvas, mapState, mode]); + return fitMapToCanvas; } diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index 92d625c..c3a2cd8 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -203,6 +203,7 @@ export default function LocationMapManager() { setZoom, svgRef, syncMapDraftFromState, + zoom, }); const performBackNavigation = useCallback(() => { diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 99a1b23..819aa06 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -206,6 +206,11 @@ async function expectMapFitsCanvas(page: Page, maxZoomPercent: number, context: return { scroll }; } +async function readMapZoomPercent(page: Page) { + const value = await page.locator(".location-map-zoom-controls span").textContent(); + return Number(value?.replace("%", "") || 0); +} + test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => { await mockMapShell(page); @@ -1458,6 +1463,56 @@ test("mobile keeps viewing draft status compact in the topbar", async ({ page }) expect(editZoomBox.x + editZoomBox.width).toBeLessThanOrEqual(editToolbarBox.x + editToolbarBox.width + 1); }); +test("mobile map refits after compact viewport resize until manually zoomed", async ({ page }) => { + await page.setViewportSize({ width: 700, height: 844 }); + await mockMapShell(page); + + const mapState = publishedMapState([ + { + id: 1258, + location_map_id: 901, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 40, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mapState), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + + await expect.poll(async () => readMapZoomPercent(page)).toBeGreaterThan(0); + const initialZoom = await readMapZoomPercent(page); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(async () => readMapZoomPercent(page)).toBeLessThan(initialZoom - 8); + const refitZoom = await readMapZoomPercent(page); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await page.getByRole("button", { name: "Zoom in" }).click(); + const manualZoom = await readMapZoomPercent(page); + expect(manualZoom).toBeGreaterThan(refitZoom); + + await page.setViewportSize({ width: 700, height: 844 }); + await page.waitForTimeout(150); + await expect.poll(async () => readMapZoomPercent(page)).toBe(manualZoom); +}); + test("narrow mobile toolbar keeps map controls tappable without overflow", async ({ page }) => { await page.setViewportSize({ width: 360, height: 844 }); await mockMapShell(page);