mdeck 3.7.1 → 3.9.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 (59) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +9 -5
  4. package/assets/extensions/themes/academic/extension.toml +4 -1
  5. package/assets/extensions/themes/academic/styles.css +22 -22
  6. package/assets/extensions/themes/glass/extension.toml +7 -1
  7. package/assets/extensions/themes/glass/styles.css +17 -17
  8. package/assets/extensions/themes/minimal/extension.toml +3 -1
  9. package/assets/extensions/themes/minimal/styles.css +14 -14
  10. package/assets/extensions/themes/neue/extension.toml +3 -1
  11. package/assets/extensions/themes/neue/styles.css +14 -14
  12. package/assets/extensions/themes/plain/extension.toml +7 -1
  13. package/assets/extensions/themes/plain/styles.css +17 -17
  14. package/assets/extensions/themes/work/extension.toml +8 -2
  15. package/assets/extensions/themes/work/styles.css +26 -26
  16. package/docs/reference/authoring.md +21 -4
  17. package/docs/reference/extensions.md +5 -1
  18. package/docs/reference/themes.md +6 -2
  19. package/docs/site/content/drawing.md +1 -1
  20. package/docs/site/content/presenting.md +45 -0
  21. package/docs/site/content/theme-authoring.md +28 -0
  22. package/examples/python/slides.de.md +493 -0
  23. package/examples/python/{slides.md → slides.en.md} +1 -1
  24. package/examples/python/slides.fa.md +492 -0
  25. package/examples/python/slides.hi.md +492 -0
  26. package/examples/showcase/slides.md +33 -0
  27. package/package.json +1 -1
  28. package/skills/write-slides/SKILL.md +1 -0
  29. package/src/build/config.js +1 -1
  30. package/src/build/slidesPlugin.js +34 -4
  31. package/src/cli/index.js +4 -0
  32. package/src/cli/themes.js +2 -0
  33. package/src/components/callout.css +10 -10
  34. package/src/components/citation.css +2 -2
  35. package/src/components/code-block.css +3 -3
  36. package/src/components/poll.css +6 -6
  37. package/src/components/video-player.css +1 -1
  38. package/src/core/editDeck.js +3 -2
  39. package/src/core/icons.js +1 -0
  40. package/src/core/languages.js +51 -0
  41. package/src/core/parseSlides.js +15 -3
  42. package/src/core/validateDeck.js +13 -1
  43. package/src/editor/components/DeckSettings.jsx +37 -3
  44. package/src/editor/components/ExtensionForms.jsx +12 -0
  45. package/src/editor/components/MetaFields.jsx +7 -0
  46. package/src/editor/extensions.js +6 -3
  47. package/src/extensions/appearance.js +6 -2
  48. package/src/extensions/manifest.js +3 -3
  49. package/src/extensions/params.js +113 -0
  50. package/src/layouts/layoutApi.jsx +7 -1
  51. package/src/live/answer/answer.css +2 -2
  52. package/src/runtime/chrome.jsx +6 -7
  53. package/src/runtime/deck-stage.js +249 -11
  54. package/src/runtime/hotspots.js +98 -0
  55. package/src/runtime/ink/bus.js +12 -1
  56. package/src/runtime/ink/room.js +20 -8
  57. package/src/runtime/main.jsx +152 -22
  58. package/src/runtime/zoom/camera.js +77 -0
  59. /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
@@ -5,6 +5,7 @@ import { marked, Parser } from 'marked'
5
5
  import { registry } from '../runtime/registry'
6
6
  import { scanTags, closeTags } from '../core/tags.js'
7
7
  import { expandPollLists } from '../core/pollList.js'
8
+ import { textDirection } from '../core/languages.js'
8
9
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
9
10
  import themedSvgs from 'virtual:deck-svgs'
10
11
  import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
@@ -222,7 +223,12 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
222
223
  const classes = ['slide', layout && 'slide--' + String(layout).replace(/[^a-zA-Z0-9_-]/g, '-'),
223
224
  image && ['title', 'chapter'].includes(layout) && 'has-image',
224
225
  layout === 'full-bleed-image' && props.overlay && 'has-overlay'].filter(Boolean).join(' ')
225
- return <section class={classes} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`}>
226
+ // A deck in Farsi, Arabic or Hebrew runs from right to left; mdeck's own
227
+ // controls around the slides keep their direction.
228
+ const dir = textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined
229
+ // Its own hotspots setting, if any (runtime/hotspots.js).
230
+ const hotspots = meta.hotspots == null ? undefined : String(meta.hotspots)
231
+ return <section class={classes} dir={dir} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`} data-hotspots={hotspots}>
226
232
  {frame !== 'none' && <SlideHeader deckConfig={deckConfig} logo={title ? deckConfig.meta?.logo : undefined}
227
233
  isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
228
234
  {children}
@@ -21,7 +21,7 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
21
21
  .answer-scale { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); }
22
22
  .answer button {
23
23
  padding: 18px 20px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink);
