diff --git a/packages/core/templates/partials/mermaid.eta b/packages/core/templates/partials/mermaid.eta index e1ec7cc..8e0b522 100644 --- a/packages/core/templates/partials/mermaid.eta +++ b/packages/core/templates/partials/mermaid.eta @@ -41,12 +41,28 @@ mermaid.initialize({ // pre's textContent has been replaced with the rendered SVG, so we restore // the source from data-source (stashed by the renderer's mermaid handler) // and re-run with our config. -const blocks = document.querySelectorAll("pre.mermaid[data-source]") -for (const pre of blocks) { - pre.textContent = pre.dataset.source ?? "" - pre.removeAttribute("data-processed") -} -if (blocks.length > 0) { +// +// We also wire a visibilitychange retry: when a freshly-spawned tab opens +// in the background (presenter mode opened from the audience tab while the +// user stays on the audience tab), mermaid's render can fail silently — +// canvas measurement and rAF are throttled in hidden tabs and the SVG +// never lands in the DOM. Re-running on visible recovers without a refresh. +async function renderUnrendered() { + const allBlocks = document.querySelectorAll("pre.mermaid[data-source]") + const needsRender = [...allBlocks].filter((pre) => !pre.querySelector("svg")) + if (needsRender.length === 0) return + for (const pre of needsRender) { + pre.textContent = pre.dataset.source ?? "" + pre.removeAttribute("data-processed") + } await mermaid.run({ querySelector: "pre.mermaid", suppressErrors: false }) } + +await renderUnrendered() +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") renderUnrendered() +}) +// window.focus catches the case where a newly-spawned tab gains OS focus +// without firing visibilitychange (it was never marked hidden). +window.addEventListener("focus", () => renderUnrendered()) diff --git a/packages/wc/components/presentation/wc.ts b/packages/wc/components/presentation/wc.ts index 970fb43..edaca70 100644 --- a/packages/wc/components/presentation/wc.ts +++ b/packages/wc/components/presentation/wc.ts @@ -304,10 +304,34 @@ export class PresentationWC extends LitElement { } }; + /** + * When the audience tab regains visibility (e.g., the presenter window + * was closed and focus returned to the audience), the IntersectionObserver + * can fire spuriously for whichever slide happens to be at the current + * scrollTop — and if a smooth scroll was interrupted while the audience + * was backgrounded, scrollTop can be stale, sending currentIndex back to + * an old slide. Force the viewport in sync with xstate's currentIndex. + */ + #onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (this.role !== "audience") return; + const ctx = this.presentation.getSnapshot().context; + const slide = ctx.slides[ctx.currentIndex]; + if (!slide) return; + const target = document.getElementById(slide.id); + if (!target) return; + const expectedTop = target.offsetTop; + if (Math.abs(this.scrollTop - expectedTop) > 10) { + this.scrollTo({ top: expectedTop, behavior: "instant" }); + } + }; + override connectedCallback() { super.connectedCallback(); window.addEventListener("keydown", this.#onKeyDown); + document.addEventListener("visibilitychange", this.#onVisibilityChange); + window.addEventListener("focus", this.#onVisibilityChange); // ── URL routing ─────────────────────────────────────────────────── // The presenter tab is opened with ?role=presenter&uuid=. @@ -392,6 +416,8 @@ export class PresentationWC extends LitElement { override disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("keydown", this.#onKeyDown); + document.removeEventListener("visibilitychange", this.#onVisibilityChange); + window.removeEventListener("focus", this.#onVisibilityChange); if (this.#tickInterval) clearInterval(this.#tickInterval); this.#bridge?.close(); } @@ -461,12 +487,17 @@ export class PresentationWC extends LitElement { } fireUpdates(entries: IntersectionObserverEntry[]) { - // IO drives audience-mode scroll navigation. In presenter mode the - // host stops scrolling and slides redistribute into named slots; the - // resulting flurry of intersection changes would otherwise feed - // bogus `navigate.scroll` events into xstate and march currentIndex - // through every slide before settling at the last one to fire. + // IO drives audience-mode scroll navigation only. if (this.role !== "audience") return; + // …and only when this tab is the focused tab. In Phase 2, the + // audience tab is typically on a secondary display while the user is + // on the presenter tab. Smooth scrolls triggered by remote + // navigation events fire intersection events as slides cross the + // viewport; if we processed those, we'd send navigate.scroll back + // through xstate and watch currentIndex march through every + // intermediate slide. Gate on document.hasFocus so IO only feeds + // state when a user is actually scrolling here. + if (!document.hasFocus()) return; for (const entry of entries) { if (entry.isIntersecting) { this.presentation.send({