Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions docs/guides/presenter-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>p</kbd> 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

Expand Down
181 changes: 118 additions & 63 deletions src/inkflow/bundles/presenter.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@
_overviewActive: 0,
_overviewCols: 1,
ws: null,
windowLink: null,
_syncingFromServer: false,
_laserMode: false
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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)",
Expand All @@ -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]);
}
Expand Down Expand Up @@ -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();
Expand All @@ -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");
Expand Down Expand Up @@ -3594,7 +3648,8 @@
renderPv();
});
loadSyncMode();
initSyncMenu(WS_PORT);
initSyncMenu();
initWindowSync(WS_PORT);
var deepLinked = readURL();
loadSlide();
renderPv();
Expand Down
7 changes: 7 additions & 0 deletions src/inkflow/presenter.html
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ <h2>KEYBINDINGS</h2>
</button>
</div>
</span>
<button type="button" class="hud-btn" id="btn-presenter-view" title="Open presenter view" aria-label="Open presenter view">
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="pointer-events:none">
<path d="M6.5 2H2.75A.75.75 0 0 0 2 2.75v10.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75V9.5"/>
<path d="M9.5 2h4v4"/>
<path d="M13.5 2 7.5 8"/>
</svg>
</button>
<span id="sb-spacer" style="flex:1"></span>
<span id="zoom-indicator" aria-hidden="true">1.0×</span>
<span class="hud-sep"></span>
Expand Down
4 changes: 3 additions & 1 deletion src/ts/presenter/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/ts/presenter/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
12 changes: 1 addition & 11 deletions src/ts/presenter/syncmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import { applySyncMode } from "./websocket";

const btnSync = document.getElementById("btn-sync")!;
const syncMenu = document.getElementById("sync-menu")!;
const syncWrap = btnSync.closest<HTMLElement>(".sync-wrap")!;

let enabled = false;

const SYNC_ORDER: SyncMode[] = ["two-way", "present", "follow", "solo"];
const SYNC_LABELS: Record<SyncMode, string> = {
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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();
Expand Down
Loading