mdeck 3.11.0 → 3.12.1

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 (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +2 -0
  4. package/docs/reference/authoring.md +48 -7
  5. package/docs/site/content/appearance.md +1 -1
  6. package/docs/site/content/audience.md +36 -8
  7. package/docs/site/content/commands.md +4 -2
  8. package/docs/site/content/drawing.md +4 -4
  9. package/docs/site/content/getting-started.md +2 -32
  10. package/docs/site/content/install.md +3 -1
  11. package/docs/site/content/presenting.md +29 -8
  12. package/docs/site/content/writing.md +27 -0
  13. package/examples/showcase/slides.ar.md +5 -5
  14. package/examples/showcase/slides.de.md +5 -5
  15. package/examples/showcase/slides.de.results.json +27 -0
  16. package/examples/showcase/slides.en.md +5 -5
  17. package/package.json +1 -1
  18. package/skills/write-slides/SKILL.md +4 -1
  19. package/src/build/check.js +27 -6
  20. package/src/build/components.js +5 -3
  21. package/src/build/homePlugin.js +64 -14
  22. package/src/build/include.js +249 -0
  23. package/src/build/inkPlugin.js +12 -2
  24. package/src/build/relayAccess.js +3 -1
  25. package/src/build/renderCheck.js +23 -3
  26. package/src/build/slidesPlugin.js +64 -16
  27. package/src/build/timingPlugin.js +34 -18
  28. package/src/cli/args.js +3 -3
  29. package/src/cli/index.js +49 -10
  30. package/src/components/Bibliography.jsx +7 -2
  31. package/src/components/Poll.jsx +2 -2
  32. package/src/components/QrCodeTag.jsx +2 -2
  33. package/src/components/WordCloud.jsx +7 -6
  34. package/src/components/activity.jsx +11 -8
  35. package/src/components/poll.css +2 -0
  36. package/src/core/icons.js +6 -1
  37. package/src/core/labels.js +3 -0
  38. package/src/core/parseSlides.js +14 -4
  39. package/src/core/reactions.js +27 -0
  40. package/src/core/resultsReport.js +153 -0
  41. package/src/core/source.js +8 -1
  42. package/src/core/validateDeck.js +6 -2
  43. package/src/home/Home.jsx +38 -93
  44. package/src/home/home.css +5 -17
  45. package/src/layouts/renderSlide.jsx +13 -1
  46. package/src/live/answer/answer.css +12 -1
  47. package/src/live/answer/answer.js +32 -4
  48. package/src/live/client.js +23 -4
  49. package/src/live/rooms.js +29 -7
  50. package/src/live/server.js +15 -1
  51. package/src/runtime/KeyHelp.jsx +115 -0
  52. package/src/runtime/PresenterChrome.jsx +168 -0
  53. package/src/runtime/Reactions.jsx +56 -0
  54. package/src/runtime/deck-stage.js +322 -17
  55. package/src/runtime/helpKeys.js +75 -0
  56. package/src/runtime/ink/room.js +6 -6
  57. package/src/runtime/main.jsx +396 -303
  58. package/src/runtime/slideControls.js +60 -0
  59. package/src/runtime/useTalkTimer.js +4 -2
@@ -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)
@@ -195,6 +195,73 @@ import { flight, durationFor, ease } from './zoom/camera.js'
195
195
  .zoom-reset svg { width: 16px; height: 16px; }
196
196
  :host([data-zoomed]) .zoom-reset { display: inline-flex; }
197
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; }
198
265
  /* Hotspots: a numbered key at the corner of each part of the slide the
199
266
  presenter can zoom to (in design pixels, inside the canvas; the badge
200
267
  keeps its size on screen). Only the presenter's own slide shows them. */
@@ -256,7 +323,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
256
323
  .ink-live .fading { transition: opacity 0.8s ease 2.2s; opacity: 0; }
257
324
  /* The ink toolbar replaces the overlay while drawing. */
258
325
  :host([data-inking]) .overlay { display: none; }
259
- .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; }
260
329
  :host([data-ink-enabled]) .btn.draw { display: inline-flex; }
261
330
  :host([data-fullscreen-available]) .btn.fullscreen { display: inline-flex; }
262
331
  @media (pointer: coarse) { .overlay .btn { height: 40px; min-width: 40px; } }
@@ -401,7 +470,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
401
470
  break-after: auto;
402
471
  page-break-after: auto;
