diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index fe4606d..7d5cbd1 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { clientPointToMap, getObjectKey, @@ -8,6 +8,11 @@ import LocationMapObject from "./LocationMapObject"; const DRAG_CHANGE_THRESHOLD = 2; const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]); +const SELECTED_OBJECT_SCROLL_PADDING = 24; + +function clampScrollPosition(value, maxValue) { + return Math.min(Math.max(0, value), Math.max(0, maxValue)); +} export default function LocationMapCanvas({ mode, @@ -53,6 +58,7 @@ export default function LocationMapCanvas({ const panDragRef = useRef(null); const objectDragHistoryCapturedRef = useRef(false); const suppressPanClickRef = useRef(false); + const autoScrolledObjectKeyRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const orderedObjects = useMemo(() => { if (!selectedObjectKey) return objects; @@ -88,6 +94,54 @@ export default function LocationMapCanvas({ return { assignedCounts, visibleItemsByZone }; }, [filters, mapItems, username]); + useEffect(() => { + if (!selectedObjectKey) { + autoScrolledObjectKeyRef.current = null; + return undefined; + } + + if (mode !== "edit" || dragState || autoScrolledObjectKeyRef.current === selectedObjectKey) { + return undefined; + } + + const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey); + if (!selectedObject || !scrollRef.current || typeof window === "undefined") { + return undefined; + } + + autoScrolledObjectKeyRef.current = selectedObjectKey; + const frameId = window.requestAnimationFrame(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const objectLeft = selectedObject.x * zoom - SELECTED_OBJECT_SCROLL_PADDING; + const objectTop = selectedObject.y * zoom - SELECTED_OBJECT_SCROLL_PADDING; + const objectRight = (selectedObject.x + selectedObject.width) * zoom + SELECTED_OBJECT_SCROLL_PADDING; + const objectBottom = (selectedObject.y + selectedObject.height) * zoom + SELECTED_OBJECT_SCROLL_PADDING; + let nextLeft = scrollElement.scrollLeft; + let nextTop = scrollElement.scrollTop; + + if (objectLeft < nextLeft) { + nextLeft = objectLeft; + } else if (objectRight > nextLeft + scrollElement.clientWidth) { + nextLeft = objectRight - scrollElement.clientWidth; + } + + if (objectTop < nextTop) { + nextTop = objectTop; + } else if (objectBottom > nextTop + scrollElement.clientHeight) { + nextTop = objectBottom - scrollElement.clientHeight; + } + + scrollElement.scrollTo({ + left: clampScrollPosition(nextLeft, scrollElement.scrollWidth - scrollElement.clientWidth), + top: clampScrollPosition(nextTop, scrollElement.scrollHeight - scrollElement.clientHeight), + }); + }); + + return () => window.cancelAnimationFrame(frameId); + }, [dragState, mode, objects, selectedObjectKey, zoom]); + const startDrag = (event, object, type) => { if (!canEditObjects || object.locked || !svgRef.current) return; event.stopPropagation(); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index a25c6e3..99a1b23 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -4145,6 +4145,75 @@ test("mobile editor uses bottom sheet controls instead of desktop object toolbar await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0); }); +test("mobile edit selection keeps resize handle in reach", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1606, + 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" }); + const zoomIn = page.getByRole("button", { name: "Zoom in" }); + await zoomIn.click(); + await zoomIn.click(); + await zoomIn.click(); + + await bakeryArea.focus(); + 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); + + await page.keyboard.press("Enter"); + 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); + + 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 editor pans from empty map space without moving areas", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page);