refactor: extract map view controls
This commit is contained in:
parent
bc7b8e41f8
commit
604025588e
28
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
28
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import LocationMapTopbar from "./LocationMapTopbar";
|
||||||
|
|
||||||
|
export function LocationMapMessageState({ message }) {
|
||||||
|
return (
|
||||||
|
<div className="location-map-page">
|
||||||
|
<p className="location-map-loading">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
|
||||||
|
return (
|
||||||
|
<div className="location-map-page">
|
||||||
|
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
|
||||||
|
<main className="location-map-workspace">
|
||||||
|
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
||||||
|
<h2>Map Unavailable</h2>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<div className="location-map-setup-actions">
|
||||||
|
<button type="button" className="btn-primary" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/hooks/useLocationMapViewControls.js
Normal file
138
frontend/src/hooks/useLocationMapViewControls.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_MAP_SIZE,
|
||||||
|
clampMapZoom,
|
||||||
|
getObjectKey,
|
||||||
|
} from "../lib/locationMapUtils";
|
||||||
|
|
||||||
|
export default function useLocationMapViewControls({
|
||||||
|
editorTool,
|
||||||
|
filters,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
mapSize,
|
||||||
|
mapState,
|
||||||
|
mode,
|
||||||
|
objects,
|
||||||
|
previewDraft,
|
||||||
|
selectedObjectKey,
|
||||||
|
setEditorTool,
|
||||||
|
setFuture,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setHistory,
|
||||||
|
setMode,
|
||||||
|
setObjects,
|
||||||
|
setPreviewDraft,
|
||||||
|
setSelectedObjectKey,
|
||||||
|
setZoom,
|
||||||
|
svgRef,
|
||||||
|
syncMapDraftFromState,
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "edit") {
|
||||||
|
setEditorTool("pan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorTool === "pan") {
|
||||||
|
setSelectedObjectKey(null);
|
||||||
|
}
|
||||||
|
}, [editorTool, mode, setEditorTool, setSelectedObjectKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedObjectKey) return;
|
||||||
|
|
||||||
|
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
||||||
|
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
|
||||||
|
setSelectedObjectKey(null);
|
||||||
|
}
|
||||||
|
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
setHistory((previous) => {
|
||||||
|
if (previous.length === 0) return previous;
|
||||||
|
const priorObjects = previous[previous.length - 1];
|
||||||
|
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
|
||||||
|
setObjects(priorObjects);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
return previous.slice(0, -1);
|
||||||
|
});
|
||||||
|
}, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
setFuture((previous) => {
|
||||||
|
if (previous.length === 0) return previous;
|
||||||
|
const nextObjects = previous[0];
|
||||||
|
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
|
||||||
|
setObjects(nextObjects);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
return previous.slice(1);
|
||||||
|
});
|
||||||
|
}, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]);
|
||||||
|
|
||||||
|
const handlePreviewDraft = useCallback(() => {
|
||||||
|
setMode("view");
|
||||||
|
setEditorTool("pan");
|
||||||
|
setPreviewDraft(true);
|
||||||
|
setSelectedObjectKey(null);
|
||||||
|
}, [setEditorTool, setMode, setPreviewDraft, setSelectedObjectKey]);
|
||||||
|
|
||||||
|
const handleViewMode = useCallback(() => {
|
||||||
|
setMode("view");
|
||||||
|
setEditorTool("pan");
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
setPreviewDraft(true);
|
||||||
|
setSelectedObjectKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewDraft(false);
|
||||||
|
if (mapState) {
|
||||||
|
syncMapDraftFromState(mapState, "view", false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasUnsavedChanges,
|
||||||
|
mapState,
|
||||||
|
setEditorTool,
|
||||||
|
setMode,
|
||||||
|
setPreviewDraft,
|
||||||
|
setSelectedObjectKey,
|
||||||
|
syncMapDraftFromState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleEditMode = useCallback(() => {
|
||||||
|
setMode("edit");
|
||||||
|
setEditorTool("edit");
|
||||||
|
setPreviewDraft(true);
|
||||||
|
if (!previewDraft && mapState && !hasUnsavedChanges) {
|
||||||
|
syncMapDraftFromState(mapState, "edit", true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
hasUnsavedChanges,
|
||||||
|
mapState,
|
||||||
|
previewDraft,
|
||||||
|
setEditorTool,
|
||||||
|
setMode,
|
||||||
|
setPreviewDraft,
|
||||||
|
syncMapDraftFromState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleFitMap = useCallback(() => {
|
||||||
|
const scrollElement = svgRef.current?.closest(".location-map-scroll");
|
||||||
|
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
|
||||||
|
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
|
||||||
|
const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24);
|
||||||
|
const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24);
|
||||||
|
const nextZoom = clampMapZoom(
|
||||||
|
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
|
||||||
|
);
|
||||||
|
setZoom(Number(nextZoom.toFixed(2)));
|
||||||
|
}, [mapSize.height, mapSize.width, setZoom, svgRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleEditMode,
|
||||||
|
handleFitMap,
|
||||||
|
handlePreviewDraft,
|
||||||
|
handleRedo,
|
||||||
|
handleUndo,
|
||||||
|
handleViewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -6,6 +6,10 @@ import { StoreContext } from "../context/StoreContext";
|
|||||||
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
|
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
|
||||||
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
|
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
|
||||||
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
|
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
|
||||||
|
import {
|
||||||
|
LocationMapLoadErrorState,
|
||||||
|
LocationMapMessageState,
|
||||||
|
} from "../components/maps/LocationMapStateViews";
|
||||||
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
||||||
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
||||||
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
|
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
|
||||||
@ -13,10 +17,10 @@ import useActionToast from "../hooks/useActionToast";
|
|||||||
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
|
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
|
||||||
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
|
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
|
||||||
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
|
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
|
||||||
|
import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
|
||||||
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
|
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAP_SIZE,
|
DEFAULT_MAP_SIZE,
|
||||||
clampMapZoom,
|
|
||||||
getMapObjectDisplayLabel,
|
getMapObjectDisplayLabel,
|
||||||
getMapStatus,
|
getMapStatus,
|
||||||
getObjectKey,
|
getObjectKey,
|
||||||
@ -145,26 +149,6 @@ export default function LocationMapManager() {
|
|||||||
}
|
}
|
||||||
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "edit") {
|
|
||||||
setEditorTool("pan");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editorTool === "pan") {
|
|
||||||
setSelectedObjectKey(null);
|
|
||||||
}
|
|
||||||
}, [editorTool, mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedObjectKey) return;
|
|
||||||
|
|
||||||
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
|
||||||
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
|
|
||||||
setSelectedObjectKey(null);
|
|
||||||
}
|
|
||||||
}, [filters.showZones, objects, selectedObjectKey]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleCreateBlank,
|
handleCreateBlank,
|
||||||
handleCreateFromZones,
|
handleCreateFromZones,
|
||||||
@ -215,69 +199,35 @@ export default function LocationMapManager() {
|
|||||||
toast,
|
toast,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleUndo = () => {
|
const {
|
||||||
setHistory((previous) => {
|
handleEditMode,
|
||||||
if (previous.length === 0) return previous;
|
handleFitMap,
|
||||||
const priorObjects = previous[previous.length - 1];
|
handlePreviewDraft,
|
||||||
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
|
handleRedo,
|
||||||
setObjects(priorObjects);
|
handleUndo,
|
||||||
setHasUnsavedChanges(true);
|
handleViewMode,
|
||||||
return previous.slice(0, -1);
|
} = useLocationMapViewControls({
|
||||||
|
editorTool,
|
||||||
|
filters,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
mapSize,
|
||||||
|
mapState,
|
||||||
|
mode,
|
||||||
|
objects,
|
||||||
|
previewDraft,
|
||||||
|
selectedObjectKey,
|
||||||
|
setEditorTool,
|
||||||
|
setFuture,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setHistory,
|
||||||
|
setMode,
|
||||||
|
setObjects,
|
||||||
|
setPreviewDraft,
|
||||||
|
setSelectedObjectKey,
|
||||||
|
setZoom,
|
||||||
|
svgRef,
|
||||||
|
syncMapDraftFromState,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleRedo = () => {
|
|
||||||
setFuture((previous) => {
|
|
||||||
if (previous.length === 0) return previous;
|
|
||||||
const nextObjects = previous[0];
|
|
||||||
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
|
|
||||||
setObjects(nextObjects);
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
return previous.slice(1);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviewDraft = () => {
|
|
||||||
setMode("view");
|
|
||||||
setEditorTool("pan");
|
|
||||||
setPreviewDraft(true);
|
|
||||||
setSelectedObjectKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewMode = () => {
|
|
||||||
setMode("view");
|
|
||||||
setEditorTool("pan");
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
setPreviewDraft(true);
|
|
||||||
setSelectedObjectKey(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewDraft(false);
|
|
||||||
if (mapState) {
|
|
||||||
syncMapDraftFromState(mapState, "view", false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditMode = () => {
|
|
||||||
setMode("edit");
|
|
||||||
setEditorTool("edit");
|
|
||||||
setPreviewDraft(true);
|
|
||||||
if (!previewDraft && mapState && !hasUnsavedChanges) {
|
|
||||||
syncMapDraftFromState(mapState, "edit", true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFitMap = () => {
|
|
||||||
const scrollElement = svgRef.current?.closest(".location-map-scroll");
|
|
||||||
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
|
|
||||||
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
|
|
||||||
const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24);
|
|
||||||
const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24);
|
|
||||||
const nextZoom = clampMapZoom(
|
|
||||||
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
|
|
||||||
);
|
|
||||||
setZoom(Number(nextZoom.toFixed(2)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const performBackNavigation = useCallback(() => {
|
const performBackNavigation = useCallback(() => {
|
||||||
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
||||||
@ -299,37 +249,21 @@ export default function LocationMapManager() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!hasLoaded || householdLoading || loading) {
|
if (!hasLoaded || householdLoading || loading) {
|
||||||
return (
|
return <LocationMapMessageState message="Loading map..." />;
|
||||||
<div className="location-map-page">
|
|
||||||
<p className="location-map-loading">Loading map...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeHousehold) {
|
if (!activeHousehold) {
|
||||||
return (
|
return <LocationMapMessageState message="Select a household to manage maps." />;
|
||||||
<div className="location-map-page">
|
|
||||||
<p className="location-map-loading">Select a household to manage maps.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="location-map-page">
|
<LocationMapLoadErrorState
|
||||||
<LocationMapTopbar location={location} status="Load Error" onBack={requestBackNavigation} />
|
location={location}
|
||||||
<main className="location-map-workspace">
|
loadError={loadError}
|
||||||
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
onBack={requestBackNavigation}
|
||||||
<h2>Map Unavailable</h2>
|
onRetry={loadMap}
|
||||||
<p>{loadError}</p>
|
/>
|
||||||
<div className="location-map-setup-actions">
|
|
||||||
<button type="button" className="btn-primary" onClick={loadMap}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user