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.
Files changed (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -4
  3. package/assets/base.css +9 -5
  4. package/assets/extensions/themes/academic/styles.css +22 -22
  5. package/assets/extensions/themes/glass/styles.css +17 -17
  6. package/assets/extensions/themes/minimal/styles.css +14 -14
  7. package/assets/extensions/themes/neue/styles.css +14 -14
  8. package/assets/extensions/themes/plain/styles.css +17 -17
  9. package/assets/extensions/themes/work/styles.css +26 -26
  10. package/docs/reference/authoring.md +12 -0
  11. package/docs/site/content/drawing.md +6 -2
  12. package/docs/site/content/media.md +3 -1
  13. package/docs/site/content/presenting.md +47 -0
  14. package/examples/python/slides.de.md +493 -0
  15. package/examples/python/{slides.md → slides.en.md} +1 -1
  16. package/examples/python/slides.fa.md +492 -0
  17. package/examples/python/slides.hi.md +492 -0
  18. package/examples/showcase/slides.md +33 -0
  19. package/package.json +1 -1
  20. package/skills/write-slides/SKILL.md +1 -0
  21. package/src/build/config.js +1 -1
  22. package/src/build/slidesPlugin.js +34 -4
  23. package/src/components/VideoPlayer.jsx +70 -6
  24. package/src/components/callout.css +10 -10
  25. package/src/components/citation.css +2 -2
  26. package/src/components/code-block.css +3 -3
  27. package/src/components/poll.css +6 -6
  28. package/src/components/video-player.css +15 -0
  29. package/src/core/editDeck.js +3 -2
  30. package/src/core/icons.js +1 -0
  31. package/src/core/labels.js +2 -0
  32. package/src/core/languages.js +51 -0
  33. package/src/core/parseSlides.js +15 -3
  34. package/src/core/validateDeck.js +5 -0
  35. package/src/editor/components/DeckSettings.jsx +4 -0
  36. package/src/editor/components/MetaFields.jsx +7 -0
  37. package/src/layouts/layoutApi.jsx +7 -1
  38. package/src/live/answer/answer.css +2 -2
  39. package/src/runtime/chrome.jsx +6 -7
  40. package/src/runtime/deck-stage.js +249 -11
  41. package/src/runtime/hotspots.js +98 -0
  42. package/src/runtime/ink/bus.js +21 -1
  43. package/src/runtime/ink/room.js +20 -8
  44. package/src/runtime/main.jsx +179 -23
  45. package/src/runtime/zoom/camera.js +77 -0
  46. /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
@@ -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
- if (data?.deckStateChanged && data.reason === 'init') sendTo(source, 'goTo', indexRef.current + 1)
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, right: 0, background: '#1a1a1a', border: '1px solid #2e2e2e', borderRadius: '5px', zIndex: 100, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)' }}>
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', justifyContent: 'space-between', gap: '6px', fontSize: '13px', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent' }}
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}>Light or dark</span>
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
- const parsed = parseSlides(slidesContent)
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-left: 1.4em; }
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 => window.parent.postMessage({ presence }, window.location.origin) : null })],
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