diff --git a/webui/src/components/PageExitGuard/index.jsx b/webui/src/components/PageExitGuard/index.jsx index f9450d54..957db4de 100644 --- a/webui/src/components/PageExitGuard/index.jsx +++ b/webui/src/components/PageExitGuard/index.jsx @@ -24,8 +24,34 @@ export default function PageExitGuard() { event.preventDefault(); event.returnValue = true; } + function blockNavigation(event) { + event.preventDefault(); + event.stopPropagation(); + setWarning({ blocked: true, visible: true }); + } + function handleLinkClick(event) { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + const link = event.target instanceof Element ? event.target.closest('a[href]') : null; + if (!link || link.hasAttribute('download')) return; + const target = (link.getAttribute('target') || document.querySelector('base[target]')?.getAttribute('target') || '_self').toLowerCase(); + if (target !== '_self') return; + const destination = new URL(link.href, window.location.href); + if (destination.protocol !== 'http:' && destination.protocol !== 'https:') return; + // Hash links stay in this document and do not abandon the rover. + if (destination.origin === window.location.origin + && destination.pathname === window.location.pathname + && destination.search === window.location.search + && link.getAttribute('href').includes('#')) return; + blockNavigation(event); + } window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('click', handleLinkClick, true); + window.addEventListener('page:navigate', blockNavigation); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('click', handleLinkClick, true); + window.removeEventListener('page:navigate', blockNavigation); + }; }, [shouldBlock]); useEffect(() => { @@ -46,18 +72,21 @@ export default function PageExitGuard() { role="dialog" aria-modal="true" aria-labelledby="page-exit-warning" - className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-center gap-10 bg-neutral-950 p-6 text-center text-white" + className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-between gap-4 bg-black p-6 text-center text-white" onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') dismiss(); }} >

- Please dock your rover + Please dock your rover!

+ , document.body, ); diff --git a/webui/src/components/PtzCamera/index.jsx b/webui/src/components/PtzCamera/index.jsx index f0dc0435..35fe6f3b 100644 --- a/webui/src/components/PtzCamera/index.jsx +++ b/webui/src/components/PtzCamera/index.jsx @@ -21,6 +21,7 @@ export default function PtzQueueCard() { const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera')); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const selfId = useSessionSelector((state) => state.session?.socketId || null); + const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false); const { setOperatingMode } = useSessionActions(); const navigate = useNavigate(); const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline)); @@ -32,7 +33,7 @@ export default function PtzQueueCard() { if (!featureEnabled) return null; const handleRequest = async () => { - if (!canUse || pending) return; + if (!canUse || !canLeaveRover || pending) return; if (isParticipant) { navigate('/ptz'); return; @@ -76,14 +77,14 @@ export default function PtzQueueCard() { }} turn={ptz?.turn} selfId={selfId} - canClick={canUse && !pending} + canClick={canUse && canLeaveRover && !pending} pending={pending} buttonLabel={actionLabel} batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'} batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'} timerLabel={timerLabel} onRequest={handleRequest} - showAction={canUse} + showAction={canUse && canLeaveRover} /> {isParticipant ? ( diff --git a/webui/src/components/RoverQueuesPanel/index.jsx b/webui/src/components/RoverQueuesPanel/index.jsx index bfb9f632..69d5afd1 100644 --- a/webui/src/components/RoverQueuesPanel/index.jsx +++ b/webui/src/components/RoverQueuesPanel/index.jsx @@ -101,6 +101,7 @@ export default function RoverQueuesPanel({ fillHeight = false, }) { const role = useSessionSelector((state) => state.session?.role || null); + const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false); const localRoster = useSessionSelector((state) => state.session?.roster ?? []); const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance')); const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true }); @@ -122,8 +123,8 @@ export default function RoverQueuesPanel({ const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster; const canRequest = useMemo( - () => (externalMode ? !externalBlocked : role && role !== 'spectator'), - [externalBlocked, externalMode, role], + () => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'), + [canLeaveRover, externalBlocked, externalMode, role], ); const adminCapable = useMemo( () => role === 'admin' || role === 'lockdown', @@ -142,7 +143,7 @@ export default function RoverQueuesPanel({ async function handleRequest(targetRoverId) { if (!targetRoverId) return; - if (externalBlocked) return; + if (!canRequest) return; if (externalMode) { /* External queue cards deliberately reuse the local row layout, but their diff --git a/webui/src/lib/interInstanceTransfer.js b/webui/src/lib/interInstanceTransfer.js index 08973826..1f645dbe 100644 --- a/webui/src/lib/interInstanceTransfer.js +++ b/webui/src/lib/interInstanceTransfer.js @@ -40,5 +40,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal export function openExternalRover(instance, roverId, { includeSettings = true } = {}) { const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings }); if (!targetUrl) return; + // These queue actions are buttons, so let the page guard veto navigation + // before beforeunload would pause painting behind the native browser dialog. + if (!window.dispatchEvent(new Event('page:navigate', { cancelable: true }))) return; window.location.href = targetUrl; }