diff --git a/packages/runtime/actor/machine.ts b/packages/runtime/actor/machine.ts index d4ad03f..b64c95e 100644 --- a/packages/runtime/actor/machine.ts +++ b/packages/runtime/actor/machine.ts @@ -29,10 +29,15 @@ export const presentation = machineBase.createMachine({ entry: ["scrollToSlide"], on: { "navigate.next": { - actions: ["nextSlide", "updateUrl", "autoStartTimer"], + actions: [ + "nextSlide", + "updateUrl", + "autoStartTimer", + "scrollToSlide", + ], }, "navigate.previous": { - actions: ["previousSlide", "updateUrl"], + actions: ["previousSlide", "updateUrl", "scrollToSlide"], }, "navigate.scroll": { actions: ["updateCurrentSlide", "updateUrl", "autoStartTimer"], diff --git a/packages/runtime/actor/setup.ts b/packages/runtime/actor/setup.ts index 865f0d2..7779146 100644 --- a/packages/runtime/actor/setup.ts +++ b/packages/runtime/actor/setup.ts @@ -25,14 +25,20 @@ export const machineBase = setup({ return context; } }), + /** + * Scroll the audience-mode viewport to the current slide. No-op in + * presenter mode (no scrolling there) and on empty decks. Used both + * as the entry action for initial state and as a side effect of + * remote-driven navigation events broadcast from the presenter tab. + */ scrollToSlide({ context }) { - if (context.currentIndex > 0) { - const slide = context.slides[context.currentIndex]; - - document.querySelector(`#${slide.id}`)?.scrollIntoView({ - behavior: "instant", - }); - } + if (context.role !== "audience") return; + if (context.slides.length === 0) return; + const slide = context.slides[context.currentIndex]; + if (!slide) return; + document.querySelector(`#${slide.id}`)?.scrollIntoView({ + behavior: "instant", + }); }, updateUrl({ context }) { const url = new URL(document.URL); diff --git a/packages/wc/components/presentation/wc.ts b/packages/wc/components/presentation/wc.ts index 4c7194a..970fb43 100644 --- a/packages/wc/components/presentation/wc.ts +++ b/packages/wc/components/presentation/wc.ts @@ -72,6 +72,19 @@ export class PresentationWC extends LitElement { #tickInterval?: ReturnType; + /** Cross-tab event bus, set up in connectedCallback. */ + #bridge?: BroadcastChannel; + + /** + * Events that get relayed across the tab boundary. Anything else + * (presentation.start, role.set, timer.*) is per-tab. + */ + static readonly #RELAYED_EVENTS = new Set([ + "navigate.next", + "navigate.previous", + "navigate.scroll", + ]); + static override styles = css` :host { display: block; @@ -296,8 +309,48 @@ export class PresentationWC extends LitElement { window.addEventListener("keydown", this.#onKeyDown); + // ── URL routing ─────────────────────────────────────────────────── + // The presenter tab is opened with ?role=presenter&uuid=. + // The uuid is critical — both tabs must share it so they reach the + // same BroadcastChannel. Without the override, each tab would get a + // freshly-generated uuid (renderer bakes one per request) and the + // channels wouldn't match. + const url = new URL(window.location.href); + const urlRole = url.searchParams.get("role"); + const urlUuid = url.searchParams.get("uuid"); + if (urlUuid) this.uuid = urlUuid; + const initialRole: "audience" | "presenter" = + urlRole === "presenter" ? "presenter" : "audience"; + + // ── Cross-tab bridge ───────────────────────────────────────────── + // Intercept the actor's send() so user-driven events (toolbar, + // chrome, IO) get broadcast before being processed locally. Events + // delivered via the channel bypass the wrapper (they're already + // remote, re-broadcasting would loop forever). + const bridge = new BroadcastChannel(`morkdeck-bridge-${this.uuid}`); + this.#bridge = bridge; + const originalSend = this.presentation.send.bind(this.presentation); + let suppressBroadcast = false; + this.presentation.send = ((event: Parameters[0]) => { + if ( + !suppressBroadcast && + PresentationWC.#RELAYED_EVENTS.has(event.type) + ) { + bridge.postMessage(event); + } + return originalSend(event); + }) as typeof originalSend; + bridge.onmessage = (ev) => { + suppressBroadcast = true; + try { + originalSend(ev.data); + } finally { + suppressBroadcast = false; + } + }; + // Mirror the xstate context into reactive @state so the template - // updates automatically. Audience-mode wiring is unchanged. + // updates automatically. this.presentation.subscribe(({ context: ctx }) => { if (ctx.role !== this.role) this.role = ctx.role; if (ctx.currentIndex !== this.currentIndex) { @@ -318,14 +371,14 @@ export class PresentationWC extends LitElement { document.addEventListener("readystatechange", () => { if (document.readyState === "complete") { - const url = new URL(document.URL); - const currentSlide = url.hash.substring(1); + const currentSlide = window.location.hash.substring(1); this.presentation.send({ presentationId: this.uuid, type: "presentation.start", slides: this.slides, currentSlide: currentSlide || undefined, + role: initialRole, }); } }); @@ -340,6 +393,7 @@ export class PresentationWC extends LitElement { super.disconnectedCallback(); window.removeEventListener("keydown", this.#onKeyDown); if (this.#tickInterval) clearInterval(this.#tickInterval); + this.#bridge?.close(); } protected override updated() { @@ -543,7 +597,17 @@ export class PresentationWC extends LitElement { #next = () => this.presentation.send({ type: "navigate.next" }); #toggleTimer = () => this.presentation.send({ type: "timer.toggle" }); #resetTimer = () => this.presentation.send({ type: "timer.reset" }); - #exit = () => this.presentation.send({ type: "role.set", role: "audience" }); + #exit = () => { + // In Phase 2 the presenter view runs in its own tab opened from the + // audience tab. Closing the tab is the expected "exit" gesture. + // For local testing — when role was flipped in place — fall back to + // role.set so the same Escape/button works. + if (window.opener && !window.opener.closed) { + window.close(); + } else { + this.presentation.send({ type: "role.set", role: "audience" }); + } + }; #onTargetChange = (e: Event) => { const value = (e.target as HTMLInputElement).value.trim(); diff --git a/packages/wc/components/toolbar.ts b/packages/wc/components/toolbar.ts index 977519c..f25cc9c 100644 --- a/packages/wc/components/toolbar.ts +++ b/packages/wc/components/toolbar.ts @@ -116,10 +116,16 @@ export class Toolbar extends MorkdeckElement { } togglePresenterMode() { - // Phase 1: flip the role in this tab to demo the presenter UI. Phase 2 - // will swap this to open a second tab (?role=presenter) for the real - // two-window experience. - this.deck.send({ type: "role.set", role: "presenter" }); + // Open the presenter view in a new tab. Both tabs share a + // BroadcastChannel keyed off the audience tab's uuid (passed through + // ?uuid=) so navigation events sync. The audience tab stays as it is + // — the user typically drags the new presenter tab to their laptop + // screen while the audience tab gets fullscreened on the projector. + const url = new URL(window.location.href); + url.searchParams.set("role", "presenter"); + url.searchParams.set("uuid", this.deck.getSnapshot().context.presentationId); + // Preserve the hash so the presenter opens on the same slide. + window.open(url.toString(), "_blank"); } override render() {