mdeck 3.10.0 → 3.11.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 (65) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +13 -2
  3. package/assets/base.css +4 -1
  4. package/docs/reference/authoring.md +66 -5
  5. package/docs/reference/extensions.md +1 -1
  6. package/docs/reference/themes.md +2 -1
  7. package/docs/site/assets/site.css +2 -0
  8. package/docs/site/content/commands.md +3 -1
  9. package/docs/site/content/drawing.md +1 -1
  10. package/docs/site/content/install.md +47 -0
  11. package/docs/site/content/media.md +20 -1
  12. package/docs/site/content/presenting.md +39 -2
  13. package/docs/site/content/sharing.md +1 -0
  14. package/docs/site/content/theme-authoring.md +2 -1
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/images/features/hotspots.webp +0 -0
  17. package/docs/site/images/features/languages.webp +0 -0
  18. package/docs/site/pages.js +1 -0
  19. package/examples/custom-layouts/slides.md +21 -9
  20. package/examples/showcase/refs.bib +21 -0
  21. package/examples/showcase/slides.ar.drawings.json +11 -0
  22. package/examples/showcase/slides.ar.md +567 -0
  23. package/examples/showcase/slides.de.drawings.json +11 -0
  24. package/examples/showcase/slides.de.md +567 -0
  25. package/examples/showcase/slides.en.drawings.json +11 -0
  26. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  27. package/package.json +1 -1
  28. package/skills/write-slides/SKILL.md +10 -1
  29. package/src/build/check.js +7 -1
  30. package/src/build/config.js +2 -0
  31. package/src/build/slidesPlugin.js +6 -6
  32. package/src/build/svgCheck.js +65 -0
  33. package/src/build/timingPlugin.js +80 -0
  34. package/src/cli/args.js +4 -3
  35. package/src/cli/index.js +34 -11
  36. package/src/components/VideoPlayer.jsx +6 -1
  37. package/src/core/editDeck.js +44 -7
  38. package/src/core/icons.js +3 -0
  39. package/src/core/labels.js +75 -0
  40. package/src/core/parseSlides.js +3 -3
  41. package/src/core/timing.js +83 -0
  42. package/src/core/validateDeck.js +5 -0
  43. package/src/editor/components/DeckSettings.jsx +5 -4
  44. package/src/editor/components/ExtensionForms.jsx +1 -1
  45. package/src/editor/components/MetaFields.jsx +1 -0
  46. package/src/extensions/params.js +28 -1
  47. package/src/home/Home.jsx +1 -0
  48. package/src/layouts/layoutApi.jsx +51 -8
  49. package/src/runtime/RehearsalSummary.jsx +129 -0
  50. package/src/runtime/component.js +27 -0
  51. package/src/runtime/deck-stage.js +49 -20
  52. package/src/runtime/ink/bus.js +13 -5
  53. package/src/runtime/ink/outbox.js +27 -0
  54. package/src/runtime/ink/room.js +31 -6
  55. package/src/runtime/ink/segments.js +21 -0
  56. package/src/runtime/main.jsx +157 -28
  57. package/src/runtime/shared.js +77 -0
  58. package/src/runtime/steps.js +36 -0
  59. package/src/runtime/useTalkTimer.js +65 -0
  60. package/examples/citations/refs.bib +0 -30
  61. package/examples/citations/refs.yml +0 -21
  62. package/examples/citations/slides.md +0 -43
  63. package/examples/citations/slides.pdf +0 -0
  64. package/examples/poll/slides.md +0 -127
  65. package/examples/showcase/slides.drawings.json +0 -11
@@ -36,6 +36,11 @@ import { Icon } from '../components/Icon.jsx'
36
36
  import { ConnectedViews } from '../components/ConnectedViews.jsx'
37
37
  import './deck-stage.js'
38
38
  import { normalMode, hotspotMode } from './hotspots.js'
39
+ import { useTalkTimer } from './useTalkTimer.js'
40
+ import { devPath } from '../core/devPath.js'
41
+ import { setView } from './shared.js'
42
+ import { RehearsalSummary } from './RehearsalSummary.jsx'
43
+ import { planFor, scheduleAt, formatClock } from '../core/timing.js'
39
44
 
40
45
  const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
41
46
 
