fixed fullscreen mode

This commit is contained in:
Dmitri 2026-07-09 19:27:39 +02:00
parent 76871f4542
commit 0a73efef22
4 changed files with 230 additions and 60 deletions

View File

@ -7,6 +7,7 @@ sound mute, and offline mode. They are not regular canvas elements and are not
stored in `tour_pages.ui_schema_json.elements`. stored in `tour_pages.ui_schema_json.elements`.
They behave like runtime chrome: They behave like runtime chrome:
- button dimensions use canvas-relative percentages - button dimensions use canvas-relative percentages
- button positions are relative to the visible canvas using `xPercent/yPercent` - button positions are relative to the visible canvas using `xPercent/yPercent`
- constructor edit mode renders controls even when hidden - constructor edit mode renders controls even when hidden
@ -16,6 +17,7 @@ They behave like runtime chrome:
## Implementation Files ## Implementation Files
Backend: Backend:
- `backend/src/db/models/global_ui_control_defaults.js` - `backend/src/db/models/global_ui_control_defaults.js`
- `backend/src/db/models/project_ui_control_settings.js` - `backend/src/db/models/project_ui_control_settings.js`
- `backend/src/db/api/global_ui_control_defaults.ts` - `backend/src/db/api/global_ui_control_defaults.ts`
@ -26,6 +28,7 @@ Backend:
- `backend/src/services/project_ui_control_settings.ts` - `backend/src/services/project_ui_control_settings.ts`
Frontend: Frontend:
- `frontend/src/types/uiControls.ts` - `frontend/src/types/uiControls.ts`
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx` - `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
- `frontend/src/components/Runtime/RuntimeControls.tsx` - `frontend/src/components/Runtime/RuntimeControls.tsx`
@ -58,27 +61,39 @@ hardcoded defaults are only a safety fallback.
Each control (`fullscreen`, `sound`, `offline`) supports: Each control (`fullscreen`, `sound`, `offline`) supports:
| Field | Purpose | | Field | Purpose |
|-------|---------| | ------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `enabled` | Disable action while keeping the control selectable in edit mode | | `enabled` | Disable action while keeping the control selectable in edit mode |
| `hidden` | Hide in runtime/preview; still render as ghost in constructor edit mode | | `hidden` | Hide in runtime/preview; still render as ghost in constructor edit mode |
| `xPercent`, `yPercent` | Canvas-relative position | | `xPercent`, `yPercent` | Canvas-relative position |
| `anchor` | Which button point is placed at the coordinate | | `anchor` | Which button point is placed at the coordinate |
| `buttonSizePercent`, `iconSizePercent`, `borderRadiusPercent` | Canvas-width-relative dimensions | | `buttonSizePercent`, `iconSizePercent`, `borderRadiusPercent` | Canvas-width-relative dimensions |
| `defaultIconUrl`, `activeIconUrl` | Optional custom asset URL/storage key per state | | `defaultIconUrl`, `activeIconUrl` | Optional custom asset URL/storage key per state |
| `defaultBackgroundColor`, `activeBackgroundColor` | Button background color per state | | `defaultBackgroundColor`, `activeBackgroundColor` | Button background color per state |
| `defaultBorderColor`, `activeBorderColor` | Button border color per state | | `defaultBorderColor`, `activeBorderColor` | Button border color per state |
| `hoverBackgroundColor`, `color` | Shared hover background and icon color | | `hoverBackgroundColor`, `color` | Shared hover background and icon color |
| `opacity`, `boxShadow`, `zIndex`, `order` | Presentation and initial ordering | | `opacity`, `boxShadow`, `zIndex`, `order` | Presentation and initial ordering |
Active state means downloaded/offline for the offline button, muted for the Active state means downloaded/offline for the offline button, muted for the
sound button, and fullscreen for the fullscreen button. sound button, and fullscreen for the fullscreen button.
When runtime is embedded in an iframe, the fullscreen control first tries the When runtime is embedded in an iframe, the fullscreen control requests
browser Fullscreen API for the presentation document, then tries to fullscreen fullscreen on the embedding iframe first when same-origin access is available.
the embedding iframe when same-origin access is available. For cross-origin For cross-origin wrappers it posts `tour-builder:request-fullscreen` to
wrappers it posts `tour-builder:request-fullscreen` to `window.parent`; exit `window.parent` before attempting child-document fullscreen, because a
attempts post `tour-builder:exit-fullscreen`. 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`.
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.
The runtime keeps a local fullscreen-active state after an embedded parent The runtime keeps a local fullscreen-active state after an embedded parent
fullscreen request because the child document may not expose a local fullscreen request because the child document may not expose a local
`document.fullscreenElement` while the parent iframe is fullscreen. The hook `document.fullscreenElement` while the parent iframe is fullscreen. The hook
@ -86,16 +101,24 @@ listens to both `fullscreenchange` and `webkitfullscreenchange` for native
fullscreen exits. fullscreen exits.
```html ```html
<iframe id="tour-frame" src="https://example.com/p/project" allow="fullscreen" allowfullscreen></iframe> <iframe
id="tour-frame"
src="https://example.com/p/project"
allow="fullscreen"
allowfullscreen
></iframe>
<script> <script>
window.addEventListener('message', async (event) => { window.addEventListener("message", async (event) => {
const frame = document.getElementById('tour-frame'); const frame = document.getElementById("tour-frame");
if (event.data?.type === 'tour-builder:request-fullscreen') { if (event.data?.type === "tour-builder:request-fullscreen") {
await frame?.requestFullscreen?.(); await frame?.requestFullscreen?.();
} }
if (event.data?.type === 'tour-builder:exit-fullscreen' && document.fullscreenElement) { if (
event.data?.type === "tour-builder:exit-fullscreen" &&
document.fullscreenElement
) {
await document.exitFullscreen(); await document.exitFullscreen();
} }
}); });
@ -109,11 +132,11 @@ non-16:9 projects as well.
Default global values: Default global values:
| Control | X | Y | Size | Icon | Radius | Order | | Control | X | Y | Size | Icon | Radius | Order |
|---------|---|---|------|------|--------|-------| | ------------ | ------- | --- | ----- | ------ | ------ | ----- |
| `offline` | `89.5` | `6` | `2.6` | `1.35` | `0.42` | `1` | | `offline` | `89.5` | `6` | `2.6` | `1.35` | `0.42` | `1` |
| `fullscreen` | `92.75` | `6` | `2.6` | `1.35` | `0.42` | `2` | | `fullscreen` | `92.75` | `6` | `2.6` | `1.35` | `0.42` | `2` |
| `sound` | `96` | `6` | `2.6` | `1.35` | `0.42` | `3` | | `sound` | `96` | `6` | `2.6` | `1.35` | `0.42` | `3` |
## APIs ## APIs
@ -143,6 +166,7 @@ Constructor renders controls through `RuntimeControls` with `editMode=true`.
Selecting a system control opens the element editor in system-control mode. Selecting a system control opens the element editor in system-control mode.
System controls: System controls:
- can be dragged to update `xPercent/yPercent` - can be dragged to update `xPercent/yPercent`
- can be edited with coordinate inputs - can be edited with coordinate inputs
- can choose separate default and active custom icons - can choose separate default and active custom icons
@ -162,6 +186,7 @@ rendered as ghost controls so authors can reselect and unhide them.
Global defaults are listed at `/global-ui-control-defaults` and edited per Global defaults are listed at `/global-ui-control-defaults` and edited per
control: control:
- `/global-ui-control-defaults/offline` - `/global-ui-control-defaults/offline`
- `/global-ui-control-defaults/fullscreen` - `/global-ui-control-defaults/fullscreen`
- `/global-ui-control-defaults/sound` - `/global-ui-control-defaults/sound`
@ -171,6 +196,7 @@ Project-level overrides are edited from
The per-control global pages and project page use `UiControlsSettingsForm`, a The per-control global pages and project page use `UiControlsSettingsForm`, a
typed editor with the same tab model used by element defaults: typed editor with the same tab model used by element defaults:
- **General Settings**: enabled/hidden state, canvas-relative position, anchor, - **General Settings**: enabled/hidden state, canvas-relative position, anchor,
order, and default/active icon URLs order, and default/active icon URLs
- **CSS Styles**: button/icon sizing, radius, icon color, background colors, - **CSS Styles**: button/icon sizing, radius, icon color, background colors,

View File

@ -165,10 +165,18 @@ across screens for projects with the same canvas ratio.
Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty
values fall back to the built-in MDI icons. values fall back to the built-in MDI icons.
Embedded runtime fullscreen uses the same wrapper fallback as Info Panel image Embedded runtime fullscreen requests the embedding iframe first when same-origin
detail fullscreen: native document fullscreen first, same-origin iframe access is available. For cross-origin wrappers it posts
fullscreen second, then parent `tour-builder:request-fullscreen` postMessage. `tour-builder:request-fullscreen` to the parent before attempting
Exit from cross-origin wrapper fullscreen posts `tour-builder:exit-fullscreen`. 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`.
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.
After a parent iframe fullscreen request, `useRuntimeFullscreen` keeps the After a parent iframe fullscreen request, `useRuntimeFullscreen` keeps the
fullscreen button in its active state even when the child document does not fullscreen button in its active state even when the child document does not
expose `document.fullscreenElement`; native enter/exit events still clear or set expose `document.fullscreenElement`; native enter/exit events still clear or set
@ -641,11 +649,11 @@ const handleElementClick = useCallback(
### Navigation Helpers (`lib/navigationHelpers.ts`) ### Navigation Helpers (`lib/navigationHelpers.ts`)
| Helper | Purpose | | Helper | Purpose |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `resolveNavigationTarget(element, pages)` | Resolves forward targets by slug; for back buttons, resolves history and returns transition fields from the incoming forward element | | `resolveNavigationTarget(element, pages)` | Resolves forward targets by slug; for back buttons, resolves history and returns transition fields from the incoming forward element |
| `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) | | `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) |
| `getNavigationDirection(element)` | Returns `'forward'` or `'back'` based on `navType` or element type | | `getNavigationDirection(element)` | Returns `'forward'` or `'back'` based on `navType` or element type |
**Note:** Navigation elements store `targetPageSlug` (not UUID) because slugs are consistent across environments (dev/stage/production). Back navigation uses page history and the incoming forward element's pre-generated `reverseVideoUrl`; runtime does not perform client-side reverse generation. **Note:** Navigation elements store `targetPageSlug` (not UUID) because slugs are consistent across environments (dev/stage/production). Back navigation uses page history and the incoming forward element's pre-generated `reverseVideoUrl`; runtime does not perform client-side reverse generation.
@ -843,7 +851,7 @@ useEffect(() => {
| **useBackgroundTransition** | Shared hook handles crossfade (non-video nav) | | **useBackgroundTransition** | Shared hook handles crossfade (non-video nav) |
| **isBackgroundReady state** | Tracks when new page background is fully rendered | | **isBackgroundReady state** | Tracks when new page background is fully rendered |
| **pendingTransitionComplete state** | Signals video ended, waiting for background | | **pendingTransitionComplete state** | Signals video ended, waiting for background |
| **Blob URL support** | Enables direct playback of preloaded forward/reverse videos | | **Blob URL support** | Enables direct playback of preloaded forward/reverse videos |
| **Video cleanup** | `removeAttribute('src')` + `load()` prevents memory leaks | | **Video cleanup** | `removeAttribute('src')` + `load()` prevents memory leaks |
| **Navigation blocking** | `isTransitionBlocking()` prevents navigation during playback | | **Navigation blocking** | `isTransitionBlocking()` prevents navigation during playback |
@ -1222,8 +1230,10 @@ Runtime fullscreen is owned by `useRuntimeFullscreen`. The hook delegates the
actual browser operations to `runtimeFullscreen.actions.ts` and actual browser operations to `runtimeFullscreen.actions.ts` and
`lib/fullscreen.ts`: `lib/fullscreen.ts`:
- enter: request document fullscreen, then same-origin embedding iframe - enter standalone: request document fullscreen
fullscreen, then parent `tour-builder:request-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
- exit: exit native fullscreen when the child document owns it; otherwise post - 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 the parent wrapper
- state: preserve active fullscreen state for parent iframe fullscreen requests - state: preserve active fullscreen state for parent iframe fullscreen requests

View File

@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestDocumentFullscreen } from './fullscreen';
const setBrowserGlobals = ({
windowValue,
documentValue,
}: {
windowValue: unknown;
documentValue: unknown;
}) => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: windowValue,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: documentValue,
});
};
test('requestDocumentFullscreen uses document fullscreen for standalone pages', async () => {
const calls: string[] = [];
const windowValue: { parent?: unknown } = {};
windowValue.parent = windowValue;
setBrowserGlobals({
windowValue,
documentValue: {
documentElement: {
requestFullscreen: async () => {
calls.push('document-fullscreen');
},
},
},
});
const entered = await requestDocumentFullscreen('runtime-global-control');
assert.equal(entered, true);
assert.deepEqual(calls, ['document-fullscreen']);
});
test('requestDocumentFullscreen asks the parent before child fullscreen when embedded', async () => {
const calls: string[] = [];
const parent = {
postMessage: (message: unknown, targetOrigin: string) => {
calls.push(`post:${targetOrigin}:${JSON.stringify(message)}`);
},
};
setBrowserGlobals({
windowValue: {
parent,
frameElement: null,
},
documentValue: {
documentElement: {
requestFullscreen: async () => {
calls.push('document-fullscreen');
},
},
},
});
const entered = await requestDocumentFullscreen('runtime-global-control');
assert.equal(entered, true);
assert.deepEqual(calls, [
'post:*:{"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 = {
postMessage: (message: unknown, targetOrigin: string) => {
calls.push(`post:${targetOrigin}:${JSON.stringify(message)}`);
},
};
setBrowserGlobals({
windowValue: {
parent,
frameElement: null,
},
documentValue: {
documentElement: {
requestFullscreen: async () => {
calls.push('document-fullscreen');
throw new TypeError('Disallowed by permissions policy');
},
},
},
});
const entered = await requestDocumentFullscreen('runtime-global-control');
assert.equal(entered, true);
assert.deepEqual(calls, [
'post:*:{"type":"tour-builder:request-fullscreen","source":"runtime-global-control"}',
'document-fullscreen',
]);
});

View File

@ -32,6 +32,10 @@ export const exitNativeFullscreen = async (): Promise<void> => {
const isEmbeddedWindow = (): boolean => window.parent !== window; const isEmbeddedWindow = (): boolean => window.parent !== window;
type FullscreenCapableElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void>;
};
export const requestElementFullscreen = async ( export const requestElementFullscreen = async (
element: HTMLElement, element: HTMLElement,
): Promise<void> => { ): Promise<void> => {
@ -40,11 +44,8 @@ export const requestElementFullscreen = async (
return; return;
} }
const webkitRequestFullscreen = ( const webkitRequestFullscreen = (element as FullscreenCapableElement)
element as HTMLElement & { .webkitRequestFullscreen;
webkitRequestFullscreen?: () => Promise<void>;
}
).webkitRequestFullscreen;
if (webkitRequestFullscreen) { if (webkitRequestFullscreen) {
await webkitRequestFullscreen.call(element); await webkitRequestFullscreen.call(element);
@ -54,25 +55,24 @@ export const requestElementFullscreen = async (
throw new Error('Fullscreen API is not available'); throw new Error('Fullscreen API is not available');
}; };
export const requestEmbeddingFrameFullscreen = async ( const requestSameOriginEmbeddingFrameFullscreen =
source = 'runtime', async (): Promise<boolean> => {
): Promise<boolean> => { try {
if (!isEmbeddedWindow()) return false; const frame = window.frameElement as FullscreenCapableElement | null;
try { if (frame) {
const frame = window.frameElement as await requestElementFullscreen(frame);
| (HTMLElement & { return true;
webkitRequestFullscreen?: () => Promise<void>; }
}) } catch {
| null; // Cross-origin parents are not directly accessible from the iframe.
if (frame) {
await requestElementFullscreen(frame);
return true;
} }
} catch {
// Cross-origin parents are not directly accessible from the iframe. return false;
} };
const requestParentFullscreen = (source: string): boolean => {
if (!isEmbeddedWindow()) return false;
window.parent.postMessage( window.parent.postMessage(
{ {
@ -85,9 +85,37 @@ export const requestEmbeddingFrameFullscreen = async (
return true; return true;
}; };
const requestEmbeddedDocumentFullscreen = async (
source: string,
): Promise<boolean> => {
if (await requestSameOriginEmbeddingFrameFullscreen()) return true;
const parentRequestSent = requestParentFullscreen(source);
try {
await requestElementFullscreen(document.documentElement);
return true;
} catch {
return parentRequestSent;
}
};
export const requestEmbeddingFrameFullscreen = async (
source = 'runtime',
): Promise<boolean> => {
if (!isEmbeddedWindow()) return false;
if (await requestSameOriginEmbeddingFrameFullscreen()) return true;
return requestParentFullscreen(source);
};
export const requestDocumentFullscreen = async ( export const requestDocumentFullscreen = async (
source = 'runtime', source = 'runtime',
): Promise<boolean> => { ): Promise<boolean> => {
if (isEmbeddedWindow()) {
return requestEmbeddedDocumentFullscreen(source);
}
try { try {
await requestElementFullscreen(document.documentElement); await requestElementFullscreen(document.documentElement);
return true; return true;