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:
Vance Ingalls
2026-07-02 10:07:35 -07:00
committed by GitHub
parent 65b2093396
commit a7c3cc7d68
5 changed files with 288 additions and 82 deletions
@@ -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();
}
};