@@ -99,7 +104,23 @@ const withFileLanguage = config => config.lang || !deckLang ? config : { ...conf
99
104
  function languageName(lang) {
100
105
  try { return new Intl.DisplayNames([lang], { type: 'language' }).of(lang) } catch { return lang }
101
106
  }
102
- const hasActivities = () => roomsIn(slidesContent).length > 0
107
+ // Asked several times on every slide change (presenter view, the phones'
108
+ // announcement, the views connected), so the answer is kept until the text
109
+ // changes: finding the tags reads the whole deck, which on an iPad made each
110
+ // slide change noticeably slower.
111
+ let activitiesOf = null, activitiesFound = false
112
+ const hasActivities = () => {
113
+ if (activitiesOf !== slidesContent) { activitiesOf = slidesContent; activitiesFound = roomsIn(slidesContent).length > 0 }
114
+ return activitiesFound
115
+ }
116
+ // The heading the phones show for a room, found once per deck.
117
+ const titlesByDeck = new WeakMap()
118
+ function titleFor(deck, room) {
119
+ let titles = titlesByDeck.get(deck)
120
+ if (!titles) titlesByDeck.set(deck, titles = new Map())
121
+ if (!titles.has(room)) titles.set(room, slideTitleFor(deck, room))
122
+ return titles.get(room)
123
+ }
103
124
  // What the phones show for the activity on a slide: its component's `phone`
104
125
  // description, from the tag's attributes and the slide's heading.
105
126
  function activityOn(deck, slide) {
@@ -110,7 +131,7 @@ function activityOn(deck, slide) {
110
131
  const component = element && registry[element.localName]
111
132
  if (typeof component?.phone !== 'function') return { room, activity: null }
112
133
  const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
113
- try { return { room, activity: component.phone(props, { slideTitle: slideTitleFor(deck, room) }) } } catch (error) { console.warn(error); return { room, activity: null } }
134
+ try { return { room, activity: component.phone(props, { slideTitle: titleFor(deck, room) }) } } catch (error) { console.warn(error); return { room, activity: null } }
114
135
  }
115
136
  function announceSlide(deck, index, { initial = false } = {}) {
116
137
  if (hasActivities()) announce({ ...activityOn(deck, deck.slides[index]), title: deck.deckConfig?.meta?.title ?? '', initial })
@@ -205,6 +226,55 @@ function usePresence() {
205
226
  return presence
206
227
  }
207
228
 
229
+ // The launch page of `mdeck run`, when this computer's presenter view was
230
+ // served by it; a built deck, `mdeck edit` and a paired iPad have none.
231
+ function useLaunchPage() {
232
+ const [url, setUrl] = useState(null)
233
+ useEffect(() => {
234
+ if (!import.meta.env.DEV) return
235
+ fetch(devPath('__mdeck/home/info'), { cache: 'no-store' }).then(response => { if (response.ok) setUrl(devPath('home.html')) }, () => {})
236
+ }, [])
237
+ return url
238
+ }
239
+
240
+ // The slide number as a field: type a number and press Enter (Go on an
241
+ // iPad's keyboard) to jump there. Escape, or leaving it empty, keeps the slide.
242
+ function SlideJump({ index, total, onJump, pad = 0, style }) {
243
+ const [draft, setDraftState] = useState(null)
244
+ // Blur follows Enter and Escape before a re-render, so it reads the ref.
245
+ const draftRef = useRef(null)
246
+ const setDraft = value => { draftRef.current = value; setDraftState(value) }
247
+ const shown = draft ?? String(index + 1).padStart(pad, '0')
248
+ function commit() {
249
+ const n = Number.parseInt(draftRef.current ?? '', 10)
250
+ setDraft(null)
251
+ if (Number.isInteger(n) && n - 1 !== index) onJump(Math.max(1, Math.min(total, n)) - 1)
252
+ }
253
+ return (
254
+ <form class="presenter-jump" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', margin: 0, ...style }}
255
+ onSubmit={e => { e.preventDefault(); commit(); e.currentTarget.elements.slide.blur() }}>
256
+ <input
257
+ name="slide" type="text" inputMode="numeric" pattern="[0-9]*" enterKeyHint="go" autoComplete="off"
258
+ title="Go to slide: type its number and press Enter" aria-label={`Slide number, 1 to ${total}`}
259
+ value={shown}
260
+ size={Math.max(2, String(total).length)}
261
+ onFocus={e => e.currentTarget.select()}
262
+ onInput={e => setDraft(e.currentTarget.value.replace(/\D/g, ''))}
263
+ onKeyDown={e => {
264
+ if (e.key !== 'Escape') return
265
+ setDraft(null)
266
+ // Typing and Escape can land in one render, which then changes nothing.
267
+ e.currentTarget.value = String(index + 1).padStart(pad, '0')
268
+ e.currentTarget.blur()
269
+ }}
270
+ onBlur={() => { if (draftRef.current !== null) commit() }}
271
+ style={{ ...S.btn, cursor: 'text', padding: '2px 4px', width: `${Math.max(2, String(total).length) + 1.5}ch`, textAlign: 'center', fontVariantNumeric: 'tabular-nums', color: 'inherit' }}
272
+ />
273
+ <span>/ {String(total).padStart(pad, '0')}</span>
274
+ </form>
275
+ )
276
+ }
277
+
208
278
  function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
209
279
  const [index, setIndex] = useState(0)
210
280
  // The notes, slide count and phones follow the language shown.
@@ -231,6 +301,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
231
301
  // Whether the number keys zoom on the slide shown: a slide may have its own setting.
232
302
  const slideHotspots = hotspotMode({ slide: slides[index]?.meta.hotspots, deck: deckConfig.hotspots, forced: hotspotsForced }) !== 'off'
233
303
  const presence = usePresence()
304
+ const launchPage = useLaunchPage()
234
305
  // Drawing in the slide's frame: this page around it must not pan or bounce either.
235
306
  useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
236
307
  // The slide zooms itself (two fingers in its frame); Safari's page zoom
@@ -270,9 +341,33 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
270
341
  try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
271
342
  }
272
343
  const [themeModalOpen, setThemeModalOpen] = useState(false)
273
- const [elapsed, setElapsed] = useState(0)
274
- const [timerRunning, setTimerRunning] = useState(false)
275
- const timerRef = useRef(null)
344
+ // The clock, and the slides' times (`time:`) to hold it against. Times a
345
+ // rehearsal saved count at once; the deck file is not read again mid-talk.
346
+ const [savedTimes, setSavedTimes] = useState({})
347
+ const plan = useMemo(() => planFor(slides, deckConfig, savedTimes), [slides, deckConfig, savedTimes])
348
+ const timer = useTalkTimer(`mdeck-timer:${DECK_CHANNEL}`, slides[index]?.id, { rehearse: controlUrl.searchParams.get('rehearse') === '1' })
349
+ const [summary, setSummary] = useState(null)
350
+ const [savedNote, setSavedNote] = useState(null)
351
+ // A reload after the rehearsal must not start another one.
352
+ useEffect(() => {
353
+ if (!controlUrl.searchParams.has('rehearse')) return
354
+ const url = new URL(window.location.href)
355
+ url.searchParams.delete('rehearse')
356
+ history.replaceState(null, '', url)
357
+ }, [])
358
+ function finishRehearsal() {
359
+ timer.pause()
360
+ setSummary(timer.spent())
361
+ }
362
+ const timerRef = useRef(timer)
363
+ timerRef.current = timer
364
+ // Next at the very end of the deck ends a rehearsal.
365
+ const atEndRef = useRef(false)
366
+ const atRehearsalEnd = () => timerRef.current.rehearsing && atEndRef.current
367
+ const finishRehearsalRef = useRef(finishRehearsal)
368
+ finishRehearsalRef.current = finishRehearsal
369
+ const summaryRef = useRef(false)
370
+ summaryRef.current = !!summary
276
371
 
277
372
  const iframeRef = useRef(null)
278
373
  const previewRef = useRef(null)
@@ -357,6 +452,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
357
452
  return
358
453
  }
359
454
  stateRef.current = state
455
+ atEndRef.current = !!data.atEnd
360
456
  setIndex(i)
361
457
  if (data.reason !== 'sync') {
362
458
  bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
@@ -376,6 +472,8 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
376
472
  function onKey(e) {
377
473
  if (e.target?.matches?.('input, select, textarea')) return
378
474
  if (e.metaKey || e.ctrlKey || e.altKey) return
475
+ if (summaryRef.current) return
476
+ if (e.key === 't' || e.key === 'T') { e.preventDefault(); timerRef.current.toggle(); return }
379
477
  const cmd =
380
478
  (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
381
479
  (e.key === 'ArrowLeft' || e.key === 'PageUp') ? 'prev' :
@@ -389,6 +487,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
389
487
  if (/^[0-9]$/.test(e.key) || e.key === 'Escape') { if (e.key !== 'Escape') e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'hotspotKey', e.key); return }
390
488
  if (!cmd) return
391
489
  e.preventDefault()
490
+ if (cmd === 'next' && atRehearsalEnd()) { finishRehearsalRef.current(); return }
392
491
  sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
393
492
  }
394
493
  window.addEventListener('keydown', onKey)
@@ -412,15 +511,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
412
511
  return () => window.removeEventListener('keydown', handler)
413
512
  }, [themeModalOpen])
