mdeck 3.10.1 → 3.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
@@ -5,8 +5,8 @@
5
5
  * (a) speaker notes — reads <script type="application/json" id="speaker-notes">
6
6
  * and posts {slideIndexChanged: N} to the parent window on nav.
7
7
  * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End; number keys
8
- * zoom to a slide's hotspots when they are on (see "Hotspots"), and
9
- * jump to slides 1–10 when they are off.
8
+ * zoom to a slide's hotspots when they are on (see "Hotspots") and do
9
+ * nothing when they are off.
10
10
  * (c) press R to reset to slide 0 (with a tasteful keyboard hint).
11
11
  * (d) bottom-center overlay showing slide count + hints, fades out on idle.
12
12
  * (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
@@ -68,6 +68,7 @@
68
68
  import { iconSvg } from '../core/icons.js'
69
69
  import { inkPaint, snapAngle } from '../core/ink.js'
70
70
  import { findHotspots, hotspotMode, zoomToCamera, cameraToZoom } from './hotspots.js'
71
+ import { slideSteps } from './steps.js'
71
72
  import { flight, durationFor, ease } from './zoom/camera.js'
72
73
 
73
74
  (() => {
@@ -85,11 +86,14 @@ import { flight, durationFor, ease } from './zoom/camera.js'
85
86
  // Holding the pen still this long turns the stroke into a straight line.
86
87
  const STRAIGHTEN_MS = 500;
87
88
  const STRAIGHTEN_SLOP = 6; // CSS px the pen may tremble while held
88
- // Laser: a red glow with a white core; the trail lasts LASER_MS.
89
+ // Laser: a red glow with a white core. The trail stays while the pen is
90
+ // down; lifted, it retracts from its tail within LASER_MS.
89
91
  const LASER_GLOW = '#ff2a2a';
90
92
  const LASER_CORE = '#ffffff';
91
93
  const LASER_MS = 1200;
92
- const LASER_GAP_MS = 250;
94
+ // A trail whose end never arrives (the other window closed) goes after this.
95
+ const LASER_HELD_MS = 30000;
96
+ const LASER_STEP = 4; // design px between the points of a held trail
93
97
  // Zoom: up to this many times the fitted size.
94
98
  const ZOOM_MAX = 6;
95
99
  const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
@@ -191,6 +195,73 @@ import { flight, durationFor, ease } from './zoom/camera.js'
191
195
  .zoom-reset svg { width: 16px; height: 16px; }
192
196
  :host([data-zoomed]) .zoom-reset { display: inline-flex; }
193
197
  :host([data-follower]) .zoom-reset { display: none; }
198
+ /* Overview (O): every slide in a grid, local to this window. The slides
199
+ keep their place in the canvas, which grows to hold the grid; a
200
+ sizer gives the stage the grid's height to scroll. */
201
+ .ov-cells, .ov-sizer { display: none; }
202
+ :host([data-overview]) { touch-action: pan-y; }
203
+ :host([data-overview]) .stage { display: block; overflow-x: hidden; overflow-y: auto; touch-action: pan-y; -webkit-overflow-scrolling: touch; }
204
+ :host([data-overview]) .canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; background: none; }
205
+ :host([data-overview]) .ov-sizer { display: block; }
206
+ :host([data-overview]) ::slotted(*) {
207
+ inset: auto !important;
208
+ left: var(--ov-x, 0) !important;
209
+ top: var(--ov-y, 0) !important;
210
+ width: var(--deck-design-w) !important;
211
+ height: var(--deck-design-h) !important;
212
+ opacity: 1;
213
+ visibility: visible;
214
+ pointer-events: none;
215
+ }
216
+ :host([data-overview]) .ink-live, :host([data-overview]) .hotspots, :host([data-overview]) .zoom-reset, :host([data-overview]) .overlay { display: none; }
217
+ :host([data-overview]) .ov-cells { display: block; position: absolute; inset: 0; z-index: 3; }
218
+ .ov-cell {
219
+ position: absolute;
220
+ box-sizing: border-box;
221
+ margin: 0;
222
+ padding: 0;
223
+ border: 0;
224
+ border-radius: 4px;
225
+ outline: calc(3px * var(--ov-k, 1)) solid rgba(255, 255, 255, 0.18);
226
+ background: transparent;
227
+ cursor: pointer;
228
+ -webkit-tap-highlight-color: transparent;
229
+ }
230
+ .ov-cell:hover { outline-color: rgba(255, 255, 255, 0.6); }
231
+ .ov-cell[data-current] { outline-color: #e11d48; }
232
+ .ov-cell[data-cursor] { outline: calc(5px * var(--ov-k, 1)) solid #4da3ff; outline-offset: calc(3px * var(--ov-k, 1)); }
233
+ .ov-cell span {
234
+ position: absolute;
235
+ left: 0;
236
+ top: 100%;
237
+ transform: scale(var(--ov-k, 1));
238
+ transform-origin: 0 0;
239
+ margin-top: calc(6px * var(--ov-k, 1));
240
+ max-width: calc(100% / var(--ov-k, 1));
241
+ overflow: hidden;
242
+ white-space: nowrap;
243
+ text-overflow: ellipsis;
244
+ color: #bbb;
245
+ font: 13px/1.3 system-ui, -apple-system, sans-serif;
246
+ text-align: left;
247
+ }
248
+ .ov-cell[data-current] span { color: #fff; }
249
+
250
+ /* Black screen (B): the screens the audience sees go black; the
251
+ presenter's own slide stays visible under a veil, to say so. */
252
+ .blank {
253
+ position: fixed;
254
+ inset: 0;
255
+ z-index: 2147482900;
256
+ background: #000;
257
+ opacity: 0;
258
+ visibility: hidden;
259
+ pointer-events: none;
260
+ transition: opacity 0.3s ease, visibility 0s linear 0.3s;
261
+ }
262
+ :host([data-blank]) .blank { opacity: 1; visibility: visible; pointer-events: auto; transition: opacity 0.3s ease; }
263
+ :host([data-presenter]) .blank { background: rgba(0, 0, 0, 0.6); pointer-events: none; }
264
+ :host([data-deck-static]) .blank { display: none; }
194
265
  /* Hotspots: a numbered key at the corner of each part of the slide the
195
266
  presenter can zoom to (in design pixels, inside the canvas; the badge
196
267
  keeps its size on screen). Only the presenter's own slide shows them. */
@@ -252,7 +323,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
252
323
  .ink-live .fading { transition: opacity 0.8s ease 2.2s; opacity: 0; }
253
324
  /* The ink toolbar replaces the overlay while drawing. */
254
325
  :host([data-inking]) .overlay { display: none; }
255
- .btn.draw, .btn.fullscreen { display: none; }
326
+ .btn.draw, .btn.fullscreen, .btn.overview { display: none; }
327
+ /* The overview is for a stage that is the page (data-zoomable). */
328
+ :host([data-zoomable]) .btn.overview { display: inline-flex; }
256
329
  :host([data-ink-enabled]) .btn.draw { display: inline-flex; }
257
330
  :host([data-fullscreen-available]) .btn.fullscreen { display: inline-flex; }
258
331
  @media (pointer: coarse) { .overlay .btn { height: 40px; min-width: 40px; } }
@@ -397,7 +470,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
397
470
  break-after: auto;
398
471
  page-break-after: auto;
399
472
  }
400
- .overlay, .ink-input, .ink-live, .ink-laser, .zoom-reset, .hotspots { display: none !important; }
473
+ .overlay, .ink-input, .ink-live, .ink-laser, .zoom-reset, .hotspots, .blank, .ov-cells, .ov-sizer { display: none !important; }
401
474
  }
402
475
  `;
403
476
 
@@ -464,6 +537,13 @@ import { flight, durationFor, ease } from './zoom/camera.js'
464
537
  this._hotspot = null;
465
538
  this._hotspotsForced = null;
466
539
  this._hotspotRaf = 0;
540
+ // Black screen (B), part of the position every screen follows.
541
+ this._blank = false;
542
+ // The join code over the slide (J), the same: shown on every screen.
543
+ this._join = false;
544
+ // Overview (O): open or not, and the slide its cursor is on.
545
+ this._overview = false;
546
+ this._ovCursor = 0;
467
547
  this._queueHotspots = this._queueHotspots.bind(this);
468
548
  this._touches = new Map();
469
549
  this._onWheel = this._onWheel.bind(this);
@@ -562,7 +642,23 @@ import { flight, durationFor, ease } from './zoom/camera.js'
562
642
  });
563
643
  canvas.appendChild(hotspots);
564
644
  this._hotspotLayer = hotspots;
645
+ const ovCells = document.createElement('div');
646
+ ovCells.className = 'ov-cells export-hidden';
647
+ ovCells.setAttribute('data-ink-ui', '');
648
+ ovCells.addEventListener('click', e => {
649
+ const cell = e.target.closest?.('.ov-cell');
650
+ if (!cell) return;
651
+ e.stopPropagation();
652
+ this._overviewGo(Number(cell.dataset.i), 'click');
653
+ });
654
+ canvas.appendChild(ovCells);
655
+ this._ovCells = ovCells;
565
656
  stage.appendChild(canvas);
657
+ const ovSizer = document.createElement('div');
658
+ ovSizer.className = 'ov-sizer';
659
+ stage.appendChild(ovSizer);
660
+ this._ovSizer = ovSizer;
661
+ this._stageEl = stage;
566
662
 
567
663
  // Only shown when fingers draw: keeps their touches from scrolling or
568
664
  // zooming. The input itself is read on the window.
@@ -599,6 +695,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
599
695
  </button>
600
696
  <span class="divider"></span>
601
697
  <button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
698
+ <button class="btn overview" type="button" aria-label="All slides" title="All slides (O)">
699
+ ${iconSvg('overview')}
700
+ </button>
602
701
  <button class="btn draw" type="button" aria-label="Draw on the slide" title="Draw (D)">
603
702
  ${iconSvg('pen')}
604
703
  </button>
@@ -610,6 +709,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
610
709
  overlay.querySelector('.prev').addEventListener('click', () => this.prev('click'));
611
710
  overlay.querySelector('.next').addEventListener('click', () => this.next('click'));
612
711
  overlay.querySelector('.reset').addEventListener('click', () => this.reset());
712
+ overlay.querySelector('.overview').addEventListener('click', () => { this.overview = true; });
613
713
  overlay.querySelector('.draw').addEventListener('click', () => { this.inking = !this.inking; });
614
714
  const fullscreenButton = overlay.querySelector('.fullscreen');
615
715
  fullscreenButton.addEventListener('click', () => toggleFullscreen());
@@ -622,7 +722,14 @@ import { flight, durationFor, ease } from './zoom/camera.js'
622
722
  // Only a top-level window can go full screen (an iPhone cannot at all).
623
723
  if (window.top === window && fullscreenAvailable()) this.setAttribute('data-fullscreen-available', '');
624
724
 
625
- this._root.append(style, stage, inkInput, inkLaser, zoomReset, overlay);
725
+ // Black screen: a click on it shows the slide again, except on a phone
726
+ // that only watches the talk (data-watching, the follow view).
727
+ const blank = document.createElement('div');
728
+ blank.className = 'blank export-hidden';
729
+ blank.setAttribute('data-ink-ui', '');
730
+ blank.addEventListener('click', () => { if (!this.hasAttribute('data-watching')) this.setBlank(false, 'click'); });
731
+
732
+ this._root.append(style, stage, inkInput, inkLaser, zoomReset, blank, overlay);
626
733
  this._canvas = canvas;
627
734
  this._slot = slot;
628
735
  this._overlay = overlay;
@@ -656,6 +763,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
656
763
  _onSlotChange() {
657
764
  this._collectSlides();
658
765
  this._restoreIndex();
766
+ if (this._overview) this._buildOverview();
659
767
  this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
660
768
  this._fit();
661
769
  }
@@ -750,6 +858,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
750
858
  // slide may stay on it.
751
859
  if (curr !== prev) this._clearLive();
752
860
  if (this._countEl) this._countEl.textContent = String(curr + 1);
861
+ // The overview marks the slide shown, wherever the change came from.
862
+ if (this._overview) {
863
+ const cells = this._ovCells?.children ?? [];
864
+ for (let i = 0; i < cells.length; i++) cells[i].toggleAttribute('data-current', i === curr);
865
+ }
753
866
 
754
867
  if (broadcast) {
755
868
  // (1) Legacy: postMessage bridge for presenter/speaker-note renderers.
@@ -788,11 +901,45 @@ import { flight, durationFor, ease } from './zoom/camera.js'
788
901
 
789
902
  get state() {
790
903
  return { index: this._index, slideId: this._slides[this._index]?.dataset.slideId,
791
- step: this._stepMap.get(this._index) ?? -1 };
904
+ step: this._stepMap.get(this._index) ?? -1, blank: this._blank, join: this._join };
905
+ }
906
+
907
+ /** Whether the screen is black (B). Setting it tells the other screens. */
908
+ get blank() { return this._blank; }
909
+ set blank(on) { this.setBlank(on); }
910
+ /** Make the screen black or show the slide again; `reason` as in statechange. */
911
+ setBlank(on, reason = 'api') {
912
+ on = !!on;
913
+ if (on === this._blank) return;
914
+ this._applyBlank(on);
915
+ this._broadcastState(reason);
916
+ }
917
+ _applyBlank(on) {
918
+ this._blank = on;
919
+ this.toggleAttribute('data-blank', on);
920
+ if (on && this._join) this._applyJoin(false);
921
+ }
922
+
923
+ /** Whether the join code shows over the slide (J). Setting it tells the other screens; main.jsx draws it. */
924
+ get join() { return this._join; }
925
+ setJoin(on, reason = 'api') {
926
+ on = !!on;
927
+ if (on === this._join) return;
928
+ this._applyJoin(on);
929
+ this._broadcastState(reason);
930
+ }
931
+ _applyJoin(on) {
932
+ this._join = on;
933
+ this.toggleAttribute('data-join', on);
934
+ if (on && this._blank) this._applyBlank(false);
935
+ this.dispatchEvent(new CustomEvent('joinchange', { detail: { join: on } }));
792
936
  }
793
937
 
794
938
  _broadcastState(reason) {
795
- const message = { deckStateChanged: this.state, reason };
939
+ // atEnd: nothing comes after this, which ends a rehearsal (main.jsx).
940
+ const state = this.state;
941
+ const atEnd = state.index === this._slides.length - 1 && state.step >= this._getSteps(this._slides[state.index]).length - 1;
942
+ const message = { deckStateChanged: state, atEnd, reason };
796
943
  window.postMessage(message, '*');
797
944
  if (window.parent !== window) window.parent.postMessage(message, '*');
798
945
  this.dispatchEvent(new CustomEvent('statechange', { detail: { ...this.state, reason } }));
@@ -805,8 +952,13 @@ import { flight, durationFor, ease } from './zoom/camera.js'
805
952
  if (index < 0 || index >= this._slides.length) return;
806
953
  const max = this._getSteps(this._slides[index]).length - 1;
807
954
  const step = Math.max(-1, Math.min(max, state.step));
955
+ // A position without `blank` (a follower paging on its own) leaves it.
956
+ const blankChanged = typeof state.blank === 'boolean' && state.blank !== this._blank;
957
+ if (blankChanged) this._applyBlank(state.blank);
958
+ const joinChanged = typeof state.join === 'boolean' && state.join !== this._join;
959
+ if (joinChanged) this._applyJoin(state.join);
808
960
  if (index === this._index) {
809
- if (step === this.state.step) return;
961
+ if (step === this.state.step) { if (blankChanged || joinChanged) this._broadcastState('sync'); return; }
810
962
  if (this._hotspot) this.wholeSlide();
811
963
  this._stepMap.set(index, step);
812
964
  this._applySteps(index);
@@ -840,6 +992,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
840
992
  const rect = this.getBoundingClientRect();
841
993
  const vw = rect.width || window.innerWidth;
842
994
  const vh = rect.height || window.innerHeight;
995
+ if (this._overview) { this._fitOverview(vw, vh); return; }
843
996
  const s = Math.min(vw / this.designWidth, vh / this.designHeight);
844
997
  this._fitScale = s;
845
998
  this._hotspotLayer?.style.setProperty('--hotspot-k', String(1 / s));
@@ -867,6 +1020,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
867
1020
  // reports, and controls on the slide (buttons, links, polls, embedded
868
1021
  // frames) keep their own taps.
869
1022
  _tapStart(e) {
1023
+ if (this._overview) { this._tap = null; return; }
870
1024
  const finger = e.pointerType === 'touch' && !(this.inking && this.inkFinger);
871
1025
  const pen = e.pointerType === 'pen' && !this.inking;
872
1026
  this._tap = (finger || pen) && e.isPrimary ? { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now() } : null;
@@ -907,7 +1061,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
907
1061
  const key = e.key;
908
1062
  let handled = true;
909
1063
 
910
- if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
1064
+ if (this._overview && this.overviewKey(key)) {
1065
+ // Arrows, Enter, Escape and O belong to the overview while it is open.
1066
+ } else if ((key === 'o' || key === 'O') && this._isPage()) {
1067
+ this.overview = true;
1068
+ } else if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
911
1069
  this.next('keyboard');
912
1070
  } else if (key === 'ArrowLeft' || key === 'PageUp') {
913
1071
  this.prev('keyboard');
@@ -923,6 +1081,12 @@ import { flight, durationFor, ease } from './zoom/camera.js'
923
1081
  toggleFullscreen();
924
1082
  } else if (key === 'r' || key === 'R') {
925
1083
  this.reset();
1084
+ } else if ((key === 'j' || key === 'J') && this._isPage() && !this.hasAttribute('data-watching') && this.hasAttribute('data-joinable')) {
1085
+ // J: the join code over the slide, in a deck phones join (main.jsx sets data-joinable).
1086
+ this.setJoin(!this._join, 'keyboard');
1087
+ } else if ((key === 'b' || key === 'B' || key === '.') && this._isPage() && !this.hasAttribute('data-watching')) {
1088
+ // B, or the "blank" button of a presenter's clicker, which sends a full stop.
1089
+ this.setBlank(!this._blank, 'keyboard');
926
1090
  } else if (/^[0-9]$/.test(key) || key === 'Escape') {
927
1091
  handled = this.hotspotKey(key);
928
1092
  } else {
@@ -935,6 +1099,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
935
1099
  }
936
1100
  }
937
1101
 
1102
+ // A stage that is the page (a deck window, the audience window, the
1103
+ // presenter's slide); the reader's stage sits in its page and has an
1104
+ // outline of its own.
1105
+ _isPage() { return this.parentElement === document.body; }
1106
+
938
1107
  _go(i, reason = 'api') {
939
1108
  if (!this._slides.length) return;
940
1109
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
@@ -949,9 +1118,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
949
1118
  // Step reveal helpers ---------------------------------------------------
950
1119
 
951
1120
  _getSteps(slide) {
952
- if (!slide) return [];
953
- // Multiple and nested reveal blocks follow document order.
954
- return [...slide.querySelectorAll('[data-step]')];
1121
+ // Each step is a list of elements that appear together.
1122
+ return slideSteps(slide);
955
1123
  }
956
1124
 
957
1125
  _applySteps(slideIndex) {
@@ -959,9 +1127,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
959
1127
  if (!slide) return;
960
1128
  const steps = this._getSteps(slide);
961
1129
  const curr = this._printing ? steps.length - 1 : (this._stepMap.get(slideIndex) ?? -1);
962
- steps.forEach((el, i) =>
1130
+ steps.forEach((elements, i) => elements.forEach(el =>
963
1131
  i <= curr ? el.setAttribute('data-step-visible', '') : el.removeAttribute('data-step-visible')
964
- );
1132
+ ));
965
1133
  this._updateStepCount(slideIndex);
966
1134
  if (slideIndex === this._index) this._queueHotspots();
967
1135
  }
@@ -1020,7 +1188,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1020
1188
  get inking() { return this.hasAttribute('data-inking'); }
1021
1189
  set inking(on) {
1022
1190
  this.toggleAttribute('data-inking', !!on);
1023
- if (!on) { this._endInk(null); this.laserDot('local', null, null); this.showSelection(null); }
1191
+ if (!on) { this._endInk(null); this.laserRelease('local'); this.laserDot('local', null, null); this.showSelection(null); }
1024
1192
  this.dispatchEvent(new CustomEvent('inkmode', { detail: { inking: !!on }, bubbles: true, composed: true }));
1025
1193
  }
1026
1194
 
@@ -1076,7 +1244,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1076
1244
  const drawing = this._inkDrawing = { pointerId: e.pointerId, touch: e.pointerType === 'touch', tool, points: [point], slideId: this._inkSlideId(), gesture: this._inkGesture, key: `${this._inkGesture}` };
1077
1245
  if (tool.tool === 'eraser') { this._erase(point); return; }
1078
1246
  if (tool.tool === 'select') { this._inkSelect('down', point); return; }
1079
- if (tool.tool === 'laser') { this.laserTrail('local', drawing.slideId, [point]); this._inkProgress(drawing); return; }
1247
+ if (tool.tool === 'laser') { this.laserTrail('local', drawing.slideId, [point], { start: true }); this._inkProgress(drawing); return; }
1080
1248
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1081
1249
  this._styleLive(path, tool);
1082
1250
  this._appendLive(path);
@@ -1228,7 +1396,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1228
1396
  if (tool === 'select') { this._inkSelect('up', drawing.points[drawing.points.length - 1], drawing); return; }
1229
1397
  const detail = { key: drawing.key, slideId: drawing.slideId, tool, color, size, points: drawing.points };
1230
1398
  if (tool === 'laser') {
1231
- // Never saved: the trail retracts on its own.
1399
+ // Never saved: lifted, the trail retracts on its own.
1400
+ this.laserRelease('local');
1232
1401
  this.laserDot('local', drawing.slideId, null);
1233
1402
  this.dispatchEvent(new CustomEvent('inklaser', { detail, bubbles: true, composed: true }));
1234
1403
  return;
@@ -1362,7 +1531,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1362
1531
  }
1363
1532
 
1364
1533
  _zoomDown(e) {
1365
- if (e.pointerType !== 'touch' || !this.hasAttribute('data-zoomable') || this._inInkUi(e)) return false;
1534
+ if (this._overview || e.pointerType !== 'touch' || !this.hasAttribute('data-zoomable') || this._inInkUi(e)) return false;
1366
1535
  this._touches.set(e.pointerId, [e.clientX, e.clientY]);
1367
1536
  if (this._touches.size === 2) {
1368
1537
  // A second finger: a pinch, not a tap or a finger's stroke.
@@ -1411,7 +1580,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1411
1580
 
1412
1581
  // Ctrl + scroll is how a trackpad pinch arrives; plain scrolling moves a zoomed slide.
1413
1582
  _onWheel(e) {
1414
- if (!this.hasAttribute('data-zoomable') || this._inInkUi(e)) return;
1583
+ if (this._overview || !this.hasAttribute('data-zoomable') || this._inInkUi(e)) return;
1415
1584
  if (e.ctrlKey) {
1416
1585
  e.preventDefault();
1417
1586
  const zoom = this.zoom;
@@ -1429,6 +1598,136 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1429
1598
  if (this.hasAttribute('data-zoomable') && !this._inInkUi(e)) e.preventDefault();
1430
1599
  }
1431
1600
 
1601
+ // Overview -----------------------------------------------------------------
1602
+ //
1603
+ // O shows every slide in a grid, in this window only: the presenter
1604
+ // view opens it in its own slide frame, so the audience never sees it.
1605
+ // Arrows move the cursor, Enter or a click goes to that slide, O or
1606
+ // Escape go back to the slide shown. The slides show every step.
1607
+ // `overviewchange` ({ open }) says when it opens or closes.
1608
+
1609
+ get overview() { return this._overview; }
1610
+ set overview(on) {
1611
+ on = !!on && this._slides.length > 0;
1612
+ if (on === this._overview) return;
1613
+ if (on) {
1614
+ if (this.inking) this.inking = false;
1615
+ this._stopZoomFlight();
1616
+ this._hotspot = null;
1617
+ if (this._zoom) this.setZoom(null, 'overview', { announce: false });
1618
+ this._ovCursor = this._index;
1619
+ }
1620
+ this._overview = on;
1621
+ this.toggleAttribute('data-overview', on);
1622
+ if (on) this._buildOverview();
1623
+ else {
1624
+ this._ovCells?.replaceChildren();
1625
+ if (this._canvas) { this._canvas.style.width = this.designWidth + 'px'; this._canvas.style.height = this.designHeight + 'px'; }
1626
+ }
1627
+ this._fit();
1628
+ if (on) this._showCursor();
1629
+ else if (this._stageEl) this._stageEl.scrollTop = 0;
1630
+ this.dispatchEvent(new CustomEvent('overviewchange', { detail: { open: on }, bubbles: true, composed: true }));
1631
+ }
1632
+
1633
+ /** Columns and scale of the grid for `count` slides in a `vw`×`vh` window. */
1634
+ overviewLayout(count, vw, vh) {
1635
+ const W = this.designWidth, H = this.designHeight;
1636
+ const gap = Math.round(W * 0.06);
1637
+ // Room under each slide for its number and title, in design pixels.
1638
+ const label = Math.round(H * 0.12);
1639
+ const pad = Math.max(16, Math.min(48, vw * 0.03));
1640
+ const room = (cols) => {
1641
+ const rows = Math.ceil(count / cols);
1642
+ const k = Math.min((vw - 2 * pad) / (cols * W + (cols - 1) * gap), (vh - 2 * pad) / (rows * (H + label) + (rows - 1) * gap));
1643
+ return { cols, rows, k };
1644
+ };
1645
+ // All slides at once, as large as they can be…
1646
+ let best = room(1);
1647
+ for (let cols = 2; cols <= count; cols++) { const next = room(cols); if (next.k > best.k) best = next; }
1648
+ // …unless they get too small to read: then fewer columns, and scrolling.
1649
+ const minWidth = 160;
1650
+ if (best.k * W < minWidth) {
1651
+ const cols = Math.max(1, Math.min(count, Math.floor((vw - 2 * pad + gap * minWidth / W) / (minWidth + gap * minWidth / W))));
1652
+ const rows = Math.ceil(count / cols);
1653
+ best = { cols, rows, k: (vw - 2 * pad) / (cols * W + (cols - 1) * gap) };
1654
+ }
1655
+ return { ...best, gap, label, pad };
1656
+ }
1657
+
1658
+ _buildOverview() {
1659
+ const cells = this._slides.map((slide, i) => {
1660
+ const cell = document.createElement('button');
1661
+ cell.type = 'button';
1662
+ cell.className = 'ov-cell';
1663
+ cell.dataset.i = String(i);
1664
+ // The slide's heading; the label names only its layout.
1665
+ const heading = slide.querySelector('h1, h2, h3');
1666
+ const title = (heading?.textContent || '').replace(/\s+/g, ' ').trim();
1667
+ cell.setAttribute('aria-label', `${i + 1}${title ? ` ${title}` : ''}`);
1668
+ const caption = document.createElement('span');
1669
+ caption.textContent = `${i + 1} ${title}`;
1670
+ cell.appendChild(caption);
1671
+ if (i === this._index) cell.setAttribute('data-current', '');
1672
+ return cell;
1673
+ });
1674
+ this._ovCells?.replaceChildren(...cells);
1675
+ }
1676
+
1677
+ _fitOverview(vw, vh) {
1678
+ const { cols, rows, k, gap, label, pad } = this.overviewLayout(this._slides.length, vw, vh);
1679
+ const W = this.designWidth, H = this.designHeight;
1680
+ const width = cols * W + (cols - 1) * gap;
1681
+ const height = rows * (H + label) + (rows - 1) * gap;
1682
+ this._fitScale = k;
1683
+ // Centred across, and down too when it all fits.
1684
+ const left = Math.max(pad, (vw - width * k) / 2);
1685
+ const top = Math.max(pad, (vh - height * k) / 2);
1686
+ this._canvas.style.width = width + 'px';
1687
+ this._canvas.style.height = height + 'px';
1688
+ this._canvas.style.transform = `translate(${left}px, ${top}px) scale(${k})`;
1689
+ this._canvas.style.setProperty('--ov-k', String(1 / k));
1690
+ if (this._ovSizer) { this._ovSizer.style.width = '1px'; this._ovSizer.style.height = `${Math.ceil(top * 2 + height * k)}px`; }
1691
+ const cells = this._ovCells?.children ?? [];
1692
+ this._slides.forEach((slide, i) => {
1693
+ const x = (i % cols) * (W + gap), y = Math.floor(i / cols) * (H + label + gap);
1694
+ slide.style.setProperty('--ov-x', x + 'px');
1695
+ slide.style.setProperty('--ov-y', y + 'px');
1696
+ const cell = cells[i];
1697
+ if (cell) Object.assign(cell.style, { left: x + 'px', top: y + 'px', width: W + 'px', height: H + 'px' });
1698
+ });
1699
+ this._ovLayout = { cols };
1700
+ }
1701
+
1702
+ _showCursor() {
1703
+ const cells = this._ovCells?.children ?? [];
1704
+ for (let i = 0; i < cells.length; i++) cells[i].toggleAttribute('data-cursor', i === this._ovCursor);
1705
+ cells[this._ovCursor]?.scrollIntoView?.({ block: 'nearest' });
1706
+ }
1707
+
1708
+ _overviewGo(i, reason = 'keyboard') {
1709
+ this.overview = false;
1710
+ if (i !== this._index) this._go(i, reason);
1711
+ }
1712
+
1713
+ /** A key typed while the overview is open; says whether it did something. */
1714
+ overviewKey(key) {
1715
+ if (!this._overview) return false;
1716
+ const count = this._slides.length;
1717
+ const cols = this._ovLayout?.cols ?? 1;
1718
+ const move = to => { this._ovCursor = Math.max(0, Math.min(count - 1, to)); this._showCursor(); };
1719
+ if (key === 'ArrowRight' || key === 'PageDown') move(this._ovCursor + 1);
1720
+ else if (key === 'ArrowLeft' || key === 'PageUp') move(this._ovCursor - 1);
1721
+ else if (key === 'ArrowDown') move(this._ovCursor + cols);
1722
+ else if (key === 'ArrowUp') move(this._ovCursor - cols);
1723
+ else if (key === 'Home') move(0);
1724
+ else if (key === 'End') move(count - 1);
1725
+ else if (key === 'Enter' || key === ' ' || key === 'Spacebar') this._overviewGo(this._ovCursor);
1726
+ else if (key === 'Escape' || key === 'o' || key === 'O') this.overview = false;
1727
+ else return false;
1728
+ return true;
1729
+ }
1730
+
1432
1731
  // Hotspots ----------------------------------------------------------------
1433
1732
  //
1434
1733
  // The parts of the slide shown that 1–9 zoom to (hotspots.js finds them).
@@ -1460,18 +1759,15 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1460
1759
 
1461
1760
  /** A digit or Escape, as typed in this window or the presenter view's; says whether it was used. */
1462
1761
  hotspotKey(key) {
1762
+ if (this._overview) return false;
1463
1763
  if (key === 'Escape') {
1464
1764
  if (!this._zoom || this.inking) return false;
1465
1765
  this.wholeSlide();
1466
1766
  return true;
1467
1767
  }
1468
1768
  if (!/^[0-9]$/.test(key)) return false;
1469
- if (this.hotspotMode === 'off') {
1470
- // Without hotspots: 1..9 jump to that slide; 0 jumps to 10.
1471
- const n = key === '0' ? 9 : parseInt(key, 10) - 1;
1472
- if (n < this._slides.length) this._go(n, 'keyboard');
1473
- return true;
1474
- }
1769
+ // Number keys are for hotspots only: without them they do nothing.
1770
+ if (this.hotspotMode === 'off') return false;
1475
1771
  if (key === '0') this.wholeSlide();
1476
1772
  else this.zoomToHotspot(Number(key));
1477
1773
  return true;
@@ -1500,7 +1796,16 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1500
1796
  if (this._hotspotRaf) return;
1501
1797
  // A hidden page gets no frames: its keys still need the hotspots.
1502
1798
  if (typeof requestAnimationFrame !== 'function' || document.hidden) { this._refreshHotspots(); return; }
1503
- this._hotspotRaf = requestAnimationFrame(() => { this._hotspotRaf = 0; this._refreshHotspots(); });
1799
+ // A page in the background gets its frames late or not at all: a timer
1800
+ // stands in, whichever comes first.
1801
+ const run = () => {
1802
+ if (!this._hotspotRaf) return;
1803
+ cancelAnimationFrame(this._hotspotRaf); clearTimeout(this._hotspotTimer);
1804
+ this._hotspotRaf = 0;
1805
+ this._refreshHotspots();
1806
+ };
1807
+ this._hotspotRaf = requestAnimationFrame(run);
1808
+ this._hotspotTimer = setTimeout(run, 120);
1504
1809
  }
1505
1810
 
1506
1811
  // An element's box in design pixels, whatever the canvas's transform.
@@ -1518,6 +1823,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1518
1823
  const design = { width: this.designWidth, height: this.designHeight };
1519
1824
  this._hotspots = findHotspots(slide, { mode: this.hotspotMode, design, max: ZOOM_MAX, rectOf: element => this._designRect(element) });
1520
1825
  this.toggleAttribute('data-has-hotspots', this._hotspots.length > 0);
1826
+ // The presenter's toolbar and the phone clicker list them (slideControls.js).
1827
+ this.dispatchEvent(new CustomEvent('hotspotschange', { detail: { count: this._hotspots.length } }));
1521
1828
  const layer = this._hotspotLayer;
1522
1829
  if (!layer) return;
1523
1830
  layer.replaceChildren(...this._hotspots.flatMap(({ rect }, i) => {
@@ -1590,8 +1897,10 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1590
1897
  // Laser -------------------------------------------------------------------
1591
1898
  //
1592
1899
  // Trails and dots by key ('local' for this window, others from the ink
1593
- // bus), in design pixels. A trail's points expire after LASER_MS, so it
1594
- // retracts from its tail; frames run only while some trail is left.
1900
+ // bus), in design pixels. While the pen is down a trail's points stay
1901
+ // (time Infinity); lifted, they are stamped so that they expire one after
1902
+ // the other within LASER_MS, and the trail retracts from its tail. Frames
1903
+ // run only while some trail is shown or retracting.
1595
1904
 
1596
1905
  _laserEntry(key, slideId) {
1597
1906
  let entry = this._lasers.get(key);
@@ -1600,16 +1909,34 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1600
1909
  return entry;
1601
1910
  }
1602
1911
 
1603
- /** Adds points to a laser trail; its dot moves to the last one. */
1604
- laserTrail(key, slideId, points) {
1912
+ /** Adds points to a laser trail, held until laserRelease; its dot moves to the last one. */
1913
+ laserTrail(key, slideId, points, { start = false } = {}) {
1605
1914
  if (!points?.length) return;
1606
1915
  const entry = this._laserEntry(key, slideId);
1607
- const t = performance.now();
1608
- for (const [x, y] of points) entry.points.push([x, y, t]);
1916
+ points.forEach(([x, y], i) => {
1917
+ // A held trail grows as long as the pen is down: skip points that
1918
+ // add nothing visible, so a long trail stays cheap to draw.
1919
+ const last = entry.points[entry.points.length - 1];
1920
+ const begins = start && i === 0;
1921
+ if (!begins && last && last[2] === Infinity && Math.hypot(x - last[0], y - last[1]) < LASER_STEP) return;
1922
+ entry.points.push([x, y, Infinity, begins]);
1923
+ });
1924
+ if (key !== 'local') { clearTimeout(entry.heldTimer); entry.heldTimer = setTimeout(() => this.laserRelease(key), LASER_HELD_MS); }
1609
1925
  entry.dot = points[points.length - 1];
1610
1926
  this._kickLaser();
1611
1927
  }
1612
1928
 
1929
+ /** The pen is lifted: the held trail retracts from its tail. */
1930
+ laserRelease(key) {
1931
+ const entry = this._lasers.get(key);
1932
+ if (!entry) return;
1933
+ const held = entry.points.filter(p => p[2] === Infinity);
1934
+ const now = performance.now();
1935
+ held.forEach((p, i) => { p[2] = now - LASER_MS + LASER_MS * (i + 1) / held.length; });
1936
+ clearTimeout(entry.heldTimer);
1937
+ this._kickLaser();
1938
+ }
1939
+
1613
1940
  /** Moves (or with null, hides) a laser dot. */
1614
1941
  laserDot(key, slideId, point) {
1615
1942
  const entry = this._lasers.get(key);
@@ -1647,6 +1974,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1647
1974
  for (const [key, entry] of this._lasers) {
1648
1975
  entry.points = entry.points.filter(p => now - p[2] < LASER_MS);
1649
1976
  if (!entry.points.length && !entry.dot) { this._lasers.delete(key); continue; }
1977
+ // A held trail needs frames too: a zoom, a new size or another slide
1978
+ // must move it while the pen stays still.
1650
1979
  if (entry.points.length) running = true;
1651
1980
  if (entry.slideId && slideId && entry.slideId !== slideId) continue;
1652
1981
  // A red glow with a white core, the core drawn second.
@@ -1657,7 +1986,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1657
1986
  ctx.beginPath();
1658
1987
  entry.points.forEach((p, i) => {
1659
1988
  const [x, y] = at(p);
1660
- if (i === 0 || p[2] - entry.points[i - 1][2] > LASER_GAP_MS) ctx.moveTo(x, y);
1989
+ if (i === 0 || p[3]) ctx.moveTo(x, y);
1661
1990
  else ctx.lineTo(x, y);
1662
1991
  });
1663
1992
  ctx.stroke();
@@ -1689,7 +2018,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1689
2018
  this._remoteInk ??= new Map();
1690
2019
  if (tool === 'laser') {
1691
2020
  const seen = this._remoteInk.get(key) ?? 0;
1692
- if (points.length > seen) this.laserTrail(key, slideId, points.slice(seen));
2021
+ if (points.length > seen) this.laserTrail(key, slideId, points.slice(seen), { start: seen === 0 });
1693
2022
  this._remoteInk.set(key, points.length);
1694
2023
  return;
1695
2024
  }
@@ -1708,7 +2037,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1708
2037
  const path = this._remoteInk?.get(key);
1709
2038
  if (path == null) return;
1710
2039
  this._remoteInk.delete(key);
1711
- if (typeof path === 'number') { this.laserDot(key, null, null); return; }
2040
+ if (typeof path === 'number') { this.laserRelease(key); this.laserDot(key, null, null); return; }
1712
2041
  if (fade) { requestAnimationFrame(() => path.classList.add('fading')); setTimeout(() => path.remove(), 3200); }
1713
2042
  else this._removeLive(path);
1714
2043
  }
@@ -1732,6 +2061,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1732
2061
  goTo(i) { this._go(i, 'api'); }
1733
2062
  /** Advance: reveals next step if the current slide has unrevealed steps, else goes to next slide. */
1734
2063
  next(reason = 'api') {
2064
+ // On a black screen, the next press shows the slide again first; so with the join code.
2065
+ if (this._blank) { this.setBlank(false, reason); return; }
2066
+ if (this._join) { this.setJoin(false, reason); return; }
1735
2067
  const steps = this._getSteps(this._slides[this._index]);
1736
2068
  const curr = this._stepMap.get(this._index) ?? -1;
1737
2069
  if (steps.length > 0 && curr < steps.length - 1) {
@@ -1747,6 +2079,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1747
2079
  }
1748
2080
  /** Go back: hides last revealed step if any, else goes to previous slide. */
1749
2081
  prev(reason = 'api') {
2082
+ if (this._blank) { this.setBlank(false, reason); return; }
2083
+ if (this._join) { this.setJoin(false, reason); return; }
1750
2084
  const steps = this._getSteps(this._slides[this._index]);
1751
2085
  const curr = this._stepMap.get(this._index) ?? -1;
1752
2086
  if (steps.length > 0 && curr >= 0) {