mdeck 3.11.0 → 3.12.1
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 +28 -0
- package/README.md +3 -3
- package/assets/base.css +2 -0
- package/docs/reference/authoring.md +48 -7
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +4 -2
- package/docs/site/content/drawing.md +4 -4
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +3 -1
- package/docs/site/content/presenting.md +29 -8
- package/docs/site/content/writing.md +27 -0
- package/examples/showcase/slides.ar.md +5 -5
- package/examples/showcase/slides.de.md +5 -5
- package/examples/showcase/slides.de.results.json +27 -0
- package/examples/showcase/slides.en.md +5 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +4 -1
- package/src/build/check.js +27 -6
- package/src/build/components.js +5 -3
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +64 -16
- package/src/build/timingPlugin.js +34 -18
- package/src/cli/args.js +3 -3
- package/src/cli/index.js +49 -10
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/icons.js +6 -1
- package/src/core/labels.js +3 -0
- package/src/core/parseSlides.js +14 -4
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/validateDeck.js +6 -2
- package/src/home/Home.jsx +38 -93
- package/src/home/home.css +5 -17
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +12 -1
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +29 -7
- package/src/live/server.js +15 -1
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/deck-stage.js +322 -17
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/room.js +6 -6
- package/src/runtime/main.jsx +396 -303
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/useTalkTimer.js +4 -2
package/src/runtime/main.jsx
CHANGED
|
@@ -33,14 +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'
|
|
39
38
|
import { useTalkTimer } from './useTalkTimer.js'
|
|
40
39
|
import { devPath } from '../core/devPath.js'
|
|
41
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'
|
|
42
45
|
import { RehearsalSummary } from './RehearsalSummary.jsx'
|
|
43
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'
|
|
44
51
|
|
|
45
52
|
const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
|
|
46
53
|
|
|
@@ -108,10 +115,11 @@ function languageName(lang) {
|
|
|
108
115
|
// announcement, the views connected), so the answer is kept until the text
|
|
109
116
|
// changes: finding the tags reads the whole deck, which on an iPad made each
|
|
110
117
|
// slide change noticeably slower.
|
|
111
|
-
|
|
118
|
+
// A deck with reactions (`reactions:`) wants the phones too, between activities.
|
|
119
|
+
let activitiesOf = null, activitiesFound = false, reactionsWanted = false
|
|
112
120
|
const hasActivities = () => {
|
|
113
121
|
if (activitiesOf !== slidesContent) { activitiesOf = slidesContent; activitiesFound = roomsIn(slidesContent).length > 0 }
|
|
114
|
-
return activitiesFound
|
|
122
|
+
return activitiesFound || reactionsWanted
|
|
115
123
|
}
|
|
116
124
|
// The heading the phones show for a room, found once per deck.
|
|
117
125
|
const titlesByDeck = new WeakMap()
|
|
@@ -140,17 +148,10 @@ function announceSlide(deck, index, { initial = false } = {}) {
|
|
|
140
148
|
// Whether this presenter screen moves the phones along, shown only for decks
|
|
141
149
|
// with activities: a failure used to be silent.
|
|
142
150
|
const STEERING = {
|
|
143
|
-
'no-code': 'Phones
|
|
144
|
-
'wrong-code':
|
|
145
|
-
unreachable: 'Phones
|
|
146
|
-
'other-screen': 'Phones follow another screen
|
|
147
|
-
}
|
|
148
|
-
function SteeringNote() {
|
|
149
|
-
const steering = useSteering()
|
|
150
|
-
if (!hasActivities() || !steering) return null
|
|
151
|
-
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' }}>
|
|
152
|
-
{steering.ok ? 'Phones follow this presentation.' : STEERING[steering.reason]}
|
|
153
|
-
</div>
|
|
151
|
+
'no-code': 'Phones don’t follow: no server key here.',
|
|
152
|
+
'wrong-code': 'Phones don’t follow: key refused.',
|
|
153
|
+
unreachable: 'Phones don’t follow: server not answering.',
|
|
154
|
+
'other-screen': 'Phones follow another screen. Change slide to take them back.',
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
function injectSpeakerNotes(slides) {
|
|
@@ -165,12 +166,15 @@ function injectSpeakerNotes(slides) {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
// URL for the presenter's own iframe and preview pane (uses postMessage)
|
|
168
|
-
function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false } = {}) {
|
|
169
|
+
function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false, remote = false } = {}) {
|
|
169
170
|
const url = new URL(window.location.href)
|
|
170
171
|
url.searchParams.delete('v')
|
|
171
172
|
url.searchParams.set('view', 'deck')
|
|
172
173
|
url.searchParams.set('embedded', '1')
|
|
173
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')
|
|
174
178
|
if (theme) url.searchParams.set('theme', theme)
|
|
175
179
|
else url.searchParams.delete('theme')
|
|
176
180
|
setLookParams(url, palette, appearance)
|
|
@@ -275,6 +279,32 @@ function SlideJump({ index, total, onJump, pad = 0, style }) {
|
|
|
275
279
|
)
|
|
276
280
|
}
|
|
277
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
|
+
|
|
278
308
|
function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
279
309
|
const [index, setIndex] = useState(0)
|
|
280
310
|
// The notes, slide count and phones follow the language shown.
|
|
@@ -289,8 +319,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
289
319
|
const [palette, setPalette] = useState(deckConfig.palette ?? '')
|
|
290
320
|
const [appearance, setAppearance] = useState(deckConfig.appearance ?? '')
|
|
291
321
|
const [audienceConnected, setAudienceConnected] = useState(false)
|
|
292
|
-
|
|
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
|
+
})
|
|
293
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()
|
|
294
341
|
// Hotspots: the deck's setting until the presenter switches them (H).
|
|
295
342
|
const deckHotspots = (normalMode(deckConfig.hotspots) ?? 'off') !== 'off'
|
|
296
343
|
const [hotspotsForced, setHotspotsForced] = useState(null)
|
|
@@ -312,10 +359,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
312
359
|
return () => document.removeEventListener('gesturestart', stop)
|
|
313
360
|
}, [])
|
|
314
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.
|
|
315
364
|
const [layout, setLayout] = useState(() => {
|
|
316
|
-
|
|
317
|
-
|
|
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()
|
|
318
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])
|
|
319
381
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
320
382
|
const slideLayout = layout === 'slide'
|
|
321
383
|
function chooseLayout(next) {
|
|
@@ -331,15 +393,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
331
393
|
const fullscreenLabel = isFullscreen ? 'Exit full screen (F)' : 'Full screen (F)'
|
|
332
394
|
const fullscreenIcon = isFullscreen ? 'fullscreen-exit' : 'fullscreen'
|
|
333
395
|
|
|
334
|
-
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
335
|
-
// The theme row shows unless the presenter folded it, like the ink toolbar,
|
|
336
|
-
// into one round button.
|
|
337
|
-
const [lookFolded, setLookFolded] = useState(() => { try { return localStorage.getItem('mdeck-presenter-look') === 'folded' } catch { return false } })
|
|
338
|
-
function foldLook(folded) {
|
|
339
|
-
setLookFolded(folded)
|
|
340
|
-
setPaletteOpen(false)
|
|
341
|
-
try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
|
|
342
|
-
}
|
|
343
396
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
|
344
397
|
// The clock, and the slides' times (`time:`) to hold it against. Times a
|
|
345
398
|
// rehearsal saved count at once; the deck file is not read again mid-talk.
|
|
@@ -370,6 +423,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
370
423
|
summaryRef.current = !!summary
|
|
371
424
|
|
|
372
425
|
const iframeRef = useRef(null)
|
|
426
|
+
const toggleOverviewRef = useRef(null)
|
|
373
427
|
const previewRef = useRef(null)
|
|
374
428
|
const audienceRef = useRef(null)
|
|
375
429
|
const bcRef = useRef(null)
|
|
@@ -380,7 +434,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
380
434
|
const langRef = useRef(lang)
|
|
381
435
|
langRef.current = lang
|
|
382
436
|
const stateRef = useRef({ index: 0, step: -1 })
|
|
383
|
-
const paletteDropRef = useRef(null)
|
|
384
437
|
indexRef.current = index
|
|
385
438
|
// Phones see the look of the slide this view shows, read from its frame.
|
|
386
439
|
useEffect(() => { setLookSource(() => iframeRef.current?.contentDocument) }, [])
|
|
@@ -398,7 +451,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
398
451
|
useEffect(() => { if (palette && !offered.some(p => p.id === palette)) setPalette('') }, [theme])
|
|
399
452
|
const notes = useMemo(() => slides.map(s => s.meta?.notes ?? ''), [slides])
|
|
400
453
|
// Preserve current slide when theme/palette causes an iframe reload
|
|
401
|
-
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])
|
|
402
455
|
// previewSrc only recomputes on a change of look; slide changes use postMessage
|
|
403
456
|
const previewSrc = useMemo(
|
|
404
457
|
() => buildChildUrl(theme, palette, appearance, indexRef.current + 1),
|
|
@@ -432,6 +485,11 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
432
485
|
}
|
|
433
486
|
if (source !== iframeRef.current?.contentWindow) return
|
|
434
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 }
|
|
435
493
|
// Another screen (a paired iPad) switched the language; the notes follow.
|
|
436
494
|
if (LANGUAGES.includes(data?.deckLanguage)) { setLang(data.deckLanguage); return }
|
|
437
495
|
if (!data?.deckStateChanged) return
|
|
@@ -454,6 +512,8 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
454
512
|
stateRef.current = state
|
|
455
513
|
atEndRef.current = !!data.atEnd
|
|
456
514
|
setIndex(i)
|
|
515
|
+
setBlank(!!state.blank)
|
|
516
|
+
setJoin(!!state.join)
|
|
457
517
|
if (data.reason !== 'sync') {
|
|
458
518
|
bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
|
|
459
519
|
}
|
|
@@ -473,6 +533,12 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
473
533
|
if (e.target?.matches?.('input, select, textarea')) return
|
|
474
534
|
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
475
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 }
|
|
476
542
|
if (e.key === 't' || e.key === 'T') { e.preventDefault(); timerRef.current.toggle(); return }
|
|
477
543
|
const cmd =
|
|
478
544
|
(e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
|
|
@@ -483,7 +549,10 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
483
549
|
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
|
|
484
550
|
if ((e.key === 'l' || e.key === 'L') && LANGUAGES.length) { e.preventDefault(); nextLanguage(); return }
|
|
485
551
|
if (e.key === 'h' || e.key === 'H') { e.preventDefault(); toggleHotspots(); return }
|
|
486
|
-
//
|
|
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.
|
|
487
556
|
if (/^[0-9]$/.test(e.key) || e.key === 'Escape') { if (e.key !== 'Escape') e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'hotspotKey', e.key); return }
|
|
488
557
|
if (!cmd) return
|
|
489
558
|
e.preventDefault()
|
|
@@ -511,15 +580,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
511
580
|
return () => window.removeEventListener('keydown', handler)
|
|
512
581
|
}, [themeModalOpen])
|
|
513
582
|
|
|
514
|
-
// Close palette dropdown on outside click
|
|
515
|
-
useEffect(() => {
|
|
516
|
-
if (!paletteOpen) return
|
|
517
|
-
function handler(e) {
|
|
518
|
-
if (!paletteDropRef.current?.contains(e.target)) setPaletteOpen(false)
|
|
519
|
-
}
|
|
520
|
-
document.addEventListener('mousedown', handler)
|
|
521
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
522
|
-
}, [paletteOpen])
|
|
523
583
|
|
|
524
584
|
// When theme/palette changes, tell the audience to hot-swap its theme without
|
|
525
585
|
// a reload. Phones pick it up with the next announcement.
|
|
@@ -540,6 +600,13 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
540
600
|
}, [lang])
|
|
541
601
|
const nextLanguage = () => setLang(current => LANGUAGES[(LANGUAGES.indexOf(current) + 1) % LANGUAGES.length])
|
|
542
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
|
+
|
|
543
610
|
// Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
|
|
544
611
|
function navCommand(cmd, value) {
|
|
545
612
|
if (cmd === 'next' && atRehearsalEnd()) return finishRehearsal()
|
|
@@ -556,6 +623,8 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
556
623
|
setAudienceConnected(true)
|
|
557
624
|
}
|
|
558
625
|
|
|
626
|
+
// Whether the phones follow this screen; told only when they do not.
|
|
627
|
+
const steering = useSteering()
|
|
559
628
|
const note = notes[index] || ''
|
|
560
629
|
const hasNext = index + 1 < slides.length
|
|
561
630
|
|
|
@@ -569,268 +638,30 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
569
638
|
const deltaText = Math.abs(delta) < 30 ? 'on time' : delta > 0 ? `${formatClock(delta)} behind` : `${formatClock(-delta)} ahead`
|
|
570
639
|
const deltaColor = Math.abs(delta) < 30 ? '#888' : delta > 0 ? '#f28b82' : '#81c995'
|
|
571
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
|
|
572
645
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
<button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
|
|
590
|
-
<button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
|
|
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>
|
|
592
|
-
<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>
|
|
593
|
-
<button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
|
|
594
|
-
{canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
|
|
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>}
|
|
597
|
-
</div>
|
|
598
|
-
)}
|
|
599
|
-
<aside class="presenter-aside" style={{
|
|
600
|
-
display: slideLayout && !drawerOpen ? 'none' : 'flex',
|
|
601
|
-
flexDirection: 'column',
|
|
602
|
-
gap: '14px',
|
|
603
|
-
padding: '16px',
|
|
604
|
-
borderLeft: '1px solid #2a2a2a',
|
|
605
|
-
background: '#111',
|
|
606
|
-
color: '#ccc',
|
|
607
|
-
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
608
|
-
fontSize: '13px',
|
|
609
|
-
overflow: 'hidden',
|
|
610
|
-
...(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)' } : {}),
|
|
611
|
-
}}>
|
|
612
|
-
|
|
613
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
|
614
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
615
|
-
<span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '14px' }}>Speaker View</span>
|
|
616
|
-
<ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888' }} />
|
|
617
|
-
</span>
|
|
618
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
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>}
|
|
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>}
|
|
622
|
-
<SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }} />
|
|
623
|
-
</span>
|
|
624
|
-
</div>
|
|
625
|
-
|
|
626
|
-
<SteeringNote />
|
|
627
|
-
|
|
628
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
629
|
-
<span style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
|
|
630
|
-
<span class="presenter-clock" style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timer.running ? '#f0f0f0' : '#555', fontWeight: 300 }}>{clock}</span>
|
|
631
|
-
{timed && <span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>of {formatClock(plan.total)}</span>}
|
|
632
|
-
</span>
|
|
633
|
-
<div style={{ display: 'flex', gap: '4px' }}>
|
|
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} />
|
|
636
|
-
</button>
|
|
637
|
-
<button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={timer.reset}><PresenterIcon name="reset" size={16} /></button>
|
|
638
|
-
{!timer.rehearsing && <button class="presenter-rehearse" style={S.btn} title="Rehearse: time each slide, then save the times into the deck" onClick={timer.startRehearsal}><PresenterIcon name="rehearse" size={16} />Rehearse</button>}
|
|
639
|
-
</div>
|
|
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>}
|
|
650
|
-
|
|
651
|
-
{hasNext && (
|
|
652
|
-
<div style={{ flexShrink: 0 }}>
|
|
653
|
-
<span style={S.label}>Next slide</span>
|
|
654
|
-
<div style={{ aspectRatio: '16/9', borderRadius: '4px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
|
|
655
|
-
<iframe
|
|
656
|
-
ref={previewRef}
|
|
657
|
-
title="Next slide preview"
|
|
658
|
-
src={previewSrc}
|
|
659
|
-
scrolling="no"
|
|
660
|
-
style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }}
|
|
661
|
-
/>
|
|
662
|
-
</div>
|
|
663
|
-
</div>
|
|
664
|
-
)}
|
|
665
|
-
|
|
666
|
-
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
667
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
|
|
668
|
-
<span style={{ ...S.label, marginBottom: 0 }}>Notes</span>
|
|
669
|
-
<input
|
|
670
|
-
type="range" min="11" max="22" step="1" value={noteSize}
|
|
671
|
-
onInput={e => setNoteSize(Number(e.currentTarget.value))}
|
|
672
|
-
style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
|
|
673
|
-
/>
|
|
674
|
-
</div>
|
|
675
|
-
<div class="notes-md" dir={textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined} style={{
|
|
676
|
-
flex: 1,
|
|
677
|
-
margin: 0,
|
|
678
|
-
padding: '10px 12px',
|
|
679
|
-
lineHeight: 1.6,
|
|
680
|
-
background: '#0a0a0a',
|
|
681
|
-
border: '1px solid #2a2a2a',
|
|
682
|
-
borderRadius: '5px',
|
|
683
|
-
fontSize: `${noteSize}px`,
|
|
684
|
-
color: note ? '#c8c8c8' : '#3a3a3a',
|
|
685
|
-
fontFamily: 'inherit',
|
|
686
|
-
overflow: 'auto',
|
|
687
|
-
}}
|
|
688
|
-
dangerouslySetInnerHTML={{
|
|
689
|
-
__html: note
|
|
690
|
-
? marked.parse(renderCitations(note).markdown)
|
|
691
|
-
: '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
|
|
692
|
-
}}
|
|
693
|
-
/>
|
|
694
|
-
</div>
|
|
695
|
-
|
|
696
|
-
{!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
|
|
697
|
-
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
698
|
-
<span style={S.label}>Theme</span>
|
|
699
|
-
<button
|
|
700
|
-
onClick={() => setThemeModalOpen(true)}
|
|
701
|
-
style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
|
|
702
|
-
>
|
|
703
|
-
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{theme}</span>
|
|
704
|
-
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
|
|
705
|
-
</button>
|
|
706
|
-
</label>
|
|
707
|
-
<label style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
708
|
-
<span style={S.label}>Palette</span>
|
|
709
|
-
<div ref={paletteDropRef} style={{ position: 'relative' }}>
|
|
710
|
-
<button
|
|
711
|
-
class="presenter-palette"
|
|
712
|
-
onClick={() => setPaletteOpen(o => !o)}
|
|
713
|
-
style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
|
|
714
|
-
>
|
|
715
|
-
<PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
|
|
716
|
-
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
|
|
717
|
-
<span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
|
|
718
|
-
</button>
|
|
719
|
-
{paletteOpen && (
|
|
720
|
-
<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)' }}>
|
|
721
|
-
{offered.map(p => (
|
|
722
|
-
<div
|
|
723
|
-
key={p.id}
|
|
724
|
-
class="presenter-palette-option"
|
|
725
|
-
title={p.description}
|
|
726
|
-
onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
|
|
727
|
-
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' }}
|
|
728
|
-
>
|
|
729
|
-
<PaletteSwatches tokens={p[shown.appearance] ?? {}} />
|
|
730
|
-
<span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
|
|
731
|
-
</div>
|
|
732
|
-
))}
|
|
733
|
-
</div>
|
|
734
|
-
)}
|
|
735
|
-
</div>
|
|
736
|
-
</label>
|
|
737
|
-
<label style={{ flexShrink: 0 }}>
|
|
738
|
-
<span style={S.label}>Mode</span>
|
|
739
|
-
<div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
740
|
-
{['light', 'dark'].map(mode => (
|
|
741
|
-
<button
|
|
742
|
-
key={mode}
|
|
743
|
-
aria-pressed={shown.appearance === mode}
|
|
744
|
-
title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'}
|
|
745
|
-
aria-label={mode === 'light' ? 'Light' : 'Dark'}
|
|
746
|
-
onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
|
|
747
|
-
style={{ ...S.btn, padding: '3px 7px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
|
|
748
|
-
><PresenterIcon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>
|
|
749
|
-
))}
|
|
750
|
-
</div>
|
|
751
|
-
</label>
|
|
752
|
-
{LANGUAGES.length > 0 && <label style={{ flexShrink: 0 }}>
|
|
753
|
-
<span style={S.label}>Language</span>
|
|
754
|
-
<div class="presenter-language" role="group" aria-label="Language" style={{ display: 'flex', gap: '2px', height: '28px' }}>
|
|
755
|
-
{LANGUAGES.map(code => (
|
|
756
|
-
<button
|
|
757
|
-
key={code}
|
|
758
|
-
aria-pressed={lang === code}
|
|
759
|
-
title={`${languageName(code)} (L switches)`}
|
|
760
|
-
onClick={() => setLang(code)}
|
|
761
|
-
style={{ ...S.btn, padding: '3px 7px', ...(lang === code ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
|
|
762
|
-
>{code.toUpperCase()}</button>
|
|
763
|
-
))}
|
|
764
|
-
</div>
|
|
765
|
-
</label>}
|
|
766
|
-
<button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
|
|
767
|
-
style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
|
|
768
|
-
><PresenterIcon name="fold" size={16} /></button>
|
|
769
|
-
</div>}
|
|
770
|
-
|
|
771
|
-
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
772
|
-
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
773
|
-
<button style={S.btn} onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={15} />Prev</button>
|
|
774
|
-
<button style={S.btn} onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={15} /></button>
|
|
775
|
-
<button style={S.btn} onClick={() => navCommand('reset')}>Reset</button>
|
|
776
|
-
<button
|
|
777
|
-
class="presenter-draw"
|
|
778
|
-
title="Draw on the slide (D)"
|
|
779
|
-
aria-pressed={inking}
|
|
780
|
-
style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
|
|
781
|
-
onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
|
|
782
|
-
><PresenterIcon name="pen" size={15} />Draw</button>
|
|
783
|
-
<button
|
|
784
|
-
class="presenter-hotspots"
|
|
785
|
-
title={hotspotsOn ? 'Hotspots on: 1–9 zoom in, 0 shows the whole slide (H)' : 'Hotspots off: 1–9 jump to slides (H)'}
|
|
786
|
-
aria-pressed={hotspotsOn}
|
|
787
|
-
aria-label="Hotspots"
|
|
788
|
-
style={{ ...S.btn, ...(hotspotsOn ? { background: '#2a2a2a', borderColor: '#444', color: '#f0f0f0' } : {}) }}
|
|
789
|
-
onClick={toggleHotspots}
|
|
790
|
-
><PresenterIcon name="hotspots" size={15} /></button>
|
|
791
|
-
{lookFolded && <button
|
|
792
|
-
class="presenter-look-unfold"
|
|
793
|
-
title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
|
|
794
|
-
aria-label="Show theme and palette"
|
|
795
|
-
onClick={() => foldLook(false)}
|
|
796
|
-
style={{ ...S.btn, position: 'relative', padding: 0, width: '32px', height: '32px', borderRadius: '50%' }}
|
|
797
|
-
>
|
|
798
|
-
<PresenterIcon name="look" size={16} />
|
|
799
|
-
<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' }} />
|
|
800
|
-
</button>}
|
|
801
|
-
<button
|
|
802
|
-
style={{
|
|
803
|
-
...S.btn,
|
|
804
|
-
marginLeft: 'auto',
|
|
805
|
-
color: audienceConnected ? '#444' : '#bbb',
|
|
806
|
-
}}
|
|
807
|
-
onClick={openAudienceWindow}
|
|
808
|
-
>
|
|
809
|
-
{audienceConnected ? 'Reconnect' : 'Audience'}
|
|
810
|
-
</button>
|
|
811
|
-
</div>
|
|
812
|
-
<div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
|
|
813
|
-
Arrow keys · Space · PgUp/PgDn · R to reset · T timer · H hotspots{slideHotspots ? ' · 1–9 zoom · 0 whole slide' : ''}
|
|
814
|
-
</div>
|
|
815
|
-
</div>
|
|
816
|
-
|
|
817
|
-
</aside>
|
|
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
|
+
/>
|
|
818
662
|
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
/>}
|
|
833
|
-
{themeModalOpen && (
|
|
663
|
+
// The theme picker, from the settings.
|
|
664
|
+
const themeModal = (
|
|
834
665
|
<div
|
|
835
666
|
style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
836
667
|
onClick={() => setThemeModalOpen(false)}
|
|
@@ -862,11 +693,218 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
|
|
|
862
693
|
</div>
|
|
863
694
|
</div>
|
|
864
695
|
</div>
|
|
865
|
-
|
|
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}
|
|
866
883
|
</div>
|
|
867
884
|
)
|
|
868
885
|
}
|
|
869
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
|
+
|
|
870
908
|
// Renders (or re-renders) the deck stage. Slides are keyed by id so an edit to
|
|
871
909
|
// one slide leaves the others and the stage's position untouched.
|
|
872
910
|
let unfit = null
|
|
@@ -928,6 +966,7 @@ async function init() {
|
|
|
928
966
|
setDeckLanguage(deckConfig)
|
|
929
967
|
document.documentElement.lang = deckLanguage()
|
|
930
968
|
configureLive(deckConfig)
|
|
969
|
+
reactionsWanted = !!reactionsOf(deckConfig)
|
|
931
970
|
setSavedResults(savedResults)
|
|
932
971
|
const { slides } = parsed
|
|
933
972
|
const presenterMode = view === 'presenter'
|
|
@@ -947,6 +986,9 @@ async function init() {
|
|
|
947
986
|
|
|
948
987
|
if (presenterMode) {
|
|
949
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
|
|
950
992
|
followPresence()
|
|
951
993
|
document.body.style.margin = '0'
|
|
952
994
|
const s = document.createElement('style')
|
|
@@ -979,6 +1021,14 @@ async function init() {
|
|
|
979
1021
|
// Digits and Escape typed in the presenter view, and its hotspots switch.
|
|
980
1022
|
else if (ctrl.command === 'hotspotKey' && typeof ctrl.value === 'string') stage.hotspotKey(ctrl.value)
|
|
981
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')
|
|
982
1032
|
}
|
|
983
1033
|
|
|
984
1034
|
// The same slide and step in another language, its words and mdeck's own;
|
|
@@ -1042,6 +1092,15 @@ async function init() {
|
|
|
1042
1092
|
|
|
1043
1093
|
// Drawing (D) in the full-screen deck and the presenter's main frame.
|
|
1044
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
|
+
}
|
|
1045
1104
|
// Main slide frames and audience windows both send and follow navigation.
|
|
1046
1105
|
// Share their position connection with ink; previews stay on local channels.
|
|
1047
1106
|
if (inkStage && (drawHere || audienceMode)) {
|
|
@@ -1067,8 +1126,37 @@ async function init() {
|
|
|
1067
1126
|
}
|
|
1068
1127
|
|
|
1069
1128
|
// The presenter's slide shows the hotspots' keys; no screen the audience sees does.
|
|
1070
|
-
|
|
1071
|
-
|
|
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) {
|
|
1072
1160
|
attachInk(inkStage, {
|
|
1073
1161
|
storageKey: inkStorageKey,
|
|
1074
1162
|
// The presenter's main frame tells the presenter view who is connected.
|
|
@@ -1097,7 +1185,12 @@ async function init() {
|
|
|
1097
1185
|
} else if (inkStage && followMode) {
|
|
1098
1186
|
// The presenter's slide and ink, read from the stage room; counted as a phone.
|
|
1099
1187
|
inkStage.setAttribute('data-follower', '')
|
|
1100
|
-
|
|
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
|
+
})
|
|
1101
1194
|
startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
|
|
1102
1195
|
watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
|
|
1103
1196
|
} else if (inkStage && audienceMode) {
|