fix: expand map while dragging areas

This commit is contained in:
Nico 2026-06-16 22:44:32 -07:00
parent dedfc30998
commit 1c50c67410
6 changed files with 203 additions and 35 deletions

View File

@ -10,11 +10,40 @@ import LocationMapObject from "./LocationMapObject";
const DRAG_CHANGE_THRESHOLD = 2; const DRAG_CHANGE_THRESHOLD = 2;
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]); const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
const SELECTED_OBJECT_SCROLL_PADDING = 24; const SELECTED_OBJECT_SCROLL_PADDING = 24;
const EDGE_PAN_ZONE = 56;
const MAX_EDGE_PAN_STEP = 18;
function clampScrollPosition(value, maxValue) { function clampScrollPosition(value, maxValue) {
return Math.min(Math.max(0, value), Math.max(0, 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) { function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) {
return [ return [
selectedObjectKey, 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) { function hasObjectBoundsChanged(object, nextObject) {
return ( return (
object.x !== nextObject.x || 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({ export default function LocationMapCanvas({
mode, mode,
objects, objects,
@ -83,12 +112,13 @@ export default function LocationMapCanvas({
setSelectedObjectKey, setSelectedObjectKey,
remember, remember,
updateObjects, updateObjects,
expandMapToFitObject,
}) { }) {
const canEditObjects = !editingLocked && mode === "edit"; const canEditObjects = !editingLocked && mode === "edit";
const canDragPan = mode === "view" || mode === "edit"; const canDragPan = mode === "view" || mode === "edit";
const hiddenByZonesLayer = objects.length > 0 && !filters.showZones; const hiddenByZonesLayer = objects.length > 0 && !filters.showZones;
const visibleObjectCount = filters.showZones const visibleObjectCount = filters.showZones
? objects.filter((object) => object.visible).length ? objects.filter((object) => object.visible !== false).length
: 0; : 0;
const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0; const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0;
const canAddFirstArea = const canAddFirstArea =
@ -215,6 +245,16 @@ export default function LocationMapCanvas({
const handlePointerMove = (event) => { const handlePointerMove = (event) => {
if (!dragState || !svgRef.current) return; if (!dragState || !svgRef.current) return;
event.preventDefault(); 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 point = clientPointToMap(event, svgRef.current, mapSize);
const deltaX = point.x - dragState.startPoint.x; const deltaX = point.x - dragState.startPoint.x;
const deltaY = point.y - dragState.startPoint.y; const deltaY = point.y - dragState.startPoint.y;
@ -225,7 +265,11 @@ export default function LocationMapCanvas({
if (!hasMeaningfulChange) return; if (!hasMeaningfulChange) return;
const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key); const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key);
if (!draggedObject) return; 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 (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return;
if (!objectDragHistoryCapturedRef.current) { if (!objectDragHistoryCapturedRef.current) {
@ -233,11 +277,15 @@ export default function LocationMapCanvas({
objectDragHistoryCapturedRef.current = true; objectDragHistoryCapturedRef.current = true;
} }
updateObjects((currentObjects) => updateObjects(
currentObjects.map((object) => { (currentObjects) => currentObjects.map((object) => {
if (getObjectKey(object) !== dragState.key) return 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 nudgeAmount = event.shiftKey ? 25 : 10;
const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0; const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0;
const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0; const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0;
const nextObject = clampObjectToMap({ const nextObjectCandidate = {
...object, ...object,
x: object.x + deltaX, x: Math.max(0, object.x + deltaX),
y: object.y + deltaY, y: Math.max(0, object.y + deltaY),
}, mapSize); };
const nextMapSize = expandMapToFitObject?.(nextObjectCandidate) || mapSize;
const nextObject = clampObjectToMap(nextObjectCandidate, nextMapSize);
setSelectedObjectKey(getObjectKey(object)); setSelectedObjectKey(getObjectKey(object));
if (!hasObjectBoundsChanged(object, nextObject)) return; if (!hasObjectBoundsChanged(object, nextObject)) return;
remember(undefined, getObjectKey(object)); remember(undefined, getObjectKey(object));
updateObjects((currentObjects) => updateObjects(
currentObjects.map((candidate) => (currentObjects) => currentObjects.map((candidate) =>
getObjectKey(candidate) === getObjectKey(object) getObjectKey(candidate) === getObjectKey(object)
? nextObject ? nextObject
: candidate : candidate
) ),
nextMapSize
); );
}; };
@ -353,8 +404,14 @@ export default function LocationMapCanvas({
panDragRef.current.moved = true; panDragRef.current.moved = true;
event.preventDefault(); event.preventDefault();
} }
scrollRef.current.scrollLeft = panDragRef.current.scrollLeft - deltaX; scrollRef.current.scrollLeft = clampScrollPosition(
scrollRef.current.scrollTop = panDragRef.current.scrollTop - deltaY; 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) => { const endPan = (event) => {

View File

@ -50,7 +50,7 @@ export default function LocationMapObject({
onObjectClick, onObjectClick,
onObjectKeyDown, onObjectKeyDown,
}) { }) {
if (!object.visible || !filters.showZones) return null; if (object.visible === false || !filters.showZones) return null;
const key = getObjectKey(object); const key = getObjectKey(object);
const displayLabel = getMapObjectDisplayLabel(object, index + 1); const displayLabel = getMapObjectDisplayLabel(object, index + 1);

View File

@ -129,6 +129,6 @@ export default function useLocationMapDraftState({
loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving, loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving,
savingAction, selectedObjectKey, setDragState, setFilters, setFuture, savingAction, selectedObjectKey, setDragState, setFilters, setFuture,
setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects, setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects,
setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom, setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom,
}; };
} }

View File

@ -8,6 +8,7 @@ import {
} from "../lib/locationMapUtils"; } from "../lib/locationMapUtils";
const EMPTY_ZONES = []; const EMPTY_ZONES = [];
const MAP_EDGE_GROW_PADDING = 360;
function createHistorySnapshot(objects, selectedObjectKey) { function createHistorySnapshot(objects, selectedObjectKey) {
return { return {
@ -27,6 +28,7 @@ export default function useLocationMapObjectActions({
setFuture, setFuture,
setHasUnsavedChanges, setHasUnsavedChanges,
setHistory, setHistory,
setMapDraft,
setObjects, setObjects,
setSelectedObjectKey, setSelectedObjectKey,
toast, toast,
@ -42,13 +44,39 @@ export default function useLocationMapObjectActions({
setFuture([]); setFuture([]);
}, [objects, selectedObjectKey, setFuture, setHistory]); }, [objects, selectedObjectKey, setFuture, setHistory]);
const updateObjects = useCallback((updater) => { const updateObjects = useCallback((updater, bounds = mapSize) => {
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
setObjects((currentObjects) => setObjects((currentObjects) =>
updater(currentObjects).map((object) => clampObjectToMap(object, mapSize)) updater(currentObjects).map((object) => clampObjectToMap(object, bounds))
); );
}, [mapSize, setHasUnsavedChanges, setObjects]); }, [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(() => { const handleAddObject = useCallback(() => {
if (!canManage) return; if (!canManage) return;
remember(); remember();
@ -148,6 +176,7 @@ export default function useLocationMapObjectActions({
handleObjectField, handleObjectField,
handleShowHiddenAreas, handleShowHiddenAreas,
handleZoneLinkChange, handleZoneLinkChange,
expandMapToFitObject,
pendingDeleteObject, pendingDeleteObject,
remember, remember,
requestDeleteObject, requestDeleteObject,

View File

@ -64,6 +64,7 @@ export default function LocationMapManager() {
setHasUnsavedChanges, setHasUnsavedChanges,
setHistory, setHistory,
setLayersOpen, setLayersOpen,
setMapDraft,
setMapState, setMapState,
setMode, setMode,
setObjects, setObjects,
@ -155,6 +156,7 @@ export default function LocationMapManager() {
handleObjectField, handleObjectField,
handleShowHiddenAreas, handleShowHiddenAreas,
handleZoneLinkChange, handleZoneLinkChange,
expandMapToFitObject,
pendingDeleteObject, pendingDeleteObject,
remember, remember,
requestDeleteObject, requestDeleteObject,
@ -171,6 +173,7 @@ export default function LocationMapManager() {
setFuture, setFuture,
setHasUnsavedChanges, setHasUnsavedChanges,
setHistory, setHistory,
setMapDraft,
setObjects, setObjects,
setSelectedObjectKey, setSelectedObjectKey,
toast, toast,
@ -420,6 +423,7 @@ export default function LocationMapManager() {
setSelectedObjectKey={setSelectedObjectKey} setSelectedObjectKey={setSelectedObjectKey}
remember={remember} remember={remember}
updateObjects={updateObjects} updateObjects={updateObjects}
expandMapToFitObject={expandMapToFitObject}
onShowZones={() => onShowZones={() =>
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,

View File

@ -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); 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<Record<string, unknown>> } | 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 }) => { test("admin selected overlapping map area renders above other areas", async ({ page }) => {
await mockMapShell(page); await mockMapShell(page);