mdeck 3.3.0 → 3.4.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +18 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +27 -4
  8. package/docs/site/content/audience.md +35 -4
  9. package/docs/site/content/commands.md +2 -0
  10. package/package.json +1 -1
  11. package/skills/write-slides/SKILL.md +1 -1
  12. package/src/build/bibliography.js +29 -11
  13. package/src/build/resultsPlugin.js +77 -0
  14. package/src/build/slidesPlugin.js +28 -5
  15. package/src/build/themeCheck.js +2 -1
  16. package/src/cli/args.js +2 -1
  17. package/src/cli/index.js +17 -4
  18. package/src/components/Numeric.jsx +59 -0
  19. package/src/components/Poll.jsx +58 -55
  20. package/src/components/Question.jsx +10 -6
  21. package/src/components/Scale.jsx +13 -8
  22. package/src/components/WordCloud.jsx +10 -6
  23. package/src/components/activity.jsx +88 -7
  24. package/src/components/inlineText.js +40 -0
  25. package/src/components/numbers.js +39 -0
  26. package/src/components/poll.css +23 -13
  27. package/src/core/editDeck.js +2 -2
  28. package/src/core/icons.js +2 -0
  29. package/src/core/labels.js +32 -0
  30. package/src/core/results.js +64 -0
  31. package/src/editor/sampleDeck.js +34 -8
  32. package/src/layouts/layoutApi.jsx +4 -3
  33. package/src/live/answer/answer.css +19 -0
  34. package/src/live/answer/answer.js +147 -24
  35. package/src/live/client.js +51 -6
  36. package/src/live/roomTag.js +6 -2
  37. package/src/live/rooms.js +6 -1
  38. package/src/runtime/citations.js +15 -5
  39. package/src/runtime/ink/bus.js +7 -13
  40. package/src/runtime/main.jsx +3 -1
  41. package/src/runtime/registry.jsx +2 -0
@@ -8,11 +8,12 @@ import { embedFonts } from './fonts.js'
8
8
  import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
9
9
  import { componentFolders, componentFiles } from './components.js'
10
10
  import { inkFileFor, normalizeInk, emptyInk } from '../core/ink.js'
11
+ import { resultsFileFor, normalizeResults } from '../core/results.js'
11
12
  import { isOwnWrite } from './ownWrites.js'
12
13
  import { fileURLToPath } from 'node:url'
13
14
  import { assertDrawings } from './drawings.js'
14
15
  import { marked } from 'marked'
15
- import { resolveBibliography } from './bibliography.js'
16
+ import { resolveBibliography, sampleBibliography } from './bibliography.js'
16
17
 
17
18
  const VIRTUAL_ID = 'virtual:slides'
18
19
  const RESOLVED_ID = '\0virtual:slides'
@@ -27,6 +28,8 @@ const SVGS_ID = 'virtual:deck-svgs'
27
28
  const RESOLVED_SVGS_ID = '\0virtual:deck-svgs'
28
29
  const BIB_ID = 'virtual:bibliography'
29
30
  const RESOLVED_BIB_ID = '\0virtual:bibliography'
31
+ const RESULTS_ID = 'virtual:deck-results'
32
+ const RESOLVED_RESULTS_ID = '\0virtual:deck-results'
30
33
 
