From 1c50c6741085198b61156fa8fa8875f449dbd33c Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 16 Jun 2026 22:44:32 -0700 Subject: [PATCH] fix: expand map while dragging areas --- .../src/components/maps/LocationMapCanvas.jsx | 119 +++++++++++++----- .../src/components/maps/LocationMapObject.jsx | 2 +- .../src/hooks/useLocationMapDraftState.js | 2 +- .../src/hooks/useLocationMapObjectActions.js | 33 ++++- frontend/src/pages/LocationMapManager.jsx | 4 + frontend/tests/location-map-manager.spec.ts | 78 ++++++++++++ 6 files changed, 203 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index fd4599f..1a04b44 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -10,11 +10,40 @@ import LocationMapObject from "./LocationMapObject"; const DRAG_CHANGE_THRESHOLD = 2; const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]); const SELECTED_OBJECT_SCROLL_PADDING = 24; +const EDGE_PAN_ZONE = 56; +const MAX_EDGE_PAN_STEP = 18; function clampScrollPosition(value, maxValue) { return Math.min(Math.max(0, value), Math.max(0, maxValue)); } +function getEdgePanStep(pointerPosition, start, end) { + if (pointerPosition < start + EDGE_PAN_ZONE) { + const intensity = (start + EDGE_PAN_ZONE - pointerPosition) / EDGE_PAN_ZONE; + return -Math.ceil(MAX_EDGE_PAN_STEP * intensity); + } + + if (pointerPosition > end - EDGE_PAN_ZONE) { + const intensity = (pointerPosition - (end - EDGE_PAN_ZONE)) / EDGE_PAN_ZONE; + return Math.ceil(MAX_EDGE_PAN_STEP * intensity); + } + + return 0; +} + +function panScrollElement(scrollElement, deltaX, deltaY) { + if (!scrollElement || (!deltaX && !deltaY)) return; + + scrollElement.scrollLeft = clampScrollPosition( + scrollElement.scrollLeft + deltaX, + scrollElement.scrollWidth - scrollElement.clientWidth + ); + scrollElement.scrollTop = clampScrollPosition( + scrollElement.scrollTop + deltaY, + scrollElement.scrollHeight - scrollElement.clientHeight + ); +} + function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) { return [ selectedObjectKey, @@ -38,6 +67,22 @@ function clampResizedObjectToMap(object, mapSize) { }; } +function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) { + if (dragState.type === "resize") { + return { + ...object, + width: Math.max(40, dragState.startObject.width + deltaX), + height: Math.max(40, dragState.startObject.height + deltaY), + }; + } + + return { + ...object, + x: Math.max(0, dragState.startObject.x + deltaX), + y: Math.max(0, dragState.startObject.y + deltaY), + }; +} + function hasObjectBoundsChanged(object, nextObject) { return ( object.x !== nextObject.x || @@ -47,22 +92,6 @@ function hasObjectBoundsChanged(object, nextObject) { ); } -function getDraggedObjectUpdate(object, dragState, deltaX, deltaY, mapSize) { - if (dragState.type === "resize") { - return clampResizedObjectToMap({ - ...object, - width: Math.max(40, dragState.startObject.width + deltaX), - height: Math.max(40, dragState.startObject.height + deltaY), - }, mapSize); - } - - return clampObjectToMap({ - ...object, - x: dragState.startObject.x + deltaX, - y: dragState.startObject.y + deltaY, - }, mapSize); -} - export default function LocationMapCanvas({ mode, objects, @@ -83,12 +112,13 @@ export default function LocationMapCanvas({ setSelectedObjectKey, remember, updateObjects, + expandMapToFitObject, }) { const canEditObjects = !editingLocked && mode === "edit"; const canDragPan = mode === "view" || mode === "edit"; const hiddenByZonesLayer = objects.length > 0 && !filters.showZones; const visibleObjectCount = filters.showZones - ? objects.filter((object) => object.visible).length + ? objects.filter((object) => object.visible !== false).length : 0; const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0; const canAddFirstArea = @@ -215,6 +245,16 @@ export default function LocationMapCanvas({ const handlePointerMove = (event) => { if (!dragState || !svgRef.current) return; event.preventDefault(); + const scrollElement = scrollRef.current; + if (scrollElement) { + const scrollBounds = scrollElement.getBoundingClientRect(); + panScrollElement( + scrollElement, + getEdgePanStep(event.clientX, scrollBounds.left, scrollBounds.right), + getEdgePanStep(event.clientY, scrollBounds.top, scrollBounds.bottom) + ); + } + const point = clientPointToMap(event, svgRef.current, mapSize); const deltaX = point.x - dragState.startPoint.x; const deltaY = point.y - dragState.startPoint.y; @@ -225,7 +265,11 @@ export default function LocationMapCanvas({ if (!hasMeaningfulChange) return; const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key); if (!draggedObject) return; - const nextDraggedObject = getDraggedObjectUpdate(draggedObject, dragState, deltaX, deltaY, mapSize); + const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY); + const nextMapSize = expandMapToFitObject?.(nextDraggedObjectCandidate) || mapSize; + const nextDraggedObject = dragState.type === "resize" + ? clampResizedObjectToMap(nextDraggedObjectCandidate, nextMapSize) + : clampObjectToMap(nextDraggedObjectCandidate, nextMapSize); if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return; if (!objectDragHistoryCapturedRef.current) { @@ -233,11 +277,15 @@ export default function LocationMapCanvas({ objectDragHistoryCapturedRef.current = true; } - updateObjects((currentObjects) => - currentObjects.map((object) => { + updateObjects( + (currentObjects) => currentObjects.map((object) => { if (getObjectKey(object) !== dragState.key) return object; - return getDraggedObjectUpdate(object, dragState, deltaX, deltaY, mapSize); - }) + const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY); + return dragState.type === "resize" + ? clampResizedObjectToMap(candidate, nextMapSize) + : clampObjectToMap(candidate, nextMapSize); + }), + nextMapSize ); }; @@ -283,22 +331,25 @@ export default function LocationMapCanvas({ const nudgeAmount = event.shiftKey ? 25 : 10; const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0; const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0; - const nextObject = clampObjectToMap({ + const nextObjectCandidate = { ...object, - x: object.x + deltaX, - y: object.y + deltaY, - }, mapSize); + x: Math.max(0, object.x + deltaX), + y: Math.max(0, object.y + deltaY), + }; + const nextMapSize = expandMapToFitObject?.(nextObjectCandidate) || mapSize; + const nextObject = clampObjectToMap(nextObjectCandidate, nextMapSize); setSelectedObjectKey(getObjectKey(object)); if (!hasObjectBoundsChanged(object, nextObject)) return; remember(undefined, getObjectKey(object)); - updateObjects((currentObjects) => - currentObjects.map((candidate) => + updateObjects( + (currentObjects) => currentObjects.map((candidate) => getObjectKey(candidate) === getObjectKey(object) ? nextObject : candidate - ) + ), + nextMapSize ); }; @@ -353,8 +404,14 @@ export default function LocationMapCanvas({ panDragRef.current.moved = true; event.preventDefault(); } - scrollRef.current.scrollLeft = panDragRef.current.scrollLeft - deltaX; - scrollRef.current.scrollTop = panDragRef.current.scrollTop - deltaY; + scrollRef.current.scrollLeft = clampScrollPosition( + panDragRef.current.scrollLeft - deltaX, + scrollRef.current.scrollWidth - scrollRef.current.clientWidth + ); + scrollRef.current.scrollTop = clampScrollPosition( + panDragRef.current.scrollTop - deltaY, + scrollRef.current.scrollHeight - scrollRef.current.clientHeight + ); }; const endPan = (event) => { diff --git a/frontend/src/components/maps/LocationMapObject.jsx b/frontend/src/components/maps/LocationMapObject.jsx index 0de6d5b..4cdc2f4 100644 --- a/frontend/src/components/maps/LocationMapObject.jsx +++ b/frontend/src/components/maps/LocationMapObject.jsx @@ -50,7 +50,7 @@ export default function LocationMapObject({ onObjectClick, onObjectKeyDown, }) { - if (!object.visible || !filters.showZones) return null; + if (object.visible === false || !filters.showZones) return null; const key = getObjectKey(object); const displayLabel = getMapObjectDisplayLabel(object, index + 1); diff --git a/frontend/src/hooks/useLocationMapDraftState.js b/frontend/src/hooks/useLocationMapDraftState.js index ef94ff4..af64193 100644 --- a/frontend/src/hooks/useLocationMapDraftState.js +++ b/frontend/src/hooks/useLocationMapDraftState.js @@ -129,6 +129,6 @@ export default function useLocationMapDraftState({ loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving, savingAction, selectedObjectKey, setDragState, setFilters, setFuture, setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects, - setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom, + setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom, }; } diff --git a/frontend/src/hooks/useLocationMapObjectActions.js b/frontend/src/hooks/useLocationMapObjectActions.js index 8bd6c68..70476f6 100644 --- a/frontend/src/hooks/useLocationMapObjectActions.js +++ b/frontend/src/hooks/useLocationMapObjectActions.js @@ -8,6 +8,7 @@ import { } from "../lib/locationMapUtils"; const EMPTY_ZONES = []; +const MAP_EDGE_GROW_PADDING = 360; function createHistorySnapshot(objects, selectedObjectKey) { return { @@ -27,6 +28,7 @@ export default function useLocationMapObjectActions({ setFuture, setHasUnsavedChanges, setHistory, + setMapDraft, setObjects, setSelectedObjectKey, toast, @@ -42,13 +44,39 @@ export default function useLocationMapObjectActions({ setFuture([]); }, [objects, selectedObjectKey, setFuture, setHistory]); - const updateObjects = useCallback((updater) => { + const updateObjects = useCallback((updater, bounds = mapSize) => { setHasUnsavedChanges(true); setObjects((currentObjects) => - updater(currentObjects).map((object) => clampObjectToMap(object, mapSize)) + updater(currentObjects).map((object) => clampObjectToMap(object, bounds)) ); }, [mapSize, setHasUnsavedChanges, setObjects]); + const expandMapToFitObject = useCallback((object) => { + const nextWidth = Math.max( + mapSize.width, + Math.ceil(Number(object.x || 0) + Number(object.width || 0) + MAP_EDGE_GROW_PADDING) + ); + const nextHeight = Math.max( + mapSize.height, + Math.ceil(Number(object.y || 0) + Number(object.height || 0) + MAP_EDGE_GROW_PADDING) + ); + const nextMapSize = { + width: nextWidth, + height: nextHeight, + }; + + if (nextWidth > mapSize.width || nextHeight > mapSize.height) { + setMapDraft((currentDraft) => ({ + ...currentDraft, + width: Math.max(Number(currentDraft.width || 0), nextWidth), + height: Math.max(Number(currentDraft.height || 0), nextHeight), + })); + setHasUnsavedChanges(true); + } + + return nextMapSize; + }, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]); + const handleAddObject = useCallback(() => { if (!canManage) return; remember(); @@ -148,6 +176,7 @@ export default function useLocationMapObjectActions({ handleObjectField, handleShowHiddenAreas, handleZoneLinkChange, + expandMapToFitObject, pendingDeleteObject, remember, requestDeleteObject, diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index 32399aa..a815dd9 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -64,6 +64,7 @@ export default function LocationMapManager() { setHasUnsavedChanges, setHistory, setLayersOpen, + setMapDraft, setMapState, setMode, setObjects, @@ -155,6 +156,7 @@ export default function LocationMapManager() { handleObjectField, handleShowHiddenAreas, handleZoneLinkChange, + expandMapToFitObject, pendingDeleteObject, remember, requestDeleteObject, @@ -171,6 +173,7 @@ export default function LocationMapManager() { setFuture, setHasUnsavedChanges, setHistory, + setMapDraft, setObjects, setSelectedObjectKey, toast, @@ -420,6 +423,7 @@ export default function LocationMapManager() { setSelectedObjectKey={setSelectedObjectKey} remember={remember} updateObjects={updateObjects} + expandMapToFitObject={expandMapToFitObject} onShowZones={() => setFilters((current) => ({ ...current, diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 65cc469..786dbea 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -2082,6 +2082,84 @@ test("admin locked map areas hide resize affordances", async ({ page }) => { await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toHaveCount(0); }); +test("admin can drag map areas beyond the current right edge", async ({ page }) => { + await page.setViewportSize({ width: 398, height: 617 }); + await mockMapShell(page); + + const baseState = draftMapState([ + { + id: 1321, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 420, + y: 80, + width: 220, + height: 140, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + const mapState = { + ...baseState, + map: { ...baseState.map, width: 700, height: 420 }, + draft_map: { ...baseState.draft_map, width: 700, height: 420 }, + }; + let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | null = null; + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mapState), + }); + }); + + await page.route("**/households/1/locations/10/map/draft", async (route) => { + savedPayload = await route.request().postDataJSON(); + const savedState = draftMapState(savedPayload?.objects || [], true); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...savedState, + map: { ...savedState.map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, + draft_map: { ...savedState.draft_map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, + }), + }); + }); + + 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.click(); + + const areaBox = await bakeryArea.boundingBox(); + expect(areaBox).not.toBeNull(); + if (!areaBox) throw new Error("Bakery map area was not measurable"); + await page.mouse.move(areaBox.x + areaBox.width / 2, areaBox.y + areaBox.height / 2); + await page.mouse.down(); + await page.mouse.move(areaBox.x + areaBox.width + 180, areaBox.y + areaBox.height / 2, { steps: 12 }); + await page.mouse.up(); + + await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await page.getByRole("button", { name: "Save Draft" }).click(); + + await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(700); + expect(savedPayload?.objects?.[0]).toEqual( + expect.objectContaining({ + label: "Bakery", + x: expect.any(Number), + }) + ); + expect(Number(savedPayload?.objects?.[0]?.x)).toBeGreaterThan(420); +}); + test("admin selected overlapping map area renders above other areas", async ({ page }) => { await mockMapShell(page);