mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(slideshow): make presenter mode work over Google Meet/Zoom screen share
Fix slideshow presenter mode for screen-share workflows by opening the audience view as a regular noopener tab, preserving audience query construction across fragments, and keeping iframe keyboard forwarding diagnosable.
This commit is contained in:
@@ -932,30 +932,86 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("present() opens a new window with mode=audience and sets presenter attribute", () => {
|
||||
const openCalls: { url: string; target: string }[] = [];
|
||||
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||
openCalls.push({ url: String(url), target: String(target) });
|
||||
return null;
|
||||
type AudienceTabClick = { href: string; target: string; rel: string };
|
||||
|
||||
function spyAudienceTabClicks(): AudienceTabClick[] {
|
||||
const clicks: AudienceTabClick[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
|
||||
function (this: HTMLAnchorElement) {
|
||||
clicks.push({ href: this.href, target: this.target, rel: this.rel });
|
||||
},
|
||||
);
|
||||
return clicks;
|
||||
}
|
||||
|
||||
function mockUserActivation(isActive: boolean): () => void {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(navigator, "userActivation");
|
||||
Object.defineProperty(navigator, "userActivation", {
|
||||
configurable: true,
|
||||
value: { isActive },
|
||||
});
|
||||
return () => {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(navigator, "userActivation", descriptor);
|
||||
} else {
|
||||
const nav = navigator as unknown as { userActivation?: unknown };
|
||||
delete nav.userActivation;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it("present() opens an audience TAB with noopener/noreferrer", () => {
|
||||
const tabClicks = spyAudienceTabClicks();
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
el.present();
|
||||
|
||||
expect(openCalls.length).toBe(1);
|
||||
expect(openCalls[0].url).toContain("mode=audience");
|
||||
expect(openCalls[0].target).toBe("_blank");
|
||||
expect(tabClicks).toHaveLength(1);
|
||||
expect(tabClicks[0].href).toContain("mode=audience");
|
||||
expect(tabClicks[0].target).toBe("_blank");
|
||||
expect(tabClicks[0].rel).toContain("noopener");
|
||||
expect(tabClicks[0].rel).toContain("noreferrer");
|
||||
expect(el.getAttribute("data-hf-presenting")).toBe("true");
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("present() puts mode=audience in the query even when the page URL has a #fragment", () => {
|
||||
const tabClicks = spyAudienceTabClicks();
|
||||
location.hash = "#intro";
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
el.present();
|
||||
|
||||
expect(tabClicks).toHaveLength(1);
|
||||
// String concat onto location.href would produce "...#intro?mode=audience",
|
||||
// leaving location.search empty in the opened tab (unsynced presenter boot).
|
||||
expect(new URL(tabClicks[0].href).searchParams.get("mode")).toBe("audience");
|
||||
|
||||
location.hash = "";
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("present() aborts (no presenter state) without user activation", () => {
|
||||
const restoreUserActivation = mockUserActivation(false);
|
||||
const tabClicks = spyAudienceTabClicks();
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
try {
|
||||
el.present();
|
||||
|
||||
// No audience tab → must not flip into presenter layout (there is no
|
||||
// exit-presenter affordance; the element would be stuck until reload).
|
||||
expect(tabClicks).toHaveLength(0);
|
||||
expect(el.getAttribute("data-hf-presenting")).toBeNull();
|
||||
} finally {
|
||||
restoreUserActivation();
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("built-in nav present button opens presenter mode and then hides itself", () => {
|
||||
const openCalls: { url: string; target: string }[] = [];
|
||||
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||
openCalls.push({ url: String(url), target: String(target) });
|
||||
return null;
|
||||
});
|
||||
const tabClicks = spyAudienceTabClicks();
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
const presentBtn = el.querySelector("[data-hf-present]") as HTMLButtonElement;
|
||||
@@ -963,8 +1019,8 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
||||
|
||||
presentBtn.click();
|
||||
|
||||
expect(openCalls).toHaveLength(1);
|
||||
expect(openCalls[0].url).toContain("mode=audience");
|
||||
expect(tabClicks).toHaveLength(1);
|
||||
expect(tabClicks[0].href).toContain("mode=audience");
|
||||
expect(el.getAttribute("data-hf-presenting")).toBe("true");
|
||||
expect(el.querySelector("[data-hf-present]")).toBeNull();
|
||||
|
||||
@@ -972,28 +1028,86 @@ describe("<hyperframes-slideshow> presenter mode", () => {
|
||||
});
|
||||
|
||||
it("P shortcut opens presenter mode from the shared component", () => {
|
||||
const openCalls: { url: string; target: string }[] = [];
|
||||
vi.spyOn(window, "open").mockImplementation((url, target) => {
|
||||
openCalls.push({ url: String(url), target: String(target) });
|
||||
return null;
|
||||
});
|
||||
const tabClicks = spyAudienceTabClicks();
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
el.focus();
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "P" }));
|
||||
|
||||
expect(openCalls).toHaveLength(1);
|
||||
expect(openCalls[0].url).toContain("mode=audience");
|
||||
expect(tabClicks).toHaveLength(1);
|
||||
expect(tabClicks[0].href).toContain("mode=audience");
|
||||
expect(el.getAttribute("data-hf-presenting")).toBe("true");
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("present() rebroadcasts the current position for a newly opened audience window", async () => {
|
||||
it("arrow keydown inside the composition iframe still drives the deck", () => {
|
||||
// Interactive decks move focus into the player iframe on click; keydowns
|
||||
// there never reach the top window's listener. The component forwards them.
|
||||
const el = document.createElement("hyperframes-slideshow") as any;
|
||||
document.body.appendChild(el);
|
||||
const iframe = document.createElement("iframe");
|
||||
el.appendChild(iframe);
|
||||
let nexts = 0;
|
||||
el.__setControllerForTest({
|
||||
next: () => {
|
||||
nexts++;
|
||||
},
|
||||
prev: () => {},
|
||||
goToSlide: () => {},
|
||||
onChange: () => () => {},
|
||||
counter: { index: 1, total: 3 },
|
||||
breadcrumb: [{ id: "main", label: "Main deck" }],
|
||||
currentSlide: { hotspots: [] },
|
||||
nextSlide: null,
|
||||
get position() {
|
||||
return MAIN_POS;
|
||||
},
|
||||
});
|
||||
|
||||
el.attachIframeKeyForwarding({ iframeElement: iframe });
|
||||
iframe.contentWindow?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
|
||||
expect(nexts).toBe(1);
|
||||
|
||||
// Text-entry targets inside the iframe must NOT navigate (duck-typed guard —
|
||||
// iframe-realm elements are not instanceof this realm's classes).
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc) {
|
||||
const input = doc.createElement("input");
|
||||
doc.body.appendChild(input);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
expect(nexts).toBe(1);
|
||||
}
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("warns once when iframe key forwarding is unavailable", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const el = document.createElement("hyperframes-slideshow") as any;
|
||||
document.body.appendChild(el);
|
||||
const iframe = document.createElement("iframe");
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new DOMException("Blocked by origin policy", "SecurityError");
|
||||
},
|
||||
});
|
||||
|
||||
el.attachIframeKeyForwarding({ iframeElement: iframe });
|
||||
iframe.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("cross-origin");
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("present() rebroadcasts the current position for a newly opened audience tab", async () => {
|
||||
const received: unknown[] = [];
|
||||
const spy = new BroadcastChannel(slideshowChannelName());
|
||||
spy.onmessage = (e: MessageEvent) => received.push(e.data);
|
||||
vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
spyAudienceTabClicks();
|
||||
|
||||
const { el } = makePresenterEl();
|
||||
el.present();
|
||||
|
||||
@@ -72,6 +72,16 @@ type SlideshowMediaElement = HTMLMediaElement & {
|
||||
dataset: DOMStringMap;
|
||||
};
|
||||
|
||||
/** True when the keydown originated in a text-entry control (typing must never
|
||||
* navigate the deck). Duck-typed so it works for events from the composition
|
||||
* iframe's realm, where instanceof this realm's element classes always fails. */
|
||||
function isTextEntryTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as HTMLElement).tagName !== "string") return false;
|
||||
const el = target as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
|
||||
}
|
||||
|
||||
function isPlayerElement(el: HTMLElement): el is PlayerElement {
|
||||
return (
|
||||
typeof (el as PlayerElement).seek === "function" &&
|
||||
@@ -178,6 +188,9 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
private initTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initInFlight = false;
|
||||
private initGeneration = 0;
|
||||
private keyForwardFrame: HTMLIFrameElement | null = null;
|
||||
private detachIframeKeys: (() => void) | null = null;
|
||||
private warnedIframeKeyForwardingUnavailable = false;
|
||||
private _muted = false;
|
||||
private mediaWireInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private playerObserver: MutationObserver | null = null;
|
||||
@@ -196,7 +209,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
}
|
||||
|
||||
/** Mode resolves from the `mode` attribute, falling back to the URL query
|
||||
* (?mode=audience) so the audience window opened by present() is detected. */
|
||||
* (?mode=audience) so the audience tab opened by present() is detected. */
|
||||
private resolveMode(): string | null {
|
||||
const attr = this.getAttribute("mode");
|
||||
if (attr) return attr;
|
||||
@@ -223,9 +236,8 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.initInFlight = false;
|
||||
this.initGeneration += 1;
|
||||
this.tabIndex = 0;
|
||||
// note: if the inner player iframe has keyboard focus, window keydown in the
|
||||
// top document won't fire — that edge remains; this listener fixes the dominant
|
||||
// case where the page loads and arrows should work without clicking the element.
|
||||
// Keydowns with focus inside the player iframe don't reach this window
|
||||
// listener — attachIframeKeyForwarding() (wired in init) covers that path.
|
||||
window.addEventListener("keydown", this.onKey);
|
||||
this.addEventListener("touchstart", this.onTouchStart, { passive: true });
|
||||
this.addEventListener("touchend", this.onTouchEnd);
|
||||
@@ -259,6 +271,7 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.initTimer = null;
|
||||
}
|
||||
window.removeEventListener("keydown", this.onKey);
|
||||
this.detachIframeKeys?.();
|
||||
this.removeEventListener("touchstart", this.onTouchStart);
|
||||
this.removeEventListener("touchend", this.onTouchEnd);
|
||||
window.removeEventListener("message", this.onMessage);
|
||||
@@ -295,17 +308,26 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an audience window and switches this element to presenter layout.
|
||||
* Audience window URL: current page URL with `mode=audience` query param.
|
||||
* Opens an audience tab and switches this element to presenter layout.
|
||||
* Audience tab URL: current page URL with `mode=audience` query param.
|
||||
*/
|
||||
present(): void {
|
||||
if (this.resolveMode() === "audience" || this.getAttribute("data-hf-presenting") === "true") {
|
||||
return;
|
||||
}
|
||||
const sep = location.search ? "&" : "?";
|
||||
// noopener,noreferrer: the audience window must not get a reference back to
|
||||
// this window (it syncs over BroadcastChannel, not window.opener).
|
||||
window.open(location.href + sep + "mode=audience", "_blank", "noopener,noreferrer");
|
||||
// URL API, not string concat: with a #fragment in the page URL, appending
|
||||
// "?mode=audience" would land inside the fragment and the opened tab would
|
||||
// boot as an unsynced second presenter.
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("mode", "audience");
|
||||
// Anchor click, not window.open(features): rel="noopener noreferrer" severs
|
||||
// opener at creation time while Chrome still opens a regular tab. Passing a
|
||||
// non-empty features string tends to create a popup WINDOW, which freezes
|
||||
// when fully covered during screen share.
|
||||
if (!this.openAudienceTab(url.href)) {
|
||||
console.warn("[hyperframes-slideshow] present(): browser blocked the audience tab");
|
||||
return;
|
||||
}
|
||||
this.setAttribute("data-hf-presenting", "true");
|
||||
this.postCurrentPresenterPositionBurst();
|
||||
this.presenterStartMs = Date.now();
|
||||
@@ -315,6 +337,22 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private openAudienceTab(href: string): boolean {
|
||||
const userActivation = (navigator as Navigator & { userActivation?: { isActive?: boolean } })
|
||||
.userActivation;
|
||||
if (userActivation && userActivation.isActive === false) return false;
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.style.display = "none";
|
||||
(document.body ?? document.documentElement).appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the elapsed readout. Re-rendering the whole chrome every second
|
||||
* (the old behavior) rebuilt the nav buttons' DOM on each tick — they
|
||||
@@ -379,6 +417,8 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
// Guard: if a disconnect or reconnect happened while waiting, bail out.
|
||||
if (gen !== this.initGeneration) return;
|
||||
|
||||
this.attachIframeKeyForwarding(playerEl);
|
||||
|
||||
// Wait for scenes to be populated (the runtime "timeline" postMessage
|
||||
// arrives ~1000ms after waitForReady resolves). Graceful fallback to []
|
||||
// on timeout so explicit startTime/endTime slides still work.
|
||||
@@ -547,6 +587,51 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
this.playerObserver.observe(this, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward keydown events from the composition iframe to onKey. Interactive
|
||||
* decks move focus into the iframe when the presenter clicks a slide, and
|
||||
* top-window keydown stops firing — without this, arrow keys stop controlling
|
||||
* the deck after any in-slide click. Same-origin frames only (cross-origin
|
||||
* access throws → warn once and leave top-window shortcuts working).
|
||||
* Re-attached on every iframe `load`,
|
||||
* because a navigation clears listeners the parent added to the content
|
||||
* window.
|
||||
*/
|
||||
private attachIframeKeyForwarding(player: Partial<PlayerElement> & HTMLElement): void {
|
||||
const frame = player.iframeElement;
|
||||
if (!(frame instanceof HTMLIFrameElement) || frame === this.keyForwardFrame) return;
|
||||
this.detachIframeKeys?.();
|
||||
const attach = (): void => {
|
||||
try {
|
||||
// addEventListener dedupes same handler+target, so re-runs are safe.
|
||||
frame.contentWindow?.addEventListener("keydown", this.onKey);
|
||||
} catch {
|
||||
this.warnIframeKeyForwardingUnavailable();
|
||||
}
|
||||
};
|
||||
attach();
|
||||
frame.addEventListener("load", attach);
|
||||
this.keyForwardFrame = frame;
|
||||
this.detachIframeKeys = (): void => {
|
||||
frame.removeEventListener("load", attach);
|
||||
try {
|
||||
frame.contentWindow?.removeEventListener("keydown", this.onKey);
|
||||
} catch {
|
||||
this.warnIframeKeyForwardingUnavailable();
|
||||
}
|
||||
this.keyForwardFrame = null;
|
||||
this.detachIframeKeys = null;
|
||||
};
|
||||
}
|
||||
|
||||
private warnIframeKeyForwardingUnavailable(): void {
|
||||
if (this.warnedIframeKeyForwardingUnavailable) return;
|
||||
this.warnedIframeKeyForwardingUnavailable = true;
|
||||
console.warn(
|
||||
"[hyperframes-slideshow] iframe keyboard forwarding is unavailable for this composition, likely because the player iframe is cross-origin. Arrow shortcuts work when focus is outside the iframe.",
|
||||
);
|
||||
}
|
||||
|
||||
private playerFrameDocument(player: Partial<PlayerElement> & HTMLElement): Document | null {
|
||||
const frame = player.iframeElement;
|
||||
if (!(frame instanceof HTMLIFrameElement)) return null;
|
||||
@@ -732,17 +817,14 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (!this.controller) return;
|
||||
const target = e.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Duck-typed (not instanceof): this handler also receives keydowns forwarded
|
||||
// from the composition iframe, whose elements are instances of the IFRAME
|
||||
// realm's classes — instanceof against this realm's would never match.
|
||||
if (isTextEntryTarget(e.target)) return;
|
||||
const active = document.activeElement;
|
||||
// With focus inside the composition iframe, the top document's activeElement
|
||||
// is the <iframe> itself — contained by this element, so `focused` stays true
|
||||
// and arrows keep driving the deck after the presenter clicks into the slide.
|
||||
const focused = active === this || this.contains(active);
|
||||
// Arrows act even when nothing is focused (active === body/null) so a freshly
|
||||
// loaded deck responds without a click; Space/Backspace have strong page-level
|
||||
@@ -751,6 +833,16 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
// doesn't drive every instance at once — only the focused deck responds.
|
||||
const multiInstance = document.querySelectorAll("hyperframes-slideshow").length > 1;
|
||||
const ambient = focused || (!multiInstance && (active === document.body || active === null));
|
||||
// P is handled BEFORE the controller guard: present() doesn't need the
|
||||
// controller, and on a slow-loading deck the advertised shortcut must work
|
||||
// immediately rather than silently doing nothing until the controller binds.
|
||||
if ((e.key === "p" || e.key === "P") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (!ambient || !this.shouldShowPresentControl()) return;
|
||||
this.present();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!this.controller) return;
|
||||
if (e.key === "ArrowRight") {
|
||||
if (!ambient) return;
|
||||
this.controller.next();
|
||||
@@ -771,10 +863,6 @@ export class HyperframesSlideshow extends HTMLElement {
|
||||
if (!focused) return;
|
||||
this.toggleFullscreen();
|
||||
e.preventDefault();
|
||||
} else if ((e.key === "p" || e.key === "P") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (!ambient || !this.shouldShowPresentControl()) return;
|
||||
this.present();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user