@ericchen1990/pano-view 3.4.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +615 -73
- package/README.zh-CN.md +583 -71
- package/dist/data.cjs +1080 -0
- package/dist/data.cjs.map +1 -0
- package/dist/data.d.mts +581 -0
- package/dist/data.d.ts +581 -0
- package/dist/data.mjs +1057 -0
- package/dist/data.mjs.map +1 -0
- package/dist/index.cjs +9120 -8431
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +904 -625
- package/dist/index.d.ts +904 -625
- package/dist/index.mjs +9273 -8578
- package/dist/index.mjs.map +1 -1
- package/dist/ui/styles.css +70 -0
- package/dist/ui.cjs +195 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.mts +63 -0
- package/dist/ui.d.ts +63 -0
- package/dist/ui.mjs +152 -0
- package/dist/ui.mjs.map +1 -0
- package/docs/dialogs.md +606 -0
- package/docs/dialogs.zh-CN.md +606 -0
- package/package.json +20 -5
package/docs/dialogs.md
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
# Dialogs and custom overlay styling
|
|
2
|
+
|
|
3
|
+
**English** | [简体中文](./dialogs.zh-CN.md) · [Component reference](../README.md#dom-overlays-and-dialogs)
|
|
4
|
+
|
|
5
|
+
This guide covers hotspot dialogs with text/images, video, galleries and iframes. The viewer owns the fullscreen-safe mount point; your application owns the selected hotspot, dialog content and styling. The examples use the current Overlay API and the optional `@ericchen1990/pano-view/ui` entry. Run `pnpm dev` in this repository and open `/overlays` to try the three integration styles.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Choose an approach](#choose-an-approach)
|
|
10
|
+
- [Quick start](#quick-start)
|
|
11
|
+
- [Choose content by hotspot](#choose-content-by-hotspot)
|
|
12
|
+
- [Customize the default appearance](#customize-the-default-appearance)
|
|
13
|
+
- [Compose a different layout](#compose-a-different-layout)
|
|
14
|
+
- [Use an existing Dialog](#use-an-existing-dialog)
|
|
15
|
+
- [Nested portals](#nested-portals)
|
|
16
|
+
- [Fullscreen and lifecycle](#fullscreen-and-lifecycle)
|
|
17
|
+
- [API reference](#api-reference)
|
|
18
|
+
- [Troubleshooting](#troubleshooting)
|
|
19
|
+
|
|
20
|
+
<a id="choose-an-approach"></a>
|
|
21
|
+
## Choose an approach
|
|
22
|
+
|
|
23
|
+
| Need | Approach | What you own |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| A working modal with minor theme changes | `PanoDialog` + CSS variables | Open state and content |
|
|
26
|
+
| Different header, close button, footer or layout | Compose the `PanoDialog*` parts | Markup, styling and content |
|
|
27
|
+
| An existing Dialog / design system | `renderOverlay` + `usePanoOverlay` + `usePanoModal` | Dialog implementation, accessibility and styling |
|
|
28
|
+
|
|
29
|
+
The default and composed dialogs handle focus, backdrop dismissal, Escape and viewer input locking. A third-party dialog keeps its own accessibility behavior; `usePanoModal` only coordinates viewer input. None of these approaches changes hotspot definitions or project JSON.
|
|
30
|
+
|
|
31
|
+
<a id="quick-start"></a>
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
Install the viewer and its existing peer dependencies as described in [Install](../README.md#install). Import the viewer stylesheet once. Import the separate UI stylesheet when you want the default dialog appearance:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useState } from "react";
|
|
38
|
+
import {
|
|
39
|
+
PanoViewer,
|
|
40
|
+
ProjectScenes,
|
|
41
|
+
usePanoViewer,
|
|
42
|
+
type LoadedPanoProject,
|
|
43
|
+
type ProjectHotspotDefinition,
|
|
44
|
+
} from "@ericchen1990/pano-view";
|
|
45
|
+
import { PanoDialog } from "@ericchen1990/pano-view/ui";
|
|
46
|
+
import "@ericchen1990/pano-view/styles.css";
|
|
47
|
+
import "@ericchen1990/pano-view/ui/styles.css";
|
|
48
|
+
import "./tour.css";
|
|
49
|
+
|
|
50
|
+
export function Tour({ project }: { project: LoadedPanoProject }) {
|
|
51
|
+
const viewer = usePanoViewer();
|
|
52
|
+
const [selected, setSelected] = useState<ProjectHotspotDefinition | null>(null);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<PanoViewer
|
|
56
|
+
ref={viewer.ref}
|
|
57
|
+
className="my-tour"
|
|
58
|
+
style={{ height: 560 }}
|
|
59
|
+
renderOverlay={({ isFullscreen }) => (
|
|
60
|
+
<>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
style={{ position: "absolute", right: 16, bottom: 16, pointerEvents: "auto" }}
|
|
64
|
+
onClick={() => void viewer.toggleFullscreen()}
|
|
65
|
+
>
|
|
66
|
+
{isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
67
|
+
</button>
|
|
68
|
+
<PanoDialog
|
|
69
|
+
open={selected !== null}
|
|
70
|
+
onOpenChange={(open) => { if (!open) setSelected(null); }}
|
|
71
|
+
title={selected?.label ?? "Details"}
|
|
72
|
+
description="Discover more about this place."
|
|
73
|
+
closeLabel="Close details"
|
|
74
|
+
>
|
|
75
|
+
{selected ? <p key={selected.id}>Content for {selected.label ?? selected.id}</p> : null}
|
|
76
|
+
</PanoDialog>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<ProjectScenes
|
|
81
|
+
project={project}
|
|
82
|
+
onHotspotClick={(event) => {
|
|
83
|
+
if (event.hotspot.id !== "details") return;
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
setSelected(event.hotspot);
|
|
86
|
+
}}
|
|
87
|
+
onActiveSceneIdChange={() => setSelected(null)}
|
|
88
|
+
/>
|
|
89
|
+
</PanoViewer>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Supply a loaded project containing a hotspot with ID `details`; see [Static projects and JSON data](../README.md#static-projects-and-json-data) for `loadPanoProject`. Adapt the selection condition to your content mapping. Only call `preventDefault()` when handling a hotspot yourself: other hotspots retain their normal scene navigation or media playback. The example explicitly closes the dialog on scene changes; the library does not impose that policy.
|
|
95
|
+
|
|
96
|
+
`PanoViewer.children` is the Three.js scene tree. Put ordinary DOM dialogs in `renderOverlay`, not beside `Sphere` or `ProjectScenes` in that tree. Keep the returned component tree stable when `isFullscreen` changes; a key based on fullscreen state would reset its content.
|
|
97
|
+
|
|
98
|
+
For multiple hotspots, continue with the [complete mapping example below](#choose-content-by-hotspot): it reuses one dialog for five pieces of content and includes the matching project JSON.
|
|
99
|
+
|
|
100
|
+
<a id="choose-content-by-hotspot"></a>
|
|
101
|
+
## Choose content by hotspot
|
|
102
|
+
|
|
103
|
+
A hotspot's marker type describes its appearance in the panorama. Its ID chooses application-owned popup content. **One `PanoDialog` is a reusable shell: its title and body change with the selected hotspot.** The dialog shows one selection at a time; close it before choosing another background marker.
|
|
104
|
+
|
|
105
|
+
### Complete example: five hotspots, five pieces of content
|
|
106
|
+
|
|
107
|
+
The registry below contains two different articles, a video, a gallery and an iframe for each scene. Both scenes deliberately reuse the same hotspot IDs. The lookup therefore uses both `sceneId` and `hotspot.id`, not the marker type or content kind.
|
|
108
|
+
|
|
109
|
+
| Scene | Hotspot ID | Popup kind | Dialog title |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| `entrance` | `story` | article | Courtyard story |
|
|
112
|
+
| `entrance` | `details` | article | Garden details |
|
|
113
|
+
| `entrance` | `clip` | video | Courtyard video |
|
|
114
|
+
| `entrance` | `photos` | gallery | Courtyard gallery |
|
|
115
|
+
| `entrance` | `guide` | iframe | Courtyard guide |
|
|
116
|
+
| `hall` | `story` | article | Hall story |
|
|
117
|
+
|
|
118
|
+
The complete source defines every content component, the registry, selection state and click handler. It is also the source displayed in the `/overlays` playground. Save it as `multi-hotspot-example.tsx`, pass a loaded project as `project`, and replace the `/fixtures/...` URLs with your own assets outside this repository.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
122
|
+
import {
|
|
123
|
+
PanoViewer,
|
|
124
|
+
ProjectScenes,
|
|
125
|
+
type LoadedPanoProject,
|
|
126
|
+
} from "@ericchen1990/pano-view";
|
|
127
|
+
import { PanoDialog } from "@ericchen1990/pano-view/ui";
|
|
128
|
+
import "@ericchen1990/pano-view/styles.css";
|
|
129
|
+
import "@ericchen1990/pano-view/ui/styles.css";
|
|
130
|
+
|
|
131
|
+
function Story({ image, text }: { image: number; text: string }) {
|
|
132
|
+
return <article style={{ display: "grid", gap: 16 }}>
|
|
133
|
+
<img src={`/fixtures/panorama/panos/${image}/sphere.webp`} alt={text}
|
|
134
|
+
style={{ display: "block", width: "100%", height: "auto" }} />
|
|
135
|
+
<p>{text}</p>
|
|
136
|
+
<label style={{ display: "grid", gap: 8 }}>Your notes
|
|
137
|
+
<input placeholder="Keep a note while switching fullscreen"
|
|
138
|
+
style={{ padding: 8, border: "1px solid currentColor", background: "transparent", color: "inherit" }} />
|
|
139
|
+
</label>
|
|
140
|
+
</article>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Movie({ src }: { src: string }) {
|
|
144
|
+
const ref = useRef<HTMLVideoElement>(null);
|
|
145
|
+
const [failed, setFailed] = useState(false);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const video = ref.current;
|
|
148
|
+
return () => { video?.pause(); };
|
|
149
|
+
}, []);
|
|
150
|
+
if (failed) return <p role="alert">This video could not be loaded. Close and reopen to try again.</p>;
|
|
151
|
+
return <video ref={ref} src={src} controls playsInline preload="metadata"
|
|
152
|
+
onError={() => setFailed(true)} style={{ display: "block", width: "100%" }} />;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Gallery({ images }: { images: number[] }) {
|
|
156
|
+
const [index, setIndex] = useState(0);
|
|
157
|
+
if (!images.length) return <p>No images available.</p>;
|
|
158
|
+
return <figure style={{ margin: 0 }}>
|
|
159
|
+
<img src={`/fixtures/panorama/panos/${images[index % images.length]}/sphere.webp`}
|
|
160
|
+
alt={`Panorama ${images[index % images.length]}`}
|
|
161
|
+
style={{ display: "block", width: "100%", height: "auto" }} />
|
|
162
|
+
<figcaption style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: 12 }}>
|
|
163
|
+
<button type="button" onClick={() => setIndex((index + images.length - 1) % images.length)}>Previous image</button>
|
|
164
|
+
<span aria-live="polite">{index % images.length + 1} / {images.length}</span>
|
|
165
|
+
<button type="button" onClick={() => setIndex((index + 1) % images.length)}>Next image</button>
|
|
166
|
+
</figcaption>
|
|
167
|
+
</figure>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type PopupDefinition = {
|
|
171
|
+
kind: "article" | "video" | "gallery" | "iframe";
|
|
172
|
+
title: string;
|
|
173
|
+
body: ReactNode;
|
|
174
|
+
};
|
|
175
|
+
export type PopupSelection = { sceneId: string; hotspotId: string };
|
|
176
|
+
|
|
177
|
+
// Application content, separate from the marker's type and project JSON.
|
|
178
|
+
export const POPUPS_BY_SCENE: Partial<Record<string, Partial<Record<string, PopupDefinition>>>> = {
|
|
179
|
+
entrance: {
|
|
180
|
+
story: { kind: "article", title: "Courtyard story", body: <Story image={1} text="The courtyard opens onto a quiet garden and a path beside the water." /> },
|
|
181
|
+
details: { kind: "article", title: "Garden details", body: <Story image={2} text="Explore the planting, stone paths and small details around the garden." /> },
|
|
182
|
+
clip: { kind: "video", title: "Courtyard video", body: <Movie src="/fixtures/hotspots/loop.webm" /> },
|
|
183
|
+
photos: { kind: "gallery", title: "Courtyard gallery", body: <Gallery images={[1, 2, 3]} /> },
|
|
184
|
+
guide: { kind: "iframe", title: "Courtyard guide", body: <iframe title="Courtyard visitor guide" src="/fixtures/hotspots/embed.html" sandbox="allow-same-origin" style={{ width: "100%", height: 320, border: 0 }} /> },
|
|
185
|
+
},
|
|
186
|
+
hall: {
|
|
187
|
+
story: { kind: "article", title: "Hall story", body: <Story image={3} text="Inside the hall, follow the architecture and the view toward the next room." /> },
|
|
188
|
+
details: { kind: "article", title: "Hall details", body: <Story image={1} text="Discover how the hall connects the building to its surrounding landscape." /> },
|
|
189
|
+
clip: { kind: "video", title: "Hall video", body: <Movie src="/fixtures/hotspots/loop.webm" /> },
|
|
190
|
+
photos: { kind: "gallery", title: "Hall gallery", body: <Gallery images={[3, 1, 2]} /> },
|
|
191
|
+
guide: { kind: "iframe", title: "Hall guide", body: <iframe title="Hall visitor guide" src="/fixtures/hotspots/embed.html" sandbox="allow-same-origin" style={{ width: "100%", height: 320, border: 0 }} /> },
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The project must contain these hotspot IDs in entrance / hall.
|
|
196
|
+
export function MultiHotspotTour({ project }: { project: LoadedPanoProject }) {
|
|
197
|
+
const [selected, setSelected] = useState<PopupSelection | null>(null);
|
|
198
|
+
const popup = selected ? POPUPS_BY_SCENE[selected.sceneId]?.[selected.hotspotId] : undefined;
|
|
199
|
+
const contentKey = selected ? JSON.stringify([selected.sceneId, selected.hotspotId]) : "closed";
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<PanoViewer style={{ height: 560 }} renderOverlay={() => (
|
|
203
|
+
<PanoDialog
|
|
204
|
+
open={popup !== undefined}
|
|
205
|
+
onOpenChange={(open) => { if (!open) setSelected(null); }}
|
|
206
|
+
title={popup?.title ?? "Hotspot content"}
|
|
207
|
+
>
|
|
208
|
+
{popup ? <div key={contentKey}>{popup.body}</div> : null}
|
|
209
|
+
</PanoDialog>
|
|
210
|
+
)}>
|
|
211
|
+
<ProjectScenes
|
|
212
|
+
project={project}
|
|
213
|
+
onHotspotClick={(event) => {
|
|
214
|
+
const mapped = POPUPS_BY_SCENE[event.sceneId]?.[event.hotspot.id];
|
|
215
|
+
if (!mapped) return; // Keep default navigation/playback for unmapped hotspots.
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
setSelected({ sceneId: event.sceneId, hotspotId: event.hotspot.id });
|
|
218
|
+
}}
|
|
219
|
+
onActiveSceneIdChange={() => setSelected(null)}
|
|
220
|
+
/>
|
|
221
|
+
</PanoViewer>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Register the corresponding markers in the project
|
|
227
|
+
|
|
228
|
+
The mapping does not create markers. The project's scene IDs and hotspot IDs must match its keys. Here is a complete project structure for the example; place each generated `viewer.json` and its assets under the referenced scene directory, then load the project with `loadPanoProject` as described in [Static projects and JSON data](../README.md#static-projects-and-json-data).
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"schemaVersion": 1,
|
|
233
|
+
"initialSceneId": "entrance",
|
|
234
|
+
"scenes": [
|
|
235
|
+
{
|
|
236
|
+
"id": "entrance",
|
|
237
|
+
"source": {"viewerUrl": "entrance/viewer.json"},
|
|
238
|
+
"hotspots": [
|
|
239
|
+
{"id":"story","type":"graphic","label":"story","position":{"yaw":-28,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
240
|
+
{"id":"details","type":"graphic","label":"details","position":{"yaw":-14,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
241
|
+
{"id":"clip","type":"graphic","label":"clip","position":{"yaw":0,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
242
|
+
{"id":"photos","type":"graphic","label":"photos","position":{"yaw":14,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
243
|
+
{"id":"guide","type":"graphic","label":"guide","position":{"yaw":28,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
244
|
+
{"id":"next-room","type":"graphic","label":"Go to next room","position":{"yaw":0,"pitch":-18},"width":7,"height":7,"graphic":{"kind":"arrow","fill":"#67e8f9"},"action":{"type":"scene","sceneId":"hall"}}
|
|
245
|
+
]
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"id": "hall",
|
|
249
|
+
"source": {"viewerUrl": "hall/viewer.json"},
|
|
250
|
+
"hotspots": [
|
|
251
|
+
{"id":"story","type":"graphic","label":"story","position":{"yaw":-28,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
252
|
+
{"id":"details","type":"graphic","label":"details","position":{"yaw":-14,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
253
|
+
{"id":"clip","type":"graphic","label":"clip","position":{"yaw":0,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
254
|
+
{"id":"photos","type":"graphic","label":"photos","position":{"yaw":14,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
255
|
+
{"id":"guide","type":"graphic","label":"guide","position":{"yaw":28,"pitch":0},"width":7,"height":7,"graphic":{"kind":"circle","fill":"#df6b42"}},
|
|
256
|
+
{"id":"next-room","type":"graphic","label":"Go to next room","position":{"yaw":0,"pitch":-18},"width":7,"height":7,"graphic":{"kind":"arrow","fill":"#67e8f9"},"action":{"type":"scene","sceneId":"entrance"}}
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
]
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
All these markers use `type: "graphic"`. The `kind` field in `POPUPS_BY_SCENE` is application metadata for the content/mapping table, not a new hotspot type or a field to add to project JSON. The two article entries have different text and images despite sharing the same content kind.
|
|
264
|
+
|
|
265
|
+
### What happens after a click
|
|
266
|
+
|
|
267
|
+
1. `onHotspotClick` supplies the scene ID and hotspot ID. `POPUPS_BY_SCENE[event.sceneId]?.[event.hotspot.id]` finds its content definition.
|
|
268
|
+
2. If no definition exists, the handler returns without `preventDefault()`. The `next-room` marker therefore keeps its normal scene navigation; other unmapped media markers keep their default playback behavior.
|
|
269
|
+
3. If a definition exists, `preventDefault()` suppresses that marker's default action, and `setSelected` stores only `{ sceneId, hotspotId }`.
|
|
270
|
+
4. The next render derives `popup` from that selection. The same `PanoDialog` receives `popup.title` and `popup.body`, so there is no separate modal instance per marker.
|
|
271
|
+
5. The content wrapper's key includes both IDs. Direct programmatic selection changes reset the previous content's form/gallery state and run its cleanup, including pausing the old video. The outer dialog and its focus boundary remain shared.
|
|
272
|
+
6. Closing or changing scenes clears `selected`, unmounts the content and releases the modal lock. Fullscreen changes do not alter the selection key, so they do not reset the content.
|
|
273
|
+
|
|
274
|
+
In `/overlays`, open **Courtyard story** and **Garden details** to compare two articles, then try video, gallery and guide. Use **Go to next room** to enter the hall; the `story` ID now opens **Hall story** with different content. The mapping table below the viewer and the copyable code show the exact relationship.
|
|
275
|
+
|
|
276
|
+
`body` accepts any React node: native media, a business component, rich text, or a component that fetches API data. Handle loading/errors in that component and dispose custom players/subscriptions in cleanup. If the content comes from JSON, map a validated content kind to a React renderer rather than putting React nodes in JSON. Configure iframe permissions for the embed; third-party response headers can reject embedding. Video autoplay remains opt-in and subject to browser policy.
|
|
277
|
+
|
|
278
|
+
<a id="customize-the-default-appearance"></a>
|
|
279
|
+
## Customize the default appearance
|
|
280
|
+
|
|
281
|
+
### 1. Theme the entire viewer with CSS variables
|
|
282
|
+
|
|
283
|
+
Use the `my-tour` class from the quick start. Place this CSS in `tour.css`, loaded after the default UI stylesheet:
|
|
284
|
+
|
|
285
|
+
```css
|
|
286
|
+
.my-tour {
|
|
287
|
+
--pano-dialog-width: 760px;
|
|
288
|
+
--pano-dialog-background: #ffffff;
|
|
289
|
+
--pano-dialog-color: #17212b;
|
|
290
|
+
--pano-dialog-backdrop: rgb(0 0 0 / 55%);
|
|
291
|
+
--pano-dialog-border: 1px solid #d0d7de;
|
|
292
|
+
--pano-dialog-radius: 16px;
|
|
293
|
+
--pano-dialog-shadow: 0 20px 64px rgb(0 0 0 / 25%);
|
|
294
|
+
--pano-dialog-padding: 24px;
|
|
295
|
+
--pano-dialog-muted-color: #52616f;
|
|
296
|
+
--pano-dialog-focus-color: #005fcc;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The variables inherit into all dialogs and their backdrops in this viewer, including in fullscreen. Separate viewer classes can use separate themes.
|
|
301
|
+
|
|
302
|
+
| Variable | Default | Affects |
|
|
303
|
+
| --- | --- | --- |
|
|
304
|
+
| `--pano-dialog-width` | `640px` | Preferred content width, capped by visible space |
|
|
305
|
+
| `--pano-dialog-background` | `#161b22` | Content background |
|
|
306
|
+
| `--pano-dialog-color` | `#f4f4f4` | Content and close button text |
|
|
307
|
+
| `--pano-dialog-backdrop` | `rgb(0 0 0 / 65%)` | Backdrop background |
|
|
308
|
+
| `--pano-dialog-border` | `1px solid #39424e` | Content border |
|
|
309
|
+
| `--pano-dialog-radius` | `12px` | Content corners |
|
|
310
|
+
| `--pano-dialog-shadow` | `0 16px 64px rgb(0 0 0 / 45%)` | Content shadow |
|
|
311
|
+
| `--pano-dialog-padding` | `20px` | Header/body spacing and close button top/right inset |
|
|
312
|
+
| `--pano-dialog-muted-color` | `#a7b4c4` | Description text |
|
|
313
|
+
| `--pano-dialog-focus-color` | `#ffb300` | Default close button focus outline |
|
|
314
|
+
|
|
315
|
+
`--pano-dialog-width` works without the optional stylesheet because the content's structural layout reads it. The remaining appearance variables require that stylesheet. `--pano-dialog-available-width` and `--pano-dialog-available-height` are calculated by the component; use the preferred width variable rather than overriding these measurements.
|
|
316
|
+
|
|
317
|
+
**Variable placement matters.** `PanoDialog.className` and `PanoDialog.style` apply to Content. Backdrop is a sibling, so `style={{ "--pano-dialog-backdrop": "..." }}` on Content cannot theme it. Set backdrop variables on the viewer/ancestor, or compose `PanoDialogBackdrop` with its own `style`.
|
|
318
|
+
|
|
319
|
+
### 2. Style one dialog with a class or inline styles
|
|
320
|
+
|
|
321
|
+
`className` targets the panel; descendants can be styled through the shipped class names:
|
|
322
|
+
|
|
323
|
+
```css
|
|
324
|
+
.my-dialog .pano-dialog-title { font-size: 1.4rem; }
|
|
325
|
+
.my-dialog .pano-dialog-description { line-height: 1.6; }
|
|
326
|
+
.my-dialog .pano-dialog-body { line-height: 1.7; }
|
|
327
|
+
.my-dialog .pano-dialog-close { color: #b42318; }
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Use a typed CSS custom property in TypeScript when the style is dynamic:
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
import type { CSSProperties } from "react";
|
|
334
|
+
import { PanoDialog, type PanoDialogProps } from "@ericchen1990/pano-view/ui";
|
|
335
|
+
|
|
336
|
+
const style: CSSProperties & { "--pano-dialog-width": string } = {
|
|
337
|
+
"--pano-dialog-width": "720px",
|
|
338
|
+
background: "#fff",
|
|
339
|
+
color: "#17212b",
|
|
340
|
+
borderRadius: 8,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Render this component inside PanoViewer.renderOverlay.
|
|
344
|
+
export function StyledDialog(props: PanoDialogProps) {
|
|
345
|
+
return <PanoDialog {...props} className="my-dialog" style={style} />;
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The default panel has inline structural styles for positioning, width, maximum height, flex layout, overflow and pointer events. A normal CSS width rule cannot override its inline width; use `--pano-dialog-width`. For other structural changes, pass an explicit `style` while preserving visible-space bounds, or compose the parts. CSS Modules can target descendants with selectors such as `.dialog :global(.pano-dialog-title)`; put inherited theme variables on the viewer's module class.
|
|
350
|
+
|
|
351
|
+
| Default selector | Meaning |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `.pano-dialog-backdrop` | Backdrop, outside Content |
|
|
354
|
+
| `.pano-dialog-content` | Content panel; receives `PanoDialog.className` and `style` |
|
|
355
|
+
| `.pano-dialog-header` | Default title/description wrapper |
|
|
356
|
+
| `.pano-dialog-title` | Title |
|
|
357
|
+
| `.pano-dialog-description` | Optional description |
|
|
358
|
+
| `.pano-dialog-close` | Default 36×36 close button with centered SVG |
|
|
359
|
+
| `.pano-dialog-body` | Scrollable default content area |
|
|
360
|
+
|
|
361
|
+
<a id="compose-a-different-layout"></a>
|
|
362
|
+
## Compose a different layout
|
|
363
|
+
|
|
364
|
+
Use parts when you need a footer, a different close control, or full ownership of the markup. This example supplies its own visual CSS and does not need `ui/styles.css`. Render `CustomLayoutDialog` from `renderOverlay` and pass the desired state and content.
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import type { ReactNode } from "react";
|
|
368
|
+
import {
|
|
369
|
+
PanoDialogRoot, PanoDialogBackdrop, PanoDialogContent,
|
|
370
|
+
PanoDialogTitle, PanoDialogDescription, PanoDialogClose,
|
|
371
|
+
} from "@ericchen1990/pano-view/ui";
|
|
372
|
+
|
|
373
|
+
export function CustomLayoutDialog({ open, onOpenChange, children }: {
|
|
374
|
+
open: boolean;
|
|
375
|
+
onOpenChange: (open: boolean) => void;
|
|
376
|
+
children: ReactNode;
|
|
377
|
+
}) {
|
|
378
|
+
return (
|
|
379
|
+
<PanoDialogRoot open={open} onOpenChange={onOpenChange}>
|
|
380
|
+
<PanoDialogBackdrop style={{ background: "rgb(0 0 0 / 60%)" }} />
|
|
381
|
+
<PanoDialogContent className="custom-panel" style={{ overflow: "hidden" }}>
|
|
382
|
+
<header className="custom-header">
|
|
383
|
+
<div>
|
|
384
|
+
<PanoDialogTitle className="custom-title">Visitor guide</PanoDialogTitle>
|
|
385
|
+
<PanoDialogDescription className="custom-description">Explore this location.</PanoDialogDescription>
|
|
386
|
+
</div>
|
|
387
|
+
<PanoDialogClose className="custom-close" type="button" aria-label="Close guide">Close</PanoDialogClose>
|
|
388
|
+
</header>
|
|
389
|
+
<div className="custom-body">{children}</div>
|
|
390
|
+
<footer className="custom-footer">
|
|
391
|
+
<PanoDialogClose className="custom-close" type="button">Done</PanoDialogClose>
|
|
392
|
+
</footer>
|
|
393
|
+
</PanoDialogContent>
|
|
394
|
+
</PanoDialogRoot>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
```css
|
|
400
|
+
.custom-panel.pano-dialog-content {
|
|
401
|
+
background: #fff;
|
|
402
|
+
color: #17212b;
|
|
403
|
+
border: 1px solid #d0d7de;
|
|
404
|
+
border-radius: 12px;
|
|
405
|
+
}
|
|
406
|
+
.custom-header {
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: flex-start;
|
|
409
|
+
justify-content: space-between;
|
|
410
|
+
gap: 16px;
|
|
411
|
+
padding: 20px;
|
|
412
|
+
flex-shrink: 0;
|
|
413
|
+
}
|
|
414
|
+
.custom-title { margin: 0; font-size: 1.25rem; }
|
|
415
|
+
.custom-description { margin: 8px 0 0; color: #52616f; }
|
|
416
|
+
.custom-body { min-height: 0; overflow: auto; padding: 0 20px 20px; }
|
|
417
|
+
.custom-footer { flex-shrink: 0; padding: 16px 20px; border-top: 1px solid #d0d7de; }
|
|
418
|
+
.custom-close { padding: 8px 12px; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 6px; cursor: pointer; }
|
|
419
|
+
.custom-close:focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; }
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Root already acquires the viewer modal lock; do not call `usePanoModal` again for it. Backdrop and Content each create their own Portal into the viewer; do not wrap them in another Portal. Content retains its measured positioning and nested portal target. It deliberately excludes `asChild` and `forceMount`; Title, Description and Close keep their Radix composition props. Always supply Title. If there is no description, omit Description and pass `aria-describedby={undefined}` to Content.
|
|
423
|
+
|
|
424
|
+
To prevent Escape dismissal or outside dismissal, use Content's `onEscapeKeyDown` or `onInteractOutside` and call `event.preventDefault()` for the behavior you want to suppress. Keep a usable Close control. For custom initial/return focus, use `onOpenAutoFocus` / `onCloseAutoFocus`; calling `preventDefault()` transfers focus handling to you. These advanced hooks are on `PanoDialogContent`, not on the convenience `PanoDialog`.
|
|
425
|
+
|
|
426
|
+
<a id="use-an-existing-dialog"></a>
|
|
427
|
+
## Use an existing Dialog
|
|
428
|
+
|
|
429
|
+
The viewer does not require your application to use the optional UI components. A third-party integration needs three connections: the local Portal container, measured layout and viewer input coordination. The following Radix example is a component to render from `renderOverlay`. Install `@radix-ui/react-dialog` as a direct dependency in the consuming app if you import it there.
|
|
430
|
+
|
|
431
|
+
```tsx
|
|
432
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
433
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
434
|
+
import {
|
|
435
|
+
PanoOverlayPortalProvider, usePanoModal, usePanoOverlay,
|
|
436
|
+
} from "@ericchen1990/pano-view";
|
|
437
|
+
|
|
438
|
+
export function ExistingDialog({ open, onOpenChange, children }: {
|
|
439
|
+
open: boolean;
|
|
440
|
+
onOpenChange: (open: boolean) => void;
|
|
441
|
+
children: ReactNode;
|
|
442
|
+
}) {
|
|
443
|
+
const { portalContainer, visibleRect } = usePanoOverlay();
|
|
444
|
+
const [localContainer, setLocalContainer] = useState<HTMLDivElement | null>(null);
|
|
445
|
+
const previousFocus = useRef<HTMLElement | null>(null);
|
|
446
|
+
usePanoModal(open);
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
450
|
+
{portalContainer ? (
|
|
451
|
+
<Dialog.Portal container={portalContainer}>
|
|
452
|
+
<Dialog.Overlay style={{ position: "absolute", inset: 0, background: "rgb(0 0 0 / 60%)", pointerEvents: "auto" }} />
|
|
453
|
+
<Dialog.Content
|
|
454
|
+
aria-describedby={undefined}
|
|
455
|
+
style={{
|
|
456
|
+
position: "absolute",
|
|
457
|
+
left: visibleRect.left + visibleRect.width / 2,
|
|
458
|
+
top: visibleRect.top + visibleRect.height / 2,
|
|
459
|
+
transform: "translate(-50%, -50%)",
|
|
460
|
+
width: Math.min(720, Math.max(0, visibleRect.width - 32)),
|
|
461
|
+
maxHeight: Math.max(0, visibleRect.height - 32),
|
|
462
|
+
display: "flex", flexDirection: "column", overflow: "hidden",
|
|
463
|
+
background: "#fff", color: "#17212b", pointerEvents: "auto",
|
|
464
|
+
}}
|
|
465
|
+
onOpenAutoFocus={() => {
|
|
466
|
+
const active = portalContainer.ownerDocument.activeElement;
|
|
467
|
+
previousFocus.current = active instanceof HTMLElement ? active : null;
|
|
468
|
+
}}
|
|
469
|
+
onCloseAutoFocus={(event) => {
|
|
470
|
+
event.preventDefault();
|
|
471
|
+
const previous = previousFocus.current;
|
|
472
|
+
const canvas = portalContainer.closest("[data-pano-viewer]")?.querySelector("canvas");
|
|
473
|
+
const target = previous?.isConnected && previous !== previous.ownerDocument.body && !previous.closest("[inert]") ? previous : canvas;
|
|
474
|
+
if (target && !target.closest("[inert]")) target.focus({ preventScroll: true });
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
<header style={{ display: "flex", justifyContent: "space-between", gap: 16, padding: 20, flexShrink: 0 }}>
|
|
478
|
+
<Dialog.Title style={{ margin: 0 }}>Visitor guide</Dialog.Title>
|
|
479
|
+
<Dialog.Close type="button">Close</Dialog.Close>
|
|
480
|
+
</header>
|
|
481
|
+
<PanoOverlayPortalProvider container={localContainer}>
|
|
482
|
+
<div style={{ minHeight: 0, overflow: "auto", padding: 20 }}>{children}</div>
|
|
483
|
+
</PanoOverlayPortalProvider>
|
|
484
|
+
<div ref={setLocalContainer} style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 2 }} />
|
|
485
|
+
</Dialog.Content>
|
|
486
|
+
</Dialog.Portal>
|
|
487
|
+
) : null}
|
|
488
|
+
</Dialog.Root>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Wait for a non-null container. Do not let a third-party Portal default to `document.body` while the viewer is mounting. Existing MUI, Ant Design, shadcn or other wrappers need an equivalent configurable mount target; adapt or wrap the actual Portal API your component exposes. An unsupported wrapper is not made fullscreen-safe merely by rendering it in `renderOverlay`.
|
|
494
|
+
|
|
495
|
+
`usePanoModal(open)` pauses viewer input and automatic rotation, makes the background viewer UI inert and closes its context menu. It does not supply the third-party modal's focus trap, accessible names, close controls or page scroll handling. Locks are scoped per viewer and reference counted; releasing a modal does not release another modal's or transition's lock. Call hooks unconditionally in a child component, not directly in the `renderOverlay` callback.
|
|
496
|
+
|
|
497
|
+
<a id="nested-portals"></a>
|
|
498
|
+
## Nested portals
|
|
499
|
+
|
|
500
|
+
Inside `PanoDialogContent`, `usePanoOverlay().portalContainer` points to a nested container inside that dialog. Use it for menus, popovers, selects and tooltips so they stay inside the dialog's focus/interaction boundary and fullscreen subtree. This small tooltip uses React's Portal directly:
|
|
501
|
+
|
|
502
|
+
```tsx
|
|
503
|
+
import { useId, useState } from "react";
|
|
504
|
+
import { createPortal } from "react-dom";
|
|
505
|
+
import { usePanoOverlay } from "@ericchen1990/pano-view";
|
|
506
|
+
|
|
507
|
+
// Render inside PanoDialog's content, or inside PanoDialogContent.
|
|
508
|
+
export function DialogHelp() {
|
|
509
|
+
const { portalContainer } = usePanoOverlay();
|
|
510
|
+
const id = useId();
|
|
511
|
+
const [visible, setVisible] = useState(false);
|
|
512
|
+
return <>
|
|
513
|
+
<button type="button" aria-describedby={visible ? id : undefined}
|
|
514
|
+
onFocus={() => setVisible(true)} onBlur={() => setVisible(false)}
|
|
515
|
+
onPointerEnter={() => setVisible(true)} onPointerLeave={() => setVisible(false)}>
|
|
516
|
+
About this place
|
|
517
|
+
</button>
|
|
518
|
+
{visible && portalContainer ? createPortal(
|
|
519
|
+
<div id={id} role="tooltip" style={{ position: "absolute", top: 80, left: 16, padding: 12, background: "#17212b", color: "#fff", pointerEvents: "none" }}>
|
|
520
|
+
Follow the path to explore the next room.
|
|
521
|
+
</div>,
|
|
522
|
+
portalContainer,
|
|
523
|
+
) : null}
|
|
524
|
+
</>;
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
The fixed coordinates above only demonstrate the mount target; position production floating UI relative to its trigger. Interactive popovers need `pointer-events: auto` on their actual panel. For a third-party Dialog, create a local target inside Content and use `PanoOverlayPortalProvider` as in the preceding example. Its descendants receive the local target; `size` and `visibleRect` still describe the original viewer and are not nested-popup positioning coordinates.
|
|
529
|
+
|
|
530
|
+
<a id="fullscreen-and-lifecycle"></a>
|
|
531
|
+
## Fullscreen and lifecycle
|
|
532
|
+
|
|
533
|
+
- Fullscreen is a viewer-level function. Keep its button in global viewer UI, as in the quick start; the dialog itself has no fullscreen button.
|
|
534
|
+
- Normal and element fullscreen modes reuse one stable viewer-local container. Do not swap containers or keys on fullscreen changes. Form values, video elements and iframe documents can then remain mounted.
|
|
535
|
+
- `size` measures the complete viewer. `visibleRect` intersects it with the visual viewport, accounting for scrolling and viewport changes. The built-in dialog centers in this visible rectangle and reserves 16px gutters. The default body scrolls while the header and Close remain available; the close button's top/right spacing uses the same padding variable, with a safe-area adjustment at the top.
|
|
536
|
+
- Closing unmounts dialog content. Reopening starts fresh; store form state above the dialog if it must persist across closes. Use the hotspot ID as the content key to reset state when changing directly between hotspots.
|
|
537
|
+
- Escape may be handled by the browser to exit fullscreen. Key events inside an iframe do not bubble to the containing page. Keep a visible Close control and test native/third-party player behavior on your supported browsers.
|
|
538
|
+
- This is DOM UI for normal viewing and standard/WebKit element fullscreen. Native video fullscreen and immersive WebXR have separate presentation surfaces.
|
|
539
|
+
|
|
540
|
+
For Next.js, keep stateful viewer/dialog usage in a Client Component (`"use client"`). Keep initial server and client state consistent, and avoid reading `document` during render. A null Portal container during initial mount is expected.
|
|
541
|
+
|
|
542
|
+
<a id="api-reference"></a>
|
|
543
|
+
## API reference
|
|
544
|
+
|
|
545
|
+
### `PanoDialog` from `/ui`
|
|
546
|
+
|
|
547
|
+
| Prop | Type / default | Meaning |
|
|
548
|
+
| --- | --- | --- |
|
|
549
|
+
| `open` | `boolean`, required | Controlled visibility |
|
|
550
|
+
| `onOpenChange` | `(open: boolean) => void`, required | Host updates visibility; clear the selected hotspot when closing |
|
|
551
|
+
| `title` | `ReactNode`, required | Visible, accessible title |
|
|
552
|
+
| `children` | `ReactNode`, required | Application content |
|
|
553
|
+
| `description` | `ReactNode`, optional | Descriptive text below the title |
|
|
554
|
+
| `closeLabel` | `string`, `"Close"` | Accessible label for the default close button |
|
|
555
|
+
| `className` | `string`, optional | Additional class on Content, not on Backdrop |
|
|
556
|
+
| `style` | `CSSProperties`, optional | Content styles; custom properties need a compatible TS type |
|
|
557
|
+
|
|
558
|
+
`PanoDialog` is a convenience component, not a pass-through for every Radix prop. Use the parts for additional dismissal, focus, DOM and layout options.
|
|
559
|
+
|
|
560
|
+
### Composable parts from `/ui`
|
|
561
|
+
|
|
562
|
+
| Export | Contract |
|
|
563
|
+
| --- | --- |
|
|
564
|
+
| `PanoDialogRoot` | Required `open`, `onOpenChange`, `children`; modal behavior and viewer lock |
|
|
565
|
+
| `PanoDialogBackdrop` | Radix Overlay props and div ref, excluding `forceMount`; owns its Portal |
|
|
566
|
+
| `PanoDialogContent` | Radix Content props and div ref, excluding `asChild` / `forceMount`; measured layout, focus restoration and nested Portal target |
|
|
567
|
+
| `PanoDialogTitle` | Radix Title props/ref; provide a meaningful title |
|
|
568
|
+
| `PanoDialogDescription` | Radix Description props/ref; omit and disable `aria-describedby` when absent |
|
|
569
|
+
| `PanoDialogClose` | Radix Close props/ref; controls dismissal, without the convenience component's icon or class |
|
|
570
|
+
|
|
571
|
+
Each export has a corresponding `*Props` type. Root is controlled and does not expose `defaultOpen`, `modal` or Trigger configuration. Close, Title and Description can use Radix `asChild` composition with suitable DOM components.
|
|
572
|
+
|
|
573
|
+
### Overlay API from the main entry
|
|
574
|
+
|
|
575
|
+
| API | Contract |
|
|
576
|
+
| --- | --- |
|
|
577
|
+
| `PanoViewer.renderOverlay(context)` | Returns DOM React content outside the Canvas; not a hook invocation site |
|
|
578
|
+
| `usePanoOverlay()` | Same context for DOM descendants; throws outside the Overlay provider |
|
|
579
|
+
| `usePanoModal(open)` | Acquires/releases this viewer's modal input lock; no return value |
|
|
580
|
+
| `PanoOverlayPortalProvider` | Required `container: HTMLDivElement \| null` and `children`; overrides only the descendant Portal target |
|
|
581
|
+
| `PanoOverlayRenderContext` | Exported context type |
|
|
582
|
+
|
|
583
|
+
| Context field | Meaning |
|
|
584
|
+
| --- | --- |
|
|
585
|
+
| `portalContainer` | Stable viewer-local `HTMLDivElement \| null`; nested target inside Content |
|
|
586
|
+
| `size` | `{ width, height }` of the full viewer, in CSS pixels |
|
|
587
|
+
| `visibleRect` | `{ left, top, width, height }` of its viewport-visible intersection, relative to the viewer |
|
|
588
|
+
| `isFullscreen` | Whether the viewer is in an element fullscreen subtree |
|
|
589
|
+
|
|
590
|
+
<a id="troubleshooting"></a>
|
|
591
|
+
## Troubleshooting
|
|
592
|
+
|
|
593
|
+
| Symptom | Check / fix |
|
|
594
|
+
| --- | --- |
|
|
595
|
+
| Dialog disappears in fullscreen | Inspect the actual Portal node. Mount inside the viewer, including every nested floating component; raising `z-index` on a body Portal does not move it into the fullscreen subtree. |
|
|
596
|
+
| Backdrop variable has no effect | Set it on the viewer/ancestor, or pass `style` directly to a composed Backdrop; Content styles cannot reach a sibling. |
|
|
597
|
+
| Panel width class has no effect | Use `--pano-dialog-width`; a normal stylesheet rule does not override inline structural width. |
|
|
598
|
+
| Custom header/body/close changes do not apply | Use descendant selectors under your Content class, or compose parts. `PanoDialog` has no header/body/close slot props. |
|
|
599
|
+
| Unstyled mode still has layout styles | Structural sizing/positioning and pointer behavior are inline by design. Omit `ui/styles.css` and supply visual styling, or use your own Dialog for full DOM ownership. |
|
|
600
|
+
| Long content hides Close | In a composed dialog, keep header/footer `flex-shrink: 0`, body `min-height: 0; overflow: auto`, and Content within measured space. |
|
|
601
|
+
| Video/form resets on fullscreen changes | Remove fullscreen-dependent keys, container swaps, conditional dialog implementations and component definitions nested in render callbacks. |
|
|
602
|
+
| Viewer still moves behind a custom modal | Call `usePanoModal(open)` from the custom component under `renderOverlay`. Default/composed Root already does this. |
|
|
603
|
+
| Nested popover is hidden or cannot receive input | Pass the Content-local target from `usePanoOverlay()` and enable pointer events on its actual panel. |
|
|
604
|
+
| A hook reports a missing provider | Render the custom DOM component from `renderOverlay`; do not call Overlay hooks in the parent of `PanoViewer` or directly in its callback. |
|
|
605
|
+
|
|
606
|
+
Before integrating, verify normal and real element fullscreen modes, a partially scrolled viewer, keyboard open/close and focus restoration, long content, nested portals, media cleanup and your supported Safari/mobile devices. A maximized browser window does not exercise the Fullscreen API.
|