@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 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
- sceneFromViewerConfig: () => sceneFromViewerConfig
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) throw new Error("src: expected audio sources");
617
- result[key] = item.map((src, i) => requiredString(src, `src[${i}]`));
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
- if (!isRecord(value.action) || value.action.type !== "scene")
638
- throw new Error("action: expected a scene action");
639
- result.action = {
640
- type: "scene",
641
- sceneId: requiredString(value.action.sceneId, "action.sceneId")
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
- if (!Array.isArray(value.scenes) || !value.scenes.length)
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].id;
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
- if (hotspot.action && !ids.has(hotspot.action.sceneId))
786
- throw new PanoDataError(
787
- "INVALID_REFERENCE",
788
- "Hotspot target scene does not exist",
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.sceneId`
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);