@openpresentation/opf-render 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/player.js ADDED
@@ -0,0 +1,706 @@
1
+ // RR-28: the slideshow player. `present()` takes a deck over the screen (full-screen, keyboard and swipe driven) and can open a
2
+ // speaker view in a second window with the current and next slide, plain-text notes, a timer and the clock. Every window
3
+ // shows the renderer's own SVG; windows of one deck follow each other over a BroadcastChannel (same origin). Hidden
4
+ // slides are skipped. No transitions or builds (deferred, opf#250) and no rich notes (opf#251): notes are plain text.
5
+ import {
6
+ DeckError, createCursor, createDeckStore, createSync, createTimer, deckChannelName, formatDuration, localIsoDate, newSurfaceId,
7
+ parseDeckDocument, plainText, presentableIndexes, prepareSlideSvg, sanitizeSvgTree, slideLabel, stateWins, svgDimensions,
8
+ } from "./deck-runtime.js";
9
+ import { loadPreviewFonts } from "./preview-fonts.js";
10
+
11
+ export { DeckError } from "./deck-runtime.js";
12
+
13
+ const AUDIENCE_CSS = `
14
+ :host { all: initial; }
15
+ * { box-sizing: border-box; }
16
+ [hidden] { display: none !important; }
17
+ .player { position: fixed; inset: 0; z-index: 2147483000; background: #000; color: #fff; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; outline: none; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; }
18
+ .player.idle { cursor: none; }
19
+ .stage { position: absolute; inset: 0; container-type: size; display: grid; place-items: center; }
20
+ .frame { width: min(100cqw, calc(100cqh * var(--ratio-n, 1.7778))); aspect-ratio: var(--ratio, 16 / 9); position: relative; }
21
+ .frame svg.opf-slide { display: block; width: 100%; height: 100%; }
22
+ .blank { position: absolute; inset: 0; }
23
+ .blank[hidden] { display: none; }
24
+ .blank.black { background: #000; }
25
+ .blank.white { background: #fff; }
26
+ .hud { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; background: linear-gradient(transparent, rgb(0 0 0 / 0.7)); }
27
+ .hud button { font: inherit; color: #fff; background: rgb(255 255 255 / 0.16); border: 1px solid rgb(255 255 255 / 0.5); border-radius: 6px; min-width: 40px; min-height: 40px; padding: 4px 12px; cursor: pointer; }
28
+ .hud button:hover { background: rgb(255 255 255 / 0.3); }
29
+ .hud button:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
30
+ .hud button[aria-disabled="true"] { opacity: 0.4; cursor: default; }
31
+ .hud .counter { min-width: 5em; text-align: center; font-variant-numeric: tabular-nums; }
32
+ .player.idle .hud:not(:focus-within) { opacity: 0; pointer-events: none; }
33
+ @media (prefers-reduced-motion: no-preference) { .hud { transition: opacity 200ms; } }
34
+ .goto { position: absolute; top: 16px; right: 16px; padding: 8px 14px; border-radius: 6px; background: rgb(0 0 0 / 0.75); border: 1px solid rgb(255 255 255 / 0.5); font-size: 20px; }
35
+ .goto[hidden] { display: none; }
36
+ .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
37
+ .msg { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; }
38
+ @media (forced-colors: active) { .hud button { border-color: ButtonText; } }
39
+ `;
40
+
41
+ const PRESENTER_CSS = `
42
+ * { box-sizing: border-box; }
43
+ [hidden] { display: none !important; }
44
+ html, body { height: 100%; margin: 0; }
45
+ body { background: #15171a; color: #f2f4f7; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
46
+ .pv { display: grid; grid-template-rows: auto 1fr auto; position: relative; height: 100vh; gap: 10px; padding: 12px; }
47
+ header, footer { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
48
+ header .grow { flex: 1 1 auto; }
49
+ .big { font-size: 22px; font-variant-numeric: tabular-nums; }
50
+ .muted { color: #aab2bd; }
51
+ .over { color: #ff8a80; }
52
+ main { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 12px; min-height: 0; }
53
+ .col { display: flex; flex-direction: column; gap: 10px; min-height: 0; min-width: 0; }
54
+ h2 { margin: 0 0 4px; font-size: 13px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: #aab2bd; }
55
+ .slidebox { position: relative; width: 100%; background: #000; border: 1px solid #3a3f47; border-radius: 6px; overflow: hidden; aspect-ratio: var(--ratio, 16 / 9); }
56
+ .slidebox svg.opf-slide { display: block; width: 100%; height: 100%; }
57
+ .slidebox .end { position: absolute; inset: 0; display: grid; place-items: center; color: #aab2bd; text-align: center; padding: 8px; }
58
+ .notes { flex: 1 1 auto; min-height: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: #1d2024; border: 1px solid #3a3f47; border-radius: 6px; padding: 10px 12px; font-size: var(--notes-size, 20px); line-height: 1.45; }
59
+ .notes:focus-visible, button:focus-visible { outline: 3px solid #8ab4ff; outline-offset: 2px; }
60
+ button { font: inherit; color: inherit; background: #2a2e34; border: 1px solid #5b626c; border-radius: 6px; min-height: 36px; padding: 4px 12px; cursor: pointer; }
61
+ button:hover { background: #343941; }
62
+ button[aria-pressed="true"] { background: #3d5a99; }
63
+ button[aria-disabled="true"] { opacity: 0.45; cursor: default; }
64
+ .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
65
+ @media (max-width: 760px) { main { grid-template-columns: 1fr; overflow: auto; } }
66
+ @media (forced-colors: active) { button, .notes, .slidebox { border-color: ButtonText; } }
67
+ `;
68
+
69
+ /** Document indexes in the presentation order, from the options. */
70
+ const sequenceOf = (document, options) => presentableIndexes(document, { includeHidden: options.includeHidden });
71
+
72
+ function drawInto(host, svg, doc, { aria } = {}) {
73
+ const template = doc.createElement("template");
74
+ template.innerHTML = prepareSlideSvg(svg);
75
+ const node = template.content.firstElementChild;
76
+ if (node) {
77
+ sanitizeSvgTree(node);
78
+ if (aria === "hidden") node.setAttribute("aria-hidden", "true");
79
+ host.replaceChildren(node);
80
+ }
81
+ return svgDimensions(svg);
82
+ }
83
+
84
+ /** Adopt a stylesheet (not subject to a page's style-src for inline styles); a <style> element where that is unavailable. */
85
+ function adoptStyles(win, doc, node, css) {
86
+ try {
87
+ const sheet = new win.CSSStyleSheet();
88
+ sheet.replaceSync(css);
89
+ node.adoptedStyleSheets = [...node.adoptedStyleSheets, sheet];
90
+ } catch {
91
+ const style = doc.createElement("style");
92
+ style.textContent = css;
93
+ (node.head ?? node).append(style);
94
+ }
95
+ }
96
+
97
+ function makeElement(doc) {
98
+ return (tag, attributes = {}, ...children) => {
99
+ const node = doc.createElement(tag);
100
+ for (const [name, value] of Object.entries(attributes)) if (value !== undefined) node.setAttribute(name, value);
101
+ node.append(...children);
102
+ return node;
103
+ };
104
+ }
105
+
106
+ /** The keys both windows understand. Returns an action, or undefined when the key is not ours. */
107
+ function keyAction(event, buffer) {
108
+ if (event.ctrlKey || event.altKey || event.metaKey) return undefined;
109
+ const key = event.key;
110
+ if (/^[0-9]$/.test(key)) return { type: "digit", digit: key };
111
+ switch (key) {
112
+ case "ArrowRight": case "ArrowDown": case "PageDown": return { type: "next" };
113
+ case "ArrowLeft": case "ArrowUp": case "PageUp": return { type: "previous" };
114
+ case " ": return { type: event.shiftKey ? "previous" : "next" };
115
+ case "Enter": return buffer ? { type: "commit" } : { type: "next" };
116
+ case "Backspace": return buffer ? { type: "erase" } : { type: "previous" };
117
+ case "Home": return { type: "first" };
118
+ case "End": return { type: "last" };
119
+ case "Escape": return buffer ? { type: "cancel" } : { type: "exit" };
120
+ case "n": case "N": return { type: "next" };
121
+ case "p": case "P": return { type: "previous" };
122
+ case "b": case "B": case ".": return { type: "blank", mode: "black" };
123
+ case "w": case "W": case ",": return { type: "blank", mode: "white" };
124
+ case "s": case "S": return { type: "speaker" };
125
+ case "f": case "F": return { type: "fullscreen" };
126
+ default: return undefined;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * One window's view of the show. A surface owns its own position and blank state, draws it through a `view`, and keeps the
132
+ * other windows of the deck in step over the channel; nothing else links the audience window and the speaker view.
133
+ */
134
+ class Surface {
135
+ constructor({ role, doc, win, store, cursor, session, view, channelName }) {
136
+ Object.assign(this, { role, doc, win, store, cursor, session, view });
137
+ this.blank = "none";
138
+ this.digits = "";
139
+ this.id = newSurfaceId();
140
+ // The revision of the state this window shows (-1: nothing has been agreed yet) and whether it still waits for an answer to its hello.
141
+ this.rev = -1;
142
+ this.joining = false;
143
+ // The opener's channel class serves every window of the show: one realm, one origin, whatever the popup's origin says.
144
+ this.sync = createSync({
145
+ name: channelName, id: this.id, BroadcastChannelImpl: session.win.BroadcastChannel,
146
+ onState: (state) => this.receive(state),
147
+ getState: () => this.state(),
148
+ });
149
+ this.keydown = (event) => this.onKey(event);
150
+ this.digitTimer = undefined;
151
+ }
152
+
153
+ state() { return { index: this.cursor.index, blank: this.blank, rev: this.rev }; }
154
+
155
+ /** Say hello: the other windows of the show answer with where it is, and this window takes the first answer. */
156
+ join() { this.joining = true; this.sync.hello(); }
157
+
158
+ /** A state from another window: the newcomer takes the first answer; after that the newer revision wins. */
159
+ receive(state) {
160
+ const adopt = (this.joining && state.reply) || stateWins(state, { rev: this.rev, from: this.id });
161
+ if (!adopt) return;
162
+ this.joining = false;
163
+ this.rev = Math.max(this.rev, state.rev);
164
+ this.apply(state, { broadcast: false });
165
+ }
166
+
167
+ /** Move to a state. `broadcast` is false for a state that came from another window. */
168
+ apply({ index, blank }, { broadcast = true } = {}) {
169
+ const moved = index !== undefined ? this.cursor.gotoIndex(index) : false;
170
+ const blankChanged = blank !== undefined && blank !== this.blank;
171
+ if (blankChanged) this.blank = blank;
172
+ if (!moved && !blankChanged) return false;
173
+ if (broadcast) { this.rev += 1; this.joining = false; }
174
+ if (moved) this.setDigits("");
175
+ this.view.render(this);
176
+ this.session.notify(this, { moved, blankChanged });
177
+ if (broadcast) {
178
+ this.sync.post(this.state());
179
+ // Without BroadcastChannel the windows of one show still follow each other directly.
180
+ if (!this.sync.active) for (const peer of this.session.surfaces) if (peer !== this) { peer.rev = this.rev; peer.apply(this.state(), { broadcast: false }); }
181
+ }
182
+ return true;
183
+ }
184
+
185
+ navigate(position) {
186
+ const sequence = this.cursor.sequence;
187
+ const target = Math.min(Math.max(position, 0), sequence.length - 1);
188
+ if (target === this.cursor.position) return false;
189
+ return this.apply({ index: sequence[target] });
190
+ }
191
+ next() { return this.navigate(this.cursor.position + 1); }
192
+ previous() { return this.navigate(this.cursor.position - 1); }
193
+ first() { return this.navigate(0); }
194
+ last() { return this.navigate(this.cursor.total - 1); }
195
+ goto(number) { return this.navigate(number - 1); }
196
+ setBlank(mode) { return this.apply({ blank: this.blank === mode ? "none" : mode }); }
197
+
198
+ setDigits(value) {
199
+ this.digits = value;
200
+ this.win.clearTimeout(this.digitTimer);
201
+ if (value) this.digitTimer = this.win.setTimeout(() => { this.digits = ""; this.view.renderDigits?.(this); }, 3000);
202
+ this.view.renderDigits?.(this);
203
+ }
204
+
205
+ /** A navigating key shows the slide again if the screen was blanked. */
206
+ onKey(event) {
207
+ if (event.defaultPrevented) return;
208
+ const target = event.composedPath?.()[0] ?? event.target;
209
+ if (target?.closest?.("input, textarea, select")) return;
210
+ const action = keyAction(event, this.digits);
211
+ if (!action) return;
212
+ // A focused button handles its own Enter and Space.
213
+ if (action.type === "next" && (event.key === "Enter" || event.key === " ") && target?.localName === "button") return;
214
+ event.preventDefault();
215
+ switch (action.type) {
216
+ case "digit": this.setDigits((this.digits + action.digit).slice(0, 4)); break;
217
+ case "erase": this.setDigits(this.digits.slice(0, -1)); break;
218
+ case "cancel": this.setDigits(""); break;
219
+ case "commit": { const number = Number.parseInt(this.digits, 10); this.setDigits(""); if (number >= 1) this.goto(number); break; }
220
+ case "blank": this.setBlank(action.mode); break;
221
+ case "exit": this.session.exit(this); break;
222
+ case "speaker": this.session.openPresenterView(); break;
223
+ case "fullscreen": this.session.toggleFullscreen(); break;
224
+ default:
225
+ this.setDigits("");
226
+ if (this.blank !== "none") this.apply({ blank: "none" });
227
+ else if (action.type === "next") this.next();
228
+ else if (action.type === "previous") this.previous();
229
+ else if (action.type === "first") this.first();
230
+ else if (action.type === "last") this.last();
231
+ }
232
+ }
233
+
234
+ dispose() {
235
+ this.win.clearTimeout(this.digitTimer);
236
+ this.sync.close();
237
+ this.view.dispose?.();
238
+ }
239
+ }
240
+
241
+ // --- the audience view: the full-screen overlay ---------------------------------------------------------------------------
242
+
243
+ function mountAudience({ doc, win, container, session }) {
244
+ const el = makeElement(doc);
245
+ const host = doc.createElement("div");
246
+ host.setAttribute("data-opf-player", "");
247
+ const root = host.attachShadow({ mode: "open" });
248
+ adoptStyles(win, doc, root, AUDIENCE_CSS);
249
+ const parts = {};
250
+ parts.frame = el("div", { class: "frame" });
251
+ parts.slide = el("section", { role: "group", "aria-roledescription": "slide" });
252
+ parts.slide.style.cssText = "position:absolute;inset:0";
253
+ parts.frame.append(parts.slide);
254
+ parts.message = el("div", { class: "msg", role: "status" }, "Loading slides…");
255
+ parts.stage = el("div", { class: "stage" }, parts.frame);
256
+ parts.blank = el("div", { class: "blank", "aria-hidden": "true" });
257
+ parts.blank.hidden = true;
258
+ parts.prev = el("button", { type: "button", "aria-label": "Previous slide" }, "◀");
259
+ parts.next = el("button", { type: "button", "aria-label": "Next slide" }, "▶");
260
+ parts.counterText = el("span", { "aria-hidden": "true" });
261
+ parts.counter = el("span", { class: "counter" }, parts.counterText);
262
+ parts.speaker = el("button", { type: "button", "aria-label": "Open speaker view (S)" }, "Speaker view");
263
+ parts.exit = el("button", { type: "button", "aria-label": "Exit slideshow (Escape)" }, "Exit");
264
+ parts.hud = el("div", { class: "hud", role: "group", "aria-label": "Slideshow controls" }, parts.prev, parts.counter, parts.next, parts.speaker, parts.exit);
265
+ parts.goto = el("div", { class: "goto", role: "status" });
266
+ parts.goto.hidden = true;
267
+ parts.live = el("div", { class: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true" });
268
+ parts.root = el("div", { class: "player", role: "dialog", "aria-modal": "true", "aria-label": "Slideshow", tabindex: "-1" }, parts.stage, parts.blank, parts.message, parts.hud, parts.goto, parts.live);
269
+ root.append(parts.root);
270
+
271
+ const view = {
272
+ host, parts, surface: undefined, idleTimer: undefined, swiped: false, start: undefined, previouslyInert: [], drawn: false, returnFocus: undefined,
273
+ setName(name) { parts.root.setAttribute("aria-label", `Slideshow: ${name}`); },
274
+ failed(message) { parts.message.setAttribute("role", "alert"); parts.message.hidden = false; parts.message.textContent = message; },
275
+ render(surface) {
276
+ const { cursor, store } = surface;
277
+ const info = store.info(cursor.index);
278
+ let svg;
279
+ try { svg = store.svg(cursor.index); } catch (error) { view.failed(error.message); return; }
280
+ parts.message.hidden = true;
281
+ const { width, height } = drawInto(parts.slide, svg, doc);
282
+ parts.frame.style.setProperty("--ratio", `${width} / ${height}`);
283
+ parts.frame.style.setProperty("--ratio-n", String(width / height));
284
+ const label = slideLabel(info, cursor.number, cursor.total);
285
+ parts.slide.setAttribute("aria-label", label);
286
+ parts.counterText.textContent = `${cursor.number} / ${cursor.total}`;
287
+ parts.prev.setAttribute("aria-disabled", String(cursor.number <= 1));
288
+ parts.next.setAttribute("aria-disabled", String(cursor.number >= cursor.total));
289
+ parts.blank.hidden = surface.blank === "none";
290
+ parts.blank.className = `blank ${surface.blank === "white" ? "white" : "black"}`;
291
+ // Announce a new slide, and a blank screen; not the first draw.
292
+ if (view.drawn) parts.live.textContent = surface.blank !== "none" ? `${surface.blank === "white" ? "White" : "Black"} screen` : label;
293
+ view.drawn = true;
294
+ this.renderDigits(surface);
295
+ const next = cursor.sequence[cursor.position + 1];
296
+ if (next !== undefined) win.setTimeout(() => { try { store.svg(next); } catch { /* shown (and reported) when reached */ } }, 0);
297
+ },
298
+ renderDigits(surface) {
299
+ parts.goto.hidden = !surface.digits;
300
+ parts.goto.textContent = surface.digits ? `Go to slide ${surface.digits}` : "";
301
+ },
302
+ wake() {
303
+ parts.root.classList.remove("idle");
304
+ win.clearTimeout(view.idleTimer);
305
+ view.idleTimer = win.setTimeout(() => parts.root.classList.add("idle"), 2500);
306
+ },
307
+ attach(returnFocus) {
308
+ view.returnFocus = returnFocus;
309
+ container.append(host);
310
+ // The rest of the page is inert (not focusable, not announced) while the dialog is open.
311
+ let node = host;
312
+ while (node && node !== doc.documentElement) {
313
+ for (const sibling of Array.from(node.parentElement?.children ?? [])) {
314
+ if (sibling !== node && !sibling.inert && sibling.localName !== "script" && sibling.localName !== "style") { sibling.inert = true; view.previouslyInert.push(sibling); }
315
+ }
316
+ node = node.parentElement;
317
+ }
318
+ parts.root.focus({ preventScroll: true });
319
+ view.wake();
320
+ },
321
+ dispose() {
322
+ win.clearTimeout(view.idleTimer);
323
+ for (const node of view.previouslyInert) node.inert = false;
324
+ view.previouslyInert = [];
325
+ host.remove();
326
+ const target = view.returnFocus;
327
+ if (target && target.isConnected) target.focus?.({ preventScroll: true });
328
+ },
329
+ };
330
+
331
+ parts.prev.addEventListener("click", () => view.surface?.previous());
332
+ parts.next.addEventListener("click", () => view.surface?.next());
333
+ parts.speaker.addEventListener("click", () => session.openPresenterView());
334
+ parts.exit.addEventListener("click", () => session.close());
335
+ parts.root.addEventListener("pointermove", () => view.wake());
336
+ parts.root.addEventListener("pointerdown", (event) => { view.swiped = false; view.start = { x: event.clientX, y: event.clientY, id: event.pointerId }; view.wake(); });
337
+ parts.root.addEventListener("pointerup", (event) => {
338
+ const start = view.start; view.start = undefined;
339
+ if (!start || start.id !== event.pointerId || !view.surface) return;
340
+ const dx = event.clientX - start.x, dy = event.clientY - start.y;
341
+ if (Math.abs(dx) >= 40 && Math.abs(dx) >= Math.abs(dy) * 1.5) { view.swiped = true; if (dx < 0) view.surface.next(); else view.surface.previous(); }
342
+ });
343
+ parts.root.addEventListener("click", (event) => {
344
+ if (view.swiped) { view.swiped = false; return; }
345
+ if (!view.surface || event.composedPath().includes(parts.hud)) return;
346
+ if (view.surface.blank !== "none") { view.surface.apply({ blank: "none" }); return; }
347
+ const box = parts.root.getBoundingClientRect();
348
+ if (event.clientX - box.left < box.width / 3) view.surface.previous(); else view.surface.next();
349
+ });
350
+ // Keep Tab inside the dialog.
351
+ parts.root.addEventListener("keydown", (event) => {
352
+ if (event.key !== "Tab") return;
353
+ const buttons = [...parts.hud.querySelectorAll("button")];
354
+ const at = buttons.indexOf(root.activeElement);
355
+ if (event.shiftKey && at <= 0) { event.preventDefault(); buttons[buttons.length - 1].focus(); }
356
+ else if (!event.shiftKey && at === buttons.length - 1) { event.preventDefault(); buttons[0].focus(); }
357
+ });
358
+ return view;
359
+ }
360
+
361
+ // --- the speaker view ------------------------------------------------------------------------------------------------
362
+
363
+ /**
364
+ * The speaker view's DOM. `target` is where it goes (a popup's body, or a shadow root) and `styleTarget` takes its styles;
365
+ * `setTitle` names a popup window. Text from the deck (notes, section) is only ever set as text.
366
+ */
367
+ function mountPresenter({ doc, win, name, session, now, duration, parent, target, styleTarget, setTitle }) {
368
+ const el = makeElement(doc);
369
+ if (setTitle) { doc.title = `Speaker view: ${name}`; doc.documentElement.lang = parent.documentElement.lang || "en"; }
370
+ const parts = {};
371
+ parts.position = el("span", { class: "big" });
372
+ parts.section = el("span", { class: "muted" });
373
+ parts.timer = el("span", { class: "big", role: "timer", "aria-label": "Elapsed time" });
374
+ parts.target = el("span", { class: "muted" });
375
+ parts.toggle = el("button", { type: "button" }, "Pause");
376
+ parts.reset = el("button", { type: "button" }, "Reset timer");
377
+ parts.clock = el("span", { class: "big", "aria-label": "Time of day" });
378
+ parts.header = el("header", {}, parts.position, parts.section, el("span", { class: "grow" }), parts.timer, parts.target, parts.toggle, parts.reset, parts.clock);
379
+ parts.currentBox = el("div", { class: "slidebox" });
380
+ parts.currentSlide = el("div", { role: "group", "aria-roledescription": "slide" });
381
+ parts.currentSlide.style.cssText = "position:absolute;inset:0";
382
+ parts.currentBox.append(parts.currentSlide);
383
+ parts.nextBox = el("div", { class: "slidebox" });
384
+ parts.nextSlide = el("div", { "aria-hidden": "true" });
385
+ parts.nextSlide.style.cssText = "position:absolute;inset:0";
386
+ parts.end = el("div", { class: "end" }, "End of slideshow");
387
+ parts.nextBox.append(parts.nextSlide, parts.end);
388
+ parts.notes = el("div", { class: "notes", tabindex: "0", role: "region", "aria-label": "Speaker notes" });
389
+ parts.left = el("div", { class: "col" }, el("div", {}, el("h2", {}, "Current slide"), parts.currentBox));
390
+ parts.right = el("div", { class: "col" }, el("div", {}, el("h2", {}, "Next slide"), parts.nextBox), el("h2", {}, "Notes"), parts.notes);
391
+ parts.main = el("main", {}, parts.left, parts.right);
392
+ parts.prev = el("button", { type: "button" }, "◀ Previous");
393
+ parts.next = el("button", { type: "button" }, "Next ▶");
394
+ parts.black = el("button", { type: "button", "aria-pressed": "false", title: "Black screen (B)" }, "Black screen");
395
+ parts.white = el("button", { type: "button", "aria-pressed": "false", title: "White screen (W)" }, "White screen");
396
+ parts.smaller = el("button", { type: "button", "aria-label": "Smaller notes text" }, "A−");
397
+ parts.larger = el("button", { type: "button", "aria-label": "Larger notes text" }, "A+");
398
+ parts.goto = el("span", { class: "muted", role: "status" });
399
+ parts.live = el("div", { class: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true" });
400
+ parts.grow = el("span", { class: "grow" });
401
+ parts.footer = el("footer", {}, parts.prev, parts.next, parts.black, parts.white, parts.grow, parts.goto, parts.smaller, parts.larger);
402
+ parts.heading = el("h1", { class: "sr-only" }, `Speaker view: ${name}`);
403
+ parts.header.prepend(parts.heading);
404
+ parts.root = el("div", { class: "pv" }, parts.header, parts.main, parts.footer, parts.live);
405
+ target.replaceChildren(parts.root);
406
+ adoptStyles(win, doc, styleTarget.getRootNode?.() ?? styleTarget, PRESENTER_CSS);
407
+
408
+ const timer = createTimer(now);
409
+ timer.start();
410
+ const targetMs = Number.isFinite(duration) && duration > 0 ? duration * 60000 : undefined;
411
+ const clockFormat = (() => { try { return new win.Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }); } catch { return undefined; } })();
412
+ let fontSize = 20;
413
+ const synced = new WeakSet();
414
+ const view = {
415
+ parts, surface: undefined, drawn: false, interval: undefined, timer,
416
+ // Fonts live in the document that loaded them; the speaker view's own document gets the same faces.
417
+ syncFonts() {
418
+ if (doc === parent) return;
419
+ try { for (const face of parent.fonts) if (!synced.has(face)) { synced.add(face); doc.fonts.add(face); } } catch { /* the notes still read; slides draw with the next best font */ }
420
+ },
421
+ render(surface) {
422
+ view.syncFonts();
423
+ const { cursor, store } = surface;
424
+ const info = store.info(cursor.index);
425
+ let svg;
426
+ try { svg = store.svg(cursor.index); } catch (error) { parts.currentSlide.textContent = error.message; return; }
427
+ const { width, height } = drawInto(parts.currentSlide, svg, doc);
428
+ parts.root.style.setProperty("--ratio", `${width} / ${height}`);
429
+ parts.currentSlide.setAttribute("aria-label", slideLabel(info, cursor.number, cursor.total));
430
+ parts.position.textContent = `Slide ${cursor.number} of ${cursor.total}`;
431
+ parts.section.textContent = info.section ? `Section: ${info.section}` : "";
432
+ const nextIndex = cursor.sequence[cursor.position + 1];
433
+ parts.end.hidden = nextIndex !== undefined;
434
+ if (nextIndex !== undefined) { try { drawInto(parts.nextSlide, store.svg(nextIndex), doc, { aria: "hidden" }); } catch { parts.nextSlide.replaceChildren(); } }
435
+ else parts.nextSlide.replaceChildren();
436
+ // Plain text only: notes are never parsed as markup.
437
+ parts.notes.textContent = info.notes || "No notes for this slide.";
438
+ parts.notes.style.color = info.notes ? "" : "#aab2bd";
439
+ parts.notes.scrollTop = 0;
440
+ parts.prev.setAttribute("aria-disabled", String(cursor.number <= 1));
441
+ parts.next.setAttribute("aria-disabled", String(cursor.number >= cursor.total));
442
+ parts.black.setAttribute("aria-pressed", String(surface.blank === "black"));
443
+ parts.white.setAttribute("aria-pressed", String(surface.blank === "white"));
444
+ if (view.drawn) parts.live.textContent = slideLabel(info, cursor.number, cursor.total);
445
+ view.drawn = true;
446
+ this.renderDigits(surface);
447
+ view.tick();
448
+ },
449
+ renderDigits(surface) { parts.goto.textContent = surface.digits ? `Go to slide ${surface.digits}` : ""; },
450
+ tick() {
451
+ const elapsed = timer.elapsed();
452
+ parts.timer.textContent = formatDuration(elapsed);
453
+ parts.timer.classList.toggle("over", targetMs !== undefined && elapsed > targetMs);
454
+ parts.target.textContent = targetMs !== undefined ? `of ${formatDuration(targetMs)}` : "";
455
+ parts.toggle.textContent = timer.running ? "Pause" : "Resume";
456
+ const date = new Date(now());
457
+ parts.clock.textContent = clockFormat ? clockFormat.format(date) : date.toTimeString().slice(0, 5);
458
+ },
459
+ dispose() { win.clearInterval(view.interval); },
460
+ };
461
+ view.interval = win.setInterval(() => view.tick(), 1000);
462
+ parts.prev.addEventListener("click", () => view.surface?.previous());
463
+ parts.next.addEventListener("click", () => view.surface?.next());
464
+ parts.black.addEventListener("click", () => view.surface?.setBlank("black"));
465
+ parts.white.addEventListener("click", () => view.surface?.setBlank("white"));
466
+ parts.toggle.addEventListener("click", () => { timer.toggle(); view.tick(); });
467
+ parts.reset.addEventListener("click", () => { timer.reset(); view.tick(); });
468
+ const resize = (delta) => { fontSize = Math.min(48, Math.max(12, fontSize + delta)); parts.notes.style.setProperty("--notes-size", `${fontSize}px`); };
469
+ parts.smaller.addEventListener("click", () => resize(-2));
470
+ parts.larger.addEventListener("click", () => resize(2));
471
+ return view;
472
+ }
473
+
474
+ // --- the session -------------------------------------------------------------------------------------------------------
475
+
476
+ /**
477
+ * A running slideshow. It is an EventTarget: `slidechange` (detail `{ slide, total, index, id, title, notes, section }`),
478
+ * `blank` (`{ mode }`), `presenterview` (`{ open }`) and `close` (`{ slide, index }`). Slide and blank changes made in the
479
+ * speaker view reach it too, so the page sees one stream of events.
480
+ */
481
+ export class PlayerSession extends EventTarget {
482
+ constructor({ doc, win, now, abort }) {
483
+ super();
484
+ Object.assign(this, { doc, win, now, abort });
485
+ this.surfaces = new Set();
486
+ this.closed = false;
487
+ this.presenter = undefined;
488
+ this.enteredFullscreen = false;
489
+ this.primary = undefined;
490
+ this.audienceHost = undefined;
491
+ // Leaving full screen without a key of ours (Escape: the browser keeps that key to itself) ends the show. A window of our own
492
+ // taking focus, the speaker view, also leaves full screen in some browsers; that must not end it.
493
+ this.keepUntil = 0;
494
+ this.onFullscreen = () => {
495
+ if (!this.enteredFullscreen || this.doc.fullscreenElement) return;
496
+ this.enteredFullscreen = false;
497
+ if (this.win.performance.now() < this.keepUntil) return;
498
+ this.close();
499
+ };
500
+ // Escape while the deck is still loading ends the show; once it plays the surface handles every key.
501
+ this.onEarlyKey = (event) => { if (event.key === "Escape" && !this.primary) this.close(); };
502
+ /** Settles when the first slide is drawn (or the start failed). */
503
+ this.ready = new Promise((resolve, reject) => { this.markReady = resolve; this.markFailed = reject; });
504
+ this.ready.catch(() => {});
505
+ }
506
+
507
+ /** The 1-based number of the showing slide, in the sequence that plays (hidden slides are not counted). */
508
+ get slide() { return this.primary?.cursor.number ?? 0; }
509
+ get total() { return this.primary?.cursor.total ?? 0; }
510
+ /** `"none"`, `"black"` or `"white"`. */
511
+ get blankMode() { return this.primary?.blank ?? "none"; }
512
+ /** Whether the speaker view window is open. */
513
+ get presenterViewOpen() { return Boolean(this.presenter && !this.presenter.win.closed); }
514
+
515
+ next() { return this.primary?.next() ?? false; }
516
+ previous() { return this.primary?.previous() ?? false; }
517
+ first() { return this.primary?.first() ?? false; }
518
+ last() { return this.primary?.last() ?? false; }
519
+ /** Show the slide with this 1-based number (clamped). */
520
+ goto(number) { return this.primary?.goto(number) ?? false; }
521
+ /** Black or white screen; `"none"` shows the slide again. */
522
+ blank(mode = "black") { return this.primary?.apply({ blank: mode }) ?? false; }
523
+
524
+ notify(surface, { moved, blankChanged }) {
525
+ if (this.closed || surface !== this.primary) return;
526
+ if (moved) {
527
+ const info = surface.store.info(surface.cursor.index);
528
+ this.dispatchEvent(new CustomEvent("slidechange", { detail: { slide: surface.cursor.number, total: surface.cursor.total, index: info.index, id: info.id, title: info.title, notes: info.notes, section: info.section } }));
529
+ }
530
+ if (blankChanged) this.dispatchEvent(new CustomEvent("blank", { detail: { mode: surface.blank } }));
531
+ }
532
+
533
+ /** The Escape key in a window: the speaker view closes by itself; in the audience window it ends the show. */
534
+ exit(surface) {
535
+ if (surface.role === "presenter" && this.primary.role === "audience") this.closePresenterView();
536
+ else this.close();
537
+ }
538
+
539
+ toggleFullscreen() {
540
+ if (!this.audienceHost) return;
541
+ // Leaving full screen with the key keeps the show running; leaving it any other way (Escape) ends it.
542
+ if (this.doc.fullscreenElement) { this.enteredFullscreen = false; this.doc.exitFullscreen?.()?.catch?.(() => {}); }
543
+ else this.requestFullscreen();
544
+ }
545
+
546
+ requestFullscreen() {
547
+ try {
548
+ const result = this.audienceHost?.requestFullscreen?.();
549
+ if (result?.then) result.then(() => { this.enteredFullscreen = true; }, () => {});
550
+ } catch { /* the overlay still fills the window */ }
551
+ }
552
+
553
+ /**
554
+ * Open the speaker view in a second window: the current and next slide, the plain-text notes, a timer and the clock, kept
555
+ * in step with the show over a BroadcastChannel. Call it from a user gesture (a key press or a click), or the browser
556
+ * blocks the window. Returns the window, or `null` when it was blocked.
557
+ */
558
+ openPresenterView() {
559
+ if (this.closed || !this.primary) return null;
560
+ if (this.presenterViewOpen) { this.presenter.win.focus(); return this.presenter.win; }
561
+ this.keepUntil = this.win.performance.now() + 3000;
562
+ const win = this.win.open("", `opf-deck-speaker-${this.channelName || "show"}`, "popup,width=1180,height=760");
563
+ if (!win) return null;
564
+ const doc = win.document;
565
+ doc.open(); doc.write('<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>'); doc.close();
566
+ const view = mountPresenter({ doc, win, name: this.name, session: this, now: this.now, duration: this.duration, parent: this.doc, target: doc.body, styleTarget: doc.head, setTitle: true });
567
+ const surface = new Surface({ role: "presenter", doc, win, store: this.primary.store, cursor: createCursor(this.primary.cursor.sequence, this.primary.cursor.position), session: this, view, channelName: this.channelName });
568
+ view.surface = surface;
569
+ surface.blank = this.primary.blank;
570
+ this.surfaces.add(surface);
571
+ this.presenter = surface;
572
+ doc.addEventListener("keydown", surface.keydown, true);
573
+ surface.rev = this.primary.rev;
574
+ view.render(surface);
575
+ surface.join();
576
+ const close = () => { if (this.presenter === surface) this.closePresenterView(); };
577
+ win.addEventListener("pagehide", close, { once: true });
578
+ this.win.addEventListener("pagehide", close, { once: true });
579
+ this.dispatchEvent(new CustomEvent("presenterview", { detail: { open: true } }));
580
+ return win;
581
+ }
582
+
583
+ closePresenterView() {
584
+ const surface = this.presenter;
585
+ if (!surface) return;
586
+ this.presenter = undefined;
587
+ this.surfaces.delete(surface);
588
+ surface.doc.removeEventListener("keydown", surface.keydown, true);
589
+ surface.dispose();
590
+ try { if (!surface.win.closed) surface.win.close(); } catch { /* already gone */ }
591
+ if (!this.closed) this.dispatchEvent(new CustomEvent("presenterview", { detail: { open: false } }));
592
+ }
593
+
594
+ /** End the slideshow: leave full screen, close the speaker view, give focus back. Fires `close`. Safe to call twice. */
595
+ close() {
596
+ if (this.closed) return;
597
+ const slide = this.slide, index = this.primary?.cursor.index;
598
+ this.closePresenterView();
599
+ this.closed = true;
600
+ this.doc.removeEventListener("fullscreenchange", this.onFullscreen);
601
+ this.doc.removeEventListener("keydown", this.onEarlyKey, true);
602
+ this.markFailed?.(new DeckError("closed", "The slideshow was closed before it started."));
603
+ for (const surface of this.surfaces) { surface.doc.removeEventListener("keydown", surface.keydown, true); surface.dispose(); }
604
+ this.surfaces.clear();
605
+ this.abort?.abort();
606
+ if (this.doc.fullscreenElement && this.enteredFullscreen) this.doc.exitFullscreen?.()?.catch?.(() => {});
607
+ this.dispatchEvent(new CustomEvent("close", { detail: { slide, index } }));
608
+ }
609
+ }
610
+
611
+ // One slideshow per document: pressing a Present button twice shows the same show.
612
+ const shows = new WeakMap();
613
+
614
+ const isNodeLike = (value) => value && typeof value === "object" && typeof value.nodeType === "number";
615
+
616
+ async function readSource(source, signal) {
617
+ if (typeof source === "string") {
618
+ if (/^\s*\{/.test(source)) return parseDeckDocument(source);
619
+ const response = await fetch(source, { signal, credentials: "same-origin" });
620
+ if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}).`);
621
+ return parseDeckDocument(await response.text());
622
+ }
623
+ if (isNodeLike(source)) {
624
+ if (!source.document) throw new DeckError("not-ready", "The <opf-deck> has no document yet.");
625
+ return parseDeckDocument(source.document);
626
+ }
627
+ return parseDeckDocument(source);
628
+ }
629
+
630
+ /**
631
+ * Start a slideshow. `source` is an OPF document (an object or JSON text), the URL of one, or an `<opf-deck>` element. The
632
+ * player covers the page at once (and asks for full screen), then resolves with the running session when the first slide is
633
+ * drawn. Call it from a click or key handler: full screen and the speaker view both need a user gesture.
634
+ *
635
+ * Options: `fonts` (the self-hosted font root URL, or a browser font registry), `renderOptions` (extra `renderSvg` options),
636
+ * `startSlide` (1-based), `includeHidden`, `fullscreen` (default true), `presenterView` (open the speaker view too),
637
+ * `role: "presenter"` (this window is the speaker view: for a second tab or window of your page), `channel` (name, or `false`
638
+ * for no sync), `container`, `signal` and `now` (an injectable clock).
639
+ */
640
+ export async function present(source, options = {}) {
641
+ const container = options.container ?? globalThis.document?.body;
642
+ if (!container) throw new DeckError("no-dom", "present() needs a browser document.");
643
+ const doc = container.ownerDocument, win = doc.defaultView;
644
+ const role = options.role === "presenter" ? "presenter" : "audience";
645
+ const abort = new AbortController();
646
+ if (options.signal) options.signal.addEventListener("abort", () => abort.abort(), { once: true });
647
+ const running = role === "audience" ? shows.get(doc) : undefined;
648
+ if (running && !running.closed) { await running.ready; return running; }
649
+ const session = new PlayerSession({ doc, win, now: options.now ?? (() => Date.now()), abort });
650
+ if (role === "audience") { shows.set(doc, session); session.addEventListener("close", () => { if (shows.get(doc) === session) shows.delete(doc); }); }
651
+
652
+ // Cover the page and ask for full screen before awaiting anything: the click that called us must still count.
653
+ let view;
654
+ if (role === "audience") {
655
+ view = mountAudience({ doc, win, container, session });
656
+ session.audienceHost = view.host;
657
+ view.attach(options.returnFocus ?? doc.activeElement);
658
+ if (options.fullscreen !== false) session.requestFullscreen();
659
+ }
660
+ doc.addEventListener("keydown", session.onEarlyKey, true);
661
+ doc.addEventListener("fullscreenchange", session.onFullscreen);
662
+ const fail = (error) => { doc.removeEventListener("keydown", session.onEarlyKey, true); doc.removeEventListener("fullscreenchange", session.onFullscreen); session.markFailed(error); session.closed = true; if (shows.get(doc) === session) shows.delete(doc); session.abort.abort(); view?.dispose(); if (doc.fullscreenElement) doc.exitFullscreen?.()?.catch?.(() => {}); throw error; };
663
+
664
+ let deck, store;
665
+ try {
666
+ deck = await readSource(source, abort.signal);
667
+ const sequence = presentableIndexes(deck, { includeHidden: options.includeHidden });
668
+ if (!sequence.length) throw new DeckError("no-slides", "The deck has no slides to present: every slide is hidden.");
669
+ let registry = options.fonts;
670
+ if (typeof registry === "string") { try { registry = await loadPreviewFonts(registry, { signal: abort.signal }); } catch { registry = undefined; } }
671
+ const date = options.renderOptions?.date ?? localIsoDate();
672
+ store = options.store ?? createDeckStore({ document: deck, fonts: registry, renderOptions: options.renderOptions ?? {}, date });
673
+ try { await store.ready(abort.signal); } catch (error) {
674
+ if (!registry || options.store) throw error;
675
+ store = createDeckStore({ document: deck, renderOptions: options.renderOptions ?? {}, date });
676
+ }
677
+ abort.signal.throwIfAborted?.();
678
+ session.name = plainText(deck.name) || "Presentation";
679
+ session.duration = Number(deck.duration);
680
+ session.channelName = options.channel === false ? "" : (options.channel ?? deckChannelName(deck));
681
+ const cursor = createCursor(sequence, Number.isInteger(options.startSlide) ? options.startSlide - 1 : 0);
682
+ if (!view) {
683
+ // The speaker view in this window (a second tab or window of the host page): its own shadow root over the page.
684
+ const host = doc.createElement("div");
685
+ host.setAttribute("data-opf-player", "");
686
+ host.style.cssText = "position:fixed;inset:0;z-index:2147483000;overflow:auto;background:#15171a";
687
+ const root = host.attachShadow({ mode: "open" });
688
+ container.append(host);
689
+ view = mountPresenter({ doc, win, name: session.name, session, now: session.now, duration: session.duration, parent: doc, target: root, styleTarget: root, setTitle: false });
690
+ const dispose = view.dispose;
691
+ view.dispose = () => { dispose(); host.remove(); };
692
+ } else view.setName(session.name);
693
+ const surface = new Surface({ role, doc, win, store, cursor, session, view, channelName: session.channelName });
694
+ view.surface = surface;
695
+ session.primary = surface;
696
+ session.surfaces.add(surface);
697
+ } catch (error) { return fail(error); }
698
+
699
+ doc.removeEventListener("keydown", session.onEarlyKey, true);
700
+ doc.addEventListener("keydown", session.primary.keydown, true);
701
+ view.render(session.primary);
702
+ session.primary.join();
703
+ session.markReady(session);
704
+ if (options.presenterView && role === "audience") session.openPresenterView();
705
+ return session;
706
+ }