mdeck 3.10.1 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
@@ -0,0 +1,115 @@
1
+ // The keys that work right now (helpKeys.js), over the slide in a
2
+ // semi-transparent layer: ? shows and hides it, Escape or a click closes it.
3
+ // The presenter view renders it itself; a deck window gets it from
4
+ // attachKeyHelp. While it is open the other keys keep working, and the list
5
+ // follows them (H, D, a slide with hotspots).
6
+ import { h, render } from 'preact'
7
+ import { useState, useEffect, useRef } from 'preact/hooks'
8
+ import { keyHelp, isHelpKey } from './helpKeys.js'
9
+
10
+ const typing = target => target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')
11
+ export const isMac = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '')
12
+
13
+ /** Open state for the help: ? toggles it, Escape closes it before anything else sees Escape. */
14
+ export function useKeyHelp() {
15
+ const [open, setOpen] = useState(false)
16
+ const openRef = useRef(false)
17
+ openRef.current = open
18
+ useEffect(() => {
19
+ const onKey = event => {
20
+ if (typing(event.target)) return
21
+ if (isHelpKey(event)) {
22
+ event.preventDefault()
23
+ event.stopPropagation()
24
+ setOpen(shown => !shown)
25
+ } else if (event.key === 'Escape' && openRef.current) {
26
+ event.preventDefault()
27
+ event.stopPropagation()
28
+ setOpen(false)
29
+ }
30
+ }
31
+ // Capturing, so that Escape closes the help instead of zooming out or ending a drawing.
32
+ window.addEventListener('keydown', onKey, true)
33
+ return () => window.removeEventListener('keydown', onKey, true)
34
+ }, [])
35
+ return [open, setOpen]
36
+ }
37
+
38
+ const S = {
39
+ layer: { position: 'fixed', inset: 0, zIndex: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', boxSizing: 'border-box', background: 'rgba(8,8,8,0.45)', fontFamily: 'ui-sans-serif, system-ui, sans-serif', direction: 'ltr' },
40
+ panel: { width: 'min(100%, 980px)', maxHeight: '100%', overflow: 'auto', boxSizing: 'border-box', padding: '22px 26px 24px', borderRadius: '14px', background: 'rgba(20,20,20,0.82)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 20px 60px rgba(0,0,0,0.45)', color: '#e8e8e8' },
41
+ head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '16px', marginBottom: '16px' },
42
+ heading: { margin: 0, fontSize: '16px', fontWeight: 600 },
43
+ hint: { fontSize: '12px', color: '#8a8a8a' },
44
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(270px, 1fr))', gap: '20px 36px' },
45
+ title: { margin: '0 0 8px', fontSize: '11px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#8a8a8a' },
46
+ // One grid per group, so its keys share a column as wide as the widest.
47
+ list: { margin: 0, padding: 0, listStyle: 'none', display: 'grid', gridTemplateColumns: 'auto 1fr', alignItems: 'baseline', gap: '8px 14px', fontSize: '13.5px', lineHeight: 1.35 },
48
+ row: { display: 'contents' },
49
+ keys: { display: 'flex', flexWrap: 'wrap', gap: '4px' },
50
+ kbd: { display: 'inline-block', minWidth: '14px', padding: '1px 6px', borderRadius: '5px', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.16)', borderBottomWidth: '2px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '12px', color: '#f4f4f4', textAlign: 'center', whiteSpace: 'nowrap' },
51
+ }
52
+
53
+ /** The layer itself: `context` is what keyHelp() takes. */
54
+ export function KeyHelp({ context, onClose }) {
55
+ const groups = keyHelp({ mac: isMac(), ...context })
56
+ return (
57
+ <div class="key-help" role="dialog" aria-label="Keyboard shortcuts" style={S.layer} onClick={onClose}>
58
+ <div style={S.panel} onClick={event => event.stopPropagation()}>
59
+ <div style={S.head}>
60
+ <h2 style={S.heading}>Keys</h2>
61
+ <span style={S.hint}>The keys that work now · ? or Esc closes</span>
62
+ </div>
63
+ <div style={S.grid}>
64
+ {groups.map(group => (
65
+ <section key={group.title} class="key-help-group">
66
+ <h3 style={S.title}>{group.title}</h3>
67
+ <ul style={S.list}>
68
+ {group.keys.map(({ keys, label }) => (
69
+ <li key={label} style={S.row}>
70
+ <span style={S.keys}>{keys.map(key => <kbd key={key} style={S.kbd}>{key}</kbd>)}</span>
71
+ <span>{label}</span>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </section>
76
+ ))}
77
+ </div>
78
+ </div>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ // A deck window's help: what it lists follows the stage (the slide's
84
+ // hotspots, drawing on or off).
85
+ function DeckKeyHelp({ stage, canDraw }) {
86
+ const [open, setOpen] = useKeyHelp()
87
+ const [, refresh] = useState(0)
88
+ useEffect(() => {
89
+ const update = () => refresh(n => n + 1)
90
+ const events = ['slidechange', 'statechange', 'inkmode', 'overviewchange']
91
+ for (const name of events) stage.addEventListener(name, update)
92
+ return () => { for (const name of events) stage.removeEventListener(name, update) }
93
+ }, [stage])
94
+ if (!open) return null
95
+ const context = {
96
+ view: 'deck',
97
+ blank: stage.blank,
98
+ join: stage.hasAttribute('data-joinable'),
99
+ joinShown: stage.join,
100
+ overview: stage.overview,
101
+ hotspots: stage.hotspotMode !== 'off',
102
+ inking: stage.inking,
103
+ canDraw,
104
+ fullscreen: window.top === window && !!window.mdeckFullscreen?.available(),
105
+ }
106
+ return <KeyHelp context={context} onClose={() => setOpen(false)} />
107
+ }
108
+
109
+ /** Gives a deck window the help that ? shows. */
110
+ export function attachKeyHelp(stage, { canDraw = true } = {}) {
111
+ const host = document.createElement('div')
112
+ host.className = 'key-help-host'
113
+ document.body.appendChild(host)
114
+ render(h(DeckKeyHelp, { stage, canDraw }), host)
115
+ }
@@ -0,0 +1,168 @@
1
+ // The presenter view's controls, shared by its three layouts (main.jsx): the
2
+ // speaker layout on a computer, the slide alone on an iPad, and the phone
3
+ // clicker. What is used during the talk is an icon button, its key in the
4
+ // tooltip; what is set once lives in two popovers: who is connected (with
5
+ // the codes that pair an iPad or a phone) and the settings.
6
+ import { h } from 'preact'
7
+ import { useEffect, useRef, useState } from 'preact/hooks'
8
+ import { S } from './chrome.jsx'
9
+ import { Icon } from '../components/Icon.jsx'
10
+ import QrCode from '../components/QrCode.jsx'
11
+ import { ConnectedViews } from '../components/ConnectedViews.jsx'
12
+ import { devicesText, phonesText } from '../live/presence.js'
13
+ import { devPath } from '../core/devPath.js'
14
+
15
+ export const tool = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', borderRadius: '10px', fontSize: '15px' }
16
+ export const toolOn = { background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }
17
+ const muted = { color: '#888', fontSize: '12px' }
18
+
19
+ /** An icon button; `label` is its tooltip and name, with the key in brackets. */
20
+ export function ToolButton({ icon, label, pressed, onClick, class: className, style, size = 18, disabled, children }) {
21
+ return <button type="button" class={className} title={label} aria-label={label} aria-pressed={pressed} disabled={disabled} onClick={onClick}
22
+ style={{ ...tool, ...(pressed ? toolOn : {}), ...(disabled ? { opacity: 0.4, cursor: 'default' } : {}), ...style }}>
23
+ {icon && <Icon name={icon} size={size} style={{ display: 'block' }} />}{children}
24
+ </button>
25
+ }
26
+
27
+ /**
28
+ * A button that opens a panel below (or above) it. The panel stays in the
29
+ * page while closed, hidden, so its controls keep their state.
30
+ */
31
+ export function Popover({ class: className, label, trigger, alert = false, above = false, align = 'end', children }) {
32
+ const [open, setOpen] = useState(false)
33
+ const ref = useRef(null)
34
+ useEffect(() => {
35
+ if (!open) return
36
+ const outside = event => { if (!ref.current?.contains(event.target)) setOpen(false) }
37
+ const escape = event => { if (event.key === 'Escape') setOpen(false) }
38
+ document.addEventListener('pointerdown', outside)
39
+ window.addEventListener('keydown', escape)
40
+ return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('keydown', escape) }
41
+ }, [open])
42
+ return <span ref={ref} class={className} style={{ position: 'relative', display: 'inline-flex' }}>
43
+ <button type="button" class={`${className}-toggle`} title={label} aria-label={label} aria-expanded={open} onClick={() => setOpen(now => !now)}
44
+ style={{ ...tool, position: 'relative', gap: '8px', ...(open ? { background: '#2a2a2a', color: '#fff' } : {}) }}>
45
+ {trigger}
46
+ {alert && <span aria-hidden="true" style={{ position: 'absolute', top: '5px', right: '5px', width: '8px', height: '8px', borderRadius: '50%', background: '#f28b82' }} />}
47
+ </button>
48
+ <div class={`${className}-panel`} role="dialog" aria-label={label} style={{
49
+ display: open ? 'flex' : 'none', flexDirection: 'column', gap: '14px', position: 'absolute', zIndex: 300,
50
+ [above ? 'bottom' : 'top']: 'calc(100% + 6px)', [align === 'end' ? 'right' : 'left']: 0,
51
+ width: 'min(340px, calc(100vw - 24px))', maxHeight: 'min(80dvh, 640px)', overflow: 'auto', boxSizing: 'border-box',
52
+ padding: '14px', background: '#161616', border: '1px solid #2e2e2e', borderRadius: '12px', boxShadow: '0 12px 40px rgba(0,0,0,0.6)',
53
+ color: '#ccc', fontSize: '13px', textAlign: 'start', whiteSpace: 'normal', fontVariantNumeric: 'normal',
54
+ }}>{children}</div>
55
+ </span>
56
+ }
57
+
58
+ export function PanelSection({ title, children, class: className }) {
59
+ return <section class={className} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
60
+ {title && <span style={{ ...S.label, marginBottom: 0 }}>{title}</span>}
61
+ {children}
62
+ </section>
63
+ }
64
+
65
+ /**
66
+ * The slide's own controls, as the presenter's slide frame reports them
67
+ * (slideControls.js): a poll's lock, eye, tick and Reset, a video's play,
68
+ * the hotspots' numbers. `press(i)` and `zoom(key)` act on the slide.
69
+ */
70
+ export function SlideControls({ controls, press, zoom, big = false }) {
71
+ if (!controls.items.length && !controls.hotspots) return null
72
+ const size = big ? { minWidth: '52px', height: '52px', borderRadius: '14px' } : {}
73
+ return <span class="presenter-slide-controls" role="group" aria-label="This slide" style={{ display: 'inline-flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
74
+ {controls.items.map((control, i) => <ToolButton key={`c${i}`} class="presenter-slide-control" icon={control.icon ?? 'check'} label={control.label} pressed={control.pressed} disabled={control.disabled} size={big ? 22 : 18} style={size} onClick={() => press(i)} />)}
75
+ {controls.hotspots > 0 && <>
76
+ {Array.from({ length: controls.hotspots }, (_, i) => <ToolButton key={`h${i}`} class="presenter-slide-hotspot" label={`Zoom in on part ${i + 1} (${i + 1})`} pressed={controls.hotspot === i + 1} style={{ ...size, fontVariantNumeric: 'tabular-nums' }} onClick={() => zoom(String(i + 1))}>{i + 1}</ToolButton>)}
77
+ {controls.hotspot != null && <ToolButton class="presenter-slide-whole" icon="fullscreen-exit" label="The whole slide (0)" size={big ? 22 : 18} style={size} onClick={() => zoom('0')} />}
78
+ </>}
79
+ </span>
80
+ }
81
+
82
+ // The launch page's view of this talk: whether devices can pair, from this
83
+ // computer under `mdeck run` only.
84
+ function useHomeInfo() {
85
+ const [info, setInfo] = useState(null)
86
+ const load = () => fetch(devPath('__mdeck/home/info'), { cache: 'no-store' }).then(response => response.ok ? response.json() : null).then(setInfo, () => setInfo(null))
87
+ useEffect(() => { if (import.meta.env.DEV) load() }, [])
88
+ return [info, load]
89
+ }
90
+
91
+ const PAIR = {
92
+ slide: { icon: 'tablet', label: 'iPad', scan: "Scan with the iPad's camera: the presenter view opens there, to draw on and go through the slides." },
93
+ remote: { icon: 'phone', label: 'Phone clicker', scan: "Scan with the phone's camera: a clicker opens there, with your notes, the time and big buttons." },
94
+ }
95
+
96
+ // Where the phones answer: the join link, a dot that is green while all is
97
+ // well, and words only for what needs the presenter (from the launch page's
98
+ // check of the server).
99
+ function Polls({ live, recheck }) {
100
+ const [copied, setCopied] = useState(false)
101
+ const copy = () => navigator.clipboard?.writeText(live.joinUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500) }, () => {})
102
+ const problem = live.server && !live.reachable ? `The poll server does not answer${live.error ? ` (${live.error})` : ''}.`
103
+ : !live.joinUrl ? <>Phones cannot reach this computer: start with <code>--network</code>, or set <code>server</code> in the deck.</>
104
+ : live.server && live.key && !live.keyAccepted ? 'The poll server does not accept this key: phones do not follow.'
105
+ : null
106
+ // A server on an older mdeck answers, but lacks what is new (reactions came with 3.12).
107
+ const outdated = !problem && live.server && live.serverOutdated
108
+ return <PanelSection title="Polls" class="presenter-polls">
109
+ {live.joinUrl && <span style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: 0 }}>
110
+ <span aria-hidden="true" style={{ flexShrink: 0, width: '8px', height: '8px', borderRadius: '50%', background: problem ? '#f28b82' : outdated ? '#f5a524' : '#81c995' }} />
111
+ <a href={live.joinUrl} target="_blank" rel="noopener" title="The join link for phones" style={{ color: '#ccc', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{live.joinUrl.replace(/^https?:\/\//, '')}</a>
112
+ <ToolButton class="presenter-copy-join" icon={copied ? 'check' : 'copy'} label={copied ? 'Copied' : 'Copy the join link'} size={15} style={{ minWidth: '30px', height: '30px', padding: 0, flexShrink: 0 }} onClick={copy} />
113
+ </span>}
114
+ {outdated && <span class="presenter-server-outdated" style={{ color: '#f5a524' }}>
115
+ Your poll server runs {live.serverVersion ? `mdeck ${live.serverVersion}` : 'an older mdeck'}, this computer {live.version}: what is new, such as reactions, may not reach the phones. Update it with <code>npm install -g mdeck</code> and restart <code>mdeck server</code>.
116
+ </span>}
117
+ {problem && <span style={{ display: 'flex', gap: '8px', alignItems: 'center', color: '#f28b82' }}>
118
+ <span style={{ flex: 1 }}>{problem}</span>
119
+ <ToolButton class="presenter-recheck" icon="reset" label="Check again" size={15} style={{ minWidth: '30px', height: '30px', padding: 0, flexShrink: 0 }} onClick={recheck} />
120
+ </span>}
121
+ </PanelSection>
122
+ }
123
+
124
+ const VIEW_NAME = { presenter: 'Presenter view', audience: 'Audience window', deck: 'Deck window' }
125
+
126
+ /**
127
+ * Who is connected, behind the icons that show it: the views and devices,
128
+ * the audience window, and the codes that pair an iPad or a phone.
129
+ */
130
+ export function Connections({ presence, phones, problem }) {
131
+ const [info, reload] = useHomeInfo()
132
+ const [code, setCode] = useState(null)
133
+ const [error, setError] = useState(null)
134
+ const action = body => fetch(devPath('__mdeck/home/action'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
135
+ .then(async response => { const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error ?? response.status); return result })
136
+ const pair = layout => action({ action: 'pair', layout }).then(result => { setCode({ url: result.url, layout }); setError(null); reload() }, failure => setError(failure.message))
137
+ const unpair = () => action({ action: 'unpair' }).then(() => { setCode(null); reload() }, failure => setError(failure.message))
138
+ const views = presence?.views ?? []
139
+ const kinds = Object.keys(VIEW_NAME).filter(view => views.some(entry => entry.view === view))
140
+ const trigger = kinds.length || phones
141
+ ? <ConnectedViews presence={presence ?? { views: [], phones: 0 }} phones={phones} style={{ color: 'inherit' }} />
142
+ : <Icon name="people" size={18} style={{ display: 'block' }} />
143
+ return <Popover class="presenter-connections" label="Connected screens and devices" trigger={trigger} alert={!!problem}>
144
+ {problem && <p role="status" style={{ margin: 0, color: '#f28b82' }}>{problem}</p>}
145
+ <PanelSection title="Connected">
146
+ {kinds.length === 0 && !(presence?.phones > 0) && <span style={muted}>Only this view.</span>}
147
+ {kinds.map(view => <span key={view}>{VIEW_NAME[view]} on {devicesText(views.filter(entry => entry.view === view))}</span>)}
148
+ {phones && presence?.phones > 0 && <span>{phonesText(presence.phones)}</span>}
149
+ </PanelSection>
150
+ {info?.live && phones && <Polls live={info.live} recheck={reload} />}
151
+ {info?.pairing && <PanelSection title="Pair a device" class="presenter-pairing">
152
+ {!info.pairing.available
153
+ ? <span style={muted}>Start <code>mdeck run</code> with <code>--network</code> so an iPad or a phone in the same network can reach it, or with <code>--server</code>.</span>
154
+ : <>
155
+ <span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
156
+ {Object.entries(PAIR).map(([layout, { icon, label }]) => <button key={layout} type="button" class={`presenter-pair presenter-pair--${layout}`} aria-pressed={code?.layout === layout} style={{ ...S.btn, ...(code?.layout === layout ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => pair(layout)}><Icon name={icon} size={16} />{label}</button>)}
157
+ {info.pairing.devices > 0 && <button type="button" class="presenter-unpair" style={{ ...S.btn, color: '#888' }} onClick={unpair}>Unpair {info.pairing.devices === 1 ? 'the device' : `${info.pairing.devices} devices`}</button>}
158
+ </span>
159
+ {code && <span style={{ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-start', '--qr-ink': '#000', '--qr-bg': '#fff' }}>
160
+ <span style={{ background: '#fff', padding: '8px', borderRadius: '8px', lineHeight: 0 }}><QrCode url={code.url} size="168" /></span>
161
+ <span>{PAIR[code.layout].scan}</span>
162
+ <span style={muted}>The code works once, for ten minutes.</span>
163
+ </span>}
164
+ {error && <span style={{ color: '#f28b82' }}>{error}</span>}
165
+ </>}
166
+ </PanelSection>}
167
+ </Popover>
168
+ }
@@ -0,0 +1,56 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import { listenToReactions } from '../live/client.js'
4
+
5
+ // The reactions phones send (src/core/reactions.js), on the presenter's
6
+ // screen only: each one floats up for a moment, and a tally keeps what came
7
+ // in the last minute, so that four 🐢 still say "slower" after they faded.
8
+
9
+ const FLOAT_MS = 2600
10
+ const TALLY_MS = 60000
11
+
12
+ const CSS = `
13
+ .reactions { position: fixed; z-index: 17; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; pointer-events: none; font-family: ui-sans-serif, system-ui, sans-serif; }
14
+ .reactions-float { position: relative; width: 160px; height: 220px; }
15
+ .reaction-bubble { position: absolute; bottom: 0; font-size: 34px; line-height: 1; animation: reaction-up ${FLOAT_MS}ms ease-out forwards; filter: drop-shadow(0 2px 4px rgba(0,0,0,.45)); }
16
+ .reaction-bubble.is-word { font-size: 15px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: rgba(17,17,17,.9); color: #f0f0f0; border: 1px solid #3a3a3a; }
17
+ @keyframes reaction-up { 0% { transform: translate(0, 0) scale(.6); opacity: 0; } 12% { transform: translate(var(--drift), -20px) scale(1.1); opacity: 1; } 75% { opacity: 1; } 100% { transform: translate(calc(var(--drift) * -1), -200px) scale(1); opacity: 0; } }
18
+ .reactions-tally { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-radius: 999px; background: rgba(17,17,17,.88); border: 1px solid #2a2a2a; color: #ccc; font-size: 13px; font-variant-numeric: tabular-nums; }
19
+ .reactions-tally span { padding: 0 4px; }
20
+ @media (prefers-reduced-motion: reduce) { .reaction-bubble { animation: none; opacity: .9; } }
21
+ `
22
+
23
+ const isEmoji = value => /\p{Extended_Pictographic}/u.test(value) && [...value].length <= 4
24
+
25
+ /** The reactions overlay; `style` places it (fixed by default, at the slide's lower left). */
26
+ export function Reactions({ style }) {
27
+ const [bubbles, setBubbles] = useState([])
28
+ const [recent, setRecent] = useState([])
29
+ useEffect(() => {
30
+ const css = document.createElement('style')
31
+ css.textContent = CSS
32
+ document.head.appendChild(css)
33
+ const stop = listenToReactions(({ value, n }) => {
34
+ const now = Date.now()
35
+ const bubble = { key: `${n}:${now}`, value, x: 10 + Math.random() * 100, drift: `${Math.round(Math.random() * 24 - 12)}px` }
36
+ // At most a screenful at once: a room that taps all together is still one cloud.
37
+ setBubbles(list => [...list.slice(-24), bubble])
38
+ setTimeout(() => setBubbles(list => list.filter(item => item !== bubble)), FLOAT_MS)
39
+ setRecent(list => [...list.filter(item => now - item.at < TALLY_MS), { value, at: now }])
40
+ })
41
+ // The tally forgets what is older than a minute.
42
+ const timer = setInterval(() => setRecent(list => { const now = Date.now(); const kept = list.filter(item => now - item.at < TALLY_MS); return kept.length === list.length ? list : kept }), 5000)
43
+ return () => { stop(); clearInterval(timer); css.remove() }
44
+ }, [])
45
+ const tally = new Map()
46
+ for (const { value } of recent) tally.set(value, (tally.get(value) ?? 0) + 1)
47
+ if (!bubbles.length && !tally.size) return null
48
+ return <div class="reactions" aria-hidden="true" style={{ left: '16px', bottom: '16px', ...style }}>
49
+ <div class="reactions-float">
50
+ {bubbles.map(bubble => <span key={bubble.key} class={`reaction-bubble${isEmoji(bubble.value) ? '' : ' is-word'}`} style={{ left: `${bubble.x}px`, '--drift': bubble.drift }}>{bubble.value}</span>)}
51
+ </div>
52
+ {tally.size > 0 && <div class="reactions-tally" title="Reactions in the last minute">
53
+ {[...tally].sort((a, b) => b[1] - a[1]).map(([value, count]) => <span key={value}>{value} {count}</span>)}
54
+ </div>}
55
+ </div>
56
+ }
@@ -0,0 +1,129 @@
1
+ // The end of a rehearsal: each slide's planned time, the time it got, and
2
+ // the time to save, which starts at the rehearsed time rounded to 15 s and
3
+ // can be moved in 15 s steps, put back to the plan, or taken out. Saving
4
+ // writes `time:` into the slides' settings through `mdeck run`
5
+ // (build/timingPlugin.js); a built deck can only copy the times.
6
+ import { h } from 'preact'
7
+ import { useEffect, useMemo, useState } from 'preact/hooks'
8
+ import { S } from './chrome.jsx'
9
+ import { Icon } from '../components/Icon.jsx'
10
+ import { slideTitle } from '../core/outline.js'
11
+ import { devPath } from '../core/devPath.js'
12
+ import { pairHeaders } from '../live/pairing.js'
13
+ import { formatTime, formatClock, roundTime, initialTimes, changedTimes, TIME_STEP } from '../core/timing.js'
14
+
15
+ const ENDPOINT = devPath('__mdeck/timing')
16
+
17
+ // Whether this page can save into the deck: only `mdeck run` answers.
18
+ async function canSaveTimes() {
19
+ try { return (await fetch(ENDPOINT, { cache: 'no-store', headers: pairHeaders() })).ok } catch { return false }
20
+ }
21
+
22
+ const dash = <span style={{ color: '#555' }}>—</span>
23
+ const sum = values => values.reduce((total, t) => total + (t ?? 0), 0)
24
+
25
+ export function RehearsalSummary({ slides, plan, spent, onSaved, onDiscard, onClose }) {
26
+ const own = plan.own
27
+ const [next, setNext] = useState(() => initialTimes(slides, spent, own))
28
+ const [canSave, setCanSave] = useState(null)
29
+ const [status, setStatus] = useState(null)
30
+ useEffect(() => { canSaveTimes().then(setCanSave) }, [])
31
+ const changes = useMemo(() => changedTimes(slides, own, next), [next])
32
+ const changeCount = Object.keys(changes).length
33
+ const setAt = (i, value) => setNext(times => times.map((t, j) => j === i ? value : t))
34
+ const step = (i, by) => setAt(i, Math.max(TIME_STEP, (next[i] ?? roundTime(spent[slides[i].id] ?? 0)) + by))
35
+
36
+ async function save() {
37
+ setStatus({ busy: true })
38
+ try {
39
+ const reply = await fetch(ENDPOINT, { method: 'POST', headers: pairHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ ids: slides.map(slide => slide.id), times: changes }) })
40
+ const body = await reply.json().catch(() => ({}))
41
+ if (!reply.ok) return setStatus({ error: body.error ?? `The deck was not saved (${reply.status})` })
42
+ onSaved(changes, body.changed ?? changeCount)
43
+ } catch (error) {
44
+ setStatus({ error: `The deck was not saved: ${error.message}` })
45
+ }
46
+ }
47
+
48
+ function copy() {
49
+ const text = slides.map((slide, i) => `${i + 1}. ${slideTitle(slide, i, null)}: ${next[i] == null ? '—' : `time: ${formatTime(next[i])}`}`).join('\n')
50
+ navigator.clipboard?.writeText(text).then(() => setStatus({ note: 'Copied the times.' }), () => setStatus({ error: 'The browser did not let this page copy.' }))
51
+ }
52
+
53
+ function onRowKey(event, i) {
54
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
55
+ event.preventDefault()
56
+ event.stopPropagation()
57
+ step(i, event.key === 'ArrowUp' ? TIME_STEP : -TIME_STEP)
58
+ }
59
+ }
60
+
61
+ const small = { ...S.btn, padding: 0, width: '26px', height: '26px' }
62
+ const cell = { padding: '6px 8px', borderBottom: '1px solid #222', verticalAlign: 'middle' }
63
+ const head = { ...cell, ...S.label, display: 'table-cell', marginBottom: 0, textAlign: 'start', position: 'sticky', top: 0, background: '#161616' }
64
+ const num = { ...cell, textAlign: 'end', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }
65
+
66
+ return (
67
+ <div class="rehearsal-summary" role="dialog" aria-modal="true" aria-label="Rehearsal" style={{ position: 'fixed', inset: 0, zIndex: 210, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', color: '#ccc' }}
68
+ onKeyDown={event => { event.stopPropagation(); if (event.key === 'Escape') onClose() }}>
69
+ <div style={{ background: '#161616', border: '1px solid #2a2a2a', borderRadius: '12px', padding: '20px 24px', width: 'min(94vw, 760px)', maxHeight: '88vh', display: 'flex', flexDirection: 'column', gap: '14px' }}>
70
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px' }}>
71
+ <span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '15px' }}>Rehearsal</span>
72
+ <span style={{ color: '#888' }}>New times start at the rehearsed time, rounded to {TIME_STEP} s. ↑ ↓ change the focused one.</span>
73
+ </div>
74
+ <div style={{ overflow: 'auto', minHeight: 0 }}>
75
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
76
+ <thead>
77
+ <tr>
78
+ <th style={head}>Slide</th>
79
+ <th style={{ ...head, textAlign: 'end' }}>Planned</th>
80
+ <th style={{ ...head, textAlign: 'end' }}>Rehearsed</th>
81
+ <th style={{ ...head, textAlign: 'center' }}>New time</th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ {slides.map((slide, i) => {
86
+ const changed = next[i] !== own[i]
87
+ return (
88
+ <tr key={`${slide.id}#${i}`} class="rehearsal-row" tabIndex={0} onKeyDown={event => onRowKey(event, i)} style={{ outline: 'none' }}>
89
+ <td style={{ ...cell, maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
90
+ <span style={{ color: '#666', marginInlineEnd: '8px', fontVariantNumeric: 'tabular-nums' }}>{i + 1}</span>{slideTitle(slide, i, null)}
91
+ </td>
92
+ <td style={{ ...num, color: '#888' }}>{own[i] == null ? dash : formatTime(own[i])}</td>
93
+ <td style={{ ...num, color: '#888' }}>{spent[slide.id] >= 1 ? formatClock(spent[slide.id]) : dash}</td>
94
+ <td style={{ ...cell, whiteSpace: 'nowrap' }}>
95
+ <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
96
+ <button style={small} title={`${TIME_STEP} s less`} aria-label={`${TIME_STEP} seconds less`} onClick={() => step(i, -TIME_STEP)}><Icon name="remove" size={14} /></button>
97
+ <span class="rehearsal-time" style={{ display: 'inline-block', minWidth: '56px', textAlign: 'center', fontVariantNumeric: 'tabular-nums', color: changed ? '#f0f0f0' : '#888', fontWeight: changed ? 600 : 400 }}>{next[i] == null ? dash : formatTime(next[i])}</span>
98
+ <button style={small} title={`${TIME_STEP} s more`} aria-label={`${TIME_STEP} seconds more`} onClick={() => step(i, TIME_STEP)}><Icon name="add" size={14} /></button>
99
+ <button style={{ ...small, visibility: changed ? 'visible' : 'hidden' }} title="Keep the planned time" aria-label="Keep the planned time" onClick={() => setAt(i, own[i])}><Icon name="undo" size={14} /></button>
100
+ <button style={{ ...small, visibility: next[i] == null ? 'hidden' : 'visible' }} title="No time for this slide" aria-label="No time for this slide" onClick={() => setAt(i, null)}><Icon name="close" size={14} /></button>
101
+ </span>
102
+ </td>
103
+ </tr>
104
+ )
105
+ })}
106
+ </tbody>
107
+ <tfoot>
108
+ <tr>
109
+ <td style={{ ...cell, color: '#888' }}>Total{plan.budget ? ` (deck time ${formatClock(plan.budget)})` : ''}</td>
110
+ <td style={num}>{formatClock(sum(own))}</td>
111
+ <td style={num}>{formatClock(sum(Object.values(spent)))}</td>
112
+ <td style={{ ...num, textAlign: 'center', color: plan.budget && sum(next) > plan.budget ? '#f28b82' : '#f0f0f0' }}>{formatClock(sum(next))}</td>
113
+ </tr>
114
+ </tfoot>
115
+ </table>
116
+ </div>
117
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
118
+ <span role="status" style={{ flex: 1, color: status?.error ? '#f28b82' : '#888' }}>
119
+ {status?.error ?? status?.note ?? (canSave === false ? 'Saving into the deck needs mdeck run; copy the times instead.' : `${changeCount} slide${changeCount === 1 ? '' : 's'} change.`)}
120
+ </span>
121
+ <button style={{ ...S.btn, color: '#888' }} title="Go on rehearsing (Esc)" onClick={onClose}>Back</button>
122
+ <button style={{ ...S.btn, color: '#888' }} title="End the rehearsal without saving" onClick={onDiscard}>Discard</button>
123
+ <button style={S.btn} onClick={copy}><Icon name="copy" size={14} />Copy</button>
124
+ <button class="rehearsal-save" style={{ ...S.btn, background: '#1c2a3a', borderColor: '#2f4a66', opacity: canSave && changeCount && !status?.busy ? 1 : 0.5 }} disabled={!canSave || !changeCount || status?.busy} onClick={save}>Save to deck</button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,27 @@
1
+ // `mdeck/component`: what a deck component may use from mdeck. Everything
2
+ // here is a promise kept across versions; anything else a component reaches
3
+ // (the page outside its slide, the stage, the presenter view) is not, and may
4
+ // change without notice. See "Deck-local components" in the authoring
5
+ // reference.
6
+ import { useEffect, useState } from 'preact/hooks'
7
+ import { getShared, setShared, subscribeShared, currentView } from './shared.js'
8
+
9
+ /**
10
+ * A value shared by every slide and every window of this talk, under `name`.
11
+ * Returns [value, setValue]; `initial` stands in until someone sets one.
12
+ * Values must survive JSON. The latest change wins.
13
+ */
14
+ export function useShared(name, initial = null) {
15
+ const [value, setValue] = useState(() => getShared(name))
16
+ useEffect(() => {
17
+ setValue(getShared(name))
18
+ return subscribeShared(name, setValue)
19
+ }, [name])
20
+ const current = () => getShared(name) === undefined ? initial : getShared(name)
21
+ return [value === undefined ? initial : value, next => setShared(name, typeof next === 'function' ? next(current()) : next)]
22
+ }
23
+
24
+ /** The kind of window this is: presenter, audience, deck, follow, preview or reader. */
25
+ export function useView() {
26
+ return currentView()
27
+ }