403
472
  }
404
- .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; }
405
474
  }
406
475
  `;
407
476
 
@@ -468,6 +537,13 @@ import { flight, durationFor, ease } from './zoom/camera.js'
468
537
  this._hotspot = null;
469
538
  this._hotspotsForced = null;
470
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;
471
547
  this._queueHotspots = this._queueHotspots.bind(this);
472
548
  this._touches = new Map();
473
549
  this._onWheel = this._onWheel.bind(this);
@@ -566,7 +642,23 @@ import { flight, durationFor, ease } from './zoom/camera.js'
566
642
  });
567
643
  canvas.appendChild(hotspots);
568
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;
569
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;
570
662
 
571
663
  // Only shown when fingers draw: keeps their touches from scrolling or
572
664
  // zooming. The input itself is read on the window.
@@ -603,6 +695,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
603
695
  </button>
604
696
  <span class="divider"></span>
605
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>
606
701
  <button class="btn draw" type="button" aria-label="Draw on the slide" title="Draw (D)">
607
702
  ${iconSvg('pen')}
608
703
  </button>
@@ -614,6 +709,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
614
709
  overlay.querySelector('.prev').addEventListener('click', () => this.prev('click'));
615
710
  overlay.querySelector('.next').addEventListener('click', () => this.next('click'));
616
711
  overlay.querySelector('.reset').addEventListener('click', () => this.reset());
712
+ overlay.querySelector('.overview').addEventListener('click', () => { this.overview = true; });
617
713
  overlay.querySelector('.draw').addEventListener('click', () => { this.inking = !this.inking; });
618
714
  const fullscreenButton = overlay.querySelector('.fullscreen');
619
715
  fullscreenButton.addEventListener('click', () => toggleFullscreen());
@@ -626,7 +722,14 @@ import { flight, durationFor, ease } from './zoom/camera.js'
626
722
  // Only a top-level window can go full screen (an iPhone cannot at all).
627
723
  if (window.top === window && fullscreenAvailable()) this.setAttribute('data-fullscreen-available', '');
628
724
 
629
- 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);
630
733
  this._canvas = canvas;
631
734
  this._slot = slot;
632
735
  this._overlay = overlay;
@@ -660,6 +763,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
660
763
  _onSlotChange() {
661
764
  this._collectSlides();
662
765
  this._restoreIndex();
766
+ if (this._overview) this._buildOverview();
663
767
  this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
664
768
  this._fit();
665
769
  }
@@ -754,6 +858,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
754
858
  // slide may stay on it.
755
859
  if (curr !== prev) this._clearLive();
756
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
+ }
757
866
 
758
867
  if (broadcast) {
759
868
  // (1) Legacy: postMessage bridge for presenter/speaker-note renderers.
@@ -792,7 +901,38 @@ import { flight, durationFor, ease } from './zoom/camera.js'
792
901
 
793
902
  get state() {
794
903
  return { index: this._index, slideId: this._slides[this._index]?.dataset.slideId,
795
- 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 } }));
796
936
  }
797
937
 
798
938
  _broadcastState(reason) {
@@ -812,8 +952,13 @@ import { flight, durationFor, ease } from './zoom/camera.js'
812
952
  if (index < 0 || index >= this._slides.length) return;
813
953
  const max = this._getSteps(this._slides[index]).length - 1;
814
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);
815
960
  if (index === this._index) {
816
- if (step === this.state.step) return;
961
+ if (step === this.state.step) { if (blankChanged || joinChanged) this._broadcastState('sync'); return; }
817
962
  if (this._hotspot) this.wholeSlide();
818
963
  this._stepMap.set(index, step);
819
964
  this._applySteps(index);
@@ -847,6 +992,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
847
992
  const rect = this.getBoundingClientRect();
848
993
  const vw = rect.width || window.innerWidth;
849
994
  const vh = rect.height || window.innerHeight;
995
+ if (this._overview) { this._fitOverview(vw, vh); return; }
850
996
  const s = Math.min(vw / this.designWidth, vh / this.designHeight);
851
997
  this._fitScale = s;
852
998
  this._hotspotLayer?.style.setProperty('--hotspot-k', String(1 / s));
@@ -874,6 +1020,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
874
1020
  // reports, and controls on the slide (buttons, links, polls, embedded
875
1021
  // frames) keep their own taps.
876
1022
  _tapStart(e) {
1023
+ if (this._overview) { this._tap = null; return; }
877
1024
  const finger = e.pointerType === 'touch' && !(this.inking && this.inkFinger);
878
1025
  const pen = e.pointerType === 'pen' && !this.inking;
879
1026
  this._tap = (finger || pen) && e.isPrimary ? { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now() } : null;
@@ -914,7 +1061,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
914
1061
  const key = e.key;
915
1062
  let handled = true;
916
1063
 
917
- 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') {
918
1069
  this.next('keyboard');
919
1070
  } else if (key === 'ArrowLeft' || key === 'PageUp') {
920
1071
  this.prev('keyboard');
@@ -930,6 +1081,12 @@ import { flight, durationFor, ease } from './zoom/camera.js'
930
1081
  toggleFullscreen();
931
1082
  } else if (key === 'r' || key === 'R') {
932
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');
933
1090
  } else if (/^[0-9]$/.test(key) || key === 'Escape') {
934
1091
  handled = this.hotspotKey(key);
935
1092
  } else {
@@ -942,6 +1099,11 @@ import { flight, durationFor, ease } from './zoom/camera.js'
942
1099
  }
943
1100
  }
944
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
+
945
1107
  _go(i, reason = 'api') {
946
1108
  if (!this._slides.length) return;
947
1109
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
@@ -1369,7 +1531,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1369
1531
  }
1370
1532
 
1371
1533
  _zoomDown(e) {
1372
- 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;
1373
1535
  this._touches.set(e.pointerId, [e.clientX, e.clientY]);
1374
1536
  if (this._touches.size === 2) {
1375
1537
  // A second finger: a pinch, not a tap or a finger's stroke.
@@ -1418,7 +1580,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1418
1580
 
1419
1581
  // Ctrl + scroll is how a trackpad pinch arrives; plain scrolling moves a zoomed slide.
1420
1582
  _onWheel(e) {
1421
- if (!this.hasAttribute('data-zoomable') || this._inInkUi(e)) return;
1583
+ if (this._overview || !this.hasAttribute('data-zoomable') || this._inInkUi(e)) return;
1422
1584
  if (e.ctrlKey) {
1423
1585
  e.preventDefault();
1424
1586
  const zoom = this.zoom;
@@ -1436,6 +1598,136 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1436
1598
  if (this.hasAttribute('data-zoomable') && !this._inInkUi(e)) e.preventDefault();
1437
1599
  }
1438
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
+
1439
1731
  // Hotspots ----------------------------------------------------------------
1440
1732
  //
1441
1733
  // The parts of the slide shown that 1–9 zoom to (hotspots.js finds them).
@@ -1467,18 +1759,15 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1467
1759
 
1468
1760
  /** A digit or Escape, as typed in this window or the presenter view's; says whether it was used. */
1469
1761
  hotspotKey(key) {
1762
+ if (this._overview) return false;
1470
1763
  if (key === 'Escape') {
1471
1764
  if (!this._zoom || this.inking) return false;
1472
1765
  this.wholeSlide();
1473
1766
  return true;
1474
1767
  }
1475
1768
  if (!/^[0-9]$/.test(key)) return false;
1476
- if (this.hotspotMode === 'off') {
1477
- // Without hotspots: 1..9 jump to that slide; 0 jumps to 10.
1478
- const n = key === '0' ? 9 : parseInt(key, 10) - 1;
1479
- if (n < this._slides.length) this._go(n, 'keyboard');
1480
- return true;
1481
- }
1769
+ // Number keys are for hotspots only: without them they do nothing.
1770
+ if (this.hotspotMode === 'off') return false;
1482
1771
  if (key === '0') this.wholeSlide();
1483
1772
  else this.zoomToHotspot(Number(key));
1484
1773
  return true;
@@ -1507,7 +1796,16 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1507
1796
  if (this._hotspotRaf) return;
1508
1797
  // A hidden page gets no frames: its keys still need the hotspots.
1509
1798
  if (typeof requestAnimationFrame !== 'function' || document.hidden) { this._refreshHotspots(); return; }
1510
- 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);
1511
1809
  }
1512
1810
 
1513
1811
  // An element's box in design pixels, whatever the canvas's transform.
@@ -1525,6 +1823,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1525
1823
  const design = { width: this.designWidth, height: this.designHeight };
1526
1824
  this._hotspots = findHotspots(slide, { mode: this.hotspotMode, design, max: ZOOM_MAX, rectOf: element => this._designRect(element) });
1527
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 } }));
1528
1828
  const layer = this._hotspotLayer;
1529
1829
  if (!layer) return;
1530
1830
  layer.replaceChildren(...this._hotspots.flatMap(({ rect }, i) => {
@@ -1761,6 +2061,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1761
2061
  goTo(i) { this._go(i, 'api'); }
1762
2062
  /** Advance: reveals next step if the current slide has unrevealed steps, else goes to next slide. */
1763
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; }
1764
2067
  const steps = this._getSteps(this._slides[this._index]);
1765
2068
  const curr = this._stepMap.get(this._index) ?? -1;
1766
2069
  if (steps.length > 0 && curr < steps.length - 1) {
@@ -1776,6 +2079,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1776
2079
  }
1777
2080
  /** Go back: hides last revealed step if any, else goes to previous slide. */
1778
2081
  prev(reason = 'api') {
2082
+ if (this._blank) { this.setBlank(false, reason); return; }
2083
+ if (this._join) { this.setJoin(false, reason); return; }
1779
2084
  const steps = this._getSteps(this._slides[this._index]);
1780
2085
  const curr = this._stepMap.get(this._index) ?? -1;
1781
2086
  if (steps.length > 0 && curr >= 0) {
@@ -0,0 +1,75 @@
1
+ // The keys that do something right now, for the help that ? shows over the
2
+ // presenter view and the deck window. Each window has its own keys
3
+ // (main.jsx for the presenter view, deck-stage.js and ink/attach.js for the
4
+ // deck window); what is listed depends on the moment: the number keys only
5
+ // while the slide shown has hotspots (without them they do nothing), the
6
+ // drawing keys while drawing, L only with several
7
+ // languages, F only where the browser can go full screen.
8
+ //
9
+ // `view` is 'presenter' or 'deck'; `hotspots` whether the number keys zoom on
10
+ // the slide shown; `hotspotSwitch` whether the presenter view's switch (H) is
11
+ // on, which a slide's own setting can overrule; `inking` whether drawing is
12
+ // on; `canDraw` whether D works in this window; `fullscreen` whether F does;
13
+ // `drawer` whether N does (the presenter view's slide-only layout);
14
+ // `languages` whether L does; `blank` whether the screen is black (B);
15
+ // `overview` whether the grid of all slides (O) is open; `join` whether J
16
+ // works (a deck phones join) and `joinShown` whether
17
+ // the join code is over the slide;
18
+ // `mac` names the command key ⌘ instead of Ctrl.
19
+ // Returns groups: [{ title, keys: [{ keys: ['→', 'Space'], label }] }].
20
+
21
+ export const HELP_KEY = '?'
22
+
23
+ export function keyHelp({ view = 'deck', blank = false, join = false, joinShown = false, overview = false, hotspots = false, hotspotSwitch = hotspots, inking = false, canDraw = true, fullscreen = false, drawer = false, languages = false, mac = false } = {}) {
24
+ const presenter = view === 'presenter'
25
+ const command = mac ? '⌘' : 'Ctrl'
26
+ const entry = (keys, label) => ({ keys, label })
27
+ const groups = []
28
+
29
+ groups.push({ title: 'Slides', keys: [
30
+ entry(['→', 'Space', 'PgDn'], 'Next step or slide'),
31
+ entry(['←', 'PgUp'], 'Previous step or slide'),
32
+ ...(presenter
33
+ ? [entry(['Home', 'R'], 'Back to the first slide')]
34
+ : [entry(['Home'], 'First slide'), entry(['End'], 'Last slide'), entry(['R'], 'Back to the first slide, steps hidden again')]),
35
+ ] })
36
+
37
+ groups.push({ title: 'Presenting', keys: [
38
+ ...(presenter ? [entry(['T'], 'Start or pause the timer')] : []),
39
+ entry(['O'], overview ? 'Close the overview' : 'All slides, to pick one'),
40
+ entry(['B', '.'], blank ? 'Show the slide again' : 'Black screen for the audience'),
41
+ ...(join ? [entry(['J'], joinShown ? 'Hide the join code' : 'Show the join code over the slide')] : []),
42
+ ...(presenter && drawer ? [entry(['N'], 'Show or hide the notes')] : []),
43
+ ...(presenter ? [entry(['H'], hotspotSwitch ? 'Turn hotspots off' : 'Turn hotspots on')] : []),
44
+ ...(presenter && languages ? [entry(['L'], 'Next language')] : []),
45
+ ...(canDraw ? [entry(['D'], inking ? 'Stop drawing' : 'Draw on the slide')] : []),
46
+ ...(!presenter ? [entry(['I'], 'Show or hide the drawings')] : []),
47
+ ...(fullscreen ? [entry(['F'], 'Full screen')] : []),
48
+ ] })
49
+
50
+ if (overview) groups.push({ title: 'All slides', keys: [
51
+ entry(['←', '→', '↑', '↓'], 'Move between the slides'),
52
+ entry(['Enter'], 'Go to that slide'),
53
+ entry(['O', 'Esc'], 'Back to the slide shown'),
54
+ ] })
55
+
56
+ if (hotspots && !overview) groups.push({ title: 'Hotspots', keys: [
57
+ entry(['1–9'], 'Zoom to a part of the slide'),
58
+ entry(['0', 'Esc'], 'Show the whole slide'),
59
+ ] })
60
+
61
+ // The drawing keys are those of the deck window's own frame; the presenter
62
+ // view passes on only D.
63
+ if (inking && !presenter) groups.push({ title: 'Drawing', keys: [
64
+ entry(['Esc'], 'Stop drawing'),
65
+ entry([`${command} Z`], 'Undo'),
66
+ entry([`Shift ${command} Z`], 'Redo'),
67
+ entry(['Delete'], 'Remove the selected drawing'),
68
+ ] })
69
+
70
+ groups.push({ title: 'Help', keys: [entry([HELP_KEY], 'Show or hide these keys'), entry(['Esc'], 'Close')] })
71
+ return groups.filter(group => group.keys.length)
72
+ }
73
+
74
+ /** Whether a key event is the one that shows the help: ? without Ctrl, ⌘ or Alt. */
75
+ export const isHelpKey = event => event.key === HELP_KEY && !event.metaKey && !event.ctrlKey && !event.altKey
@@ -100,10 +100,10 @@ const stateListeners = new Set()
100
100
  export function followStageRoom(stage, { onLanguage } = {}) {
101
101
  stateListeners.add(state => {
102
102
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
103
- const position = { index: state.index, step: state.step ?? -1, slideId: state.slideId }
103
+ const position = { index: state.index, step: state.step ?? -1, slideId: state.slideId, ...(typeof state.blank === 'boolean' ? { blank: state.blank } : {}) }
104
104
  if (state.lang && onLanguage?.(state.lang, position)) return
105
105
  const current = stage.state
106
- if (current?.index === state.index && current?.step === state.step) return
106
+ if (current?.index === state.index && current?.step === state.step && (position.blank === undefined || current?.blank === position.blank)) return
107
107
  stage.setState(position)
108
108
  })
109
109
  }
@@ -112,7 +112,7 @@ export function followStageRoom(stage, { onLanguage } = {}) {
112
112
  export function onStagePosition(listener) {
113
113
  stateListeners.add(state => {
114
114
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
115
- listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId, lang: state.lang })
115
+ listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId, lang: state.lang, blank: state.blank })
116
116
  })
117
117
  }
118
118
 
@@ -125,9 +125,9 @@ export function announceStagePosition(stage, { language = () => null } = {}) {
125
125
  const room = stageRoom()
126
126
  let allowed = null, last = null
127
127
  async function post(event) {
128
- const { index, step, slideId } = stage.state
128
+ const { index, step, slideId, blank } = stage.state
129
129
  const lang = language() ?? undefined
130
- const key = `${slideId}:${index}:${step}:${lang}`
130
+ const key = `${slideId}:${index}:${step}:${lang}:${blank}`
131
131
  if (event?.detail.reason === 'sync') { last = key; return }
132
132
  if (key === last || allowed === false) return
133
133
  // Opening a window must not move the projector; only changes count.
@@ -138,7 +138,7 @@ export function announceStagePosition(stage, { language = () => null } = {}) {
138
138
  allowed = info.reachable && !!info.canReset
139
139
  if (!allowed) return
140
140
  }
141
- fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, lang, at, screen: room.screen } }) }).catch(() => {})
141
+ fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, lang, blank, at, screen: room.screen } }) }).catch(() => {})
142
142
  }
143
143
  stage.addEventListener('statechange', post)
144
144
  post()