31
34
  const MIME_BY_EXT = {
32
35
  '.png': 'image/png',
@@ -139,6 +142,8 @@ export function collectLocalAssetRefs(markdown) {
139
142
  if (token.type === 'image') add(token.href)
140
143
  if (token.type === 'html') {
141
144
  for (const match of token.text.matchAll(/<(?:img|video|audio|source|videoplayer)\b[^>]*\bsrc=(["'])([^"']+)\1/gi)) add(match[2])
145
+ // Markdown pictures in a tag's attributes, such as a poll's options.
146
+ for (const match of token.text.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) add(match[1])
142
147
  }
143
148
  })
144
149
  }
@@ -216,7 +221,7 @@ export function deckLookOnly(registry, deckConfig = {}) {
216
221
  // extension changes are announced instead of forcing a reload. `source` is
217
222
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
218
223
  // `slidesPath` then only says where its folder is.
219
- export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, ink = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
224
+ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = false, editor = false, stripNotes = false, stripActivities = false, ink = true, results = true, embedFonts: embedThemeFonts = false, deckLook = false, source: given = null } = {}) {
220
225
  const abs = resolve(slidesPath)
221
226
  const readDeck = () => given ?? readFileSync(abs, 'utf-8')
222
227
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
@@ -225,6 +230,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
225
230
  return stripActivities ? stripActivitiesFrom(shared).source : shared
226
231
  }
227
232
  const inkPath = inkFileFor(abs)
233
+ const resultsPath = resultsFileFor(abs)
228
234
  const extensionDirs = extensionRoots(abs).map(root => root.dir)
229
235
  let componentDirs = componentFolders(abs, readDeck()).map(folder => folder.dir)
230
236
  const watchDirs = () => [...extensionDirs, ...componentDirs]
@@ -243,6 +249,9 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
243
249
  const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
244
250
  // The reference files (and .csl style) the deck names, from the last load.
245
251
  let bibFiles = []
252
+ // The dev and edit servers also show the sample deck (?sample=1, the
253
+ // design page), with its references from memory; builds never do.
254
+ let serving = false
246
255
  // The editor keeps its page: the formatted references arrive as a hot update.
247
256
  const bibUpdate = server => {
248
257
  const mod = server.moduleGraph.getModuleById(RESOLVED_BIB_ID)
@@ -262,6 +271,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
262
271
 
263
272
  return {
264
273
  name: 'vite-plugin-slides',
274
+ configResolved(config) { serving = config.command === 'serve' },
265
275
  configureServer(server) {
266
276
  server.watcher.add([abs, inkPath, ...watchDirs()])
267
277
  const refresh = file => {
@@ -280,6 +290,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
280
290
  if (id === INK_ID) return RESOLVED_INK_ID
281
291
  if (id === SVGS_ID) return RESOLVED_SVGS_ID
282
292
  if (id === BIB_ID) return RESOLVED_BIB_ID
293
+ if (id === RESULTS_ID) return RESOLVED_RESULTS_ID
283
294
  },
284
295
  load(id) {
285
296
  if (id === RESOLVED_ID) {
@@ -319,6 +330,16 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
319
330
  }
320
331
  return `export default ${JSON.stringify(data)}\nexport const inkFileName = ${JSON.stringify(basename(inkPath))}`
321
332
  }
333
+ // The answers kept in the talk (<deck>.results.json): builds show them
334
+ // where no room server is (src/live/client.js). `mdeck run` shows the
335
+ // live answers, and writes the file, so it does not read it here.
336
+ if (id === RESOLVED_RESULTS_ID) {
337
+ let data = normalizeResults({})
338
+ if (results && !serving && existsSync(resultsPath)) {
339
+ try { data = normalizeResults(JSON.parse(readFileSync(resultsPath, 'utf-8'))) } catch (error) { this.warn(`${resultsPath}: ${error.message}; showing no results`) }
340
+ }
341
+ return `export default ${JSON.stringify(data)}`
342
+ }
322
343
  if (id === RESOLVED_SVGS_ID) {
323
344
  watchDeck(this)
324
345
  const svgs = themedSvgs(abs, sourceFor(readDeck()))
@@ -329,12 +350,12 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
329
350
  // that none of it travels in the deck (bibliography.js).
330
351
  if (id === RESOLVED_BIB_ID) {
331
352
  watchDeck(this)
332
- return resolveBibliography(abs, sourceFor(readDeck())).then(bibliography => {
353
+ const data = bibliography => bibliography && { ids: bibliography.ids, clusters: bibliography.clusters, singles: bibliography.singles, entries: bibliography.entries, order: bibliography.order }
354
+ return Promise.all([resolveBibliography(abs, sourceFor(readDeck()), { sample: serving }), serving ? sampleBibliography() : null]).then(([bibliography, sample]) => {
333
355
  bibFiles = bibliography ? [...bibliography.files, bibliography.style].filter(Boolean) : []
334
356
  for (const file of bibFiles) if (existsSync(file)) this.addWatchFile(file)
335
357
  if (bibliography?.diagnostics.length) this.warn(bibliography.diagnostics.map(d => d.message).join('\n'))
336
- const data = bibliography && { ids: bibliography.ids, clusters: bibliography.clusters, singles: bibliography.singles, entries: bibliography.entries, order: bibliography.order }
337
- return `export default ${JSON.stringify(data)}`
358
+ return `export default ${JSON.stringify(data(bibliography))}\nexport const sample = ${JSON.stringify(data(sample))}`
338
359
  })
339
360
  }
340
361
  if (id === RESOLVED_EXTENSIONS_ID) {
@@ -381,6 +402,8 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
381
402
  }
382
403
  // mdeck's own writes (saved ink, a new slide id) must not reload the
383
404
  // windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
405
+ // The results file is written during the talk and read only by builds.
406
+ if (changed === resultsPath) return []
384
407
  const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
385
408
  if (own) {
386
409
  invalidate(server, changed === abs ? SOURCE_IDS : [RESOLVED_INK_ID])
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'
10
10
  import { resolve } from 'node:path'
11
11
  import { launchChrome } from './chrome.js'
12
12
  import { frameworkRoot } from '../paths.js'
13
- import { sampleDeck, SAMPLE_PICTURE } from '../editor/sampleDeck.js'
13
+ import { sampleDeck, SAMPLE_PICTURE, SAMPLE_REFERENCES, SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
14
14
  import { loadRegistry } from '../extensions/discover.js'
15
15
 
16
16
  // Runs in the page before the deck: keeps what the page reports.
@@ -91,6 +91,7 @@ export function buildSampleDeck({ extensions = {}, outDir }) {
91
91
  try {
92
92
  writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
93
93
  writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
94
+ writeFileSync(resolve(temp, SAMPLE_REFERENCES_FILE), SAMPLE_REFERENCES)
94
95
  // Keyed by id, or by "kind:id" to keep a theme and a palette of one name apart.
95
96
  for (const [key, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', ...key.split(':').map((part, i, all) => all.length > 1 && i === 0 ? `${part}s` : part)), { recursive: true })
96
97
  mkdirSync(resolve(temp, 'home'))
package/src/cli/args.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Parse the entire command before it can start a server or change any files.
2
2
  const value = 'value', flag = 'flag', optional = 'optional'
3
3
  const common = { '--port': value, '--no-open': flag }
4
- const output = { '--output': value, '-o': '--output', '--no-drawings': flag }
4
+ const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--no-results': flag }
5
5
  const commands = {
6
6
  new: { max: 0 },
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
@@ -21,6 +21,7 @@ const commands = {
21
21
  server: { '--port': value, '--host': optional, max: 0 },
22
22
  skill: { '--print': flag, '--install': 'list', '--project': flag, max: 0 },
23
23
  migrate: { '--dry-run': flag, max: 1 },
24
+ results: { '--output': value, '-o': '--output', max: 1 },
24
25
  }
25
26
 
26
27
  export function validateServerOrigin(address) {
package/src/cli/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer, build, preview } from 'vite'
2
- import { existsSync, copyFileSync, mkdtempSync, readFileSync, statSync } from 'fs'
2
+ import { existsSync, copyFileSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'fs'
3
3
  import { chmod, rm, mkdir, writeFile } from 'fs/promises'
4
4
  import { resolve, dirname, basename, relative, isAbsolute } from 'path'
5
5
  import { tmpdir } from 'os'
@@ -15,6 +15,7 @@ import { homePlugin } from '../build/homePlugin.js'
15
15
  import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
16
16
  import { livePlugin, startLiveServer } from '../live/server.js'
17
17
  import { inkPlugin } from '../build/inkPlugin.js'
18
+ import { resultsPlugin } from '../build/resultsPlugin.js'
18
19
  import { createPairing, pairingPlugin } from '../build/pairing.js'
19
20
  import { isAllowedRequest } from '../build/editorPlugin.js'
20
21
  import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
@@ -27,6 +28,7 @@ import { parseSlides } from '../core/parseSlides.js'
27
28
  import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
28
29
  import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
29
30
  import { assertDrawings } from '../build/drawings.js'
31
+ import { resultsFileFor, normalizeResults, resultsCsv } from '../core/results.js'
30
32
  import { createRelayAccess } from '../build/relayAccess.js'
31
33
  import { sessionCode } from '../live/code.js'
32
34
  import { migrateDeck } from './migrate.js'
@@ -331,6 +333,7 @@ const HELP = `
331
333
  --no-pdf do not render the PDF inside
332
334
  ${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
333
335
  --no-drawings for build, send and pdf: leave out <slides>.drawings.json
336
+ --no-results for build, send and pdf: leave out <slides>.results.json (the answers kept in the talk)
334
337
  ${c.green}mdeck preview${c.reset} [folder] Preview a built folder (default: dist)
335
338
 
336
339
  ${c.dim}Check and look things up${c.reset}
@@ -341,6 +344,7 @@ const HELP = `
341
344
  ${c.green}mdeck snapshot${c.reset} [slides.md] Pictures of slides as PNG files (default: .mdeck-snapshots/)
342
345
  --slide <numbers> only these slides: 3, 3,5 or 2-4
343
346
  --dark | --light in this appearance instead of the deck's own -o <folder>
347
+ ${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV (room, phone, time, answer)
344
348
  ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
345
349
  List built-in and deck-local layouts, themes and palettes
346
350
  ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
@@ -466,6 +470,15 @@ if (command === 'new') {
466
470
  else if (hasFlag('--dry-run')) tip('No files changed. Run without --dry-run to apply these changes.')
467
471
  } catch (error) { err(error.message); process.exitCode = 1 }
468
472
 
473
+ } else if (command === 'results') {
474
+ const input = requireInput('results')
475
+ const file = resultsFileFor(resolve(input))
476
+ if (!existsSync(file)) { err(`No answers kept for ${basename(input)} yet: ${basename(file)} appears beside it once people answer during mdeck run.`); process.exit(1) }
477
+ let csv
478
+ try { csv = resultsCsv(normalizeResults(JSON.parse(readFileSync(file, 'utf8')))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
479
+ const out = outputOption()
480
+ if (out) { writeFileSync(out, csv); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
481
+ else process.stdout.write(csv)
469
482
  } else if (command === 'check') {
470
483
  const input = requireInput('check')
471
484
  const registry = registryFor(input)
@@ -562,7 +575,7 @@ if (command === 'new') {
562
575
  ...base,
563
576
  // A paired iPad may save ink and steer the rooms, like this computer.
564
577
  ...(relay ? { base: relay.base } : {}),
565
- plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
578
+ plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), resultsPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
566
579
  publicDir: dirname(abs),
567
580
  server: {
568
581
  ...base.server,
@@ -687,7 +700,7 @@ if (command === 'new') {
687
700
  try {
688
701
  await build({
689
702
  ...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
690
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
703
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
691
704
  build: {
692
705
  outDir,
693
706
  emptyOutDir: !tempDir,
@@ -759,7 +772,7 @@ if (command === 'new') {
759
772
  try {
760
773
  await build({
761
774
  ...baseConfig(input, { selfContained: true }),
762
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
775
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: true }), viteSingleFile()],
763
776
  build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
764
777
  logLevel: 'warn',
765
778
  })
@@ -0,0 +1,59 @@
1
+ import { h } from 'preact'
2
+ import { useRoom, latestByDevice } from '../live/client.js'
3
+ import { t } from '../core/labels.js'
4
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
5
+ import { slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
6
+ import { parseNumber, groupNumbers, isRightNumber } from './numbers.js'
7
+ import './poll.css'
8
+
9
+ // <numeric room="limit" question="What is $\lim_{x\to 0} \frac{\sin x}{x}$?" answer="1" tolerance="0.01" />
10
+ // Phones type a number (0.5, 0,5 or 1/2); each phone's latest one counts.
11
+ // The slide shows the most frequent answers as bars (`limit`, 6 by default)
12
+ // and the rest as Other. `answer` and `tolerance` (0 by default) say which
13
+ // answers are right: the tick outlines them, says how many were right, and
14
+ // tells each phone whether its answer was. Close, Reset and the results
15
+ // button work as for a poll.
16
+
17
+ export default function Numeric({ room = 'numeric', question = '', answer = '', tolerance = '', limit = '6', results = '', qr }) {
18
+ const right = parseNumber(answer)
19
+ const within = Math.max(0, parseNumber(tolerance) ?? 0)
20
+ const live = useRoom(room)
21
+ const controls = useControls(room, live, { results: results !== 'hidden', solution: solutionFor(answer, tolerance, phoneHtml) })
22
+ const texts = latestByDevice(controls.counted(live.messages)).map(message => message.data?.value).filter(value => parseNumber(value) != null)
23
+ const groups = groupNumbers(texts)
24
+ const shown = groups.slice(0, Math.max(1, Number(limit) || 6))
25
+ const other = groups.slice(shown.length).reduce((sum, group) => sum + group.n, 0)
26
+ const rows = [...shown.map(group => ({ ...group, correct: isRightNumber(group.value, right, within) })), ...(other ? [{ text: t('numeric.other'), n: other, other: true }] : [])]
27
+ const max = Math.max(1, ...rows.map(row => row.n))
28
+ const rightCount = right == null ? 0 : groups.filter(group => isRightNumber(group.value, right, within)).reduce((sum, group) => sum + group.n, 0)
29
+ const still = live.offline && !texts.length
30
+ return <div class={`poll poll--numeric${controls.revealed ? ' is-solved' : ''}${still ? ' is-static' : ''}${controls.results ? '' : ' is-results-hidden'}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
31
+ <div class="poll-main">
32
+ <ActivityQuestion text={question} />
33
+ <div class="poll-bars" role="list">
34
+ {rows.map(row => <div class={`poll-row${row.correct ? ' is-correct' : ''}${row.other ? ' is-other' : ''}`} role="listitem" key={row.other ? '' : row.value}>
35
+ <span class="poll-label">{row.text}</span>
36
+ <span class="poll-track"><span class="poll-fill" style={{ width: `${(row.n / max) * 100}%` }} /></span>
37
+ <span class="poll-count">{row.n}</span>
38
+ </div>)}
39
+ </div>
40
+ {!rows.length && !live.offline && <p class="question-empty">{t('question.empty')}</p>}
41
+ {right != null && controls.revealed && <p class="numeric-answer">{t('respond.solution', { answer: <span><span dangerouslySetInnerHTML={{ __html: slideHtml(answer) }} />{within > 0 && ` ± ${tolerance}`}</span> })} · {t('numeric.right', { n: rightCount })}</p>}
42
+ <ActivityFooter count={texts.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
43
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} results solve={right != null} />
44
+ </ActivityFooter>
45
+ </div>
46
+ <JoinCode room={room} {...live} qr={wantsQr(qr)} size={240} />
47
+ </div>
48
+ }
49
+
50
+ // What phones get once the right answer shows: the number, how far off still
51
+ // counts, and the answer as written, drawn.
52
+ function solutionFor(answer, tolerance, draw) {
53
+ const value = parseNumber(answer)
54
+ if (value == null) return null
55
+ const within = Math.max(parseNumber(tolerance) ?? 0, 1e-9 * Math.max(1, Math.abs(value)))
56
+ return { value, tolerance: within, text: String(answer), html: draw(String(answer)) }
57
+ }
58
+
59
+ Numeric.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'numeric', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '' })
@@ -1,9 +1,8 @@
1
1
  import { h } from 'preact'
2
- import { useEffect, useRef, useState } from 'preact/hooks'
3
2
  import { useRoom, latestByDevice } from '../live/client.js'
4
3
  import { t } from '../core/labels.js'
5
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
6
- import { Icon } from './Icon.jsx'
4
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr, isSet } from './activity.jsx'
5
+ import { choicesOf, slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
7
6
  import './poll.css'
8
7
 
9
8
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
@@ -13,78 +12,82 @@ import './poll.css'
13
12
  // as described by Poll.phone. Each device has one vote and can change it; the
14
13
  // latest one counts.
15
14
  //
15
+ // `multiple` lets each phone pick several options; the slide and the phones
16
+ // say so. A vote is then the list of options picked.
17
+ //
18
+ // `buttons="letters"` (or "numbers") puts A, B, C… (1, 2, 3…) before the
19
+ // options on the slide, and the phones show only those: for options that are
20
+ // pictures, long formulas, or anything else better read on the big screen.
21
+ //
16
22
  // `answer="Thai"` (several: "Thai|Pizza") marks the right answer: a button
17
- // with a tick outlines its label, bar and count in green. Showing it is an
18
- // event on the stage (`pollreveal`, { room, shown }), which the ink bus
19
- // passes to the audience window and other devices.
23
+ // with a tick outlines its label, bar and count in green, and phones say
24
+ // whether theirs was right. A button with a lock closes the poll: later votes
25
+ // do not count, and phones cannot vote until it opens again. A button with
26
+ // an eye shows or hides the results (bars and counts); `results="hidden"`
27
+ // starts with them hidden, e.g. for peer instruction. Hidden, the audience
28
+ // window shows no bars; the presenter's own screens show them faint, so the
29
+ // presenter can still judge the votes. All of them reach every screen of
30
+ // the talk (useControls in activity.jsx).
20
31
  //
21
- // A button with an eye shows or hides the results (bars and counts), the
22
- // same way (`pollresults`, { room, shown }). `results="hidden"` starts with
23
- // them hidden, e.g. for peer instruction. Hidden, the audience window shows no
24
- // bars; the presenter's own screens show them faint, so the presenter can
25
- // still judge the votes.
32
+ // The question and the options are Markdown with $…$ maths, as on a slide:
33
+ // options="$x^2$|$2x$|$\frac{x^3}{3}$". A bar inside a formula stays in it
34
+ // ($|x|$); \| is a bar in the text. Votes and `answer` use the source text.
26
35
 
27
- const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
36
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
37
+ /** What `buttons` puts before each option: letters, numbers, or nothing. */
38
+ export const keysFor = (buttons, count) => {
39
+ const kind = String(buttons ?? '').toLowerCase()
40
+ if (kind === 'letters') return Array.from({ length: count }, (_, i) => LETTERS[i] ?? String(i + 1))
41
+ if (kind === 'numbers') return Array.from({ length: count }, (_, i) => String(i + 1))
42
+ return null
43
+ }
28
44
 
29
- export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '' }) {
45
+ // A vote: one option, or with `multiple` the options picked. A phone page
46
+ // from before `multiple` sends one option; it counts as a list of one.
47
+ const picksOf = (value, multiple) => multiple ? (Array.isArray(value) ? value : [value]) : (Array.isArray(value) ? [] : [value])
48
+
49
+ export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '', multiple, buttons }) {
30
50
  const choices = choicesOf(options)
51
+ const several = isSet(multiple)
52
+ const keys = keysFor(buttons, choices.length)
31
53
  const correct = choicesOf(answer).filter(choice => choices.includes(choice))
32
- const [shown, setShown] = useState(false)
33
- const [resultsShown, setResultsShown] = useState(results !== 'hidden')
34
- const ref = useRef()
35
- const stage = () => ref.current?.closest('deck-stage')
36
- useEffect(() => {
37
- const target = stage()
38
- if (!target || !correct.length) return
39
- const onReveal = event => { if (event.detail?.room === room) setShown(!!event.detail.shown) }
40
- target.addEventListener('pollreveal', onReveal)
41
- return () => target.removeEventListener('pollreveal', onReveal)
42
- }, [room, correct.length])
43
- useEffect(() => {
44
- const target = stage()
45
- if (!target) return
46
- const onResults = event => { if (event.detail?.room === room) setResultsShown(!!event.detail.shown) }
47
- target.addEventListener('pollresults', onResults)
48
- return () => target.removeEventListener('pollresults', onResults)
49
- }, [room])
50
- const toggle = () => {
51
- const next = !shown
52
- setShown(next)
53
- stage()?.dispatchEvent(new CustomEvent('pollreveal', { detail: { room, shown: next, local: true } }))
54
- }
55
- const toggleResults = () => {
56
- const next = !resultsShown
57
- setResultsShown(next)
58
- stage()?.dispatchEvent(new CustomEvent('pollresults', { detail: { room, shown: next, local: true } }))
59
- }
60
54
  const live = useRoom(room)
61
- const votes = latestByDevice(live.messages).map(message => message.data?.value).filter(vote => choices.includes(vote))
62
- const counts = choices.map(choice => votes.filter(vote => vote === choice).length)
55
+ const controls = useControls(room, live, { results: results !== 'hidden', solution: correct.length ? correct : null })
56
+ const votes = latestByDevice(controls.counted(live.messages))
57
+ .map(message => picksOf(message.data?.value, several).filter(pick => choices.includes(pick)))
58
+ .filter(picks => picks.length)
59
+ const counts = choices.map(choice => votes.filter(picks => picks.includes(choice)).length)
63
60
  const max = Math.max(1, ...counts)
64
61
  const code = wantsQr(qr)
65
62
  const still = live.offline && !votes.length
66
- return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}${resultsShown ? '' : ' is-results-hidden'}`} ref={ref}>
63
+ return <div class={`poll${controls.revealed ? ' is-solved' : ''}${still ? ' is-static' : ''}${controls.results ? '' : ' is-results-hidden'}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
67
64
  <div class="poll-main">
68
- {question && <p class="poll-question">{question}</p>}
65
+ <ActivityQuestion text={question} />
66
+ {several && <p class="poll-several">{t('poll.several')}</p>}
69
67
  <div class="poll-bars" role="list">
70
68
  {choices.map((choice, i) => <div class={`poll-row${correct.includes(choice) ? ' is-correct' : ''}`} role="listitem" key={choice}>
71
- <span class="poll-label">{choice}</span>
69
+ <span class="poll-label">{keys && <span class="poll-key">{keys[i]}</span>}<span dangerouslySetInnerHTML={{ __html: slideHtml(choice) }} /></span>
72
70
  <span class="poll-track"><span class="poll-fill" style={{ width: `${(counts[i] / max) * 100}%` }} /></span>
73
71
  <span class="poll-count">{counts[i]}</span>
74
72
  </div>)}
75
73
  </div>
76
- <ActivityFooter count={votes.length} {...live}>
77
- <button type="button" class={`poll-results${resultsShown ? '' : ' is-off'}`} title={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-label={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-pressed={!resultsShown} onClick={toggleResults}>
78
- <Icon name={resultsShown ? 'eye' : 'eye-off'} size={24} class="poll-results-icon" />
79
- </button>
80
- {correct.length > 0 && <button type="button" class={`poll-solve${shown ? ' is-on' : ''}`} title={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-label={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-pressed={shown} onClick={toggle}>
81
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>
82
- </button>}
74
+ <ActivityFooter count={votes.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
75
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} results solve={correct.length > 0} />
83
76
  </ActivityFooter>
84
77
  </div>
85
78
  <JoinCode room={room} {...live} qr={code} />
86
79
  </div>
87
80
  }
88
81
 
89
- // What the phones show while this poll is on screen.
90
- Poll.phone = ({ question, options }, { slideTitle = '' } = {}) => ({ type: 'choice', question: question || slideTitle, options: choicesOf(options) })
82
+ // What the phones show while this poll is on screen. `optionsHtml` are the
83
+ // options drawn, with maths as MathML; with `buttons`, phones show `keys`
84
+ // instead. An answer page from before them shows `question` and `options`.
85
+ Poll.phone = ({ question, options, multiple, buttons }, { slideTitle = '' } = {}) => {
86
+ const choices = choicesOf(options)
87
+ const keys = keysFor(buttons, choices.length)
88
+ return {
89
+ type: 'choice', ...phoneQuestion(question, slideTitle), options: choices,
90
+ ...(keys ? { keys } : { optionsHtml: choices.map(phoneHtml) }),
91
+ ...(isSet(multiple) ? { multiple: true } : {}),
92
+ }
93
+ }
@@ -1,6 +1,7 @@
1
1
  import { h } from 'preact'
2
2
  import { useRoom } from '../live/client.js'
3
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
3
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
4
+ import { phoneQuestion } from './inlineText.js'
4
5
  import { t } from '../core/labels.js'
5
6
  import './poll.css'
6
7
 
@@ -10,19 +11,22 @@ import './poll.css'
10
11
 
11
12
  export default function Question({ room = 'question', question = '', limit = '8', qr }) {
12
13
  const live = useRoom(room)
13
- const answers = live.messages.map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
14
+ const controls = useControls(room, live)
15
+ const answers = controls.counted(live.messages).map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
14
16
  const shown = answers.slice(-Math.max(1, Number(limit) || 8)).reverse()
15
17
  const code = wantsQr(qr)
16
- return <div class="poll poll--question">
18
+ return <div class={`poll poll--question${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
17
19
  <div class="poll-main">
18
- {question && <p class="poll-question">{question}</p>}
20
+ <ActivityQuestion text={question} />
19
21
  {shown.length
20
22
  ? <ul class="question-cards">{shown.map(answer => <li key={answer.n}>{answer.text}</li>)}</ul>
21
23
  : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
22
- <ActivityFooter count={answers.length} {...live} />
24
+ <ActivityFooter count={answers.length} {...live} reset={controls.reset} closed={controls.closed}>
25
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
26
+ </ActivityFooter>
23
27
  </div>
24
28
  <JoinCode room={room} {...live} qr={code} size={240} />
25
29
  </div>
26
30
  }
27
31
 
28
- Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 200 })
32
+ Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 200 })
@@ -1,12 +1,14 @@
1
1
  import { h } from 'preact'
2
2
  import { useRoom, latestByDevice } from '../live/client.js'
3
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
3
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
4
+ import { slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
4
5
  import { t } from '../core/labels.js'
5
6
  import './poll.css'
6
7
 
7
8
  // <scale room="pace" question="How is the pace?" min="1" max="5" low="Too slow" high="Too fast" />
8
9
  // Phones pick a number; each device's latest answer counts. The slide shows
9
- // how many chose each number, and the average.
10
+ // how many chose each number, and the average. The question and the labels
11
+ // are Markdown with $…$ maths, like a poll's.
10
12
 
11
13
  const bounds = ({ min, max }) => {
12
14
  const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
@@ -18,14 +20,15 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
18
20
  const [min, max] = bounds(rest)
19
21
  const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
20
22
  const live = useRoom(room)
21
- const values = latestByDevice(live.messages).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
23
+ const controls = useControls(room, live)
24
+ const values = latestByDevice(controls.counted(live.messages)).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
22
25
  const counts = steps.map(step => values.filter(value => value === step).length)
23
26
  const most = Math.max(1, ...counts)
24
27
  const average = values.length ? (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1) : null
25
28
  const code = wantsQr(qr)
26
- return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}`}>
29
+ return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
27
30
  <div class="poll-main">
28
- {question && <p class="poll-question">{question}</p>}
31
+ <ActivityQuestion text={question} />
29
32
  <div class="scale-columns" role="list">
30
33
  {steps.map((step, i) => <div class="scale-column" role="listitem" key={step}>
31
34
  <span class="scale-count">{counts[i]}</span>
@@ -33,8 +36,10 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
33
36
  <span class="scale-step">{step}</span>
34
37
  </div>)}
35
38
  </div>
36
- {(low || high) && <p class="scale-labels"><span>{low}</span><span>{high}</span></p>}
37
- <ActivityFooter count={values.length} {...live} />
39
+ {(low || high) && <p class="scale-labels"><span dangerouslySetInnerHTML={{ __html: slideHtml(low) }} /><span dangerouslySetInnerHTML={{ __html: slideHtml(high) }} /></p>}
40
+ <ActivityFooter count={values.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
41
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
42
+ </ActivityFooter>
38
43
  {average && <p class="scale-average">{t('scale.average', { n: average })}</p>}
39
44
  </div>
40
45
  <JoinCode room={room} {...live} qr={code} size={240} />
@@ -43,5 +48,5 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
43
48
 
44
49
  Scale.phone = ({ question, low, high, ...rest }, { slideTitle = '' } = {}) => {
45
50
  const [min, max] = bounds(rest)
46
- return { type: 'scale', question: question || slideTitle, min, max, minLabel: low ?? '', maxLabel: high ?? '' }
51
+ return { type: 'scale', ...phoneQuestion(question, slideTitle), min, max, minLabel: low ?? '', maxLabel: high ?? '', ...(low ? { minLabelHtml: phoneHtml(low) } : {}), ...(high ? { maxLabelHtml: phoneHtml(high) } : {}) }
47
52
  }
@@ -2,7 +2,8 @@ import { h } from 'preact'
2
2
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  import cloud from 'd3-cloud'
4
4
  import { useRoom } from '../live/client.js'
5
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
5
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
6
+ import { phoneQuestion } from './inlineText.js'
6
7
  import { t } from '../core/labels.js'
7
8
  import './poll.css'
8
9
 
@@ -64,7 +65,8 @@ function layout(words, { width, height, font }) {
64
65
 
65
66
  export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
66
67
  const live = useRoom(room)
67
- const all = countWords(live.messages)
68
+ const controls = useControls(room, live)
69
+ const all = countWords(controls.counted(live.messages))
68
70
  const words = all.slice(0, Math.max(1, Number(limit) || 60))
69
71
  const total = all.reduce((sum, word) => sum + word.n, 0)
70
72
  const cloudHeight = Math.max(200, Number(height) || 520)
@@ -85,9 +87,9 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
85
87
  const key = words.map(word => `${word.text}:${word.n}`).join('|')
86
88
  const placed = useMemo(() => layout(words, { width: measure.width, height: cloudHeight, font: measure.font }), [key, measure.width, measure.font, measure.fontsReady, cloudHeight])
87
89
  const code = wantsQr(qr)
88
- return <div class="poll poll--cloud">
90
+ return <div class={`poll poll--cloud${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
89
91
  <div class="poll-main">
90
- {question && <p class="poll-question">{question}</p>}
92
+ <ActivityQuestion text={question} />
91
93
  {/* Without a server and words there is nothing to place: no empty box. */}
92
94
  <div class="word-cloud" ref={ref} style={{ height: live.offline && !words.length ? 0 : `${cloudHeight}px` }}>
93
95
  {words.length
@@ -97,10 +99,12 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
97
99
  </svg>
98
100
  : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
99
101
  </div>
100
- <ActivityFooter count={total} {...live} />
102
+ <ActivityFooter count={total} {...live} reset={controls.reset} closed={controls.closed}>
103
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
104
+ </ActivityFooter>
101
105
  </div>
102
106
  <JoinCode room={room} {...live} qr={code} size={240} />
103
107
  </div>
104
108
  }
105
109
 
106
- WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 40 })
110
+ WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40 })