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`.
|
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`
|
||||||
@ -59,7 +62,7 @@ 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 |
|
||||||
@ -74,11 +77,23 @@ Each control (`fullscreen`, `sound`, `offline`) supports:
|
|||||||
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -110,7 +133,7 @@ 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` |
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
@ -642,7 +650,7 @@ 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 |
|
||||||
@ -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
|
||||||
|
|||||||
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;
|
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,17 +55,10 @@ 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> => {
|
|
||||||
if (!isEmbeddedWindow()) return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const frame = window.frameElement as
|
const frame = window.frameElement as FullscreenCapableElement | null;
|
||||||
| (HTMLElement & {
|
|
||||||
webkitRequestFullscreen?: () => Promise<void>;
|
|
||||||
})
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (frame) {
|
if (frame) {
|
||||||
await requestElementFullscreen(frame);
|
await requestElementFullscreen(frame);
|
||||||
@ -74,6 +68,12 @@ export const requestEmbeddingFrameFullscreen = async (
|
|||||||
// Cross-origin parents are not directly accessible from the iframe.
|
// 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(
|
||||||
{
|
{
|
||||||
type: 'tour-builder:request-fullscreen',
|
type: 'tour-builder:request-fullscreen',
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user