diff --git a/documentation/global-ui-controls.md b/documentation/global-ui-controls.md index 046ed06..e84677a 100644 --- a/documentation/global-ui-controls.md +++ b/documentation/global-ui-controls.md @@ -80,19 +80,20 @@ sound button, and fullscreen for the fullscreen button. When runtime is embedded in an iframe, the fullscreen control requests fullscreen on the embedding iframe first when same-origin access is available. For cross-origin wrappers it posts `tour-builder:request-fullscreen` to -`window.parent` before attempting child-document fullscreen, because a -permissions-policy rejection inside the iframe can consume the user activation -needed by the parent fallback. If the iframe itself grants fullscreen but the -parent does not listen for the message, the child-document fullscreen fallback -can still enter fullscreen. Standalone runtime pages use the browser Fullscreen -API for the presentation document. Exit attempts post -`tour-builder:exit-fullscreen`. +`window.parent` and, for nested iframe wrappers, `window.top` before attempting +child-document fullscreen, because a permissions-policy rejection inside the +iframe can consume the user activation needed by the parent fallback. If the +iframe itself grants fullscreen but the parent does not listen for the message, +the child-document fullscreen fallback can still enter fullscreen. Standalone +runtime pages use the browser Fullscreen API for the presentation document. +Exit attempts post `tour-builder:exit-fullscreen` to the same parent/top +targets. Browser fullscreen is not equivalent for top-level pages and iframes. A top-level runtime page can request document fullscreen from a user click, while a cross-origin iframe normally needs `allow="fullscreen"` / `allowfullscreen` -or a parent page that handles `tour-builder:request-fullscreen` and fullscreens -the iframe element. +or a parent/top page that handles `tour-builder:request-fullscreen` and +fullscreens the iframe element. The runtime keeps a local fullscreen-active state after an embedded parent fullscreen request because the child document may not expose a local diff --git a/frontend/docs/runtime-presentation.md b/frontend/docs/runtime-presentation.md index e342131..19ca0ff 100644 --- a/frontend/docs/runtime-presentation.md +++ b/frontend/docs/runtime-presentation.md @@ -167,16 +167,17 @@ Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty values fall back to the built-in MDI icons. Embedded runtime fullscreen requests the embedding iframe first when same-origin access is available. For cross-origin wrappers it posts -`tour-builder:request-fullscreen` to the parent before attempting -child-document fullscreen, so a permissions-policy rejection inside the iframe -does not consume the user activation needed by the parent fallback. If the -iframe itself grants fullscreen but the parent does not listen for the message, -the child-document fullscreen fallback can still enter fullscreen. Standalone -runtime pages use native document fullscreen. Exit from cross-origin wrapper -fullscreen posts `tour-builder:exit-fullscreen`. +`tour-builder:request-fullscreen` to the parent and, for nested iframe wrappers, +the top window before attempting child-document fullscreen, so a +permissions-policy rejection inside the iframe does not consume the user +activation needed by the parent fallback. If the iframe itself grants fullscreen +but the parent does not listen for the message, the child-document fullscreen +fallback can still enter fullscreen. Standalone runtime pages use native +document fullscreen. Exit from cross-origin wrapper fullscreen posts +`tour-builder:exit-fullscreen` to the same parent/top targets. Top-level runtime pages can request document fullscreen from a user click, but cross-origin iframe embeds normally require `allow="fullscreen"` / -`allowfullscreen` or a parent listener that fullscreens the iframe element. +`allowfullscreen` or a parent/top listener that fullscreens the iframe element. After a parent iframe fullscreen request, `useRuntimeFullscreen` keeps the fullscreen button in its active state even when the child document does not expose `document.fullscreenElement`; native enter/exit events still clear or set @@ -1231,11 +1232,11 @@ actual browser operations to `runtimeFullscreen.actions.ts` and `lib/fullscreen.ts`: - enter standalone: request document fullscreen -- enter embedded: request same-origin embedding iframe fullscreen or post parent - `tour-builder:request-fullscreen`, then fall back to child-document fullscreen - when iframe permissions allow it +- enter embedded: request same-origin embedding iframe fullscreen or post + `tour-builder:request-fullscreen` to parent/top wrappers, then fall back to + child-document fullscreen when iframe permissions allow it - exit: exit native fullscreen when the child document owns it; otherwise post - `tour-builder:exit-fullscreen` to the parent wrapper + `tour-builder:exit-fullscreen` to parent/top wrappers - state: preserve active fullscreen state for parent iframe fullscreen requests that do not create a child `document.fullscreenElement` - events: listen to both `fullscreenchange` and `webkitfullscreenchange` diff --git a/frontend/src/lib/fullscreen.test.ts b/frontend/src/lib/fullscreen.test.ts index de33882..40a7d84 100644 --- a/frontend/src/lib/fullscreen.test.ts +++ b/frontend/src/lib/fullscreen.test.ts @@ -73,6 +73,44 @@ test('requestDocumentFullscreen asks the parent before child fullscreen when emb ]); }); +test('requestDocumentFullscreen also asks the top window when embedded in nested frames', async () => { + const calls: string[] = []; + const parent = { + postMessage: (message: unknown, targetOrigin: string) => { + calls.push(`parent:${targetOrigin}:${JSON.stringify(message)}`); + }, + }; + const top = { + postMessage: (message: unknown, targetOrigin: string) => { + calls.push(`top:${targetOrigin}:${JSON.stringify(message)}`); + }, + }; + + setBrowserGlobals({ + windowValue: { + parent, + top, + frameElement: null, + }, + documentValue: { + documentElement: { + requestFullscreen: async () => { + calls.push('document-fullscreen'); + }, + }, + }, + }); + + const entered = await requestDocumentFullscreen('runtime-global-control'); + + assert.equal(entered, true); + assert.deepEqual(calls, [ + 'parent:*:{"type":"tour-builder:request-fullscreen","source":"runtime-global-control"}', + 'top:*:{"type":"tour-builder:request-fullscreen","source":"runtime-global-control"}', + 'document-fullscreen', + ]); +}); + test('requestDocumentFullscreen returns parent request when embedded child fullscreen is blocked', async () => { const calls: string[] = []; const parent = { diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts index dab39b6..c71966d 100644 --- a/frontend/src/lib/fullscreen.ts +++ b/frontend/src/lib/fullscreen.ts @@ -74,13 +74,16 @@ const requestSameOriginEmbeddingFrameFullscreen = const requestParentFullscreen = (source: string): boolean => { if (!isEmbeddedWindow()) return false; - window.parent.postMessage( - { - type: 'tour-builder:request-fullscreen', - source, - }, - '*', - ); + const message = { + type: 'tour-builder:request-fullscreen', + source, + }; + + window.parent.postMessage(message, '*'); + + if (window.top && window.top !== window.parent) { + window.top.postMessage(message, '*'); + } return true; }; @@ -127,13 +130,16 @@ export const requestDocumentFullscreen = async ( export const requestParentFullscreenExit = (source = 'runtime'): boolean => { if (!isEmbeddedWindow()) return false; - window.parent.postMessage( - { - type: 'tour-builder:exit-fullscreen', - source, - }, - '*', - ); + const message = { + type: 'tour-builder:exit-fullscreen', + source, + }; + + window.parent.postMessage(message, '*'); + + if (window.top && window.top !== window.parent) { + window.top.postMessage(message, '*'); + } return true; };