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.
- package/CHANGELOG.md +44 -0
- package/README.md +15 -4
- package/assets/base.css +6 -1
- package/docs/reference/authoring.md +111 -9
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +6 -3
- package/docs/site/content/drawing.md +5 -5
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +49 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +67 -9
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/content/writing.md +27 -0
- package/docs/site/images/features/hotspots.webp +0 -0
- package/docs/site/images/features/languages.webp +0 -0
- package/docs/site/pages.js +1 -0
- package/examples/custom-layouts/slides.md +21 -9
- package/examples/showcase/refs.bib +21 -0
- package/examples/showcase/slides.ar.drawings.json +11 -0
- package/examples/showcase/slides.ar.md +567 -0
- package/examples/showcase/slides.de.drawings.json +11 -0
- package/examples/showcase/slides.de.md +567 -0
- package/examples/showcase/slides.de.results.json +57 -0
- package/examples/showcase/slides.en.drawings.json +11 -0
- package/examples/showcase/{slides.md → slides.en.md} +112 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +14 -2
- package/src/build/check.js +34 -7
- package/src/build/components.js +5 -3
- package/src/build/config.js +2 -0
- 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 +70 -22
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +96 -0
- package/src/cli/args.js +6 -5
- package/src/cli/index.js +77 -21
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +9 -1
- package/src/core/labels.js +78 -0
- package/src/core/parseSlides.js +16 -6
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +11 -2
- package/src/editor/components/DeckSettings.jsx +1 -0
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/home/Home.jsx +39 -93
- package/src/home/home.css +5 -17
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +9 -0
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +18 -6
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +371 -37
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +37 -12
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +508 -286
- package/src/runtime/shared.js +77 -0
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +67 -0
- package/examples/citations/refs.bib +0 -30
- package/examples/citations/refs.yml +0 -21
- package/examples/citations/slides.md +0 -43
- package/examples/citations/slides.pdf +0 -0
- package/examples/poll/slides.md +0 -127
- package/examples/showcase/slides.drawings.json +0 -11
package/src/runtime/main.jsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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) {
|