fix: expand map while dragging areas
This commit is contained in:
parent
dedfc30998
commit
1c50c67410
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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 }) => {
|
||||
await mockMapShell(page);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user