414
513
 
415
- useEffect(() => {
416
- if (timerRunning) {
417
- timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000)
418
- } else {
419
- clearInterval(timerRef.current)
420
- }
421
- return () => clearInterval(timerRef.current)
422
- }, [timerRunning])
423
-
424
514
  // Close palette dropdown on outside click
425
515
  useEffect(() => {
426
516
  if (!paletteOpen) return
@@ -451,8 +541,9 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
451
541
  const nextLanguage = () => setLang(current => LANGUAGES[(LANGUAGES.indexOf(current) + 1) % LANGUAGES.length])
452
542
 
453
543
  // Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
454
- function navCommand(cmd) {
455
- sendTo(iframeRef.current?.contentWindow, cmd)
544
+ function navCommand(cmd, value) {
545
+ if (cmd === 'next' && atRehearsalEnd()) return finishRehearsal()
546
+ sendTo(iframeRef.current?.contentWindow, cmd, value)
456
547
  }
457
548
 
458
549
  function openAudienceWindow() {
@@ -468,7 +559,15 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
468
559
  const note = notes[index] || ''
469
560
  const hasNext = index + 1 < slides.length
470
561
 
471
- const clock = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`
562
+ const clock = formatClock(timer.totalElapsed).padStart(5, '0')
563
+ // With times in the deck: what is left of this slide, and how the talk
564
+ // stands against the plan (behind when positive).
565
+ const timed = plan.total > 0
566
+ const { planned, slideLeft, delta } = scheduleAt(plan, index, timer)
567
+ const slideColor = !planned ? '#888' : slideLeft <= 0 ? '#f28b82' : slideLeft <= 15 ? '#f5a524' : '#ccc'
568
+ const slideText = !planned ? 'no time' : slideLeft > 0 ? `${formatClock(slideLeft)} left` : `+${formatClock(-slideLeft)} over`
569
+ const deltaText = Math.abs(delta) < 30 ? 'on time' : delta > 0 ? `${formatClock(delta)} behind` : `${formatClock(-delta)} ahead`
570
+ const deltaColor = Math.abs(delta) < 30 ? '#888' : delta > 0 ? '#f28b82' : '#81c995'
472
571
  const pill = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', fontSize: '15px' }
473
572
 
474
573
  return (
@@ -481,9 +580,12 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
481
580
  />
482
581
  {slideLayout && (
483
582
  <div class="presenter-pill" role="toolbar" aria-label="Presenter" style={{ position: 'fixed', top: 'max(10px, env(safe-area-inset-top))', right: 'max(10px, env(safe-area-inset-right))', zIndex: 20, display: 'flex', gap: '4px', alignItems: 'center', padding: '4px', borderRadius: '12px', background: 'rgba(17,17,17,0.88)', border: '1px solid #2a2a2a', color: '#ccc', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', fontVariantNumeric: 'tabular-nums' }}>
484
- <span style={{ padding: '0 8px', color: '#888' }}>{index + 1}/{slides.length}</span>
583
+ <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ padding: '0 4px', color: '#888' }} />
485
584
  <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888', padding: '0 6px' }} />
486
- <button style={{ ...pill, color: timerRunning ? '#f0f0f0' : '#777' }} title="Start or pause the timer" onClick={() => setTimerRunning(r => !r)}>{clock}</button>
585
+ {timer.rehearsing && <button class="presenter-rehearsing" style={{ ...pill, color: '#f28b82' }} title="Finish the rehearsal" onClick={finishRehearsal}>● Finish</button>}
586
+ <button class="presenter-clock" style={{ ...pill, color: timer.running ? '#f0f0f0' : '#777', gap: '8px' }} title="Start or pause the timer (T)" onClick={timer.toggle}>
587
+ {clock}{timed && <span class="presenter-slide-time" style={{ color: slideColor, fontSize: '12px' }}>{slideText}</span>}
588
+ </button>
487
589
  <button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
488
590
  <button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
489
591
  <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>
@@ -491,6 +593,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
491
593
  <button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
492
594
  {canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
493
595
  <button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
596
+ {launchPage && <a class="presenter-home" href={launchPage} style={{ ...pill, boxSizing: 'border-box', textDecoration: 'none' }} title="Back to the launch page" aria-label="Back to the launch page"><PresenterIcon name="home" /></a>}
494
597
  </div>
495
598
  )}
496
599
  <aside class="presenter-aside" style={{
@@ -514,26 +617,36 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
514
617
  </span>
515
618
  <span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
516
619
  {!slideLayout && canFullscreen && <button class="presenter-fullscreen" style={{ ...S.btn, padding: '3px 8px' }} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
620
+ {!slideLayout && launchPage && <a class="presenter-home" href={launchPage} style={{ ...S.btn, boxSizing: 'border-box', padding: '3px 8px', textDecoration: 'none' }} title="Back to the launch page" aria-label="Back to the launch page"><PresenterIcon name="home" /></a>}
517
621
  {!slideLayout && <button class="presenter-layout" style={{ ...S.btn, padding: '3px 8px' }} title="Slide only, notes in a drawer (for an iPad)" onClick={() => chooseLayout('slide')}><PresenterIcon name="tablet" /></button>}
518
- <span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>
519
- {String(index + 1).padStart(2, '0')} / {String(slides.length).padStart(2, '0')}
520
- </span>
622
+ <SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }} />
521
623
  </span>
522
624
  </div>
523
625
 
524
626
  <SteeringNote />
525
627
 
526
628
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
527
- <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timerRunning ? '#f0f0f0' : '#555', fontWeight: 300 }}>
528
- {String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
629
+ <span style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
630
+ <span class="presenter-clock" style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timer.running ? '#f0f0f0' : '#555', fontWeight: 300 }}>{clock}</span>
631
+ {timed && <span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>of {formatClock(plan.total)}</span>}
529
632
  </span>
530
633
  <div style={{ display: 'flex', gap: '4px' }}>
531
- <button style={S.btn} title={timerRunning ? 'Pause the timer' : 'Start the timer'} aria-label={timerRunning ? 'Pause the timer' : 'Start the timer'} onClick={() => setTimerRunning(r => !r)}>
532
- <PresenterIcon name={timerRunning ? 'pause' : 'play'} size={16} />
634
+ <button style={S.btn} title={timer.running ? 'Pause the timer (T)' : 'Start the timer (T)'} aria-label={timer.running ? 'Pause the timer' : 'Start the timer'} onClick={timer.toggle}>
635
+ <PresenterIcon name={timer.running ? 'pause' : 'play'} size={16} />
533
636
  </button>
534
- <button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={() => { setTimerRunning(false); setElapsed(0) }}><PresenterIcon name="reset" size={16} /></button>
637
+ <button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={timer.reset}><PresenterIcon name="reset" size={16} /></button>
638
+ {!timer.rehearsing && <button class="presenter-rehearse" style={S.btn} title="Rehearse: time each slide, then save the times into the deck" onClick={timer.startRehearsal}><PresenterIcon name="rehearse" size={16} />Rehearse</button>}
535
639
  </div>
536
640
  </div>
641
+ {timed && <div class="presenter-schedule" style={{ display: 'flex', justifyContent: 'space-between', flexShrink: 0, marginTop: '-8px', fontVariantNumeric: 'tabular-nums' }}>
642
+ <span>Slide <span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span>{planned ? <span style={{ color: '#555' }}> of {formatClock(planned)}</span> : null}</span>
643
+ <span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span>
644
+ </div>}
645
+ {timer.rehearsing && <div class="presenter-rehearsing" role="status" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, padding: '6px 10px', borderRadius: '5px', border: '1px solid #5c2b28', color: '#f28b82' }}>
646
+ <span>● Rehearsing{timer.running ? '' : ' (paused)'}: this slide {formatClock(timer.slideElapsed)}</span>
647
+ <button style={{ ...S.btn, padding: '2px 10px' }} onClick={finishRehearsal}>Finish</button>
648
+ </div>}
649
+ {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
537
650
 
538
651
  {hasNext && (
539
652
  <div style={{ flexShrink: 0 }}>
@@ -697,12 +810,26 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
697
810
  </button>
698
811
  </div>
699
812
  <div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
700
- Arrow keys · Space · PgUp/PgDn · R to reset · H hotspots{slideHotspots ? ' · 1–9 zoom · 0 whole slide' : ''}
813
+ Arrow keys · Space · PgUp/PgDn · R to reset · T timer · H hotspots{slideHotspots ? ' · 1–9 zoom · 0 whole slide' : ''}
701
814
  </div>
702
815
  </div>
703
816
 
704
817
  </aside>
705
818
 
819
+ {summary && <RehearsalSummary
820
+ slides={slides}
821
+ plan={plan}
822
+ spent={summary}
823
+ onClose={() => { setSummary(null); timer.toggle() }}
824
+ onDiscard={() => { setSummary(null); timer.endRehearsal() }}
825
+ onSaved={(times, changed) => {
826
+ setSummary(null)
827
+ timer.endRehearsal()
828
+ setSavedTimes(saved => ({ ...saved, ...Object.fromEntries(Object.entries(times).map(([id, t]) => [id, t ?? undefined])) }))
829
+ setSavedNote(`Saved the times of ${changed} slide${changed === 1 ? '' : 's'} into the deck.`)
830
+ setTimeout(() => setSavedNote(null), 6000)
831
+ }}
832
+ />}
706
833
  {themeModalOpen && (
707
834
  <div
708
835
  style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
@@ -812,6 +939,8 @@ async function init() {
812
939
  // The deck window and the presenter's main frame draw; the audience window
813
940
  // and the next-slide preview show what is drawn.
814
941
  const drawHere = !audienceMode && !followMode && (!embedded || url.searchParams.get('draw') === '1')
942
+ // What deck components learn from useView() (component.js).
943
+ setView(audienceMode ? 'audience' : followMode ? 'follow' : readerMode ? 'reader' : embedded ? (drawHere && !editorMode ? 'presenter' : 'preview') : 'deck')
815
944
  const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
816
945
 
817
946
  injectSpeakerNotes(slides)
@@ -0,0 +1,77 @@
1
+ // State that deck components share across slides and across the windows of
2
+ // one talk: the presenter's slide frame, the audience window, other deck
3
+ // windows in this browser, and a paired iPad. Components reach it through
4
+ // `mdeck/component` (component.js). Values travel on the ink bus (ink/bus.js)
5
+ // as `shared` messages; nothing is saved, and a window that opens later asks
6
+ // the others for what they hold (`shared-ask`). A deck whose components use
7
+ // none of it sends nothing.
8
+ //
9
+ // The latest change wins as it arrives. A value replayed for a window that
10
+ // opened later only fills a name that window does not know yet, so no clocks
11
+ // are compared between devices.
12
+
13
+ const values = new Map()
14
+ const listeners = new Map()
15
+ let send = null
16
+ let asked = false
17
+ let view = 'deck'
18
+
19
+ // Windows that only show the talk read shared state but do not change it.
20
+ const READ_ONLY = new Set(['preview', 'reader', 'follow'])
21
+
22
+ /** The kind of window this is: presenter, audience, deck, follow, preview or reader. */
23
+ export function setView(next) { view = next }
24
+ export function currentView() { return view }
25
+
26
+ function notify(name) {
27
+ for (const listener of listeners.get(name) ?? []) listener(values.get(name))
28
+ }
29
+
30
+ // Asks the others once, when this window has both a bus and a component
31
+ // that uses shared state.
32
+ function ask() {
33
+ if (asked || !send || !listeners.size) return
34
+ asked = true
35
+ send({ type: 'shared-ask' })
36
+ }
37
+
38
+ /** The bus this window sends on. */
39
+ export function connectShared(sender) {
40
+ send = sender
41
+ ask()
42
+ }
43
+
44
+ /** A `shared` or `shared-ask` message from another window. */
45
+ export function receiveShared(message) {
46
+ if (message.type === 'shared-ask') {
47
+ for (const [name, value] of values) send?.({ type: 'shared', name, value, replay: true })
48
+ return
49
+ }
50
+ if (typeof message.name !== 'string') return
51
+ if (message.replay && values.has(message.name)) return
52
+ values.set(message.name, message.value)
53
+ notify(message.name)
54
+ }
55
+
56
+ export function getShared(name) { return values.get(name) }
57
+
58
+ /** Sets a value here and in every other window; ignored where the talk is only shown. */
59
+ export function setShared(name, value) {
60
+ if (READ_ONLY.has(view)) return false
61
+ // Only what survives JSON reaches the other windows; keep this one the same.
62
+ const plain = value === undefined ? null : JSON.parse(JSON.stringify(value))
63
+ values.set(name, plain)
64
+ notify(name)
65
+ send?.({ type: 'shared', name, value: plain })
66
+ return true
67
+ }
68
+
69
+ export function subscribeShared(name, listener) {
70
+ if (!listeners.has(name)) listeners.set(name, new Set())
71
+ listeners.get(name).add(listener)
72
+ ask()
73
+ return () => listeners.get(name)?.delete(listener)
74
+ }
75
+
76
+ /** For tests: forget everything. */
77
+ export function resetShared() { values.clear(); listeners.clear(); send = null; asked = false; view = 'deck' }
@@ -0,0 +1,36 @@
1
+ // The steps of a slide: what appears one press at a time.
2
+ //
3
+ // Steps follow the slide's document order. Inside an SVG drawing that order
4
+ // is also what is drawn on top, so a drawing may number its parts instead:
5
+ // `data-step="2"` shows after `data-step="1"`, and parts with the same number
6
+ // appear together. A drawing's numbered steps take the place of its first
7
+ // one; parts with a bare `data-step` keep their own place.
8
+
9
+ // [[element, …], …]: each step's elements, in the order they are shown.
10
+ export function slideSteps(slide) {
11
+ if (!slide) return []
12
+ const order = []
13
+ const drawings = new Map()
14
+ for (const element of slide.querySelectorAll('[data-step]')) {
15
+ const drawing = element.closest('svg')
16
+ const number = numbered(element.getAttribute('data-step'))
17
+ if (!drawing || number == null) { order.push([element]); continue }
18
+ let parts = drawings.get(drawing)
19
+ if (!parts) {
20
+ parts = new Map()
21
+ drawings.set(drawing, parts)
22
+ order.push(parts)
23
+ }
24
+ if (!parts.has(number)) parts.set(number, [])
25
+ parts.get(number).push(element)
26
+ }
27
+ return order.flatMap(entry => entry instanceof Map
28
+ ? [...entry.keys()].sort((a, b) => a - b).map(number => entry.get(number))
29
+ : [entry])
30
+ }
31
+
32
+ function numbered(value) {
33
+ if (value == null || !value.trim()) return null
34
+ const number = Number(value)
35
+ return Number.isFinite(number) ? number : null
36
+ }
@@ -0,0 +1,65 @@
1
+ // The presenter view's clock: the time of the whole talk and of each slide.
2
+ // It reads the wall clock rather than counting ticks, so it does not drift,
3
+ // and keeps its state in sessionStorage, so a reload carries on where it was.
4
+ // Time counts only while the clock runs; it goes to the slide shown.
5
+ //
6
+ // A rehearsal is the same clock, started from nothing: the time each slide
7
+ // got is what the rehearsal summary offers to save (core/timing.js).
8
+ import { useEffect, useRef, useState } from 'preact/hooks'
9
+
10
+ const fresh = () => ({ running: false, rehearsing: false, mark: 0, total: 0, spent: {}, slide: null })
11
+
12
+ function load(key) {
13
+ try {
14
+ const saved = JSON.parse(sessionStorage.getItem(key))
15
+ if (saved && typeof saved.total === 'number' && saved.spent && typeof saved.spent === 'object') return { ...fresh(), ...saved }
16
+ } catch {}
17
+ return fresh()
18
+ }
19
+
20
+ // The time since the last mark goes to the whole talk and the slide shown.
21
+ function settle(state, now) {
22
+ if (!state.running || state.slide == null) return { ...state, mark: now }
23
+ const gone = Math.max(0, now - state.mark)
24
+ return { ...state, mark: now, total: state.total + gone, spent: { ...state.spent, [state.slide]: (state.spent[state.slide] ?? 0) + gone } }
25
+ }
26
+
27
+ export function useTalkTimer(key, slideId, { rehearse = false } = {}) {
28
+ const [state, setState] = useState(() => {
29
+ const saved = load(key)
30
+ // ?rehearse=1 starts a rehearsal, unless this page is in one already.
31
+ if (rehearse && !saved.rehearsing) return { ...fresh(), rehearsing: true, running: true, mark: Date.now(), slide: slideId }
32
+ return saved
33
+ })
34
+ const [, setTick] = useState(0)
35
+ const stateRef = useRef(state)
36
+ stateRef.current = state
37
+ const update = change => setState(current => change(settle(current, Date.now())))
38
+
39
+ useEffect(() => { try { sessionStorage.setItem(key, JSON.stringify(state)) } catch {} }, [state])
40
+ useEffect(() => { if (slideId != null && slideId !== state.slide) update(s => ({ ...s, slide: slideId })) }, [slideId])
41
+ useEffect(() => {
42
+ if (!state.running) return
43
+ const timer = setInterval(() => setTick(n => n + 1), 500)
44
+ return () => clearInterval(timer)
45
+ }, [state.running])
46
+
47
+ const live = state.running ? Math.max(0, Date.now() - state.mark) : 0
48
+ const seconds = ms => Math.floor(ms / 1000)
49
+ return {
50
+ running: state.running,
51
+ rehearsing: state.rehearsing,
52
+ totalElapsed: seconds(state.total + live),
53
+ slideElapsed: seconds((state.spent[state.slide] ?? 0) + live),
54
+ // Each slide's time so far, in seconds.
55
+ spent: () => {
56
+ const now = settle(stateRef.current, Date.now())
57
+ return Object.fromEntries(Object.entries(now.spent).map(([id, ms]) => [id, ms / 1000]))
58
+ },
59
+ toggle: () => update(s => ({ ...s, running: !s.running })),
60
+ pause: () => update(s => ({ ...s, running: false })),
61
+ reset: () => setState(s => ({ ...fresh(), slide: s.slide })),
62
+ startRehearsal: () => setState(s => ({ ...fresh(), rehearsing: true, running: true, mark: Date.now(), slide: s.slide })),
63
+ endRehearsal: () => setState(s => ({ ...fresh(), slide: s.slide })),
64
+ }
65
+ }
@@ -1,30 +0,0 @@
1
- @article{sweller1988,
2
- author = {Sweller, John},
3
- title = {Cognitive Load During Problem Solving: Effects on Learning},
4
- journal = {Cognitive Science},
5
- year = {1988},
6
- volume = {12},
7
- number = {2},
8
- pages = {257--285},
9
- doi = {10.1207/s15516709cog1202_4}
10
- }
11
-
12
- @article{roediger2006,
13
- author = {Roediger, Henry L. and Karpicke, Jeffrey D.},
14
- title = {Test-Enhanced Learning: Taking Memory Tests Improves Long-Term Retention},
15
- journal = {Psychological Science},
16
- year = {2006},
17
- volume = {17},
18
- number = {3},
19
- pages = {249--255},
20
- doi = {10.1111/j.1467-9280.2006.01693.x}
21
- }
22
-
23
- @book{mayer2009,
24
- author = {Mayer, Richard E.},
25
- title = {Multimedia Learning},
26
- edition = {2},
27
- publisher = {Cambridge University Press},
28
- address = {Cambridge},
29
- year = {2009}
30
- }
@@ -1,21 +0,0 @@
1
- # The same deck can draw on a Typst (Hayagriva) file as well.
2
- freeman2014:
3
- type: Article
4
- title: Active learning increases student performance in science, engineering, and mathematics
5
- author:
6
- - Freeman, Scott
7
- - Eddy, Sarah L.
8
- - McDonough, Miles
9
- - Smith, Michelle K.
10
- - Okoroafor, Nnadozie
11
- - Jordt, Hannah
12
- - Wenderoth, Mary Pat
13
- date: 2014
14
- page-range: 8410-8415
15
- serial-number:
16
- doi: 10.1073/pnas.1319030111
17
- parent:
18
- type: Periodical
19
- title: Proceedings of the National Academy of Sciences
20
- volume: 111
21
- issue: 23
@@ -1,43 +0,0 @@
1
- ---
2
- theme: academic
3
- meta:
4
- title: "What helps people learn"
5
- author: "Tilman Schieber"
6
- organization: "FHNW"
7
- bibliography: [refs.bib, refs.yml]
8
- csl: apa
9
- ---
10
-
11
- ---
12
- :::meta
13
- layout: title
14
- :::
15
- # What helps people learn
16
- ## Citations from a reference file
17
-
18
- :::notes
19
- The references come from `refs.bib` (BibTeX, as Zotero exports it) and `refs.yml` (Hayagriva, Typst's format). Try `csl: vancouver` or `lang: de` in the deck settings.
20
- :::
21
-
22
- ---
23
- # Less on the slide
24
-
25
- Working memory holds only a few things at once, so a slide that makes people search wastes it [@sweller1988].
26
-
27
- Words spoken alongside a picture beat the same words printed on it [@mayer2009, chap. 11].
28
-
29
- ---
30
- # Ask, don't tell
31
-
32
- As @roediger2006 showed, recalling something fixes it better than reading it again.
33
-
34
- Lectures with active learning cut failure rates by about a third [@freeman2014; see also @roediger2006].
35
-
36
- :::notes
37
- Freeman et al. pooled 225 studies [-@freeman2014].
38
- :::
39
-
40
- ---
41
- # References
42
-
43
- <bibliography />
Binary file