diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 765cfc2ef4..6b64898943 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,13 +1,14 @@ import { Dialog } from '@base-ui/react/dialog'; -import { useDisclosure, useFullscreen } from '@mantine/hooks'; +import { useDisclosure } from '@mantine/hooks'; import { memo } from 'react'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { LuCoffee } from 'react-icons/lu'; import { useLocation } from 'react-router'; -import { isLocalhost, supportsFullscreen } from '../../../externals'; +import { isLocalhost } from '../../../externals'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { navigatorConstants } from '../../../viewerConfig'; +import { useFullscreen } from '../../hooks/useFullscreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useClientStore } from '../../stores/clientStore'; import { useViewOptionsStore } from '../../stores/viewOptions'; @@ -32,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { const isSmallScreen = useIsSmallScreen(); const [isRenameOpen, handlers] = useDisclosure(false); - const { fullscreen, toggle } = useFullscreen(); + const { fullscreen, isSupported: isFullscreenSupported, toggle: toggleFullscreen } = useFullscreen(); const { mirror, toggleMirror } = useViewOptionsStore(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const location = useLocation(); @@ -57,8 +58,14 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
- {supportsFullscreen && ( - + {isFullscreenSupported && ( + { + onClose(); + toggleFullscreen(); + }} + > Toggle Fullscreen {fullscreen ? : } diff --git a/apps/client/src/common/hooks/useFullscreen.ts b/apps/client/src/common/hooks/useFullscreen.ts new file mode 100644 index 0000000000..390502f7b0 --- /dev/null +++ b/apps/client/src/common/hooks/useFullscreen.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface UseFullscreenReturn { + /** Whether the document is currently in fullscreen mode */ + fullscreen: boolean; + /** + * Whether the Fullscreen API is available and permitted in the current browsing context. + * When false, `toggle` is a no-op and the UI should hide or disable the action. + */ + isSupported: boolean; + /** + * Toggles fullscreen on/off. + * Safe to use directly as an onClick handler — the underlying Promise is handled + * internally, so rejections (eg. browser policy, user denial) never surface as + * unhandled promise rejections. + */ + toggle: () => void; +} + +/** + * Manages browser fullscreen state. + * + * Compared to @mantine/hooks useFullscreen: + * - `isSupported` is co-located so there is a single source of truth + * - `toggle` is a plain void callback, safe to pass directly to event handlers + * - Error handling is internal; callers do not need try/catch + * - Only targets document.documentElement (the ref API is not used in this project) + * - No vendor-prefix shims (all supported browsers implement the standard API) + */ +export function useFullscreen(): UseFullscreenReturn { + const isSupported = Boolean(document.fullscreenEnabled); + const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement)); + + // Keep `fullscreen` state in sync with the actual browser state. + // The fullscreenchange event fires whenever the document enters or exits fullscreen + // (including when the user presses Escape to exit), so React state never drifts + // from reality. The listener is removed on unmount via the cleanup return. + useEffect(() => { + const handleChange = () => setFullscreen(Boolean(document.fullscreenElement)); + document.addEventListener('fullscreenchange', handleChange); + return () => document.removeEventListener('fullscreenchange', handleChange); + }, []); + + const toggle = useCallback(() => { + if (!isSupported) return; + + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => { + // requestFullscreen() can be rejected in certain browser contexts + // (eg. browser security policy, extensions blocking fullscreen) + }); + } else { + document.exitFullscreen().catch(() => { + // exitFullscreen() can also be rejected in rare edge cases + }); + } + }, [isSupported]); + + return { fullscreen, isSupported, toggle }; +} diff --git a/apps/client/src/externals.ts b/apps/client/src/externals.ts index 0393d65360..b9c38edfa1 100644 --- a/apps/client/src/externals.ts +++ b/apps/client/src/externals.ts @@ -27,7 +27,6 @@ export const isLocalhost = currentHostName === 'localhost' || currentHostName == export const isOntimeCloud = document.querySelector('base')?.hasAttribute('data-is-cloud'); export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; -export const supportsFullscreen = document.fullscreenEnabled; // resolve entrypoint URLs