fix: auto-fit mobile store maps

This commit is contained in:
Nico 2026-06-04 16:18:11 -07:00
parent faf96ef929
commit bf60bd6a3f
3 changed files with 92 additions and 23 deletions

View File

@ -1,9 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { import useMobileMapAutoFit from "./useMobileMapAutoFit";
DEFAULT_MAP_SIZE, import { getObjectKey } from "../lib/locationMapUtils";
clampMapZoom,
getObjectKey,
} from "../lib/locationMapUtils";
export default function useLocationMapViewControls({ export default function useLocationMapViewControls({
editorTool, editorTool,
@ -48,6 +45,14 @@ export default function useLocationMapViewControls({
} }
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]); }, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
const fitMapToCanvas = useMobileMapAutoFit({
mapSize,
mapState,
mode,
setZoom,
svgRef,
});
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
setHistory((previous) => { setHistory((previous) => {
if (previous.length === 0) return previous; if (previous.length === 0) return previous;
@ -121,22 +126,9 @@ export default function useLocationMapViewControls({
syncMapDraftFromState, 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 { return {
handleEditMode, handleEditMode,
handleFitMap, handleFitMap: fitMapToCanvas,
handlePreviewDraft, handlePreviewDraft,
handleRedo, handleRedo,
handleUndo, handleUndo,

View File

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

View File

@ -3613,7 +3613,7 @@ test("viewer map areas are keyboard selectable", async ({ page }) => {
await expect(page.getByLabel("Map summary")).toBeVisible(); 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 page.setViewportSize({ width: 390, height: 844 });
await mockMapShell(page); 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"); await page.goto("/stores/100/locations/10/map");
const scroll = page.locator(".location-map-scroll"); 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) => { await scroll.evaluate((element) => {
element.scrollLeft = 320; element.scrollLeft = 320;
element.scrollTop = 240; 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(); await page.getByRole("button", { name: "Fit" }).click();
const zoomText = page.locator(".location-map-zoom-controls span");
await expect.poll(async () => { await expect.poll(async () => {
const value = await zoomText.textContent(); const value = await zoomText.textContent();
return Number(value?.replace("%", "") || 0); return Number(value?.replace("%", "") || 0);
}).toBeLessThan(45); }).toBeLessThan(45);
const scrollBox = await page.locator(".location-map-scroll").boundingBox(); scrollBox = await page.locator(".location-map-scroll").boundingBox();
const svgBox = await page.locator(".location-map-svg").boundingBox(); svgBox = await page.locator(".location-map-svg").boundingBox();
expect(scrollBox).not.toBeNull(); expect(scrollBox).not.toBeNull();
expect(svgBox).not.toBeNull(); expect(svgBox).not.toBeNull();
if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable"); if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable");