orz-slides 0.1.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.
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Browser entry for orz-slides — the in-file presentation engine.
3
+ *
4
+ * esbuild bundles this (with orz-markdown, reveal.js, the slide parser, layout
5
+ * engine, and assembler) into a single IIFE, `dist/orz-slides.browser.js`,
6
+ * exposing `window.orzslides`.
7
+ *
8
+ * On boot it reads the embedded deck source from `<script id="orz-deck">`,
9
+ * parses + assembles it into reveal `<section>`s, initialises reveal.js, lazily
10
+ * loads the canvas/diagram enhancers (mermaid / smiles-drawer / Chart.js) that a
11
+ * deck actually uses, and runs the WP6 scale-to-fit pass.
12
+ *
13
+ * Two delivery modes (see cli.ts): `--inline` embeds this bundle in each
14
+ * .slides.html; `--cdn` references a published copy on jsDelivr
15
+ * (package `orz-slides-browser`).
16
+ */
17
+ import { md } from 'orz-markdown';
18
+ import { parseDeck } from './slide-parser.js';
19
+ import { renderDeck, renderSlide } from './render-slide.js';
20
+ import Reveal from 'reveal.js';
21
+ const VERSION = typeof __ORZSLIDES_VERSION__ !== 'undefined' ? __ORZSLIDES_VERSION__ : '0.0.0';
22
+ /* ---------- enhancers (lazy-loaded by presence) ---------- */
23
+ function loadScript(src) {
24
+ return new Promise((resolve, reject) => {
25
+ if (!src)
26
+ return resolve();
27
+ if (document.querySelector(`script[data-lib="${src}"]`))
28
+ return resolve();
29
+ const s = document.createElement('script');
30
+ s.src = src;
31
+ s.async = true;
32
+ s.setAttribute('data-lib', src);
33
+ s.onload = () => resolve();
34
+ s.onerror = () => reject(new Error('failed to load ' + src));
35
+ document.head.appendChild(s);
36
+ });
37
+ }
38
+ /** Load only the enhancer libraries the rendered deck needs. */
39
+ function loadEnhancers(cfg) {
40
+ const e = (cfg && cfg.enhancers) || {};
41
+ const jobs = [];
42
+ if (e.mermaidJs && document.querySelector('.mermaid'))
43
+ jobs.push(loadScript(e.mermaidJs));
44
+ if (e.smilesJs && document.querySelector('canvas[data-smiles]'))
45
+ jobs.push(loadScript(e.smilesJs));
46
+ if (e.chartJs && document.querySelector('canvas.orz-chart'))
47
+ jobs.push(loadScript(e.chartJs));
48
+ return Promise.all(jobs).catch(() => undefined);
49
+ }
50
+ /** Draw mermaid diagrams, smiles structures, and charts that haven't been drawn. */
51
+ /** Is the active theme dark? (drives the smiles draw scheme) */
52
+ function isDarkTheme() {
53
+ const cfg = window.__ORZ_SLIDES__ || {};
54
+ const id = document.documentElement.getAttribute('data-theme') || cfg.defaultTheme;
55
+ const t = (cfg.themes || []).find((x) => x.id === id);
56
+ return !!(t && t.scheme === 'dark');
57
+ }
58
+ function enhance() {
59
+ const w = window;
60
+ try {
61
+ if (w.mermaid) {
62
+ w.mermaid.initialize({ startOnLoad: false });
63
+ const p = w.mermaid.run({ querySelector: '.mermaid:not([data-processed])' });
64
+ if (p && p.then)
65
+ p.then(() => fitCurrent()).catch(() => undefined); // re-fit once the SVG lands
66
+ }
67
+ }
68
+ catch (e) { /* ignore */ }
69
+ try {
70
+ if (w.SmilesDrawer) {
71
+ // 'dark' draws light-coloured bonds/atoms for dark themes. Redraw when the
72
+ // scheme changes (e.g. the user switches to a dark theme in the editor).
73
+ const scheme = isDarkTheme() ? 'dark' : 'light';
74
+ document.querySelectorAll('canvas[data-smiles]').forEach((c) => {
75
+ const cc = c;
76
+ // __scheme is set only once a draw succeeds; __pending guards against
77
+ // re-clearing the canvas while a draw for this scheme is in flight.
78
+ if (cc.__scheme === scheme || cc.__pending === scheme)
79
+ return;
80
+ cc.__pending = scheme;
81
+ if (cc.__ow === undefined) {
82
+ cc.__ow = c.width;
83
+ cc.__oh = c.height;
84
+ }
85
+ c.width = cc.__ow;
86
+ c.height = cc.__oh;
87
+ const drawer = new w.SmilesDrawer.Drawer({ width: cc.__ow, height: cc.__oh });
88
+ w.SmilesDrawer.parse(c.getAttribute('data-smiles'), (tree) => {
89
+ try {
90
+ drawer.draw(tree, c, scheme, false);
91
+ cc.__scheme = scheme;
92
+ }
93
+ catch (e) { /* ignore */ }
94
+ cc.__pending = null;
95
+ fitCurrent();
96
+ });
97
+ });
98
+ }
99
+ }
100
+ catch (e) { /* ignore */ }
101
+ // Charts are responsive to their container, so they must be drawn (or
102
+ // resized) only while their slide is visible — a chart drawn in a hidden
103
+ // (display:none) slide sizes to 0. Scope to the current slide; resize on
104
+ // revisit.
105
+ try {
106
+ if (w.Chart) {
107
+ const present = (Reveal.getCurrentSlide && Reveal.getCurrentSlide()) || document;
108
+ present.querySelectorAll('canvas.orz-chart[data-chart]').forEach((c) => {
109
+ if (w.Chart.getChart(c))
110
+ return; // sized by fitRegionGraphics
111
+ try {
112
+ const cfg = JSON.parse(c.getAttribute('data-chart') || '{}');
113
+ cfg.options = cfg.options || {};
114
+ // Not responsive: Chart.js can't get a definite height through the grid
115
+ // chain, so we size it explicitly to the region in fitRegionGraphics.
116
+ cfg.options.responsive = false;
117
+ cfg.options.maintainAspectRatio = false;
118
+ cfg.options.animation = false;
119
+ new w.Chart(c, cfg);
120
+ }
121
+ catch (e) { /* ignore */ }
122
+ });
123
+ }
124
+ }
125
+ catch (e) { /* ignore */ }
126
+ initTabs();
127
+ fixYouTube();
128
+ }
129
+ /** YouTube embeds don't reliably play from a local `file://` page: YouTube
130
+ * validates the embedding origin/referrer, which is null off the filesystem,
131
+ * so embedding-restricted videos refuse to play. When the deck is opened as a
132
+ * local file, swap each iframe for a clickable poster that opens the video on
133
+ * youtube.com (which always plays). Served over http(s) the iframe — which
134
+ * works there — is left untouched. The wrapper keeps its `data-md`, so
135
+ * copy-as-Markdown still recovers `{{youtube ...}}`. */
136
+ function fixYouTube() {
137
+ if (location.protocol !== 'file:')
138
+ return;
139
+ document.querySelectorAll('.youtube-embed').forEach((box) => {
140
+ if (box.__ytFacade)
141
+ return;
142
+ const iframe = box.querySelector('iframe');
143
+ const src = iframe ? iframe.getAttribute('src') || '' : '';
144
+ const m = src.match(/embed\/([\w-]{6,})/);
145
+ if (!iframe || !m)
146
+ return;
147
+ box.__ytFacade = true;
148
+ const id = m[1];
149
+ const a = document.createElement('a');
150
+ a.href = 'https://www.youtube.com/watch?v=' + id;
151
+ a.target = '_blank';
152
+ a.rel = 'noopener noreferrer';
153
+ a.className = 'youtube-facade';
154
+ a.setAttribute('aria-label', 'Play video on YouTube');
155
+ a.style.backgroundImage = "url('https://i.ytimg.com/vi/" + id + "/hqdefault.jpg')";
156
+ a.innerHTML = '<span class="youtube-facade-play" aria-hidden="true"></span>';
157
+ iframe.replaceWith(a);
158
+ });
159
+ }
160
+ /** Wire up `::: tabs` blocks (build the button bar, toggle panels). The
161
+ * orz-markdown runtime that normally does this isn't bundled into slides. */
162
+ function initTabs() {
163
+ document.querySelectorAll('.tabs:not([data-js])').forEach((tabs) => {
164
+ const panels = Array.from(tabs.querySelectorAll(':scope > .tab'));
165
+ if (!panels.length)
166
+ return;
167
+ const bar = document.createElement('div');
168
+ bar.className = 'tabs-bar';
169
+ panels.forEach((panel, i) => {
170
+ const btn = document.createElement('button');
171
+ btn.className = 'tabs-bar-btn' + (i === 0 ? ' active' : '');
172
+ btn.textContent = panel.getAttribute('data-label') || 'Tab ' + (i + 1);
173
+ btn.addEventListener('click', () => {
174
+ bar.querySelectorAll('.tabs-bar-btn').forEach((b) => b.classList.remove('active'));
175
+ panels.forEach((p) => p.classList.remove('active'));
176
+ btn.classList.add('active');
177
+ panel.classList.add('active');
178
+ });
179
+ bar.appendChild(btn);
180
+ if (i === 0)
181
+ panel.classList.add('active');
182
+ });
183
+ tabs.insertBefore(bar, panels[0]);
184
+ tabs.setAttribute('data-js', '1'); // match orz-markdown's runtime so it doesn't re-init (double bar)
185
+ });
186
+ }
187
+ /* ---------- WP6 scale-to-fit ---------- */
188
+ const FIT_FLOOR = 0.6;
189
+ function fitRegion(region) {
190
+ region.style.removeProperty('--region-scale');
191
+ const content = region.firstElementChild;
192
+ if (!content)
193
+ return;
194
+ let scale = 1;
195
+ for (let i = 0; i < 12; i++) {
196
+ if (content.scrollHeight <= region.clientHeight + 1 && content.scrollWidth <= region.clientWidth + 1)
197
+ break;
198
+ scale -= 0.07;
199
+ if (scale < FIT_FLOOR) {
200
+ scale = FIT_FLOOR;
201
+ region.style.setProperty('--region-scale', String(scale));
202
+ break;
203
+ }
204
+ region.style.setProperty('--region-scale', String(scale));
205
+ }
206
+ region.setAttribute('data-scale', scale.toFixed(2));
207
+ }
208
+ /** Size mermaid SVGs to fit their region (both dimensions), keeping the diagram's
209
+ * aspect ratio. Font scale-to-fit can't shrink an SVG, so a tall flowchart would
210
+ * otherwise overflow the region. Charts fill the region via maintainAspectRatio. */
211
+ /** Space available to a graphic = region width × (region height minus the height
212
+ * of the OTHER content in its markdown-body) — so a caption and a diagram can
213
+ * share one region without the diagram overflowing. */
214
+ function availFor(region, graphic) {
215
+ const w = region.clientWidth;
216
+ const mb = graphic.closest('.markdown-body');
217
+ if (!mb)
218
+ return { w, h: region.clientHeight };
219
+ let node = graphic;
220
+ while (node.parentElement && node.parentElement !== mb)
221
+ node = node.parentElement;
222
+ let other = 0;
223
+ Array.prototype.forEach.call(mb.children, (ch) => {
224
+ if (ch !== node)
225
+ other += ch.offsetHeight;
226
+ });
227
+ return { w, h: Math.max(40, region.clientHeight - other - 6) };
228
+ }
229
+ function fitRegionGraphics(region) {
230
+ if (!region.clientWidth || !region.clientHeight)
231
+ return;
232
+ region.querySelectorAll('.mermaid svg').forEach((svg) => {
233
+ const av = availFor(region, svg);
234
+ const vb = svg.viewBox && svg.viewBox.baseVal;
235
+ const ar = vb && vb.height ? vb.width / vb.height : 1;
236
+ let w = av.w;
237
+ let h = w / ar;
238
+ if (h > av.h) {
239
+ h = av.h;
240
+ w = h * ar;
241
+ }
242
+ svg.style.maxWidth = 'none';
243
+ svg.style.width = Math.floor(w) + 'px';
244
+ svg.style.height = Math.floor(h) + 'px';
245
+ });
246
+ const Chart = window.Chart;
247
+ if (Chart && Chart.getChart) {
248
+ region.querySelectorAll('canvas.orz-chart').forEach((c) => {
249
+ const inst = Chart.getChart(c);
250
+ if (inst) {
251
+ const av = availFor(region, c);
252
+ try {
253
+ inst.resize(av.w, av.h);
254
+ }
255
+ catch (e) { /* ignore */ }
256
+ }
257
+ });
258
+ }
259
+ }
260
+ /** Fit every region on the currently-shown slide (only it is laid out). */
261
+ function fitCurrent() {
262
+ const slide = (Reveal.getCurrentSlide && Reveal.getCurrentSlide()) || null;
263
+ if (!slide || slide.getAttribute('data-fit') === 'off')
264
+ return;
265
+ slide.querySelectorAll('.orz-region').forEach((region) => {
266
+ fitRegion(region);
267
+ fitRegionGraphics(region);
268
+ });
269
+ }
270
+ /* ---------- mount ---------- */
271
+ /** Read the embedded deck source (single source of truth). */
272
+ function deckSource() {
273
+ const srcEl = document.getElementById('orz-deck');
274
+ return (srcEl ? srcEl.textContent || '' : '').replace(/^\n/, '').replace(/\n\s*$/, '');
275
+ }
276
+ /** Assemble a deck source into `.reveal .slides`. */
277
+ function assemble(source) {
278
+ const slidesEl = document.querySelector('.reveal .slides');
279
+ if (slidesEl)
280
+ slidesEl.innerHTML = renderDeck(parseDeck(source), md);
281
+ applyFragments(); // tag step-reveal units before reveal counts fragments
282
+ }
283
+ const refresh = () => {
284
+ enhance();
285
+ // The single-slide editor re-render replaces a section's innerHTML (dropping
286
+ // fragment classes) and syncs before calling refresh — re-tag and re-sync.
287
+ if (applyFragments()) {
288
+ try {
289
+ Reveal.sync();
290
+ }
291
+ catch (e) { /* ignore */ }
292
+ }
293
+ fitCurrent();
294
+ };
295
+ /** Re-render the whole deck from a (possibly edited) source, then re-sync reveal.
296
+ * Used by the in-file editor after structural edits. */
297
+ function renderAll(source) {
298
+ assemble(source);
299
+ try {
300
+ Reveal.sync();
301
+ }
302
+ catch (e) { /* ignore */ }
303
+ loadEnhancers(window.__ORZ_SLIDES__ || {}).then(refresh);
304
+ }
305
+ function mount() {
306
+ const cfg = window.__ORZ_SLIDES__ || {};
307
+ const deck = parseDeck(deckSource());
308
+ assemble(deckSource());
309
+ const ratio = (deck.config.ratio || cfg.ratio || '16:9').split(':').map(Number);
310
+ const W = 960;
311
+ const H = Math.round((W * (ratio[1] || 9)) / (ratio[0] || 16));
312
+ deckW = W;
313
+ deckH = H;
314
+ Reveal.initialize({
315
+ width: W, height: H, margin: 0.03, minScale: 0.2, maxScale: 4,
316
+ hash: true, controls: true, progress: true, slideNumber: 'c/t',
317
+ });
318
+ window.orzslides.reveal = Reveal;
319
+ // Presenter keybindings (shown in reveal's '?' help): S = speaker view,
320
+ // T = on-deck clock/timer overlay.
321
+ try {
322
+ Reveal.addKeyBinding({ keyCode: 83, key: 'S', description: 'Speaker view' }, openSpeaker);
323
+ Reveal.addKeyBinding({ keyCode: 84, key: 'T', description: 'Toggle timer/clock' }, toggleDeckTimer);
324
+ }
325
+ catch (e) { /* ignore */ }
326
+ // Keep the speaker window in step with the deck.
327
+ Reveal.on('slidechanged', syncSpeaker);
328
+ Reveal.on('fragmentshown', syncSpeaker);
329
+ Reveal.on('fragmenthidden', syncSpeaker);
330
+ const relayout = () => { try {
331
+ Reveal.layout();
332
+ }
333
+ catch (e) { /* ignore */ } };
334
+ loadEnhancers(cfg).then(() => { relayout(); refresh(); });
335
+ // Diagrams/charts finish asynchronously and may settle after the first fit —
336
+ // re-fit several times so their final size is measured into scale-to-fit.
337
+ Reveal.on('slidechanged', () => { enhance(); [80, 500, 1400].forEach((t) => setTimeout(refresh, t)); });
338
+ [200, 700, 1500, 2600].forEach((t) => setTimeout(() => { relayout(); refresh(); }, t));
339
+ window.addEventListener('resize', () => setTimeout(() => { relayout(); fitCurrent(); }, 60));
340
+ // QR code → click to view fullscreen. Capture phase + stopPropagation so this
341
+ // runs BEFORE the embedded orz-markdown runtime's own qr-expand handler (whose
342
+ // .qrcode-overlay geometry lives in common.css, which slides doesn't ship) —
343
+ // otherwise the runtime intercepts the click and opens an unstyled overlay.
344
+ document.addEventListener('click', (e) => {
345
+ const el = e.target;
346
+ const qr = el && el.closest ? el.closest('.qrcode') : null;
347
+ if (!qr)
348
+ return;
349
+ const svg = qr.querySelector('svg');
350
+ if (!svg)
351
+ return;
352
+ e.stopPropagation();
353
+ e.preventDefault();
354
+ const overlay = document.createElement('div');
355
+ overlay.className = 'orz-qr-overlay';
356
+ overlay.appendChild(svg.cloneNode(true));
357
+ const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey, true); };
358
+ const onKey = (ev) => { if (ev.key === 'Escape') {
359
+ ev.stopPropagation();
360
+ ev.preventDefault();
361
+ close();
362
+ } };
363
+ overlay.addEventListener('click', close);
364
+ document.addEventListener('keydown', onKey, true);
365
+ document.body.appendChild(overlay);
366
+ }, true);
367
+ }
368
+ /* ---------- fragments (step-reveal) ---------- */
369
+ /** The reveal-step units inside a region body: list items reveal individually,
370
+ * other top-level blocks reveal as a whole — in document order. */
371
+ function stepUnits(body) {
372
+ const units = [];
373
+ Array.prototype.forEach.call(body.children, (child) => {
374
+ const tag = child.tagName.toLowerCase();
375
+ if (tag === 'ul' || tag === 'ol') {
376
+ Array.prototype.forEach.call(child.children, (li) => {
377
+ if (li.tagName.toLowerCase() === 'li')
378
+ units.push(li);
379
+ });
380
+ }
381
+ else {
382
+ units.push(child);
383
+ }
384
+ });
385
+ return units;
386
+ }
387
+ /** Tag the content of every `step` slide with reveal's `fragment` class.
388
+ * Idempotent; returns true if it added any new fragment (so the caller can
389
+ * re-sync reveal). Manual `{{attrs[.fragment]}}` blocks are left untouched. */
390
+ function applyFragments() {
391
+ let added = false;
392
+ document.querySelectorAll('section[data-step] .orz-region .markdown-body').forEach((body) => {
393
+ stepUnits(body).forEach((el) => {
394
+ if (!el.classList.contains('fragment')) {
395
+ el.classList.add('fragment');
396
+ added = true;
397
+ }
398
+ });
399
+ });
400
+ return added;
401
+ }
402
+ /* ---------- presenter clock / timer ---------- */
403
+ let deckW = 960;
404
+ let deckH = 540;
405
+ let tickTimer = 0;
406
+ let deckTimerEl = null;
407
+ let speakerWin = null;
408
+ const clock = {
409
+ startMs: 0,
410
+ accumMs: 0,
411
+ running: false,
412
+ start() { if (!this.running) {
413
+ this.startMs = Date.now();
414
+ this.running = true;
415
+ } },
416
+ pause() { if (this.running) {
417
+ this.accumMs += Date.now() - this.startMs;
418
+ this.running = false;
419
+ } },
420
+ toggle() { if (this.running)
421
+ this.pause();
422
+ else
423
+ this.start(); },
424
+ reset() { this.accumMs = 0; this.startMs = Date.now(); },
425
+ elapsed() { return this.accumMs + (this.running ? Date.now() - this.startMs : 0); },
426
+ };
427
+ function pad2(n) { return n < 10 ? '0' + n : '' + n; }
428
+ /** Elapsed ms → m:ss (or h:mm:ss past an hour). */
429
+ function fmtElapsed(ms) {
430
+ const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
431
+ return h > 0 ? h + ':' + pad2(m) + ':' + pad2(sec) : m + ':' + pad2(sec);
432
+ }
433
+ function wallClock() {
434
+ const d = new Date();
435
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
436
+ }
437
+ function ensureTick() { if (!tickTimer)
438
+ tickTimer = window.setInterval(updateClocks, 1000); }
439
+ function updateClocks() {
440
+ const speakerOpen = !!(speakerWin && !speakerWin.closed);
441
+ if (!deckTimerEl && !speakerOpen) {
442
+ if (tickTimer) {
443
+ clearInterval(tickTimer);
444
+ tickTimer = 0;
445
+ }
446
+ return;
447
+ }
448
+ updateDeckTimer();
449
+ if (speakerOpen) {
450
+ const d = speakerWin.document;
451
+ const set = (id, v) => { const el = d.getElementById(id); if (el)
452
+ el.textContent = v; };
453
+ set('sv-clock', wallClock());
454
+ set('sv-timer', fmtElapsed(clock.elapsed()));
455
+ set('sv-ttoggle', clock.running ? 'Pause' : 'Start');
456
+ }
457
+ }
458
+ function updateDeckTimer() {
459
+ if (!deckTimerEl)
460
+ return;
461
+ deckTimerEl.innerHTML =
462
+ '<span class="orz-timer-clock">' + wallClock() + '</span>' +
463
+ '<span class="orz-timer-elapsed">' + fmtElapsed(clock.elapsed()) + '</span>';
464
+ }
465
+ /** Toggle the on-deck clock + elapsed-timer overlay (T). */
466
+ function toggleDeckTimer() {
467
+ if (deckTimerEl) {
468
+ deckTimerEl.remove();
469
+ deckTimerEl = null;
470
+ return;
471
+ }
472
+ clock.start();
473
+ deckTimerEl = document.createElement('div');
474
+ deckTimerEl.className = 'orz-timer';
475
+ document.body.appendChild(deckTimerEl);
476
+ updateDeckTimer();
477
+ ensureTick();
478
+ }
479
+ /* ---------- speaker view (self-contained popup) ---------- */
480
+ function topSections() {
481
+ return Array.prototype.slice.call(document.querySelectorAll('.reveal > .slides > section'));
482
+ }
483
+ /** A scaled, reveal-positioned-neutralised clone of a slide for preview. */
484
+ function stageHTML(section) {
485
+ if (!section)
486
+ return '<div class="sv-end">— End —</div>';
487
+ const clone = section.cloneNode(true);
488
+ clone.querySelectorAll('aside.notes').forEach((n) => n.remove());
489
+ clone.classList.add('present');
490
+ clone.removeAttribute('hidden');
491
+ clone.style.cssText = '';
492
+ return '<div class="reveal"><div class="slides">' + clone.outerHTML + '</div></div>';
493
+ }
494
+ function scaleStage(stage) {
495
+ const slides = stage.querySelector('.slides');
496
+ if (!slides || !stage.clientWidth)
497
+ return;
498
+ const k = stage.clientWidth / deckW;
499
+ slides.style.transform = 'scale(' + k + ')';
500
+ stage.style.height = deckH * k + 'px';
501
+ }
502
+ /** Push current/next preview + notes + position into the speaker window. */
503
+ function syncSpeaker() {
504
+ if (!speakerWin || speakerWin.closed)
505
+ return;
506
+ const d = speakerWin.document;
507
+ const cur = (Reveal.getCurrentSlide && Reveal.getCurrentSlide()) || null;
508
+ const sections = topSections();
509
+ const idx = cur ? sections.indexOf(cur) : -1;
510
+ const next = idx >= 0 ? sections[idx + 1] || null : null;
511
+ const curEl = d.getElementById('sv-cur');
512
+ const nxtEl = d.getElementById('sv-nxt');
513
+ const notesEl = d.getElementById('sv-notes');
514
+ const posEl = d.getElementById('sv-pos');
515
+ if (curEl) {
516
+ curEl.innerHTML = stageHTML(cur);
517
+ scaleStage(curEl);
518
+ }
519
+ if (nxtEl) {
520
+ nxtEl.innerHTML = stageHTML(next);
521
+ scaleStage(nxtEl);
522
+ }
523
+ if (notesEl) {
524
+ const notes = cur ? cur.querySelector('aside.notes') : null;
525
+ notesEl.innerHTML = notes && notes.innerHTML.trim() ? notes.innerHTML : '<em class="sv-nonotes">No notes for this slide.</em>';
526
+ }
527
+ if (posEl)
528
+ posEl.textContent = (idx + 1) + ' / ' + sections.length;
529
+ }
530
+ function speakerDoc(headCss) {
531
+ return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Speaker View — orz-slides</title>'
532
+ + headCss
533
+ + '<style>'
534
+ + '*{box-sizing:border-box}html,body{margin:0;height:100%;background:#16161a;color:#e8e8ea;font:14px/1.45 system-ui,-apple-system,sans-serif}'
535
+ + '.sv-top{display:flex;align-items:center;gap:18px;padding:8px 16px;background:#000;border-bottom:1px solid #2a2a30}'
536
+ + '.sv-clock{font-size:20px;font-variant-numeric:tabular-nums;opacity:.85}'
537
+ + '.sv-tbox{display:flex;align-items:center;gap:8px}.sv-tbox #sv-timer{font-size:22px;font-variant-numeric:tabular-nums;min-width:74px}'
538
+ + '.sv-tbox button{font:inherit;font-size:12px;padding:3px 10px;border:1px solid #4a4a52;background:#23232a;color:#e8e8ea;border-radius:6px;cursor:pointer}.sv-tbox button:hover{background:#30303a}'
539
+ + '.sv-pos{margin-left:auto;font-size:17px;opacity:.8;font-variant-numeric:tabular-nums}'
540
+ + '.sv-main{display:grid;grid-template-columns:1.7fr 1fr;gap:14px;padding:14px;height:calc(100% - 49px)}'
541
+ + '.sv-current{min-height:0;display:flex;flex-direction:column}.sv-side{display:grid;grid-template-rows:auto 1fr;gap:14px;min-height:0}'
542
+ + '.sv-label{font-size:11px;letter-spacing:.09em;text-transform:uppercase;opacity:.5;margin:0 0 5px}'
543
+ + '.sv-stage{position:relative;overflow:hidden;background:#000;border:1px solid #2a2a30;border-radius:7px}'
544
+ + '.sv-stage .reveal{position:static;width:auto;height:auto}'
545
+ + '.sv-stage .slides{position:absolute;left:0;top:0;width:' + deckW + 'px;height:' + deckH + 'px;transform-origin:0 0;margin:0;padding:0;text-align:left}'
546
+ + '.sv-stage .slides>section{position:relative!important;display:block!important;left:0!important;top:0!important;transform:none!important;width:' + deckW + 'px!important;height:' + deckH + 'px!important;opacity:1!important;visibility:visible!important;background:var(--bg,#fff)}'
547
+ + '.sv-stage .fragment{opacity:1!important;visibility:visible!important}'
548
+ + '.sv-notes{background:#fbfbfa;color:#16161a;border-radius:7px;padding:16px 18px;overflow:auto;min-height:0;font-size:16px;line-height:1.5}'
549
+ + '.sv-notes :first-child{margin-top:0}.sv-notes .sv-nonotes{color:#888}'
550
+ + '.sv-end{display:flex;align-items:center;justify-content:center;height:100%;opacity:.45;font-size:18px}'
551
+ + '</style></head><body>'
552
+ + '<div class="sv-top"><div class="sv-clock" id="sv-clock">--:--:--</div>'
553
+ + '<div class="sv-tbox"><span id="sv-timer">0:00</span>'
554
+ + '<button id="sv-ttoggle">Pause</button><button id="sv-treset">Reset</button></div>'
555
+ + '<div class="sv-pos" id="sv-pos">– / –</div></div>'
556
+ + '<div class="sv-main"><div class="sv-current"><div class="sv-label">Current</div><div class="sv-stage" id="sv-cur"></div></div>'
557
+ + '<div class="sv-side"><div><div class="sv-label">Next</div><div class="sv-stage" id="sv-nxt"></div></div>'
558
+ + '<div style="display:flex;flex-direction:column;min-height:0"><div class="sv-label">Notes</div><div class="sv-notes" id="sv-notes"></div></div></div></div>'
559
+ + '</body></html>';
560
+ }
561
+ /** Open (or focus) the speaker-view popup (S). */
562
+ function openSpeaker() {
563
+ if (speakerWin && !speakerWin.closed) {
564
+ speakerWin.focus();
565
+ return;
566
+ }
567
+ const win = window.open('', 'orz-speaker', 'width=1280,height=800');
568
+ if (!win)
569
+ return;
570
+ speakerWin = win;
571
+ const headCss = Array.prototype.slice
572
+ .call(document.querySelectorAll('link[rel="stylesheet"], style'))
573
+ .map((n) => n.outerHTML)
574
+ .join('\n');
575
+ win.document.open();
576
+ win.document.write(speakerDoc(headCss));
577
+ win.document.close();
578
+ const d = win.document;
579
+ const tg = d.getElementById('sv-ttoggle');
580
+ const rs = d.getElementById('sv-treset');
581
+ if (tg)
582
+ tg.addEventListener('click', () => { clock.toggle(); updateClocks(); });
583
+ if (rs)
584
+ rs.addEventListener('click', () => { clock.reset(); updateClocks(); });
585
+ // Drive the main deck from the speaker window.
586
+ d.addEventListener('keydown', (ev) => {
587
+ const k = ev.key;
588
+ if (k === ' ' || k === 'ArrowRight' || k === 'ArrowDown' || k === 'PageDown' || k === 'n' || k === 'N') {
589
+ ev.preventDefault();
590
+ Reveal.next();
591
+ }
592
+ else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'PageUp' || k === 'p' || k === 'P') {
593
+ ev.preventDefault();
594
+ Reveal.prev();
595
+ }
596
+ });
597
+ win.addEventListener('resize', syncSpeaker);
598
+ clock.start();
599
+ ensureTick();
600
+ syncSpeaker();
601
+ win.focus();
602
+ }
603
+ window.orzslides = {
604
+ version: VERSION,
605
+ md,
606
+ parseDeck,
607
+ renderDeck,
608
+ renderSlide,
609
+ mount,
610
+ renderAll,
611
+ refresh,
612
+ openSpeaker,
613
+ toggleTimer: toggleDeckTimer,
614
+ reveal: null,
615
+ };