mdeck 3.7.1 → 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 +9 -0
- package/README.md +3 -3
- 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 +1 -1
- package/docs/site/content/presenting.md +45 -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/callout.css +10 -10
- package/src/components/citation.css +2 -2
- package/src/components/code-block.css +3 -3
- package/src/components/poll.css +6 -6
- package/src/components/video-player.css +1 -1
- package/src/core/editDeck.js +3 -2
- package/src/core/icons.js +1 -0
- package/src/core/languages.js +51 -0
- package/src/core/parseSlides.js +15 -3
- package/src/core/validateDeck.js +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 +12 -1
- package/src/runtime/ink/room.js +20 -8
- package/src/runtime/main.jsx +152 -22
- package/src/runtime/zoom/camera.js +77 -0
- /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
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
|
|
|
@@ -79,6 +81,24 @@ function withConfigOverrides(deckConfig) {
|
|
|
79
81
|
// The deck's text: the file's, or on the dev server with ?sample=1 the sample
|
|
80
82
|
// deck, which the design page's pictures show.
|
|
81
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
|
+
}
|
|
82
102
|
const hasActivities = () => roomsIn(slidesContent).length > 0
|
|
83
103
|
// What the phones show for the activity on a slide: its component's `phone`
|
|
84
104
|
// description, from the tag's attributes and the slide's heading.
|
|
@@ -185,8 +205,15 @@ function usePresence() {
|
|
|
185
205
|
return presence
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
function PresenterView({ deckConfig, slides }) {
|
|
208
|
+
function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
189
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])
|
|
190
217
|
const [theme, setTheme] = useState(deckConfig.theme ?? 'neue')
|
|
191
218
|
// '' means the theme's own default, for both.
|
|
192
219
|
const [palette, setPalette] = useState(deckConfig.palette ?? '')
|
|
@@ -194,6 +221,15 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
194
221
|
const [audienceConnected, setAudienceConnected] = useState(false)
|
|
195
222
|
const [noteSize, setNoteSize] = useState(13)
|
|
196
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'
|
|
197
233
|
const presence = usePresence()
|
|
198
234
|
// Drawing in the slide's frame: this page around it must not pan or bounce either.
|
|
199
235
|
useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
|
|
@@ -246,6 +282,8 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
246
282
|
const orderRef = useRef(null)
|
|
247
283
|
orderRef.current ??= createOrder()
|
|
248
284
|
const indexRef = useRef(0)
|
|
285
|
+
const langRef = useRef(lang)
|
|
286
|
+
langRef.current = lang
|
|
249
287
|
const stateRef = useRef({ index: 0, step: -1 })
|
|
250
288
|
const paletteDropRef = useRef(null)
|
|
251
289
|
indexRef.current = index
|
|
@@ -255,7 +293,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
255
293
|
useEffect(() => {
|
|
256
294
|
announceSlide({ slides, deckConfig }, index, { initial: !announcedOnce.current })
|
|
257
295
|
announcedOnce.current = true
|
|
258
|
-
}, [index])
|
|
296
|
+
}, [index, lang])
|
|
259
297
|
|
|
260
298
|
const themeMeta = THEME_METAS[theme]
|
|
261
299
|
// The palettes this theme offers, and what the deck shows right now.
|
|
@@ -289,17 +327,33 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
289
327
|
useEffect(() => {
|
|
290
328
|
function onMessage({ data, source }) {
|
|
291
329
|
if (source === previewRef.current?.contentWindow) {
|
|
292
|
-
|
|
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
|
+
}
|
|
293
336
|
return
|
|
294
337
|
}
|
|
295
338
|
if (source !== iframeRef.current?.contentWindow) return
|
|
296
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 }
|
|
297
342
|
if (!data?.deckStateChanged) return
|
|
298
343
|
const state = data.deckStateChanged
|
|
299
344
|
const i = state.index
|
|
300
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
|
+
}
|
|
301
354
|
if (data.reason === 'init' && stateRef.current.index === i) {
|
|
302
355
|
sendTo(iframeRef.current?.contentWindow, 'setState', stateRef.current)
|
|
356
|
+
keepLanguage()
|
|
303
357
|
return
|
|
304
358
|
}
|
|
305
359
|
stateRef.current = state
|
|
@@ -308,6 +362,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
308
362
|
bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
|
|
309
363
|
}
|
|
310
364
|
sendTo(previewRef.current?.contentWindow, 'goTo', i + 1)
|
|
365
|
+
keepLanguage()
|
|
311
366
|
}
|
|
312
367
|
window.addEventListener('message', onMessage)
|
|
313
368
|
// The frame may have started drawing before this listener was there.
|
|
@@ -328,6 +383,10 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
328
383
|
(e.key === 'd' || e.key === 'D') ? 'ink' : null
|
|
329
384
|
if ((e.key === 'f' || e.key === 'F') && fullscreen?.available()) { e.preventDefault(); fullscreen.toggle(); return }
|
|
330
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 }
|
|
331
390
|
if (!cmd) return
|
|
332
391
|
e.preventDefault()
|
|
333
392
|
sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
|
|
@@ -336,6 +395,13 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
336
395
|
return () => window.removeEventListener('keydown', onKey)
|
|
337
396
|
}, [])
|
|
338
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
|
+
|
|
339
405
|
// Escape to close theme modal
|
|
340
406
|
useEffect(() => {
|
|
341
407
|
if (!themeModalOpen) return
|
|
@@ -371,6 +437,19 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
371
437
|
bcRef.current?.postMessage({ deckControl: { command: 'setTheme', theme, palette, appearance } })
|
|
372
438
|
}, [theme, palette, appearance])
|
|
373
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
|
+
|
|
374
453
|
// Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
|
|
375
454
|
function navCommand(cmd) {
|
|
376
455
|
sendTo(iframeRef.current?.contentWindow, cmd)
|
|
@@ -408,6 +487,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
408
487
|
<button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
|
|
409
488
|
<button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
|
|
410
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>
|
|
411
491
|
<button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
|
|
412
492
|
{canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
|
|
413
493
|
<button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
|
|
@@ -479,7 +559,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
479
559
|
style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
|
|
480
560
|
/>
|
|
481
561
|
</div>
|
|
482
|
-
<div class="notes-md" style={{
|
|
562
|
+
<div class="notes-md" dir={textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined} style={{
|
|
483
563
|
flex: 1,
|
|
484
564
|
margin: 0,
|
|
485
565
|
padding: '10px 12px',
|
|
@@ -501,17 +581,17 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
501
581
|
</div>
|
|
502
582
|
|
|
503
583
|
{!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
|
|
504
|
-
<label style={{ flex: 1 }}>
|
|
584
|
+
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
505
585
|
<span style={S.label}>Theme</span>
|
|
506
586
|
<button
|
|
507
587
|
onClick={() => setThemeModalOpen(true)}
|
|
508
588
|
style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
|
|
509
589
|
>
|
|
510
|
-
<span>{theme}</span>
|
|
590
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{theme}</span>
|
|
511
591
|
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
|
|
512
592
|
</button>
|
|
513
593
|
</label>
|
|
514
|
-
<label style={{ flex: 1 }}>
|
|
594
|
+
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
515
595
|
<span style={S.label}>Palette</span>
|
|
516
596
|
<div ref={paletteDropRef} style={{ position: 'relative' }}>
|
|
517
597
|
<button
|
|
@@ -519,22 +599,22 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
519
599
|
onClick={() => setPaletteOpen(o => !o)}
|
|
520
600
|
style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
|
|
521
601
|
>
|
|
522
|
-
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
523
602
|
<PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
|
|
603
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
524
604
|
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
|
|
525
605
|
</button>
|
|
526
606
|
{paletteOpen && (
|
|
527
|
-
<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)' }}>
|
|
528
608
|
{offered.map(p => (
|
|
529
609
|
<div
|
|
530
610
|
key={p.id}
|
|
531
611
|
class="presenter-palette-option"
|
|
532
612
|
title={p.description}
|
|
533
613
|
onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
|
|
534
|
-
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' }}
|
|
535
615
|
>
|
|
536
|
-
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
537
616
|
<PaletteSwatches tokens={p[shown.appearance] ?? {}} />
|
|
617
|
+
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
538
618
|
</div>
|
|
539
619
|
))}
|
|
540
620
|
</div>
|
|
@@ -542,7 +622,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
542
622
|
</div>
|
|
543
623
|
</label>
|
|
544
624
|
<label style={{ flexShrink: 0 }}>
|
|
545
|
-
<span style={S.label}>
|
|
625
|
+
<span style={S.label}>Mode</span>
|
|
546
626
|
<div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
547
627
|
{['light', 'dark'].map(mode => (
|
|
548
628
|
<button
|
|
@@ -556,6 +636,20 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
556
636
|
))}
|
|
557
637
|
</div>
|
|
558
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>}
|
|
559
653
|
<button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
|
|
560
654
|
style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
|
|
561
655
|
><PresenterIcon name="fold" size={16} /></button>
|
|
@@ -573,6 +667,14 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
573
667
|
style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
|
|
574
668
|
onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
|
|
575
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>
|
|
576
678
|
{lookFolded && <button
|
|
577
679
|
class="presenter-look-unfold"
|
|
578
680
|
title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
|
|
@@ -595,7 +697,7 @@ function PresenterView({ deckConfig, slides }) {
|
|
|
595
697
|
</button>
|
|
596
698
|
</div>
|
|
597
699
|
<div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
|
|
598
|
-
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' : ''}
|
|
599
701
|
</div>
|
|
600
702
|
</div>
|
|
601
703
|
|
|
@@ -647,7 +749,7 @@ function mountDeck({ deck, deckConfig, selection = null, editor = false }) {
|
|
|
647
749
|
injectSpeakerNotes(slides)
|
|
648
750
|
const seen = new Map()
|
|
649
751
|
const app = (
|
|
650
|
-
<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')}>
|
|
651
753
|
{slides.map((slide, i) => {
|
|
652
754
|
const count = (seen.get(slide.id) ?? 0) + 1
|
|
653
755
|
seen.set(slide.id, count)
|
|
@@ -682,8 +784,8 @@ async function init() {
|
|
|
682
784
|
if (import.meta.env.DEV && url.searchParams.get('sample') === '1') {
|
|
683
785
|
const { sampleDeck, sampleDataUrl } = await import('../editor/sampleDeck.js')
|
|
684
786
|
slidesContent = sampleDeck(sampleDataUrl())
|
|
685
|
-
}
|
|
686
|
-
|
|
787
|
+
} else chooseLanguage(url.searchParams.get('lang'))
|
|
788
|
+
let parsed = parseSlides(slidesContent)
|
|
687
789
|
const editorMode = url.searchParams.get('editor') === '1'
|
|
688
790
|
// One switch selects the view: deck, reader, presenter or audience.
|
|
689
791
|
const defaultView = typeof __MDECK_DEFAULT_VIEW__ !== 'undefined' ? __MDECK_DEFAULT_VIEW__ : 'deck'
|
|
@@ -694,7 +796,7 @@ async function init() {
|
|
|
694
796
|
document.body.style.whiteSpace = 'pre-wrap'
|
|
695
797
|
return
|
|
696
798
|
}
|
|
697
|
-
const deckConfig = withConfigOverrides(parsed.deckConfig)
|
|
799
|
+
const deckConfig = withConfigOverrides(withFileLanguage(parsed.deckConfig))
|
|
698
800
|
setCalloutLabels(deckConfig)
|
|
699
801
|
setDeckLanguage(deckConfig)
|
|
700
802
|
document.documentElement.lang = deckLanguage()
|
|
@@ -722,11 +824,11 @@ async function init() {
|
|
|
722
824
|
s.textContent = `
|
|
723
825
|
.notes-md p { margin: 0 0 6px; }
|
|
724
826
|
.notes-md p:last-child { margin-bottom: 0; }
|
|
725
|
-
.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; }
|
|
726
828
|
.notes-md li { margin-bottom: 2px; }
|
|
727
829
|
.notes-md strong, .notes-md b { color: #f0f0f0; font-weight: 600; }
|
|
728
830
|
.notes-md em, .notes-md i { font-style: italic; color: #bbb; }
|
|
729
|
-
.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; }
|
|
730
832
|
.notes-md a { color: #aaa; }
|
|
731
833
|
`
|
|
732
834
|
document.head.appendChild(s)
|
|
@@ -743,7 +845,32 @@ async function init() {
|
|
|
743
845
|
else if (ctrl.command === 'reset') stage.reset()
|
|
744
846
|
else if (ctrl.command === 'goTo' && Number.isInteger(ctrl.value)) stage.goTo(ctrl.value)
|
|
745
847
|
else if (ctrl.command === 'setState') stage.setState(ctrl.value)
|
|
848
|
+
else if (ctrl.command === 'setLanguage') switchLanguage(ctrl.value)
|
|
746
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
|
|
747
874
|
}
|
|
748
875
|
|
|
749
876
|
// Editor preview: the editor pushes whole sources; render best effort and
|
|
@@ -789,8 +916,8 @@ async function init() {
|
|
|
789
916
|
// Main slide frames and audience windows both send and follow navigation.
|
|
790
917
|
// Share their position connection with ink; previews stay on local channels.
|
|
791
918
|
if (inkStage && (drawHere || audienceMode)) {
|
|
792
|
-
followStageRoom(inkStage)
|
|
793
|
-
announceStagePosition(inkStage)
|
|
919
|
+
followStageRoom(inkStage, { onLanguage: (lang, state) => switchLanguage(lang, { state, announce: false }) })
|
|
920
|
+
announceLanguage = announceStagePosition(inkStage, { language: () => LANGUAGES.length ? deckLang : null })
|
|
794
921
|
}
|
|
795
922
|
// Whether another screen shows the deck, so that a video played in the
|
|
796
923
|
// presenter view is silent there and its sound comes from that screen
|
|
@@ -810,6 +937,8 @@ async function init() {
|
|
|
810
937
|
setInterval(markWatched, SCREEN_SAY_MS)
|
|
811
938
|
}
|
|
812
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', '')
|
|
813
942
|
if (inkStage && drawHere) {
|
|
814
943
|
attachInk(inkStage, {
|
|
815
944
|
storageKey: inkStorageKey,
|
|
@@ -839,6 +968,7 @@ async function init() {
|
|
|
839
968
|
} else if (inkStage && followMode) {
|
|
840
969
|
// The presenter's slide and ink, read from the stage room; counted as a phone.
|
|
841
970
|
inkStage.setAttribute('data-follower', '')
|
|
971
|
+
onStagePosition(position => { if (position.lang) switchLanguage(position.lang, { announce: false }) })
|
|
842
972
|
startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
|
|
843
973
|
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
|
|
844
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
|