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.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -3
  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 +1 -1
  12. package/docs/site/content/presenting.md +45 -0
  13. package/examples/python/slides.de.md +493 -0
  14. package/examples/python/{slides.md → slides.en.md} +1 -1
  15. package/examples/python/slides.fa.md +492 -0
  16. package/examples/python/slides.hi.md +492 -0
  17. package/examples/showcase/slides.md +33 -0
  18. package/package.json +1 -1
  19. package/skills/write-slides/SKILL.md +1 -0
  20. package/src/build/config.js +1 -1
  21. package/src/build/slidesPlugin.js +34 -4
  22. package/src/components/callout.css +10 -10
  23. package/src/components/citation.css +2 -2
  24. package/src/components/code-block.css +3 -3
  25. package/src/components/poll.css +6 -6
  26. package/src/components/video-player.css +1 -1
  27. package/src/core/editDeck.js +3 -2
  28. package/src/core/icons.js +1 -0
  29. package/src/core/languages.js +51 -0
  30. package/src/core/parseSlides.js +15 -3
  31. package/src/core/validateDeck.js +5 -0
  32. package/src/editor/components/DeckSettings.jsx +4 -0
  33. package/src/editor/components/MetaFields.jsx +7 -0
  34. package/src/layouts/layoutApi.jsx +7 -1
  35. package/src/live/answer/answer.css +2 -2
  36. package/src/runtime/chrome.jsx +6 -7
  37. package/src/runtime/deck-stage.js +249 -11
  38. package/src/runtime/hotspots.js +98 -0
  39. package/src/runtime/ink/bus.js +12 -1
  40. package/src/runtime/ink/room.js +20 -8
  41. package/src/runtime/main.jsx +152 -22
  42. package/src/runtime/zoom/camera.js +77 -0
  43. /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
 
@@ -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
- 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
+ }
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, 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)' }}>
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', 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' }}
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}>Light or dark</span>
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
- const parsed = parseSlides(slidesContent)
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-left: 1.4em; }
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