From bf60bd6a3f4b92455671759b413313f2f7b1bdf5 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 16:18:11 -0700 Subject: [PATCH] fix: auto-fit mobile store maps --- .../src/hooks/useLocationMapViewControls.js | 30 ++++------ frontend/src/hooks/useMobileMapAutoFit.js | 60 +++++++++++++++++++ frontend/tests/location-map-manager.spec.ts | 25 ++++++-- 3 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 frontend/src/hooks/useMobileMapAutoFit.js diff --git a/frontend/src/hooks/useLocationMapViewControls.js b/frontend/src/hooks/useLocationMapViewControls.js index cf8295d..fa5c5e2 100644 --- a/frontend/src/hooks/useLocationMapViewControls.js +++ b/frontend/src/hooks/useLocationMapViewControls.js @@ -1,9 +1,6 @@ import { useCallback, useEffect } from "react"; -import { - DEFAULT_MAP_SIZE, - clampMapZoom, - getObjectKey, -} from "../lib/locationMapUtils"; +import useMobileMapAutoFit from "./useMobileMapAutoFit"; +import { getObjectKey } from "../lib/locationMapUtils"; export default function useLocationMapViewControls({ editorTool, @@ -48,6 +45,14 @@ export default function useLocationMapViewControls({ } }, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]); + const fitMapToCanvas = useMobileMapAutoFit({ + mapSize, + mapState, + mode, + setZoom, + svgRef, + }); + const handleUndo = useCallback(() => { setHistory((previous) => { if (previous.length === 0) return previous; @@ -121,22 +126,9 @@ export default function useLocationMapViewControls({ syncMapDraftFromState, ]); - const handleFitMap = useCallback(() => { - const scrollElement = svgRef.current?.closest(".location-map-scroll"); - const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth; - const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight; - const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24); - const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24); - const nextZoom = clampMapZoom( - Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height) - ); - setZoom(Number(nextZoom.toFixed(2))); - scrollElement?.scrollTo({ left: 0, top: 0 }); - }, [mapSize.height, mapSize.width, setZoom, svgRef]); - return { handleEditMode, - handleFitMap, + handleFitMap: fitMapToCanvas, handlePreviewDraft, handleRedo, handleUndo, diff --git a/frontend/src/hooks/useMobileMapAutoFit.js b/frontend/src/hooks/useMobileMapAutoFit.js new file mode 100644 index 0000000..9e76e2d --- /dev/null +++ b/frontend/src/hooks/useMobileMapAutoFit.js @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils"; + +function getAutoFitKey(mapState, mapSize) { + return [ + mapState?.location?.id || "", + mapState?.draft_map?.id || "", + mapState?.published_map?.id || "", + mapSize.width, + mapSize.height, + ].join(":"); +} + +function getFitZoom(mapSize, scrollElement) { + const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth; + const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight; + const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24); + const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24); + + return Number(clampMapZoom( + Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height) + ).toFixed(2)); +} + +export default function useMobileMapAutoFit({ + mapSize, + mapState, + mode, + setZoom, + svgRef, +}) { + const lastAutoFitKeyRef = useRef(null); + const autoFitKey = useMemo( + () => getAutoFitKey(mapState, mapSize), + [mapSize, mapState] + ); + + const fitMapToCanvas = useCallback(() => { + const scrollElement = svgRef.current?.closest(".location-map-scroll"); + setZoom(getFitZoom(mapSize, scrollElement)); + scrollElement?.scrollTo({ left: 0, top: 0 }); + }, [mapSize, setZoom, svgRef]); + + useEffect(() => { + if (typeof window === "undefined") return undefined; + if (!mapState || mode === "setup") return undefined; + if (!window.matchMedia("(max-width: 520px)").matches) return undefined; + if (lastAutoFitKeyRef.current === autoFitKey) return undefined; + + const frameId = window.requestAnimationFrame(() => { + if (!svgRef.current) return; + lastAutoFitKeyRef.current = autoFitKey; + fitMapToCanvas(); + }); + + return () => window.cancelAnimationFrame(frameId); + }, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]); + + return fitMapToCanvas; +} diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index e76d3fa..5fd6477 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -3613,7 +3613,7 @@ test("viewer map areas are keyboard selectable", async ({ page }) => { await expect(page.getByLabel("Map summary")).toBeVisible(); }); -test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => { +test("mobile starts fitted and fit returns panned maps into the canvas", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); @@ -3646,6 +3646,24 @@ test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => await page.goto("/stores/100/locations/10/map"); const scroll = page.locator(".location-map-scroll"); + const zoomText = page.locator(".location-map-zoom-controls span"); + await expect.poll(async () => { + const value = await zoomText.textContent(); + return Number(value?.replace("%", "") || 0); + }).toBeLessThan(45); + + let scrollBox = await page.locator(".location-map-scroll").boundingBox(); + let svgBox = await page.locator(".location-map-svg").boundingBox(); + expect(scrollBox).not.toBeNull(); + expect(svgBox).not.toBeNull(); + if (!scrollBox || !svgBox) throw new Error("Initial mobile fitted map was not measurable"); + expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); + expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); + + const zoomIn = page.getByRole("button", { name: "Zoom in" }); + await zoomIn.click(); + await zoomIn.click(); + await zoomIn.click(); await scroll.evaluate((element) => { element.scrollLeft = 320; element.scrollTop = 240; @@ -3659,14 +3677,13 @@ test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => await page.getByRole("button", { name: "Fit" }).click(); - const zoomText = page.locator(".location-map-zoom-controls span"); await expect.poll(async () => { const value = await zoomText.textContent(); return Number(value?.replace("%", "") || 0); }).toBeLessThan(45); - const scrollBox = await page.locator(".location-map-scroll").boundingBox(); - const svgBox = await page.locator(".location-map-svg").boundingBox(); + scrollBox = await page.locator(".location-map-scroll").boundingBox(); + svgBox = await page.locator(".location-map-svg").boundingBox(); expect(scrollBox).not.toBeNull(); expect(svgBox).not.toBeNull(); if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable");