From 03a4817c5ed8f78f00c449bc44c985673d80ad9f Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 22:57:26 -0700 Subject: [PATCH] fix: keep selected map handle reachable on zoom --- .../src/components/maps/LocationMapCanvas.jsx | 24 ++++- frontend/tests/location-map-manager.spec.ts | 88 ++++++++++++++++--- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index 7d5cbd1..f54b8ed 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -14,6 +14,17 @@ function clampScrollPosition(value, maxValue) { return Math.min(Math.max(0, value), Math.max(0, maxValue)); } +function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) { + return [ + selectedObjectKey, + zoom.toFixed(2), + object.x, + object.y, + object.width, + object.height, + ].join(":"); +} + export default function LocationMapCanvas({ mode, objects, @@ -58,7 +69,7 @@ export default function LocationMapCanvas({ const panDragRef = useRef(null); const objectDragHistoryCapturedRef = useRef(false); const suppressPanClickRef = useRef(false); - const autoScrolledObjectKeyRef = useRef(null); + const autoScrolledObjectViewRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const orderedObjects = useMemo(() => { if (!selectedObjectKey) return objects; @@ -96,11 +107,11 @@ export default function LocationMapCanvas({ useEffect(() => { if (!selectedObjectKey) { - autoScrolledObjectKeyRef.current = null; + autoScrolledObjectViewRef.current = null; return undefined; } - if (mode !== "edit" || dragState || autoScrolledObjectKeyRef.current === selectedObjectKey) { + if (mode !== "edit" || dragState) { return undefined; } @@ -109,7 +120,12 @@ export default function LocationMapCanvas({ return undefined; } - autoScrolledObjectKeyRef.current = selectedObjectKey; + const scrollKey = getSelectedObjectScrollKey(selectedObject, selectedObjectKey, zoom); + if (autoScrolledObjectViewRef.current === scrollKey) { + return undefined; + } + autoScrolledObjectViewRef.current = scrollKey; + const frameId = window.requestAnimationFrame(() => { const scrollElement = scrollRef.current; if (!scrollElement) return; diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 819aa06..0d4ee63 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -211,6 +211,23 @@ async function readMapZoomPercent(page: Page) { return Number(value?.replace("%", "") || 0); } +async function expectResizeHandleWithinMapScroll(page: Page) { + const scroll = page.locator(".location-map-scroll"); + const resizeHandle = page.locator(".location-map-resize-handle"); + await expect(resizeHandle).toBeVisible(); + const scrollBox = await scroll.boundingBox(); + const handleBox = await resizeHandle.boundingBox(); + expect(scrollBox).not.toBeNull(); + expect(handleBox).not.toBeNull(); + if (!scrollBox || !handleBox) { + throw new Error("Selected resize handle was not measurable"); + } + expect(handleBox.x).toBeGreaterThanOrEqual(scrollBox.x); + expect(handleBox.y).toBeGreaterThanOrEqual(scrollBox.y); + expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(scrollBox.x + scrollBox.width + 1); + expect(handleBox.y + handleBox.height).toBeLessThanOrEqual(scrollBox.y + scrollBox.height + 1); +} + test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => { await mockMapShell(page); @@ -4253,20 +4270,65 @@ test("mobile edit selection keeps resize handle in reach", async ({ page }) => { await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80); + await expectResizeHandleWithinMapScroll(page); +}); - const resizeHandle = page.locator(".location-map-resize-handle"); - await expect(resizeHandle).toBeVisible(); - const scrollBox = await scroll.boundingBox(); - const handleBox = await resizeHandle.boundingBox(); - expect(scrollBox).not.toBeNull(); - expect(handleBox).not.toBeNull(); - if (!scrollBox || !handleBox) { - throw new Error("Selected resize handle was not measurable"); - } - expect(handleBox.x).toBeGreaterThanOrEqual(scrollBox.x); - expect(handleBox.y).toBeGreaterThanOrEqual(scrollBox.y); - expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(scrollBox.x + scrollBox.width + 1); - expect(handleBox.y + handleBox.height).toBeLessThanOrEqual(scrollBox.y + scrollBox.height + 1); +test("mobile edit zoom keeps selected resize handle in reach", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1608, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 720, + y: 480, + width: 240, + 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 page.getByRole("button", { name: "Continue Editing" }).click(); + + const scroll = page.locator(".location-map-scroll"); + const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); + await bakeryArea.focus(); + await page.keyboard.press("Enter"); + await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); + await expectResizeHandleWithinMapScroll(page); + + await scroll.evaluate((element) => { + element.scrollLeft = 0; + element.scrollTop = 0; + }); + await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBe(0); + await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBe(0); + + const zoomIn = page.getByRole("button", { name: "Zoom in" }); + await zoomIn.click(); + await zoomIn.click(); + await zoomIn.click(); + + await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200); + await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80); + await expectResizeHandleWithinMapScroll(page); }); test("mobile editor pans from empty map space without moving areas", async ({ page }) => {