mdeck 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +2 -0
  4. package/docs/reference/authoring.md +48 -7
  5. package/docs/site/content/appearance.md +1 -1
  6. package/docs/site/content/audience.md +36 -8
  7. package/docs/site/content/commands.md +4 -2
  8. package/docs/site/content/drawing.md +4 -4
  9. package/docs/site/content/getting-started.md +2 -32
  10. package/docs/site/content/install.md +3 -1
  11. package/docs/site/content/presenting.md +29 -8
  12. package/docs/site/content/writing.md +27 -0
  13. package/examples/showcase/slides.ar.md +5 -5
  14. package/examples/showcase/slides.de.md +5 -5
  15. package/examples/showcase/slides.de.results.json +27 -0
  16. package/examples/showcase/slides.en.md +5 -5
  17. package/package.json +1 -1
  18. package/skills/write-slides/SKILL.md +4 -1
  19. package/src/build/check.js +27 -6
  20. package/src/build/components.js +5 -3
  21. package/src/build/homePlugin.js +64 -14
  22. package/src/build/include.js +249 -0
  23. package/src/build/inkPlugin.js +12 -2
  24. package/src/build/relayAccess.js +3 -1
  25. package/src/build/renderCheck.js +23 -3
  26. package/src/build/slidesPlugin.js +64 -16
  27. package/src/build/timingPlugin.js +34 -18
  28. package/src/cli/args.js +3 -3
  29. package/src/cli/index.js +49 -10
  30. package/src/components/Bibliography.jsx +7 -2
  31. package/src/components/Poll.jsx +2 -2
  32. package/src/components/QrCodeTag.jsx +2 -2
  33. package/src/components/WordCloud.jsx +7 -6
  34. package/src/components/activity.jsx +11 -8
  35. package/src/components/poll.css +2 -0
  36. package/src/core/icons.js +6 -1
  37. package/src/core/labels.js +3 -0
  38. package/src/core/parseSlides.js +14 -4
  39. package/src/core/reactions.js +27 -0
  40. package/src/core/resultsReport.js +153 -0
  41. package/src/core/source.js +8 -1
  42. package/src/core/validateDeck.js +6 -2
  43. package/src/home/Home.jsx +38 -93
  44. package/src/home/home.css +5 -17
  45. package/src/layouts/renderSlide.jsx +13 -1
  46. package/src/live/answer/answer.css +12 -1
  47. package/src/live/answer/answer.js +32 -4
  48. package/src/live/client.js +23 -4
  49. package/src/live/rooms.js +29 -7
  50. package/src/live/server.js +15 -1
  51. package/src/runtime/KeyHelp.jsx +115 -0
  52. package/src/runtime/PresenterChrome.jsx +168 -0
  53. package/src/runtime/Reactions.jsx +56 -0
  54. package/src/runtime/deck-stage.js +322 -17
  55. package/src/runtime/helpKeys.js +75 -0
  56. package/src/runtime/ink/room.js +6 -6
  57. package/src/runtime/main.jsx +396 -303
  58. package/src/runtime/slideControls.js +60 -0
  59. package/src/runtime/useTalkTimer.js +4 -2
@@ -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 ? 'Poll server not answering.'
103
+ : !live.joinUrl ? <>Phones can’t reach this computer: use <code>--network</code>.</>
104
+ : live.server && live.key && !live.keyAccepted ? 'Poll server refuses the key.'
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' }} title={`Server ${live.serverVersion ?? 'older'}, here ${live.version}: npm install -g mdeck there, then restart mdeck server`}>
115
+ Poll server needs an update to {live.version}.
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
+ }