fix: refit mobile maps after viewport changes

This commit is contained in:
Nico 2026-06-04 22:45:40 -07:00
parent 90404b778a
commit 870253eee5
4 changed files with 104 additions and 1 deletions

View File

@ -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(() => {

View File

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

View File

@ -203,6 +203,7 @@ export default function LocationMapManager() {
setZoom,
svgRef,
syncMapDraftFromState,
zoom,
});
const performBackNavigation = useCallback(() => {

View File

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