24
- font: inherit; font-size: 20px; font-weight: 600; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
24
+ font: inherit; font-size: 20px; font-weight: 600; text-align: start; cursor: pointer; -webkit-tap-highlight-color: transparent;
25
25
  }
26
26
  .answer-scale button { padding: 16px 0; text-align: center; }
27
27
  .answer button:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--rule)); }
@@ -39,7 +39,7 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
39
39
  .answer-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 12px; }
40
40
  .answer .answer-keys button { padding: 22px 0; text-align: center; font-size: 28px; }
41
41
  /* `multiple`: a box that is ticked when picked. */
42
- .answer-check { display: inline-block; width: 1em; height: 1em; margin-right: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
42
+ .answer-check { display: inline-block; width: 1em; height: 1em; margin-inline-end: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
43
43
  .answer button.is-picked .answer-check { opacity: 1; background: currentColor; box-shadow: inset 0 0 0 2px var(--accent); }
44
44
  /* Closed: no more answers. */
45
45
  .answer button:disabled, .answer textarea:disabled, .answer input:disabled { opacity: .45; cursor: default; }
@@ -42,14 +42,13 @@ export const S = {
42
42
  },
43
43
  }
44
44
 
45
+ // A palette as the theme gallery shows it (mdeck-themes): one chip of its
46
+ // background, accent and second accent.
45
47
  export function PaletteSwatches({ tokens }) {
46
- const keys = ['--bg', '--surface', '--rule', '--accent', '--accent-2', '--accent-3', '--ink']
48
+ const stripe = key => <i style={{ display: 'block', background: tokens[key] ?? tokens['--accent'] ?? 'transparent' }} />
47
49
  return (
48
- <div style={{ display: 'flex', gap: '2px', alignItems: 'center', flexShrink: 0 }}>
49
- {keys.map(k => tokens[k]
50
- ? <div key={k} style={{ width: '10px', height: '10px', borderRadius: '2px', background: tokens[k], border: '1px solid rgba(255,255,255,0.08)', flexShrink: 0 }} />
51
- : null
52
- )}
53
- </div>
50
+ <span aria-hidden="true" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', width: '30px', height: '18px', borderRadius: '999px', overflow: 'hidden', border: '1px solid rgba(127,127,127,0.35)', flexShrink: 0 }}>
51
+ {stripe('--bg')}{stripe('--accent')}{stripe('--accent-2')}
52
+ </span>
54
53
  )
55
54
  }
@@ -4,7 +4,9 @@
4
4
  * Handles:
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
- * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
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
10
  * (c) press R to reset to slide 0 (with a tasteful keyboard hint).
9
11
  * (d) bottom-center overlay showing slide count + hints, fades out on idle.
10
12
  * (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
@@ -65,6 +67,8 @@
65
67
 
66
68
  import { iconSvg } from '../core/icons.js'
67
69
  import { inkPaint, snapAngle } from '../core/ink.js'
70
+ import { findHotspots, hotspotMode, zoomToCamera, cameraToZoom } from './hotspots.js'
71
+ import { flight, durationFor, ease } from './zoom/camera.js'
68
72
 
69
73
  (() => {
70
74
  const DESIGN_W_DEFAULT = 1920;
@@ -187,6 +191,43 @@ import { inkPaint, snapAngle } from '../core/ink.js'
187
191
  .zoom-reset svg { width: 16px; height: 16px; }
188
192
  :host([data-zoomed]) .zoom-reset { display: inline-flex; }
189
193
  :host([data-follower]) .zoom-reset { display: none; }
194
+ /* Hotspots: a numbered key at the corner of each part of the slide the
195
+ presenter can zoom to (in design pixels, inside the canvas; the badge
196
+ keeps its size on screen). Only the presenter's own slide shows them. */
197
+ .hotspots { position: absolute; inset: 0; z-index: 2; pointer-events: none; display: none; }
198
+ :host([data-hotspot-badges]) .hotspots { display: block; }
199
+ :host([data-zoomed]) .hotspots, :host([data-zoom-flying]) .hotspots, :host([data-deck-static]) .hotspots { display: none; }
200
+ .hotspot {
201
+ position: absolute;
202
+ z-index: 1;
203
+ min-width: 24px;
204
+ height: 24px;
205
+ padding: 0 6px;
206
+ box-sizing: border-box;
207
+ transform: scale(var(--hotspot-k, 1)) translate(-35%, -35%);
208
+ transform-origin: 0 0;
209
+ border: 1px solid rgba(255, 255, 255, 0.45);
210
+ border-radius: 7px;
211
+ background: rgba(17, 17, 17, 0.82);
212
+ color: #fff;
213
+ font: 600 13px/22px ui-monospace, SFMono-Regular, Menlo, monospace;
214
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
215
+ cursor: zoom-in;
216
+ pointer-events: auto;
217
+ touch-action: manipulation;
218
+ -webkit-tap-highlight-color: transparent;
219
+ }
220
+ .hotspot:hover { background: #e11d48; border-color: #e11d48; }
221
+ .hotspot-area {
222
+ position: absolute;
223
+ border-radius: 6px;
224
+ outline: calc(2px * var(--hotspot-k, 1)) dashed #e11d48;
225
+ outline-offset: calc(4px * var(--hotspot-k, 1));
226
+ opacity: 0;
227
+ transition: opacity 0.15s;
228
+ pointer-events: none;
229
+ }
230
+ .hotspot:hover + .hotspot-area { opacity: 1; }
190
231
  :host([data-inking][data-ink-tool="eraser"]) { cursor: cell; }
191
232
  .ink-laser {
192
233
  position: fixed;
@@ -356,7 +397,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
356
397
  break-after: auto;
357
398
  page-break-after: auto;
358
399
  }
359
- .overlay, .ink-input, .ink-live, .ink-laser, .zoom-reset { display: none !important; }
400
+ .overlay, .ink-input, .ink-live, .ink-laser, .zoom-reset, .hotspots { display: none !important; }
360
401
  }
361
402
  `;
362
403
 
@@ -416,6 +457,14 @@ import { inkPaint, snapAngle } from '../core/ink.js'
416
457
  this._lasers = new Map();
417
458
  this._inkUpAt = 0;
418
459
  this._zoom = null;
460
+ this._zoomRaf = 0;
461
+ // Hotspots of the slide shown, the one zoomed to (1-based) and the
462
+ // presenter's switch (true, false, or null for the deck's setting).
463
+ this._hotspots = [];
464
+ this._hotspot = null;
465
+ this._hotspotsForced = null;
466
+ this._hotspotRaf = 0;
467
+ this._queueHotspots = this._queueHotspots.bind(this);
419
468
  this._touches = new Map();
420
469
  this._onWheel = this._onWheel.bind(this);
421
470
  this._onGesture = this._onGesture.bind(this);
@@ -438,7 +487,10 @@ import { inkPaint, snapAngle } from '../core/ink.js'
438
487
  this._loadNotes();
439
488
  this._syncPrintPageRule();
440
489
  window.addEventListener('keydown', this._onKey);
441
- if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver(() => this._fit()); this._resizeObserver.observe(this); }
490
+ if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver(() => { this._fit(); this._queueHotspots(); }); this._resizeObserver.observe(this); }
491
+ // Pictures and fonts that arrive late move the hotspots.
492
+ this.addEventListener('load', this._queueHotspots, true);
493
+ document.fonts?.ready?.then(this._queueHotspots);
442
494
  window.addEventListener('resize', this._onResize);
443
495
  window.addEventListener('mousemove', this._onMouseMove, { passive: true });
444
496
  window.addEventListener('beforeprint', this._onBeforePrint);
@@ -458,6 +510,10 @@ import { inkPaint, snapAngle } from '../core/ink.js'
458
510
  for (const [type, handler] of this._windowInput()) window.removeEventListener(type, handler, { capture: true });
459
511
  cancelAnimationFrame(this._laserRaf);
460
512
  this._laserRaf = 0;
513
+ this.removeEventListener('load', this._queueHotspots, true);
514
+ cancelAnimationFrame(this._hotspotRaf);
515
+ this._hotspotRaf = 0;
516
+ this._stopZoomFlight();
461
517
  if (this._hideTimer) clearTimeout(this._hideTimer);
462
518
  if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
463
519
  }
@@ -496,6 +552,16 @@ import { inkPaint, snapAngle } from '../core/ink.js'
496
552
  inkLive.setAttribute('viewBox', `0 0 ${this.designWidth} ${this.designHeight}`);
497
553
  inkLive.setAttribute('preserveAspectRatio', 'none');
498
554
  canvas.appendChild(inkLive);
555
+ const hotspots = document.createElement('div');
556
+ hotspots.className = 'hotspots export-hidden';
557
+ hotspots.addEventListener('click', e => {
558
+ const badge = e.target.closest?.('.hotspot');
559
+ if (!badge) return;
560
+ e.stopPropagation();
561
+ this.zoomToHotspot(Number(badge.dataset.n));
562
+ });
563
+ canvas.appendChild(hotspots);
564
+ this._hotspotLayer = hotspots;
499
565
  stage.appendChild(canvas);
500
566
 
501
567
  // Only shown when fingers draw: keeps their touches from scrolling or
@@ -515,7 +581,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
515
581
  zoomReset.setAttribute('aria-label', 'Show the whole slide');
516
582
  zoomReset.title = 'Show the whole slide';
517
583
  zoomReset.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M8 11h6M20 20l-4-4"/></svg>1:1';
518
- zoomReset.addEventListener('click', () => this.setZoom(null, 'reset'));
584
+ zoomReset.addEventListener('click', () => this.wholeSlide());
519
585
 
520
586
  // Overlay: compact, solid black, with clickable controls.
521
587
  const overlay = document.createElement('div');
@@ -674,7 +740,12 @@ import { inkPaint, snapAngle } from '../core/ink.js'
674
740
  if (step >= 0) this._stepMap.set(curr, step);
675
741
  this._applySteps(curr);
676
742
  // Every slide starts whole; each window does this itself.
677
- if (curr !== prev && this._zoom) this.setZoom(null, 'slide', { announce: false });
743
+ if (curr !== prev) {
744
+ this._stopZoomFlight();
745
+ this._hotspot = null;
746
+ if (this._zoom) this.setZoom(null, 'slide', { announce: false });
747
+ }
748
+ this._queueHotspots();
678
749
  // The live layer sits above every slide: nothing drawn on another
679
750
  // slide may stay on it.
680
751
  if (curr !== prev) this._clearLive();
@@ -736,6 +807,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
736
807
  const step = Math.max(-1, Math.min(max, state.step));
737
808
  if (index === this._index) {
738
809
  if (step === this.state.step) return;
810
+ if (this._hotspot) this.wholeSlide();
739
811
  this._stepMap.set(index, step);
740
812
  this._applySteps(index);
741
813
  this._broadcastState('sync');
@@ -770,6 +842,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
770
842
  const vh = rect.height || window.innerHeight;
771
843
  const s = Math.min(vw / this.designWidth, vh / this.designHeight);
772
844
  this._fitScale = s;
845
+ this._hotspotLayer?.style.setProperty('--hotspot-k', String(1 / s));
773
846
  const zoom = this._zoom;
774
847
  if (!zoom) { this._canvas.style.transform = `scale(${s})`; return; }
775
848
  const tx = (this.designWidth / 2 - zoom.x) * s * zoom.scale;
@@ -778,7 +851,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
778
851
  this._kickLaser();
779
852
  }
780
853
 
781
- _onResize() { this._fit(); }
854
+ _onResize() { this._fit(); this._queueHotspots(); }
782
855
 
783
856
  _onMouseMove() {
784
857
  // Keep overlay visible while mouse moves; hide after idle.
@@ -811,6 +884,8 @@ import { inkPaint, snapAngle } from '../core/ink.js'
811
884
  return;
812
885
  }
813
886
  if (ms > TAP_MS || Math.hypot(dx, dy) > TAP_SLOP) return;
887
+ // In a hotspot: a tap anywhere on the slide shows the whole slide again.
888
+ if (this._hotspot) { this.wholeSlide(); return; }
814
889
  if (!this.inking) {
815
890
  // Not drawing: a tap on the left third goes back, on the right third forward.
816
891
  if (this._zoom) return;
@@ -848,10 +923,8 @@ import { inkPaint, snapAngle } from '../core/ink.js'
848
923
  toggleFullscreen();
849
924
  } else if (key === 'r' || key === 'R') {
850
925
  this.reset();
851
- } else if (/^[0-9]$/.test(key)) {
852
- // 1..9 jump to that slide; 0 jumps to 10.
853
- const n = key === '0' ? 9 : parseInt(key, 10) - 1;
854
- if (n < this._slides.length) this._go(n, 'keyboard');
926
+ } else if (/^[0-9]$/.test(key) || key === 'Escape') {
927
+ handled = this.hotspotKey(key);
855
928
  } else {
856
929
  handled = false;
857
930
  }
@@ -890,6 +963,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
890
963
  i <= curr ? el.setAttribute('data-step-visible', '') : el.removeAttribute('data-step-visible')
891
964
  );
892
965
  this._updateStepCount(slideIndex);
966
+ if (slideIndex === this._index) this._queueHotspots();
893
967
  }
894
968
 
895
969
  _updateStepCount(slideIndex) {
@@ -1245,7 +1319,10 @@ import { inkPaint, snapAngle } from '../core/ink.js'
1245
1319
  return this._zoom ? { ...this._zoom } : { scale: 1, x: this.designWidth / 2, y: this.designHeight / 2 };
1246
1320
  }
1247
1321
 
1248
- setZoom(zoom, reason = 'api', { announce = true } = {}) {
1322
+ setZoom(zoom, reason = 'api', { announce = true, flying = false } = {}) {
1323
+ // A pinch, a pan or another window's zoom takes over from a flight,
1324
+ // and the view is no longer a hotspot's.
1325
+ if (!flying) { this._stopZoomFlight(); this._hotspot = null; }
1249
1326
  const next = this._clampZoom(zoom);
1250
1327
  const same = JSON.stringify(next) === JSON.stringify(this._zoom);
1251
1328
  this._zoom = next;
@@ -1352,6 +1429,164 @@ import { inkPaint, snapAngle } from '../core/ink.js'
1352
1429
  if (this.hasAttribute('data-zoomable') && !this._inInkUi(e)) e.preventDefault();
1353
1430
  }
1354
1431
 
1432
+ // Hotspots ----------------------------------------------------------------
1433
+ //
1434
+ // The parts of the slide shown that 1–9 zoom to (hotspots.js finds them).
1435
+ // Hotspots are off unless the deck (`data-hotspots` on the stage) or the
1436
+ // slide (`data-hotspots` on its section) turns them on with 'auto' or
1437
+ // 'marked', or the presenter's switch (`hotspotsForced`) does. The same
1438
+ // number again, 0, Escape, a tap on the slide or any step or slide
1439
+ // change shows the whole slide again. Zooms to and from a hotspot fly
1440
+ // (zoom/camera.js), and other windows fly the same way: their
1441
+ // `zoomchange` carries `fly: true` and the view it lands on.
1442
+
1443
+ get hotspotsForced() { return this._hotspotsForced; }
1444
+ set hotspotsForced(value) {
1445
+ this._hotspotsForced = value === true || value === false ? value : null;
1446
+ if (this.hotspotMode === 'off' && this._hotspot) this.wholeSlide();
1447
+ this._refreshHotspots();
1448
+ }
1449
+
1450
+ /** 'off', 'auto' or 'marked', for the slide shown. */
1451
+ get hotspotMode() {
1452
+ return hotspotMode({ slide: this._slides[this._index]?.getAttribute?.('data-hotspots'), deck: this.getAttribute('data-hotspots'), forced: this._hotspotsForced });
1453
+ }
1454
+
1455
+ /** The current slide's hotspots: [{ rect, zoom }], in design pixels. */
1456
+ get hotspots() { return this._hotspots.map(({ rect, zoom }) => ({ rect: { ...rect }, zoom: { ...zoom } })); }
1457
+
1458
+ /** The hotspot zoomed to (1-based), or null. */
1459
+ get hotspot() { return this._hotspot; }
1460
+
1461
+ /** A digit or Escape, as typed in this window or the presenter view's; says whether it was used. */
1462
+ hotspotKey(key) {
1463
+ if (key === 'Escape') {
1464
+ if (!this._zoom || this.inking) return false;
1465
+ this.wholeSlide();
1466
+ return true;
1467
+ }
1468
+ 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
+ }
1475
+ if (key === '0') this.wholeSlide();
1476
+ else this.zoomToHotspot(Number(key));
1477
+ return true;
1478
+ }
1479
+
1480
+ /** Fly to hotspot `n` (1-based); the same one again flies back out. */
1481
+ zoomToHotspot(n) {
1482
+ if (n === this._hotspot) { this.wholeSlide(); return true; }
1483
+ if (!this._hotspot) this._refreshHotspots();
1484
+ const spot = this._hotspots[n - 1];
1485
+ if (!spot) return false;
1486
+ this.flyZoom(spot.zoom, 'hotspot');
1487
+ this._hotspot = n;
1488
+ return true;
1489
+ }
1490
+
1491
+ /** Fly back to the whole slide. */
1492
+ wholeSlide() {
1493
+ this._hotspot = null;
1494
+ if (!this._zoom && !this._zoomRaf) return;
1495
+ this.flyZoom(null, 'hotspot-reset');
1496
+ this._hotspot = null;
1497
+ }
1498
+
1499
+ _queueHotspots() {
1500
+ if (this._hotspotRaf) return;
1501
+ // A hidden page gets no frames: its keys still need the hotspots.
1502
+ if (typeof requestAnimationFrame !== 'function' || document.hidden) { this._refreshHotspots(); return; }
1503
+ this._hotspotRaf = requestAnimationFrame(() => { this._hotspotRaf = 0; this._refreshHotspots(); });
1504
+ }
1505
+
1506
+ // An element's box in design pixels, whatever the canvas's transform.
1507
+ _designRect(element) {
1508
+ const canvas = this._canvas.getBoundingClientRect();
1509
+ const box = element.getBoundingClientRect();
1510
+ if (!canvas.width || !canvas.height) return null;
1511
+ const kx = this.designWidth / canvas.width, ky = this.designHeight / canvas.height;
1512
+ return { x: (box.left - canvas.left) * kx, y: (box.top - canvas.top) * ky, w: box.width * kx, h: box.height * ky };
1513
+ }
1514
+
1515
+ _refreshHotspots() {
1516
+ if (!this._canvas || this._printing) return;
1517
+ const slide = this._slides[this._index];
1518
+ const design = { width: this.designWidth, height: this.designHeight };
1519
+ this._hotspots = findHotspots(slide, { mode: this.hotspotMode, design, max: ZOOM_MAX, rectOf: element => this._designRect(element) });
1520
+ this.toggleAttribute('data-has-hotspots', this._hotspots.length > 0);
1521
+ const layer = this._hotspotLayer;
1522
+ if (!layer) return;
1523
+ layer.replaceChildren(...this._hotspots.flatMap(({ rect }, i) => {
1524
+ const badge = document.createElement('button');
1525
+ badge.type = 'button';
1526
+ badge.className = 'hotspot';
1527
+ badge.dataset.n = String(i + 1);
1528
+ badge.setAttribute('data-ink-ui', '');
1529
+ badge.textContent = String(i + 1);
1530
+ badge.title = `Zoom in (${i + 1})`;
1531
+ badge.setAttribute('aria-label', `Zoom in on part ${i + 1}`);
1532
+ badge.style.left = `${rect.x}px`;
1533
+ badge.style.top = `${rect.y}px`;
1534
+ const area = document.createElement('div');
1535
+ area.className = 'hotspot-area';
1536
+ Object.assign(area.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px` });
1537
+ return [badge, area];
1538
+ }));
1539
+ }
1540
+
1541
+ /**
1542
+ * Fly to `zoom` ({ scale, x, y }, or null for the whole slide) along a
1543
+ * smooth zoom path. Announced once, at the start, with `fly: true` and
1544
+ * where it lands, so other windows fly along.
1545
+ */
1546
+ flyZoom(zoom, reason = 'api', { announce = true } = {}) {
1547
+ this._stopZoomFlight();
1548
+ const design = { width: this.designWidth, height: this.designHeight };
1549
+ const whole = { scale: 1, x: design.width / 2, y: design.height / 2 };
1550
+ const to = this._clampZoom(zoom);
1551
+ // Others get the view asked for and keep it on the slide themselves:
1552
+ // their screens may have another shape.
1553
+ if (announce) {
1554
+ const { scale, x, y } = to ? zoom : whole;
1555
+ this.dispatchEvent(new CustomEvent('zoomchange', { detail: { slideId: this._inkSlideId(), scale, x, y, reason, fly: true }, bubbles: true, composed: true }));
1556
+ }
1557
+ const land = () => {
1558
+ this._zoomRaf = 0;
1559
+ this.removeAttribute('data-zoom-flying');
1560
+ this.setZoom(to, reason, { announce: false, flying: true });
1561
+ };
1562
+ const from = zoomToCamera(this.zoom, design), end = zoomToCamera(to ?? whole, design);
1563
+ const path = flight(from, end);
1564
+ // A hidden window gets no animation frames: it lands at once.
1565
+ if (document.hidden || this.hasAttribute('noscale') || typeof requestAnimationFrame !== 'function' || path.length < 1e-3) { land(); return; }
1566
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1567
+ land();
1568
+ this._canvas.animate?.([{ opacity: 0.4 }, { opacity: 1 }], { duration: 250, easing: 'ease-out' });
1569
+ return;
1570
+ }
1571
+ const start = performance.now();
1572
+ const duration = durationFor(path.length);
1573
+ this.setAttribute('data-zoom-flying', '');
1574
+ const frame = now => {
1575
+ const t = (now - start) / duration;
1576
+ if (t >= 1) { land(); return; }
1577
+ this.setZoom(cameraToZoom(path.at(ease(Math.max(0, t))), design), reason, { announce: false, flying: true });
1578
+ this._zoomRaf = requestAnimationFrame(frame);
1579
+ };
1580
+ this._zoomRaf = requestAnimationFrame(frame);
1581
+ }
1582
+
1583
+ _stopZoomFlight() {
1584
+ if (!this._zoomRaf) return;
1585
+ cancelAnimationFrame(this._zoomRaf);
1586
+ this._zoomRaf = 0;
1587
+ this.removeAttribute('data-zoom-flying');
1588
+ }
1589
+
1355
1590
  // Laser -------------------------------------------------------------------
