mdeck 3.10.1 → 3.12.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 (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
@@ -33,9 +33,21 @@ import { setCalloutLabels } from './markedSetup'
33
33
  import { setDeckLanguage, deckLanguage, stageLabels, t } from '../core/labels.js'
34
34
  import { textDirection } from '../core/languages.js'
35
35
  import { Icon } from '../components/Icon.jsx'
36
- import { ConnectedViews } from '../components/ConnectedViews.jsx'
37
36
  import './deck-stage.js'
38
37
  import { normalMode, hotspotMode } from './hotspots.js'
38
+ import { useTalkTimer } from './useTalkTimer.js'
39
+ import { devPath } from '../core/devPath.js'
40
+ import { setView } from './shared.js'
41
+ import { watchSlideControls, pressSlideControl } from './slideControls.js'
42
+ import QrCodeTag from '../components/QrCodeTag.jsx'
43
+ import { ToolButton, Popover, PanelSection, SlideControls, Connections, tool } from './PresenterChrome.jsx'
44
+ import mdeckMark from '../../assets/logo/mark.svg'
45
+ import { RehearsalSummary } from './RehearsalSummary.jsx'
46
+ import { planFor, scheduleAt, formatClock } from '../core/timing.js'
47
+ import { KeyHelp, useKeyHelp, attachKeyHelp } from './KeyHelp.jsx'
48
+ import { isHelpKey } from './helpKeys.js'
49
+ import { Reactions } from './Reactions.jsx'
50
+ import { reactionsOf } from '../core/reactions.js'
39
51
 
40
52
  const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
41
53
 
@@ -99,7 +111,24 @@ const withFileLanguage = config => config.lang || !deckLang ? config : { ...conf
99
111
  function languageName(lang) {
100
112
  try { return new Intl.DisplayNames([lang], { type: 'language' }).of(lang) } catch { return lang }
101
113
  }
102
- const hasActivities = () => roomsIn(slidesContent).length > 0
114
+ // Asked several times on every slide change (presenter view, the phones'
115
+ // announcement, the views connected), so the answer is kept until the text
116
+ // changes: finding the tags reads the whole deck, which on an iPad made each
117
+ // slide change noticeably slower.
118
+ // A deck with reactions (`reactions:`) wants the phones too, between activities.
119
+ let activitiesOf = null, activitiesFound = false, reactionsWanted = false
120
+ const hasActivities = () => {
121
+ if (activitiesOf !== slidesContent) { activitiesOf = slidesContent; activitiesFound = roomsIn(slidesContent).length > 0 }
122
+ return activitiesFound || reactionsWanted
123
+ }
124
+ // The heading the phones show for a room, found once per deck.
125
+ const titlesByDeck = new WeakMap()
126
+ function titleFor(deck, room) {
127
+ let titles = titlesByDeck.get(deck)
128
+ if (!titles) titlesByDeck.set(deck, titles = new Map())
129
+ if (!titles.has(room)) titles.set(room, slideTitleFor(deck, room))
130
+ return titles.get(room)
131
+ }
103
132
  // What the phones show for the activity on a slide: its component's `phone`
104
133
  // description, from the tag's attributes and the slide's heading.
105
134
  function activityOn(deck, slide) {
@@ -110,7 +139,7 @@ function activityOn(deck, slide) {
110
139
  const component = element && registry[element.localName]
111
140
  if (typeof component?.phone !== 'function') return { room, activity: null }
112
141
  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 } }
142
+ try { return { room, activity: component.phone(props, { slideTitle: titleFor(deck, room) }) } } catch (error) { console.warn(error); return { room, activity: null } }
114
143
  }
115
144
  function announceSlide(deck, index, { initial = false } = {}) {
116
145
  if (hasActivities()) announce({ ...activityOn(deck, deck.slides[index]), title: deck.deckConfig?.meta?.title ?? '', initial })
@@ -124,13 +153,6 @@ const STEERING = {
124
153
  unreachable: 'Phones do not follow: the server does not answer.',
125
154
  'other-screen': 'Phones follow another screen, where this presentation was moved on more recently. Change the slide here to take them back.',
126
155
  }
127
- function SteeringNote() {
128
- const steering = useSteering()
129
- if (!hasActivities() || !steering) return null
130
- return <div role="status" style={{ flexShrink: 0, padding: '6px 10px', borderRadius: '5px', fontSize: '12px', lineHeight: 1.4, border: `1px solid ${steering.ok ? '#2c4a35' : '#5c2b28'}`, color: steering.ok ? '#81c995' : '#f28b82' }}>
131
- {steering.ok ? 'Phones follow this presentation.' : STEERING[steering.reason]}
132
- </div>
133
- }
134
156
 
135
157
  function injectSpeakerNotes(slides) {
136
158
  let tag = document.getElementById('speaker-notes')
@@ -144,12 +166,15 @@ function injectSpeakerNotes(slides) {
144
166
  }
145
167
 
146
168
  // URL for the presenter's own iframe and preview pane (uses postMessage)
147
- function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false } = {}) {
169
+ function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false, remote = false } = {}) {
148
170
  const url = new URL(window.location.href)
149
171
  url.searchParams.delete('v')
150
172
  url.searchParams.set('view', 'deck')
151
173
  url.searchParams.set('embedded', '1')
152
174
  if (draw) url.searchParams.set('draw', '1')
175
+ // A phone's small slide is for looking; it does not start drawing.
176
+ if (remote) url.searchParams.set('remote', '1')
177
+ else url.searchParams.delete('remote')
153
178
  if (theme) url.searchParams.set('theme', theme)
154
179
  else url.searchParams.delete('theme')
155
180
  setLookParams(url, palette, appearance)
@@ -205,6 +230,81 @@ function usePresence() {
205
230
  return presence
206
231
  }
207
232
 
233
+ // The launch page of `mdeck run`, when this computer's presenter view was
234
+ // served by it; a built deck, `mdeck edit` and a paired iPad have none.
235
+ function useLaunchPage() {
236
+ const [url, setUrl] = useState(null)
237
+ useEffect(() => {
238
+ if (!import.meta.env.DEV) return
239
+ fetch(devPath('__mdeck/home/info'), { cache: 'no-store' }).then(response => { if (response.ok) setUrl(devPath('home.html')) }, () => {})
240
+ }, [])
241
+ return url
242
+ }
243
+
244
+ // The slide number as a field: type a number and press Enter (Go on an
245
+ // iPad's keyboard) to jump there. Escape, or leaving it empty, keeps the slide.
246
+ function SlideJump({ index, total, onJump, pad = 0, style }) {
247
+ const [draft, setDraftState] = useState(null)
248
+ // Blur follows Enter and Escape before a re-render, so it reads the ref.
249
+ const draftRef = useRef(null)
250
+ const setDraft = value => { draftRef.current = value; setDraftState(value) }
251
+ const shown = draft ?? String(index + 1).padStart(pad, '0')
252
+ function commit() {
253
+ const n = Number.parseInt(draftRef.current ?? '', 10)
254
+ setDraft(null)
255
+ if (Number.isInteger(n) && n - 1 !== index) onJump(Math.max(1, Math.min(total, n)) - 1)
256
+ }
257
+ return (
258
+ <form class="presenter-jump" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', margin: 0, ...style }}
259
+ onSubmit={e => { e.preventDefault(); commit(); e.currentTarget.elements.slide.blur() }}>
260
+ <input
261
+ name="slide" type="text" inputMode="numeric" pattern="[0-9]*" enterKeyHint="go" autoComplete="off"
262
+ title="Go to slide: type its number and press Enter" aria-label={`Slide number, 1 to ${total}`}
263
+ value={shown}
264
+ size={Math.max(2, String(total).length)}
265
+ onFocus={e => e.currentTarget.select()}
266
+ onInput={e => setDraft(e.currentTarget.value.replace(/\D/g, ''))}
267
+ onKeyDown={e => {
268
+ if (e.key !== 'Escape') return
269
+ setDraft(null)
270
+ // Typing and Escape can land in one render, which then changes nothing.
271
+ e.currentTarget.value = String(index + 1).padStart(pad, '0')
272
+ e.currentTarget.blur()
273
+ }}
274
+ onBlur={() => { if (draftRef.current !== null) commit() }}
275
+ 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' }}
276
+ />
277
+ <span>/ {String(total).padStart(pad, '0')}</span>
278
+ </form>
279
+ )
280
+ }
281
+
282
+ const PRESENTER_LAYOUTS = ['speaker', 'slide', 'remote']
283
+ // A slide's first heading, as plain words, for the phone's "Next:" line.
284
+ function slideTitleOf(slide) {
285
+ const texts = [slide?.content ?? '', ...Object.values(slide?.regions ?? {}).map(region => region.content ?? '')]
286
+ for (const text of texts) {
287
+ const heading = text.replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^#{1,3}\s+(.+)$/m)
288
+ if (heading) return heading[1].replace(/[*_`]/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').trim()
289
+ }
290
+ return ''
291
+ }
292
+ // A touch screen gets the slide alone, a computer the speaker layout. The
293
+ // phone remote is chosen, not guessed: its pairing code opens ?layout=remote.
294
+ function defaultLayout() {
295
+ return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
296
+ }
297
+ // ?layout= in the address (from a pairing code) chooses the layout; the
298
+ // device then remembers it like a layout chosen with the buttons.
299
+ function requestedLayout() {
300
+ const url = new URL(window.location.href)
301
+ const asked = url.searchParams.get('layout')
302
+ if (!asked) return null
303
+ url.searchParams.delete('layout')
304
+ try { history.replaceState(history.state, '', url) } catch {}
305
+ return PRESENTER_LAYOUTS.includes(asked) ? asked : null
306
+ }
307
+
208
308
  function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
209
309
  const [index, setIndex] = useState(0)
210
310
  // The notes, slide count and phones follow the language shown.
@@ -219,8 +319,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
219
319
  const [palette, setPalette] = useState(deckConfig.palette ?? '')
220
320
  const [appearance, setAppearance] = useState(deckConfig.appearance ?? '')
221
321
  const [audienceConnected, setAudienceConnected] = useState(false)
222
- const [noteSize, setNoteSize] = useState(13)
322
+ // Larger notes in the phone remote, whether the address or the device chose it.
323
+ const [noteSize, setNoteSize] = useState(() => {
324
+ let saved = null
325
+ try { saved = localStorage.getItem('mdeck-presenter-layout') } catch {}
326
+ return (new URL(window.location.href).searchParams.get('layout') ?? saved) === 'remote' ? 17 : 13
327
+ })
223
328
  const [inking, setInking] = useState(false)
329
+ // Black screen (B): the audience's screens are black, this view veils its slide.
330
+ const [blank, setBlank] = useState(false)
331
+ // The join code over the slide (J), on every screen.
332
+ const [join, setJoin] = useState(false)
333
+ // The phone clicker: the slide's controls, as its frame reports them (slideControls.js).
334
+ const [slideControls, setSlideControls] = useState({ items: [], hotspots: 0, hotspot: null })
335
+ // The overview (O) in this view's slide frame: keys go to it while it is open.
336
+ const [overviewOpen, setOverviewOpen] = useState(false)
337
+ const overviewRef = useRef(false)
338
+ overviewRef.current = overviewOpen
339
+ // The keys that work now, over everything (?).
340
+ const [helpOpen, setHelpOpen] = useKeyHelp()
224
341
  // Hotspots: the deck's setting until the presenter switches them (H).
225
342
  const deckHotspots = (normalMode(deckConfig.hotspots) ?? 'off') !== 'off'
226
343
  const [hotspotsForced, setHotspotsForced] = useState(null)
@@ -231,6 +348,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
231
348
  // Whether the number keys zoom on the slide shown: a slide may have its own setting.
232
349
  const slideHotspots = hotspotMode({ slide: slides[index]?.meta.hotspots, deck: deckConfig.hotspots, forced: hotspotsForced }) !== 'off'
233
350
  const presence = usePresence()
351
+ const launchPage = useLaunchPage()
234
352
  // Drawing in the slide's frame: this page around it must not pan or bounce either.
235
353
  useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
236
354
  // The slide zooms itself (two fingers in its frame); Safari's page zoom
@@ -241,10 +359,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
241
359
  return () => document.removeEventListener('gesturestart', stop)
242
360
  }, [])
243
361
  // 'slide': the slide fills the screen (an iPad); the notes open as a drawer.
362
+ // 'remote': a phone in the hand, with the slide small, the notes and big
363
+ // buttons to go on and back.
244
364
  const [layout, setLayout] = useState(() => {
245
- try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (saved) return saved } catch {}
246
- return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
365
+ const asked = requestedLayout()
366
+ if (asked) { try { localStorage.setItem('mdeck-presenter-layout', asked) } catch {} return asked }
367
+ try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (PRESENTER_LAYOUTS.includes(saved)) return saved } catch {}
368
+ return defaultLayout()
247
369
  })
370
+ const remoteLayout = layout === 'remote'
371
+ // A phone's screen must not lock in the middle of the talk (where the
372
+ // browser lets a page ask: https or this computer).
373
+ useEffect(() => {
374
+ if (!remoteLayout || !navigator.wakeLock) return
375
+ let lock = null, gone = false
376
+ const ask = () => { if (document.visibilityState === 'visible') navigator.wakeLock.request('screen').then(held => { if (gone) held.release(); else lock = held }, () => {}) }
377
+ ask()
378
+ document.addEventListener('visibilitychange', ask)
379
+ return () => { gone = true; document.removeEventListener('visibilitychange', ask); lock?.release().catch(() => {}) }
380
+ }, [remoteLayout])
248
381
  const [drawerOpen, setDrawerOpen] = useState(false)
249
382
  const slideLayout = layout === 'slide'
250
383
  function chooseLayout(next) {
@@ -260,21 +393,37 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
260
393
  const fullscreenLabel = isFullscreen ? 'Exit full screen (F)' : 'Full screen (F)'
261
394
  const fullscreenIcon = isFullscreen ? 'fullscreen-exit' : 'fullscreen'
262
395
 
263
- const [paletteOpen, setPaletteOpen] = useState(false)
264
- // The theme row shows unless the presenter folded it, like the ink toolbar,
265
- // into one round button.
266
- const [lookFolded, setLookFolded] = useState(() => { try { return localStorage.getItem('mdeck-presenter-look') === 'folded' } catch { return false } })
267
- function foldLook(folded) {
268
- setLookFolded(folded)
269
- setPaletteOpen(false)
270
- try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
271
- }
272
396
  const [themeModalOpen, setThemeModalOpen] = useState(false)
273
- const [elapsed, setElapsed] = useState(0)
274
- const [timerRunning, setTimerRunning] = useState(false)
275
- const timerRef = useRef(null)
397
+ // The clock, and the slides' times (`time:`) to hold it against. Times a
398
+ // rehearsal saved count at once; the deck file is not read again mid-talk.
399
+ const [savedTimes, setSavedTimes] = useState({})
400
+ const plan = useMemo(() => planFor(slides, deckConfig, savedTimes), [slides, deckConfig, savedTimes])
401
+ const timer = useTalkTimer(`mdeck-timer:${DECK_CHANNEL}`, slides[index]?.id, { rehearse: controlUrl.searchParams.get('rehearse') === '1' })
402
+ const [summary, setSummary] = useState(null)
403
+ const [savedNote, setSavedNote] = useState(null)
404
+ // A reload after the rehearsal must not start another one.
405
+ useEffect(() => {
406
+ if (!controlUrl.searchParams.has('rehearse')) return
407
+ const url = new URL(window.location.href)
408
+ url.searchParams.delete('rehearse')
409
+ history.replaceState(null, '', url)
410
+ }, [])
411
+ function finishRehearsal() {
412
+ timer.pause()
413
+ setSummary(timer.spent())
414
+ }
415
+ const timerRef = useRef(timer)
416
+ timerRef.current = timer
417
+ // Next at the very end of the deck ends a rehearsal.
418
+ const atEndRef = useRef(false)
419
+ const atRehearsalEnd = () => timerRef.current.rehearsing && atEndRef.current
420
+ const finishRehearsalRef = useRef(finishRehearsal)
421
+ finishRehearsalRef.current = finishRehearsal
422
+ const summaryRef = useRef(false)
423
+ summaryRef.current = !!summary
276
424
 
277
425
  const iframeRef = useRef(null)
426
+ const toggleOverviewRef = useRef(null)
278
427
  const previewRef = useRef(null)
279
428
  const audienceRef = useRef(null)
280
429
  const bcRef = useRef(null)
@@ -285,7 +434,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
285
434
  const langRef = useRef(lang)
286
435
  langRef.current = lang
287
436
  const stateRef = useRef({ index: 0, step: -1 })
288
- const paletteDropRef = useRef(null)
289
437
  indexRef.current = index
290
438
  // Phones see the look of the slide this view shows, read from its frame.
291
439
  useEffect(() => { setLookSource(() => iframeRef.current?.contentDocument) }, [])
@@ -303,7 +451,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
303
451
  useEffect(() => { if (palette && !offered.some(p => p.id === palette)) setPalette('') }, [theme])
304
452
  const notes = useMemo(() => slides.map(s => s.meta?.notes ?? ''), [slides])
305
453
  // Preserve current slide when theme/palette causes an iframe reload
306
- const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true }), [theme, palette, appearance])
454
+ const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true, remote: remoteLayout }), [theme, palette, appearance, remoteLayout])
307
455
  // previewSrc only recomputes on a change of look; slide changes use postMessage
308
456
  const previewSrc = useMemo(
309
457
  () => buildChildUrl(theme, palette, appearance, indexRef.current + 1),
@@ -337,6 +485,11 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
337
485
  }
338
486
  if (source !== iframeRef.current?.contentWindow) return
339
487
  if (typeof data?.inkMode === 'boolean') { setInking(data.inkMode); return }
488
+ // The clicker's slide frame says what can be done on the slide shown.
489
+ if (data?.slideControls) { setSlideControls(data.slideControls); return }
490
+ if (typeof data?.overview === 'boolean') { setOverviewOpen(data.overview); return }
491
+ // ? typed while the slide's frame had the keyboard.
492
+ if (data?.keyHelp === 'toggle') { setHelpOpen(open => !open); return }
340
493
  // Another screen (a paired iPad) switched the language; the notes follow.
341
494
  if (LANGUAGES.includes(data?.deckLanguage)) { setLang(data.deckLanguage); return }
342
495
  if (!data?.deckStateChanged) return
@@ -357,7 +510,10 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
357
510
  return
358
511
  }
359
512
  stateRef.current = state
513
+ atEndRef.current = !!data.atEnd
360
514
  setIndex(i)
515
+ setBlank(!!state.blank)
516
+ setJoin(!!state.join)
361
517
  if (data.reason !== 'sync') {
362
518
  bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
363
519
  }
@@ -376,6 +532,14 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
376
532
  function onKey(e) {
377
533
  if (e.target?.matches?.('input, select, textarea')) return
378
534
  if (e.metaKey || e.ctrlKey || e.altKey) return
535
+ if (summaryRef.current) return
536
+ if (overviewRef.current && ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'PageDown', 'PageUp', 'Home', 'End', 'Enter', ' ', 'Escape', 'o', 'O'].includes(e.key)) {
537
+ e.preventDefault()
538
+ sendTo(iframeRef.current?.contentWindow, 'overviewKey', e.key)
539
+ return
540
+ }
541
+ if (e.key === 'o' || e.key === 'O') { e.preventDefault(); toggleOverviewRef.current(); return }
542
+ if (e.key === 't' || e.key === 'T') { e.preventDefault(); timerRef.current.toggle(); return }
379
543
  const cmd =
380
544
  (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
381
545
  (e.key === 'ArrowLeft' || e.key === 'PageUp') ? 'prev' :
@@ -385,10 +549,14 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
385
549
  if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
386
550
  if ((e.key === 'l' || e.key === 'L') && LANGUAGES.length) { e.preventDefault(); nextLanguage(); return }
387
551
  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.
552
+ // B, or a clicker's blank button (a full stop).
553
+ if (e.key === 'b' || e.key === 'B' || e.key === '.') { e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'blank', 'toggle'); return }
554
+ if ((e.key === 'j' || e.key === 'J') && (hasActivities() || reactionsOf(firstConfig))) { e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'join', 'toggle'); return }
555
+ // Digits zoom to hotspots; Escape zooms out.
389
556
  if (/^[0-9]$/.test(e.key) || e.key === 'Escape') { if (e.key !== 'Escape') e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'hotspotKey', e.key); return }
390
557
  if (!cmd) return
391
558
  e.preventDefault()
559
+ if (cmd === 'next' && atRehearsalEnd()) { finishRehearsalRef.current(); return }
392
560
  sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
393
561
  }
394
562
  window.addEventListener('keydown', onKey)
@@ -412,24 +580,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
412
580
  return () => window.removeEventListener('keydown', handler)
413
581
  }, [themeModalOpen])
414
582
 
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
- // Close palette dropdown on outside click
425
- useEffect(() => {
426
- if (!paletteOpen) return
427
- function handler(e) {
428
- if (!paletteDropRef.current?.contains(e.target)) setPaletteOpen(false)
429
- }
430
- document.addEventListener('mousedown', handler)
431
- return () => document.removeEventListener('mousedown', handler)
432
- }, [paletteOpen])
433
583
 
434
584
  // When theme/palette changes, tell the audience to hot-swap its theme without
435
585
  // a reload. Phones pick it up with the next announcement.
@@ -450,9 +600,17 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
450
600
  }, [lang])
451
601
  const nextLanguage = () => setLang(current => LANGUAGES[(LANGUAGES.indexOf(current) + 1) % LANGUAGES.length])
452
602
 
603
+ const toggleBlank = () => sendTo(iframeRef.current?.contentWindow, 'blank', 'toggle')
604
+ // In the slide-only layout the notes drawer would cover the grid.
605
+ const toggleOverview = () => { setDrawerOpen(false); sendTo(iframeRef.current?.contentWindow, 'overview', 'toggle') }
606
+ toggleOverviewRef.current = toggleOverview
607
+ const blankLabel = blank ? 'Show the slide again (B)' : 'Black screen for the audience (B)'
608
+ const blankOn = { background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }
609
+
453
610
  // Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
454
- function navCommand(cmd) {
455
- sendTo(iframeRef.current?.contentWindow, cmd)
611
+ function navCommand(cmd, value) {
612
+ if (cmd === 'next' && atRehearsalEnd()) return finishRehearsal()
613
+ sendTo(iframeRef.current?.contentWindow, cmd, value)
456
614
  }
457
615
 
458
616
  function openAudienceWindow() {
@@ -465,245 +623,45 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
465
623
  setAudienceConnected(true)
466
624
  }
467
625
 
626
+ // Whether the phones follow this screen; told only when they do not.
627
+ const steering = useSteering()
468
628
  const note = notes[index] || ''
469
629
  const hasNext = index + 1 < slides.length
470
630
 
471
- const clock = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`
631
+ const clock = formatClock(timer.totalElapsed).padStart(5, '0')
632
+ // With times in the deck: what is left of this slide, and how the talk
633
+ // stands against the plan (behind when positive).
634
+ const timed = plan.total > 0
635
+ const { planned, slideLeft, delta } = scheduleAt(plan, index, timer)
636
+ const slideColor = !planned ? '#888' : slideLeft <= 0 ? '#f28b82' : slideLeft <= 15 ? '#f5a524' : '#ccc'
637
+ const slideText = !planned ? 'no time' : slideLeft > 0 ? `${formatClock(slideLeft)} left` : `+${formatClock(-slideLeft)} over`
638
+ const deltaText = Math.abs(delta) < 30 ? 'on time' : delta > 0 ? `${formatClock(delta)} behind` : `${formatClock(-delta)} ahead`
639
+ const deltaColor = Math.abs(delta) < 30 ? '#888' : delta > 0 ? '#f28b82' : '#81c995'
472
640
  const pill = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', fontSize: '15px' }
641
+ const notesHtml = note
642
+ ? marked.parse(renderCitations(note).markdown)
643
+ : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>'
644
+ const notesDir = textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined
473
645
 
474
- return (
475
- <div class={`presenter presenter--${layout}`} style={{ display: 'grid', gridTemplateColumns: slideLayout ? '1fr' : '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
476
- <iframe
477
- ref={iframeRef}
478
- title="Presenter deck"
479
- src={iframeSrc}
480
- style={{ width: '100%', height: '100%', border: '0' }}
481
- />
482
- {slideLayout && (
483
- <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>
485
- <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>
487
- <button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
488
- <button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
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>
491
- <button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
492
- {canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
493
- <button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
494
- </div>
495
- )}
496
- <aside class="presenter-aside" style={{
497
- display: slideLayout && !drawerOpen ? 'none' : 'flex',
498
- flexDirection: 'column',
499
- gap: '14px',
500
- padding: '16px',
501
- borderLeft: '1px solid #2a2a2a',
502
- background: '#111',
503
- color: '#ccc',
504
- fontFamily: 'ui-sans-serif, system-ui, sans-serif',
505
- fontSize: '13px',
506
- overflow: 'hidden',
507
- ...(slideLayout ? { position: 'fixed', top: 0, right: 0, bottom: 0, width: 'min(380px, 92vw)', zIndex: 19, paddingTop: '64px', boxShadow: '-12px 0 32px rgba(0,0,0,0.5)' } : {}),
508
- }}>
509
-
510
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
511
- <span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
512
- <span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '14px' }}>Speaker View</span>
513
- <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888' }} />
514
- </span>
515
- <span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
516
- {!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>}
517
- {!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>
521
- </span>
522
- </div>
523
-
524
- <SteeringNote />
525
-
526
- <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')}
529
- </span>
530
- <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} />
533
- </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>
535
- </div>
536
- </div>
646
+ // Reactions from the phones float up here, never on the projector.
647
+ const reacting = !!reactionsOf(deckConfig)
648
+ const summaryPanel = summary && <RehearsalSummary
649
+ slides={slides}
650
+ plan={plan}
651
+ spent={summary}
652
+ onClose={() => { setSummary(null); timer.toggle() }}
653
+ onDiscard={() => { setSummary(null); timer.endRehearsal() }}
654
+ onSaved={(times, changed) => {
655
+ setSummary(null)
656
+ timer.endRehearsal()
657
+ setSavedTimes(saved => ({ ...saved, ...Object.fromEntries(Object.entries(times).map(([id, t]) => [id, t ?? undefined])) }))
658
+ setSavedNote(`Saved the times of ${changed} slide${changed === 1 ? '' : 's'} into the deck.`)
659
+ setTimeout(() => setSavedNote(null), 6000)
660
+ }}
661
+ />
537
662
 
538
- {hasNext && (
539
- <div style={{ flexShrink: 0 }}>
540
- <span style={S.label}>Next slide</span>
541
- <div style={{ aspectRatio: '16/9', borderRadius: '4px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
542
- <iframe
543
- ref={previewRef}
544
- title="Next slide preview"
545
- src={previewSrc}
546
- scrolling="no"
547
- style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }}
548
- />
549
- </div>
550
- </div>
551
- )}
552
-
553
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
554
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
555
- <span style={{ ...S.label, marginBottom: 0 }}>Notes</span>
556
- <input
557
- type="range" min="11" max="22" step="1" value={noteSize}
558
- onInput={e => setNoteSize(Number(e.currentTarget.value))}
559
- style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
560
- />
561
- </div>
562
- <div class="notes-md" dir={textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined} style={{
563
- flex: 1,
564
- margin: 0,
565
- padding: '10px 12px',
566
- lineHeight: 1.6,
567
- background: '#0a0a0a',
568
- border: '1px solid #2a2a2a',
569
- borderRadius: '5px',
570
- fontSize: `${noteSize}px`,
571
- color: note ? '#c8c8c8' : '#3a3a3a',
572
- fontFamily: 'inherit',
573
- overflow: 'auto',
574
- }}
575
- dangerouslySetInnerHTML={{
576
- __html: note
577
- ? marked.parse(renderCitations(note).markdown)
578
- : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
579
- }}
580
- />
581
- </div>
582
-
583
- {!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
584
- <label style={{ flex: '1 1 auto', minWidth: 0 }}>
585
- <span style={S.label}>Theme</span>
586
- <button
587
- onClick={() => setThemeModalOpen(true)}
588
- style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
589
- >
590
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{theme}</span>
591
- <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
592
- </button>
593
- </label>
594
- <label style={{ flex: '1 1 auto', minWidth: 0 }}>
595
- <span style={S.label}>Palette</span>
596
- <div ref={paletteDropRef} style={{ position: 'relative' }}>
597
- <button
598
- class="presenter-palette"
599
- onClick={() => setPaletteOpen(o => !o)}
600
- style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
601
- >
602
- <PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
603
- <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
604
- <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
605
- </button>
606
- {paletteOpen && (
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)' }}>
608
- {offered.map(p => (
609
- <div
610
- key={p.id}
611
- class="presenter-palette-option"
612
- title={p.description}
613
- onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
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' }}
615
- >
616
- <PaletteSwatches tokens={p[shown.appearance] ?? {}} />
617
- <span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
618
- </div>
619
- ))}
620
- </div>
621
- )}
622
- </div>
623
- </label>
624
- <label style={{ flexShrink: 0 }}>
625
- <span style={S.label}>Mode</span>
626
- <div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
627
- {['light', 'dark'].map(mode => (
628
- <button
629
- key={mode}
630
- aria-pressed={shown.appearance === mode}
631
- title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'}
632
- aria-label={mode === 'light' ? 'Light' : 'Dark'}
633
- onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
634
- style={{ ...S.btn, padding: '3px 7px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
635
- ><PresenterIcon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>
636
- ))}
637
- </div>
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>}
653
- <button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
654
- style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
655
- ><PresenterIcon name="fold" size={16} /></button>
656
- </div>}
657
-
658
- <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
659
- <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
660
- <button style={S.btn} onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={15} />Prev</button>
661
- <button style={S.btn} onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={15} /></button>
662
- <button style={S.btn} onClick={() => navCommand('reset')}>Reset</button>
663
- <button
664
- class="presenter-draw"
665
- title="Draw on the slide (D)"
666
- aria-pressed={inking}
667
- style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
668
- onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
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>
678
- {lookFolded && <button
679
- class="presenter-look-unfold"
680
- title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
681
- aria-label="Show theme and palette"
682
- onClick={() => foldLook(false)}
683
- style={{ ...S.btn, position: 'relative', padding: 0, width: '32px', height: '32px', borderRadius: '50%' }}
684
- >
685
- <PresenterIcon name="look" size={16} />
686
- <span style={{ position: 'absolute', right: '2px', bottom: '2px', width: '9px', height: '9px', borderRadius: '50%', background: shown.palette?.[shown.appearance]?.['--accent'] ?? '#888', boxShadow: '0 0 0 1.5px #222' }} />
687
- </button>}
688
- <button
689
- style={{
690
- ...S.btn,
691
- marginLeft: 'auto',
692
- color: audienceConnected ? '#444' : '#bbb',
693
- }}
694
- onClick={openAudienceWindow}
695
- >
696
- {audienceConnected ? 'Reconnect' : 'Audience'}
697
- </button>
698
- </div>
699
- <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' : ''}
701
- </div>
702
- </div>
703
-
704
- </aside>
705
-
706
- {themeModalOpen && (
663
+ // The theme picker, from the settings.
664
+ const themeModal = (
707
665
  <div
708
666
  style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
709
667
  onClick={() => setThemeModalOpen(false)}
@@ -735,11 +693,218 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
735
693
  </div>
736
694
  </div>
737
695
  </div>
738
- )}
696
+ )
697
+ // The controls, the same in every layout (PresenterChrome.jsx); the layouts
698
+ // differ in where they put them.
699
+ const frame = () => iframeRef.current?.contentWindow
700
+ const problem = hasActivities() && steering && !steering.ok ? STEERING[steering.reason] : null
701
+ const slideBar = big => <SlideControls controls={slideControls} press={i => sendTo(frame(), 'slideControl', i)} zoom={key => sendTo(frame(), 'hotspotKey', key)} big={big} />
702
+ // The audience window, one click away (not on a phone: it would open there).
703
+ const audienceButton = <ToolButton class="presenter-audience" icon="projector" label={audienceConnected ? 'Open the audience window again' : 'Open the audience window, for the projector'} onClick={openAudienceWindow} />
704
+ const connections = <Connections presence={presence} phones={hasActivities()} problem={problem} />
705
+ const timerChip = <button class="presenter-clock" title={timer.running ? 'Pause the timer (T)' : 'Start the timer (T)'} aria-pressed={timer.running} onClick={timer.toggle}
706
+ style={{ ...tool, gap: '8px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: timer.running ? '#f0f0f0' : '#777' }}>
707
+ <Icon name={timer.running ? 'pause' : 'play'} size={14} style={{ display: 'block' }} />{clock}
708
+ {timed && !remoteLayout && <span class="presenter-slide-time" style={{ color: slideColor, fontSize: '12px' }}>{slideText}</span>}
709
+ </button>
710
+ const finishButton = timer.rehearsing && <button class="presenter-rehearsing" style={{ ...tool, color: '#f28b82' }} title="Finish the rehearsal" onClick={finishRehearsal}>● Finish</button>
711
+ const nav = <>
712
+ <ToolButton icon="prev" label="Previous (←)" onClick={() => navCommand('prev')} />
713
+ <ToolButton icon="next" label="Next (→, Space)" onClick={() => navCommand('next')} />
714
+ </>
715
+ const drawButton = <ToolButton class="presenter-draw" icon="pen" label="Draw on the slide (D)" pressed={inking} style={inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : undefined} onClick={() => sendTo(frame(), 'ink', 'toggle')} />
716
+ const blankButton = <ToolButton class="presenter-blank" icon="blank" label={blankLabel} pressed={blank} onClick={toggleBlank} />
717
+ // Only where phones join: decks with activities or reactions.
718
+ const joinButton = (hasActivities() || reacting) && <ToolButton class="presenter-join" icon="join" label={join ? 'Hide the join code (J)' : 'Show the join code on the slide (J)'} pressed={join} onClick={() => sendTo(frame(), 'join', 'toggle')} />
719
+ const overviewButton = <ToolButton class="presenter-overview" icon="overview" label="All slides (O)" pressed={overviewOpen} onClick={toggleOverview} />
720
+ const hotspotsButton = <ToolButton class="presenter-hotspots" icon="hotspots" label={hotspotsOn ? 'Hotspots on: 1–9 zoom in (H)' : 'Hotspots off (H)'} pressed={hotspotsOn} onClick={toggleHotspots} />
721
+ const fullscreenButton = canFullscreen && <ToolButton class="presenter-fullscreen" icon={fullscreenIcon} label={fullscreenLabel} pressed={isFullscreen} onClick={() => fullscreen.toggle()} />
722
+ const separator = <span aria-hidden="true" style={{ width: '1px', alignSelf: 'stretch', margin: '6px 4px', background: '#2a2a2a' }} />
723
+ const LAYOUTS = [['speaker', 'speaker', 'Speaker', 'Slide, notes and next slide side by side (a computer)'], ['slide', 'tablet', 'Slide only', 'The slide fills the screen, notes in a drawer (an iPad)'], ['remote', 'phone', 'Clicker', 'Notes, time and big buttons (a phone)']]
724
+ const settings = <Popover class="presenter-settings" label="Settings" trigger={<Icon name="settings" size={18} style={{ display: 'block' }} />}>
725
+ <PanelSection title="Layout">
726
+ <span role="group" aria-label="Layout" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
727
+ {LAYOUTS.map(([id, icon, name, hint]) => <button key={id} type="button" class={`presenter-layout presenter-layout--${id}`} title={hint} aria-pressed={layout === id} onClick={() => chooseLayout(id)}
728
+ style={{ ...S.btn, ...(layout === id ? { background: '#2a2a2a', color: '#fff', borderColor: '#444' } : {}) }}><Icon name={icon} size={16} />{name}</button>)}
729
+ </span>
730
+ </PanelSection>
731
+ {remoteLayout && <PanelSection title="During the talk">
732
+ <span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>{overviewButton}{hotspotsButton}{fullscreenButton}</span>
733
+ </PanelSection>}
734
+ <PanelSection title="Look" class="presenter-look">
735
+ <span style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
736
+ <button type="button" onClick={() => setThemeModalOpen(true)} style={{ ...S.btn }} title="Choose the theme"><Icon name="grid" size={14} />{themeMeta?.title ?? theme}</button>
737
+ <span class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px' }}>
738
+ {['light', 'dark'].map(mode => <button key={mode} type="button" aria-pressed={shown.appearance === mode} title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'} aria-label={mode === 'light' ? 'Light' : 'Dark'}
739
+ onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
740
+ style={{ ...S.btn, padding: '5px 8px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}><Icon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>)}
741
+ </span>
742
+ </span>
743
+ <span class="presenter-palette" role="group" aria-label="Palette" style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
744
+ {offered.map(p => <button key={p.id} type="button" class="presenter-palette-option" title={p.description} aria-pressed={shown.palette?.id === p.id}
745
+ onClick={() => setPalette(p.id === themeMeta?.palette ? '' : p.id)}
746
+ style={{ ...S.btn, justifyContent: 'flex-start', gap: '8px', border: 'none', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa' }}>
747
+ <PaletteSwatches tokens={p[shown.appearance] ?? {}} /><span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
748
+ </button>)}
749
+ </span>
750
+ {LANGUAGES.length > 0 && <span class="presenter-language" role="group" aria-label="Language" style={{ display: 'flex', gap: '2px', flexWrap: 'wrap' }}>
751
+ {LANGUAGES.map(code => <button key={code} type="button" aria-pressed={lang === code} title={`${languageName(code)} (L switches)`} onClick={() => setLang(code)}
752
+ style={{ ...S.btn, padding: '5px 9px', ...(lang === code ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}>{code.toUpperCase()}</button>)}
753
+ </span>}
754
+ </PanelSection>
755
+ <PanelSection title="Timer">
756
+ <span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
757
+ <button type="button" class="presenter-timer-reset" style={S.btn} onClick={timer.reset}><Icon name="reset" size={16} />Reset the timer</button>
758
+ {!timer.rehearsing && <button type="button" class="presenter-rehearse" style={S.btn} title="Time each slide, then save the times into the deck" onClick={timer.startRehearsal}><Icon name="rehearse" size={16} />Rehearse</button>}
759
+ </span>
760
+ </PanelSection>
761
+ <label class="presenter-note-size" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
762
+ <span>Size of the notes</span>
763
+ <span aria-hidden="true" style={{ marginInlineStart: 'auto', fontSize: '11px', color: '#888' }}>A</span>
764
+ <input type="range" min="11" max="26" step="1" value={noteSize} aria-label="Size of the notes" onInput={e => setNoteSize(Number(e.currentTarget.value))} style={{ width: '96px', accentColor: '#888', cursor: 'pointer' }} />
765
+ <span aria-hidden="true" style={{ fontSize: '17px', color: '#888' }}>A</span>
766
+ </label>
767
+ {launchPage && <a class="presenter-home" href={launchPage} style={{ ...S.btn, alignSelf: 'flex-start', boxSizing: 'border-box', textDecoration: 'none' }}><Icon name="home" size={16} />Back to the launch page</a>}
768
+ </Popover>
769
+ const notesBox = <div class="notes-md" dir={notesDir} style={{ flex: 1, minHeight: 0, overflow: 'auto', margin: 0, padding: '12px 14px', lineHeight: 1.55, background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', fontSize: `${noteSize}px`, color: note ? '#c8c8c8' : '#3a3a3a' }}
770
+ dangerouslySetInnerHTML={{ __html: notesHtml }} />
771
+ const nextTitle = hasNext ? `Next: ${slideTitleOf(slides[index + 1]) || `slide ${index + 2}`}` : 'Last slide'
772
+ const schedule = timed && <div class="presenter-schedule" style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
773
+ <span>Slide <span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span>{planned ? <span style={{ color: '#555' }}> of {formatClock(planned)}</span> : null}</span>
774
+ <span><span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span><span style={{ color: '#555' }}> · of {formatClock(plan.total)}</span></span>
775
+ </div>
776
+ const rehearsing = timer.rehearsing && <div class="presenter-rehearsing" role="status" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, padding: '6px 10px', borderRadius: '8px', border: '1px solid #5c2b28', color: '#f28b82' }}>
777
+ <span>● Rehearsing{timer.running ? '' : ' (paused)'}: this slide {formatClock(timer.slideElapsed)}</span>
778
+ <button style={{ ...S.btn, padding: '2px 10px' }} onClick={finishRehearsal}>Finish</button>
779
+ </div>
780
+ const blankNote = blank && <div class="presenter-blank-note" role="status" style={{ position: 'fixed', left: layout === 'speaker' ? 'calc(100% / 3)' : '50%', bottom: remoteLayout ? '110px' : '76px', transform: 'translateX(-50%)', zIndex: 18, display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 14px', borderRadius: '999px', background: 'rgba(17,17,17,0.92)', border: '1px solid #3a3a3a', color: '#f0f0f0', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '14px', pointerEvents: 'none' }}>
781
+ <PresenterIcon name="blank" size={16} />The audience sees a black screen
782
+ </div>
783
+ const font = { fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', color: '#ccc' }
784
+ const extras = <>
785
+ {blankNote}
786
+ {reacting && <Reactions style={remoteLayout ? { left: '16px', bottom: '100px' } : undefined} />}
787
+ {helpOpen && <KeyHelp
788
+ context={{ view: 'presenter', blank, join: hasActivities() || reacting, joinShown: join, overview: overviewOpen, hotspots: slideHotspots, hotspotSwitch: hotspotsOn, inking, fullscreen: canFullscreen, drawer: slideLayout, languages: LANGUAGES.length > 0 }}
789
+ onClose={() => setHelpOpen(false)}
790
+ />}
791
+ {summaryPanel}
792
+ {themeModalOpen && themeModal}
793
+ </>
794
+
795
+ // A phone in the hand: the slide small at the top, what can be done on it,
796
+ // the notes, and the buttons to go on and back where the thumb is.
797
+ if (remoteLayout) {
798
+ const big = { ...S.btn, height: '72px', fontSize: '18px', fontWeight: 600, borderRadius: '14px' }
799
+ return (
800
+ <div class="presenter presenter--remote" style={{ ...font, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: '10px', height: '100dvh', boxSizing: 'border-box', padding: 'max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left))', background: '#111', overflow: 'hidden', touchAction: 'manipulation' }}>
801
+ <div class="presenter-remote-bar" style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
802
+ <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ color: '#888', whiteSpace: 'nowrap' }} />
803
+ <span style={{ flex: 1 }} />
804
+ {finishButton}{timerChip}{joinButton}{blankButton}{connections}{settings}
805
+ </div>
806
+ <div style={{ position: 'relative', flexShrink: 0, width: '100%', maxHeight: '32dvh', aspectRatio: '16/9', margin: '0 auto', borderRadius: '8px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
807
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ width: '100%', height: '100%', border: '0', display: 'block' }} />
808
+ </div>
809
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', flexShrink: 0, fontSize: '13px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>
810
+ <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{nextTitle}</span>
811
+ {timed && <span style={{ flexShrink: 0 }}><span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span> · <span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span></span>}
812
+ </div>
813
+ {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
814
+ {slideBar(true)}
815
+ {notesBox}
816
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '10px', flexShrink: 0 }}>
817
+ <button class="presenter-remote-prev" style={big} aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={28} /></button>
818
+ <button class="presenter-remote-next" style={{ ...big, background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }} aria-label="Next" onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={28} /></button>
819
+ </div>
820
+ {extras}
821
+ </div>
822
+ )
823
+ }
824
+
825
+ // The next slide and the notes: the speaker layout's column, the iPad's drawer.
826
+ const aside = <aside class="presenter-aside" style={{
827
+ ...font, display: slideLayout && !drawerOpen ? 'none' : 'flex', flexDirection: 'column', gap: '12px', padding: '14px', boxSizing: 'border-box',
828
+ borderLeft: '1px solid #2a2a2a', background: '#111', overflow: 'hidden', minHeight: 0,
829
+ ...(slideLayout ? { position: 'fixed', top: 0, right: 0, bottom: 0, width: 'min(400px, 92vw)', zIndex: 19, paddingTop: '68px', boxShadow: '-12px 0 32px rgba(0,0,0,0.5)' } : {}),
830
+ }}>
831
+ {!slideLayout && <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
832
+ <SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} style={{ color: '#888', fontVariantNumeric: 'tabular-nums' }} />
833
+ <span style={{ flex: 1 }} />
834
+ {finishButton}{timerChip}{audienceButton}{connections}{settings}
835
+ </div>}
836
+ {slideLayout && <span style={{ display: 'none' }}><SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} /></span>}
837
+ {schedule}
838
+ {rehearsing}
839
+ {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
840
+ {hasNext ? <div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexShrink: 0 }}>
841
+ <div style={{ width: '46%', flexShrink: 0, aspectRatio: '16/9', borderRadius: '6px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
842
+ <iframe ref={previewRef} title="Next slide preview" src={previewSrc} scrolling="no" style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }} />
843
+ </div>
844
+ <span style={{ color: '#888', lineHeight: 1.4 }}>{nextTitle}</span>
845
+ </div> : <span style={{ color: '#666', flexShrink: 0 }}>Last slide</span>}
846
+ {notesBox}
847
+ </aside>
848
+
849
+ // The iPad: the slide fills the screen, one slim bar on top.
850
+ if (slideLayout) return (
851
+ <div class="presenter presenter--slide" style={{ height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
852
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ width: '100%', height: '100%', border: '0', display: 'block' }} />
853
+ <div class="presenter-pill" role="toolbar" aria-label="Presenter" style={{ ...font, 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', flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 'calc(100vw - 20px)', padding: '4px', borderRadius: '14px', background: 'rgba(17,17,17,0.9)', border: '1px solid #2a2a2a', fontVariantNumeric: 'tabular-nums' }}>
854
+ <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ padding: '0 4px', color: '#888' }} />
855
+ {finishButton}{timerChip}
856
+ {separator}{nav}
857
+ {separator}{drawButton}{blankButton}{joinButton}{overviewButton}{hotspotsButton}
858
+ {(slideControls.items.length > 0 || slideControls.hotspots > 0) && <>{separator}{slideBar(false)}</>}
859
+ {separator}
860
+ <ToolButton class="presenter-notes" icon="file-text" label="Notes and the next slide (N)" pressed={drawerOpen} onClick={() => setDrawerOpen(open => !open)} />
861
+ {fullscreenButton}{audienceButton}{connections}{settings}
862
+ </div>
863
+ {aside}
864
+ {extras}
865
+ </div>
866
+ )
867
+
868
+ // A computer: the slide with its toolbar, the time, next slide and notes beside it.
869
+ return (
870
+ <div class="presenter presenter--speaker" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden' }}>
871
+ <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
872
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ flex: 1, minHeight: 0, width: '100%', border: '0', display: 'block' }} />
873
+ <div class="presenter-toolbar" role="toolbar" aria-label="Presenter" style={{ ...font, display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap', padding: '8px 12px', borderTop: '1px solid #2a2a2a', background: '#111' }}>
874
+ {nav}
875
+ {separator}{drawButton}{blankButton}{joinButton}{overviewButton}{hotspotsButton}{fullscreenButton}
876
+ {(slideControls.items.length > 0 || slideControls.hotspots > 0) && <>{separator}<span style={{ ...S.label, marginBottom: 0, marginInlineEnd: '4px' }}>This slide</span>{slideBar(false)}</>}
877
+ <span style={{ flex: 1 }} />
878
+ <ToolButton class="presenter-help" icon="guides" label="All keys (?)" onClick={() => setHelpOpen(open => !open)} />
879
+ </div>
880
+ </div>
881
+ {aside}
882
+ {extras}
739
883
  </div>
740
884
  )
741
885
  }
742
886
 
887
+ // The join code over the slide (J in the presenter view): the room scans it
888
+ // from the projector, a phone following the talk shows it too. A click on it,
889
+ // or the next step, hides it again where the screen is not only watching.
890
+ function JoinOverlay({ stage }) {
891
+ const [on, setOn] = useState(stage.join)
892
+ useEffect(() => {
893
+ const follow = () => setOn(stage.join)
894
+ stage.addEventListener('joinchange', follow)
895
+ return () => stage.removeEventListener('joinchange', follow)
896
+ }, [])
897
+ if (!on) return null
898
+ const size = Math.round(Math.min(window.innerHeight * 0.55, window.innerWidth * 0.5, 520))
899
+ return <div class="join-overlay export-hidden" role="dialog" aria-label={t('join.scan')}
900
+ onClick={() => { if (!stage.hasAttribute('data-watching')) stage.setJoin(false, 'click') }}
901
+ style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0, 0, 0, 0.72)' }}>
902
+ <div style={{ background: '#fff', color: '#111', borderRadius: '18px', padding: '24px 32px', '--qr-ink': '#000', '--qr-bg': '#fff', '--ink': '#111', '--muted': '#555' }}>
903
+ <QrCodeTag join size={String(size)} />
904
+ </div>
905
+ </div>
906
+ }
907
+
743
908
  // Renders (or re-renders) the deck stage. Slides are keyed by id so an edit to
744
909
  // one slide leaves the others and the stage's position untouched.
745
910
  let unfit = null
@@ -801,6 +966,7 @@ async function init() {
801
966
  setDeckLanguage(deckConfig)
802
967
  document.documentElement.lang = deckLanguage()
803
968
  configureLive(deckConfig)
969
+ reactionsWanted = !!reactionsOf(deckConfig)
804
970
  setSavedResults(savedResults)
805
971
  const { slides } = parsed
806
972
  const presenterMode = view === 'presenter'
@@ -812,12 +978,17 @@ async function init() {
812
978
  // The deck window and the presenter's main frame draw; the audience window
813
979
  // and the next-slide preview show what is drawn.
814
980
  const drawHere = !audienceMode && !followMode && (!embedded || url.searchParams.get('draw') === '1')
981
+ // What deck components learn from useView() (component.js).
982
+ setView(audienceMode ? 'audience' : followMode ? 'follow' : readerMode ? 'reader' : embedded ? (drawHere && !editorMode ? 'presenter' : 'preview') : 'deck')
815
983
  const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
816
984
 
817
985
  injectSpeakerNotes(slides)
818
986
 
819
987
  if (presenterMode) {
820
988
  actAsPresenter()
989
+ // A tool of mdeck's, like the launch page: its mark in the tab.
990
+ const icon = document.querySelector('link[rel=icon]') ?? document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
991
+ icon.href = mdeckMark
821
992
  followPresence()
822
993
  document.body.style.margin = '0'
823
994
  const s = document.createElement('style')
@@ -850,6 +1021,14 @@ async function init() {
850
1021
  // Digits and Escape typed in the presenter view, and its hotspots switch.
851
1022
  else if (ctrl.command === 'hotspotKey' && typeof ctrl.value === 'string') stage.hotspotKey(ctrl.value)
852
1023
  else if (ctrl.command === 'hotspots') stage.hotspotsForced = ctrl.value
1024
+ // The phone clicker pressed one of the slide's controls.
1025
+ else if (ctrl.command === 'slideControl' && Number.isInteger(ctrl.value)) pressSlideControl(stage, ctrl.value)
1026
+ // B in the presenter view: the screens the audience sees go black.
1027
+ // O in the presenter view: the overview opens in its own slide frame.
1028
+ else if (ctrl.command === 'overview') stage.overview = ctrl.value === 'toggle' ? !stage.overview : !!ctrl.value
1029
+ else if (ctrl.command === 'overviewKey' && typeof ctrl.value === 'string') stage.overviewKey(ctrl.value)
1030
+ else if (ctrl.command === 'blank') stage.setBlank(ctrl.value === 'toggle' ? !stage.blank : !!ctrl.value, 'keyboard')
1031
+ else if (ctrl.command === 'join') stage.setJoin(ctrl.value === 'toggle' ? !stage.join : !!ctrl.value, 'keyboard')
853
1032
  }
854
1033
 
855
1034
  // The same slide and step in another language, its words and mdeck's own;
@@ -913,6 +1092,15 @@ async function init() {
913
1092
 
914
1093
  // Drawing (D) in the full-screen deck and the presenter's main frame.
915
1094
  const inkStage = document.querySelector('deck-stage')
1095
+ // The join code over the slide, on every screen but the next-slide preview;
1096
+ // J shows it in a deck phones join.
1097
+ if (inkStage && (hasActivities() || reactionsOf(deckConfig))) inkStage.setAttribute('data-joinable', '')
1098
+ if (inkStage && !(embedded && !drawHere)) {
1099
+ const host = document.createElement('div')
1100
+ host.className = 'join-overlay-host'
1101
+ document.body.appendChild(host)
1102
+ render(<JoinOverlay stage={inkStage} />, host)
1103
+ }
916
1104
  // Main slide frames and audience windows both send and follow navigation.
917
1105
  // Share their position connection with ink; previews stay on local channels.
918
1106
  if (inkStage && (drawHere || audienceMode)) {
@@ -938,8 +1126,37 @@ async function init() {
938
1126
  }
939
1127
 
940
1128
  // The presenter's slide shows the hotspots' keys; no screen the audience sees does.
941
- if (inkStage && embedded && drawHere) inkStage.setAttribute('data-hotspot-badges', '')
942
- if (inkStage && drawHere) {
1129
+ // A black screen only veils it.
1130
+ // The phone remote's small slide only shows: no keys, no drawing.
1131
+ const remoteFrame = embedded && url.searchParams.get('remote') === '1'
1132
+ if (inkStage && embedded && drawHere) {
1133
+ if (!remoteFrame) inkStage.setAttribute('data-hotspot-badges', '')
1134
+ inkStage.setAttribute('data-presenter', '')
1135
+ // The presenter view routes the keys to the overview while it is open.
1136
+ inkStage.addEventListener('overviewchange', event => window.parent.postMessage({ overview: event.detail.open }, window.location.origin))
1137
+ // A frame that reloaded (another theme) has none open.
1138
+ window.parent.postMessage({ overview: false }, window.location.origin)
1139
+ }
1140
+ // ? lists the keys that work now: in a deck window over the slide; in the
1141
+ // presenter's slide frame it goes to the presenter view, which shows them.
1142
+ if (inkStage && !embedded && !audienceMode && !followMode) attachKeyHelp(inkStage, { canDraw: drawHere })
1143
+ else if (embedded && drawHere) {
1144
+ window.addEventListener('keydown', event => {
1145
+ if (!isHelpKey(event) || event.target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return
1146
+ event.preventDefault()
1147
+ window.parent.postMessage({ keyHelp: 'toggle' }, window.location.origin)
1148
+ })
1149
+ }
1150
+ // The presenter view shows the slide's own controls in its toolbar, the
1151
+ // clicker as big buttons (slideControls.js).
1152
+ if (inkStage && embedded && drawHere) watchSlideControls(inkStage, controls => window.parent.postMessage({ slideControls: controls }, window.location.origin))
1153
+ if (inkStage && remoteFrame) {
1154
+ watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'presenter', onPresence: presence => {
1155
+ watchedOnServer = (presence?.views ?? []).some(entry => entry.view === 'audience' || entry.view === 'deck')
1156
+ markWatched()
1157
+ window.parent.postMessage({ presence }, window.location.origin)
1158
+ } })] })
1159
+ } else if (inkStage && drawHere) {
943
1160
  attachInk(inkStage, {
944
1161
  storageKey: inkStorageKey,
945
1162
  // The presenter's main frame tells the presenter view who is connected.
@@ -968,7 +1185,12 @@ async function init() {
968
1185
  } else if (inkStage && followMode) {
969
1186
  // The presenter's slide and ink, read from the stage room; counted as a phone.
970
1187
  inkStage.setAttribute('data-follower', '')
971
- onStagePosition(position => { if (position.lang) switchLanguage(position.lang, { announce: false }) })
1188
+ inkStage.setAttribute('data-watching', '')
1189
+ onStagePosition(position => {
1190
+ if (position.lang) switchLanguage(position.lang, { announce: false })
1191
+ if (typeof position.blank === 'boolean') inkStage.setBlank(position.blank, 'sync')
1192
+ if (typeof position.join === 'boolean') inkStage.setJoin(position.join, 'sync')
1193
+ })
972
1194
  startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
973
1195
  watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
974
1196
  } else if (inkStage && audienceMode) {