@ericchen1990/pano-view 3.5.0 → 3.6.1

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.
@@ -0,0 +1,606 @@
1
+ # 弹窗使用与自定义样式
2
+
3
+ [English](./dialogs.md) | **简体中文** · [组件参考](../README.zh-CN.md#dom-overlay-与弹窗)
4
+
5
+ 本指南说明如何为热点添加图文、视频、画册和 iframe 弹窗。viewer 负责提供支持全屏的挂载位置,应用负责选择热点、内容和样式。示例使用当前 Overlay API 与可选的 `@ericchen1990/pano-view/ui` 入口。在本仓库运行 `pnpm dev`,打开 `/overlays` 可体验三种接入方式。
6
+
7
+ ## 目录
8
+
9
+ - [选择接入方式](#choose-an-approach)
10
+ - [快速开始](#quick-start)
11
+ - [按热点选择内容](#choose-content-by-hotspot)
12
+ - [定制默认弹窗外观](#customize-the-default-appearance)
13
+ - [组合自己的布局](#compose-a-different-layout)
14
+ - [接入已有 Dialog](#use-an-existing-dialog)
15
+ - [二级 Portal](#nested-portals)
16
+ - [全屏与生命周期](#fullscreen-and-lifecycle)
17
+ - [API 参考](#api-reference)
18
+ - [常见问题](#troubleshooting)
19
+
20
+ <a id="choose-an-approach"></a>
21
+ ## 选择接入方式
22
+
23
+ | 需求 | 推荐方式 | 应用负责 |
24
+ | --- | --- | --- |
25
+ | 直接使用弹窗,仅调整主题 | `PanoDialog` + CSS 变量 | 打开状态和内容 |
26
+ | 自定义标题栏、关闭按钮、底部操作或布局 | 组合 `PanoDialog*` 组件 | 结构、样式和内容 |
27
+ | 已有 Dialog 或设计系统 | `renderOverlay` + `usePanoOverlay` + `usePanoModal` | 弹窗实现、无障碍行为和样式 |
28
+
29
+ 默认弹窗与组成组件负责焦点、遮罩关闭、Escape 和 viewer 输入锁。第三方弹窗保留自己的无障碍行为;`usePanoModal` 只协调 viewer 输入。以上方式均不要求修改热点定义或项目 JSON。
30
+
31
+ <a id="quick-start"></a>
32
+ ## 快速开始
33
+
34
+ 按 [安装说明](../README.zh-CN.md#安装) 安装 viewer 与现有 peer dependencies。viewer 样式只需导入一次;使用默认弹窗外观时,再导入独立的 UI 样式:
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
+ 传入已加载的项目,并在项目中准备一个 ID 为 `details` 的热点;`loadPanoProject` 用法见[静态项目与 JSON 数据](../README.zh-CN.md#静态项目与-json-数据)。按自己的内容映射调整选中条件。只有应用接管点击时才调用 `preventDefault()`;其他热点继续使用默认的场景跳转或媒体播放。示例显式选择在切换场景时关闭弹窗,库本身不强制这种业务策略。
95
+
96
+ `PanoViewer.children` 是 Three.js 场景树。普通 DOM 弹窗放在 `renderOverlay` 中,不要与 `Sphere`、`ProjectScenes` 并列放进场景树。全屏变化时保持返回的组件树稳定;以全屏状态作为 key 会重置内容。
97
+
98
+ 多个热点的接入请继续看[下方完整映射示例](#choose-content-by-hotspot):一个弹窗复用五份内容,并提供对应的项目 JSON。
99
+
100
+ <a id="choose-content-by-hotspot"></a>
101
+ ## 按热点选择内容
102
+
103
+ 热点的标记类型描述它在全景中的外观,热点 ID 用于查找应用自己的弹窗内容。**一个 `PanoDialog` 是复用的外壳:选中的热点改变时,标题和内容随之改变。** 同一时刻展示一个选中热点的内容,关闭后再点击另一个背景热点。
104
+
105
+ ### 完整示例:五个热点,对应五份内容
106
+
107
+ 下方映射为每个场景配置两份不同的图文、一段视频、一组画册和一个 iframe。两个场景刻意复用同一组热点 ID,因此查找时同时使用 `sceneId` 和 `hotspot.id`,而不是按标记类型或内容类型选择。
108
+
109
+ | 场景 | 热点 ID | 内容类型 | 弹窗标题 |
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
+ 下面是完整源码,包含所有内容组件、映射、选中状态和点击处理,也是 `/overlays` 页面展示的代码。保存为 `multi-hotspot-example.tsx`,以 `project` 传入已加载的项目即可;在仓库外使用时,将 `/fixtures/...` 替换为自己的资源地址。
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
+ ### 在项目中注册对应的热点标记
227
+
228
+ 内容映射不会自动创建标记,项目中的场景 ID 和热点 ID 必须与映射键一致。以下是该示例的完整项目结构;将每个场景生成的 `viewer.json` 及资源放在对应目录,再按[静态项目与 JSON 数据](../README.zh-CN.md#静态项目与-json-数据)中的方式使用 `loadPanoProject` 加载。
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
+ 所有标记均使用 `type: "graphic"`。`POPUPS_BY_SCENE` 中的 `kind` 仅是应用用于说明内容和绘制映射表的元数据,不是新增的热点类型,也不需要写入项目 JSON。两个图文条目虽然同为 article,却拥有不同的文字和图片。
264
+
265
+ ### 点击后的数据流
266
+
267
+ 1. `onHotspotClick` 提供场景 ID 和热点 ID。`POPUPS_BY_SCENE[event.sceneId]?.[event.hotspot.id]` 查找对应内容定义。
268
+ 2. 没有配置时直接返回,不调用 `preventDefault()`。因此 `next-room` 标记仍执行默认场景跳转,其他未映射的媒体标记仍保留默认播放行为。
269
+ 3. 找到内容后调用 `preventDefault()` 取消该标记原本的默认动作,再用 `setSelected` 仅保存 `{ sceneId, hotspotId }`。
270
+ 4. 下一次渲染从选中状态派生出 `popup`。同一个 `PanoDialog` 接收新的 `popup.title` 和 `popup.body`,不必为每个热点创建单独的弹窗实例。
271
+ 5. 内容容器的 key 同时包含两个 ID。通过代码直接切换选中热点时,旧内容的表单/画册状态会重置并执行清理,包括暂停原视频;外层弹窗和焦点范围继续复用。
272
+ 6. 关闭弹窗或切换场景会清空 `selected`,卸载内容并释放模态锁。切换全屏不改变选中热点的 key,因此不会重置内容。
273
+
274
+ 在 `/overlays` 中分别打开 **Courtyard story** 和 **Garden details**,比较两个图文热点,再尝试视频、画册和指南。点击 **Go to next room** 进入 hall 后,同样的 `story` ID 会打开内容不同的 **Hall story**。viewer 下的映射表与可复制代码会展示实际对应关系。
275
+
276
+ `body` 接受任意 React 节点:原生媒体、业务组件、图文内容,或请求 API 数据的组件。加载中/错误状态在内容组件中处理,自定义播放器和订阅在 cleanup 中释放。如果内容来自 JSON,应将已校验的内容类型映射到 React 渲染器,不要将 React 节点写进 JSON。iframe 的权限按嵌入来源配置,第三方响应头可能禁止嵌入;视频自动播放需明确开启,并受浏览器策略限制。
277
+
278
+ <a id="customize-the-default-appearance"></a>
279
+ ## 定制默认弹窗外观
280
+
281
+ ### 1. 使用 CSS 变量设置整个 viewer 的主题
282
+
283
+ 使用快速开始示例中的 `my-tour` 类,将以下样式写入 `tour.css`,并在默认 UI 样式之后加载:
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
+ 变量会继承到当前 viewer 内所有弹窗及其遮罩,全屏时仍然有效。不同 viewer 可使用不同的主题类。
301
+
302
+ | 变量 | 默认值 | 作用范围 |
303
+ | --- | --- | --- |
304
+ | `--pano-dialog-width` | `640px` | 内容期望宽度,仍受可见空间上限约束 |
305
+ | `--pano-dialog-background` | `#161b22` | 内容背景 |
306
+ | `--pano-dialog-color` | `#f4f4f4` | 内容和关闭按钮文字颜色 |
307
+ | `--pano-dialog-backdrop` | `rgb(0 0 0 / 65%)` | 遮罩背景 |
308
+ | `--pano-dialog-border` | `1px solid #39424e` | 内容边框 |
309
+ | `--pano-dialog-radius` | `12px` | 内容圆角 |
310
+ | `--pano-dialog-shadow` | `0 16px 64px rgb(0 0 0 / 45%)` | 内容阴影 |
311
+ | `--pano-dialog-padding` | `20px` | 标题栏、内容间距和关闭按钮上/右边距 |
312
+ | `--pano-dialog-muted-color` | `#a7b4c4` | 描述文字颜色 |
313
+ | `--pano-dialog-focus-color` | `#ffb300` | 默认关闭按钮的焦点轮廓颜色 |
314
+
315
+ `--pano-dialog-width` 由组件的结构布局读取,即使不导入可选样式文件也有效;其余外观变量由该样式文件使用。`--pano-dialog-available-width` 和 `--pano-dialog-available-height` 由组件计算,请调整期望宽度变量,不要覆盖这些测量值。
316
+
317
+ **变量放置位置很重要。** `PanoDialog.className` 和 `PanoDialog.style` 作用于 Content。Backdrop 是并列节点,因此在 Content 上设置 `style={{ "--pano-dialog-backdrop": "..." }}` 不会改变遮罩。遮罩变量应放在 viewer/祖先上,或组合 `PanoDialogBackdrop` 并直接设置它的 `style`。
318
+
319
+ ### 2. 用 className 或内联 style 定制单个弹窗
320
+
321
+ `className` 作用于内容面板,可通过默认类名定位后代:
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
+ TypeScript 中动态设置 CSS 自定义属性时,为其补充兼容类型:
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
+ 默认面板的定位、宽度、最大高度、flex 布局、overflow 和 pointer events 使用内联结构样式。普通 CSS 的 width 规则不能覆盖内联宽度,应使用 `--pano-dialog-width`。需要改变其他结构样式时,可显式传入 `style` 并保留可见空间约束,或使用组成组件。CSS Modules 可用 `.dialog :global(.pano-dialog-title)` 定位后代;需要继承的主题变量放在 viewer 的 module 类上。
350
+
351
+ | 默认选择器 | 含义 |
352
+ | --- | --- |
353
+ | `.pano-dialog-backdrop` | 遮罩,位于 Content 外部 |
354
+ | `.pano-dialog-content` | 内容面板,接收 `PanoDialog.className` 和 `style` |
355
+ | `.pano-dialog-header` | 默认标题与描述的容器 |
356
+ | `.pano-dialog-title` | 标题 |
357
+ | `.pano-dialog-description` | 可选描述 |
358
+ | `.pano-dialog-close` | 默认 36×36 关闭按钮,使用居中的 SVG |
359
+ | `.pano-dialog-body` | 默认可滚动内容区 |
360
+
361
+ <a id="compose-a-different-layout"></a>
362
+ ## 组合自己的布局
363
+
364
+ 需要底部操作、不同的关闭控件或完全控制内容结构时,使用组成组件。下面示例提供自己的视觉样式,不需要 `ui/styles.css`。从 `renderOverlay` 返回 `CustomLayoutDialog`,传入打开状态和内容即可。
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 已经获取 viewer 模态锁,不要再为它调用 `usePanoModal`。Backdrop 与 Content 分别创建自己的 Portal,无需再包一层 Portal。Content 保留测量定位和二级 Portal 容器,明确不接受 `asChild` 与 `forceMount`;Title、Description 和 Close 保留 Radix 的组合 props。始终提供有意义的 Title;如果没有描述,省略 Description,并给 Content 传入 `aria-describedby={undefined}`。
423
+
424
+ 需要阻止 Escape 或外部点击关闭时,在 Content 上使用 `onEscapeKeyDown` 或 `onInteractOutside`,并针对需要阻止的行为调用 `event.preventDefault()`,同时保留可用的 Close 控件。自定义初始焦点和返回焦点使用 `onOpenAutoFocus` / `onCloseAutoFocus`;调用 `preventDefault()` 后由应用接管焦点处理。这些高级事件位于 `PanoDialogContent`,不属于便捷组件 `PanoDialog` 的 props。
425
+
426
+ <a id="use-an-existing-dialog"></a>
427
+ ## 接入已有 Dialog
428
+
429
+ viewer 不强制应用使用可选 UI 组件。第三方接入需要连接三件事:本地 Portal 容器、测量布局与 viewer 输入协调。下面以 Radix 为例,组件应从 `renderOverlay` 渲染。应用直接导入 `@radix-ui/react-dialog` 时,需要将它安装为自己的直接依赖。
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
+ 等待容器非 null 后再创建 Portal;viewer 挂载期间也不能让第三方 Portal 回退到 `document.body`。已有 MUI、Ant Design、shadcn 或其他封装,需要能配置等价的挂载目标;按实际暴露的 Portal API 适配。仅将组件放进 `renderOverlay`,不会自动修复它内部写死的 body Portal。
494
+
495
+ `usePanoModal(open)` 暂停 viewer 输入与自动旋转,让背景 viewer 界面 inert 并关闭其右键菜单。它不会为第三方弹窗提供焦点约束、无障碍名称、关闭控件或页面滚动管理。锁按 viewer 隔离、引用计数;关闭弹窗不会释放其他弹窗或转场持有的锁。hook 应在子组件中无条件调用,不要直接写在 `renderOverlay` 回调中。
496
+
497
+ <a id="nested-portals"></a>
498
+ ## 二级 Portal
499
+
500
+ 在 `PanoDialogContent` 内,`usePanoOverlay().portalContainer` 指向该弹窗内部的二级容器。菜单、Popover、Select、Tooltip 使用它,才能同时留在弹窗的焦点/交互范围与全屏子树中。下面以原生 React Portal 展示一个简单 Tooltip:
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
+ 示例中的固定坐标仅用于说明挂载位置;正式的浮层应根据触发元素定位。可交互 Popover 的实际面板需要 `pointer-events: auto`。第三方 Dialog 可按上一示例在 Content 内创建本地容器,并使用 `PanoOverlayPortalProvider` 包裹内容。它只替换后代读取到的 Portal 容器;`size` 和 `visibleRect` 仍描述原始 viewer,不能直接用作二级浮层的定位坐标。
529
+
530
+ <a id="fullscreen-and-lifecycle"></a>
531
+ ## 全屏与生命周期
532
+
533
+ - 全屏是 viewer 全局功能。按钮放在全局 viewer 界面,如快速开始示例所示;弹窗本身不包含全屏按钮。
534
+ - 普通模式和元素全屏复用同一个 viewer 内的稳定容器。不要在切换时更换容器或 key,表单值、视频节点和 iframe 文档才可以保持挂载。
535
+ - `size` 测量完整 viewer;`visibleRect` 表示其与 visual viewport 的可见交集,考虑页面滚动和视口变化。内置弹窗在可见矩形中居中,并预留 16px 边距。默认内容区滚动,标题栏和 Close 保持可用;关闭按钮上侧和右侧采用同一个 padding 变量,上侧额外考虑安全区域。
536
+ - 关闭会卸载内容,重新打开会重新初始化。需要跨关闭保留的表单状态应提升到弹窗外部;热点间直接切换时使用热点 ID 作为内容 key,重置该热点独立的状态。
537
+ - 浏览器可能优先用 Escape 退出全屏。iframe 内的键盘事件不会冒泡到外层页面。保留可见的关闭按钮,并在目标浏览器验证原生或第三方播放器行为。
538
+ - 此处是普通查看模式与标准/WebKit 元素全屏下的 DOM 界面。原生视频全屏和沉浸式 WebXR 使用不同的呈现区域。
539
+
540
+ 在 Next.js 中,将需要状态的 viewer/弹窗放入 Client Component(`"use client"`)。保持服务端与客户端初始状态一致,不在 render 中读取 `document`。初始挂载时 Portal 容器为 null 属于正常情况。
541
+
542
+ <a id="api-reference"></a>
543
+ ## API 参考
544
+
545
+ ### `/ui` 导出的 `PanoDialog`
546
+
547
+ | Prop | 类型 / 默认值 | 含义 |
548
+ | --- | --- | --- |
549
+ | `open` | `boolean`,必填 | 受控打开状态 |
550
+ | `onOpenChange` | `(open: boolean) => void`,必填 | 宿主更新状态;关闭时可清空选中的热点 |
551
+ | `title` | `ReactNode`,必填 | 可见且可被辅助技术读取的标题 |
552
+ | `children` | `ReactNode`,必填 | 应用内容 |
553
+ | `description` | `ReactNode`,可选 | 标题下的描述文字 |
554
+ | `closeLabel` | `string`,`"Close"` | 默认关闭按钮的无障碍名称 |
555
+ | `className` | `string`,可选 | Content 上的附加类,不作用于 Backdrop |
556
+ | `style` | `CSSProperties`,可选 | Content 样式;自定义属性需补充兼容的 TS 类型 |
557
+
558
+ `PanoDialog` 是便捷组件,不会透传所有 Radix props。额外的关闭策略、焦点事件、DOM 和布局选项使用组成组件设置。
559
+
560
+ ### `/ui` 导出的组成组件
561
+
562
+ | 导出 | 约定 |
563
+ | --- | --- |
564
+ | `PanoDialogRoot` | 必填 `open`、`onOpenChange`、`children`;模态行为与 viewer 锁 |
565
+ | `PanoDialogBackdrop` | Radix Overlay props 与 div ref,不接受 `forceMount`;自带 Portal |
566
+ | `PanoDialogContent` | Radix Content props 与 div ref,不接受 `asChild` / `forceMount`;测量布局、焦点恢复、二级 Portal 容器 |
567
+ | `PanoDialogTitle` | Radix Title props/ref;提供有意义的标题 |
568
+ | `PanoDialogDescription` | Radix Description props/ref;没有描述时省略,并关闭 `aria-describedby` |
569
+ | `PanoDialogClose` | Radix Close props/ref;负责关闭,不自带便捷组件的图标或 class |
570
+
571
+ 每个组件均有对应的 `*Props` 类型。Root 受控,不提供 `defaultOpen`、`modal` 或 Trigger 配置。Close、Title 和 Description 支持 Radix `asChild` 与合适的 DOM 组件组合。
572
+
573
+ ### 主入口导出的 Overlay API
574
+
575
+ | API | 约定 |
576
+ | --- | --- |
577
+ | `PanoViewer.renderOverlay(context)` | 返回 Canvas 之外的 DOM React 内容;不是调用 hook 的位置 |
578
+ | `usePanoOverlay()` | 为 DOM 后代提供相同 context;在 Overlay provider 外调用会抛错 |
579
+ | `usePanoModal(open)` | 获取/释放当前 viewer 的模态输入锁,无返回值 |
580
+ | `PanoOverlayPortalProvider` | 必填 `container: HTMLDivElement \| null`、`children`;只覆盖后代的 Portal 目标 |
581
+ | `PanoOverlayRenderContext` | 导出的 context 类型 |
582
+
583
+ | Context 字段 | 含义 |
584
+ | --- | --- |
585
+ | `portalContainer` | viewer 内稳定的 `HTMLDivElement \| null`;在 Content 内为二级目标 |
586
+ | `size` | 完整 viewer 的 `{ width, height }`,单位为 CSS 像素 |
587
+ | `visibleRect` | viewer 与视口可见交集的 `{ left, top, width, height }`,以 viewer 为坐标基准 |
588
+ | `isFullscreen` | viewer 是否处于相关的元素全屏状态 |
589
+
590
+ <a id="troubleshooting"></a>
591
+ ## 常见问题
592
+
593
+ | 现象 | 检查与处理 |
594
+ | --- | --- |
595
+ | 全屏时弹窗不见了 | 检查实际 Portal DOM。弹窗和所有二级浮层均应挂在 viewer 内;单纯提高 body Portal 的 `z-index` 不会将它移入全屏子树。 |
596
+ | 遮罩颜色变量无效 | 放在 viewer/祖先上,或直接给组成组件 Backdrop 设置 `style`;Content 样式无法影响并列节点。 |
597
+ | 面板宽度 class 无效 | 使用 `--pano-dialog-width`;普通样式规则不能覆盖内联结构宽度。 |
598
+ | 标题栏、内容区或关闭按钮定制无效 | 在 Content 的 class 下定位后代,或使用组成组件。`PanoDialog` 没有 header/body/close 的 slot props。 |
599
+ | 不导入默认样式仍然有定位 | 结构定位、尺寸和指针行为按设计使用内联样式。省略 `ui/styles.css` 后提供自己的外观;完全控制 DOM 时接入自己的 Dialog。 |
600
+ | 长内容把关闭按钮挤走 | 组成布局中,header/footer 设置 `flex-shrink: 0`,body 设置 `min-height: 0; overflow: auto`,并保持 Content 的测量尺寸约束。 |
601
+ | 切换全屏后视频/表单重置 | 移除依赖全屏状态的 key、容器切换、不同弹窗实现间的条件切换,以及在 render 回调内定义的组件函数。 |
602
+ | 自定义弹窗后面的全景仍会移动 | 在 `renderOverlay` 下的自定义组件中调用 `usePanoModal(open)`;默认/组成组件的 Root 已经处理。 |
603
+ | 二级 Popover 被隐藏或不可点击 | 使用 `usePanoOverlay()` 返回的 Content 内部容器,并在实际面板上开启 pointer events。 |
604
+ | hook 提示缺少 provider | 从 `renderOverlay` 返回自定义 DOM 组件;不要在 `PanoViewer` 的父组件或回调本身调用 Overlay hook。 |
605
+
606
+ 接入时应确认普通模式与真实元素全屏、viewer 部分滚出屏幕、键盘开关与焦点恢复、长内容、二级 Portal、媒体清理及目标 Safari/移动设备的表现。最大化浏览器窗口不能替代 Fullscreen API 验证。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericchen1990/pano-view",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "description": "React components for panoramic viewing experiences.",
5
5
  "author": "Eric Chen",
6
6
  "license": "MIT",
@@ -34,6 +34,7 @@
34
34
  ],
35
35
  "files": [
36
36
  "dist",
37
+ "docs",
37
38
  "README.md",
38
39
  "README.zh-CN.md",
39
40
  "LICENSE"
@@ -52,9 +53,16 @@
52
53
  "types": "./dist/data.d.ts",
53
54
  "import": "./dist/data.mjs",
54
55
  "require": "./dist/data.cjs"
55
- }
56
+ },
57
+ "./ui": {
58
+ "types": "./dist/ui.d.ts",
59
+ "import": "./dist/ui.mjs",
60
+ "require": "./dist/ui.cjs"
61
+ },
62
+ "./ui/styles.css": "./dist/ui/styles.css"
56
63
  },
57
64
  "dependencies": {
65
+ "@radix-ui/react-dialog": "^1.3.1",
58
66
  "@types/three": "^0.185.0",
59
67
  "howler": "^2.2.4"
60
68
  },
@@ -80,7 +88,7 @@
80
88
  "typescript": "^5.9.3"
81
89
  },
82
90
  "scripts": {
83
- "build": "node -e \"require('node:fs').rmSync('dist', {recursive:true,force:true})\" && tsup && node -e \"require('node:fs').copyFileSync('src/styles.css', 'dist/styles.css')\"",
91
+ "build": "node -e \"require('node:fs').rmSync('dist', {recursive:true,force:true})\" && tsup && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.mkdirSync('dist/ui',{recursive:true});fs.copyFileSync('src/ui/styles.css','dist/ui/styles.css')\"",
84
92
  "typecheck": "tsc --noEmit",
85
93
  "test": "tsup --config test/tsup.config.ts && node --test .test-dist/*.js",
86
94
  "pack:check": "npm pack --dry-run && node test/pack-smoke.mjs"