improved full screen functionality

This commit is contained in:
Dmitri 2026-07-02 09:02:00 +02:00
parent 10bb6da35f
commit f7219ece35
3 changed files with 150 additions and 91 deletions

View File

@ -51,6 +51,12 @@ import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari } from '../lib/browserUtils'; import { isSafari } from '../lib/browserUtils';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { backgroundAudioController } from '../lib/backgroundAudioController'; import { backgroundAudioController } from '../lib/backgroundAudioController';
import {
exitNativeFullscreen,
getFullscreenElement,
requestDocumentFullscreen,
requestParentFullscreenExit,
} from '../lib/fullscreen';
import { import {
resolveNavigationTarget, resolveNavigationTarget,
isTransitionBlocking, isTransitionBlocking,
@ -473,24 +479,40 @@ export default function RuntimePresentation({
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
if (!document.fullscreenElement) { const fullscreenElement = getFullscreenElement();
await document.documentElement.requestFullscreen(); if (!isFullscreen && !fullscreenElement) {
setIsFullscreen(true); const entered = await requestDocumentFullscreen(
} else { 'runtime-global-control',
await document.exitFullscreen(); );
setIsFullscreen(false); setIsFullscreen(entered);
return;
} }
if (fullscreenElement) {
await exitNativeFullscreen();
} else {
requestParentFullscreenExit('runtime-global-control');
}
setIsFullscreen(false);
} catch (err) { } catch (err) {
const parentExitRequested = requestParentFullscreenExit(
'runtime-global-control',
);
if (parentExitRequested) {
setIsFullscreen(false);
return;
}
logger.error( logger.error(
'Fullscreen toggle failed:', 'Fullscreen toggle failed:',
err instanceof Error ? err : { error: err }, err instanceof Error ? err : { error: err },
); );
} }
}, []); }, [isFullscreen]);
useEffect(() => { useEffect(() => {
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement)); setIsFullscreen(Boolean(getFullscreenElement()));
}; };
document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('fullscreenchange', handleFullscreenChange);

View File

@ -19,6 +19,13 @@ import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../lib/fonts'; import { getFontByKey, getFontStyle } from '../../lib/fonts';
import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute'; import { useGlobalAudioMute } from '../../hooks/useGlobalAudioMute';
import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl'; import { buildChromeFreeEmbedUrl, isValidEmbedUrl } from '../../lib/embedUrl';
import {
exitNativeFullscreen,
getFullscreenElement,
isNativeFullscreenElement,
requestElementFullscreen,
requestEmbeddingFrameFullscreen,
} from '../../lib/fullscreen';
interface ImageDetailPanelProps { interface ImageDetailPanelProps {
element: CanvasElement; element: CanvasElement;
@ -32,72 +39,6 @@ interface ImageDetailPanelProps {
onDetailPositionChange?: (xPercent: number, yPercent: number) => void; 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<void> => {
if (document.exitFullscreen) {
await document.exitFullscreen();
return;
}
const webkitExitFullscreen = (
document as Document & {
webkitExitFullscreen?: () => Promise<void>;
}
).webkitExitFullscreen;
if (webkitExitFullscreen) {
await webkitExitFullscreen.call(document);
}
};
const requestEmbeddingFrameFullscreen = async (): Promise<boolean> => {
if (window.parent === window) return false;
try {
const frame = window.frameElement as
| (HTMLElement & {
webkitRequestFullscreen?: () => Promise<void>;
})
| 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<ImageDetailPanelProps> = ({ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
element, element,
image, image,
@ -197,26 +138,11 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
try { try {
// Enter fullscreen // Enter fullscreen
if (panel.requestFullscreen) { await requestElementFullscreen(panel);
await panel.requestFullscreen();
} else if (
(
panel as HTMLDivElement & {
webkitRequestFullscreen?: () => Promise<void>;
}
).webkitRequestFullscreen
) {
// Safari fallback
await (
panel as HTMLDivElement & {
webkitRequestFullscreen: () => Promise<void>;
}
).webkitRequestFullscreen();
}
} catch { } catch {
// Fullscreen can be blocked inside iframes. Try to fullscreen the // Fullscreen can be blocked inside iframes. Try to fullscreen the
// embedding iframe before falling back to filling the iframe viewport. // embedding iframe before falling back to filling the iframe viewport.
await requestEmbeddingFrameFullscreen(); await requestEmbeddingFrameFullscreen('image-detail-panel');
} finally { } finally {
setIsFullscreen(true); setIsFullscreen(true);
} }

View File

@ -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<void> => {
if (document.exitFullscreen) {
await document.exitFullscreen();
return;
}
const webkitExitFullscreen = (
document as Document & {
webkitExitFullscreen?: () => Promise<void>;
}
).webkitExitFullscreen;
if (webkitExitFullscreen) {
await webkitExitFullscreen.call(document);
}
};
const isEmbeddedWindow = (): boolean => window.parent !== window;
export const requestElementFullscreen = async (
element: HTMLElement,
): Promise<void> => {
if (element.requestFullscreen) {
await element.requestFullscreen();
return;
}
const webkitRequestFullscreen = (
element as HTMLElement & {
webkitRequestFullscreen?: () => Promise<void>;
}
).webkitRequestFullscreen;
if (webkitRequestFullscreen) {
await webkitRequestFullscreen.call(element);
return;
}
throw new Error('Fullscreen API is not available');
};
export const requestEmbeddingFrameFullscreen = async (
source = 'runtime',
): Promise<boolean> => {
if (!isEmbeddedWindow()) return false;
try {
const frame = window.frameElement as
| (HTMLElement & {
webkitRequestFullscreen?: () => Promise<void>;
})
| 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<boolean> => {
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;
};