diff --git a/frontend/src/hooks/useUnsavedMapLeaveGuard.js b/frontend/src/hooks/useUnsavedMapLeaveGuard.js new file mode 100644 index 0000000..c1bd893 --- /dev/null +++ b/frontend/src/hooks/useUnsavedMapLeaveGuard.js @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useBeforeUnload } from "react-router-dom"; + +export default function useUnsavedMapLeaveGuard({ + shouldGuardLeave, + navigate, + performBackNavigation, +}) { + const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null); + const guardedClickTargetRef = useRef(null); + const allowGuardedClickRef = useRef(false); + + useBeforeUnload( + useCallback((event) => { + if (!shouldGuardLeave) return; + event.preventDefault(); + event.returnValue = ""; + }, [shouldGuardLeave]), + { capture: true } + ); + + useEffect(() => { + if (!shouldGuardLeave) { + setPendingLeaveTarget(null); + guardedClickTargetRef.current = null; + return undefined; + } + + const handleDocumentClick = (event) => { + if (allowGuardedClickRef.current) return; + + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + + const target = event.target instanceof Element ? event.target : null; + const button = target?.closest("button"); + const isSwitchingHousehold = Boolean( + button?.classList.contains("household-option") && + !button.classList.contains("create-household-btn") && + !button.closest(".household-option-row.active") + ); + + if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) { + event.preventDefault(); + event.stopPropagation(); + guardedClickTargetRef.current = button; + setPendingLeaveTarget({ type: "click" }); + return; + } + + const anchor = target?.closest("a[href]"); + if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) { + return; + } + + const nextUrl = new URL(anchor.href, window.location.href); + if (nextUrl.origin !== window.location.origin) return; + + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; + if (nextPath === currentPath) return; + + event.preventDefault(); + setPendingLeaveTarget({ type: "path", path: nextPath }); + }; + + document.addEventListener("click", handleDocumentClick, true); + return () => document.removeEventListener("click", handleDocumentClick, true); + }, [shouldGuardLeave]); + + const requestBackNavigation = useCallback(() => { + if (shouldGuardLeave) { + setPendingLeaveTarget({ type: "back" }); + return; + } + performBackNavigation(); + }, [performBackNavigation, shouldGuardLeave]); + + const clearPendingLeave = useCallback(() => { + guardedClickTargetRef.current = null; + setPendingLeaveTarget(null); + }, []); + + const confirmPendingLeave = useCallback(() => { + const target = pendingLeaveTarget; + setPendingLeaveTarget(null); + if (target?.type === "click") { + const guardedClickTarget = guardedClickTargetRef.current; + guardedClickTargetRef.current = null; + if (guardedClickTarget) { + allowGuardedClickRef.current = true; + guardedClickTarget.click(); + window.setTimeout(() => { + allowGuardedClickRef.current = false; + }, 0); + return; + } + } + if (target?.type === "path" && target.path) { + navigate(target.path); + return; + } + performBackNavigation(); + }, [navigate, pendingLeaveTarget, performBackNavigation]); + + return { + pendingLeaveTarget, + requestBackNavigation, + clearPendingLeave, + confirmPendingLeave, + }; +} diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index 360b3d3..409de94 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { createBlankLocationMap, createLocationMapFromZones, @@ -17,6 +17,7 @@ import LocationMapToolbar from "../components/maps/LocationMapToolbar"; import LocationMapTopbar from "../components/maps/LocationMapTopbar"; import ConfirmSlideModal from "../components/modals/ConfirmSlideModal"; import useActionToast from "../hooks/useActionToast"; +import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard"; import getApiErrorMessage from "../lib/getApiErrorMessage"; import { DEFAULT_MAP_FILTERS, @@ -73,12 +74,9 @@ export default function LocationMapManager() { const [future, setFuture] = useState([]); const [dragState, setDragState] = useState(null); const [pendingDeleteObject, setPendingDeleteObject] = useState(null); - const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null); const svgRef = useRef(null); const toastRef = useRef(toast); const loadRequestRef = useRef(0); - const guardedClickTargetRef = useRef(null); - const allowGuardedClickRef = useRef(false); const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId)); const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)); @@ -134,71 +132,6 @@ export default function LocationMapManager() { [activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width] ); - useBeforeUnload( - useCallback((event) => { - if (!shouldGuardLeave) return; - event.preventDefault(); - event.returnValue = ""; - }, [shouldGuardLeave]), - { capture: true } - ); - - useEffect(() => { - if (!shouldGuardLeave) { - setPendingLeaveTarget(null); - guardedClickTargetRef.current = null; - return undefined; - } - - const handleDocumentClick = (event) => { - if (allowGuardedClickRef.current) return; - - if ( - event.defaultPrevented || - event.button !== 0 || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey - ) { - return; - } - - const target = event.target instanceof Element ? event.target : null; - const button = target?.closest("button"); - const isSwitchingHousehold = Boolean( - button?.classList.contains("household-option") && - !button.classList.contains("create-household-btn") && - !button.closest(".household-option-row.active") - ); - if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) { - event.preventDefault(); - event.stopPropagation(); - guardedClickTargetRef.current = button; - setPendingLeaveTarget({ type: "click" }); - return; - } - - const anchor = target?.closest("a[href]"); - if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) { - return; - } - - const nextUrl = new URL(anchor.href, window.location.href); - if (nextUrl.origin !== window.location.origin) return; - - const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; - const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; - if (nextPath === currentPath) return; - - event.preventDefault(); - setPendingLeaveTarget({ type: "path", path: nextPath }); - }; - - document.addEventListener("click", handleDocumentClick, true); - return () => document.removeEventListener("click", handleDocumentClick, true); - }, [shouldGuardLeave]); - const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); @@ -618,35 +551,16 @@ export default function LocationMapManager() { navigate("/"); }, [navigate, returnPath]); - const handleBack = useCallback(() => { - if (shouldGuardLeave) { - setPendingLeaveTarget({ type: "back" }); - return; - } - performBackNavigation(); - }, [performBackNavigation, shouldGuardLeave]); - - const confirmPendingLeave = useCallback(() => { - const target = pendingLeaveTarget; - setPendingLeaveTarget(null); - if (target?.type === "click") { - const guardedClickTarget = guardedClickTargetRef.current; - guardedClickTargetRef.current = null; - if (guardedClickTarget) { - allowGuardedClickRef.current = true; - guardedClickTarget.click(); - window.setTimeout(() => { - allowGuardedClickRef.current = false; - }, 0); - return; - } - } - if (target?.type === "path" && target.path) { - navigate(target.path); - return; - } - performBackNavigation(); - }, [navigate, pendingLeaveTarget, performBackNavigation]); + const { + pendingLeaveTarget, + requestBackNavigation, + clearPendingLeave, + confirmPendingLeave, + } = useUnsavedMapLeaveGuard({ + shouldGuardLeave, + navigate, + performBackNavigation, + }); if (!hasLoaded || householdLoading || loading) { return ( @@ -667,7 +581,7 @@ export default function LocationMapManager() { if (loadError) { return (
- +

Map Unavailable

@@ -689,7 +603,7 @@ export default function LocationMapManager() { return (
- +
{hasAnyMap && mode !== "setup" ? ( @@ -805,10 +719,7 @@ export default function LocationMapManager() { title="Leave with unsaved changes?" description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them." confirmLabel="Leave Map" - onClose={() => { - guardedClickTargetRef.current = null; - setPendingLeaveTarget(null); - }} + onClose={clearPendingLeave} onConfirm={confirmPendingLeave} />