@ericchen1990/pano-view 3.5.0 → 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 +93 -0
- package/README.zh-CN.md +93 -0
- package/dist/data.cjs +202 -25
- package/dist/data.cjs.map +1 -1
- package/dist/data.d.mts +90 -8
- package/dist/data.d.ts +90 -8
- package/dist/data.mjs +202 -25
- package/dist/data.mjs.map +1 -1
- package/dist/index.cjs +1638 -1281
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +148 -11
- package/dist/index.d.ts +148 -11
- package/dist/index.mjs +1244 -883
- 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 +11 -3
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Try the components in the [live playground](https://pano-view-playground.vercel.
|
|
|
17
17
|
- [Examples](#examples)
|
|
18
18
|
- [Exported components](#exported-components)
|
|
19
19
|
- [PanoViewer](#panoviewer)
|
|
20
|
+
- [DOM overlays and dialogs](#dom-overlays-and-dialogs)
|
|
20
21
|
- [Sphere](#sphere)
|
|
21
22
|
- [PanoVideo](#panovideo)
|
|
22
23
|
- [Tile](#tile)
|
|
@@ -200,6 +201,50 @@ export function Tour() {
|
|
|
200
201
|
|
|
201
202
|
Configured `initialView` values apply at `onSceneReady`, defaulting missing fields to yaw 0, pitch 0, fov 75. With no view configured, the current view is retained. Hotspots hide during transitions. Control selection with `activeSceneId` / `onActiveSceneIdChange`. Call `event.preventDefault()` in `onHotspotClick` to cancel the default action. `onHotspotError` receives the scene, hotspot and original error (media errors may be events containing an `error` field). `renderHotspot(hotspot, context)` returns `undefined` for the default renderer or `null` to hide an item. Context includes `sceneId`, `playing`, `onClick`, `togglePlayback`, `reportError` and `renderDefault`.
|
|
202
203
|
|
|
204
|
+
### Hotspot actions and shared popup content
|
|
205
|
+
|
|
206
|
+
`ProjectHotspotAction` is a discriminated union. Keep `{ type: "scene", sceneId }` for navigation. Use `{ type: "view", view: { yaw, pitch, fov }, duration?: number }` to animate the current scene (700 ms by default, 0 for immediate movement), or `{ type: "popup", popupId }` to request shared content. Narrow `action.type` before reading `sceneId`. Explicit actions take priority over media playback and can all be cancelled with `onHotspotClick(event).preventDefault()`.
|
|
207
|
+
|
|
208
|
+
Add an optional project-level `popups: ProjectPopupDefinition[]`. Each entry has a unique `id`, a nonempty `title`, and ordered `blocks` with unique IDs within that popup:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"id": "story",
|
|
213
|
+
"title": "About this place",
|
|
214
|
+
"blocks": [
|
|
215
|
+
{ "id": "intro", "type": "text", "text": "Plain text, including line breaks." },
|
|
216
|
+
{ "id": "photo", "type": "image", "src": "media/photo.webp", "alt": "The main hall" },
|
|
217
|
+
{ "id": "film", "type": "video", "src": "media/movie.mp4", "poster": "media/photo.webp" },
|
|
218
|
+
{ "id": "voice", "type": "audio", "src": "media/story.mp3" }
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Store this content structure in `project.json`; store media as separate files. Popup paths resolve from the project directory, independently of each panorama's `viewer.json`. `loadPanoProject` returns resolved `popups`; its optional `resolvePopupAssetUrl(path, context)` receives `{ defaultUrl, popupId, blockId, field }`, where field is `src` or `poster`. Like the existing resolver, return an absolute URL or site-root path. Existing `resolveAssetUrl` callbacks continue to receive only their scene/hotspot resources. `resolveProjectPopupAssets` is also exported from `/data` for custom loaders. Request credentials for JSON do not automatically protect media.
|
|
224
|
+
|
|
225
|
+
`ProjectScenes.onPopupRequest({ sceneId, hotspot, popup })` delegates display to your overlay. A missing handler or missing popup reports through `onHotspotError`. The optional UI entry supplies `ProjectPopupDialog` without adding Dialog dependencies to the viewer core:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { useState } from "react";
|
|
229
|
+
import { PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
|
|
230
|
+
import { ProjectPopupDialog } from "@ericchen1990/pano-view/ui";
|
|
231
|
+
import "@ericchen1990/pano-view/ui/styles.css";
|
|
232
|
+
|
|
233
|
+
export function Tour({ project }: { project: LoadedPanoProject }) {
|
|
234
|
+
const [popup, setPopup] = useState<ProjectPopupDefinition | null>(null);
|
|
235
|
+
return <PanoViewer renderOverlay={() => (
|
|
236
|
+
<ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
|
|
237
|
+
)}>
|
|
238
|
+
<ProjectScenes project={project} onPopupRequest={({ popup }) => setPopup(popup)}
|
|
239
|
+
onActiveSceneIdChange={() => setPopup(null)} />
|
|
240
|
+
</PanoViewer>;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`ProjectPopupDialog` accepts `popup` (or `null` to close), `onOpenChange`, optional `closeLabel` and `onMediaError(block)`. It renders text as text, uses native audio/video controls without autoplay, and releases media when content closes or changes. It inherits `PanoDialog` fullscreen, sizing and focus behavior. Replace this component with your own overlay to customize rendering.
|
|
245
|
+
|
|
246
|
+
The schema remains version 1 and old projects remain valid. Projects using new actions require a viewer that supports them. `parsePanoProject(input, { draft: true })` is for authoring only: it permits empty scene lists and unfinished popup titles/media. Loading, saving and package export validate strictly. Host applications may save the same document in a database or map it to tables; the viewer has no database connection.
|
|
247
|
+
|
|
203
248
|
For individual panoramas and existing configs use the pure data entry. Next.js viewer components need a client boundary; server-side data loading uses `/data`:
|
|
204
249
|
|
|
205
250
|
```ts
|
|
@@ -492,6 +537,22 @@ Presets and hotspot rendering are in [Scenes](#scenes).
|
|
|
492
537
|
- Context menu helpers — `createPanoContextMenuPresets`, `composePanoContextMenuItems`, and related APIs ([`PanoContextMenu`](#panocontextmenu))
|
|
493
538
|
- Polygon / polyline validation — `validatePolygonVertices`, `validatePolylineVertices`, `unwrapPolygonVertices`
|
|
494
539
|
|
|
540
|
+
## DOM overlays and dialogs
|
|
541
|
+
|
|
542
|
+
Use `PanoViewer.renderOverlay` for ordinary DOM UI outside the Three.js tree. `usePanoOverlay()` supplies its stable Portal target, measured viewer/visible dimensions and fullscreen state. The optional `/ui` entry provides `PanoDialog` and composable parts; your application can also integrate an existing Dialog.
|
|
543
|
+
|
|
544
|
+
See the separate **[Dialog and styling guide](./docs/dialogs.md)** for complete examples, CSS variable defaults and placement, custom layouts, third-party integration, nested portals, lifecycle, API tables and troubleshooting. The guide is included in the npm package. Run `pnpm dev` and open `/overlays` for the local examples.
|
|
545
|
+
|
|
546
|
+
### Default dialog
|
|
547
|
+
|
|
548
|
+
Start with [Quick start](./docs/dialogs.md#quick-start), then [customize the default appearance](./docs/dialogs.md#customize-the-default-appearance). Default styling is an optional import from `@ericchen1990/pano-view/ui/styles.css`.
|
|
549
|
+
|
|
550
|
+
For multiple hotspots, use the [complete scene/hotspot-to-content example](./docs/dialogs.md#choose-content-by-hotspot): five content definitions, one shared dialog, matching project JSON and scene-local IDs.
|
|
551
|
+
|
|
552
|
+
### Compose parts or integrate an existing Dialog
|
|
553
|
+
|
|
554
|
+
Use [composable parts](./docs/dialogs.md#compose-a-different-layout) for a custom header/body/footer, or [connect an existing Dialog](./docs/dialogs.md#use-an-existing-dialog) through the local Portal container and `usePanoModal(open)`. Fullscreen belongs in viewer-level UI, not in dialog content.
|
|
555
|
+
|
|
495
556
|
## PanoViewer
|
|
496
557
|
|
|
497
558
|
`PanoViewer` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
|
|
@@ -2065,6 +2126,8 @@ Playback uses Web Audio via Howler.js (bundled; hosts do not install
|
|
|
2065
2126
|
format. `src` may be a string or an array of fallback URLs. The component uses Web Audio rather than HTML5
|
|
2066
2127
|
streaming to retain stereo panning.
|
|
2067
2128
|
|
|
2129
|
+
For extensionless sources such as `blob:` URLs, pass `format="mp3"` (or an array of container hints in source order). The optional `format` field is also supported by audio definitions in `project.json`.
|
|
2130
|
+
|
|
2068
2131
|
`playing` is controlled like `VideoHotspot`. `loop` defaults to `false`,
|
|
2069
2132
|
`muted` to `false`, `volume` to `1`, and `pauseWhenHidden` to `true` (pause
|
|
2070
2133
|
when the tab is hidden, resume if `playing` is still true). A first user
|
|
@@ -2361,3 +2424,33 @@ Start playback from a click or keyboard gesture, check supported formats and per
|
|
|
2361
2424
|
Serve files over local HTTP(S), rather than double-clicking `file://` HTML. HTTPS pages should load HTTPS assets. Use versioned asset directories and coordinate JSON/image caching so stale configs do not target replaced files. `UNSUPPORTED_SCHEMA_VERSION` requires a supported config version. Planar conversions have no `viewer.json` and need a planar viewer.
|
|
2362
2425
|
|
|
2363
2426
|
For conversion failures see [converter error codes and troubleshooting](../converter/README.md#error-codes-and-troubleshooting).
|
|
2427
|
+
|
|
2428
|
+
### Camera-center limits
|
|
2429
|
+
|
|
2430
|
+
`PanoViewer`, every `Scenes` scene, and each `project.json` scene accept optional `viewLimits`:
|
|
2431
|
+
|
|
2432
|
+
```ts
|
|
2433
|
+
viewLimits: {
|
|
2434
|
+
yaw: { min: 170, max: -170 },
|
|
2435
|
+
pitch: { min: -30, max: 45 },
|
|
2436
|
+
fov: { min: 40, max: 90 },
|
|
2437
|
+
}
|
|
2438
|
+
```
|
|
2439
|
+
|
|
2440
|
+
Angles are degrees. Yaw bounds are within −180…180; min > max crosses the seam. −180…180 allows a full circle. Pitch bounds are within −90…90, FOV within 1…179; both require min ≤ max. Equal bounds lock an axis. Scene axes override viewer axes individually; omitted axes inherit viewer settings, with existing `minFov` / `maxFov` (30 / 100 by default) as the FOV fallback. Bounds apply to the camera center, including pointer, touch, keyboard, inertia, auto rotation, gyro and view navigation APIs; they do not keep the entire visible image inside the bounds. Explicit axes have hard boundaries even with `bouncingLimits`. Limited yaw animations stay in the permitted arc, overriding a shorter path through forbidden angles. Immersive WebXR remains controlled by the headset.
|
|
2441
|
+
|
|
2442
|
+
Scene limits take effect when the incoming scene takes over, before its initial view. Live changes cancel navigation and inertia and constrain the current view without rewriting authored initial views or reloading textures. Removing scene limits restores viewer limits.
|
|
2443
|
+
|
|
2444
|
+
### Hotspot interaction events and custom actions
|
|
2445
|
+
|
|
2446
|
+
`events` maps `click`, `pointerenter`, and `pointerleave` to one action each. Every event supports `scene`, `view`, `popup`, and `{ type: "custom", actionId, params? }`. `params` is a JSON object; functions, credentials, API implementations and form definitions belong to the host. Parameter strings are not resolved or packaged as assets.
|
|
2447
|
+
|
|
2448
|
+
```json
|
|
2449
|
+
{"events":{"click":{"type":"scene","sceneId":"hall"},"pointerenter":{"type":"custom","actionId":"product.preview","params":{"visible":true}},"pointerleave":{"type":"custom","actionId":"product.preview","params":{"visible":false}}}}
|
|
2450
|
+
```
|
|
2451
|
+
|
|
2452
|
+
Legacy `action` remains a click fallback; `events.click` wins. Editing or clearing click removes the legacy fallback. Loading a project does not migrate it. New event configurations require an updated viewer (project schemaVersion remains 1). Media without a click action still toggles playback; hover has no default action.
|
|
2453
|
+
|
|
2454
|
+
`ProjectScenes.onHotspotEvent(event)` receives `trigger`, `sceneId`, `hotspot`, `interaction` and `preventDefault()`. On clicks it runs before the existing `onHotspotClick`; either callback can cancel the action. `onCustomAction(event)` additionally receives `action` and may return a Promise. Dispatch by the stable `action.actionId`, validate business parameters and throw for unknown actions. Missing handlers, synchronous exceptions and rejected Promises reach `onHotspotError` with `trigger`. Requests, authentication, cancellation, duplicate prevention and response ordering are host responsibilities; events dispatch independently without automatic retries.
|
|
2455
|
+
|
|
2456
|
+
Hover runs once per logical hotspot boundary, skips touch and keyboard focus, and respects visibility and pointer interaction settings. Scene changes, hiding and unmounting clear hover state without executing leave actions. Existing tooltip behavior is independent. Custom renderers must forward `context.onHoverChange(hovered, interaction)` as well as `context.onClick`; `context.renderDefault()` wires both automatically.
|
package/README.zh-CN.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- [示例](#示例)
|
|
18
18
|
- [导出组件](#导出组件)
|
|
19
19
|
- [PanoViewer](#panoviewer)
|
|
20
|
+
- [DOM Overlay 与弹窗](#dom-overlay-与弹窗)
|
|
20
21
|
- [Sphere](#sphere)
|
|
21
22
|
- [PanoVideo](#panovideo)
|
|
22
23
|
- [Tile](#tile)
|
|
@@ -196,6 +197,50 @@ export function Tour() {
|
|
|
196
197
|
|
|
197
198
|
配置了 `initialView` 的场景在 `onSceneReady` 时应用视角,缺省字段为 yaw 0、pitch 0、fov 75;未配置时保持当前视角。过渡期间热点隐藏。使用 `activeSceneId` / `onActiveSceneIdChange` 可控制当前场景。`onHotspotClick(event)` 中调用 `event.preventDefault()` 可取消默认动作;`onHotspotError` 提供场景、热点和原始错误;媒体的原始错误可能是带有 `error` 字段的事件。`renderHotspot(hotspot, context)` 返回 `undefined` 使用默认渲染,返回 `null` 隐藏;context 包含 `sceneId`、`playing`、`onClick`、`togglePlayback`、`reportError` 和 `renderDefault`。
|
|
198
199
|
|
|
200
|
+
### 热点动作与共享弹窗内容
|
|
201
|
+
|
|
202
|
+
`ProjectHotspotAction` 是可辨别联合类型。场景跳转保持 `{ type: "scene", sceneId }`;`{ type: "view", view: { yaw, pitch, fov }, duration?: number }` 调整当前场景视角,默认动画 700 毫秒,0 表示立即应用;`{ type: "popup", popupId }` 请求共享内容。读取 `sceneId` 前先判断 `action.type`。显式动作优先于媒体播放,三种动作均可由 `onHotspotClick(event).preventDefault()` 取消。
|
|
203
|
+
|
|
204
|
+
项目新增可选 `popups: ProjectPopupDefinition[]`。每项包含唯一 `id`、非空 `title` 和有序 `blocks`;内容块 ID 在弹窗内唯一:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"id": "story",
|
|
209
|
+
"title": "About this place",
|
|
210
|
+
"blocks": [
|
|
211
|
+
{ "id": "intro", "type": "text", "text": "Plain text, including line breaks." },
|
|
212
|
+
{ "id": "photo", "type": "image", "src": "media/photo.webp", "alt": "The main hall" },
|
|
213
|
+
{ "id": "film", "type": "video", "src": "media/movie.mp4", "poster": "media/photo.webp" },
|
|
214
|
+
{ "id": "voice", "type": "audio", "src": "media/story.mp3" }
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
内容结构存在 `project.json`,媒体保留为独立文件。弹窗路径相对项目目录解析,不跟随各全景的 `viewer.json`。`loadPanoProject` 返回解析后的 `popups`;新增可选 `resolvePopupAssetUrl(path, context)`,上下文是 `{ defaultUrl, popupId, blockId, field }`,field 为 `src` 或 `poster`;返回绝对 URL 或站点根路径。原有 `resolveAssetUrl` 回调继续只处理场景/热点资源。自定义加载器也可使用 `/data` 导出的 `resolveProjectPopupAssets`。JSON 请求的凭证不会自动保护媒体资源。
|
|
220
|
+
|
|
221
|
+
`ProjectScenes.onPopupRequest({ sceneId, hotspot, popup })` 将展示交给宿主 Overlay。缺少处理器或目标弹窗时通过 `onHotspotError` 报错。独立 UI 入口提供 `ProjectPopupDialog`,查看器主入口不引入 Dialog 依赖:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { useState } from "react";
|
|
225
|
+
import { PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
|
|
226
|
+
import { ProjectPopupDialog } from "@ericchen1990/pano-view/ui";
|
|
227
|
+
import "@ericchen1990/pano-view/ui/styles.css";
|
|
228
|
+
|
|
229
|
+
export function Tour({ project }: { project: LoadedPanoProject }) {
|
|
230
|
+
const [popup, setPopup] = useState<ProjectPopupDefinition | null>(null);
|
|
231
|
+
return <PanoViewer renderOverlay={() => (
|
|
232
|
+
<ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
|
|
233
|
+
)}>
|
|
234
|
+
<ProjectScenes project={project} onPopupRequest={({ popup }) => setPopup(popup)}
|
|
235
|
+
onActiveSceneIdChange={() => setPopup(null)} />
|
|
236
|
+
</PanoViewer>;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`ProjectPopupDialog` 接收 `popup`(null 关闭)、`onOpenChange`,以及可选 `closeLabel` 和 `onMediaError(block)`。文字按纯文本展示,音视频使用原生控件且不自动播放;关闭或切换内容时释放媒体。全屏、尺寸和焦点行为复用 `PanoDialog`。需要自定义渲染时可替换成自己的 Overlay。
|
|
241
|
+
|
|
242
|
+
继续使用 schemaVersion 1,旧项目仍可读取,包含新动作的项目需使用支持它们的新版查看器。`parsePanoProject(input, { draft: true })` 仅用于编辑草稿,允许空场景列表及未完成的弹窗标题/媒体;加载、保存和导出仍严格校验。宿主可将同一份文档存入数据库或映射到表,查看器不直接连接数据库。
|
|
243
|
+
|
|
199
244
|
单图和已有配置可用纯数据入口;Next.js 的查看器组件需位于客户端边界,服务端加载数据则使用 `/data`:
|
|
200
245
|
|
|
201
246
|
```ts
|
|
@@ -467,6 +512,22 @@ export function Tour() {
|
|
|
467
512
|
- 右键菜单辅助 — `createPanoContextMenuPresets`、`composePanoContextMenuItems` 及相关 API([`PanoContextMenu`](#panocontextmenu))
|
|
468
513
|
- 多边形 / 折线校验 — `validatePolygonVertices`、`validatePolylineVertices`、`unwrapPolygonVertices`
|
|
469
514
|
|
|
515
|
+
## DOM Overlay 与弹窗
|
|
516
|
+
|
|
517
|
+
普通 DOM 界面使用 `PanoViewer.renderOverlay`,位于 Three.js 渲染树之外。`usePanoOverlay()` 提供稳定的 Portal 容器、viewer/可见区域尺寸和全屏状态。可选 `/ui` 入口提供 `PanoDialog` 与组成组件,也可以接入应用已有的 Dialog。
|
|
518
|
+
|
|
519
|
+
独立的 **[弹窗使用与自定义样式指南](./docs/dialogs.zh-CN.md)** 包含完整示例、CSS 变量默认值与放置位置、布局组合、第三方接入、二级 Portal、生命周期、API 表和排错说明。指南随 npm 包分发。在本仓库运行 `pnpm dev`,打开 `/overlays` 可体验示例。
|
|
520
|
+
|
|
521
|
+
### 默认弹窗
|
|
522
|
+
|
|
523
|
+
从[快速开始](./docs/dialogs.zh-CN.md#quick-start)接入,再[定制默认外观](./docs/dialogs.zh-CN.md#customize-the-default-appearance)。默认外观单独导入 `@ericchen1990/pano-view/ui/styles.css`,也可不导入。
|
|
524
|
+
|
|
525
|
+
多个热点使用[场景/热点到内容的完整映射示例](./docs/dialogs.zh-CN.md#choose-content-by-hotspot):五份内容、一个共享弹窗、对应项目 JSON,以及场景内热点 ID 的处理。
|
|
526
|
+
|
|
527
|
+
### 组成组件与已有 Dialog 接入
|
|
528
|
+
|
|
529
|
+
自定义标题栏、内容区和底部操作使用[组成组件](./docs/dialogs.zh-CN.md#compose-a-different-layout);已有 Dialog 使用本地 Portal 容器与 `usePanoModal(open)` [完成接入](./docs/dialogs.zh-CN.md#use-an-existing-dialog)。全屏功能放在 viewer 全局界面,不放入弹窗内容。
|
|
530
|
+
|
|
470
531
|
## PanoViewer
|
|
471
532
|
|
|
472
533
|
`PanoViewer` 负责画布、透视相机与控件。为其容器指定明确尺寸,并在内部放置一个全景源。
|
|
@@ -1761,6 +1822,8 @@ import { VideoHotspot } from "@ericchen1990/pano-view";
|
|
|
1761
1822
|
|
|
1762
1823
|
音频通过 Howler.js 的 Web Audio 模式播放(Howler.js 已包含在本包构建产物中,宿主无需另行安装)。文件需完成解码后才能开始播放,可使用 MP3 格式。`src` 可以是字符串或回退 URL 数组。组件不使用 HTML5 流式音频模式,以保留立体声像控制。
|
|
1763
1824
|
|
|
1825
|
+
对于 `blob:` 等没有扩展名的音频地址,可传 `format="mp3"`,或按素材顺序传格式数组。`project.json` 中的音频定义也支持可选 `format` 字段。
|
|
1826
|
+
|
|
1764
1827
|
`playing` 与 `VideoHotspot` 一样受控。`loop` 默认 `false`,`muted` 默认 `false`,`volume` 默认 `1`,`pauseWhenHidden` 默认 `true`(标签页隐藏时暂停,`playing` 仍为 `true` 时,在标签页恢复可见后继续播放)。首次用户手势会解锁音频;若自动播放被阻止,会报告 `"blocked"`,并在 `playing` 仍为 true 时于下一次指针或按键后重试 `play()`。
|
|
1765
1828
|
|
|
1766
1829
|
默认标记在 **停止**(扬声器)与 **播放**(扬声器 + 声波)之间切换。可用 `icon` 自定义停止图标、`playingIcon` 自定义播放图标。只设 `icon` 时两种状态共用该图。`marker={false}` 隐藏标记,仍保留定点声源。整个导览共用或按场景切换的非空间背景音频请使用 [`BackgroundAudio`](#backgroundaudio)。
|
|
@@ -1999,3 +2062,33 @@ CORS 不授予对象读取权限。使用 CDN 时也需确保实际响应带有
|
|
|
1999
2062
|
通过本地 HTTP(S) 服务预览,不使用 `file://`。HTTPS 页面使用 HTTPS 资源。采用带版本的资源目录,并协调项目 JSON 与图片的缓存,避免旧配置请求新目录。`UNSUPPORTED_SCHEMA_VERSION` 需要匹配支持的配置版本;平面图没有 `viewer.json`,需要平面查看器。
|
|
2000
2063
|
|
|
2001
2064
|
转换阶段的问题另见 [converter 错误码与故障排查](../converter/README.zh-CN.md#错误码与故障排查)。
|
|
2065
|
+
|
|
2066
|
+
### 相机中心范围
|
|
2067
|
+
|
|
2068
|
+
`PanoViewer`、`Scenes` 的每个场景及 `project.json` 的每个场景均支持可选的 `viewLimits`:
|
|
2069
|
+
|
|
2070
|
+
```ts
|
|
2071
|
+
viewLimits: {
|
|
2072
|
+
yaw: { min: 170, max: -170 },
|
|
2073
|
+
pitch: { min: -30, max: 45 },
|
|
2074
|
+
fov: { min: 40, max: 90 },
|
|
2075
|
+
}
|
|
2076
|
+
```
|
|
2077
|
+
|
|
2078
|
+
角度单位为度。Yaw 上下限在 −180…180 内;min > max 表示跨接缝,−180…180 表示完整一圈。Pitch 在 −90…90、FOV 在 1…179 内,均要求 min ≤ max。相等时锁定该轴。场景逐轴覆盖播放器设置;未配置的轴继承播放器,FOV 最后回退到原有 `minFov` / `maxFov`(默认 30 / 100)。范围限制相机中心,覆盖鼠标、触摸、键盘、惯性、自动旋转、陀螺仪及视角导航 API,不保证整个可见画面不超界。显式配置的轴即使启用 `bouncingLimits` 也使用硬边界。Yaw 动画沿允许区间插值,不走穿过禁区的最短路径;沉浸式 WebXR 继续由头显控制姿态。
|
|
2079
|
+
|
|
2080
|
+
新场景接管画面时先应用范围,再设置初始视角。实时修改会取消导航与惯性、收回越界视角,不修改保存的初始视角或重新加载纹理。移除场景范围时恢复播放器设置。
|
|
2081
|
+
|
|
2082
|
+
### 热点交互事件与自定义动作
|
|
2083
|
+
|
|
2084
|
+
`events` 为 `click`、`pointerenter`、`pointerleave` 分别绑定一个动作。三个事件均支持 `scene`、`view`、`popup` 和 `{ type: "custom", actionId, params? }`。`params` 是 JSON 对象;函数、凭据、API 实现和表单定义由宿主维护。参数内的字符串不作为素材解析或打包。
|
|
2085
|
+
|
|
2086
|
+
```json
|
|
2087
|
+
{"events":{"click":{"type":"scene","sceneId":"hall"},"pointerenter":{"type":"custom","actionId":"product.preview","params":{"visible":true}},"pointerleave":{"type":"custom","actionId":"product.preview","params":{"visible":false}}}}
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
旧 `action` 继续作为点击动作的后备,`events.click` 优先;编辑或清除点击时移除旧字段。打开项目不自动迁移。新事件配置需要升级后的播放器,项目 schemaVersion 仍为 1。没有点击动作的媒体继续切换播放,悬停没有默认动作。
|
|
2091
|
+
|
|
2092
|
+
`ProjectScenes.onHotspotEvent(event)` 提供 `trigger`、`sceneId`、`hotspot`、`interaction` 和 `preventDefault()`。点击时先调用它,再调用原有 `onHotspotClick`,任一回调均可取消动作。`onCustomAction(event)` 额外提供 `action`,支持返回 Promise。宿主按稳定的 `action.actionId` 分发,校验业务参数,对未知动作抛出错误。缺少执行器、同步异常和 Promise 拒绝进入附带 `trigger` 的 `onHotspotError`。请求、鉴权、取消、防重复和迟到响应由宿主负责;各事件独立分发,不自动重试。
|
|
2093
|
+
|
|
2094
|
+
悬停按整个热点的边界触发一次,跳过触屏和键盘焦点,并遵循可见性、指针交互设置。切换场景、隐藏和卸载仅清理状态,不执行离开动作。原有 tooltip 行为独立保留。自定义渲染器需同时转发 `context.onHoverChange(hovered, interaction)` 和 `context.onClick`;调用 `context.renderDefault()` 会自动接线。
|
package/dist/data.cjs
CHANGED
|
@@ -21,10 +21,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var data_exports = {};
|
|
22
22
|
__export(data_exports, {
|
|
23
23
|
PanoDataError: () => PanoDataError,
|
|
24
|
+
hotspotAction: () => hotspotAction,
|
|
24
25
|
loadPanoProject: () => loadPanoProject,
|
|
25
26
|
loadSceneFromJson: () => loadSceneFromJson,
|
|
26
27
|
parsePanoProject: () => parsePanoProject,
|
|
27
|
-
|
|
28
|
+
projectHotspotTriggers: () => projectHotspotTriggers,
|
|
29
|
+
resolveProjectHotspotAssets: () => resolveHotspotAssets,
|
|
30
|
+
resolveProjectPopupAssets: () => resolveProjectPopupAssets,
|
|
31
|
+
sceneFromViewerConfig: () => sceneFromViewerConfig,
|
|
32
|
+
setHotspotAction: () => setHotspotAction
|
|
28
33
|
});
|
|
29
34
|
module.exports = __toCommonJS(data_exports);
|
|
30
35
|
|
|
@@ -301,7 +306,152 @@ async function loadSceneFromJson(url, options) {
|
|
|
301
306
|
});
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// src/view-limits.ts
|
|
310
|
+
function parseViewLimits(value) {
|
|
311
|
+
if (value === void 0) return void 0;
|
|
312
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
313
|
+
throw new Error("viewLimits: expected an object");
|
|
314
|
+
const result = {};
|
|
315
|
+
for (const axis of ["yaw", "pitch", "fov"]) {
|
|
316
|
+
const input = value[axis];
|
|
317
|
+
if (input === void 0) continue;
|
|
318
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
319
|
+
throw new Error(`viewLimits.${axis}: expected min and max`);
|
|
320
|
+
const { min, max } = input;
|
|
321
|
+
const lower = axis === "yaw" ? -180 : axis === "pitch" ? -90 : 1;
|
|
322
|
+
const upper = axis === "yaw" ? 180 : axis === "pitch" ? 90 : 179;
|
|
323
|
+
if (typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min < lower || min > upper || max < lower || max > upper)
|
|
324
|
+
throw new Error(`viewLimits.${axis}: expected finite min and max from ${lower} to ${upper}`);
|
|
325
|
+
if (axis !== "yaw" && min > max)
|
|
326
|
+
throw new Error(`viewLimits.${axis}: min must not exceed max`);
|
|
327
|
+
result[axis] = { min, max };
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/data/popups.ts
|
|
333
|
+
function parsePopups(value, draft = false) {
|
|
334
|
+
if (value === void 0) return void 0;
|
|
335
|
+
try {
|
|
336
|
+
if (!Array.isArray(value)) throw new Error("popups: expected an array");
|
|
337
|
+
const ids = /* @__PURE__ */ new Set();
|
|
338
|
+
const string = (input, field, allowEmpty = false) => {
|
|
339
|
+
if (typeof input !== "string" || !allowEmpty && !input.trim())
|
|
340
|
+
throw new Error(`${field}: expected a ${allowEmpty ? "string" : "nonempty string"}`);
|
|
341
|
+
return input;
|
|
342
|
+
};
|
|
343
|
+
return value.map((entry, index) => {
|
|
344
|
+
const field = `popups[${index}]`;
|
|
345
|
+
if (!isRecord(entry)) throw new Error(`${field}: expected an object`);
|
|
346
|
+
const id = requiredString(entry.id, `${field}.id`);
|
|
347
|
+
if (ids.has(id)) throw new Error(`${field}.id: duplicate popup ID`);
|
|
348
|
+
ids.add(id);
|
|
349
|
+
const title = string(entry.title, `${field}.title`, draft);
|
|
350
|
+
if (!Array.isArray(entry.blocks)) throw new Error(`${field}.blocks: expected an array`);
|
|
351
|
+
const blockIds = /* @__PURE__ */ new Set();
|
|
352
|
+
const blocks = entry.blocks.map((block, i) => {
|
|
353
|
+
const path = `${field}.blocks[${i}]`;
|
|
354
|
+
if (!isRecord(block)) throw new Error(`${path}: expected an object`);
|
|
355
|
+
const id2 = requiredString(block.id, `${path}.id`);
|
|
356
|
+
if (blockIds.has(id2)) throw new Error(`${path}.id: duplicate block ID`);
|
|
357
|
+
blockIds.add(id2);
|
|
358
|
+
if (block.type === "text") return { id: id2, type: "text", text: string(block.text, `${path}.text`, true) };
|
|
359
|
+
const src = string(block.src, `${path}.src`, draft);
|
|
360
|
+
if (src) resolveResource("https://popup.invalid/", src);
|
|
361
|
+
if (block.type === "image") return {
|
|
362
|
+
id: id2,
|
|
363
|
+
type: "image",
|
|
364
|
+
src,
|
|
365
|
+
...block.alt === void 0 ? {} : { alt: string(block.alt, `${path}.alt`, true) }
|
|
366
|
+
};
|
|
367
|
+
if (block.type === "audio") return { id: id2, type: "audio", src };
|
|
368
|
+
if (block.type === "video") {
|
|
369
|
+
const poster = block.poster === void 0 ? void 0 : string(block.poster, `${path}.poster`, true);
|
|
370
|
+
if (poster) resolveResource("https://popup.invalid/", poster);
|
|
371
|
+
return { id: id2, type: "video", src, ...poster ? { poster } : {} };
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`${path}.type: unsupported content block`);
|
|
374
|
+
});
|
|
375
|
+
return { id, title, blocks };
|
|
376
|
+
});
|
|
377
|
+
} catch (cause) {
|
|
378
|
+
const message = cause instanceof Error ? cause.message : "Invalid popup content";
|
|
379
|
+
throw new PanoDataError("INVALID_PROJECT", message, { field: message.split(":")[0], cause });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function resolveProjectPopupAssets(popup, baseUrl, resolver) {
|
|
383
|
+
return { ...popup, blocks: popup.blocks.map((block) => {
|
|
384
|
+
if (block.type === "text") return { ...block };
|
|
385
|
+
const resolve = (path, field) => {
|
|
386
|
+
if (!path) throw new PanoDataError("INVALID_PROJECT", `${popup.id}/${block.id}.${field}: choose a resource`);
|
|
387
|
+
const context = { defaultUrl: resolveResource(baseUrl, path), popupId: popup.id, blockId: block.id, field };
|
|
388
|
+
const result = resolver ? resolver(path, context) : context.defaultUrl;
|
|
389
|
+
if (typeof result !== "string" || !result.startsWith("/") && !/^(https?:|blob:|data:)/i.test(result))
|
|
390
|
+
throw new PanoDataError("INVALID_CONFIG", "resolvePopupAssetUrl must return an absolute URL or a site-root path");
|
|
391
|
+
return result;
|
|
392
|
+
};
|
|
393
|
+
return { ...block, src: resolve(block.src, "src"), ...block.type === "video" && block.poster ? { poster: resolve(block.poster, "poster") } : {} };
|
|
394
|
+
}) };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ../../shared/cursor-assets.ts
|
|
398
|
+
function mapCursorAssets(cursor, resolve) {
|
|
399
|
+
return cursor.replace(
|
|
400
|
+
/url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|((?:\\.|[^)\\])*?))\s*\)/gi,
|
|
401
|
+
(_match, double, single, bare) => {
|
|
402
|
+
const path = (double ?? single ?? bare ?? "").replace(
|
|
403
|
+
/\\([\da-f]{1,6}\s?|.)/gi,
|
|
404
|
+
(_escape, value) => /^[\da-f]/i.test(value) ? String.fromCodePoint(
|
|
405
|
+
Math.min(parseInt(value.trim(), 16), 1114111)
|
|
406
|
+
) : value
|
|
407
|
+
);
|
|
408
|
+
return `url(${JSON.stringify(resolve(path))})`;
|
|
409
|
+
}
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
304
413
|
// src/data/hotspots.ts
|
|
414
|
+
var projectHotspotTriggers = ["click", "pointerenter", "pointerleave"];
|
|
415
|
+
function hotspotAction(hotspot, trigger) {
|
|
416
|
+
return hotspot.events?.[trigger] ?? (trigger === "click" ? hotspot.action : void 0);
|
|
417
|
+
}
|
|
418
|
+
function setHotspotAction(hotspot, trigger, action) {
|
|
419
|
+
const events = { ...hotspot.events };
|
|
420
|
+
const next = { ...hotspot, events };
|
|
421
|
+
if (trigger === "click") delete next.action;
|
|
422
|
+
if (action) events[trigger] = action;
|
|
423
|
+
else delete events[trigger];
|
|
424
|
+
if (!Object.keys(events).length) delete next.events;
|
|
425
|
+
return next;
|
|
426
|
+
}
|
|
427
|
+
function jsonValue(value, seen = /* @__PURE__ */ new Set()) {
|
|
428
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
429
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
430
|
+
if (typeof value !== "object" || !value || seen.has(value)) throw new Error("params: expected JSON data");
|
|
431
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("params: expected JSON data");
|
|
432
|
+
seen.add(value);
|
|
433
|
+
const result = Array.isArray(value) ? value.map((item) => jsonValue(item, seen)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonValue(item, seen)]));
|
|
434
|
+
seen.delete(value);
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
function parseAction(action) {
|
|
438
|
+
if (!isRecord(action)) throw new Error("action: expected an object");
|
|
439
|
+
if (action.type === "scene") return { type: "scene", sceneId: requiredString(action.sceneId, "action.sceneId") };
|
|
440
|
+
if (action.type === "popup") return { type: "popup", popupId: requiredString(action.popupId, "action.popupId") };
|
|
441
|
+
if (action.type === "custom") {
|
|
442
|
+
if (action.params !== void 0 && (!isRecord(action.params) || Array.isArray(action.params))) throw new Error("action.params: expected an object");
|
|
443
|
+
return { type: "custom", actionId: requiredString(action.actionId, "action.actionId"), ...action.params === void 0 ? {} : { params: jsonValue(action.params) } };
|
|
444
|
+
}
|
|
445
|
+
if (action.type === "view") {
|
|
446
|
+
const position = parsePosition(action.view, "action.view");
|
|
447
|
+
const fov = finite(action.view.fov, "action.view.fov");
|
|
448
|
+
if (fov <= 0 || fov >= 180) throw new Error("action.view.fov: expected 0 < fov < 180");
|
|
449
|
+
const duration = action.duration === void 0 ? void 0 : finite(action.duration, "action.duration");
|
|
450
|
+
if (duration !== void 0 && duration < 0) throw new Error("action.duration: expected a nonnegative number");
|
|
451
|
+
return { type: "view", view: { ...position, fov }, ...duration === void 0 ? {} : { duration } };
|
|
452
|
+
}
|
|
453
|
+
throw new Error("action.type: unsupported action");
|
|
454
|
+
}
|
|
305
455
|
var common = [
|
|
306
456
|
"label",
|
|
307
457
|
"visible",
|
|
@@ -344,6 +494,7 @@ var fields = {
|
|
|
344
494
|
],
|
|
345
495
|
audio: [
|
|
346
496
|
"src",
|
|
497
|
+
"format",
|
|
347
498
|
"playing",
|
|
348
499
|
"loop",
|
|
349
500
|
"muted",
|
|
@@ -612,9 +763,10 @@ function parseHotspot(value, sceneId, field) {
|
|
|
612
763
|
result[key] = n;
|
|
613
764
|
} else if (key === "fontWeight" && typeof item === "number")
|
|
614
765
|
result[key] = finite(item, key);
|
|
615
|
-
else if (key === "src" && kind === "audio" && Array.isArray(item)) {
|
|
616
|
-
if (!item.length)
|
|
617
|
-
|
|
766
|
+
else if ((key === "src" || key === "format") && kind === "audio" && Array.isArray(item)) {
|
|
767
|
+
if (!item.length)
|
|
768
|
+
throw new Error(`${key}: expected audio ${key === "src" ? "sources" : "formats"}`);
|
|
769
|
+
result[key] = item.map((entry, i) => requiredString(entry, `${key}[${i}]`));
|
|
618
770
|
} else {
|
|
619
771
|
if (typeof item !== "string")
|
|
620
772
|
throw new Error(`${key}: expected a string`);
|
|
@@ -633,13 +785,19 @@ function parseHotspot(value, sceneId, field) {
|
|
|
633
785
|
throw new Error("text: required");
|
|
634
786
|
if (kind === "sequence" && !result.frameCount)
|
|
635
787
|
throw new Error("frameCount: required");
|
|
636
|
-
if (value.action !== void 0)
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
788
|
+
if (value.action !== void 0) result.action = parseAction(value.action);
|
|
789
|
+
if (value.events !== void 0) {
|
|
790
|
+
if (!isRecord(value.events)) throw new Error("events: expected an object");
|
|
791
|
+
const events = {};
|
|
792
|
+
for (const key of Object.keys(value.events)) {
|
|
793
|
+
if (!projectHotspotTriggers.includes(key)) throw new Error(`events.${key}: unsupported trigger`);
|
|
794
|
+
try {
|
|
795
|
+
events[key] = parseAction(value.events[key]);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
throw new Error(`events.${key}.${error instanceof Error ? error.message : "invalid action"}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
result.events = events;
|
|
643
801
|
}
|
|
644
802
|
return result;
|
|
645
803
|
} catch (cause) {
|
|
@@ -663,6 +821,11 @@ function resolveHotspotAssets(hotspot, baseUrl, sceneId, resolver) {
|
|
|
663
821
|
resolver
|
|
664
822
|
) : path;
|
|
665
823
|
const result = { ...hotspot };
|
|
824
|
+
if (result.cursor) result.cursor = mapCursorAssets(result.cursor, resolve);
|
|
825
|
+
if (result.tooltipAppearance?.background) result.tooltipAppearance = {
|
|
826
|
+
...result.tooltipAppearance,
|
|
827
|
+
background: mapCursorAssets(result.tooltipAppearance.background, resolve)
|
|
828
|
+
};
|
|
666
829
|
if ("src" in result)
|
|
667
830
|
result.src = Array.isArray(result.src) ? result.src.map(resolve) : resolve(result.src);
|
|
668
831
|
if ("poster" in result && result.poster)
|
|
@@ -685,7 +848,7 @@ function resolveHotspotAssets(hotspot, baseUrl, sceneId, resolver) {
|
|
|
685
848
|
function optionalString(record, key) {
|
|
686
849
|
return record[key] === void 0 ? void 0 : requiredString(record[key], key);
|
|
687
850
|
}
|
|
688
|
-
function parsePanoProject(value) {
|
|
851
|
+
function parsePanoProject(value, options = {}) {
|
|
689
852
|
if (!isRecord(value))
|
|
690
853
|
throw new PanoDataError("INVALID_PROJECT", "Expected a project object");
|
|
691
854
|
if (value.schemaVersion !== 1)
|
|
@@ -695,7 +858,8 @@ function parsePanoProject(value) {
|
|
|
695
858
|
{ field: "schemaVersion" }
|
|
696
859
|
);
|
|
697
860
|
try {
|
|
698
|
-
|
|
861
|
+
const popups = parsePopups(value.popups, options.draft);
|
|
862
|
+
if (!Array.isArray(value.scenes) || !options.draft && !value.scenes.length)
|
|
699
863
|
throw new Error("scenes: expected a nonempty array");
|
|
700
864
|
const ids = /* @__PURE__ */ new Set();
|
|
701
865
|
const scenes = value.scenes.map(
|
|
@@ -719,6 +883,7 @@ function parsePanoProject(value) {
|
|
|
719
883
|
viewerUrl: requiredString(src.viewerUrl, "source.viewerUrl"),
|
|
720
884
|
baseUrl
|
|
721
885
|
} : { viewerConfig: parseConfig(src.viewerConfig), baseUrl };
|
|
886
|
+
const viewLimits = parseViewLimits(entry.viewLimits);
|
|
722
887
|
let initialView;
|
|
723
888
|
if (entry.initialView !== void 0) {
|
|
724
889
|
if (!isRecord(entry.initialView))
|
|
@@ -756,6 +921,7 @@ function parsePanoProject(value) {
|
|
|
756
921
|
title: optionalString(entry, "title"),
|
|
757
922
|
source,
|
|
758
923
|
initialView,
|
|
924
|
+
...viewLimits === void 0 ? {} : { viewLimits },
|
|
759
925
|
hotspots
|
|
760
926
|
};
|
|
761
927
|
} catch (cause) {
|
|
@@ -773,8 +939,8 @@ function parsePanoProject(value) {
|
|
|
773
939
|
}
|
|
774
940
|
}
|
|
775
941
|
);
|
|
776
|
-
const initialSceneId = optionalString(value, "initialSceneId") ?? scenes[0]
|
|
777
|
-
if (!ids.has(initialSceneId))
|
|
942
|
+
const initialSceneId = optionalString(value, "initialSceneId") ?? scenes[0]?.id;
|
|
943
|
+
if (initialSceneId !== void 0 && !ids.has(initialSceneId))
|
|
778
944
|
throw new PanoDataError(
|
|
779
945
|
"INVALID_REFERENCE",
|
|
780
946
|
"initialSceneId: scene does not exist",
|
|
@@ -782,22 +948,31 @@ function parsePanoProject(value) {
|
|
|
782
948
|
);
|
|
783
949
|
for (const [index, scene] of scenes.entries()) {
|
|
784
950
|
for (const [i, hotspot] of (scene.hotspots ?? []).entries()) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
"Hotspot target
|
|
789
|
-
{
|
|
951
|
+
for (const trigger of projectHotspotTriggers) {
|
|
952
|
+
const action = hotspotAction(hotspot, trigger);
|
|
953
|
+
if (action?.type === "popup" && !popups?.some((popup) => popup.id === action.popupId))
|
|
954
|
+
throw new PanoDataError("INVALID_REFERENCE", "Hotspot target popup does not exist", {
|
|
790
955
|
sceneId: scene.id,
|
|
791
|
-
field: `scenes[${index}].hotspots[${i}].action.
|
|
792
|
-
}
|
|
793
|
-
)
|
|
956
|
+
field: `scenes[${index}].hotspots[${i}].${hotspot.events?.[trigger] ? `events.${trigger}` : "action"}.popupId`
|
|
957
|
+
});
|
|
958
|
+
if (action?.type === "scene" && !ids.has(action.sceneId))
|
|
959
|
+
throw new PanoDataError(
|
|
960
|
+
"INVALID_REFERENCE",
|
|
961
|
+
"Hotspot target scene does not exist",
|
|
962
|
+
{
|
|
963
|
+
sceneId: scene.id,
|
|
964
|
+
field: `scenes[${index}].hotspots[${i}].${hotspot.events?.[trigger] ? `events.${trigger}` : "action"}.sceneId`
|
|
965
|
+
}
|
|
966
|
+
);
|
|
967
|
+
}
|
|
794
968
|
}
|
|
795
969
|
}
|
|
796
970
|
return {
|
|
797
971
|
schemaVersion: 1,
|
|
798
972
|
title: optionalString(value, "title"),
|
|
799
973
|
initialSceneId,
|
|
800
|
-
scenes
|
|
974
|
+
scenes,
|
|
975
|
+
...popups === void 0 ? {} : { popups }
|
|
801
976
|
};
|
|
802
977
|
} catch (cause) {
|
|
803
978
|
if (cause instanceof PanoDataError) throw cause;
|
|
@@ -858,6 +1033,7 @@ async function loadPanoProject(source, options = {}) {
|
|
|
858
1033
|
baseUrl: sourceBase ?? root
|
|
859
1034
|
});
|
|
860
1035
|
aborted(controller.signal);
|
|
1036
|
+
if (item.viewLimits !== void 0) scene.viewLimits = item.viewLimits;
|
|
861
1037
|
scenes[index] = {
|
|
862
1038
|
scene,
|
|
863
1039
|
title: item.title,
|
|
@@ -894,7 +1070,8 @@ async function loadPanoProject(source, options = {}) {
|
|
|
894
1070
|
return {
|
|
895
1071
|
title: definition.title,
|
|
896
1072
|
initialSceneId: definition.initialSceneId,
|
|
897
|
-
scenes
|
|
1073
|
+
scenes,
|
|
1074
|
+
...definition.popups === void 0 ? {} : { popups: definition.popups.map((popup) => resolveProjectPopupAssets(popup, root, options.resolvePopupAssetUrl)) }
|
|
898
1075
|
};
|
|
899
1076
|
} finally {
|
|
900
1077
|
signal?.removeEventListener("abort", cancel);
|