1356
1591
  //
1357
1592
  // Trails and dots by key ('local' for this window, others from the ink
@@ -1500,6 +1735,8 @@ import { inkPaint, snapAngle } from '../core/ink.js'
1500
1735
  const steps = this._getSteps(this._slides[this._index]);
1501
1736
  const curr = this._stepMap.get(this._index) ?? -1;
1502
1737
  if (steps.length > 0 && curr < steps.length - 1) {
1738
+ // The step may appear outside the hotspot: show the whole slide.
1739
+ if (this._hotspot) this.wholeSlide();
1503
1740
  this._stepMap.set(this._index, curr + 1);
1504
1741
  this._applySteps(this._index);
1505
1742
  this._broadcastState(reason);
@@ -1513,6 +1750,7 @@ import { inkPaint, snapAngle } from '../core/ink.js'
1513
1750
  const steps = this._getSteps(this._slides[this._index]);
1514
1751
  const curr = this._stepMap.get(this._index) ?? -1;
1515
1752
  if (steps.length > 0 && curr >= 0) {
1753
+ if (this._hotspot) this.wholeSlide();
1516
1754
  this._stepMap.set(this._index, curr - 1);
1517
1755
  this._applySteps(this._index);
1518
1756
  this._broadcastState(reason);
@@ -0,0 +1,98 @@
1
+ // Hotspots: the parts of a slide the presenter can zoom to with 1–9.
2
+ //
3
+ // A slide's mode is 'off', 'auto' (pictures, code, tables, maths, videos,
4
+ // callouts, columns and activities, plus anything marked `data-hotspot`) or
5
+ // 'marked' (only the marked ones). The zoom that shows a hotspot is worked
6
+ // out for the slide's own shape, not the window's, so every screen that
7
+ // follows shows the same part; the stage keeps the view on the slide, so a
8
+ // hotspot of another shape shows the slide around it.
9
+
10
+ import { fitRect } from './zoom/camera.js'
11
+
12
+ export const HOTSPOT_MODES = ['off', 'auto', 'marked']
13
+ export const MAX_HOTSPOTS = 9
14
+ // Room around a hotspot, as a share of its larger side.
15
+ const MARGIN = 0.06
16
+ // A zoom closer than this to the whole slide is not worth a key.
17
+ const MIN_SCALE = 1.15
18
+ // Smaller than this (in design pixels) is an icon or a bullet, not a hotspot.
19
+ const MIN_SIDE = 48
20
+
21
+ const AUTO = [
22
+ 'img', 'svg', '.themed-picture', '.code-block-wrapper', 'pre', 'table', '.katex-display',
23
+ '.video-player-container', 'video', '.callout', '.columns > .column', '.poll',
24
+ ].join(', ')
25
+ // Never a hotspot, nor anything inside it.
26
+ const NEVER = '.slide-header, .slide-footer, .slide-footnotes, .slide-citations, button, a, .katex, [data-ink-ui], [data-hotspot="off"]'
27
+
28
+ // The mode a slide shows: `forced` is the presenter's switch (true, false or
29
+ // null for the deck's own setting).
30
+ export function hotspotMode({ slide, deck, forced = null }) {
31
+ if (forced === false) return 'off'
32
+ const own = normalMode(slide)
33
+ const decks = normalMode(deck) ?? 'off'
34
+ if (forced === true) return own ?? (decks === 'off' ? 'auto' : decks)
35
+ return own ?? decks
36
+ }
37
+
38
+ // A setting as written (true and false too) or as an attribute.
39
+ export function normalMode(value) {
40
+ if (value === true || value === 'true') return 'auto'
41
+ if (value === false || value === 'false') return 'off'
42
+ return HOTSPOT_MODES.includes(value) ? value : null
43
+ }
44
+
45
+ // The stage's zoom ({ scale, x, y }) that shows `rect` ({ x, y, w, h }, design
46
+ // pixels) as large as the slide allows, or null when it nearly fills it.
47
+ export function hotspotZoom(rect, { width, height }, { margin = MARGIN, max = Infinity } = {}) {
48
+ if (!(rect.w > 0 && rect.h > 0)) return null
49
+ const camera = fitRect(rect, width / height, margin)
50
+ const scale = Math.min(max, width / camera.w)
51
+ if (scale < MIN_SCALE) return null
52
+ return { scale, x: camera.x, y: camera.y }
53
+ }
54
+
55
+ // Between the stage's zoom and the camera of zoom/camera.js ({ x, y, w }:
56
+ // the centre and the width of slide the view shows).
57
+ export const zoomToCamera = (zoom, { width }) => ({ x: zoom.x, y: zoom.y, w: width / zoom.scale })
58
+ export const cameraToZoom = (camera, { width }) => ({ scale: width / camera.w, x: camera.x, y: camera.y })
59
+
60
+ // Reading order: rows top to bottom (items that overlap vertically share a
61
+ // row), each row left to right; right to left on a right-to-left slide.
62
+ export function readingOrder(items, { rtl = false } = {}) {
63
+ const sorted = [...items].sort((a, b) => a.rect.y - b.rect.y)
64
+ const rows = []
65
+ for (const item of sorted) {
66
+ const row = rows.at(-1)
67
+ const middle = item.rect.y + item.rect.h / 2
68
+ if (row && middle < row.bottom) { row.items.push(item); row.bottom = Math.max(row.bottom, item.rect.y + item.rect.h) }
69
+ else rows.push({ items: [item], bottom: item.rect.y + item.rect.h })
70
+ }
71
+ return rows.flatMap(row => row.items.sort((a, b) => rtl ? b.rect.x - a.rect.x : a.rect.x - b.rect.x))
72
+ }
73
+
74
+ // The hotspots of a shown slide: [{ element, rect, zoom }], in reading order.
75
+ // `rectOf(element)` gives an element's box in design pixels.
76
+ export function findHotspots(slide, { mode, rectOf, design, max = Infinity }) {
77
+ if (!slide || mode === 'off' || !HOTSPOT_MODES.includes(mode)) return []
78
+ const selector = mode === 'marked' ? '[data-hotspot]' : `[data-hotspot], ${AUTO}`
79
+ const chosen = []
80
+ for (const element of slide.querySelectorAll(selector)) {
81
+ const marked = element.hasAttribute('data-hotspot') && element.getAttribute('data-hotspot') !== 'off'
82
+ if (!marked && element.closest(NEVER)) continue
83
+ // Inside a part already chosen: that part shows it.
84
+ if (!marked && chosen.some(item => item.element.contains(element))) continue
85
+ // Not revealed yet, or not shown at all.
86
+ const step = element.closest('[data-step]')
87
+ if (step && !step.hasAttribute('data-step-visible')) continue
88
+ const rect = rectOf(element)
89
+ if (!rect || rect.w <= 0 || rect.h <= 0) continue
90
+ if (!marked && (rect.w < MIN_SIDE || rect.h < MIN_SIDE)) continue
91
+ // Nearly the whole slide: no key, but what is inside may have one.
92
+ const zoom = hotspotZoom(rect, design, { max })
93
+ if (!zoom) continue
94
+ chosen.push({ element, rect, zoom })
95
+ }
96
+ const rtl = slide.closest?.('[dir=rtl]') != null
97
+ return readingOrder(chosen, { rtl }).slice(0, MAX_HOTSPOTS)
98
+ }
@@ -57,7 +57,11 @@ export function createInkBus(stage, transports) {
57
57
  } else if (message.type === 'media') {
58
58
  stage.dispatchEvent(new CustomEvent('mediacontrol', { detail: { video: message.video, action: message.action, time: message.time } }))
59
59
  } else if (message.type === 'zoom') {
60
- if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
60
+ if (!message.slideId || message.slideId !== stage._inkSlideId()) return
61
+ const zoom = message.scale > 1 ? { scale: message.scale, x: message.x, y: message.y } : null
62
+ // A hotspot's zoom flies here too; a pinch follows as it comes.
63
+ if (message.fly) stage.flyZoom(zoom, 'remote', { announce: false })
64
+ else stage.setZoom(zoom, 'remote', { announce: false })
61
65
  } else if (message.type === 'dot') {
62
66
  stage.laserDot(message.key, message.slideId, message.point)
63
67
  } else if (message.type === 'end') {
@@ -120,6 +124,13 @@ export function createInkBus(stage, transports) {
120
124
  stage.addEventListener('zoomchange', ({ detail }) => {
121
125
  if (detail.reason === 'remote') return
122
126
  const { slideId, scale, x, y } = detail
127
+ // A flight is sent at once, with where it lands; the others fly it themselves.
128
+ if (detail.fly) {
129
+ clearTimeout(zoomTimer)
130
+ zoomTimer = zoom = null
131
+ send({ type: 'zoom', key: `${me}:zoom`, slideId, scale, x, y, fly: true })
132
+ return
133
+ }
123
134
  zoom = { slideId, scale, x, y }
124
135
  zoomTimer ??= setTimeout(sendZoom, scale > 1 ? DOT_MS : 0)
125
136
  })
@@ -67,13 +67,19 @@ export function roomTransport({ receive = false, view = null, onPresence = null
67
67
  // Position followers, attached before the transport listens.
68
68
  const stateListeners = new Set()
69
69
 
70
- /** Follow navigation from another device in either kind of main slide window. */
71
- export function followStageRoom(stage) {
70
+ /**
71
+ * Follow navigation from another device in either kind of main slide window.
72
+ * `onLanguage(lang, state)` switches a deck in several languages to the one
73
+ * shown there, at that state, and says whether it did.
74
+ */
75
+ export function followStageRoom(stage, { onLanguage } = {}) {
72
76
  stateListeners.add(state => {
73
77
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
78
+ const position = { index: state.index, step: state.step ?? -1, slideId: state.slideId }
79
+ if (state.lang && onLanguage?.(state.lang, position)) return
74
80
  const current = stage.state
75
81
  if (current?.index === state.index && current?.step === state.step) return
76
- stage.setState({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
82
+ stage.setState(position)
77
83
  })
78
84
  }
79
85
 
@@ -81,17 +87,22 @@ export function followStageRoom(stage) {
81
87
  export function onStagePosition(listener) {
82
88
  stateListeners.add(state => {
83
89
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
84
- listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
90
+ listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId, lang: state.lang })
85
91
  })
86
92
  }
87
93
 
88
- /** Publish navigation made here; received positions never echo back. */
89
- export function announceStagePosition(stage) {
94
+ /**
95
+ * Publish navigation made here; received positions never echo back.
96
+ * `language()` names the language shown, in a deck that has several. Returns
97
+ * a function to call when that changes.
98
+ */
99
+ export function announceStagePosition(stage, { language = () => null } = {}) {
90
100
  const room = stageRoom()
91
101
  let allowed = null, last = null
92
102
  async function post(event) {
93
103
  const { index, step, slideId } = stage.state
94
- const key = `${slideId}:${index}:${step}`
104
+ const lang = language() ?? undefined
105
+ const key = `${slideId}:${index}:${step}:${lang}`
95
106
  if (event?.detail.reason === 'sync') { last = key; return }
96
107
  if (key === last || allowed === false) return
97
108
  // Opening a window must not move the projector; only changes count.
@@ -102,8 +113,9 @@ export function announceStagePosition(stage) {
102
113
  allowed = info.reachable && !!info.canReset
103
114
  if (!allowed) return
104
115
  }
105
- fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, at, screen: room.screen } }) }).catch(() => {})
116
+ 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(() => {})
106
117
  }
107
118
  stage.addEventListener('statechange', post)
108
119
  post()
120
+ return () => post({ detail: { reason: 'language' } })
109
121
  }