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.
- package/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/assets/base.css +4 -1
- package/docs/reference/authoring.md +66 -5
- package/docs/reference/extensions.md +1 -1
- package/docs/reference/themes.md +2 -1
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/commands.md +3 -1
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/install.md +47 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +39 -2
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/theme-authoring.md +2 -1
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/images/features/hotspots.webp +0 -0
- package/docs/site/images/features/languages.webp +0 -0
- package/docs/site/pages.js +1 -0
- package/examples/custom-layouts/slides.md +21 -9
- package/examples/showcase/refs.bib +21 -0
- package/examples/showcase/slides.ar.drawings.json +11 -0
- package/examples/showcase/slides.ar.md +567 -0
- package/examples/showcase/slides.de.drawings.json +11 -0
- package/examples/showcase/slides.de.md +567 -0
- package/examples/showcase/slides.en.drawings.json +11 -0
- package/examples/showcase/{slides.md → slides.en.md} +112 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +10 -1
- package/src/build/check.js +7 -1
- package/src/build/config.js +2 -0
- package/src/build/slidesPlugin.js +6 -6
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +80 -0
- package/src/cli/args.js +4 -3
- package/src/cli/index.js +34 -11
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +3 -0
- package/src/core/labels.js +75 -0
- package/src/core/parseSlides.js +3 -3
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +5 -0
- package/src/editor/components/DeckSettings.jsx +5 -4
- package/src/editor/components/ExtensionForms.jsx +1 -1
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/extensions/params.js +28 -1
- package/src/home/Home.jsx +1 -0
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +49 -20
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +31 -6
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +157 -28
- package/src/runtime/shared.js +77 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +65 -0
- package/examples/citations/refs.bib +0 -30
- package/examples/citations/refs.yml +0 -21
- package/examples/citations/slides.md +0 -43
- package/examples/citations/slides.pdf +0 -0
- package/examples/poll/slides.md +0 -127
- package/examples/showcase/slides.drawings.json +0 -11
package/src/runtime/main.jsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
<
|
|
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:
|
|
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
|
-
<
|
|
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={{
|
|
528
|
-
{
|
|
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={
|
|
532
|
-
<PresenterIcon name={
|
|
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={
|
|
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
|