@scenar/embed 0.3.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/LICENSE +190 -0
- package/README.md +69 -0
- package/define.d.ts +2 -0
- package/define.d.ts.map +1 -0
- package/define.js +8 -0
- package/define.js.map +1 -0
- package/element.d.ts +55 -0
- package/element.d.ts.map +1 -0
- package/element.js +129 -0
- package/element.js.map +1 -0
- package/embed.global.js +1 -0
- package/global.d.ts +9 -0
- package/global.d.ts.map +1 -0
- package/global.js +10 -0
- package/global.js.map +1 -0
- package/index.d.ts +7 -0
- package/index.d.ts.map +1 -0
- package/index.js +11 -0
- package/index.js.map +1 -0
- package/mount.d.ts +40 -0
- package/mount.d.ts.map +1 -0
- package/mount.js +63 -0
- package/mount.js.map +1 -0
- package/package.json +61 -0
- package/react.d.ts +35 -0
- package/react.d.ts.map +1 -0
- package/react.js +58 -0
- package/react.js.map +1 -0
- package/resolve.d.ts +57 -0
- package/resolve.d.ts.map +1 -0
- package/resolve.js +57 -0
- package/resolve.js.map +1 -0
- package/src/define.ts +8 -0
- package/src/element.test.ts +108 -0
- package/src/element.ts +148 -0
- package/src/global.ts +11 -0
- package/src/index.ts +25 -0
- package/src/mount.test.ts +202 -0
- package/src/mount.ts +113 -0
- package/src/react.test.tsx +89 -0
- package/src/react.tsx +121 -0
- package/src/resolve.test.ts +77 -0
- package/src/resolve.ts +80 -0
package/mount.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createEmbedHostController, } from "@scenar/core";
|
|
2
|
+
import { applyThemeToSrc, originFromSrc, resolveTheme, } from "./resolve.js";
|
|
3
|
+
/** Whether the host page is in dark mode (the `dark` class on `<html>`). */
|
|
4
|
+
function hostPrefersDark() {
|
|
5
|
+
return (typeof document !== "undefined" &&
|
|
6
|
+
document.documentElement.classList.contains("dark"));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wire an existing iframe to a hosted Scenar tour: set its themed `src`, pin the
|
|
10
|
+
* embed origin, adopt the embed's reported aspect ratio, and (for `theme:auto`)
|
|
11
|
+
* track the host's dark mode.
|
|
12
|
+
*
|
|
13
|
+
* This is the one place the loader's behavior lives. The `<scenar-embed>`
|
|
14
|
+
* element and the React `<ScenarEmbed>` are thin adapters that create the iframe
|
|
15
|
+
* + own layout, then hand it here — so neither re-implements the bridge, exactly
|
|
16
|
+
* as {@link createEmbedHostController} is shared by the console and this loader.
|
|
17
|
+
*
|
|
18
|
+
* The caller owns the iframe element (and removes it for full teardown); this
|
|
19
|
+
* function owns only the listeners it adds, all released by {@link EmbedMount.destroy}.
|
|
20
|
+
*/
|
|
21
|
+
export function createEmbedMount(iframe, options) {
|
|
22
|
+
const { src, theme = "auto", onAspectRatio, onEvent } = options;
|
|
23
|
+
const origin = originFromSrc(src);
|
|
24
|
+
// Track the last applied theme so an unrelated `<html>` class mutation never
|
|
25
|
+
// triggers a needless reload — only a real light/dark flip reassigns `src`.
|
|
26
|
+
let appliedTheme = null;
|
|
27
|
+
const applyTheme = () => {
|
|
28
|
+
const resolved = resolveTheme(theme, hostPrefersDark());
|
|
29
|
+
if (resolved === appliedTheme)
|
|
30
|
+
return;
|
|
31
|
+
appliedTheme = resolved;
|
|
32
|
+
iframe.src = applyThemeToSrc(src, resolved);
|
|
33
|
+
};
|
|
34
|
+
const controller = createEmbedHostController({ iframe, origin }, {
|
|
35
|
+
onEvent: (event) => {
|
|
36
|
+
if (event.type === "resize") {
|
|
37
|
+
onAspectRatio?.({ widthPx: event.widthPx, heightPx: event.heightPx });
|
|
38
|
+
}
|
|
39
|
+
onEvent?.(event);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
let observer;
|
|
43
|
+
if (theme === "auto" &&
|
|
44
|
+
typeof MutationObserver !== "undefined" &&
|
|
45
|
+
typeof document !== "undefined") {
|
|
46
|
+
observer = new MutationObserver(applyTheme);
|
|
47
|
+
observer.observe(document.documentElement, {
|
|
48
|
+
attributes: true,
|
|
49
|
+
attributeFilter: ["class"],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Assign the initial themed src last, so the controller's listener is already
|
|
53
|
+
// attached when the frame begins loading.
|
|
54
|
+
applyTheme();
|
|
55
|
+
return {
|
|
56
|
+
controller,
|
|
57
|
+
destroy() {
|
|
58
|
+
observer?.disconnect();
|
|
59
|
+
controller.destroy();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=mount.js.map
|
package/mount.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,yBAAyB,GAC1B,MAAM,cAAc,CAAC;AACtB,OAAO,EAEL,eAAe,EACf,aAAa,EACb,YAAY,GACb,MAAM,cAAc,CAAC;AA4BtB,4EAA4E;AAC5E,SAAS,eAAe;IACtB,OAAO,CACL,OAAO,QAAQ,KAAK,WAAW;QAC/B,QAAQ,CAAC,eAAe,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,CACpD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAAyB,EACzB,OAA0B;IAE1B,MAAM,EAAE,GAAG,EAAE,KAAK,GAAG,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAChE,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAElC,6EAA6E;IAC7E,4EAA4E;IAC5E,IAAI,YAAY,GAA4B,IAAI,CAAC;IACjD,MAAM,UAAU,GAAG,GAAS,EAAE;QAC5B,MAAM,QAAQ,GAAG,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;QACxD,IAAI,QAAQ,KAAK,YAAY;YAAE,OAAO;QACtC,YAAY,GAAG,QAAQ,CAAC;QACxB,MAAM,CAAC,GAAG,GAAG,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,MAAM,UAAU,GAAG,yBAAyB,CAC1C,EAAE,MAAM,EAAE,MAAM,EAAE,EAClB;QACE,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YACjB,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC5B,aAAa,EAAE,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;YACxE,CAAC;YACD,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;QACnB,CAAC;KACF,CACF,CAAC;IAEF,IAAI,QAAsC,CAAC;IAC3C,IACE,KAAK,KAAK,MAAM;QAChB,OAAO,gBAAgB,KAAK,WAAW;QACvC,OAAO,QAAQ,KAAK,WAAW,EAC/B,CAAC;QACD,QAAQ,GAAG,IAAI,gBAAgB,CAAC,UAAU,CAAC,CAAC;QAC5C,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,eAAe,EAAE;YACzC,UAAU,EAAE,IAAI;YAChB,eAAe,EAAE,CAAC,OAAO,CAAC;SAC3B,CAAC,CAAC;IACL,CAAC;IAED,8EAA8E;IAC9E,0CAA0C;IAC1C,UAAU,EAAE,CAAC;IAEb,OAAO;QACL,UAAU;QACV,OAAO;YACL,QAAQ,EAAE,UAAU,EAAE,CAAC;YACvB,UAAU,CAAC,OAAO,EAAE,CAAC;QACvB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scenar/embed",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Drop-in host-side embedding for Scenar tours — a framework-agnostic <scenar-embed> web component and a matching React wrapper.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./dist/define.js",
|
|
9
|
+
"./dist/embed.global.js"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/stigmer/scenar.git",
|
|
14
|
+
"directory": "packages/embed"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"scenar",
|
|
18
|
+
"scenario",
|
|
19
|
+
"embed",
|
|
20
|
+
"iframe",
|
|
21
|
+
"web-component",
|
|
22
|
+
"react",
|
|
23
|
+
"demo",
|
|
24
|
+
"playback"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./index.d.ts",
|
|
31
|
+
"import": "./index.js",
|
|
32
|
+
"default": "./index.js"
|
|
33
|
+
},
|
|
34
|
+
"./define": {
|
|
35
|
+
"types": "./define.d.ts",
|
|
36
|
+
"import": "./define.js",
|
|
37
|
+
"default": "./define.js"
|
|
38
|
+
},
|
|
39
|
+
"./react": {
|
|
40
|
+
"types": "./react.d.ts",
|
|
41
|
+
"import": "./react.js",
|
|
42
|
+
"default": "./react.js"
|
|
43
|
+
},
|
|
44
|
+
"./loader": "./embed.global.js"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@scenar/core": "0.3.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"react": ">=18.0.0",
|
|
51
|
+
"react-dom": ">=18.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"react-dom": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/react.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import type { ScenarEmbedEvent } from "@scenar/core";
|
|
3
|
+
import { type EmbedSource, type ScenarEmbedTheme } from "./resolve.js";
|
|
4
|
+
/** Imperative transport exposed through a ref, driving the bridge. */
|
|
5
|
+
export interface ScenarEmbedHandle {
|
|
6
|
+
play(): void;
|
|
7
|
+
pause(): void;
|
|
8
|
+
seek(timeMs: number): void;
|
|
9
|
+
setMuted(muted: boolean): void;
|
|
10
|
+
setVolume(volume: number): void;
|
|
11
|
+
}
|
|
12
|
+
export interface ScenarEmbedProps extends EmbedSource {
|
|
13
|
+
/** Accessible iframe title (defaults to a generic label). */
|
|
14
|
+
readonly title?: string;
|
|
15
|
+
/** Theme strategy (default `auto`: track the host's `dark` class). */
|
|
16
|
+
readonly theme?: ScenarEmbedTheme;
|
|
17
|
+
/** Receive every well-formed event from the embed (ready/progress/etc.). */
|
|
18
|
+
readonly onEvent?: (event: ScenarEmbedEvent) => void;
|
|
19
|
+
/** Class applied to the responsive wrapper. */
|
|
20
|
+
readonly className?: string;
|
|
21
|
+
/** Extra styles merged onto the responsive wrapper. */
|
|
22
|
+
readonly style?: CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Embed a hosted Scenar tour as a responsive, theme-synced iframe — the React
|
|
26
|
+
* sibling of `<scenar-embed>`. It is a thin adapter over {@link createEmbedMount}
|
|
27
|
+
* (it does not render the custom element), so it carries no protocol logic and
|
|
28
|
+
* stays free of web-component-in-React SSR/typing friction.
|
|
29
|
+
*
|
|
30
|
+
* SSR-safe: the iframe renders without a `src` on the server and on the first
|
|
31
|
+
* client render, so hydration never mismatches; the effect then assigns the
|
|
32
|
+
* themed `src` and adopts the embed's reported aspect ratio.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ScenarEmbed: import("react").ForwardRefExoticComponent<ScenarEmbedProps & import("react").RefAttributes<ScenarEmbedHandle>>;
|
|
35
|
+
//# sourceMappingURL=react.d.ts.map
|
package/react.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAMnB,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EAItB,MAAM,cAAc,CAAC;AAKtB,sEAAsE;AACtE,MAAM,WAAW,iBAAiB;IAChC,IAAI,IAAI,IAAI,CAAC;IACb,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IACrD,+CAA+C;IAC/C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,uDAAuD;IACvD,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;CAChC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,gHAiEvB,CAAC"}
|
package/react.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from "react";
|
|
4
|
+
import { createEmbedMount } from "./mount.js";
|
|
5
|
+
import { EMBED_BASE_ASPECT_HEIGHT, EMBED_BASE_ASPECT_WIDTH, resolveEmbedSrc, } from "./resolve.js";
|
|
6
|
+
/** Accessible iframe title when the host passes none. */
|
|
7
|
+
const DEFAULT_TITLE = "Interactive product tour";
|
|
8
|
+
/**
|
|
9
|
+
* Embed a hosted Scenar tour as a responsive, theme-synced iframe — the React
|
|
10
|
+
* sibling of `<scenar-embed>`. It is a thin adapter over {@link createEmbedMount}
|
|
11
|
+
* (it does not render the custom element), so it carries no protocol logic and
|
|
12
|
+
* stays free of web-component-in-React SSR/typing friction.
|
|
13
|
+
*
|
|
14
|
+
* SSR-safe: the iframe renders without a `src` on the server and on the first
|
|
15
|
+
* client render, so hydration never mismatches; the effect then assigns the
|
|
16
|
+
* themed `src` and adopts the embed's reported aspect ratio.
|
|
17
|
+
*/
|
|
18
|
+
export const ScenarEmbed = forwardRef(function ScenarEmbed({ src, id, base, title, theme = "auto", onEvent, className, style }, ref) {
|
|
19
|
+
const iframeRef = useRef(null);
|
|
20
|
+
const mountRef = useRef(null);
|
|
21
|
+
const [ratio, setRatio] = useState(`${EMBED_BASE_ASPECT_WIDTH} / ${EMBED_BASE_ASPECT_HEIGHT}`);
|
|
22
|
+
// Latest onEvent without forcing a re-mount when the callback identity changes.
|
|
23
|
+
const onEventRef = useRef(onEvent);
|
|
24
|
+
onEventRef.current = onEvent;
|
|
25
|
+
const resolvedSrc = resolveEmbedSrc({ src, id, base });
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const iframe = iframeRef.current;
|
|
28
|
+
if (!iframe)
|
|
29
|
+
return;
|
|
30
|
+
const mount = createEmbedMount(iframe, {
|
|
31
|
+
src: resolvedSrc,
|
|
32
|
+
theme,
|
|
33
|
+
onAspectRatio: ({ widthPx, heightPx }) => setRatio(`${widthPx} / ${heightPx}`),
|
|
34
|
+
onEvent: (event) => onEventRef.current?.(event),
|
|
35
|
+
});
|
|
36
|
+
mountRef.current = mount;
|
|
37
|
+
return () => {
|
|
38
|
+
mount.destroy();
|
|
39
|
+
mountRef.current = null;
|
|
40
|
+
};
|
|
41
|
+
}, [resolvedSrc, theme]);
|
|
42
|
+
useImperativeHandle(ref, () => ({
|
|
43
|
+
play: () => mountRef.current?.controller.play(),
|
|
44
|
+
pause: () => mountRef.current?.controller.pause(),
|
|
45
|
+
seek: (timeMs) => mountRef.current?.controller.seek(timeMs),
|
|
46
|
+
setMuted: (muted) => mountRef.current?.controller.setMuted(muted),
|
|
47
|
+
setVolume: (volume) => mountRef.current?.controller.setVolume(volume),
|
|
48
|
+
}), []);
|
|
49
|
+
return (_jsx("div", { className: className, style: { position: "relative", width: "100%", aspectRatio: ratio, ...style }, children: _jsx("iframe", { ref: iframeRef, title: title ?? DEFAULT_TITLE, loading: "lazy", allow: "autoplay; fullscreen", allowFullScreen: true, style: {
|
|
50
|
+
position: "absolute",
|
|
51
|
+
inset: 0,
|
|
52
|
+
width: "100%",
|
|
53
|
+
height: "100%",
|
|
54
|
+
border: 0,
|
|
55
|
+
borderRadius: "inherit",
|
|
56
|
+
} }) }));
|
|
57
|
+
});
|
|
58
|
+
//# sourceMappingURL=react.js.map
|
package/react.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"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"}
|
package/resolve.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free helpers shared by every adapter (the `<scenar-embed>` element,
|
|
3
|
+
* the React wrapper, and the IIFE global). Keeping URL/theme/origin resolution
|
|
4
|
+
* here — with zero DOM access — makes each rule unit-testable in isolation and
|
|
5
|
+
* guarantees the element and the React component resolve identically.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* How the embed's color theme is chosen.
|
|
9
|
+
*
|
|
10
|
+
* - `auto` (default) — track the host page's `dark` class on `<html>`.
|
|
11
|
+
* - `light` / `dark` — pin the theme regardless of the host.
|
|
12
|
+
*/
|
|
13
|
+
export type ScenarEmbedTheme = "auto" | "light" | "dark";
|
|
14
|
+
/**
|
|
15
|
+
* The tour's recorded canonical viewport (mirrors `DEFAULT_VIEWPORT` in
|
|
16
|
+
* `@scenar/cli`). Used as the pre-handshake aspect-ratio baseline, before the
|
|
17
|
+
* embed reports its exact rendered size over the `resize` event.
|
|
18
|
+
*/
|
|
19
|
+
export declare const EMBED_BASE_ASPECT_WIDTH = 896;
|
|
20
|
+
export declare const EMBED_BASE_ASPECT_HEIGHT = 480;
|
|
21
|
+
/**
|
|
22
|
+
* Where a tour is served from. Pass `src` (the full embed URL) directly, or a
|
|
23
|
+
* `base` + `id` pair that resolves to `<base>/<id>/` — the convenience the React
|
|
24
|
+
* wrapper offers so docs can reference tours by slug.
|
|
25
|
+
*/
|
|
26
|
+
export interface EmbedSource {
|
|
27
|
+
/** The full embed URL (absolute). Takes precedence over `id` + `base`. */
|
|
28
|
+
readonly src?: string;
|
|
29
|
+
/** The published tour slug, resolved against `base`. */
|
|
30
|
+
readonly id?: string;
|
|
31
|
+
/** The base URL the `id` is resolved under (e.g. a GitHub Pages repo root). */
|
|
32
|
+
readonly base?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve an {@link EmbedSource} to a single absolute embed URL (without the
|
|
36
|
+
* theme query — that is layered on at mount time). Throws a developer-actionable
|
|
37
|
+
* error if neither form is supplied.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveEmbedSrc(source: EmbedSource): string;
|
|
40
|
+
/**
|
|
41
|
+
* Collapse a {@link ScenarEmbedTheme} to the concrete `light`/`dark` the embed
|
|
42
|
+
* understands. `auto` defers to `isDark` (the host's resolved state), which the
|
|
43
|
+
* caller reads from the DOM — this function stays pure.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveTheme(theme: ScenarEmbedTheme, isDark: boolean): "light" | "dark";
|
|
46
|
+
/**
|
|
47
|
+
* Stamp the resolved theme onto an embed URL as `?theme=…`. The packed embed
|
|
48
|
+
* reads this query at load to apply its palette; changing it reloads the frame
|
|
49
|
+
* in the new theme (acceptable for an autoplay demo).
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyThemeToSrc(src: string, theme: "light" | "dark"): string;
|
|
52
|
+
/**
|
|
53
|
+
* The embed's exact origin (scheme + host + port, no path) — used to pin both
|
|
54
|
+
* inbound events and outbound commands. Derived from the embed URL.
|
|
55
|
+
*/
|
|
56
|
+
export declare function originFromSrc(src: string): string;
|
|
57
|
+
//# sourceMappingURL=resolve.d.ts.map
|
package/resolve.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAE5C;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAO3D;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAIvF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAI5E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEjD"}
|
package/resolve.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free helpers shared by every adapter (the `<scenar-embed>` element,
|
|
3
|
+
* the React wrapper, and the IIFE global). Keeping URL/theme/origin resolution
|
|
4
|
+
* here — with zero DOM access — makes each rule unit-testable in isolation and
|
|
5
|
+
* guarantees the element and the React component resolve identically.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The tour's recorded canonical viewport (mirrors `DEFAULT_VIEWPORT` in
|
|
9
|
+
* `@scenar/cli`). Used as the pre-handshake aspect-ratio baseline, before the
|
|
10
|
+
* embed reports its exact rendered size over the `resize` event.
|
|
11
|
+
*/
|
|
12
|
+
export const EMBED_BASE_ASPECT_WIDTH = 896;
|
|
13
|
+
export const EMBED_BASE_ASPECT_HEIGHT = 480;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve an {@link EmbedSource} to a single absolute embed URL (without the
|
|
16
|
+
* theme query — that is layered on at mount time). Throws a developer-actionable
|
|
17
|
+
* error if neither form is supplied.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveEmbedSrc(source) {
|
|
20
|
+
if (source.src)
|
|
21
|
+
return source.src;
|
|
22
|
+
if (source.id && source.base) {
|
|
23
|
+
const base = source.base.endsWith("/") ? source.base : `${source.base}/`;
|
|
24
|
+
return `${base}${source.id}/`;
|
|
25
|
+
}
|
|
26
|
+
throw new Error("ScenarEmbed: provide `src`, or both `id` and `base`.");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Collapse a {@link ScenarEmbedTheme} to the concrete `light`/`dark` the embed
|
|
30
|
+
* understands. `auto` defers to `isDark` (the host's resolved state), which the
|
|
31
|
+
* caller reads from the DOM — this function stays pure.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveTheme(theme, isDark) {
|
|
34
|
+
if (theme === "light")
|
|
35
|
+
return "light";
|
|
36
|
+
if (theme === "dark")
|
|
37
|
+
return "dark";
|
|
38
|
+
return isDark ? "dark" : "light";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Stamp the resolved theme onto an embed URL as `?theme=…`. The packed embed
|
|
42
|
+
* reads this query at load to apply its palette; changing it reloads the frame
|
|
43
|
+
* in the new theme (acceptable for an autoplay demo).
|
|
44
|
+
*/
|
|
45
|
+
export function applyThemeToSrc(src, theme) {
|
|
46
|
+
const url = new URL(src);
|
|
47
|
+
url.searchParams.set("theme", theme);
|
|
48
|
+
return url.toString();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The embed's exact origin (scheme + host + port, no path) — used to pin both
|
|
52
|
+
* inbound events and outbound commands. Derived from the embed URL.
|
|
53
|
+
*/
|
|
54
|
+
export function originFromSrc(src) {
|
|
55
|
+
return new URL(src).origin;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=resolve.js.map
|
package/resolve.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAC3C,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAgB5C;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,MAAmB;IACjD,IAAI,MAAM,CAAC,GAAG;QAAE,OAAO,MAAM,CAAC,GAAG,CAAC;IAClC,IAAI,MAAM,CAAC,EAAE,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,GAAG,CAAC;QACzE,OAAO,GAAG,IAAI,GAAG,MAAM,CAAC,EAAE,GAAG,CAAC;IAChC,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;AAC1E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,KAAuB,EAAE,MAAe;IACnE,IAAI,KAAK,KAAK,OAAO;QAAE,OAAO,OAAO,CAAC;IACtC,IAAI,KAAK,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACpC,OAAO,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;AACnC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW,EAAE,KAAuB;IAClE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACzB,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACrC,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAC;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;AAC7B,CAAC"}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effecting entry: importing `@scenar/embed/define` registers the
|
|
3
|
+
* `<scenar-embed>` custom element. For bundler hosts that want the tag available
|
|
4
|
+
* without calling {@link defineScenarEmbed} themselves.
|
|
5
|
+
*/
|
|
6
|
+
import { defineScenarEmbed } from "./element.js";
|
|
7
|
+
|
|
8
|
+
defineScenarEmbed();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type ScenarEmbedElement,
|
|
4
|
+
SCENAR_EMBED_TAG,
|
|
5
|
+
defineScenarEmbed,
|
|
6
|
+
} from "./element.js";
|
|
7
|
+
|
|
8
|
+
const SRC = "https://embed.example/tour/";
|
|
9
|
+
const ORIGIN = "https://embed.example";
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
defineScenarEmbed();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
document.documentElement.className = "";
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create, configure, and connect a `<scenar-embed>`; return it, its iframe, and
|
|
22
|
+
* a sentinel `contentWindow` (with a no-op postMessage so the controller's
|
|
23
|
+
* teardown `destroy` command is harmless, and so the per-frame `event.source`
|
|
24
|
+
* check is genuinely exercised).
|
|
25
|
+
*/
|
|
26
|
+
function mountElement(
|
|
27
|
+
attrs: Record<string, string>,
|
|
28
|
+
): { el: ScenarEmbedElement; iframe: HTMLIFrameElement; frame: MessageEventSource } {
|
|
29
|
+
const el = document.createElement(SCENAR_EMBED_TAG) as ScenarEmbedElement;
|
|
30
|
+
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
|
|
31
|
+
document.body.appendChild(el);
|
|
32
|
+
const iframe = el.querySelector("iframe") as HTMLIFrameElement;
|
|
33
|
+
const frame = { postMessage: () => {} } as unknown as MessageEventSource;
|
|
34
|
+
Object.defineProperty(iframe, "contentWindow", { value: frame, configurable: true });
|
|
35
|
+
return { el, iframe, frame };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function postResize(widthPx: number, heightPx: number, source: MessageEventSource): void {
|
|
39
|
+
const event = new MessageEvent("message", {
|
|
40
|
+
data: { source: "scenar-embed", v: 1, type: "resize", widthPx, heightPx },
|
|
41
|
+
origin: ORIGIN,
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(event, "source", { value: source, configurable: true });
|
|
44
|
+
window.dispatchEvent(event);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("defineScenarEmbed", () => {
|
|
48
|
+
it("registers the tag once and is idempotent", () => {
|
|
49
|
+
defineScenarEmbed();
|
|
50
|
+
defineScenarEmbed();
|
|
51
|
+
expect(customElements.get(SCENAR_EMBED_TAG)).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("<scenar-embed>", () => {
|
|
56
|
+
it("renders a lazy, autoplay-capable iframe inside a responsive box", () => {
|
|
57
|
+
const { el, iframe } = mountElement({ src: SRC, title: "Welcome tour" });
|
|
58
|
+
|
|
59
|
+
expect(iframe).toBeTruthy();
|
|
60
|
+
expect(iframe.getAttribute("loading")).toBe("lazy");
|
|
61
|
+
expect(iframe.getAttribute("allow")).toBe("autoplay; fullscreen");
|
|
62
|
+
expect(iframe.hasAttribute("allowfullscreen")).toBe(true);
|
|
63
|
+
expect(iframe.getAttribute("title")).toBe("Welcome tour");
|
|
64
|
+
expect(el.style.position).toBe("relative");
|
|
65
|
+
expect(el.style.aspectRatio).toBe("896 / 480");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to a generic accessible title", () => {
|
|
69
|
+
const { iframe } = mountElement({ src: SRC });
|
|
70
|
+
expect(iframe.getAttribute("title")).toBe("Interactive product tour");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("frames the tour with the host theme baked into src", () => {
|
|
74
|
+
document.documentElement.classList.add("dark");
|
|
75
|
+
const { iframe } = mountElement({ src: SRC });
|
|
76
|
+
expect(iframe.src).toBe("https://embed.example/tour/?theme=dark");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("updates the iframe title when the attribute changes (no reload)", () => {
|
|
80
|
+
const { el, iframe } = mountElement({ src: SRC, title: "First" });
|
|
81
|
+
const srcBefore = iframe.src;
|
|
82
|
+
el.setAttribute("title", "Second");
|
|
83
|
+
expect(iframe.getAttribute("title")).toBe("Second");
|
|
84
|
+
expect(iframe.src).toBe(srcBefore);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("adopts the reported aspect ratio and re-dispatches embed events", () => {
|
|
88
|
+
const { el, frame } = mountElement({ src: SRC });
|
|
89
|
+
|
|
90
|
+
const seen: unknown[] = [];
|
|
91
|
+
el.addEventListener("scenar:resize", (e) => seen.push((e as CustomEvent).detail));
|
|
92
|
+
|
|
93
|
+
postResize(900, 520, frame);
|
|
94
|
+
|
|
95
|
+
expect(el.style.aspectRatio).toBe("900 / 520");
|
|
96
|
+
expect(seen).toEqual([{ type: "resize", widthPx: 900, heightPx: 520 }]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("tears down on disconnect", () => {
|
|
100
|
+
const { el, frame } = mountElement({ src: SRC });
|
|
101
|
+
postResize(900, 520, frame);
|
|
102
|
+
expect(el.style.aspectRatio).toBe("900 / 520");
|
|
103
|
+
|
|
104
|
+
el.remove();
|
|
105
|
+
postResize(640, 360, frame);
|
|
106
|
+
expect(el.style.aspectRatio).toBe("900 / 520");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/element.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type EmbedMount, createEmbedMount } from "./mount.js";
|
|
2
|
+
import {
|
|
3
|
+
type ScenarEmbedTheme,
|
|
4
|
+
EMBED_BASE_ASPECT_HEIGHT,
|
|
5
|
+
EMBED_BASE_ASPECT_WIDTH,
|
|
6
|
+
} from "./resolve.js";
|
|
7
|
+
|
|
8
|
+
/** The custom element tag name. */
|
|
9
|
+
export const SCENAR_EMBED_TAG = "scenar-embed";
|
|
10
|
+
|
|
11
|
+
/** Accessible iframe title when the host sets none. */
|
|
12
|
+
const DEFAULT_TITLE = "Interactive product tour";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SSR-safe base: subclassing `HTMLElement` evaluates at module load, which would
|
|
16
|
+
* throw in a non-DOM environment (server render). Fall back to a stub so this
|
|
17
|
+
* module can be imported anywhere; the element is only ever *registered* in a
|
|
18
|
+
* browser via {@link defineScenarEmbed}.
|
|
19
|
+
*/
|
|
20
|
+
const HTMLElementBase: typeof HTMLElement =
|
|
21
|
+
typeof HTMLElement !== "undefined"
|
|
22
|
+
? HTMLElement
|
|
23
|
+
: (class {} as unknown as typeof HTMLElement);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `<scenar-embed src="…">` — a framework-agnostic, drop-in embed for a hosted
|
|
27
|
+
* Scenar tour. It owns an internal iframe and its responsive box; all bridge
|
|
28
|
+
* behavior (origin pinning, theme sync, resize → aspect-ratio) comes from
|
|
29
|
+
* {@link createEmbedMount}, so the element holds no protocol logic of its own.
|
|
30
|
+
*
|
|
31
|
+
* Attributes:
|
|
32
|
+
* - `src` (required) — the absolute embed URL.
|
|
33
|
+
* - `title` — accessible iframe title (defaults to a generic label).
|
|
34
|
+
* - `theme` — `auto` (default) | `light` | `dark`.
|
|
35
|
+
*
|
|
36
|
+
* Events: re-dispatches every embed event as a DOM `CustomEvent` named
|
|
37
|
+
* `scenar:<type>` (e.g. `scenar:ready`, `scenar:completed`) with the event in
|
|
38
|
+
* `detail`, so a vanilla host can listen without touching `postMessage`.
|
|
39
|
+
*
|
|
40
|
+
* Imperative transport (`play`/`pause`/`seek`/`setMuted`/`setVolume`) delegates
|
|
41
|
+
* to the bridge; calls before the element has a `src` are no-ops.
|
|
42
|
+
*/
|
|
43
|
+
export class ScenarEmbedElement extends HTMLElementBase {
|
|
44
|
+
static get observedAttributes(): string[] {
|
|
45
|
+
return ["src", "title", "theme"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private iframe: HTMLIFrameElement | null = null;
|
|
49
|
+
private mount: EmbedMount | null = null;
|
|
50
|
+
|
|
51
|
+
connectedCallback(): void {
|
|
52
|
+
if (!this.iframe) this.render();
|
|
53
|
+
this.attach();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnectedCallback(): void {
|
|
57
|
+
this.mount?.destroy();
|
|
58
|
+
this.mount = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
62
|
+
if (oldValue === newValue) return;
|
|
63
|
+
if (name === "title") {
|
|
64
|
+
if (this.iframe) this.iframe.setAttribute("title", this.titleAttr());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// A new src or theme rebuilds the bridge (and reloads the frame).
|
|
68
|
+
if (this.isConnected) this.attach();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
play(): void {
|
|
72
|
+
this.mount?.controller.play();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pause(): void {
|
|
76
|
+
this.mount?.controller.pause();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
seek(timeMs: number): void {
|
|
80
|
+
this.mount?.controller.seek(timeMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setMuted(muted: boolean): void {
|
|
84
|
+
this.mount?.controller.setMuted(muted);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setVolume(volume: number): void {
|
|
88
|
+
this.mount?.controller.setVolume(volume);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private titleAttr(): string {
|
|
92
|
+
return this.getAttribute("title") ?? DEFAULT_TITLE;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private themeAttr(): ScenarEmbedTheme {
|
|
96
|
+
const theme = this.getAttribute("theme");
|
|
97
|
+
return theme === "light" || theme === "dark" ? theme : "auto";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private setAspectRatio(width: number, height: number): void {
|
|
101
|
+
this.style.aspectRatio = `${width} / ${height}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build the host box + the internal iframe (once). */
|
|
105
|
+
private render(): void {
|
|
106
|
+
this.style.display = "block";
|
|
107
|
+
this.style.position = "relative";
|
|
108
|
+
this.style.width = "100%";
|
|
109
|
+
this.setAspectRatio(EMBED_BASE_ASPECT_WIDTH, EMBED_BASE_ASPECT_HEIGHT);
|
|
110
|
+
|
|
111
|
+
const iframe = document.createElement("iframe");
|
|
112
|
+
iframe.setAttribute("title", this.titleAttr());
|
|
113
|
+
iframe.setAttribute("loading", "lazy");
|
|
114
|
+
iframe.setAttribute("allow", "autoplay; fullscreen");
|
|
115
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
116
|
+
iframe.style.cssText =
|
|
117
|
+
"position:absolute;inset:0;width:100%;height:100%;border:0;border-radius:inherit";
|
|
118
|
+
|
|
119
|
+
this.appendChild(iframe);
|
|
120
|
+
this.iframe = iframe;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** (Re)create the bridge for the current `src`/`theme`. */
|
|
124
|
+
private attach(): void {
|
|
125
|
+
const src = this.getAttribute("src");
|
|
126
|
+
if (!src || !this.iframe) return;
|
|
127
|
+
|
|
128
|
+
this.mount?.destroy();
|
|
129
|
+
this.mount = createEmbedMount(this.iframe, {
|
|
130
|
+
src,
|
|
131
|
+
theme: this.themeAttr(),
|
|
132
|
+
onAspectRatio: ({ widthPx, heightPx }) => this.setAspectRatio(widthPx, heightPx),
|
|
133
|
+
onEvent: (event) =>
|
|
134
|
+
this.dispatchEvent(new CustomEvent(`scenar:${event.type}`, { detail: event })),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Register `<scenar-embed>` (idempotent and SSR-safe). Pass a custom tag name to
|
|
141
|
+
* register under a different element name. A no-op when the registry is absent
|
|
142
|
+
* (server) or the tag is already defined.
|
|
143
|
+
*/
|
|
144
|
+
export function defineScenarEmbed(tagName: string = SCENAR_EMBED_TAG): void {
|
|
145
|
+
if (typeof customElements === "undefined") return;
|
|
146
|
+
if (customElements.get(tagName)) return;
|
|
147
|
+
customElements.define(tagName, ScenarEmbedElement);
|
|
148
|
+
}
|
package/src/global.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The IIFE entry, bundled to `dist/embed.global.js` (see vite.config.ts) — the
|
|
3
|
+
* single pasteable `<script>` for no-bundler hosts. It auto-registers
|
|
4
|
+
* `<scenar-embed>` on load and also exposes the registrar on `window.ScenarEmbed`
|
|
5
|
+
* for hosts that register under a custom tag.
|
|
6
|
+
*/
|
|
7
|
+
import { ScenarEmbedElement, defineScenarEmbed } from "./element.js";
|
|
8
|
+
|
|
9
|
+
defineScenarEmbed();
|
|
10
|
+
|
|
11
|
+
export { ScenarEmbedElement, defineScenarEmbed };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @scenar/embed — public API
|
|
2
|
+
// Drop-in host-side embedding for hosted Scenar tours. Importing this entry has
|
|
3
|
+
// no side effects (it does not register the element); call `defineScenarEmbed()`
|
|
4
|
+
// or import `@scenar/embed/define` to register `<scenar-embed>`.
|
|
5
|
+
|
|
6
|
+
// Custom element + registrar
|
|
7
|
+
export { ScenarEmbedElement, defineScenarEmbed, SCENAR_EMBED_TAG } from "./element.js";
|
|
8
|
+
|
|
9
|
+
// Framework-free mount (the shared core both adapters build on)
|
|
10
|
+
export { createEmbedMount } from "./mount.js";
|
|
11
|
+
export type { EmbedMount, EmbedMountOptions, EmbedAspectRatio } from "./mount.js";
|
|
12
|
+
|
|
13
|
+
// Pure resolution helpers + baseline constants
|
|
14
|
+
export {
|
|
15
|
+
resolveEmbedSrc,
|
|
16
|
+
resolveTheme,
|
|
17
|
+
applyThemeToSrc,
|
|
18
|
+
originFromSrc,
|
|
19
|
+
EMBED_BASE_ASPECT_WIDTH,
|
|
20
|
+
EMBED_BASE_ASPECT_HEIGHT,
|
|
21
|
+
} from "./resolve.js";
|
|
22
|
+
export type { ScenarEmbedTheme, EmbedSource } from "./resolve.js";
|
|
23
|
+
|
|
24
|
+
// The embed event/command types for host consumers (re-exported from @scenar/core)
|
|
25
|
+
export type { ScenarEmbedEvent, ScenarEmbedCommand } from "@scenar/core";
|