@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { type EmbedAspectRatio, type EmbedMount, createEmbedMount } from "./mount.js";
|
|
3
|
+
|
|
4
|
+
const SRC = "https://embed.example/tour/";
|
|
5
|
+
const ORIGIN = "https://embed.example";
|
|
6
|
+
|
|
7
|
+
/** Build a framed Scenar embed resize message (protocol v1). */
|
|
8
|
+
function resizeMessage(widthPx: unknown, heightPx: unknown) {
|
|
9
|
+
return { source: "scenar-embed", v: 1, type: "resize", widthPx, heightPx };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dispatch a window `message` event, attributed to a given origin and source
|
|
14
|
+
* window. The controller adopts a message only when both the origin matches the
|
|
15
|
+
* pinned embed origin and the source is the embed's own iframe window.
|
|
16
|
+
*/
|
|
17
|
+
function post(
|
|
18
|
+
data: unknown,
|
|
19
|
+
{ origin = ORIGIN, source = null as MessageEventSource | null } = {},
|
|
20
|
+
) {
|
|
21
|
+
const event = new MessageEvent("message", { data, origin });
|
|
22
|
+
Object.defineProperty(event, "source", { value: source, configurable: true });
|
|
23
|
+
window.dispatchEvent(event);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A detached iframe never navigates when its `src` is set (no browsing context),
|
|
28
|
+
* so tests can assert on `iframe.src` without jsdom navigation noise. We pin a
|
|
29
|
+
* unique sentinel as its `contentWindow` so the controller's per-frame
|
|
30
|
+
* `event.source` check is genuinely exercised.
|
|
31
|
+
*/
|
|
32
|
+
function makeIframe(): { iframe: HTMLIFrameElement; frame: MessageEventSource } {
|
|
33
|
+
const iframe = document.createElement("iframe");
|
|
34
|
+
// The controller posts commands to contentWindow (e.g. `destroy` on teardown),
|
|
35
|
+
// so the sentinel must carry a postMessage.
|
|
36
|
+
const frame = { postMessage: () => {} } as unknown as MessageEventSource;
|
|
37
|
+
Object.defineProperty(iframe, "contentWindow", { value: frame, configurable: true });
|
|
38
|
+
return { iframe, frame };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Let queued MutationObserver callbacks deliver. */
|
|
42
|
+
function flushMicrotasks(): Promise<void> {
|
|
43
|
+
return new Promise((r) => setTimeout(r, 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let mounts: EmbedMount[] = [];
|
|
47
|
+
function track(mount: EmbedMount): EmbedMount {
|
|
48
|
+
mounts.push(mount);
|
|
49
|
+
return mount;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
document.documentElement.className = "";
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
for (const mount of mounts) mount.destroy();
|
|
58
|
+
mounts = [];
|
|
59
|
+
document.documentElement.className = "";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("createEmbedMount — theme", () => {
|
|
63
|
+
it("applies ?theme=light by default (no dark class on <html>)", () => {
|
|
64
|
+
const { iframe } = makeIframe();
|
|
65
|
+
track(createEmbedMount(iframe, { src: SRC }));
|
|
66
|
+
expect(iframe.src).toBe("https://embed.example/tour/?theme=light");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("applies ?theme=dark when the host is dark", () => {
|
|
70
|
+
document.documentElement.classList.add("dark");
|
|
71
|
+
const { iframe } = makeIframe();
|
|
72
|
+
track(createEmbedMount(iframe, { src: SRC }));
|
|
73
|
+
expect(iframe.src).toBe("https://embed.example/tour/?theme=dark");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("pins a fixed theme regardless of the host (theme=dark)", () => {
|
|
77
|
+
const { iframe } = makeIframe();
|
|
78
|
+
track(createEmbedMount(iframe, { src: SRC, theme: "dark" }));
|
|
79
|
+
expect(iframe.src).toBe("https://embed.example/tour/?theme=dark");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reloads in the new theme when the host toggles dark mode (auto)", async () => {
|
|
83
|
+
const { iframe } = makeIframe();
|
|
84
|
+
track(createEmbedMount(iframe, { src: SRC, theme: "auto" }));
|
|
85
|
+
expect(iframe.src).toContain("theme=light");
|
|
86
|
+
|
|
87
|
+
document.documentElement.classList.add("dark");
|
|
88
|
+
await flushMicrotasks();
|
|
89
|
+
expect(iframe.src).toContain("theme=dark");
|
|
90
|
+
|
|
91
|
+
document.documentElement.classList.remove("dark");
|
|
92
|
+
await flushMicrotasks();
|
|
93
|
+
expect(iframe.src).toContain("theme=light");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not observe the host when the theme is pinned", async () => {
|
|
97
|
+
const { iframe } = makeIframe();
|
|
98
|
+
track(createEmbedMount(iframe, { src: SRC, theme: "light" }));
|
|
99
|
+
document.documentElement.classList.add("dark");
|
|
100
|
+
await flushMicrotasks();
|
|
101
|
+
expect(iframe.src).toContain("theme=light");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("createEmbedMount — resize → aspect ratio", () => {
|
|
106
|
+
it("reports the exact size from a valid resize sent by its own frame", () => {
|
|
107
|
+
const ratios: EmbedAspectRatio[] = [];
|
|
108
|
+
const { iframe, frame } = makeIframe();
|
|
109
|
+
track(createEmbedMount(iframe, { src: SRC, onAspectRatio: (r) => ratios.push(r) }));
|
|
110
|
+
|
|
111
|
+
post(resizeMessage(900, 520), { source: frame });
|
|
112
|
+
|
|
113
|
+
expect(ratios).toEqual([{ widthPx: 900, heightPx: 520 }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("routes each resize only to the frame that sent it (two embeds, one page)", () => {
|
|
117
|
+
const aRatios: EmbedAspectRatio[] = [];
|
|
118
|
+
const bRatios: EmbedAspectRatio[] = [];
|
|
119
|
+
const a = makeIframe();
|
|
120
|
+
const b = makeIframe();
|
|
121
|
+
track(createEmbedMount(a.iframe, { src: SRC, onAspectRatio: (r) => aRatios.push(r) }));
|
|
122
|
+
track(createEmbedMount(b.iframe, { src: SRC, onAspectRatio: (r) => bRatios.push(r) }));
|
|
123
|
+
|
|
124
|
+
post(resizeMessage(700, 400), { source: b.frame });
|
|
125
|
+
expect(bRatios).toEqual([{ widthPx: 700, heightPx: 400 }]);
|
|
126
|
+
expect(aRatios).toEqual([]);
|
|
127
|
+
|
|
128
|
+
post(resizeMessage(1000, 300), { source: a.frame });
|
|
129
|
+
expect(aRatios).toEqual([{ widthPx: 1000, heightPx: 300 }]);
|
|
130
|
+
expect(bRatios).toEqual([{ widthPx: 700, heightPx: 400 }]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("createEmbedMount — ignores messages that do not match the contract", () => {
|
|
135
|
+
let iframe: HTMLIFrameElement;
|
|
136
|
+
let frame: MessageEventSource;
|
|
137
|
+
let onAspectRatio: ReturnType<typeof vi.fn>;
|
|
138
|
+
let onEvent: ReturnType<typeof vi.fn>;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
({ iframe, frame } = makeIframe());
|
|
142
|
+
onAspectRatio = vi.fn();
|
|
143
|
+
onEvent = vi.fn();
|
|
144
|
+
track(createEmbedMount(iframe, { src: SRC, onAspectRatio, onEvent }));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects a spoofed origin", () => {
|
|
148
|
+
post(resizeMessage(900, 520), { origin: "https://evil.example", source: frame });
|
|
149
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("rejects a resize from a frame it does not own", () => {
|
|
153
|
+
post(resizeMessage(900, 520), { source: window });
|
|
154
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects a foreign source tag", () => {
|
|
158
|
+
post({ source: "other-widget", v: 1, type: "resize", widthPx: 9, heightPx: 5 }, { source: frame });
|
|
159
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects a mismatched protocol version", () => {
|
|
163
|
+
post({ source: "scenar-embed", v: 999, type: "resize", widthPx: 9, heightPx: 5 }, { source: frame });
|
|
164
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects a malformed payload", () => {
|
|
168
|
+
post(resizeMessage("huge", 520), { source: frame });
|
|
169
|
+
post(resizeMessage(900, Number.NaN), { source: frame });
|
|
170
|
+
post("not-an-object", { source: frame });
|
|
171
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("still forwards non-resize events to onEvent without touching layout", () => {
|
|
175
|
+
post({ source: "scenar-embed", v: 1, type: "completed" }, { source: frame });
|
|
176
|
+
expect(onEvent).toHaveBeenCalledWith({ type: "completed" });
|
|
177
|
+
expect(onAspectRatio).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("createEmbedMount — teardown", () => {
|
|
182
|
+
it("stops reacting to resizes after destroy", () => {
|
|
183
|
+
const ratios: EmbedAspectRatio[] = [];
|
|
184
|
+
const { iframe, frame } = makeIframe();
|
|
185
|
+
const mount = createEmbedMount(iframe, { src: SRC, onAspectRatio: (r) => ratios.push(r) });
|
|
186
|
+
|
|
187
|
+
mount.destroy();
|
|
188
|
+
post(resizeMessage(900, 520), { source: frame });
|
|
189
|
+
expect(ratios).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("stops observing host theme after destroy", async () => {
|
|
193
|
+
const { iframe } = makeIframe();
|
|
194
|
+
const mount = createEmbedMount(iframe, { src: SRC, theme: "auto" });
|
|
195
|
+
expect(iframe.src).toContain("theme=light");
|
|
196
|
+
|
|
197
|
+
mount.destroy();
|
|
198
|
+
document.documentElement.classList.add("dark");
|
|
199
|
+
await flushMicrotasks();
|
|
200
|
+
expect(iframe.src).toContain("theme=light");
|
|
201
|
+
});
|
|
202
|
+
});
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ScenarEmbedEvent,
|
|
3
|
+
type ScenarEmbedHostController,
|
|
4
|
+
createEmbedHostController,
|
|
5
|
+
} from "@scenar/core";
|
|
6
|
+
import {
|
|
7
|
+
type ScenarEmbedTheme,
|
|
8
|
+
applyThemeToSrc,
|
|
9
|
+
originFromSrc,
|
|
10
|
+
resolveTheme,
|
|
11
|
+
} from "./resolve.js";
|
|
12
|
+
|
|
13
|
+
/** The embed's exact rendered size, reported over the `resize` event. */
|
|
14
|
+
export interface EmbedAspectRatio {
|
|
15
|
+
readonly widthPx: number;
|
|
16
|
+
readonly heightPx: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Options for {@link createEmbedMount}. */
|
|
20
|
+
export interface EmbedMountOptions {
|
|
21
|
+
/** The absolute embed URL, without a theme query (the mount adds it). */
|
|
22
|
+
readonly src: string;
|
|
23
|
+
/** Theme strategy (default `auto`). */
|
|
24
|
+
readonly theme?: ScenarEmbedTheme;
|
|
25
|
+
/** Called whenever the embed reports a new rendered size, for host layout. */
|
|
26
|
+
readonly onAspectRatio?: (ratio: EmbedAspectRatio) => void;
|
|
27
|
+
/** Called for every well-formed event from the pinned embed. */
|
|
28
|
+
readonly onEvent?: (event: ScenarEmbedEvent) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A live mount: the host controller plus a teardown for all listeners. */
|
|
32
|
+
export interface EmbedMount {
|
|
33
|
+
/** Imperative transport (play/pause/seek/setMuted/setVolume) over the bridge. */
|
|
34
|
+
readonly controller: ScenarEmbedHostController;
|
|
35
|
+
/** Disconnect the theme observer and the controller's message listener. */
|
|
36
|
+
destroy(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether the host page is in dark mode (the `dark` class on `<html>`). */
|
|
40
|
+
function hostPrefersDark(): boolean {
|
|
41
|
+
return (
|
|
42
|
+
typeof document !== "undefined" &&
|
|
43
|
+
document.documentElement.classList.contains("dark")
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wire an existing iframe to a hosted Scenar tour: set its themed `src`, pin the
|
|
49
|
+
* embed origin, adopt the embed's reported aspect ratio, and (for `theme:auto`)
|
|
50
|
+
* track the host's dark mode.
|
|
51
|
+
*
|
|
52
|
+
* This is the one place the loader's behavior lives. The `<scenar-embed>`
|
|
53
|
+
* element and the React `<ScenarEmbed>` are thin adapters that create the iframe
|
|
54
|
+
* + own layout, then hand it here — so neither re-implements the bridge, exactly
|
|
55
|
+
* as {@link createEmbedHostController} is shared by the console and this loader.
|
|
56
|
+
*
|
|
57
|
+
* The caller owns the iframe element (and removes it for full teardown); this
|
|
58
|
+
* function owns only the listeners it adds, all released by {@link EmbedMount.destroy}.
|
|
59
|
+
*/
|
|
60
|
+
export function createEmbedMount(
|
|
61
|
+
iframe: HTMLIFrameElement,
|
|
62
|
+
options: EmbedMountOptions,
|
|
63
|
+
): EmbedMount {
|
|
64
|
+
const { src, theme = "auto", onAspectRatio, onEvent } = options;
|
|
65
|
+
const origin = originFromSrc(src);
|
|
66
|
+
|
|
67
|
+
// Track the last applied theme so an unrelated `<html>` class mutation never
|
|
68
|
+
// triggers a needless reload — only a real light/dark flip reassigns `src`.
|
|
69
|
+
let appliedTheme: "light" | "dark" | null = null;
|
|
70
|
+
const applyTheme = (): void => {
|
|
71
|
+
const resolved = resolveTheme(theme, hostPrefersDark());
|
|
72
|
+
if (resolved === appliedTheme) return;
|
|
73
|
+
appliedTheme = resolved;
|
|
74
|
+
iframe.src = applyThemeToSrc(src, resolved);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const controller = createEmbedHostController(
|
|
78
|
+
{ iframe, origin },
|
|
79
|
+
{
|
|
80
|
+
onEvent: (event) => {
|
|
81
|
+
if (event.type === "resize") {
|
|
82
|
+
onAspectRatio?.({ widthPx: event.widthPx, heightPx: event.heightPx });
|
|
83
|
+
}
|
|
84
|
+
onEvent?.(event);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
let observer: MutationObserver | undefined;
|
|
90
|
+
if (
|
|
91
|
+
theme === "auto" &&
|
|
92
|
+
typeof MutationObserver !== "undefined" &&
|
|
93
|
+
typeof document !== "undefined"
|
|
94
|
+
) {
|
|
95
|
+
observer = new MutationObserver(applyTheme);
|
|
96
|
+
observer.observe(document.documentElement, {
|
|
97
|
+
attributes: true,
|
|
98
|
+
attributeFilter: ["class"],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Assign the initial themed src last, so the controller's listener is already
|
|
103
|
+
// attached when the frame begins loading.
|
|
104
|
+
applyTheme();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
controller,
|
|
108
|
+
destroy() {
|
|
109
|
+
observer?.disconnect();
|
|
110
|
+
controller.destroy();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { createRef } from "react";
|
|
3
|
+
import { render, screen, act, cleanup } from "@testing-library/react";
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
|
+
import { ScenarEmbed, type ScenarEmbedHandle } from "./react.js";
|
|
6
|
+
|
|
7
|
+
const SRC = "https://embed.example/tour/";
|
|
8
|
+
const ORIGIN = "https://embed.example";
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
cleanup();
|
|
12
|
+
document.documentElement.className = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Pin a sentinel window (with a spyable postMessage) as the iframe's frame. */
|
|
16
|
+
function stubFrame(iframe: HTMLIFrameElement): { postMessage: ReturnType<typeof vi.fn> } {
|
|
17
|
+
const postMessage = vi.fn();
|
|
18
|
+
const frame = { postMessage } as unknown as MessageEventSource;
|
|
19
|
+
Object.defineProperty(iframe, "contentWindow", { value: frame, configurable: true });
|
|
20
|
+
return { postMessage };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function postResize(iframe: HTMLIFrameElement, widthPx: number, heightPx: number): void {
|
|
24
|
+
const event = new MessageEvent("message", {
|
|
25
|
+
data: { source: "scenar-embed", v: 1, type: "resize", widthPx, heightPx },
|
|
26
|
+
origin: ORIGIN,
|
|
27
|
+
});
|
|
28
|
+
Object.defineProperty(event, "source", { value: iframe.contentWindow, configurable: true });
|
|
29
|
+
act(() => {
|
|
30
|
+
window.dispatchEvent(event);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("ScenarEmbed (React)", () => {
|
|
35
|
+
it("renders a responsive, lazy iframe and assigns the themed src after mount", () => {
|
|
36
|
+
render(<ScenarEmbed src={SRC} title="Welcome tour" />);
|
|
37
|
+
const iframe = screen.getByTitle("Welcome tour") as HTMLIFrameElement;
|
|
38
|
+
stubFrame(iframe);
|
|
39
|
+
|
|
40
|
+
expect(iframe.getAttribute("loading")).toBe("lazy");
|
|
41
|
+
expect(iframe.getAttribute("allow")).toBe("autoplay; fullscreen");
|
|
42
|
+
expect(iframe.src).toBe("https://embed.example/tour/?theme=light");
|
|
43
|
+
|
|
44
|
+
const wrapper = iframe.parentElement as HTMLElement;
|
|
45
|
+
expect(wrapper.style.aspectRatio).toBe("896 / 480");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("resolves id + base into the embed src", () => {
|
|
49
|
+
render(<ScenarEmbed id="welcome" base="https://embed.example/demos" title="t" />);
|
|
50
|
+
const iframe = screen.getByTitle("t") as HTMLIFrameElement;
|
|
51
|
+
stubFrame(iframe);
|
|
52
|
+
expect(iframe.src).toBe("https://embed.example/demos/welcome/?theme=light");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not render a src on the server (SSR-safe, no hydration mismatch)", () => {
|
|
56
|
+
const markup = renderToStaticMarkup(<ScenarEmbed src={SRC} title="t" />);
|
|
57
|
+
expect(markup).toContain("<iframe");
|
|
58
|
+
expect(markup).not.toContain("src=");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("adopts the reported aspect ratio and forwards events", () => {
|
|
62
|
+
const onEvent = vi.fn();
|
|
63
|
+
render(<ScenarEmbed src={SRC} title="t" onEvent={onEvent} />);
|
|
64
|
+
const iframe = screen.getByTitle("t") as HTMLIFrameElement;
|
|
65
|
+
stubFrame(iframe);
|
|
66
|
+
|
|
67
|
+
postResize(iframe, 900, 520);
|
|
68
|
+
|
|
69
|
+
const wrapper = iframe.parentElement as HTMLElement;
|
|
70
|
+
expect(wrapper.style.aspectRatio).toBe("900 / 520");
|
|
71
|
+
expect(onEvent).toHaveBeenCalledWith({ type: "resize", widthPx: 900, heightPx: 520 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("drives transport through the ref handle", () => {
|
|
75
|
+
const ref = createRef<ScenarEmbedHandle>();
|
|
76
|
+
render(<ScenarEmbed ref={ref} src={SRC} title="t" />);
|
|
77
|
+
const iframe = screen.getByTitle("t") as HTMLIFrameElement;
|
|
78
|
+
const { postMessage } = stubFrame(iframe);
|
|
79
|
+
|
|
80
|
+
act(() => {
|
|
81
|
+
ref.current?.play();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
85
|
+
{ source: "scenar-embed", v: 1, type: "play" },
|
|
86
|
+
ORIGIN,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import type { ScenarEmbedEvent } from "@scenar/core";
|
|
12
|
+
import { type EmbedMount, createEmbedMount } from "./mount.js";
|
|
13
|
+
import {
|
|
14
|
+
type EmbedSource,
|
|
15
|
+
type ScenarEmbedTheme,
|
|
16
|
+
EMBED_BASE_ASPECT_HEIGHT,
|
|
17
|
+
EMBED_BASE_ASPECT_WIDTH,
|
|
18
|
+
resolveEmbedSrc,
|
|
19
|
+
} from "./resolve.js";
|
|
20
|
+
|
|
21
|
+
/** Accessible iframe title when the host passes none. */
|
|
22
|
+
const DEFAULT_TITLE = "Interactive product tour";
|
|
23
|
+
|
|
24
|
+
/** Imperative transport exposed through a ref, driving the bridge. */
|
|
25
|
+
export interface ScenarEmbedHandle {
|
|
26
|
+
play(): void;
|
|
27
|
+
pause(): void;
|
|
28
|
+
seek(timeMs: number): void;
|
|
29
|
+
setMuted(muted: boolean): void;
|
|
30
|
+
setVolume(volume: number): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScenarEmbedProps extends EmbedSource {
|
|
34
|
+
/** Accessible iframe title (defaults to a generic label). */
|
|
35
|
+
readonly title?: string;
|
|
36
|
+
/** Theme strategy (default `auto`: track the host's `dark` class). */
|
|
37
|
+
readonly theme?: ScenarEmbedTheme;
|
|
38
|
+
/** Receive every well-formed event from the embed (ready/progress/etc.). */
|
|
39
|
+
readonly onEvent?: (event: ScenarEmbedEvent) => void;
|
|
40
|
+
/** Class applied to the responsive wrapper. */
|
|
41
|
+
readonly className?: string;
|
|
42
|
+
/** Extra styles merged onto the responsive wrapper. */
|
|
43
|
+
readonly style?: CSSProperties;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Embed a hosted Scenar tour as a responsive, theme-synced iframe — the React
|
|
48
|
+
* sibling of `<scenar-embed>`. It is a thin adapter over {@link createEmbedMount}
|
|
49
|
+
* (it does not render the custom element), so it carries no protocol logic and
|
|
50
|
+
* stays free of web-component-in-React SSR/typing friction.
|
|
51
|
+
*
|
|
52
|
+
* SSR-safe: the iframe renders without a `src` on the server and on the first
|
|
53
|
+
* client render, so hydration never mismatches; the effect then assigns the
|
|
54
|
+
* themed `src` and adopts the embed's reported aspect ratio.
|
|
55
|
+
*/
|
|
56
|
+
export const ScenarEmbed = forwardRef<ScenarEmbedHandle, ScenarEmbedProps>(
|
|
57
|
+
function ScenarEmbed({ src, id, base, title, theme = "auto", onEvent, className, style }, ref) {
|
|
58
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
59
|
+
const mountRef = useRef<EmbedMount | null>(null);
|
|
60
|
+
const [ratio, setRatio] = useState(
|
|
61
|
+
`${EMBED_BASE_ASPECT_WIDTH} / ${EMBED_BASE_ASPECT_HEIGHT}`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Latest onEvent without forcing a re-mount when the callback identity changes.
|
|
65
|
+
const onEventRef = useRef(onEvent);
|
|
66
|
+
onEventRef.current = onEvent;
|
|
67
|
+
|
|
68
|
+
const resolvedSrc = resolveEmbedSrc({ src, id, base });
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const iframe = iframeRef.current;
|
|
72
|
+
if (!iframe) return;
|
|
73
|
+
const mount = createEmbedMount(iframe, {
|
|
74
|
+
src: resolvedSrc,
|
|
75
|
+
theme,
|
|
76
|
+
onAspectRatio: ({ widthPx, heightPx }) => setRatio(`${widthPx} / ${heightPx}`),
|
|
77
|
+
onEvent: (event) => onEventRef.current?.(event),
|
|
78
|
+
});
|
|
79
|
+
mountRef.current = mount;
|
|
80
|
+
return () => {
|
|
81
|
+
mount.destroy();
|
|
82
|
+
mountRef.current = null;
|
|
83
|
+
};
|
|
84
|
+
}, [resolvedSrc, theme]);
|
|
85
|
+
|
|
86
|
+
useImperativeHandle(
|
|
87
|
+
ref,
|
|
88
|
+
() => ({
|
|
89
|
+
play: () => mountRef.current?.controller.play(),
|
|
90
|
+
pause: () => mountRef.current?.controller.pause(),
|
|
91
|
+
seek: (timeMs) => mountRef.current?.controller.seek(timeMs),
|
|
92
|
+
setMuted: (muted) => mountRef.current?.controller.setMuted(muted),
|
|
93
|
+
setVolume: (volume) => mountRef.current?.controller.setVolume(volume),
|
|
94
|
+
}),
|
|
95
|
+
[],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className={className}
|
|
101
|
+
style={{ position: "relative", width: "100%", aspectRatio: ratio, ...style }}
|
|
102
|
+
>
|
|
103
|
+
<iframe
|
|
104
|
+
ref={iframeRef}
|
|
105
|
+
title={title ?? DEFAULT_TITLE}
|
|
106
|
+
loading="lazy"
|
|
107
|
+
allow="autoplay; fullscreen"
|
|
108
|
+
allowFullScreen
|
|
109
|
+
style={{
|
|
110
|
+
position: "absolute",
|
|
111
|
+
inset: 0,
|
|
112
|
+
width: "100%",
|
|
113
|
+
height: "100%",
|
|
114
|
+
border: 0,
|
|
115
|
+
borderRadius: "inherit",
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyThemeToSrc,
|
|
4
|
+
originFromSrc,
|
|
5
|
+
resolveEmbedSrc,
|
|
6
|
+
resolveTheme,
|
|
7
|
+
} from "./resolve.js";
|
|
8
|
+
|
|
9
|
+
describe("resolveEmbedSrc", () => {
|
|
10
|
+
it("returns an explicit src verbatim", () => {
|
|
11
|
+
expect(resolveEmbedSrc({ src: "https://e.example/tour/" })).toBe("https://e.example/tour/");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("prefers src over id+base when both are given", () => {
|
|
15
|
+
expect(
|
|
16
|
+
resolveEmbedSrc({ src: "https://e.example/a/", id: "b", base: "https://e.example" }),
|
|
17
|
+
).toBe("https://e.example/a/");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("joins base + id with a single trailing slash (base without slash)", () => {
|
|
21
|
+
expect(resolveEmbedSrc({ id: "welcome", base: "https://e.example/demos" })).toBe(
|
|
22
|
+
"https://e.example/demos/welcome/",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("joins base + id without doubling the slash (base with slash)", () => {
|
|
27
|
+
expect(resolveEmbedSrc({ id: "welcome", base: "https://e.example/demos/" })).toBe(
|
|
28
|
+
"https://e.example/demos/welcome/",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("throws an actionable error when neither src nor id+base is provided", () => {
|
|
33
|
+
expect(() => resolveEmbedSrc({})).toThrowError(/provide `src`, or both `id` and `base`/);
|
|
34
|
+
expect(() => resolveEmbedSrc({ id: "welcome" })).toThrowError(/id` and `base`/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveTheme", () => {
|
|
39
|
+
it("pins light and dark regardless of host state", () => {
|
|
40
|
+
expect(resolveTheme("light", true)).toBe("light");
|
|
41
|
+
expect(resolveTheme("light", false)).toBe("light");
|
|
42
|
+
expect(resolveTheme("dark", false)).toBe("dark");
|
|
43
|
+
expect(resolveTheme("dark", true)).toBe("dark");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("defers to the host's resolved state under auto", () => {
|
|
47
|
+
expect(resolveTheme("auto", true)).toBe("dark");
|
|
48
|
+
expect(resolveTheme("auto", false)).toBe("light");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("applyThemeToSrc", () => {
|
|
53
|
+
it("adds the theme query when absent", () => {
|
|
54
|
+
expect(applyThemeToSrc("https://e.example/tour/", "dark")).toBe(
|
|
55
|
+
"https://e.example/tour/?theme=dark",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("replaces an existing theme query rather than appending", () => {
|
|
60
|
+
expect(applyThemeToSrc("https://e.example/tour/?theme=dark", "light")).toBe(
|
|
61
|
+
"https://e.example/tour/?theme=light",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves other query params", () => {
|
|
66
|
+
expect(applyThemeToSrc("https://e.example/tour/?a=1", "dark")).toBe(
|
|
67
|
+
"https://e.example/tour/?a=1&theme=dark",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("originFromSrc", () => {
|
|
73
|
+
it("returns scheme + host + port with no path", () => {
|
|
74
|
+
expect(originFromSrc("https://e.example/tour/?theme=dark")).toBe("https://e.example");
|
|
75
|
+
expect(originFromSrc("http://localhost:4173/")).toBe("http://localhost:4173");
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* How the embed's color theme is chosen.
|
|
10
|
+
*
|
|
11
|
+
* - `auto` (default) — track the host page's `dark` class on `<html>`.
|
|
12
|
+
* - `light` / `dark` — pin the theme regardless of the host.
|
|
13
|
+
*/
|
|
14
|
+
export type ScenarEmbedTheme = "auto" | "light" | "dark";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The tour's recorded canonical viewport (mirrors `DEFAULT_VIEWPORT` in
|
|
18
|
+
* `@scenar/cli`). Used as the pre-handshake aspect-ratio baseline, before the
|
|
19
|
+
* embed reports its exact rendered size over the `resize` event.
|
|
20
|
+
*/
|
|
21
|
+
export const EMBED_BASE_ASPECT_WIDTH = 896;
|
|
22
|
+
export const EMBED_BASE_ASPECT_HEIGHT = 480;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where a tour is served from. Pass `src` (the full embed URL) directly, or a
|
|
26
|
+
* `base` + `id` pair that resolves to `<base>/<id>/` — the convenience the React
|
|
27
|
+
* wrapper offers so docs can reference tours by slug.
|
|
28
|
+
*/
|
|
29
|
+
export interface EmbedSource {
|
|
30
|
+
/** The full embed URL (absolute). Takes precedence over `id` + `base`. */
|
|
31
|
+
readonly src?: string;
|
|
32
|
+
/** The published tour slug, resolved against `base`. */
|
|
33
|
+
readonly id?: string;
|
|
34
|
+
/** The base URL the `id` is resolved under (e.g. a GitHub Pages repo root). */
|
|
35
|
+
readonly base?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve an {@link EmbedSource} to a single absolute embed URL (without the
|
|
40
|
+
* theme query — that is layered on at mount time). Throws a developer-actionable
|
|
41
|
+
* error if neither form is supplied.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveEmbedSrc(source: EmbedSource): string {
|
|
44
|
+
if (source.src) return source.src;
|
|
45
|
+
if (source.id && source.base) {
|
|
46
|
+
const base = source.base.endsWith("/") ? source.base : `${source.base}/`;
|
|
47
|
+
return `${base}${source.id}/`;
|
|
48
|
+
}
|
|
49
|
+
throw new Error("ScenarEmbed: provide `src`, or both `id` and `base`.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Collapse a {@link ScenarEmbedTheme} to the concrete `light`/`dark` the embed
|
|
54
|
+
* understands. `auto` defers to `isDark` (the host's resolved state), which the
|
|
55
|
+
* caller reads from the DOM — this function stays pure.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveTheme(theme: ScenarEmbedTheme, isDark: boolean): "light" | "dark" {
|
|
58
|
+
if (theme === "light") return "light";
|
|
59
|
+
if (theme === "dark") return "dark";
|
|
60
|
+
return isDark ? "dark" : "light";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stamp the resolved theme onto an embed URL as `?theme=…`. The packed embed
|
|
65
|
+
* reads this query at load to apply its palette; changing it reloads the frame
|
|
66
|
+
* in the new theme (acceptable for an autoplay demo).
|
|
67
|
+
*/
|
|
68
|
+
export function applyThemeToSrc(src: string, theme: "light" | "dark"): string {
|
|
69
|
+
const url = new URL(src);
|
|
70
|
+
url.searchParams.set("theme", theme);
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The embed's exact origin (scheme + host + port, no path) — used to pin both
|
|
76
|
+
* inbound events and outbound commands. Derived from the embed URL.
|
|
77
|
+
*/
|
|
78
|
+
export function originFromSrc(src: string): string {
|
|
79
|
+
return new URL(src).origin;
|
|
80
|
+
}
|