fixed fullscreen mode
This commit is contained in:
parent
76871f4542
commit
0a73efef22
@ -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`.
|
||||
|
||||
They behave like runtime chrome:
|
||||
|
||||
- button dimensions use canvas-relative percentages
|
||||
- button positions are relative to the visible canvas using `xPercent/yPercent`
|
||||
- constructor edit mode renders controls even when hidden
|
||||
@ -16,6 +17,7 @@ They behave like runtime chrome:
|
||||
## Implementation Files
|
||||
|
||||
Backend:
|
||||
|
||||
- `backend/src/db/models/global_ui_control_defaults.js`
|
||||
- `backend/src/db/models/project_ui_control_settings.js`
|
||||
- `backend/src/db/api/global_ui_control_defaults.ts`
|
||||
@ -26,6 +28,7 @@ Backend:
|
||||
- `backend/src/services/project_ui_control_settings.ts`
|
||||
|
||||
Frontend:
|
||||
|
||||
- `frontend/src/types/uiControls.ts`
|
||||
- `frontend/src/components/UiControls/UiControlsSettingsForm.tsx`
|
||||
- `frontend/src/components/Runtime/RuntimeControls.tsx`
|
||||
@ -58,27 +61,39 @@ hardcoded defaults are only a safety fallback.
|
||||
|
||||
Each control (`fullscreen`, `sound`, `offline`) supports:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `enabled` | Disable action while keeping the control selectable in edit mode |
|
||||
| `hidden` | Hide in runtime/preview; still render as ghost in constructor edit mode |
|
||||
| `xPercent`, `yPercent` | Canvas-relative position |
|
||||
| `anchor` | Which button point is placed at the coordinate |
|
||||
| `buttonSizePercent`, `iconSizePercent`, `borderRadiusPercent` | Canvas-width-relative dimensions |
|
||||
| `defaultIconUrl`, `activeIconUrl` | Optional custom asset URL/storage key per state |
|
||||
| `defaultBackgroundColor`, `activeBackgroundColor` | Button background color per state |
|
||||
| `defaultBorderColor`, `activeBorderColor` | Button border color per state |
|
||||
| `hoverBackgroundColor`, `color` | Shared hover background and icon color |
|
||||
| `opacity`, `boxShadow`, `zIndex`, `order` | Presentation and initial ordering |
|
||||
| Field | Purpose |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `enabled` | Disable action while keeping the control selectable in edit mode |
|
||||
| `hidden` | Hide in runtime/preview; still render as ghost in constructor edit mode |
|
||||
| `xPercent`, `yPercent` | Canvas-relative position |
|
||||
| `anchor` | Which button point is placed at the coordinate |
|
||||
| `buttonSizePercent`, `iconSizePercent`, `borderRadiusPercent` | Canvas-width-relative dimensions |
|
||||
| `defaultIconUrl`, `activeIconUrl` | Optional custom asset URL/storage key per state |
|
||||
| `defaultBackgroundColor`, `activeBackgroundColor` | Button background color per state |
|
||||
| `defaultBorderColor`, `activeBorderColor` | Button border color per state |
|
||||
| `hoverBackgroundColor`, `color` | Shared hover background and icon color |
|
||||
| `opacity`, `boxShadow`, `zIndex`, `order` | Presentation and initial ordering |
|
||||
|
||||
Active state means downloaded/offline for the offline button, muted for the
|
||||
sound button, and fullscreen for the fullscreen button.
|
||||
|
||||
When runtime is embedded in an iframe, the fullscreen control first tries the
|
||||
browser Fullscreen API for the presentation document, then tries to fullscreen
|
||||
the embedding iframe when same-origin access is available. For cross-origin
|
||||
wrappers it posts `tour-builder:request-fullscreen` to `window.parent`; exit
|
||||
attempts post `tour-builder:exit-fullscreen`.
|
||||
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`.
|
||||
|
||||
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
|
||||
fullscreen request because the child document may not expose a local
|
||||
`document.fullscreenElement` while the parent iframe is fullscreen. The hook
|
||||
@ -86,16 +101,24 @@ listens to both `fullscreenchange` and `webkitfullscreenchange` for native
|
||||
fullscreen exits.
|
||||
|
||||
```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>
|
||||
window.addEventListener('message', async (event) => {
|
||||
const frame = document.getElementById('tour-frame');
|
||||
window.addEventListener("message", async (event) => {
|
||||
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?.();
|
||||
}
|
||||
|
||||
if (event.data?.type === 'tour-builder:exit-fullscreen' && document.fullscreenElement) {
|
||||
if (
|
||||
event.data?.type === "tour-builder:exit-fullscreen" &&
|
||||
document.fullscreenElement
|
||||
) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
@ -109,11 +132,11 @@ non-16:9 projects as well.
|
||||
|
||||
Default global values:
|
||||
|
||||
| Control | X | Y | Size | Icon | Radius | Order |
|
||||
|---------|---|---|------|------|--------|-------|
|
||||
| `offline` | `89.5` | `6` | `2.6` | `1.35` | `0.42` | `1` |
|
||||
| `fullscreen` | `92.75` | `6` | `2.6` | `1.35` | `0.42` | `2` |
|
||||
| `sound` | `96` | `6` | `2.6` | `1.35` | `0.42` | `3` |
|
||||
| Control | X | Y | Size | Icon | Radius | Order |
|
||||
| ------------ | ------- | --- | ----- | ------ | ------ | ----- |
|
||||
| `offline` | `89.5` | `6` | `2.6` | `1.35` | `0.42` | `1` |
|
||||
| `fullscreen` | `92.75` | `6` | `2.6` | `1.35` | `0.42` | `2` |
|
||||
| `sound` | `96` | `6` | `2.6` | `1.35` | `0.42` | `3` |
|
||||
|
||||
## 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.
|
||||
|
||||
System controls:
|
||||
|
||||
- can be dragged to update `xPercent/yPercent`
|
||||
- can be edited with coordinate inputs
|
||||
- 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
|
||||
control:
|
||||
|
||||
- `/global-ui-control-defaults/offline`
|
||||
- `/global-ui-control-defaults/fullscreen`
|
||||
- `/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
|
||||
typed editor with the same tab model used by element defaults:
|
||||
|
||||
- **General Settings**: enabled/hidden state, canvas-relative position, anchor,
|
||||
order, and default/active icon URLs
|
||||
- **CSS Styles**: button/icon sizing, radius, icon color, background colors,
|
||||
|
||||
@ -165,10 +165,18 @@ across screens for projects with the same canvas ratio.
|
||||
|
||||
Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty
|
||||
values fall back to the built-in MDI icons.
|
||||
Embedded runtime fullscreen uses the same wrapper fallback as Info Panel image
|
||||
detail fullscreen: native document fullscreen first, same-origin iframe
|
||||
fullscreen second, then parent `tour-builder:request-fullscreen` postMessage.
|
||||
Exit from cross-origin wrapper fullscreen posts `tour-builder:exit-fullscreen`.
|
||||
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`.
|
||||
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
|
||||
fullscreen button in its active state even when the child document does not
|
||||
expose `document.fullscreenElement`; native enter/exit events still clear or set
|
||||
@ -641,11 +649,11 @@ const handleElementClick = useCallback(
|
||||
|
||||
### 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 |
|
||||
| `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) |
|
||||
| `getNavigationDirection(element)` | Returns `'forward'` or `'back'` based on `navType` or element type |
|
||||
| `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) |
|
||||
| `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.
|
||||
|
||||
@ -843,7 +851,7 @@ useEffect(() => {
|
||||
| **useBackgroundTransition** | Shared hook handles crossfade (non-video nav) |
|
||||
| **isBackgroundReady state** | Tracks when new page background is fully rendered |
|
||||
| **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 |
|
||||
| **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
|
||||
`lib/fullscreen.ts`:
|
||||
|
||||
- enter: request document fullscreen, then same-origin embedding iframe
|
||||
fullscreen, then parent `tour-builder:request-fullscreen`
|
||||
- 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
|
||||
- exit: exit native fullscreen when the child document owns it; otherwise post
|
||||
`tour-builder:exit-fullscreen` to the parent wrapper
|
||||
- state: preserve active fullscreen state for parent iframe fullscreen requests
|
||||
|
||||
106
frontend/src/lib/fullscreen.test.ts
Normal file
106
frontend/src/lib/fullscreen.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
@ -32,6 +32,10 @@ export const exitNativeFullscreen = async (): Promise<void> => {
|
||||
|
||||
const isEmbeddedWindow = (): boolean => window.parent !== window;
|
||||
|
||||
type FullscreenCapableElement = HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const requestElementFullscreen = async (
|
||||
element: HTMLElement,
|
||||
): Promise<void> => {
|
||||
@ -40,11 +44,8 @@ export const requestElementFullscreen = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const webkitRequestFullscreen = (
|
||||
element as HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
}
|
||||
).webkitRequestFullscreen;
|
||||
const webkitRequestFullscreen = (element as FullscreenCapableElement)
|
||||
.webkitRequestFullscreen;
|
||||
|
||||
if (webkitRequestFullscreen) {
|
||||
await webkitRequestFullscreen.call(element);
|
||||
@ -54,25 +55,24 @@ export const requestElementFullscreen = async (
|
||||
throw new Error('Fullscreen API is not available');
|
||||
};
|
||||
|
||||
export const requestEmbeddingFrameFullscreen = async (
|
||||
source = 'runtime',
|
||||
): Promise<boolean> => {
|
||||
if (!isEmbeddedWindow()) return false;
|
||||
const requestSameOriginEmbeddingFrameFullscreen =
|
||||
async (): Promise<boolean> => {
|
||||
try {
|
||||
const frame = window.frameElement as FullscreenCapableElement | null;
|
||||
|
||||
try {
|
||||
const frame = window.frameElement as
|
||||
| (HTMLElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
})
|
||||
| null;
|
||||
|
||||
if (frame) {
|
||||
await requestElementFullscreen(frame);
|
||||
return true;
|
||||
if (frame) {
|
||||
await requestElementFullscreen(frame);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin parents are not directly accessible from the iframe.
|
||||
}
|
||||
} 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(
|
||||
{
|
||||
@ -85,9 +85,37 @@ export const requestEmbeddingFrameFullscreen = async (
|
||||
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 (
|
||||
source = 'runtime',
|
||||
): Promise<boolean> => {
|
||||
if (isEmbeddedWindow()) {
|
||||
return requestEmbeddedDocumentFullscreen(source);
|
||||
}
|
||||
|
||||
try {
|
||||
await requestElementFullscreen(document.documentElement);
|
||||
return true;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user