From f7219ece35e1a7179b9b72961cf3039ca7fd6cec Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 2 Jul 2026 09:02:00 +0200 Subject: [PATCH] improved full screen functionality --- .../src/components/RuntimePresentation.tsx | 38 ++++-- .../UiElements/ImageDetailPanel.tsx | 92 ++------------- frontend/src/lib/fullscreen.ts | 111 ++++++++++++++++++ 3 files changed, 150 insertions(+), 91 deletions(-) create mode 100644 frontend/src/lib/fullscreen.ts diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index cf4953c..8c13abf 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -51,6 +51,12 @@ import { downloadManager } from '../lib/offline/DownloadManager'; import { isSafari } from '../lib/browserUtils'; import { logger } from '../lib/logger'; import { backgroundAudioController } from '../lib/backgroundAudioController'; +import { + exitNativeFullscreen, + getFullscreenElement, + requestDocumentFullscreen, + requestParentFullscreenExit, +} from '../lib/fullscreen'; import { resolveNavigationTarget, isTransitionBlocking, @@ -473,24 +479,40 @@ export default function RuntimePresentation({ const toggleFullscreen = useCallback(async () => { try { - if (!document.fullscreenElement) { - await document.documentElement.requestFullscreen(); - setIsFullscreen(true); - } else { - await document.exitFullscreen(); - setIsFullscreen(false); + const fullscreenElement = getFullscreenElement(); + if (!isFullscreen && !fullscreenElement) { + const entered = await requestDocumentFullscreen( + 'runtime-global-control', + ); + setIsFullscreen(entered); + return; } + + if (fullscreenElement) { + await exitNativeFullscreen(); + } else { + requestParentFullscreenExit('runtime-global-control'); + } + setIsFullscreen(false); } catch (err) { + const parentExitRequested = requestParentFullscreenExit( + 'runtime-global-control', + ); + if (parentExitRequested) { + setIsFullscreen(false); + return; + } + logger.error( 'Fullscreen toggle failed:', err instanceof Error ? err : { error: err }, ); } - }, []); + }, [isFullscreen]); useEffect(() => { const handleFullscreenChange = () => { - setIsFullscreen(Boolean(document.fullscreenElement)); + setIsFullscreen(Boolean(getFullscreenElement())); }; document.addEventListener('fullscreenchange', handleFullscreenChange); diff --git a/frontend/src/components/UiElements/ImageDetailPanel.tsx b/frontend/src/components/UiElements/ImageDetailPanel.tsx index 19fbca8..14a49b4 100644 --- a/frontend/src/components/UiElements/ImageDetailPanel.tsx +++ b/frontend/src/components/UiElements/ImageDetailPanel.tsx @@ -19,6 +19,13 @@ import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { getFontByKey, getFontStyle } from '../../lib/fonts'; import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute'; import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl'; +import { + exitNativeFullscreen, + getFullscreenElement, + isNativeFullscreenElement, + requestElementFullscreen, + requestEmbeddingFrameFullscreen, +} from '../../lib/fullscreen'; interface ImageDetailPanelProps { element: CanvasElement; @@ -32,72 +39,6 @@ interface ImageDetailPanelProps { onDetailPositionChange?: (xPercent: number, yPercent: number) => void; } -const getFullscreenElement = (): Element | null => { - return ( - document.fullscreenElement ?? - ( - document as Document & { - webkitFullscreenElement?: Element | null; - } - ).webkitFullscreenElement ?? - null - ); -}; - -const isNativeFullscreenElement = (element: Element | null): boolean => - !!element && getFullscreenElement() === element; - -const exitNativeFullscreen = async (): Promise => { - if (document.exitFullscreen) { - await document.exitFullscreen(); - return; - } - - const webkitExitFullscreen = ( - document as Document & { - webkitExitFullscreen?: () => Promise; - } - ).webkitExitFullscreen; - - if (webkitExitFullscreen) { - await webkitExitFullscreen.call(document); - } -}; - -const requestEmbeddingFrameFullscreen = async (): Promise => { - if (window.parent === window) return false; - - try { - const frame = window.frameElement as - | (HTMLElement & { - webkitRequestFullscreen?: () => Promise; - }) - | null; - - if (frame?.requestFullscreen) { - await frame.requestFullscreen(); - return true; - } - - if (frame?.webkitRequestFullscreen) { - await frame.webkitRequestFullscreen(); - return true; - } - } catch { - // Cross-origin parents are not directly accessible from the iframe. - } - - window.parent.postMessage( - { - type: 'tour-builder:request-fullscreen', - source: 'image-detail-panel', - }, - '*', - ); - - return false; -}; - const ImageDetailPanel: React.FC = ({ element, image, @@ -197,26 +138,11 @@ const ImageDetailPanel: React.FC = ({ try { // Enter fullscreen - if (panel.requestFullscreen) { - await panel.requestFullscreen(); - } else if ( - ( - panel as HTMLDivElement & { - webkitRequestFullscreen?: () => Promise; - } - ).webkitRequestFullscreen - ) { - // Safari fallback - await ( - panel as HTMLDivElement & { - webkitRequestFullscreen: () => Promise; - } - ).webkitRequestFullscreen(); - } + await requestElementFullscreen(panel); } catch { // Fullscreen can be blocked inside iframes. Try to fullscreen the // embedding iframe before falling back to filling the iframe viewport. - await requestEmbeddingFrameFullscreen(); + await requestEmbeddingFrameFullscreen('image-detail-panel'); } finally { setIsFullscreen(true); } diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts new file mode 100644 index 0000000..f2a72f1 --- /dev/null +++ b/frontend/src/lib/fullscreen.ts @@ -0,0 +1,111 @@ +export const getFullscreenElement = (): Element | null => { + return ( + document.fullscreenElement ?? + ( + document as Document & { + webkitFullscreenElement?: Element | null; + } + ).webkitFullscreenElement ?? + null + ); +}; + +export const isNativeFullscreenElement = (element: Element | null): boolean => + !!element && getFullscreenElement() === element; + +export const exitNativeFullscreen = async (): Promise => { + if (document.exitFullscreen) { + await document.exitFullscreen(); + return; + } + + const webkitExitFullscreen = ( + document as Document & { + webkitExitFullscreen?: () => Promise; + } + ).webkitExitFullscreen; + + if (webkitExitFullscreen) { + await webkitExitFullscreen.call(document); + } +}; + +const isEmbeddedWindow = (): boolean => window.parent !== window; + +export const requestElementFullscreen = async ( + element: HTMLElement, +): Promise => { + if (element.requestFullscreen) { + await element.requestFullscreen(); + return; + } + + const webkitRequestFullscreen = ( + element as HTMLElement & { + webkitRequestFullscreen?: () => Promise; + } + ).webkitRequestFullscreen; + + if (webkitRequestFullscreen) { + await webkitRequestFullscreen.call(element); + return; + } + + throw new Error('Fullscreen API is not available'); +}; + +export const requestEmbeddingFrameFullscreen = async ( + source = 'runtime', +): Promise => { + if (!isEmbeddedWindow()) return false; + + try { + const frame = window.frameElement as + | (HTMLElement & { + webkitRequestFullscreen?: () => Promise; + }) + | null; + + if (frame) { + await requestElementFullscreen(frame); + return true; + } + } catch { + // Cross-origin parents are not directly accessible from the iframe. + } + + window.parent.postMessage( + { + type: 'tour-builder:request-fullscreen', + source, + }, + '*', + ); + + return true; +}; + +export const requestDocumentFullscreen = async ( + source = 'runtime', +): Promise => { + try { + await requestElementFullscreen(document.documentElement); + return true; + } catch { + return requestEmbeddingFrameFullscreen(source); + } +}; + +export const requestParentFullscreenExit = (source = 'runtime'): boolean => { + if (!isEmbeddedWindow()) return false; + + window.parent.postMessage( + { + type: 'tour-builder:exit-fullscreen', + source, + }, + '*', + ); + + return true; +};