@vctrl/embed 0.22.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/CHANGELOG.md +23 -0
- package/LICENSE.md +650 -0
- package/README.md +103 -0
- package/package.json +41 -0
- package/project.json +63 -0
- package/src/embed.ts +362 -0
- package/src/index.ts +28 -0
- package/src/protocol.ts +149 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lib.json +15 -0
- package/vite.config.ts +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @vctrl/embed
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@vctrl/embed)
|
|
4
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
5
|
+
|
|
6
|
+
Framework-agnostic JavaScript SDK for controlling Vectreal embedded 3D scene previews from any web page.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @vctrl/embed
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**CDN (UMD — no bundler needed):**
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<script src="https://cdn.vectreal.com/embed/latest/vectreal-embed.umd.js"></script>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<div style="width: 100%; height: 400px;">
|
|
24
|
+
<iframe
|
|
25
|
+
id="vectreal-scene"
|
|
26
|
+
src="https://vectreal.com/preview/fullscreen/<projectId>/<sceneId>?token=YOUR_PREVIEW_API_KEY"
|
|
27
|
+
style="width: 100%; height: 100%; border: 0;"
|
|
28
|
+
allow="autoplay; xr-spatial-tracking"
|
|
29
|
+
allowfullscreen
|
|
30
|
+
></iframe>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<script type="module">
|
|
34
|
+
import { VectrealEmbed } from '@vctrl/embed'
|
|
35
|
+
|
|
36
|
+
const embed = new VectrealEmbed(document.getElementById('vectreal-scene'))
|
|
37
|
+
|
|
38
|
+
const { cameras } = await embed.ready()
|
|
39
|
+
console.log('Available cameras:', cameras)
|
|
40
|
+
|
|
41
|
+
embed.on('camera_changed', ({ cameraId }) => {
|
|
42
|
+
console.log('Camera changed to:', cameraId)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
embed.activateCamera('detail')
|
|
46
|
+
</script>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
### `new VectrealEmbed(iframe, options?)`
|
|
52
|
+
|
|
53
|
+
| Option | Type | Default | Description |
|
|
54
|
+
| -------------- | -------- | ------------------------------- | ------------------------------------------------ |
|
|
55
|
+
| `iframeOrigin` | `string` | Auto-detected from `iframe.src` | Expected iframe origin for postMessage security. |
|
|
56
|
+
| `readyTimeout` | `number` | `15000` | ms before `ready()` rejects. |
|
|
57
|
+
|
|
58
|
+
### Methods
|
|
59
|
+
|
|
60
|
+
| Method | Description |
|
|
61
|
+
| -------------------------------- | -------------------------------------------------------------- |
|
|
62
|
+
| `ready()` | Resolves with `{ sceneId, cameras }` when the viewer is ready. |
|
|
63
|
+
| `activateCamera(cameraId)` | Switch to a named camera. |
|
|
64
|
+
| `setTransition(options)` | Override transition type, duration, and easing. |
|
|
65
|
+
| `setControlsEnabled(enabled)` | Enable or disable orbit controls. |
|
|
66
|
+
| `setAutoRotate(enabled, speed?)` | Toggle auto-rotate. |
|
|
67
|
+
| `setZoomEnabled(enabled)` | Toggle scroll-zoom. |
|
|
68
|
+
| `setPanEnabled(enabled)` | Toggle right-click pan. |
|
|
69
|
+
| `sendScrollProgress(progress)` | Drive scroll-triggered interactions (0–1). |
|
|
70
|
+
| `sendMessage(message, payload?)` | Trigger a named `host_message` interaction. |
|
|
71
|
+
| `on(type, handler)` | Subscribe to a viewer event. Returns unsubscribe. |
|
|
72
|
+
| `off(type, handler)` | Remove a specific handler. |
|
|
73
|
+
| `destroy()` | Remove all listeners and stop processing messages. |
|
|
74
|
+
|
|
75
|
+
### Events
|
|
76
|
+
|
|
77
|
+
| Type | Payload | When |
|
|
78
|
+
| --------------------- | ----------------------------------------- | ------------------------------------------------------- |
|
|
79
|
+
| `viewer_ready` | `void` | Viewer command surface is registered. |
|
|
80
|
+
| `model_loaded` | `void` | Model finished loading and initial framing is complete. |
|
|
81
|
+
| `camera_changed` | `{ cameraId }` | Active camera changed. |
|
|
82
|
+
| `auto_rotate_changed` | `{ enabled }` | Auto-rotate state changed. |
|
|
83
|
+
| `interaction_event` | `{ eventName, interactionId?, payload? }` | Publisher custom event fired. |
|
|
84
|
+
|
|
85
|
+
## URL parameter shorthand
|
|
86
|
+
|
|
87
|
+
For static initial configuration without JavaScript, add query parameters to the iframe `src`:
|
|
88
|
+
|
|
89
|
+
| Parameter | Example | Effect |
|
|
90
|
+
| -------------------- | -------------------- | ------------------------------------- |
|
|
91
|
+
| `?camera=<id>` | `?camera=hero` | Activates a camera on `viewer_ready`. |
|
|
92
|
+
| `?autoRotate=0` | `?autoRotate=1` | Overrides stored auto-rotate state. |
|
|
93
|
+
| `?transition=<type>` | `?transition=linear` | Overrides stored transition type. |
|
|
94
|
+
|
|
95
|
+
## Documentation
|
|
96
|
+
|
|
97
|
+
Full guide and examples: [vectreal.com/docs/guides/embed-sdk](https://vectreal.com/docs/guides/embed-sdk)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
AGPL-3.0-only — see [LICENSE](https://github.com/vectreal/vectreal-platform/blob/main/LICENSE).
|
|
102
|
+
|
|
103
|
+
Part of the [Vectreal Platform](https://github.com/vectreal/vectreal-platform) monorepo.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.22.0",
|
|
3
|
+
"name": "@vctrl/embed",
|
|
4
|
+
"description": "Framework-agnostic JavaScript SDK for controlling Vectreal embedded 3D scene previews via postMessage.",
|
|
5
|
+
"bugs": {
|
|
6
|
+
"url": "https://github.com/vectreal/vectreal-platform/issues"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://vectreal.com",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vectreal",
|
|
11
|
+
"3d",
|
|
12
|
+
"embed",
|
|
13
|
+
"sdk",
|
|
14
|
+
"iframe",
|
|
15
|
+
"postmessage",
|
|
16
|
+
"gltf",
|
|
17
|
+
"glb",
|
|
18
|
+
"model-viewer"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/vectreal/vectreal-platform.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "AGPL-3.0-only",
|
|
25
|
+
"main": "./index.cjs",
|
|
26
|
+
"module": "./index.js",
|
|
27
|
+
"browser": "./vectreal-embed.umd.js",
|
|
28
|
+
"types": "./index.d.ts",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"import": "./index.js",
|
|
38
|
+
"require": "./index.cjs"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vctrl/embed",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "packages/embed/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"lint": {
|
|
9
|
+
"executor": "@nx/eslint:lint",
|
|
10
|
+
"outputs": ["{options.outputFile}"],
|
|
11
|
+
"options": {
|
|
12
|
+
"fix": true,
|
|
13
|
+
"lintFilePatterns": [
|
|
14
|
+
"packages/embed/src/**/*.{ts,js}",
|
|
15
|
+
"packages/embed/vite.config.ts",
|
|
16
|
+
"packages/embed/package.json"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"typecheck": {
|
|
21
|
+
"executor": "nx:run-commands",
|
|
22
|
+
"outputs": [],
|
|
23
|
+
"options": {
|
|
24
|
+
"command": "tsc --noEmit -p packages/embed/tsconfig.json"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"build": {
|
|
28
|
+
"executor": "@nx/vite:build",
|
|
29
|
+
"outputs": ["{options.outputPath}"],
|
|
30
|
+
"options": {
|
|
31
|
+
"outputPath": "build/packages/vctrl/embed",
|
|
32
|
+
"configFile": "packages/embed/vite.config.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"build-ci": {
|
|
36
|
+
"executor": "@nx/vite:build",
|
|
37
|
+
"outputs": ["{options.outputPath}"],
|
|
38
|
+
"options": {
|
|
39
|
+
"outputPath": "build/packages/vctrl/embed",
|
|
40
|
+
"configFile": "packages/embed/vite.config.ts"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"copy-md": {
|
|
44
|
+
"dependsOn": ["build"],
|
|
45
|
+
"executor": "nx:run-commands",
|
|
46
|
+
"outputs": [],
|
|
47
|
+
"options": {
|
|
48
|
+
"commands": [
|
|
49
|
+
"rsync -rat packages/embed/*.md build/packages/vctrl/embed/"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"publish": {
|
|
54
|
+
"dependsOn": ["copy-md"],
|
|
55
|
+
"executor": "nx:run-commands",
|
|
56
|
+
"outputs": [],
|
|
57
|
+
"options": {
|
|
58
|
+
"command": "pnpm publish --no-git-checks --access public",
|
|
59
|
+
"cwd": "build/packages/vctrl/embed"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HOSTED_PREVIEW_HOST_SOURCE,
|
|
3
|
+
HOSTED_PREVIEW_VIEWER_SOURCE,
|
|
4
|
+
type EmbedCameraDescriptor,
|
|
5
|
+
type HostedPreviewOutgoingMessage
|
|
6
|
+
} from './protocol'
|
|
7
|
+
|
|
8
|
+
import type { ViewerCommand, ViewerInteractionEvent } from '@vctrl/viewer'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Public types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface EmbedOptions {
|
|
15
|
+
/** Override the detected iframe origin for postMessage targeting. */
|
|
16
|
+
iframeOrigin?: string
|
|
17
|
+
/** Milliseconds before ready() rejects. Default: 15 000. */
|
|
18
|
+
readyTimeout?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EmbedReadyInfo {
|
|
22
|
+
sceneId: string | undefined
|
|
23
|
+
cameras: EmbedCameraDescriptor[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SetTransitionOptions {
|
|
27
|
+
type: 'none' | 'linear' | 'object_avoidance'
|
|
28
|
+
duration?: number
|
|
29
|
+
easing?: 'linear' | 'ease_in' | 'ease_out' | 'ease_in_out'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EmbedEventMap = {
|
|
33
|
+
viewer_ready: void
|
|
34
|
+
model_loaded: void
|
|
35
|
+
camera_changed: { cameraId: string }
|
|
36
|
+
auto_rotate_changed: { enabled: boolean }
|
|
37
|
+
interaction_event: {
|
|
38
|
+
interactionId?: string
|
|
39
|
+
eventName: string
|
|
40
|
+
payload?: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type EmbedEventType = keyof EmbedEventMap
|
|
45
|
+
export type EmbedEventHandler<K extends EmbedEventType> = (
|
|
46
|
+
data: EmbedEventMap[K]
|
|
47
|
+
) => void
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// VectrealEmbed
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000
|
|
54
|
+
|
|
55
|
+
export class VectrealEmbed {
|
|
56
|
+
private readonly iframe: HTMLIFrameElement
|
|
57
|
+
private readonly targetOrigin: string
|
|
58
|
+
private readonly readyTimeout: number
|
|
59
|
+
|
|
60
|
+
private handlers = new Map<
|
|
61
|
+
EmbedEventType,
|
|
62
|
+
Set<EmbedEventHandler<EmbedEventType>>
|
|
63
|
+
>()
|
|
64
|
+
private pendingCommands: Array<{
|
|
65
|
+
source: string
|
|
66
|
+
type: string
|
|
67
|
+
[key: string]: unknown
|
|
68
|
+
}> = []
|
|
69
|
+
private isReady = false
|
|
70
|
+
private boundListener: (event: MessageEvent<unknown>) => void
|
|
71
|
+
private pingIntervalId: number | null = null
|
|
72
|
+
|
|
73
|
+
constructor(iframe: HTMLIFrameElement, options: EmbedOptions = {}) {
|
|
74
|
+
this.iframe = iframe
|
|
75
|
+
this.readyTimeout = options.readyTimeout ?? DEFAULT_READY_TIMEOUT_MS
|
|
76
|
+
|
|
77
|
+
const src = iframe.src || iframe.getAttribute('src') || ''
|
|
78
|
+
let detectedOrigin: string | undefined
|
|
79
|
+
if (src) {
|
|
80
|
+
try {
|
|
81
|
+
detectedOrigin = new URL(src, window.location.href).origin
|
|
82
|
+
} catch {
|
|
83
|
+
// unparseable src - fall through to require explicit iframeOrigin
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!options.iframeOrigin && !detectedOrigin) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'VectrealEmbed: cannot determine iframe origin from src. Pass options.iframeOrigin explicitly.'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
this.targetOrigin = options.iframeOrigin ?? detectedOrigin!
|
|
92
|
+
|
|
93
|
+
this.boundListener = this.handleMessage.bind(this)
|
|
94
|
+
window.addEventListener('message', this.boundListener)
|
|
95
|
+
|
|
96
|
+
// Retry ping every 500 ms until we receive a pong. The iframe's React app
|
|
97
|
+
// may not have hydrated and registered its message listener yet when the
|
|
98
|
+
// constructor runs, so a single fire-and-forget ping is unreliable.
|
|
99
|
+
this.startPingPolling()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Lifecycle
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolves when the embedded viewer emits viewer_ready.
|
|
108
|
+
* Rejects if no response within readyTimeout.
|
|
109
|
+
*/
|
|
110
|
+
ready(): Promise<EmbedReadyInfo> {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const timer = window.setTimeout(() => {
|
|
113
|
+
this.off('viewer_ready', onReady as EmbedEventHandler<'viewer_ready'>)
|
|
114
|
+
reject(
|
|
115
|
+
new Error(
|
|
116
|
+
`VectrealEmbed: viewer did not become ready within ${this.readyTimeout}ms`
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
}, this.readyTimeout)
|
|
120
|
+
|
|
121
|
+
const onReady = () => {
|
|
122
|
+
window.clearTimeout(timer)
|
|
123
|
+
resolve({
|
|
124
|
+
sceneId: this.sceneId,
|
|
125
|
+
cameras: this.cameras
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.isReady) {
|
|
130
|
+
window.clearTimeout(timer)
|
|
131
|
+
resolve({ sceneId: this.sceneId, cameras: this.cameras })
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.on('viewer_ready', onReady as EmbedEventHandler<'viewer_ready'>)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Remove all listeners and stop responding to messages. */
|
|
140
|
+
destroy(): void {
|
|
141
|
+
this.stopPingPolling()
|
|
142
|
+
window.removeEventListener('message', this.boundListener)
|
|
143
|
+
this.handlers.clear()
|
|
144
|
+
this.pendingCommands = []
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Commands
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** Switch to a named camera. */
|
|
152
|
+
activateCamera(cameraId: string): void {
|
|
153
|
+
this.sendCommand({ type: 'activate_camera', cameraId })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Override the transition behaviour for subsequent camera switches. */
|
|
157
|
+
setTransition(options: SetTransitionOptions): void {
|
|
158
|
+
this.sendCommand({
|
|
159
|
+
type: 'set_transition',
|
|
160
|
+
transitionType: options.type,
|
|
161
|
+
duration: options.duration,
|
|
162
|
+
easing: options.easing
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Enable or disable orbit controls. */
|
|
167
|
+
setControlsEnabled(enabled: boolean): void {
|
|
168
|
+
this.sendCommand({ type: 'set_controls_enabled', enabled })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Toggle auto-rotate. */
|
|
172
|
+
setAutoRotate(enabled: boolean, speed?: number): void {
|
|
173
|
+
this.sendCommand({ type: 'set_auto_rotate', enabled, speed })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Toggle scroll-zoom. */
|
|
177
|
+
setZoomEnabled(enabled: boolean): void {
|
|
178
|
+
this.sendCommand({ type: 'set_controls_options', zoom: enabled })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Toggle right-click pan. */
|
|
182
|
+
setPanEnabled(enabled: boolean): void {
|
|
183
|
+
this.sendCommand({ type: 'set_controls_options', pan: enabled })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Drive scroll-triggered interactions defined in the Publisher.
|
|
188
|
+
* @param progress 0 (page top) – 1 (page bottom)
|
|
189
|
+
*/
|
|
190
|
+
sendScrollProgress(progress: number): void {
|
|
191
|
+
this.postToIframe({
|
|
192
|
+
source: HOSTED_PREVIEW_HOST_SOURCE,
|
|
193
|
+
type: 'host_scroll_progress',
|
|
194
|
+
progress: Math.min(1, Math.max(0, progress))
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Trigger a named host_message interaction defined in the Publisher.
|
|
200
|
+
*/
|
|
201
|
+
sendMessage(message: string, payload?: Record<string, unknown>): void {
|
|
202
|
+
this.postToIframe({
|
|
203
|
+
source: HOSTED_PREVIEW_HOST_SOURCE,
|
|
204
|
+
type: 'host_message',
|
|
205
|
+
message,
|
|
206
|
+
payload
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Events
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/** Subscribe to a viewer event. Returns an unsubscribe function. */
|
|
215
|
+
on<K extends EmbedEventType>(
|
|
216
|
+
type: K,
|
|
217
|
+
handler: EmbedEventHandler<K>
|
|
218
|
+
): () => void {
|
|
219
|
+
if (!this.handlers.has(type)) {
|
|
220
|
+
this.handlers.set(type, new Set())
|
|
221
|
+
}
|
|
222
|
+
this.handlers.get(type)!.add(handler as EmbedEventHandler<EmbedEventType>)
|
|
223
|
+
return () => this.off(type, handler)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remove a specific handler. */
|
|
227
|
+
off<K extends EmbedEventType>(type: K, handler: EmbedEventHandler<K>): void {
|
|
228
|
+
this.handlers
|
|
229
|
+
.get(type)
|
|
230
|
+
?.delete(handler as EmbedEventHandler<EmbedEventType>)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Internal state (populated via pong)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
private sceneId: string | undefined = undefined
|
|
238
|
+
private cameras: EmbedCameraDescriptor[] = []
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Private helpers
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
private sendCommand(command: ViewerCommand): void {
|
|
245
|
+
const message = {
|
|
246
|
+
source: HOSTED_PREVIEW_HOST_SOURCE,
|
|
247
|
+
type: 'viewer_command' as const,
|
|
248
|
+
command
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!this.isReady) {
|
|
252
|
+
this.pendingCommands.push(message)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.postToIframe(message)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private postToIframe(message: Record<string, unknown>): void {
|
|
260
|
+
try {
|
|
261
|
+
this.iframe.contentWindow?.postMessage(message, this.targetOrigin)
|
|
262
|
+
} catch {
|
|
263
|
+
// iframe may not be loaded yet; silently discard
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private startPingPolling(): void {
|
|
268
|
+
this.postToIframe({ source: HOSTED_PREVIEW_HOST_SOURCE, type: 'ping' })
|
|
269
|
+
this.pingIntervalId = window.setInterval(() => {
|
|
270
|
+
if (this.isReady) {
|
|
271
|
+
this.stopPingPolling()
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
this.postToIframe({ source: HOSTED_PREVIEW_HOST_SOURCE, type: 'ping' })
|
|
275
|
+
}, 500)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private stopPingPolling(): void {
|
|
279
|
+
if (this.pingIntervalId !== null) {
|
|
280
|
+
window.clearInterval(this.pingIntervalId)
|
|
281
|
+
this.pingIntervalId = null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private flushPendingCommands(): void {
|
|
286
|
+
for (const cmd of this.pendingCommands) {
|
|
287
|
+
this.postToIframe(cmd)
|
|
288
|
+
}
|
|
289
|
+
this.pendingCommands = []
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private emit<K extends EmbedEventType>(
|
|
293
|
+
type: K,
|
|
294
|
+
data: EmbedEventMap[K]
|
|
295
|
+
): void {
|
|
296
|
+
this.handlers.get(type)?.forEach((handler) => {
|
|
297
|
+
;(handler as EmbedEventHandler<K>)(data)
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private handleMessage(event: MessageEvent<unknown>): void {
|
|
302
|
+
const data = event.data as HostedPreviewOutgoingMessage
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
!data ||
|
|
306
|
+
typeof data !== 'object' ||
|
|
307
|
+
data.source !== HOSTED_PREVIEW_VIEWER_SOURCE
|
|
308
|
+
) {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate the message origin matches our iframe
|
|
313
|
+
if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
switch (data.type) {
|
|
318
|
+
case 'pong':
|
|
319
|
+
this.stopPingPolling()
|
|
320
|
+
this.sceneId = data.sceneId
|
|
321
|
+
this.cameras = data.cameras
|
|
322
|
+
this.isReady = true
|
|
323
|
+
this.flushPendingCommands()
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
case 'viewer_event':
|
|
327
|
+
this.handleViewerEvent(data.event)
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
case 'interaction_event':
|
|
331
|
+
this.emit('interaction_event', {
|
|
332
|
+
interactionId: data.interactionId,
|
|
333
|
+
eventName: data.eventName,
|
|
334
|
+
payload: data.payload
|
|
335
|
+
})
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private handleViewerEvent(event: ViewerInteractionEvent): void {
|
|
341
|
+
switch (event.type) {
|
|
342
|
+
case 'viewer_ready':
|
|
343
|
+
this.isReady = true
|
|
344
|
+
this.flushPendingCommands()
|
|
345
|
+
this.emit('viewer_ready', undefined as void)
|
|
346
|
+
break
|
|
347
|
+
case 'model_loaded':
|
|
348
|
+
this.emit('model_loaded', undefined as void)
|
|
349
|
+
break
|
|
350
|
+
case 'camera_changed':
|
|
351
|
+
this.emit('camera_changed', { cameraId: event.cameraId })
|
|
352
|
+
break
|
|
353
|
+
case 'auto_rotate_changed':
|
|
354
|
+
this.emit('auto_rotate_changed', { enabled: event.enabled })
|
|
355
|
+
break
|
|
356
|
+
case 'initial_framing_completed':
|
|
357
|
+
// Translate to model_loaded for embed consumers - signals scene is visible
|
|
358
|
+
this.emit('model_loaded', undefined as void)
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { VectrealEmbed } from './embed'
|
|
2
|
+
export type {
|
|
3
|
+
EmbedEventHandler,
|
|
4
|
+
EmbedEventMap,
|
|
5
|
+
EmbedEventType,
|
|
6
|
+
EmbedOptions,
|
|
7
|
+
EmbedReadyInfo,
|
|
8
|
+
SetTransitionOptions
|
|
9
|
+
} from './embed'
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
HOSTED_PREVIEW_HOST_SOURCE,
|
|
13
|
+
HOSTED_PREVIEW_VIEWER_SOURCE,
|
|
14
|
+
isHostedPreviewIncomingMessage,
|
|
15
|
+
isViewerCommand
|
|
16
|
+
} from './protocol'
|
|
17
|
+
export type {
|
|
18
|
+
EmbedCameraDescriptor,
|
|
19
|
+
HostedPreviewCustomEventMessage,
|
|
20
|
+
HostedPreviewHostMessage,
|
|
21
|
+
HostedPreviewIncomingMessage,
|
|
22
|
+
HostedPreviewOutgoingMessage,
|
|
23
|
+
HostedPreviewPingMessage,
|
|
24
|
+
HostedPreviewPongMessage,
|
|
25
|
+
HostedPreviewScrollProgressMessage,
|
|
26
|
+
HostedPreviewViewerCommandMessage,
|
|
27
|
+
HostedPreviewViewerEventMessage
|
|
28
|
+
} from './protocol'
|