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
+
1.0×
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);