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.
- package/CHANGELOG.md +13 -0
- package/README.md +3 -3
- package/assets/base.css +9 -5
- package/assets/extensions/themes/academic/extension.toml +4 -1
- package/assets/extensions/themes/academic/styles.css +22 -22
- package/assets/extensions/themes/glass/extension.toml +7 -1
- package/assets/extensions/themes/glass/styles.css +17 -17
- package/assets/extensions/themes/minimal/extension.toml +3 -1
- package/assets/extensions/themes/minimal/styles.css +14 -14
- package/assets/extensions/themes/neue/extension.toml +3 -1
- package/assets/extensions/themes/neue/styles.css +14 -14
- package/assets/extensions/themes/plain/extension.toml +7 -1
- package/assets/extensions/themes/plain/styles.css +17 -17
- package/assets/extensions/themes/work/extension.toml +8 -2
- package/assets/extensions/themes/work/styles.css +26 -26
- package/docs/reference/authoring.md +21 -4
- package/docs/reference/extensions.md +5 -1
- package/docs/reference/themes.md +6 -2
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/presenting.md +45 -0
- package/docs/site/content/theme-authoring.md +28 -0
- package/examples/python/slides.de.md +493 -0
- package/examples/python/{slides.md → slides.en.md} +1 -1
- package/examples/python/slides.fa.md +492 -0
- package/examples/python/slides.hi.md +492 -0
- package/examples/showcase/slides.md +33 -0
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +1 -0
- package/src/build/config.js +1 -1
- package/src/build/slidesPlugin.js +34 -4
- package/src/cli/index.js +4 -0
- package/src/cli/themes.js +2 -0
- package/src/components/callout.css +10 -10
- package/src/components/citation.css +2 -2
- package/src/components/code-block.css +3 -3
- package/src/components/poll.css +6 -6
- package/src/components/video-player.css +1 -1
- package/src/core/editDeck.js +3 -2
- package/src/core/icons.js +1 -0
- package/src/core/languages.js +51 -0
- package/src/core/parseSlides.js +15 -3
- package/src/core/validateDeck.js +13 -1
- package/src/editor/components/DeckSettings.jsx +37 -3
- package/src/editor/components/ExtensionForms.jsx +12 -0
- package/src/editor/components/MetaFields.jsx +7 -0
- package/src/editor/extensions.js +6 -3
- package/src/extensions/appearance.js +6 -2
- package/src/extensions/manifest.js +3 -3
- package/src/extensions/params.js +113 -0
- package/src/layouts/layoutApi.jsx +7 -1
- package/src/live/answer/answer.css +2 -2
- package/src/runtime/chrome.jsx +6 -7
- package/src/runtime/deck-stage.js +249 -11
- package/src/runtime/hotspots.js +98 -0
- package/src/runtime/ink/bus.js +12 -1
- package/src/runtime/ink/room.js +20 -8
- package/src/runtime/main.jsx +152 -22
- package/src/runtime/zoom/camera.js +77 -0
- /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
|
-
|
|
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:
|
|
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-
|
|
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; }
|
package/src/runtime/chrome.jsx
CHANGED
|
@@ -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
|
|
48
|
+
const stripe = key => <i style={{ display: 'block', background: tokens[key] ?? tokens['--accent'] ?? 'transparent' }} />
|
|
47
49
|
return (
|
|
48
|
-
<
|
|
49
|
-
{
|
|
50
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime/ink/bus.js
CHANGED
|
@@ -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
|
|
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
|
})
|
package/src/runtime/ink/room.js
CHANGED
|
@@ -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
|
-
/**
|
|
71
|
-
|
|
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(
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
|
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
|
}
|