Add store location map manager #20
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
clientPointToMap,
|
clientPointToMap,
|
||||||
getObjectKey,
|
getObjectKey,
|
||||||
@ -22,6 +23,11 @@ export default function LocationMapCanvas({
|
|||||||
updateObjects,
|
updateObjects,
|
||||||
}) {
|
}) {
|
||||||
const canEditObjects = mode === "edit" && editorTool === "edit";
|
const canEditObjects = mode === "edit" && editorTool === "edit";
|
||||||
|
const canDragPan = mode === "view" || editorTool === "pan";
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const panDragRef = useRef(null);
|
||||||
|
const suppressPanClickRef = useRef(false);
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
|
||||||
const startDrag = (event, object, type) => {
|
const startDrag = (event, object, type) => {
|
||||||
if (!canEditObjects || object.locked || !svgRef.current) return;
|
if (!canEditObjects || object.locked || !svgRef.current) return;
|
||||||
@ -71,17 +77,67 @@ export default function LocationMapCanvas({
|
|||||||
|
|
||||||
const handleObjectClick = (event, object) => {
|
const handleObjectClick = (event, object) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (suppressPanClickRef.current) {
|
||||||
|
suppressPanClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode === "view" || canEditObjects) {
|
if (mode === "view" || canEditObjects) {
|
||||||
setSelectedObjectKey(getObjectKey(object));
|
setSelectedObjectKey(getObjectKey(object));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
|
if (suppressPanClickRef.current) {
|
||||||
|
suppressPanClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!dragState) {
|
if (!dragState) {
|
||||||
setSelectedObjectKey(null);
|
setSelectedObjectKey(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startPan = (event) => {
|
||||||
|
if (!canDragPan || !scrollRef.current || event.button !== 0) return;
|
||||||
|
const objectElement = event.target.closest?.(".location-map-object");
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
panDragRef.current = {
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
scrollLeft: scrollRef.current.scrollLeft,
|
||||||
|
scrollTop: scrollRef.current.scrollTop,
|
||||||
|
objectKey: objectElement?.getAttribute("data-object-key") || null,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
setIsPanning(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const movePan = (event) => {
|
||||||
|
if (!panDragRef.current || !scrollRef.current) return;
|
||||||
|
const deltaX = event.clientX - panDragRef.current.startX;
|
||||||
|
const deltaY = event.clientY - panDragRef.current.startY;
|
||||||
|
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
|
||||||
|
panDragRef.current.moved = true;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
scrollRef.current.scrollLeft = panDragRef.current.scrollLeft - deltaX;
|
||||||
|
scrollRef.current.scrollTop = panDragRef.current.scrollTop - deltaY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const endPan = (event) => {
|
||||||
|
if (!panDragRef.current) return;
|
||||||
|
const wasMoved = panDragRef.current.moved;
|
||||||
|
const objectKey = panDragRef.current.objectKey;
|
||||||
|
if (!wasMoved && mode === "view" && objectKey) {
|
||||||
|
setSelectedObjectKey(objectKey);
|
||||||
|
suppressPanClickRef.current = true;
|
||||||
|
} else {
|
||||||
|
suppressPanClickRef.current = wasMoved;
|
||||||
|
}
|
||||||
|
panDragRef.current = null;
|
||||||
|
setIsPanning(false);
|
||||||
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
const fittedLabel = (object, fallback) => {
|
const fittedLabel = (object, fallback) => {
|
||||||
const rawLabel = object.label || object.zone_name || fallback;
|
const rawLabel = object.label || object.zone_name || fallback;
|
||||||
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 14));
|
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 14));
|
||||||
@ -115,6 +171,7 @@ export default function LocationMapCanvas({
|
|||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={key}
|
key={key}
|
||||||
|
data-object-key={key}
|
||||||
className={[
|
className={[
|
||||||
"location-map-object",
|
"location-map-object",
|
||||||
isSelected ? "is-selected" : "",
|
isSelected ? "is-selected" : "",
|
||||||
@ -165,9 +222,18 @@ export default function LocationMapCanvas({
|
|||||||
"location-map-canvas-shell",
|
"location-map-canvas-shell",
|
||||||
mode === "edit" ? "is-edit-mode" : "is-view-mode",
|
mode === "edit" ? "is-edit-mode" : "is-view-mode",
|
||||||
editorTool === "edit" ? "is-object-tool" : "is-pan-tool",
|
editorTool === "edit" ? "is-object-tool" : "is-pan-tool",
|
||||||
|
isPanning ? "is-panning" : "",
|
||||||
].filter(Boolean).join(" ")}
|
].filter(Boolean).join(" ")}
|
||||||
>
|
>
|
||||||
<div className="location-map-scroll">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="location-map-scroll"
|
||||||
|
onPointerDown={startPan}
|
||||||
|
onPointerMove={movePan}
|
||||||
|
onPointerUp={endPan}
|
||||||
|
onPointerCancel={endPan}
|
||||||
|
onPointerLeave={endPan}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
className={[
|
className={[
|
||||||
|
|||||||
@ -203,6 +203,15 @@
|
|||||||
touch-action: pan-x pan-y;
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-map-canvas-shell.is-pan-tool .location-map-scroll {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-map-canvas-shell.is-panning .location-map-scroll {
|
||||||
|
cursor: grabbing;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.location-map-svg {
|
.location-map-svg {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
|
|||||||
@ -238,6 +238,19 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
|
|||||||
await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible();
|
||||||
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Pan Mode" }).click();
|
||||||
|
const bakeryPanObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
||||||
|
const panBox = await bakeryPanObject.boundingBox();
|
||||||
|
expect(panBox).not.toBeNull();
|
||||||
|
if (!panBox) throw new Error("Bakery map object was not measurable");
|
||||||
|
await page.mouse.move(panBox.x + panBox.width / 2, panBox.y + panBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(panBox.x + panBox.width / 2 - 80, panBox.y + panBox.height / 2 - 20, {
|
||||||
|
steps: 8,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
||||||
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
||||||
await bakeryObject.click();
|
await bakeryObject.click();
|
||||||
await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
|
await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
|
||||||
@ -246,7 +259,7 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({
|
|||||||
expect(savedPayload).not.toBeNull();
|
expect(savedPayload).not.toBeNull();
|
||||||
expect(savedPayload?.objects).toEqual(
|
expect(savedPayload?.objects).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ label: "Bread Wall", zone_id: 501 }),
|
expect.objectContaining({ label: "Bread Wall", zone_id: 501, x: 28, y: 28 }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user