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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -3
- package/assets/base.css +2 -0
- package/docs/reference/authoring.md +48 -7
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +4 -2
- package/docs/site/content/drawing.md +4 -4
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +3 -1
- package/docs/site/content/presenting.md +29 -8
- package/docs/site/content/writing.md +27 -0
- package/examples/showcase/slides.ar.md +5 -5
- package/examples/showcase/slides.de.md +5 -5
- package/examples/showcase/slides.de.results.json +27 -0
- package/examples/showcase/slides.en.md +5 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +4 -1
- package/src/build/check.js +27 -6
- package/src/build/components.js +5 -3
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +64 -16
- package/src/build/timingPlugin.js +34 -18
- package/src/cli/args.js +3 -3
- package/src/cli/index.js +49 -10
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/icons.js +6 -1
- package/src/core/labels.js +3 -0
- package/src/core/parseSlides.js +14 -4
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/validateDeck.js +6 -2
- package/src/home/Home.jsx +38 -93
- package/src/home/home.css +5 -17
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +12 -1
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +29 -7
- package/src/live/server.js +15 -1
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/deck-stage.js +322 -17
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/room.js +6 -6
- package/src/runtime/main.jsx +396 -303
- package/src/runtime/slideControls.js +60 -0
- 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")
|
|
9
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
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
|
package/src/runtime/ink/room.js
CHANGED
|
@@ -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()
|