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/home/Home.jsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
3
|
import mark from '../../assets/logo/mark.svg'
|
|
4
|
-
import QrCode from '../components/QrCode.jsx'
|
|
5
4
|
import { Icon } from '../components/Icon.jsx'
|
|
6
5
|
import { ConnectedViews } from '../components/ConnectedViews.jsx'
|
|
7
|
-
import { devicesText
|
|
6
|
+
import { devicesText } from '../live/presence.js'
|
|
8
7
|
import { devPath } from '../core/devPath.js'
|
|
9
8
|
|
|
10
9
|
// Where the dev server serves from: / normally, /t/<id>/ with `mdeck run --server`.
|
|
@@ -118,8 +117,6 @@ function Preview({ info }) {
|
|
|
118
117
|
<button class="home-btn home-btn--small" onClick={() => control('prev')} aria-label="Previous"><HomeIcon name="prev" size={14} /></button>
|
|
119
118
|
<button class="home-btn home-btn--small" onClick={() => control('next')} aria-label="Next"><HomeIcon name="next" size={14} /></button>
|
|
120
119
|
<span class="home-preview-count">{index + 1} / {info.slides}</span>
|
|
121
|
-
<span class="home-spacer" />
|
|
122
|
-
<a class="home-btn home-btn--small" href={audienceUrl} target="_blank" rel="noopener">Open audience window <HomeIcon name="external" size={13} /></a>
|
|
123
120
|
</div>
|
|
124
121
|
</section>
|
|
125
122
|
}
|
|
@@ -171,102 +168,45 @@ function Diagnostics({ diagnostics, name }) {
|
|
|
171
168
|
</ul>
|
|
172
169
|
}
|
|
173
170
|
|
|
174
|
-
function CopyButton({ text, label = 'Copy' }) {
|
|
175
|
-
const [copied, setCopied] = useState(false)
|
|
176
|
-
const copy = async () => {
|
|
177
|
-
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1400) } catch {}
|
|
178
|
-
}
|
|
179
|
-
return <button class="home-btn home-btn--small" onClick={copy}><HomeIcon name={copied ? 'check' : 'copy'} size={14} />{copied ? 'Copied' : label}</button>
|
|
180
|
-
}
|
|
181
171
|
|
|
182
172
|
// One session pairs the presenter view with the audience window, so both
|
|
183
173
|
// links on this page drive each other.
|
|
184
174
|
const SESSION = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)
|
|
185
175
|
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const keyState = !live.key ? null : live.keyAccepted ? ['is-ok', 'Accepted by the server'] : live.reachable ? ['is-bad', 'Not accepted by the server'] : ['is-idle', 'Not checked']
|
|
192
|
-
return <section class="home-section">
|
|
176
|
+
// The answers kept in the talk: each question with how it came out, and
|
|
177
|
+
// the files to save them as (the same as `mdeck results`).
|
|
178
|
+
function Answers({ results, file, onRefresh }) {
|
|
179
|
+
const href = format => `${devPath('__mdeck/home/results')}?format=${format}`
|
|
180
|
+
return <section class="home-section home-section--wide home-answers">
|
|
193
181
|
<div class="home-section-head">
|
|
194
|
-
<h2>
|
|
195
|
-
<button class="home-btn home-btn--small" onClick={
|
|
182
|
+
<h2>Answers from the talk</h2>
|
|
183
|
+
<button class="home-btn home-btn--small" onClick={onRefresh}>Refresh</button>
|
|
196
184
|
</div>
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
</div>
|
|
206
|
-
<div>
|
|
207
|
-
<dt>Join link for phones</dt>
|
|
208
|
-
<dd>
|
|
209
|
-
{live.joinUrl
|
|
210
|
-
? <><a href={live.joinUrl} target="_blank" rel="noopener"><code>{live.joinUrl}</code></a> <CopyButton text={live.joinUrl} /></>
|
|
211
|
-
: <>
|
|
212
|
-
<p class="home-note">Phones cannot reach this computer. Start with <code>--network</code>, or set <code>server</code> in the deck.</p>
|
|
213
|
-
{live.localJoinUrl && <p><a href={live.localJoinUrl} target="_blank" rel="noopener">Try the answer page here</a> <span>in another browser tab or window.</span></p>}
|
|
214
|
-
</>}
|
|
215
|
-
<p>Session code <code class="home-code">{live.code}</code>, the same for every poll in this deck.</p>
|
|
216
|
-
{presence && <p>{presence.phones ? `${phonesText(presence.phones)} connected now.` : 'No phones connected yet.'}</p>}
|
|
217
|
-
</dd>
|
|
218
|
-
</div>
|
|
219
|
-
{live.server && <div>
|
|
220
|
-
<dt>Server key</dt>
|
|
221
|
-
<dd>
|
|
222
|
-
{live.key
|
|
223
|
-
? <>
|
|
224
|
-
<code class="home-code">{live.key}</code> <CopyButton text={live.key} />
|
|
225
|
-
<span class={`home-pill ${keyState[0]}`}>{keyState[1]}</span>
|
|
226
|
-
<p>The presenter view and audience window buttons above carry it. Or open <a href={presenterUrl} target="_blank" rel="noopener">the presenter view with the key</a>.</p>
|
|
227
|
-
</>
|
|
228
|
-
: <p>Start <code>mdeck run</code> with <code>MDECK_SERVER_KEY</code> set to the server's key to see it here.</p>}
|
|
229
|
-
</dd>
|
|
230
|
-
</div>}
|
|
231
|
-
</dl>
|
|
232
|
-
</section>
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Presenting from an iPad: a one-time QR code opens the presenter view there,
|
|
236
|
-
// paired with this server so it may save ink and steer the projector, which
|
|
237
|
-
// shows the audience window on this computer.
|
|
238
|
-
function Tablet({ pairing, relay, presence, onChange, onError }) {
|
|
239
|
-
const away = (presence?.views ?? []).filter(entry => entry.view === 'presenter' && entry.device !== 'computer')
|
|
240
|
-
const [url, setUrl] = useState(null)
|
|
241
|
-
const offer = () => api('/action', { action: 'pair' }).then(result => { setUrl(result.url); onChange() }).catch(error => onError(error.message))
|
|
242
|
-
const unpair = () => api('/action', { action: 'unpair' }).then(() => { setUrl(null); onChange() }).catch(error => onError(error.message))
|
|
243
|
-
return <section class="home-section home-tablet">
|
|
244
|
-
<div class="home-section-head">
|
|
245
|
-
<h2>Present from an iPad</h2>
|
|
246
|
-
{pairing.devices > 0 && <button class="home-btn home-btn--small" onClick={unpair}>Unpair {pairing.devices === 1 ? 'the device' : `${pairing.devices} devices`}</button>}
|
|
247
|
-
</div>
|
|
248
|
-
{relay && <p class={`home-note home-relay home-relay--${relay.state}`}>{relay.state === 'up' ? 'Connected to your server: the iPad can reach this computer from any network.' : relay.state === 'refused' ? `Connecting to the server failed: ${relay.reason}` : relay.state === 'down' ? 'Lost the connection to the server; reconnecting…' : 'Connecting to your server…'}</p>}
|
|
249
|
-
{away.length > 0 && <p class="home-ok"><HomeIcon name="check" /> Presenter view open on {devicesText(away)}.</p>}
|
|
250
|
-
{!pairing.available
|
|
251
|
-
? <p class="home-note">Start <code>mdeck run</code> with <code>--network</code>, so an iPad in the same network can reach it, or with <code>--server</code> to reach it through your server.</p>
|
|
252
|
-
: url
|
|
253
|
-
? <div class="home-pair">
|
|
254
|
-
<QrCode url={url} size="180" />
|
|
255
|
-
<div>
|
|
256
|
-
<p>Scan this with the iPad's camera. It opens the presenter view there, where you draw and go through the slides.</p>
|
|
257
|
-
<p>Show the <a href={`${BASE}?view=audience&session=${SESSION}`} target="_blank" rel="noopener">audience window</a> on the projector from this computer: it follows the iPad.</p>
|
|
258
|
-
<p class="home-note">The code works once, for ten minutes. {pairing.devices > 0 ? `${pairing.devices} paired so far.` : ''}</p>
|
|
185
|
+
<ul class="home-outputs">
|
|
186
|
+
{results.map(question => {
|
|
187
|
+
const { counts = [], voted, phones, average } = question.summary
|
|
188
|
+
const top = counts.slice(0, 3).map(row => `${row.answer} ${row.count}`).join(' · ')
|
|
189
|
+
return <li class="home-output" key={question.room}>
|
|
190
|
+
<div class="home-output-body">
|
|
191
|
+
<strong>{question.slide ? `Slide ${question.slide}: ` : ''}{question.question || question.room}</strong>
|
|
192
|
+
<span>{phones === 1 ? '1 phone' : `${phones} phones`}{voted != null && voted !== phones ? `, ${voted} counted` : ''}{question.closed ? ', closed' : ''}{average != null ? ` · average ${average}` : ''}{top ? ` · ${top}` : ''}</span>
|
|
259
193
|
</div>
|
|
260
|
-
</
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
194
|
+
</li>
|
|
195
|
+
})}
|
|
196
|
+
</ul>
|
|
197
|
+
<div class="home-answer-files">
|
|
198
|
+
<a class="home-btn home-btn--primary" href={href('csv')} download><HomeIcon name="download" size={15} />Answers (CSV)</a>
|
|
199
|
+
<a class="home-btn" href={href('summary')} download><HomeIcon name="download" size={15} />Summary (CSV)</a>
|
|
200
|
+
<a class="home-btn" href={href('json')} download><HomeIcon name="download" size={15} />Everything (JSON)</a>
|
|
201
|
+
</div>
|
|
202
|
+
<p class="home-hint">Kept in <code>{file}</code> beside the deck while <code>mdeck run</code> runs; phones are numbered per question, never named.</p>
|
|
267
203
|
</section>
|
|
268
204
|
}
|
|
269
205
|
|
|
206
|
+
// For decks with a `server` setting: the server's health, the viewers' link
|
|
207
|
+
// and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
|
|
208
|
+
// polls.
|
|
209
|
+
|
|
270
210
|
export function Home() {
|
|
271
211
|
const [info, setInfo] = useState(null)
|
|
272
212
|
const [error, setError] = useState('')
|
|
@@ -313,6 +253,11 @@ export function Home() {
|
|
|
313
253
|
<p class="home-meta">{meta.join(' · ')}</p>
|
|
314
254
|
<p class="home-file"><code>{info.file}</code> <button class="home-link" onClick={() => api('/action', { action: 'reveal', output: 'deck' }).catch(e => setError(e.message))}>Show</button></p>
|
|
315
255
|
</div>
|
|
256
|
+
{/* mdeck run --server: whether the connection to the server is up. */}
|
|
257
|
+
{info.relay && <span class={`home-health home-relay${info.relay.state === 'up' ? '' : info.relay.state === 'refused' ? ' is-bad' : ' is-warn'}`} title={info.relay.reason ?? info.relay.url}>
|
|
258
|
+
<HomeIcon name={info.relay.state === 'up' ? 'check' : 'alert'} />
|
|
259
|
+
{info.relay.state === 'up' ? 'Connected to your server' : info.relay.state === 'refused' ? 'Server refused the connection' : info.relay.state === 'down' ? 'Reconnecting to your server…' : 'Connecting to your server…'}
|
|
260
|
+
</span>}
|
|
316
261
|
<a class={`home-health${errors ? ' is-bad' : warnings ? ' is-warn' : ''}`} href="#check">
|
|
317
262
|
<HomeIcon name={errors || warnings ? 'alert' : 'check'} />
|
|
318
263
|
{errors ? `${errors} error${errors === 1 ? '' : 's'}` : warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : 'All good'}
|
|
@@ -330,13 +275,14 @@ export function Home() {
|
|
|
330
275
|
<ConnectedViews presence={presence} phones={!!info.live} />
|
|
331
276
|
</div>
|
|
332
277
|
<div class="home-tiles">
|
|
333
|
-
<ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide.
|
|
278
|
+
<ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Pair an iPad or a phone clicker from it." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
|
|
279
|
+
<ViewTile icon="rehearse" title="Rehearse" text="Go through the talk as if giving it: each slide is timed, and the times can be saved into the deck." href={`${BASE}?view=presenter&rehearse=1&session=${SESSION}${code}`} />
|
|
334
280
|
<ViewTile icon="projector" title="Audience window" text="Slides for the projector, without notes. Navigation stays in sync with the presenter." href={`${BASE}?view=audience&session=${SESSION}${code}`} open={openOn('audience')} />
|
|
335
281
|
<ViewTile icon="reader" title="Reader view" text="Outline, reading mode and look picker, as people you send it to see it." href={`${BASE}?view=reader`} />
|
|
336
282
|
</div>
|
|
337
283
|
</section>
|
|
338
284
|
|
|
339
|
-
<section class=
|
|
285
|
+
<section class="home-section home-section--wide">
|
|
340
286
|
<h2>Write and learn</h2>
|
|
341
287
|
<div class="home-tiles">
|
|
342
288
|
<ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
|
|
@@ -345,9 +291,9 @@ export function Home() {
|
|
|
345
291
|
</div>
|
|
346
292
|
</section>
|
|
347
293
|
|
|
348
|
-
{info.live && <LivePolls live={info.live} presence={presence} onRecheck={load} />}
|
|
349
294
|
|
|
350
|
-
{info.
|
|
295
|
+
{info.results?.length > 0 && <Answers results={info.results} file={info.name.replace(/\.md$/i, '') + '.results.json'} onRefresh={load} />}
|
|
296
|
+
|
|
351
297
|
|
|
352
298
|
<section class="home-section home-section--wide">
|
|
353
299
|
<h2>Share</h2>
|
package/src/home/home.css
CHANGED
|
@@ -80,6 +80,11 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
|
|
|
80
80
|
.home-tile-body strong { font-weight: 600; color: var(--home-strong); font-size: 13px; }
|
|
81
81
|
.home-tile-body span { color: var(--home-muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
82
82
|
|
|
83
|
+
/* ── Answers from the talk ─────────────────────────────────────────── */
|
|
84
|
+
.home-answer-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
85
|
+
.home-answer-files a { text-decoration: none; }
|
|
86
|
+
.home-answers .home-hint { color: var(--home-faint); font-size: 12px; margin: 8px 0 0; }
|
|
87
|
+
|
|
83
88
|
/* ── Share ────────────────────────────────────────────────────────────── */
|
|
84
89
|
.home-outputs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
85
90
|
.home-output { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); }
|
|
@@ -90,8 +95,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
|
|
|
90
95
|
.home-output-body details { margin-top: 6px; font-size: 12px; }
|
|
91
96
|
.home-output-body pre { max-height: 220px; overflow: auto; padding: 8px; border-radius: 4px; background: var(--home-bg); white-space: pre-wrap; }
|
|
92
97
|
.home-note { color: var(--home-warn) !important; }
|
|
93
|
-
.home-relay--up { color: var(--home-ok) !important; }
|
|
94
|
-
.home-relay--refused { color: var(--home-bad) !important; }
|
|
95
98
|
.home-output-actions { flex: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
96
99
|
.home-status { font-size: 12px; color: var(--home-faint); min-width: 90px; text-align: right; }
|
|
97
100
|
.home-status--done { color: var(--home-ok); }
|
|
@@ -117,17 +120,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
|
|
|
117
120
|
.home-line { margin-left: auto; flex: none; color: var(--home-faint); font-size: 12px; }
|
|
118
121
|
|
|
119
122
|
/* ── Live polls ───────────────────────────────────────────────────────── */
|
|
120
|
-
.home-live { display: grid; gap: 10px; margin: 0; }
|
|
121
|
-
.home-live > div { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px; align-items: baseline; }
|
|
122
|
-
.home-live dt { color: var(--home-muted); font-size: 12px; }
|
|
123
|
-
.home-live dd { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; font-size: 13px; }
|
|
124
|
-
.home-live dd > p { flex-basis: 100%; margin: 0; color: var(--home-muted); font-size: 12px; }
|
|
125
|
-
.home-live code { overflow-wrap: anywhere; }
|
|
126
|
-
.home-live a { color: var(--home-ink); }
|
|
127
|
-
.home-code { padding: 1px 6px; border: 1px solid var(--home-control); border-radius: 4px; background: var(--home-button); color: var(--home-strong); }
|
|
128
|
-
.home-pill { display: inline-block; padding: 0 8px; border: 1px solid var(--home-control); border-radius: 999px; font-size: 11px; color: var(--home-muted); }
|
|
129
|
-
.home-pill.is-ok { border-color: #2c4a35; color: var(--home-ok); }
|
|
130
|
-
.home-pill.is-bad { border-color: #5c2b28; color: var(--home-bad); }
|
|
131
123
|
|
|
132
124
|
@media (max-width: 760px) {
|
|
133
125
|
.home > :not(.home-header) { padding-left: 12px; padding-right: 12px; }
|
|
@@ -136,12 +128,8 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
|
|
|
136
128
|
.home-output { flex-wrap: wrap; }
|
|
137
129
|
.home-output-actions { width: 100%; justify-content: flex-start; }
|
|
138
130
|
.home-status { text-align: left; min-width: 0; }
|
|
139
|
-
.home-live > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
|
140
131
|
}
|
|
141
132
|
|
|
142
|
-
.home-pair { --qr-ink: #000; --qr-bg: #fff; display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
|
|
143
|
-
.home-pair canvas, .home-pair img, .home-pair svg { background: #fff; padding: 8px; border-radius: 6px; flex-shrink: 0; }
|
|
144
|
-
.home-pair p, .home-tablet > p { font-size: 13px; margin: 0 0 8px; }
|
|
145
133
|
|
|
146
134
|
/* A view that is open now, next to its title; the section head lists who is connected. */
|
|
147
135
|
.home-open { margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 500; color: var(--home-ok); background: rgba(129, 201, 149, 0.12); white-space: nowrap; }
|
|
@@ -6,6 +6,7 @@ import { registry } from '../runtime/registry'
|
|
|
6
6
|
import { scanTags, closeTags } from '../core/tags.js'
|
|
7
7
|
import { expandPollLists } from '../core/pollList.js'
|
|
8
8
|
import { textDirection } from '../core/languages.js'
|
|
9
|
+
import { sourceLines, fenceState } from '../core/source.js'
|
|
9
10
|
import { InkLayer } from '../runtime/ink/InkLayer.jsx'
|
|
10
11
|
import themedSvgs from 'virtual:deck-svgs'
|
|
11
12
|
import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
|
|
@@ -15,19 +16,32 @@ import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '
|
|
|
15
16
|
const FN_DEF_RE = /^\[\^([^\]\n]+)\]:\s+([^\n]+)/gm
|
|
16
17
|
const FN_REF_RE = /\[\^([^\]\n]+)\]/g
|
|
17
18
|
|
|
19
|
+
// `change(text, lineStart)` applied to the prose of `markdown` only: fenced
|
|
20
|
+
// code blocks and `inline code` keep what is written in them.
|
|
21
|
+
function inProse(markdown, change) {
|
|
22
|
+
let fence = null
|
|
23
|
+
return sourceLines(markdown).map(line => {
|
|
24
|
+
const raw = markdown.slice(line.start, line.end)
|
|
25
|
+
const before = fence
|
|
26
|
+
fence = fenceState(line.text, fence)
|
|
27
|
+
if (before || fence) return raw
|
|
28
|
+
return raw.split(/((`+)[^`]*?\2)/).map((part, i) => i % 3 === 0 ? change(part, i === 0) : i % 3 === 1 ? part : '').join('')
|
|
29
|
+
}).join('')
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
function preprocessFootnotes(markdown) {
|
|
19
33
|
const defs = {}
|
|
20
|
-
const stripped = markdown.replace(FN_DEF_RE, (_, label,
|
|
21
|
-
defs[label] =
|
|
34
|
+
const stripped = inProse(markdown, (text, lineStart) => lineStart ? text.replace(FN_DEF_RE, (_, label, note) => {
|
|
35
|
+
defs[label] = note.trim()
|
|
22
36
|
return ''
|
|
23
|
-
})
|
|
37
|
+
}) : text)
|
|
24
38
|
|
|
25
39
|
let counter = 0
|
|
26
40
|
const labelToNum = {}
|
|
27
|
-
const processed = stripped.replace(FN_REF_RE, (_, label) => {
|
|
41
|
+
const processed = inProse(stripped, text => text.replace(FN_REF_RE, (_, label) => {
|
|
28
42
|
if (labelToNum[label] === undefined) labelToNum[label] = ++counter
|
|
29
43
|
return `<sup>${labelToNum[label]}</sup>`
|
|
30
|
-
})
|
|
44
|
+
}))
|
|
31
45
|
|
|
32
46
|
if (counter === 0) return { processed: markdown, footnotesHtml: '' }
|
|
33
47
|
|
|
@@ -94,15 +108,39 @@ export function inlineThemedPictures(html) {
|
|
|
94
108
|
return out + html.slice(last)
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
// A drawing in the page more than once (on two slides, or in a slide shown
|
|
112
|
+
// twice) would repeat its ids, and url(#…) and href="#…" find the first copy,
|
|
113
|
+
// which may be on a hidden slide: each copy gets ids of its own.
|
|
114
|
+
let drawingCopies = 0
|
|
115
|
+
export function ownIds(svg) {
|
|
116
|
+
const named = svg.querySelectorAll('[id]')
|
|
117
|
+
if (!named.length || svg.hasAttribute('data-own-ids')) return
|
|
118
|
+
const suffix = `--${++drawingCopies}`
|
|
119
|
+
const renamed = new Map([...named].map(element => [element.id, element.id + suffix]))
|
|
120
|
+
for (const element of named) element.id = renamed.get(element.id)
|
|
121
|
+
const swap = text => text.replace(/url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/g, (match, quote, id) => renamed.has(id) ? `url(#${renamed.get(id)})` : match)
|
|
122
|
+
for (const element of svg.querySelectorAll('*')) {
|
|
123
|
+
for (const attribute of [...element.attributes]) {
|
|
124
|
+
const local = attribute.value.startsWith('#') && renamed.get(attribute.value.slice(1))
|
|
125
|
+
if (attribute.localName === 'href' && local) attribute.value = '#' + local
|
|
126
|
+
else if (attribute.value.includes('url(')) attribute.value = swap(attribute.value)
|
|
127
|
+
}
|
|
128
|
+
if (element.localName === 'style') element.textContent = swap(element.textContent)
|
|
129
|
+
}
|
|
130
|
+
svg.setAttribute('data-own-ids', '')
|
|
131
|
+
}
|
|
132
|
+
|
|
97
133
|
// A picture from the slide's settings; `fit: cover` fills its box like a
|
|
98
134
|
// photo, otherwise the drawing fits inside.
|
|
99
135
|
export function Picture({ src, alt, fit, position, class: className, ...attributes }) {
|
|
136
|
+
const ref = useRef()
|
|
100
137
|
const markup = themedSvg(src)
|
|
138
|
+
useEffect(() => { const svg = ref.current?.querySelector(':scope > svg'); if (svg) ownIds(svg) }, [markup])
|
|
101
139
|
if (!markup) return <img class={className} src={src} alt={alt} style={{ objectFit: fit, objectPosition: position }} {...attributes} />
|
|
102
140
|
const aspect = fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet'
|
|
103
141
|
// The box sets the size; the drawing scales inside it.
|
|
104
142
|
const html = markup.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height|preserveAspectRatio)="[^"]*"/g, '').replace('<svg', `<svg preserveAspectRatio="${aspect}"`))
|
|
105
|
-
return <div class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
|
|
143
|
+
return <div ref={ref} class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
|
|
106
144
|
}
|
|
107
145
|
|
|
108
146
|
// ─── Hydration for inline components ──────────────────────────────────────
|
|
@@ -116,6 +154,7 @@ export function HtmlContent({ html, class: className, ...attributes }) {
|
|
|
116
154
|
|
|
117
155
|
useEffect(() => {
|
|
118
156
|
if (!ref.current) return
|
|
157
|
+
ref.current.querySelectorAll('.themed-picture > svg').forEach(ownIds)
|
|
119
158
|
const mounted = []
|
|
120
159
|
for (const [name, Component] of Object.entries(registry)) {
|
|
121
160
|
for (const el of [...ref.current.querySelectorAll(name)]) {
|
|
@@ -197,8 +236,12 @@ export function prepareSlide(slide) {
|
|
|
197
236
|
const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
|
|
198
237
|
const entries = Object.entries(slide.regions ?? { body: { name: 'body', content: slide.content } })
|
|
199
238
|
.sort((a, b) => position(a[1]) - position(b[1]))
|
|
200
|
-
|
|
201
|
-
|
|
239
|
+
// The regions are scanned as one text, so numbered citations and footnotes
|
|
240
|
+
// run on across them. The separator stands on a line of its own: joined
|
|
241
|
+
// mid-line, a region's closing ``` fence read as an opening one, and the
|
|
242
|
+
// next region as code.
|
|
243
|
+
let separator = '\n\u0000mdeck-region\u0000\n'
|
|
244
|
+
while (entries.some(([, r]) => r.content.includes(separator.trim()))) separator = separator.replace('\u0000\n', '\u0000\u0000\n')
|
|
202
245
|
const bib = bibliographyOf(slide.deckConfig)
|
|
203
246
|
const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
|
|
204
247
|
const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
|
|
@@ -2,12 +2,24 @@ import { h } from 'preact'
|
|
|
2
2
|
import { layouts } from 'virtual:mdeck-extensions'
|
|
3
3
|
import { resolveLayoutProps } from './layoutProps'
|
|
4
4
|
import { SlideFrame, prepareSlide } from './layoutApi'
|
|
5
|
+
import { includeOf } from '../core/parseSlides.js'
|
|
5
6
|
|
|
6
7
|
// Every layout — built-in or deck-local — arrives through the same registry.
|
|
7
8
|
export { layouts }
|
|
8
9
|
export const manifests = Object.fromEntries(Object.entries(layouts).map(([id, t]) => [id, t.manifest]))
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// An include that reaches the page is one the deck was not expanded for: the
|
|
12
|
+
// editor works on the file as written (build/include.js). It shows as a card
|
|
13
|
+
// naming the file, in the editor's own words.
|
|
14
|
+
function includeCard(slide) {
|
|
15
|
+
const include = includeOf(slide.content)
|
|
16
|
+
if (!include) return slide
|
|
17
|
+
const content = `## Slides from \`${include.file}\`\n\n${include.slides ? `Slides ${include.slides} of that file appear` : 'The slides of that file appear'} here when the deck is presented. Edit them in that file.`
|
|
18
|
+
return { ...slide, content, regions: { ...slide.regions, body: { ...slide.regions?.body, name: 'body', content, explicit: false } } }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SlideRenderer(given) {
|
|
22
|
+
const slide = includeCard(given)
|
|
11
23
|
const id = slide.meta.layout ?? 'generic'
|
|
12
24
|
const entry = Object.hasOwn(layouts, id) ? layouts[id] : layouts.generic
|
|
13
25
|
const prepared = prepareSlide(slide)
|
|
@@ -52,3 +52,12 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
|
|
|
52
52
|
.answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
|
|
53
53
|
.answer-number input { width: 100%; min-width: 0; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; font-size: 22px; font-variant-numeric: tabular-nums; }
|
|
54
54
|
.answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|
|
55
|
+
/* Reactions: a row of emoji that float up on the presenter's screen. */
|
|
56
|
+
.answer-reactions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; width: 100%; max-width: 520px; margin: 0 auto; padding: 0 20px 8px; }
|
|
57
|
+
.answer-reactions button {
|
|
58
|
+
min-width: 46px; height: 46px; padding: 0 10px; border: 1px solid var(--rule); border-radius: 23px; background: var(--bg); color: var(--ink);
|
|
59
|
+
font: inherit; font-size: 24px; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
|
60
|
+
}
|
|
61
|
+
.answer-reactions button.is-sent { animation: answer-react .5s ease-out; }
|
|
62
|
+
@keyframes answer-react { 30% { transform: scale(1.25); border-color: var(--accent); } 100% { transform: scale(1); } }
|
|
63
|
+
@media (prefers-reduced-motion: reduce) { .answer-reactions button.is-sent { animation: none; border-color: var(--accent); } }
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
pickSeveral: 'Tap all that apply.', thanksSeveral: 'Thanks! You chose {choice}. Tap to change.', closed: 'Closed: no more answers.',
|
|
13
13
|
right: 'Right!', wrong: 'Not quite. The right answer: {answer}', solution: 'The right answer: {answer}',
|
|
14
14
|
number: 'A number', invalid: 'Enter a number, such as 0.5 or 1/2.', numberSent: 'Sent: {value}. Send another to change.',
|
|
15
|
+
react: 'React',
|
|
15
16
|
},
|
|
16
17
|
de: {
|
|
17
18
|
waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…',
|
|
18
19
|
pickSeveral: 'Alle zutreffenden antippen.', thanksSeveral: 'Danke! Gewählt: {choice}. Zum Ändern antippen.', closed: 'Geschlossen: keine weiteren Antworten.',
|
|
19
20
|
right: 'Richtig!', wrong: 'Leider nicht. Richtig ist: {answer}', solution: 'Richtig ist: {answer}',
|
|
20
21
|
number: 'Eine Zahl', invalid: 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.', numberSent: 'Gesendet: {value}. Zum Ändern eine andere senden.',
|
|
22
|
+
react: 'Reagieren',
|
|
21
23
|
},
|
|
22
24
|
}
|
|
23
25
|
let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
|
|
@@ -191,17 +193,23 @@
|
|
|
191
193
|
solution ? verdict(sent != null, right, drawn(solution.html, solution.text)) : null,
|
|
192
194
|
]
|
|
193
195
|
},
|
|
194
|
-
// { question, placeholder, maxLength }: short text, as often as
|
|
196
|
+
// { question, placeholder, maxLength, once }: short text, as often as
|
|
197
|
+
// people like; with `once` (a word cloud) the latest one counts, and the
|
|
198
|
+
// phone says which it sent.
|
|
195
199
|
text(activity, room) {
|
|
196
|
-
const
|
|
200
|
+
const sent = activity.once ? remembered(room) : null
|
|
201
|
+
const input = el('textarea', { rows: activity.once ? 1 : 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
|
|
197
202
|
const form = el('form', { class: 'answer-text', onsubmit: async event => {
|
|
198
203
|
event.preventDefault()
|
|
199
204
|
const value = input.value.trim()
|
|
200
205
|
if (!value) return
|
|
201
|
-
try {
|
|
206
|
+
try {
|
|
207
|
+
await answer(room, value)
|
|
208
|
+
if (activity.once) { remember(room, value); status = '' } else status = words.sent
|
|
209
|
+
} catch (error) { status = error.message }
|
|
202
210
|
render()
|
|
203
211
|
} }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
|
|
204
|
-
return [form, statusLine('')]
|
|
212
|
+
return [form, statusLine(sent != null ? fill(words.numberSent, { value: sent }) : '')]
|
|
205
213
|
},
|
|
206
214
|
// { question, min, max, minLabel, maxLabel }: a number on a scale.
|
|
207
215
|
scale(activity, room) {
|
|
@@ -222,6 +230,25 @@
|
|
|
222
230
|
},
|
|
223
231
|
}
|
|
224
232
|
|
|
233
|
+
// Reactions (src/core/reactions.js): a row of buttons whenever the deck
|
|
234
|
+
// offers them; a tap floats up on the presenter's screen. One tap a
|
|
235
|
+
// second at most, so a thumb on a button is not a flood.
|
|
236
|
+
const REACT_MS = 1000
|
|
237
|
+
let reactedAt = 0
|
|
238
|
+
async function react(value, button) {
|
|
239
|
+
const now = Date.now()
|
|
240
|
+
if (now - reactedAt < REACT_MS) return
|
|
241
|
+
reactedAt = now
|
|
242
|
+
button.classList.remove('is-sent')
|
|
243
|
+
void button.offsetWidth
|
|
244
|
+
button.classList.add('is-sent')
|
|
245
|
+
try {
|
|
246
|
+
await fetch(new URL(`rooms/${encodeURIComponent(`${code}.reactions`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
const reactions = list => el('nav', { class: 'answer-reactions', 'aria-label': words.react ?? 'React' },
|
|
250
|
+
list.map(value => el('button', { type: 'button', 'aria-label': value, onclick: event => react(value, event.currentTarget) }, value)))
|
|
251
|
+
|
|
225
252
|
function render() {
|
|
226
253
|
const activity = current?.activity
|
|
227
254
|
const form = activity && FORMS[activity.type]
|
|
@@ -230,6 +257,7 @@
|
|
|
230
257
|
form
|
|
231
258
|
? el('main', { class: 'answer' }, activity.question && el('h1', {}, drawn(activity.questionHtml, activity.question)), form(activity, current.room))
|
|
232
259
|
: el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
|
|
260
|
+
connected && Array.isArray(current?.reactions) && current.reactions.length ? reactions(current.reactions.filter(value => typeof value === 'string').slice(0, 8)) : null,
|
|
233
261
|
current?.title ? el('footer', {}, current.title) : null,
|
|
234
262
|
// The presenter's screen sends the link when other devices can follow the slides.
|
|
235
263
|
/^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
|
package/src/live/client.js
CHANGED
|
@@ -13,11 +13,12 @@ import { pairToken, pairHeaders } from './pairing.js'
|
|
|
13
13
|
import { keptAnswers, answersAsMessages } from '../core/results.js'
|
|
14
14
|
import { devPath } from '../core/devPath.js'
|
|
15
15
|
import { t, deckLanguage } from '../core/labels.js'
|
|
16
|
+
import { reactionsOf, REACTIONS_ROOM } from '../core/reactions.js'
|
|
16
17
|
|
|
17
18
|
// The QR code component the built-in poll uses, for your own activities.
|
|
18
19
|
export { default as QrCode } from '../components/QrCode.jsx'
|
|
19
20
|
|
|
20
|
-
let settings = { server: null, code: '000000' }
|
|
21
|
+
let settings = { server: null, code: '000000', reactions: null }
|
|
21
22
|
|
|
22
23
|
// Called by the runtime with the deck settings.
|
|
23
24
|
// `mdeck run --server <address>` and MDECK_SERVER name the server without the
|
|
@@ -26,7 +27,7 @@ const givenServer = typeof __MDECK_SERVER__ === 'string' ? __MDECK_SERVER__ : nu
|
|
|
26
27
|
|
|
27
28
|
export function configureLive(deckConfig = {}) {
|
|
28
29
|
const server = givenServer ?? deckConfig.server
|
|
29
|
-
settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig) }
|
|
30
|
+
settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig), reactions: reactionsOf(deckConfig) }
|
|
30
31
|
infoRequest = null
|
|
31
32
|
takeKeyFromAddress()
|
|
32
33
|
}
|
|
@@ -211,6 +212,22 @@ export function useRoom(room) {
|
|
|
211
212
|
return { messages: kept ? answersAsMessages(kept.answers) : messages, reset, state, setState, present, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Reactions sent from the phones, for the presenter's screen:
|
|
217
|
+
* `onReaction({ value, at, n })` for each one as it arrives. Returns a
|
|
218
|
+
* function that stops listening. Nothing happens without a room server.
|
|
219
|
+
*/
|
|
220
|
+
export function listenToReactions(onReaction) {
|
|
221
|
+
if (typeof EventSource === 'undefined') return () => {}
|
|
222
|
+
const source = new EventSource(`${roomPath(REACTIONS_ROOM)}/events`)
|
|
223
|
+
source.addEventListener('message', event => {
|
|
224
|
+
const message = JSON.parse(event.data)
|
|
225
|
+
const value = message.data?.value
|
|
226
|
+
if (typeof value === 'string' && settings.reactions?.includes(value)) onReaction({ value, at: message.at, n: message.n })
|
|
227
|
+
})
|
|
228
|
+
return () => source.close()
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
/** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
|
|
215
232
|
export function useJoinLink() {
|
|
216
233
|
const [info, setInfo] = useState({})
|
|
@@ -259,7 +276,7 @@ export function captureLook(doc = lookSource()) {
|
|
|
259
276
|
|
|
260
277
|
// The words the answer page shows, in the deck's language.
|
|
261
278
|
const phoneWords = () => ({
|
|
262
|
-
waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'),
|
|
279
|
+
waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'), react: t('respond.react'),
|
|
263
280
|
pickSeveral: t('poll.pickSeveral'), thanksSeveral: t('poll.thanksSeveral'), several: t('poll.several'),
|
|
264
281
|
closed: t('respond.closed'), right: t('respond.right'), wrong: t('respond.wrong'), solution: t('respond.solution'),
|
|
265
282
|
number: t('numeric.placeholder'), invalid: t('numeric.invalid'), numberSent: t('numeric.sent'),
|
|
@@ -304,7 +321,9 @@ async function sendCurrent() {
|
|
|
304
321
|
if (look) lastLook = look
|
|
305
322
|
// Phones that answer can also follow the slides, where that works.
|
|
306
323
|
const follow = followUrl(info)
|
|
307
|
-
|
|
324
|
+
// The reactions the phones offer, whatever is on the slide.
|
|
325
|
+
const reactions = settings.reactions ? { reactions: settings.reactions } : {}
|
|
326
|
+
const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...reactions, ...(follow ? { follow } : {}) }
|
|
308
327
|
try {
|
|
309
328
|
const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
|
|
310
329
|
const body = response.ok ? await response.json().catch(() => ({})) : {}
|
package/src/live/rooms.js
CHANGED
|
@@ -9,11 +9,17 @@ import { timingSafeEqual } from 'node:crypto'
|
|
|
9
9
|
import { readFileSync } from 'node:fs'
|
|
10
10
|
import { CODE_RE } from './code.js'
|
|
11
11
|
|
|
12
|
+
// This mdeck's version, in /info: a presenter on a newer mdeck can tell that
|
|
13
|
+
// this server lacks what its deck uses (src/build/homePlugin.js).
|
|
14
|
+
const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version
|
|
15
|
+
|
|
12
16
|
// The phones' answer page, served by the server itself at /<code>.
|
|
13
17
|
const ANSWER_FILES = Object.fromEntries(['answer.html', 'answer.js', 'answer.css'].map(name => [name, readFileSync(new URL(`./answer/${name}`, import.meta.url))]))
|
|
14
18
|
const ANSWER_TYPES = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8' }
|
|
15
19
|
|
|
16
20
|
export const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
21
|
+
// A session's reactions room: `<code>.reactions`.
|
|
22
|
+
const isReactionRoom = id => id.endsWith('.reactions')
|
|
17
23
|
// What a connected window may say it is, for the presenter's overview of
|
|
18
24
|
// who is connected (src/components/ConnectedViews.jsx); phones only count.
|
|
19
25
|
export const VIEWS = ['presenter', 'audience', 'deck', 'phone']
|
|
@@ -46,9 +52,11 @@ export function createRooms({ maxMessages = 5000, maxInk = 5000, maxRooms = 500,
|
|
|
46
52
|
return {
|
|
47
53
|
post(id, { from = null, data }) {
|
|
48
54
|
const room = get(id)
|
|
49
|
-
|
|
55
|
+
// Reactions (src/core/reactions.js) are passed on and forgotten.
|
|
56
|
+
const keep = !isReactionRoom(id)
|
|
57
|
+
if (keep && room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
|
|
50
58
|
const message = { n: room.next++, at: new Date(now()).toISOString(), from, data }
|
|
51
|
-
room.messages.push(message)
|
|
59
|
+
if (keep) room.messages.push(message)
|
|
52
60
|
broadcast(room, { type: 'message', message })
|
|
53
61
|
return message
|
|
54
62
|
},
|
|
@@ -181,12 +189,13 @@ function reply(response, status, body) {
|
|
|
181
189
|
// ?presence=1 adds who is connected to the session
|
|
182
190
|
// ?only=state sends the room's state alone
|
|
183
191
|
// GET /rooms/<id>/presence who is connected to the session, once
|
|
184
|
-
// POST /rooms/<id> { from, data } → { n }
|
|
192
|
+
// POST /rooms/<id> { from, data } → { n }; in <code>.reactions
|
|
193
|
+
// passed on without being kept
|
|
185
194
|
// POST /rooms/<id>/reset presenter only
|
|
186
195
|
// POST /rooms/<id>/state { state } — presenter only
|
|
187
196
|
// POST /rooms/<id>/ink { messages } — presenter only, live ink
|
|
188
197
|
// Answers carry no cookies and no personal data, so any page may use them.
|
|
189
|
-
export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), heartbeatMs = 20000 } = {}) {
|
|
198
|
+
export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), reactionLimit = createLimiter({ burst: 120, perMs: 10000 }), heartbeatMs = 20000 } = {}) {
|
|
190
199
|
return async (request, response, next = () => reply(response, 404, { error: 'Not found' })) => {
|
|
191
200
|
const { pathname, searchParams } = new URL(request.url, 'http://localhost')
|
|
192
201
|
response.setHeader('Access-Control-Allow-Origin', '*')
|
|
@@ -194,7 +203,7 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
|
|
|
194
203
|
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
195
204
|
if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return }
|
|
196
205
|
try {
|
|
197
|
-
if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), ...info(request) })
|
|
206
|
+
if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), version: VERSION, ...info(request) })
|
|
198
207
|
const page = pathname.slice(1)
|
|
199
208
|
if (request.method === 'GET' && (CODE_RE.test(page) || Object.hasOwn(ANSWER_FILES, page))) {
|
|
200
209
|
const name = CODE_RE.test(page) ? 'answer.html' : page
|
|
@@ -240,8 +249,11 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
|
|
|
240
249
|
else if (!rooms.setState(id, (await readBody(request, MAX_STATE)).state)) return reply(response, 200, { ok: true, ignored: true })
|
|
241
250
|
return reply(response, 200, { ok: true })
|
|
242
251
|
}
|
|
243
|
-
|
|
252
|
+
// Reactions have a limit of their own: a room behind one address (a
|
|
253
|
+
// lecture hall's network) must not use up its answers with them.
|
|
254
|
+
if (!(isReactionRoom(id) ? reactionLimit : limit)(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
|
|
244
255
|
const body = await readBody(request)
|
|
256
|
+
if (isReactionRoom(id) && (typeof body.data?.value !== 'string' || body.data.value.length > 16)) return reply(response, 400, { error: 'A reaction is a short text' })
|
|
245
257
|
if (!('data' in body)) return reply(response, 400, { error: 'Send { data }' })
|
|
246
258
|
const from = typeof body.from === 'string' ? body.from.slice(0, 64) : null
|
|
247
259
|
return reply(response, 201, { n: rooms.post(id, { from, data: body.data }).n })
|