mdeck 3.7.0 → 3.8.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 +4 -4
- package/assets/base.css +9 -5
- package/assets/extensions/themes/academic/styles.css +22 -22
- package/assets/extensions/themes/glass/styles.css +17 -17
- package/assets/extensions/themes/minimal/styles.css +14 -14
- package/assets/extensions/themes/neue/styles.css +14 -14
- package/assets/extensions/themes/plain/styles.css +17 -17
- package/assets/extensions/themes/work/styles.css +26 -26
- package/docs/reference/authoring.md +12 -0
- package/docs/site/content/drawing.md +6 -2
- package/docs/site/content/media.md +3 -1
- package/docs/site/content/presenting.md +47 -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/components/VideoPlayer.jsx +70 -6
- 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 +15 -0
- package/src/core/editDeck.js +3 -2
- package/src/core/icons.js +1 -0
- package/src/core/labels.js +2 -0
- package/src/core/languages.js +51 -0
- package/src/core/parseSlides.js +15 -3
- package/src/core/validateDeck.js +5 -0
- package/src/editor/components/DeckSettings.jsx +4 -0
- package/src/editor/components/MetaFields.jsx +7 -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 +21 -1
- package/src/runtime/ink/room.js +20 -8
- package/src/runtime/main.jsx +179 -23
- package/src/runtime/zoom/camera.js +77 -0
- /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
package/src/runtime/main.jsx
CHANGED
|
@@ -3,7 +3,7 @@ import { h, render } from 'preact'
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
4
4
|
import { marked } from 'marked'
|
|
5
5
|
import { renderCitations, onBibliography } from './citations.js'
|
|
6
|
-
import deckFileContent from 'virtual:slides'
|
|
6
|
+
import deckFileContent, { language as fileLanguage, languages as deckLanguages } from 'virtual:slides'
|
|
7
7
|
import { parseSlides } from '../core/parseSlides'
|
|
8
8
|
import { validateDeck } from '../core/validateDeck'
|
|
9
9
|
import { loadTheme, setExtensionOverrides, THEME_NAMES, THEME_METAS, PALETTES } from './themeLoader'
|
|
@@ -31,9 +31,11 @@ import './reader.css'
|
|
|
31
31
|
import { SlideRenderer, manifests } from '../layouts/renderSlide'
|
|
32
32
|
import { setCalloutLabels } from './markedSetup'
|
|
33
33
|
import { setDeckLanguage, deckLanguage, stageLabels, t } from '../core/labels.js'
|
|
34
|
+
import { textDirection } from '../core/languages.js'
|
|
34
35
|
import { Icon } from '../components/Icon.jsx'
|
|
35
36
|
import { ConnectedViews } from '../components/ConnectedViews.jsx'
|
|
36
37
|
import './deck-stage.js'
|
|
38
|
+
import { normalMode, hotspotMode } from './hotspots.js'
|
|
37
39
|
|
|
38
40
|
const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
|
|
39
41
|
|
|
@@ -50,6 +52,10 @@ if (requestedView(controlUrl) === 'presenter' && !controlUrl.searchParams.has('s
|
|
|
50
52
|
history.replaceState(null, '', controlUrl)
|
|
51
53
|
}
|
|
52
54
|
const DECK_CHANNEL = `deck-control:${controlUrl.pathname}:${controlUrl.searchParams.get('session') ?? 'default'}`
|
|
55
|
+
// An audience window says it is there this often, and counts as gone when
|
|
56
|
+
// it has not said so for this long (or says so as it closes).
|
|
57
|
+
const SCREEN_SAY_MS = 3000
|
|
58
|
+
const SCREEN_GONE_MS = 8000
|
|
53
59
|
|
|
54
60
|
function withConfigOverrides(deckConfig) {
|
|
55
61
|
const url = new URL(window.location.href)
|
|
@@ -75,6 +81,24 @@ function withConfigOverrides(deckConfig) {
|
|
|
75
81
|
// The deck's text: the file's, or on the dev server with ?sample=1 the sample
|
|
76
82
|
// deck, which the design page's pictures show.
|
|
77
83
|
let slidesContent = deckFileContent
|
|
84
|
+
// The deck's languages under `mdeck run`, one file each (talk.en.md beside
|
|
85
|
+
// talk.de.md); a deck in one language has none and shows no switch. The
|
|
86
|
+
// presenter view switches every window, keeping the slide and step: slides
|
|
87
|
+
// match by their place in the deck.
|
|
88
|
+
const LANGUAGES = Object.keys(deckLanguages)
|
|
89
|
+
let deckLang = fileLanguage
|
|
90
|
+
function chooseLanguage(lang) {
|
|
91
|
+
if (!LANGUAGES.includes(lang)) return false
|
|
92
|
+
deckLang = lang
|
|
93
|
+
slidesContent = deckLanguages[lang]
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
// A deck without `lang` is in the language its file name gives.
|
|
97
|
+
const withFileLanguage = config => config.lang || !deckLang ? config : { ...config, lang: deckLang }
|
|
98
|
+
// A language's name in that language, for the switch's tooltip.
|
|
99
|
+
function languageName(lang) {
|
|
100
|
+
try { return new Intl.DisplayNames([lang], { type: 'language' }).of(lang) } catch { return lang }
|
|
101
|
+
}
|
|
78
102
|
const hasActivities = () => roomsIn(slidesContent).length > 0
|
|
79
103
|
// What the phones show for the activity on a slide: its component's `phone`
|
|
80
104
|
// description, from the tag's attributes and the slide's heading.
|
|
@@ -181,8 +205,15 @@ function usePresence() {
|
|
|
181
205
|
return presence
|
|
182
206
|
}
|
|
183
207
|
|
|
184
|
-
function PresenterView({ deckConfig, slides }) {
|
|
208
|
+
function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
185
209
|
const [index, setIndex] = useState(0)
|
|
210
|
+
// The notes, slide count and phones follow the language shown.
|
|
211
|
+
const [lang, setLang] = useState(deckLang)
|
|
212
|
+
const { slides, deckConfig } = useMemo(() => {
|
|
213
|
+
if (!chooseLanguage(lang)) return { slides: firstSlides, deckConfig: firstConfig }
|
|
214
|
+
const parsed = parseSlides(slidesContent)
|
|
215
|
+
return { slides: parsed.slides, deckConfig: { ...firstConfig, ...withFileLanguage(parsed.deckConfig) } }
|
|
216
|
+
}, [lang])
|
|
186
217
|
const [theme, setTheme] = useState(deckConfig.theme ?? 'neue')
|
|
187
218
|
// '' means the theme's own default, for both.
|
|
188
219
|
const [palette, setPalette] = useState(deckConfig.palette ?? '')
|
|
@@ -190,6 +221,15 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
190
221
|
const [audienceConnected, setAudienceConnected] = useState(false)
|
|
191
222
|
const [noteSize, setNoteSize] = useState(13)
|
|
192
223
|
const [inking, setInking] = useState(false)
|
|
224
|
+
// Hotspots: the deck's setting until the presenter switches them (H).
|
|
225
|
+
const deckHotspots = (normalMode(deckConfig.hotspots) ?? 'off') !== 'off'
|
|
226
|
+
const [hotspotsForced, setHotspotsForced] = useState(null)
|
|
227
|
+
const hotspotsOn = hotspotsForced ?? deckHotspots
|
|
228
|
+
const hotspotsRef = useRef(null)
|
|
229
|
+
hotspotsRef.current = hotspotsForced
|
|
230
|
+
const toggleHotspots = () => setHotspotsForced(forced => !(forced ?? deckHotspots))
|
|
231
|
+
// Whether the number keys zoom on the slide shown: a slide may have its own setting.
|
|
232
|
+
const slideHotspots = hotspotMode({ slide: slides[index]?.meta.hotspots, deck: deckConfig.hotspots, forced: hotspotsForced }) !== 'off'
|
|
193
233
|
const presence = usePresence()
|
|
194
234
|
// Drawing in the slide's frame: this page around it must not pan or bounce either.
|
|
195
235
|
useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
|
|
@@ -242,6 +282,8 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
242
282
|
const orderRef = useRef(null)
|
|
243
283
|
orderRef.current ??= createOrder()
|
|
244
284
|
const indexRef = useRef(0)
|
|
285
|
+
const langRef = useRef(lang)
|
|
286
|
+
langRef.current = lang
|
|
245
287
|
const stateRef = useRef({ index: 0, step: -1 })
|
|
246
288
|
const paletteDropRef = useRef(null)
|
|
247
289
|
indexRef.current = index
|
|
@@ -251,7 +293,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
251
293
|
useEffect(() => {
|
|
252
294
|
announceSlide({ slides, deckConfig }, index, { initial: !announcedOnce.current })
|
|
253
295
|
announcedOnce.current = true
|
|
254
|
-
}, [index])
|
|
296
|
+
}, [index, lang])
|
|
255
297
|
|
|
256
298
|
const themeMeta = THEME_METAS[theme]
|
|
257
299
|
// The palettes this theme offers, and what the deck shows right now.
|
|
@@ -285,17 +327,33 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
285
327
|
useEffect(() => {
|
|
286
328
|
function onMessage({ data, source }) {
|
|
287
329
|
if (source === previewRef.current?.contentWindow) {
|
|
288
|
-
|
|
330
|
+
// A preview that (re)loaded, such as after the last slide, opens in
|
|
331
|
+
// the deck file's language: it gets the one shown.
|
|
332
|
+
if (data?.deckStateChanged && data.reason === 'init') {
|
|
333
|
+
sendTo(source, 'goTo', indexRef.current + 1)
|
|
334
|
+
if (LANGUAGES.length) sendTo(source, 'setLanguage', langRef.current)
|
|
335
|
+
}
|
|
289
336
|
return
|
|
290
337
|
}
|
|
291
338
|
if (source !== iframeRef.current?.contentWindow) return
|
|
292
339
|
if (typeof data?.inkMode === 'boolean') { setInking(data.inkMode); return }
|
|
340
|
+
// Another screen (a paired iPad) switched the language; the notes follow.
|
|
341
|
+
if (LANGUAGES.includes(data?.deckLanguage)) { setLang(data.deckLanguage); return }
|
|
293
342
|
if (!data?.deckStateChanged) return
|
|
294
343
|
const state = data.deckStateChanged
|
|
295
344
|
const i = state.index
|
|
296
345
|
// Theme reloads must not reset the current reveal position.
|
|
346
|
+
// A reloaded frame opens in the deck file's language; after its place
|
|
347
|
+
// is back, it gets the one shown.
|
|
348
|
+
const keepLanguage = () => {
|
|
349
|
+
if (data.reason !== 'init') return
|
|
350
|
+
if (LANGUAGES.length) sendTo(iframeRef.current?.contentWindow, 'setLanguage', langRef.current)
|
|
351
|
+
// A reloaded frame keeps the presenter's hotspots switch too.
|
|
352
|
+
if (hotspotsRef.current != null) sendTo(iframeRef.current?.contentWindow, 'hotspots', hotspotsRef.current)
|
|
353
|
+
}
|
|
297
354
|
if (data.reason === 'init' && stateRef.current.index === i) {
|
|
298
355
|
sendTo(iframeRef.current?.contentWindow, 'setState', stateRef.current)
|
|
356
|
+
keepLanguage()
|
|
299
357
|
return
|
|
300
358
|
}
|
|
301
359
|
stateRef.current = state
|
|
@@ -304,6 +362,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
304
362
|
bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
|
|
305
363
|
}
|
|
306
364
|
sendTo(previewRef.current?.contentWindow, 'goTo', i + 1)
|
|
365
|
+
keepLanguage()
|
|
307
366
|
}
|
|
308
367
|
window.addEventListener('message', onMessage)
|
|
309
368
|
// The frame may have started drawing before this listener was there.
|
|
@@ -324,6 +383,10 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
324
383
|
(e.key === 'd' || e.key === 'D') ? 'ink' : null
|
|
325
384
|
if ((e.key === 'f' || e.key === 'F') && fullscreen?.available()) { e.preventDefault(); fullscreen.toggle(); return }
|
|
326
385
|
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
|
|
386
|
+
if ((e.key === 'l' || e.key === 'L') && LANGUAGES.length) { e.preventDefault(); nextLanguage(); return }
|
|
387
|
+
if (e.key === 'h' || e.key === 'H') { e.preventDefault(); toggleHotspots(); return }
|
|
388
|
+
// Digits zoom to hotspots (or jump to slides without them); Escape zooms out.
|
|
389
|
+
if (/^[0-9]$/.test(e.key) || e.key === 'Escape') { if (e.key !== 'Escape') e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'hotspotKey', e.key); return }
|
|
327
390
|
if (!cmd) return
|
|
328
391
|
e.preventDefault()
|
|
329
392
|
sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
|
|
@@ -332,6 +395,13 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
332
395
|
return () => window.removeEventListener('keydown', onKey)
|
|
333
396
|
}, [])
|
|
334
397
|
|
|
398
|
+
// The presenter's hotspots switch goes to the slide frame.
|
|
399
|
+
const hotspotsOnce = useRef(false)
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (!hotspotsOnce.current) { hotspotsOnce.current = true; return }
|
|
402
|
+
sendTo(iframeRef.current?.contentWindow, 'hotspots', hotspotsForced)
|
|
403
|
+
}, [hotspotsForced])
|
|
404
|
+
|
|
335
405
|
// Escape to close theme modal
|
|
336
406
|
useEffect(() => {
|
|
337
407
|
if (!themeModalOpen) return
|
|
@@ -367,6 +437,19 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
367
437
|
bcRef.current?.postMessage({ deckControl: { command: 'setTheme', theme, palette, appearance } })
|
|
368
438
|
}, [theme, palette, appearance])
|
|
369
439
|
|
|
440
|
+
// Another language: every window swaps its slides in place. The address
|
|
441
|
+
// keeps it, for a reload and for windows opened later.
|
|
442
|
+
const langOnce = useRef(false)
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
if (!langOnce.current) { langOnce.current = true; return }
|
|
445
|
+
const url = new URL(window.location.href)
|
|
446
|
+
url.searchParams.set('lang', lang)
|
|
447
|
+
history.replaceState(null, '', url)
|
|
448
|
+
for (const win of [iframeRef.current?.contentWindow, previewRef.current?.contentWindow]) sendTo(win, 'setLanguage', lang)
|
|
449
|
+
bcRef.current?.postMessage({ deckControl: { command: 'setLanguage', value: lang } })
|
|
450
|
+
}, [lang])
|
|
451
|
+
const nextLanguage = () => setLang(current => LANGUAGES[(LANGUAGES.indexOf(current) + 1) % LANGUAGES.length])
|
|
452
|
+
|
|
370
453
|
// Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
|
|
371
454
|
function navCommand(cmd) {
|
|
372
455
|
sendTo(iframeRef.current?.contentWindow, cmd)
|
|
@@ -404,6 +487,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
404
487
|
<button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
|
|
405
488
|
<button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
|
|
406
489
|
<button class="presenter-draw" title="Draw on the slide (D)" aria-pressed={inking} style={{ ...pill, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }} onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}><PresenterIcon name="pen" /></button>
|
|
490
|
+
<button class="presenter-hotspots" title={hotspotsOn ? 'Hotspots on: 1–9 zoom in (H)' : 'Hotspots off: 1–9 jump to slides (H)'} aria-label="Hotspots" aria-pressed={hotspotsOn} style={{ ...pill, ...(hotspotsOn ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={toggleHotspots}><PresenterIcon name="hotspots" /></button>
|
|
407
491
|
<button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
|
|
408
492
|
{canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
|
|
409
493
|
<button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
|
|
@@ -475,7 +559,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
475
559
|
style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
|
|
476
560
|
/>
|
|
477
561
|
</div>
|
|
478
|
-
<div class="notes-md" style={{
|
|
562
|
+
<div class="notes-md" dir={textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined} style={{
|
|
479
563
|
flex: 1,
|
|
480
564
|
margin: 0,
|
|
481
565
|
padding: '10px 12px',
|
|
@@ -497,17 +581,17 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
497
581
|
</div>
|
|
498
582
|
|
|
499
583
|
{!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
|
|
500
|
-
<label style={{ flex: 1 }}>
|
|
584
|
+
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
501
585
|
<span style={S.label}>Theme</span>
|
|
502
586
|
<button
|
|
503
587
|
onClick={() => setThemeModalOpen(true)}
|
|
504
588
|
style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
|
|
505
589
|
>
|
|
506
|
-
<span>{theme}</span>
|
|
590
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{theme}</span>
|
|
507
591
|
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
|
|
508
592
|
</button>
|
|
509
593
|
</label>
|
|
510
|
-
<label style={{ flex: 1 }}>
|
|
594
|
+
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
511
595
|
<span style={S.label}>Palette</span>
|
|
512
596
|
<div ref={paletteDropRef} style={{ position: 'relative' }}>
|
|
513
597
|
<button
|
|
@@ -515,22 +599,22 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
515
599
|
onClick={() => setPaletteOpen(o => !o)}
|
|
516
600
|
style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
|
|
517
601
|
>
|
|
518
|
-
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
519
602
|
<PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
|
|
603
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
520
604
|
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
|
|
521
605
|
</button>
|
|
522
606
|
{paletteOpen && (
|
|
523
|
-
<div style={{ position: 'absolute', bottom: 'calc(100% + 4px)', left: 0,
|
|
607
|
+
<div style={{ position: 'absolute', bottom: 'calc(100% + 4px)', left: 0, minWidth: '100%', whiteSpace: 'nowrap', background: '#1a1a1a', border: '1px solid #2e2e2e', borderRadius: '5px', zIndex: 100, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)' }}>
|
|
524
608
|
{offered.map(p => (
|
|
525
609
|
<div
|
|
526
610
|
key={p.id}
|
|
527
611
|
class="presenter-palette-option"
|
|
528
612
|
title={p.description}
|
|
529
613
|
onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
|
|
530
|
-
style={{ padding: '6px 8px', cursor: 'pointer', display: 'flex', alignItems: 'center',
|
|
614
|
+
style={{ padding: '6px 8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent' }}
|
|
531
615
|
>
|
|
532
|
-
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
533
616
|
<PaletteSwatches tokens={p[shown.appearance] ?? {}} />
|
|
617
|
+
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
534
618
|
</div>
|
|
535
619
|
))}
|
|
536
620
|
</div>
|
|
@@ -538,7 +622,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
538
622
|
</div>
|
|
539
623
|
</label>
|
|
540
624
|
<label style={{ flexShrink: 0 }}>
|
|
541
|
-
<span style={S.label}>
|
|
625
|
+
<span style={S.label}>Mode</span>
|
|
542
626
|
<div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
543
627
|
{['light', 'dark'].map(mode => (
|
|
544
628
|
<button
|
|
@@ -552,6 +636,20 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
552
636
|
))}
|
|
553
637
|
</div>
|
|
554
638
|
</label>
|
|
639
|
+
{LANGUAGES.length > 0 && <label style={{ flexShrink: 0 }}>
|
|
640
|
+
<span style={S.label}>Language</span>
|
|
641
|
+
<div class="presenter-language" role="group" aria-label="Language" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
642
|
+
{LANGUAGES.map(code => (
|
|
643
|
+
<button
|
|
644
|
+
key={code}
|
|
645
|
+
aria-pressed={lang === code}
|
|
646
|
+
title={`${languageName(code)} (L switches)`}
|
|
647
|
+
onClick={() => setLang(code)}
|
|
648
|
+
style={{ ...S.btn, padding: '3px 7px', ...(lang === code ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
|
|
649
|
+
>{code.toUpperCase()}</button>
|
|
650
|
+
))}
|
|
651
|
+
</div>
|
|
652
|
+
</label>}
|
|
555
653
|
<button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
|
|
556
654
|
style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
|
|
557
655
|
><PresenterIcon name="fold" size={16} /></button>
|
|
@@ -569,6 +667,14 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
569
667
|
style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
|
|
570
668
|
onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
|
|
571
669
|
><PresenterIcon name="pen" size={15} />Draw</button>
|
|
670
|
+
<button
|
|
671
|
+
class="presenter-hotspots"
|
|
672
|
+
title={hotspotsOn ? 'Hotspots on: 1–9 zoom in, 0 shows the whole slide (H)' : 'Hotspots off: 1–9 jump to slides (H)'}
|
|
673
|
+
aria-pressed={hotspotsOn}
|
|
674
|
+
aria-label="Hotspots"
|
|
675
|
+
style={{ ...S.btn, ...(hotspotsOn ? { background: '#2a2a2a', borderColor: '#444', color: '#f0f0f0' } : {}) }}
|
|
676
|
+
onClick={toggleHotspots}
|
|
677
|
+
><PresenterIcon name="hotspots" size={15} /></button>
|
|
572
678
|
{lookFolded && <button
|
|
573
679
|
class="presenter-look-unfold"
|
|
574
680
|
title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
|
|
@@ -591,7 +697,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
591
697
|
</button>
|
|
592
698
|
</div>
|
|
593
699
|
<div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
|
|
594
|
-
Arrow keys · Space · PgUp/PgDn · R to reset
|
|
700
|
+
Arrow keys · Space · PgUp/PgDn · R to reset · H hotspots{slideHotspots ? ' · 1–9 zoom · 0 whole slide' : ''}
|
|
595
701
|
</div>
|
|
596
702
|
</div>
|
|
597
703
|
|
|
@@ -643,7 +749,7 @@ function mountDeck({ deck, deckConfig, selection = null, editor = false }) {
|
|
|
643
749
|
injectSpeakerNotes(slides)
|
|
644
750
|
const seen = new Map()
|
|
645
751
|
const app = (
|
|
646
|
-
<deck-stage width={deckConfig.width ?? 1920} height={deckConfig.height ?? 1080}>
|
|
752
|
+
<deck-stage width={deckConfig.width ?? 1920} height={deckConfig.height ?? 1080} data-hotspots={String(deckConfig.hotspots ?? 'off')}>
|
|
647
753
|
{slides.map((slide, i) => {
|
|
648
754
|
const count = (seen.get(slide.id) ?? 0) + 1
|
|
649
755
|
seen.set(slide.id, count)
|
|
@@ -678,8 +784,8 @@ async function init() {
|
|
|
678
784
|
if (import.meta.env.DEV && url.searchParams.get('sample') === '1') {
|
|
679
785
|
const { sampleDeck, sampleDataUrl } = await import('../editor/sampleDeck.js')
|
|
680
786
|
slidesContent = sampleDeck(sampleDataUrl())
|
|
681
|
-
}
|
|
682
|
-
|
|
787
|
+
} else chooseLanguage(url.searchParams.get('lang'))
|
|
788
|
+
let parsed = parseSlides(slidesContent)
|
|
683
789
|
const editorMode = url.searchParams.get('editor') === '1'
|
|
684
790
|
// One switch selects the view: deck, reader, presenter or audience.
|
|
685
791
|
const defaultView = typeof __MDECK_DEFAULT_VIEW__ !== 'undefined' ? __MDECK_DEFAULT_VIEW__ : 'deck'
|
|
@@ -690,7 +796,7 @@ async function init() {
|
|
|
690
796
|
document.body.style.whiteSpace = 'pre-wrap'
|
|
691
797
|
return
|
|
692
798
|
}
|
|
693
|
-
const deckConfig = withConfigOverrides(parsed.deckConfig)
|
|
799
|
+
const deckConfig = withConfigOverrides(withFileLanguage(parsed.deckConfig))
|
|
694
800
|
setCalloutLabels(deckConfig)
|
|
695
801
|
setDeckLanguage(deckConfig)
|
|
696
802
|
document.documentElement.lang = deckLanguage()
|
|
@@ -718,11 +824,11 @@ async function init() {
|
|
|
718
824
|
s.textContent = `
|
|
719
825
|
.notes-md p { margin: 0 0 6px; }
|
|
720
826
|
.notes-md p:last-child { margin-bottom: 0; }
|
|
721
|
-
.notes-md ul, .notes-md ol { margin: 0 0 6px; padding-
|
|
827
|
+
.notes-md ul, .notes-md ol { margin: 0 0 6px; padding-inline-start: 1.4em; }
|
|
722
828
|
.notes-md li { margin-bottom: 2px; }
|
|
723
829
|
.notes-md strong, .notes-md b { color: #f0f0f0; font-weight: 600; }
|
|
724
830
|
.notes-md em, .notes-md i { font-style: italic; color: #bbb; }
|
|
725
|
-
.notes-md code { font-family: ui-monospace, monospace; font-size: 0.9em; background: #222; padding: 1px 4px; border-radius: 3px; }
|
|
831
|
+
.notes-md code { direction: ltr; unicode-bidi: isolate; font-family: ui-monospace, monospace; font-size: 0.9em; background: #222; padding: 1px 4px; border-radius: 3px; }
|
|
726
832
|
.notes-md a { color: #aaa; }
|
|
727
833
|
`
|
|
728
834
|
document.head.appendChild(s)
|
|
@@ -739,7 +845,32 @@ async function init() {
|
|
|
739
845
|
else if (ctrl.command === 'reset') stage.reset()
|
|
740
846
|
else if (ctrl.command === 'goTo' && Number.isInteger(ctrl.value)) stage.goTo(ctrl.value)
|
|
741
847
|
else if (ctrl.command === 'setState') stage.setState(ctrl.value)
|
|
848
|
+
else if (ctrl.command === 'setLanguage') switchLanguage(ctrl.value)
|
|
742
849
|
else if (ctrl.command === 'ink' && drawHere) stage.inking = ctrl.value === 'toggle' ? !stage.inking : !!ctrl.value
|
|
850
|
+
// Digits and Escape typed in the presenter view, and its hotspots switch.
|
|
851
|
+
else if (ctrl.command === 'hotspotKey' && typeof ctrl.value === 'string') stage.hotspotKey(ctrl.value)
|
|
852
|
+
else if (ctrl.command === 'hotspots') stage.hotspotsForced = ctrl.value
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// The same slide and step in another language, its words and mdeck's own;
|
|
856
|
+
// or `state`, where the screen that switched is: that screen tells the
|
|
857
|
+
// others, not the ones that follow it. Says whether it switched.
|
|
858
|
+
let announceLanguage = null
|
|
859
|
+
function switchLanguage(lang, { state = document.querySelector('deck-stage')?.state, announce = true } = {}) {
|
|
860
|
+
if (lang === deckLang || !chooseLanguage(lang)) return false
|
|
861
|
+
const stage = document.querySelector('deck-stage')
|
|
862
|
+
parsed = parseSlides(slidesContent)
|
|
863
|
+
const config = withConfigOverrides(withFileLanguage(parsed.deckConfig))
|
|
864
|
+
setCalloutLabels(config)
|
|
865
|
+
setDeckLanguage(config)
|
|
866
|
+
document.documentElement.lang = deckLanguage()
|
|
867
|
+
// Explicit slide ids may differ between the files; the place does not.
|
|
868
|
+
mountDeck({ deck: parsed, deckConfig: config })
|
|
869
|
+
if (state) setTimeout(() => document.querySelector('deck-stage')?.setState({ ...state, slideId: undefined }), 0)
|
|
870
|
+
if (!embedded && !followMode && stage) announceSlide(parsed, stage.index)
|
|
871
|
+
if (announce) announceLanguage?.()
|
|
872
|
+
if (embedded) window.parent.postMessage({ deckLanguage: lang }, window.location.origin)
|
|
873
|
+
return true
|
|
743
874
|
}
|
|
744
875
|
|
|
745
876
|
// Editor preview: the editor pushes whole sources; render best effort and
|
|
@@ -785,14 +916,38 @@ async function init() {
|
|
|
785
916
|
// Main slide frames and audience windows both send and follow navigation.
|
|
786
917
|
// Share their position connection with ink; previews stay on local channels.
|
|
787
918
|
if (inkStage && (drawHere || audienceMode)) {
|
|
788
|
-
followStageRoom(inkStage)
|
|
789
|
-
announceStagePosition(inkStage)
|
|
919
|
+
followStageRoom(inkStage, { onLanguage: (lang, state) => switchLanguage(lang, { state, announce: false }) })
|
|
920
|
+
announceLanguage = announceStagePosition(inkStage, { language: () => LANGUAGES.length ? deckLang : null })
|
|
790
921
|
}
|
|
922
|
+
// Whether another screen shows the deck, so that a video played in the
|
|
923
|
+
// presenter view is silent there and its sound comes from that screen
|
|
924
|
+
// (components/VideoPlayer.jsx). The server says who is connected; an
|
|
925
|
+
// audience window also says so to the windows of its own browser, for a
|
|
926
|
+
// deck opened without a server (a built folder).
|
|
927
|
+
const screens = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(`mdeck-screens:${inkStorageKey}`) : null
|
|
928
|
+
let watchedOnServer = false, audienceHere = 0
|
|
929
|
+
const markWatched = () => inkStage?.toggleAttribute('data-others-watching', watchedOnServer || Date.now() - audienceHere < SCREEN_GONE_MS)
|
|
930
|
+
if (screens && audienceMode) {
|
|
931
|
+
const say = message => screens.postMessage(message)
|
|
932
|
+
say('audience')
|
|
933
|
+
setInterval(() => say('audience'), SCREEN_SAY_MS)
|
|
934
|
+
window.addEventListener('pagehide', () => say('gone'))
|
|
935
|
+
} else if (screens && embedded && drawHere) {
|
|
936
|
+
screens.onmessage = ({ data }) => { audienceHere = data === 'audience' ? Date.now() : 0; markWatched() }
|
|
937
|
+
setInterval(markWatched, SCREEN_SAY_MS)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The presenter's slide shows the hotspots' keys; no screen the audience sees does.
|
|
941
|
+
if (inkStage && embedded && drawHere) inkStage.setAttribute('data-hotspot-badges', '')
|
|
791
942
|
if (inkStage && drawHere) {
|
|
792
943
|
attachInk(inkStage, {
|
|
793
944
|
storageKey: inkStorageKey,
|
|
794
945
|
// The presenter's main frame tells the presenter view who is connected.
|
|
795
|
-
transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence =>
|
|
946
|
+
transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence => {
|
|
947
|
+
watchedOnServer = (presence?.views ?? []).some(entry => entry.view === 'audience' || entry.view === 'deck')
|
|
948
|
+
markWatched()
|
|
949
|
+
window.parent.postMessage({ presence }, window.location.origin)
|
|
950
|
+
} : null })],
|
|
796
951
|
onMode: inking => { if (embedded) window.parent.postMessage({ inkMode: inking }, window.location.origin) },
|
|
797
952
|
})
|
|
798
953
|
// Drawing turns itself on, with the laser and the toolbar folded into
|
|
@@ -813,6 +968,7 @@ async function init() {
|
|
|
813
968
|
} else if (inkStage && followMode) {
|
|
814
969
|
// The presenter's slide and ink, read from the stage room; counted as a phone.
|
|
815
970
|
inkStage.setAttribute('data-follower', '')
|
|
971
|
+
onStagePosition(position => { if (position.lang) switchLanguage(position.lang, { announce: false }) })
|
|
816
972
|
startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
|
|
817
973
|
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
|
|
818
974
|
} else if (inkStage && audienceMode) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Zoom mode's camera: { x, y, w }, the world point at the centre of the view
|
|
2
|
+
// and the width of world the view shows.
|
|
3
|
+
//
|
|
4
|
+
// Flights follow van Wijk and Nuij's smooth zooming and panning path
|
|
5
|
+
// ("Smooth and efficient zooming and panning", 2003; d3's interpolateZoom):
|
|
6
|
+
// the camera rises in proportion to the distance, so a far jump shows how far
|
|
7
|
+
// it goes and a neighbouring slide is a short pan, at a steady perceived speed.
|
|
8
|
+
|
|
9
|
+
const RHO = Math.SQRT2
|
|
10
|
+
const RHO2 = 2
|
|
11
|
+
const RHO4 = 4
|
|
12
|
+
const EPSILON = 1e-12
|
|
13
|
+
|
|
14
|
+
// The camera that shows `rect` whole in a view of the given aspect (width /
|
|
15
|
+
// height), with `margin` of the larger side around it.
|
|
16
|
+
export function fitRect(rect, aspect, margin = 0) {
|
|
17
|
+
const w = Math.max(rect.w, rect.h * aspect) * (1 + 2 * margin)
|
|
18
|
+
return { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2, w }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The CSS transform (origin 0 0) that puts the camera's view in a viewport.
|
|
22
|
+
export function cameraTransform(camera, viewWidth, viewHeight) {
|
|
23
|
+
const k = viewWidth / camera.w
|
|
24
|
+
return { k, tx: viewWidth / 2 - camera.x * k, ty: viewHeight / 2 - camera.y * k }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cosh = x => (Math.exp(x) + Math.exp(-x)) / 2
|
|
28
|
+
const sinh = x => (Math.exp(x) - Math.exp(-x)) / 2
|
|
29
|
+
const tanh = x => { const e = Math.exp(2 * x); return (e - 1) / (e + 1) }
|
|
30
|
+
|
|
31
|
+
// `at(t)` for t in [0, 1] gives the camera along the path; `length` is the
|
|
32
|
+
// path's length in van Wijk's units (about one per doubling or halving).
|
|
33
|
+
export function flight(from, to) {
|
|
34
|
+
const dx = to.x - from.x, dy = to.y - from.y, d2 = dx * dx + dy * dy
|
|
35
|
+
const w0 = from.w, w1 = to.w
|
|
36
|
+
let at, length
|
|
37
|
+
if (d2 < EPSILON * w0 * w0) {
|
|
38
|
+
length = Math.log(w1 / w0) / RHO
|
|
39
|
+
at = t => ({ x: from.x + t * dx, y: from.y + t * dy, w: w0 * Math.exp(RHO * t * length) })
|
|
40
|
+
} else {
|
|
41
|
+
const d1 = Math.sqrt(d2)
|
|
42
|
+
const b0 = (w1 * w1 - w0 * w0 + RHO4 * d2) / (2 * w0 * RHO2 * d1)
|
|
43
|
+
const b1 = (w1 * w1 - w0 * w0 - RHO4 * d2) / (2 * w1 * RHO2 * d1)
|
|
44
|
+
const r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0)
|
|
45
|
+
const r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1)
|
|
46
|
+
length = (r1 - r0) / RHO
|
|
47
|
+
at = t => {
|
|
48
|
+
const s = t * length
|
|
49
|
+
const u = w0 / (RHO2 * d1) * (cosh(r0) * tanh(RHO * s + r0) - sinh(r0))
|
|
50
|
+
return { x: from.x + u * dx, y: from.y + u * dy, w: w0 * cosh(r0) / cosh(RHO * s + r0) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
length = Math.abs(length)
|
|
54
|
+
return { length, at: t => (t <= 0 ? { ...from } : t >= 1 ? { ...to } : at(t)) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A straight slide-over at the same zoom, for the next slide in a chapter:
|
|
58
|
+
// no rising and landing for a step to a neighbour. `length` is the distance
|
|
59
|
+
// in views, weighted so a neighbour takes about half a second.
|
|
60
|
+
export function pan(from, to) {
|
|
61
|
+
const ratio = to.w / from.w
|
|
62
|
+
const distance = Math.hypot(to.x - from.x, to.y - from.y) / Math.max(from.w, to.w)
|
|
63
|
+
const at = t => ({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t, w: from.w * Math.pow(ratio, t) })
|
|
64
|
+
return { length: distance * 0.75, at: t => (t <= 0 ? { ...from } : t >= 1 ? { ...to } : at(t)) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const MIN_MS = 450
|
|
68
|
+
export const MAX_MS = 1600
|
|
69
|
+
export const QUICK_MS = 200
|
|
70
|
+
|
|
71
|
+
// Milliseconds for a flight of this length.
|
|
72
|
+
export function durationFor(length) {
|
|
73
|
+
return Math.round(Math.min(MAX_MS, Math.max(MIN_MS, length * 650)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Slow at both ends, so a flight starts and lands gently.
|
|
77
|
+
export const ease = t => (1 - Math.cos(Math.PI * t)) / 2
|
|
File without changes
|