diff --git a/docs/guides/presenter-view.md b/docs/guides/presenter-view.md index add3d784..d3d15d86 100644 --- a/docs/guides/presenter-view.md +++ b/docs/guides/presenter-view.md @@ -70,28 +70,47 @@ See [Authoring slides](slides.md#speaker-notes) and ## Using a second screen -To present on a projector while keeping the panel on your laptop screen, open two -browser windows at the same URL (`http://localhost:7777`). +To present on a projector while keeping the panel on your laptop screen, +click the "Open presenter view" button in the status bar. +It opens a second window at the same position +(see [Multi-window sync](#multi-window-sync) for what makes this work in a static build). In the window on your laptop screen, press p to open the panel. Leave the other window full-screen on the projector. -Navigation in either window is broadcast to the other over WebSocket, so both -stay in sync regardless of which one you use to advance. +Navigation in either window is broadcast to the other, +so both stay in sync regardless of which one you use to advance. ## Multi-window sync -Position sync runs over the same WebSocket the main view uses for live reload. -Any number of windows can be open at once: - -- A navigation in any window is broadcast to every other open window. -- A window opened at the bare URL adopts the shared position on connect, so it - lands where the presenter already is (second-screen follow). +How position sync reaches other windows depends on how the deck is running: + +- **`inkflow serve`** relays it over the same WebSocket the main view uses for live reload. + Any number of windows can be open at once, + connected however you like: a fresh tab, another browser, even another device on the network. +- **`inkflow build` output** (including a bare `file://` double-click, with no server at all) + has no WebSocket to relay over, + so it syncs exactly **two** windows directly: + the one you launch "Open presenter view" from, and the window it opens. + They talk to each other over a direct browser-to-browser link + that only exists between opener and opened window, + so a second window opened independently + (a fresh tab typed in by hand, rather than via the button) will not join the sync. + +Within either mode: + +- A navigation in one window is broadcast to the other(s). +- A window opened at the bare URL (via the button) adopts the shared position on + connect, so it lands where the presenter already is (second-screen follow). - A window opened at a deliberate deep link (a URL naming a slide, such as `#slide=5`) keeps that slide instead of being pulled to the shared position. A browser refresh counts as a deep link, so reloading never yanks a window off its slide. The slide lives in the URL fragment so the link works the same whether the deck is served, hosted as a static build, or opened straight from disk. -- After a deck rebuild, the position is preserved (clamped if the slide count drops). +- Under `serve`, the position is preserved across a deck rebuild (clamped if the + slide count drops). + +The [sync modes](#sync-modes) below (two-way/present/follow/solo) work identically +in both cases — only the transport between windows differs. ### Sync modes diff --git a/src/inkflow/bundles/presenter.js b/src/inkflow/bundles/presenter.js index 64096244..711bc284 100644 --- a/src/inkflow/bundles/presenter.js +++ b/src/inkflow/bundles/presenter.js @@ -282,6 +282,7 @@ _overviewActive: 0, _overviewCols: 1, ws: null, + windowLink: null, _syncingFromServer: false, _laserMode: false }; @@ -2729,33 +2730,69 @@ } if (receives()) requestSync(); } + function postToPeer(msg) { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify(msg)); + } else if (state.windowLink && !state.windowLink.closed) { + state.windowLink.postMessage(msg, "*"); + } + } function requestSync() { - if (state.ws && state.ws.readyState === WebSocket.OPEN) - state.ws.send(JSON.stringify({ type: "sync-request" })); + postToPeer({ type: "sync-request" }); } function sendNav(transition) { - if (!state.ws || state.ws.readyState !== WebSocket.OPEN || state._syncingFromServer || !sends()) - return; - state.ws.send( - JSON.stringify({ - type: "nav", - slideIndex: state.slideIndex, - step: state.step, - ...transition ? { transition } : {} - }) - ); + if (state._syncingFromServer || !sends()) return; + postToPeer({ + type: "nav", + slideIndex: state.slideIndex, + step: state.step, + ...transition ? { transition } : {} + }); } function sendSnap() { - if (!state.ws || state.ws.readyState !== WebSocket.OPEN || state._syncingFromServer || !sends()) + if (state._syncingFromServer || !sends()) return; + postToPeer({ + type: "nav", + slideIndex: state.slideIndex, + step: state.step, + snap: true + }); + } + function currentNavMessage() { + return { type: "nav", slideIndex: state.slideIndex, step: state.step }; + } + function applyIncomingPosition(msg) { + if (!receives()) return; + if (msg.snap) { + snapInflight(); + snapStepRun(); return; - state.ws.send( - JSON.stringify({ - type: "nav", - slideIndex: state.slideIndex, - step: state.step, - snap: true - }) + } + const newIndex = Math.min( + Math.max(0, msg.slideIndex | 0), + Math.max(0, state.slides.length - 1) ); + const newStep = Math.max(0, msg.step | 0); + if (newIndex === state.slideIndex && newStep === state.step) return; + if (newIndex === state.slideIndex) { + const prevStep = state.step; + state._syncingFromServer = true; + state.step = newStep; + if (Math.abs(newStep - prevStep) === 1) applyCurrentStep(); + else applyCurrentStepInstant(); + state._syncingFromServer = false; + renderPvNext(); + updatePvInfo(); + return; + } + state._syncingFromServer = true; + state.slideIndex = newIndex; + state.step = newStep; + loadSlide(() => { + if (state.step > 0) applyCurrentStep(); + state._syncingFromServer = false; + }, msg.transition ?? null); + renderPv(); } function connectWS(wsPort, authoritative) { if (!wsPort) return; @@ -2793,41 +2830,11 @@ } else if (msg.type === "error") { showError(msg.message); } else if (msg.type === "position") { - if (!receives()) return; - if (msg.snap) { - snapInflight(); - snapStepRun(); - return; - } - if (firstPositionPending) { + if (receives() && !msg.snap && firstPositionPending) { firstPositionPending = false; return; } - const newIndex = Math.min( - Math.max(0, msg.slideIndex | 0), - Math.max(0, state.slides.length - 1) - ); - const newStep = Math.max(0, msg.step | 0); - if (newIndex === state.slideIndex && newStep === state.step) return; - if (newIndex === state.slideIndex) { - const prevStep = state.step; - state._syncingFromServer = true; - state.step = newStep; - if (Math.abs(newStep - prevStep) === 1) applyCurrentStep(); - else applyCurrentStepInstant(); - state._syncingFromServer = false; - renderPvNext(); - updatePvInfo(); - return; - } - state._syncingFromServer = true; - state.slideIndex = newIndex; - state.step = newStep; - loadSlide(() => { - if (state.step > 0) applyCurrentStep(); - state._syncingFromServer = false; - }, msg.transition ?? null); - renderPv(); + applyIncomingPosition(msg); } }; state.ws.onclose = () => { @@ -2841,8 +2848,6 @@ // src/ts/presenter/syncmenu.ts var btnSync = document.getElementById("btn-sync"); var syncMenu = document.getElementById("sync-menu"); - var syncWrap = btnSync.closest(".sync-wrap"); - var enabled = false; var SYNC_ORDER = ["two-way", "present", "follow", "solo"]; var SYNC_LABELS = { "two-way": "Two-way (send + receive)", @@ -2867,7 +2872,6 @@ closeMenu(); } function cycleSyncMode() { - if (!enabled) return; const i = SYNC_ORDER.indexOf(state.syncMode); setSyncMode(SYNC_ORDER[(i + 1) % SYNC_ORDER.length]); } @@ -2898,13 +2902,7 @@ if (syncMenu.classList.contains("open")) closeMenu(); else openMenu(); } - function initSyncMenu(wsPort) { - if (!wsPort) { - syncWrap.style.display = "none"; - document.getElementById("help-sync-row").style.display = "none"; - return; - } - enabled = true; + function initSyncMenu() { btnSync.addEventListener("click", (e) => { e.stopPropagation(); toggleMenu(); @@ -2917,6 +2915,62 @@ renderSyncButton(); } + // src/ts/presenter/windowsync.ts + var statusDot = document.getElementById("ws-dot"); + var btnPresenterView = document.getElementById("btn-presenter-view"); + var POLL_INTERVAL_MS = 300; + var POPUP_BLOCKED_MESSAGE = "Pop-up blocked \u2014 allow pop-ups for this page to open the presenter view."; + function isSyncPayload(data) { + if (typeof data !== "object" || data === null) return false; + const msg = data; + return msg.type === "nav" && typeof msg.slideIndex === "number" && typeof msg.step === "number"; + } + function isSyncRequest(data) { + return typeof data === "object" && data !== null && data.type === "sync-request"; + } + var linkHandler; + var linkPoll; + function attachLink(win, requestCatchUp = false) { + state.windowLink = win; + statusDot.className = "connected"; + btnPresenterView.style.display = "none"; + linkHandler = (e) => { + if (e.source !== win || e.origin !== window.origin) return; + if (isSyncRequest(e.data)) { + win.postMessage(currentNavMessage(), "*"); + return; + } + if (isSyncPayload(e.data)) applyIncomingPosition(e.data); + }; + window.addEventListener("message", linkHandler); + linkPoll = setInterval(() => { + if (win.closed) detachLink(); + }, POLL_INTERVAL_MS); + if (requestCatchUp) requestSync(); + } + function detachLink() { + state.windowLink = null; + statusDot.className = ""; + btnPresenterView.style.display = ""; + if (linkHandler) window.removeEventListener("message", linkHandler); + linkHandler = void 0; + clearInterval(linkPoll); + linkPoll = void 0; + } + function initWindowSync(wsPort) { + btnPresenterView.addEventListener("click", () => { + if (wsPort === null && state.windowLink) return; + const child = window.open(location.href); + if (!child) { + showLogs([{ level: "warning", message: POPUP_BLOCKED_MESSAGE }]); + return; + } + if (wsPort === null) attachLink(child); + }); + if (wsPort !== null) return; + if (window.opener) attachLink(window.opener, true); + } + // src/ts/presenter/laser.ts var SVG_NS2 = "http://www.w3.org/2000/svg"; var stageWrap2 = document.getElementById("stage-wrap"); @@ -3594,7 +3648,8 @@ renderPv(); }); loadSyncMode(); - initSyncMenu(WS_PORT); + initSyncMenu(); + initWindowSync(WS_PORT); var deepLinked = readURL(); loadSlide(); renderPv(); diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index 0c31fc65..916a179b 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -225,6 +225,13 @@

KEYBINDINGS

+ diff --git a/src/ts/presenter/main.ts b/src/ts/presenter/main.ts index 342f358c..83f0971b 100644 --- a/src/ts/presenter/main.ts +++ b/src/ts/presenter/main.ts @@ -10,6 +10,7 @@ import { } from "./transitions"; import { showError, showLogs } from "./ui"; import { connectWS, loadSyncMode } from "./websocket"; +import { initWindowSync } from "./windowsync"; import "./keyboard"; // ── Injected by server ── @@ -37,7 +38,8 @@ window.addEventListener("popstate", () => { renderPv(); }); loadSyncMode(); -initSyncMenu(WS_PORT); +initSyncMenu(); +initWindowSync(WS_PORT); // Capture deep-link authority before loadSlide()/syncURL() rewrites the URL. const deepLinked = readURL(); loadSlide(); diff --git a/src/ts/presenter/state.ts b/src/ts/presenter/state.ts index bd35b641..ea2d8ff1 100644 --- a/src/ts/presenter/state.ts +++ b/src/ts/presenter/state.ts @@ -11,6 +11,7 @@ export const state = { _overviewActive: 0, _overviewCols: 1, ws: null as WebSocket | null, + windowLink: null as Window | null, _syncingFromServer: false, _laserMode: false, }; diff --git a/src/ts/presenter/syncmenu.ts b/src/ts/presenter/syncmenu.ts index f2d928e1..8d870ba9 100644 --- a/src/ts/presenter/syncmenu.ts +++ b/src/ts/presenter/syncmenu.ts @@ -8,9 +8,6 @@ import { applySyncMode } from "./websocket"; const btnSync = document.getElementById("btn-sync")!; const syncMenu = document.getElementById("sync-menu")!; -const syncWrap = btnSync.closest(".sync-wrap")!; - -let enabled = false; const SYNC_ORDER: SyncMode[] = ["two-way", "present", "follow", "solo"]; const SYNC_LABELS: Record = { @@ -39,7 +36,6 @@ export function setSyncMode(mode: SyncMode): void { } export function cycleSyncMode(): void { - if (!enabled) return; const i = SYNC_ORDER.indexOf(state.syncMode); setSyncMode(SYNC_ORDER[(i + 1) % SYNC_ORDER.length]); } @@ -78,13 +74,7 @@ function toggleMenu(): void { else openMenu(); } -export function initSyncMenu(wsPort: number | null): void { - if (!wsPort) { - syncWrap.style.display = "none"; - document.getElementById("help-sync-row")!.style.display = "none"; - return; - } - enabled = true; +export function initSyncMenu(): void { btnSync.addEventListener("click", (e) => { // Stop the opening click from reaching the just-added outside-click handler. e.stopPropagation(); diff --git a/src/ts/presenter/websocket.ts b/src/ts/presenter/websocket.ts index c0ee0cb5..24269ac8 100644 --- a/src/ts/presenter/websocket.ts +++ b/src/ts/presenter/websocket.ts @@ -1,4 +1,10 @@ -import type { SyncMode, TransitionData, WsMessage } from "../shared/types"; +import type { + NavMessage, + SyncMode, + SyncPosition, + TransitionData, + WsMessage, +} from "../shared/types"; import { renderPv, renderPvNext, updatePvInfo } from "./pv"; import { state } from "./state"; import { @@ -56,51 +62,103 @@ export function applySyncMode(mode: SyncMode): void { if (receives()) requestSync(); } -// Ask the server to reply (to this client only) with the current position. -function requestSync(): void { - if (state.ws && state.ws.readyState === WebSocket.OPEN) - state.ws.send(JSON.stringify({ type: "sync-request" })); +// ── Outbound ───────────────────────────────────────────────────────────────── + +// Send over whichever transport is live: the WS relay (serve) or the window-link +// (build; windowsync.ts). Exactly one is ever active — see initWindowSync. +// targetOrigin is "*": a window-link peer can be a file:// window carrying an +// opaque origin, which a stricter target could never reliably match. +function postToPeer(msg: NavMessage | { type: "sync-request" }): void { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify(msg)); + } else if (state.windowLink && !state.windowLink.closed) { + state.windowLink.postMessage(msg, "*"); + } } -// ── Outbound ───────────────────────────────────────────────────────────────── +// Ask the peer to reply with its current position: the server relays this to the +// client that pushed it last (serve), or the window-link peer answers directly +// (build — see windowsync.ts's sync-request handling and currentNavMessage below). +// Exported so windowsync.ts can also use it to close the window.open()-to- +// listener-ready race on a freshly opened presenter view. +export function requestSync(): void { + postToPeer({ type: "sync-request" }); +} export function sendNav(transition?: TransitionData | null): void { - if ( - !state.ws || - state.ws.readyState !== WebSocket.OPEN || - state._syncingFromServer || - !sends() - ) - return; - state.ws.send( - JSON.stringify({ - type: "nav", - slideIndex: state.slideIndex, - step: state.step, - ...(transition ? { transition } : {}), - }), - ); + if (state._syncingFromServer || !sends()) return; + postToPeer({ + type: "nav", + slideIndex: state.slideIndex, + step: state.step, + ...(transition ? { transition } : {}), + }); } // Tell other connected screens to snap their in-flight transition to its end, // matching a local same-direction-press snap. Position is unchanged, so this is a // separate signal rather than a normal nav. export function sendSnap(): void { - if ( - !state.ws || - state.ws.readyState !== WebSocket.OPEN || - state._syncingFromServer || - !sends() - ) + if (state._syncingFromServer || !sends()) return; + postToPeer({ + type: "nav", + slideIndex: state.slideIndex, + step: state.step, + snap: true, + }); +} + +// This window's current position, handed directly to a window-link peer that just +// asked for it (a sync-request reply). Unlike sendNav this is a direct answer to an +// explicit request rather than a broadcast, so it isn't gated by +// sends()/_syncingFromServer — the peer gets the truth regardless of this window's +// own sync mode, same as the WS server always answers from its last-known position. +export function currentNavMessage(): NavMessage { + return { type: "nav", slideIndex: state.slideIndex, step: state.step }; +} + +// Apply a position pushed by a peer — shared by the WS relay (serve) and the +// window-link transport (build; windowsync.ts), so the two transports can never +// drift in what "receiving a position" actually does to the slide/step state. +export function applyIncomingPosition(msg: SyncPosition): void { + if (!receives()) return; + if (msg.snap) { + // Another screen snapped its in-flight animation; match it. Position is + // already in sync, so just collapse ours — whichever is live (a slide + // transition or a step run). + snapInflight(); + snapStepRun(); return; - state.ws.send( - JSON.stringify({ - type: "nav", - slideIndex: state.slideIndex, - step: state.step, - snap: true, - }), + } + const newIndex = Math.min( + Math.max(0, msg.slideIndex | 0), + Math.max(0, state.slides.length - 1), ); + const newStep = Math.max(0, msg.step | 0); + if (newIndex === state.slideIndex && newStep === state.step) return; + if (newIndex === state.slideIndex) { + // Same slide, step-only change from a peer: reveal it in place + // rather than rebuilding the slide DOM (which would interrupt the + // step animation and replay the entry transition). A single-step + // delta animates; a multi-step jump lands instantly. + const prevStep = state.step; + state._syncingFromServer = true; + state.step = newStep; + if (Math.abs(newStep - prevStep) === 1) applyCurrentStep(); + else applyCurrentStepInstant(); + state._syncingFromServer = false; + renderPvNext(); + updatePvInfo(); + return; + } + state._syncingFromServer = true; + state.slideIndex = newIndex; + state.step = newStep; + loadSlide(() => { + if (state.step > 0) applyCurrentStep(); + state._syncingFromServer = false; + }, msg.transition ?? null); + renderPv(); } // ── Connection ─────────────────────────────────────────────────────────────── @@ -152,50 +210,15 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void { } else if (msg.type === "error") { showError(msg.message); } else if (msg.type === "position") { - if (!receives()) return; - if (msg.snap) { - // Another screen snapped its in-flight animation; match it. Position is - // already in sync, so just collapse ours — whichever is live (a slide - // transition or a step run). - snapInflight(); - snapStepRun(); - return; - } - if (firstPositionPending) { - // Discard exactly the stale connect-time push so an authoritative - // window keeps its own position. Later updates apply normally. + // Discard exactly the stale connect-time push so an authoritative window + // keeps its own position; later updates apply normally. Mirrors the + // receives()/snap short-circuits inside applyIncomingPosition so a + // non-receiving or snap message never consumes this one-shot flag. + if (receives() && !msg.snap && firstPositionPending) { firstPositionPending = false; return; } - const newIndex = Math.min( - Math.max(0, msg.slideIndex | 0), - Math.max(0, state.slides.length - 1), - ); - const newStep = Math.max(0, msg.step | 0); - if (newIndex === state.slideIndex && newStep === state.step) return; - if (newIndex === state.slideIndex) { - // Same slide, step-only change from a peer: reveal it in place - // rather than rebuilding the slide DOM (which would interrupt the - // step animation and replay the entry transition). A single-step - // delta animates; a multi-step jump lands instantly. - const prevStep = state.step; - state._syncingFromServer = true; - state.step = newStep; - if (Math.abs(newStep - prevStep) === 1) applyCurrentStep(); - else applyCurrentStepInstant(); - state._syncingFromServer = false; - renderPvNext(); - updatePvInfo(); - return; - } - state._syncingFromServer = true; - state.slideIndex = newIndex; - state.step = newStep; - loadSlide(() => { - if (state.step > 0) applyCurrentStep(); - state._syncingFromServer = false; - }, msg.transition ?? null); - renderPv(); + applyIncomingPosition(msg); } }; diff --git a/src/ts/presenter/windowsync.test.ts b/src/ts/presenter/windowsync.test.ts new file mode 100644 index 00000000..2ce194bc --- /dev/null +++ b/src/ts/presenter/windowsync.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, test } from "vitest"; + +// windowsync.ts's transitive imports (websocket, ui) capture DOM element +// references at module evaluation time via getElementById, so the DOM must exist +// before it loads. Mirrors picker.test.ts / transitions.test.ts. + +let isSyncPayload: typeof import("./windowsync").isSyncPayload; + +beforeAll(async () => { + document.body.innerHTML = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+
    + +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + `; + ({ isSyncPayload } = await import("./windowsync")); +}); + +describe("isSyncPayload", () => { + test("accepts a well-formed nav message", () => { + expect(isSyncPayload({ type: "nav", slideIndex: 2, step: 1 })).toBe( + true, + ); + expect( + isSyncPayload({ + type: "nav", + slideIndex: 0, + step: 0, + snap: true, + transition: { type: "cut", duration: 0 }, + }), + ).toBe(true); + }); + + test("rejects messages of the wrong shape or type", () => { + expect(isSyncPayload(null)).toBe(false); + expect(isSyncPayload(undefined)).toBe(false); + expect(isSyncPayload("nav")).toBe(false); + expect( + isSyncPayload({ type: "position", slideIndex: 0, step: 0 }), + ).toBe(false); + expect(isSyncPayload({ type: "nav", slideIndex: "0", step: 0 })).toBe( + false, + ); + expect(isSyncPayload({ type: "nav", step: 0 })).toBe(false); + }); + + test("ignores unrelated postMessage traffic", () => { + // e.g. a browser extension or an unrelated page broadcasting on the tab. + expect(isSyncPayload({ source: "react-devtools", payload: {} })).toBe( + false, + ); + }); +}); diff --git a/src/ts/presenter/windowsync.ts b/src/ts/presenter/windowsync.ts new file mode 100644 index 00000000..6118090a --- /dev/null +++ b/src/ts/presenter/windowsync.ts @@ -0,0 +1,117 @@ +import type { NavMessage } from "../shared/types"; +import { state } from "./state"; +import { showLogs } from "./ui"; +import { + applyIncomingPosition, + currentNavMessage, + requestSync, +} from "./websocket"; + +// Position sync for `inkflow build` output, which has no WebSocket server behind +// it (WS_PORT is null — see globals.d.ts / server.py build_html). Two windows of +// the same exported page sync directly via window.open()/postMessage instead: the +// opener holds a Window reference to whatever it opens, and postMessage works over +// that reference regardless of file://'s opaque origin (targetOrigin "*" in +// websocket.ts's postToPeer — a stricter target could never reliably match two +// file:// windows). Symmetric, not master/slave — whichever side has the reference +// can send and receive, gated by the same per-window SyncMode as the WS transport +// (see websocket.ts). Capped at two windows: a window that is itself a child (has +// window.opener) never offers to open a third. +// +// In serve mode (WS_PORT set) the button is still wired, purely as a convenience +// for opening another tab — the WS relay already syncs however many windows +// connect, so there's no two-window cap and no link bookkeeping on that path; +// each click opens a genuinely new window rather than reusing one. + +const statusDot = document.getElementById("ws-dot")!; +const btnPresenterView = document.getElementById("btn-presenter-view")!; + +// How fast a closed peer is noticed (button re-enabled, dot goes disconnected). +// Cheap to poll — just a `.closed` read — so this stays snappy. +const POLL_INTERVAL_MS = 300; + +const POPUP_BLOCKED_MESSAGE = + "Pop-up blocked — allow pop-ups for this page to open the presenter view."; + +/** Narrows an incoming postMessage payload to a NavMessage before it's applied. */ +export function isSyncPayload(data: unknown): data is NavMessage { + if (typeof data !== "object" || data === null) return false; + const msg = data as Partial; + return ( + msg.type === "nav" && + typeof msg.slideIndex === "number" && + typeof msg.step === "number" + ); +} + +/** A peer asking us to hand back our current position (websocket.ts requestSync). */ +function isSyncRequest(data: unknown): boolean { + return ( + typeof data === "object" && + data !== null && + (data as { type?: unknown }).type === "sync-request" + ); +} + +let linkHandler: ((e: MessageEvent) => void) | undefined; +let linkPoll: ReturnType | undefined; + +// `requestCatchUp` is only for the "I was opened by someone" boot path: it closes +// the race between window.open() returning and this window's listener existing, by +// asking the opener directly for whatever it's actually showing right now, rather +// than waiting for its next navigation. The opener side never needs this — its own +// state already is the current truth. +function attachLink(win: Window, requestCatchUp = false): void { + state.windowLink = win; + statusDot.className = "connected"; + btnPresenterView.style.display = "none"; + linkHandler = (e: MessageEvent) => { + // e.source pins this to the specific window we linked; e.origin guards + // against that same window later navigating to unrelated content while + // still holding the reference (window.opener and the link both survive a + // navigation of the window they point at). + if (e.source !== win || e.origin !== window.origin) return; + if (isSyncRequest(e.data)) { + win.postMessage(currentNavMessage(), "*"); + return; + } + if (isSyncPayload(e.data)) applyIncomingPosition(e.data); + }; + window.addEventListener("message", linkHandler); + linkPoll = setInterval(() => { + if (win.closed) detachLink(); + }, POLL_INTERVAL_MS); + if (requestCatchUp) requestSync(); +} + +function detachLink(): void { + state.windowLink = null; + statusDot.className = ""; + btnPresenterView.style.display = ""; + if (linkHandler) window.removeEventListener("message", linkHandler); + linkHandler = undefined; + clearInterval(linkPoll); + linkPoll = undefined; +} + +export function initWindowSync(wsPort: number | null): void { + btnPresenterView.addEventListener("click", () => { + if (wsPort === null && state.windowLink) return; // already linked (2-window cap) + // No target name: a named target gets reused/refocused by a later + // window.open() call with the same name — including from inside the window + // it names, which would just reload itself. Serve has no window-count cap + // (the WS relay already syncs however many windows connect), so repeat + // clicks must always open a genuinely new window rather than colliding with + // whichever window happens to hold that name. + const child = window.open(location.href); + if (!child) { + showLogs([{ level: "warning", message: POPUP_BLOCKED_MESSAGE }]); + return; + } + if (wsPort === null) attachLink(child); + }); + + if (wsPort !== null) return; // serve: WS already syncs any window opened this way + + if (window.opener) attachLink(window.opener as Window, true); +} diff --git a/src/ts/shared/types.ts b/src/ts/shared/types.ts index b8dfb9bb..92102d65 100644 --- a/src/ts/shared/types.ts +++ b/src/ts/shared/types.ts @@ -41,6 +41,13 @@ export interface NavMessage { snap?: boolean; } +// The position-carrying fields common to an outbound NavMessage and an inbound +// WsMessage "position" — what applyIncomingPosition (websocket.ts) needs, shared by +// the WebSocket relay (serve) and the window-link transport (build; windowsync.ts). +// Derived from NavMessage rather than restated so a new field can't drift between +// the two (or WsMessage's "position" variant below). +export type SyncPosition = Omit; + export type WsMessage = | { type: "update"; @@ -49,10 +56,4 @@ export type WsMessage = logs: LogEntry[]; } | { type: "error"; message: string } - | { - type: "position"; - slideIndex: number; - step: number; - transition?: TransitionData; - snap?: boolean; - }; + | ({ type: "position" } & SyncPosition);