@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/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @vctrl/embed
2
+
3
+ [![npm](https://img.shields.io/npm/v/@vctrl/embed)](https://www.npmjs.com/package/@vctrl/embed)
4
+ [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-orange.svg)](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'