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