mdeck 3.3.0 → 3.5.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 (83) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/assets/base.css +23 -2
  4. package/assets/extensions/palettes/cobalt/extension.toml +30 -0
  5. package/assets/extensions/palettes/greyscale/extension.toml +57 -0
  6. package/assets/extensions/themes/academic/styles.css +18 -5
  7. package/assets/extensions/themes/glass/extension.toml +59 -0
  8. package/assets/extensions/themes/glass/styles.css +754 -0
  9. package/assets/extensions/themes/minimal/styles.css +16 -4
  10. package/assets/extensions/themes/neue/styles.css +17 -4
  11. package/assets/extensions/themes/plain/extension.toml +58 -0
  12. package/assets/extensions/themes/plain/styles.css +471 -0
  13. package/assets/extensions/themes/work/extension.toml +57 -0
  14. package/assets/extensions/themes/work/styles.css +512 -0
  15. package/docs/reference/authoring.md +50 -10
  16. package/docs/reference/palettes.md +4 -3
  17. package/docs/reference/themes.md +5 -3
  18. package/docs/site/content/appearance.md +24 -10
  19. package/docs/site/content/audience.md +49 -4
  20. package/docs/site/content/commands.md +2 -0
  21. package/docs/site/content/theme-repository.md +7 -7
  22. package/docs/site/images/themes/academic-dark.webp +0 -0
  23. package/docs/site/images/themes/academic-light.webp +0 -0
  24. package/docs/site/images/themes/academic.webp +0 -0
  25. package/docs/site/images/themes/glass-dark.webp +0 -0
  26. package/docs/site/images/themes/glass-light.webp +0 -0
  27. package/docs/site/images/themes/glass.webp +0 -0
  28. package/docs/site/images/themes/minimal-dark.webp +0 -0
  29. package/docs/site/images/themes/minimal-light.webp +0 -0
  30. package/docs/site/images/themes/minimal.webp +0 -0
  31. package/docs/site/images/themes/neue-dark.webp +0 -0
  32. package/docs/site/images/themes/neue-light.webp +0 -0
  33. package/docs/site/images/themes/neue.webp +0 -0
  34. package/docs/site/images/themes/plain-dark.webp +0 -0
  35. package/docs/site/images/themes/plain-light.webp +0 -0
  36. package/docs/site/images/themes/plain.webp +0 -0
  37. package/docs/site/images/themes/work-dark.webp +0 -0
  38. package/docs/site/images/themes/work-light.webp +0 -0
  39. package/docs/site/images/themes/work.webp +0 -0
  40. package/examples/showcase/slides.md +2 -2
  41. package/package.json +4 -1
  42. package/skills/write-slides/SKILL.md +2 -2
  43. package/src/build/bibliography.js +29 -11
  44. package/src/build/resultsPlugin.js +77 -0
  45. package/src/build/slidesPlugin.js +42 -18
  46. package/src/build/themeCheck.js +2 -1
  47. package/src/cli/args.js +2 -1
  48. package/src/cli/index.js +17 -4
  49. package/src/components/Numeric.jsx +59 -0
  50. package/src/components/Poll.jsx +72 -57
  51. package/src/components/Question.jsx +10 -6
  52. package/src/components/Scale.jsx +13 -8
  53. package/src/components/WordCloud.jsx +10 -6
  54. package/src/components/activity.jsx +88 -7
  55. package/src/components/inlineText.js +63 -0
  56. package/src/components/numbers.js +39 -0
  57. package/src/components/poll.css +26 -13
  58. package/src/core/editDeck.js +5 -6
  59. package/src/core/icons.js +2 -0
  60. package/src/core/labels.js +32 -0
  61. package/src/core/pollList.js +66 -0
  62. package/src/core/results.js +64 -0
  63. package/src/core/tags.js +195 -0
  64. package/src/editor/sampleDeck.js +34 -8
  65. package/src/extensions/discover.js +2 -2
  66. package/src/extensions/moved.js +4 -4
  67. package/src/layouts/layoutApi.jsx +24 -13
  68. package/src/live/answer/answer.css +19 -0
  69. package/src/live/answer/answer.js +147 -24
  70. package/src/live/client.js +51 -6
  71. package/src/live/roomTag.js +11 -15
  72. package/src/live/rooms.js +6 -1
  73. package/src/runtime/citations.js +15 -5
  74. package/src/runtime/ink/bus.js +7 -13
  75. package/src/runtime/main.jsx +5 -2
  76. package/src/runtime/markedSetup.js +3 -0
  77. package/src/runtime/registry.jsx +2 -0
  78. package/assets/extensions/palettes/neon/extension.toml +0 -30
  79. package/assets/extensions/themes/aurora/extension.toml +0 -40
  80. package/assets/extensions/themes/aurora/styles.css +0 -644
  81. package/docs/site/images/themes/aurora-dark.webp +0 -0
  82. package/docs/site/images/themes/aurora-light.webp +0 -0
  83. package/docs/site/images/themes/aurora.webp +0 -0
@@ -8,11 +8,13 @@ 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 { scanTags, replaceTagsIn, withAttribute } from '../core/tags.js'
17
+ import { resolveBibliography, sampleBibliography } from './bibliography.js'
16
18
 
17
19
  const VIRTUAL_ID = 'virtual:slides'
18
20
  const RESOLVED_ID = '\0virtual:slides'
@@ -27,6 +29,8 @@ const SVGS_ID = 'virtual:deck-svgs'
27
29
  const RESOLVED_SVGS_ID = '\0virtual:deck-svgs'
28
30
  const BIB_ID = 'virtual:bibliography'
29
31
  const RESOLVED_BIB_ID = '\0virtual:bibliography'
32
+ const RESULTS_ID = 'virtual:deck-results'
33
+ const RESOLVED_RESULTS_ID = '\0virtual:deck-results'
30
34
 
31
35
  const MIME_BY_EXT = {
32
36
  '.png': 'image/png',
@@ -94,21 +98,19 @@ function inlineMarkdownImages(markdown, baseDir) {
94
98
  })
95
99
  }
96
100
 
97
- function inlineHtmlImgSources(markdown, baseDir) {
98
- return markdown.replace(/(<img\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
99
- const dataUrl = toDataUrl(src, baseDir)
100
- if (!dataUrl) return m
101
- return `${pre}${quote}${dataUrl}${endQuote}`
101
+ // The `src` of the named tags, as data: URLs where the file is local.
102
+ function inlineTagSources(markdown, baseDir, names) {
103
+ return replaceTagsIn(markdown, tag => {
104
+ if (tag.closing || !names.has(tag.name) || !tag.attrs.src) return
105
+ const dataUrl = toDataUrl(tag.attrs.src, baseDir)
106
+ return dataUrl ? withAttribute(tag, 'src', dataUrl) : undefined
102
107
  })
103
108
  }
104
109
 
105
- function inlineHtmlMediaSources(markdown, baseDir) {
106
- return markdown.replace(/(<(?:video|audio|source|videoplayer)\b[^>]*\bsrc=)(["'])([^"']+)(\2)/gi, (m, pre, quote, src, endQuote) => {
107
- const dataUrl = toDataUrl(src, baseDir)
108
- if (!dataUrl) return m
109
- return `${pre}${quote}${dataUrl}${endQuote}`
110
- })
111
- }
110
+ const PICTURE_TAGS = new Set(['img'])
111
+ const MEDIA_TAGS = new Set(['video', 'audio', 'source', 'videoplayer'])
112
+ const inlineHtmlImgSources = (markdown, baseDir) => inlineTagSources(markdown, baseDir, PICTURE_TAGS)
113
+ const inlineHtmlMediaSources = (markdown, baseDir) => inlineTagSources(markdown, baseDir, MEDIA_TAGS)
112
114
 
113
115
  function inlineYamlImageFields(markdown, baseDir) {
114
116
  return markdown.replace(/^(\s*)(image|logo):\s*(["']?)([^"'\n]+)\3\s*$/gm, (m, indent, key, quote, value) => {
@@ -138,7 +140,11 @@ export function collectLocalAssetRefs(markdown) {
138
140
  marked.walkTokens(marked.lexer(body), token => {
139
141
  if (token.type === 'image') add(token.href)
140
142
  if (token.type === 'html') {
141
- for (const match of token.text.matchAll(/<(?:img|video|audio|source|videoplayer)\b[^>]*\bsrc=(["'])([^"']+)\1/gi)) add(match[2])
143
+ for (const tag of scanTags(token.text)) {
144
+ if (PICTURE_TAGS.has(tag.name) || MEDIA_TAGS.has(tag.name)) add(tag.attrs.src)
145
+ // Markdown pictures in a tag's attributes, such as a poll's options.
146
+ for (const value of Object.values(tag.attrs)) for (const match of value.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) add(match[1])
147
+ }
142
148
  }
143
149
  })
144
150
  }
@@ -216,7 +222,7 @@ export function deckLookOnly(registry, deckConfig = {}) {
216
222
  // extension changes are announced instead of forcing a reload. `source` is
217
223
  // the deck's text where there is no file (`mdeck design`, on the sample deck);
218
224
  // `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 } = {}) {
225
+ 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
226
  const abs = resolve(slidesPath)
221
227
  const readDeck = () => given ?? readFileSync(abs, 'utf-8')
222
228
  const watchDeck = context => { if (given === null) context.addWatchFile(abs) }
@@ -225,6 +231,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
225
231
  return stripActivities ? stripActivitiesFrom(shared).source : shared
226
232
  }
227
233
  const inkPath = inkFileFor(abs)
234
+ const resultsPath = resultsFileFor(abs)
228
235
  const extensionDirs = extensionRoots(abs).map(root => root.dir)
229
236
  let componentDirs = componentFolders(abs, readDeck()).map(folder => folder.dir)
230
237
  const watchDirs = () => [...extensionDirs, ...componentDirs]
@@ -243,6 +250,9 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
243
250
  const SOURCE_IDS = [RESOLVED_ID, RESOLVED_INK_ID, RESOLVED_SVGS_ID, RESOLVED_BIB_ID]
244
251
  // The reference files (and .csl style) the deck names, from the last load.
245
252
  let bibFiles = []
253
+ // The dev and edit servers also show the sample deck (?sample=1, the
254
+ // design page), with its references from memory; builds never do.
255
+ let serving = false
246
256
  // The editor keeps its page: the formatted references arrive as a hot update.
247
257
  const bibUpdate = server => {
248
258
  const mod = server.moduleGraph.getModuleById(RESOLVED_BIB_ID)
@@ -262,6 +272,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
262
272
 
263
273
  return {
264
274
  name: 'vite-plugin-slides',
275
+ configResolved(config) { serving = config.command === 'serve' },
265
276
  configureServer(server) {
266
277
  server.watcher.add([abs, inkPath, ...watchDirs()])
267
278
  const refresh = file => {
@@ -280,6 +291,7 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
280
291
  if (id === INK_ID) return RESOLVED_INK_ID
281
292
  if (id === SVGS_ID) return RESOLVED_SVGS_ID
282
293
  if (id === BIB_ID) return RESOLVED_BIB_ID
294
+ if (id === RESULTS_ID) return RESOLVED_RESULTS_ID
283
295
  },
284
296
  load(id) {
285
297
  if (id === RESOLVED_ID) {
@@ -319,6 +331,16 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
319
331
  }
320
332
  return `export default ${JSON.stringify(data)}\nexport const inkFileName = ${JSON.stringify(basename(inkPath))}`
321
333
  }
334
+ // The answers kept in the talk (<deck>.results.json): builds show them
335
+ // where no room server is (src/live/client.js). `mdeck run` shows the
336
+ // live answers, and writes the file, so it does not read it here.
337
+ if (id === RESOLVED_RESULTS_ID) {
338
+ let data = normalizeResults({})
339
+ if (results && !serving && existsSync(resultsPath)) {
340
+ try { data = normalizeResults(JSON.parse(readFileSync(resultsPath, 'utf-8'))) } catch (error) { this.warn(`${resultsPath}: ${error.message}; showing no results`) }
341
+ }
342
+ return `export default ${JSON.stringify(data)}`
343
+ }
322
344
  if (id === RESOLVED_SVGS_ID) {
323
345
  watchDeck(this)
324
346
  const svgs = themedSvgs(abs, sourceFor(readDeck()))
@@ -329,12 +351,12 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
329
351
  // that none of it travels in the deck (bibliography.js).
330
352
  if (id === RESOLVED_BIB_ID) {
331
353
  watchDeck(this)
332
- return resolveBibliography(abs, sourceFor(readDeck())).then(bibliography => {
354
+ const data = bibliography => bibliography && { ids: bibliography.ids, clusters: bibliography.clusters, singles: bibliography.singles, entries: bibliography.entries, order: bibliography.order }
355
+ return Promise.all([resolveBibliography(abs, sourceFor(readDeck()), { sample: serving }), serving ? sampleBibliography() : null]).then(([bibliography, sample]) => {
333
356
  bibFiles = bibliography ? [...bibliography.files, bibliography.style].filter(Boolean) : []
334
357
  for (const file of bibFiles) if (existsSync(file)) this.addWatchFile(file)
335
358
  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)}`
359
+ return `export default ${JSON.stringify(data(bibliography))}\nexport const sample = ${JSON.stringify(data(sample))}`
338
360
  })
339
361
  }
340
362
  if (id === RESOLVED_EXTENSIONS_ID) {
@@ -381,6 +403,8 @@ export function slidesPlugin(slidesPath, { inlineImages = false, inlineMedia = f
381
403
  }
382
404
  // mdeck's own writes (saved ink, a new slide id) must not reload the
383
405
  // windows mid-talk: the pages refresh their ink on `mdeck:ink` instead.
406
+ // The results file is written during the talk and read only by builds.
407
+ if (changed === resultsPath) return []
384
408
  const own = [inkPath, abs].includes(changed) && (() => { try { return isOwnWrite(changed, readFileSync(changed, 'utf-8')) } catch { return false } })()
385
409
  if (own) {
386
410
  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,9 @@
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'
6
+ import { pollChoices } from '../core/pollList.js'
7
7
  import './poll.css'
8
8
 
9
9
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
@@ -13,78 +13,93 @@ import './poll.css'
13
13
  // as described by Poll.phone. Each device has one vote and can change it; the
14
14
  // latest one counts.
15
15
  //
16
+ // `multiple` lets each phone pick several options; the slide and the phones
17
+ // say so. A vote is then the list of options picked.
18
+ //
19
+ // `buttons="letters"` (or "numbers") puts A, B, C… (1, 2, 3…) before the
20
+ // options on the slide, and the phones show only those: for options that are
21
+ // pictures, long formulas, or anything else better read on the big screen.
22
+ //
16
23
  // `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.
24
+ // with a tick outlines its label, bar and count in green, and phones say
25
+ // whether theirs was right. A button with a lock closes the poll: later votes
26
+ // do not count, and phones cannot vote until it opens again. A button with
27
+ // an eye shows or hides the results (bars and counts); `results="hidden"`
28
+ // starts with them hidden, e.g. for peer instruction. Hidden, the audience
29
+ // window shows no bars; the presenter's own screens show them faint, so the
30
+ // presenter can still judge the votes. All of them reach every screen of
31
+ // the talk (useControls in activity.jsx).
32
+ //
33
+ // The question and the options are Markdown with $…$ maths, as on a slide:
34
+ // options="$x^2$|$2x$|$\frac{x^3}{3}$". A bar inside a formula stays in it
35
+ // ($|x|$); \| is a bar in the text. Votes and `answer` use the source text.
20
36
  //
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.
37
+ // Options may instead be a list inside the tag, one item each, `[x]` for a
38
+ // right one (src/core/pollList.js). They arrive here as `data-choices`.
26
39
 
27
- const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
40
+ // The options and the right ones, from the list or from `options` and `answer`.
41
+ function choicesFrom({ options, answer, 'data-choices': listed }) {
42
+ const fromList = listed != null && pollChoices(listed)
43
+ const choices = fromList ? fromList.map(choice => choice.text) : choicesOf(options)
44
+ const marked = fromList ? fromList.filter(choice => choice.right).map(choice => choice.text) : []
45
+ const correct = marked.length ? marked : choicesOf(answer).filter(choice => choices.includes(choice))
46
+ return { choices, correct }
47
+ }
28
48
 
29
- export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '' }) {
30
- const choices = choicesOf(options)
31
- 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
- }
49
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
50
+ /** What `buttons` puts before each option: letters, numbers, or nothing. */
51
+ export const keysFor = (buttons, count) => {
52
+ const kind = String(buttons ?? '').toLowerCase()
53
+ if (kind === 'letters') return Array.from({ length: count }, (_, i) => LETTERS[i] ?? String(i + 1))
54
+ if (kind === 'numbers') return Array.from({ length: count }, (_, i) => String(i + 1))
55
+ return null
56
+ }
57
+
58
+ // A vote: one option, or with `multiple` the options picked. A phone page
59
+ // from before `multiple` sends one option; it counts as a list of one.
60
+ const picksOf = (value, multiple) => multiple ? (Array.isArray(value) ? value : [value]) : (Array.isArray(value) ? [] : [value])
61
+
62
+ export default function Poll({ room = 'poll', question = '', qr, results = '', multiple, buttons, ...attributes }) {
63
+ const { choices, correct } = choicesFrom(attributes)
64
+ const several = isSet(multiple)
65
+ const keys = keysFor(buttons, choices.length)
60
66
  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)
67
+ const controls = useControls(room, live, { results: results !== 'hidden', solution: correct.length ? correct : null })
68
+ const votes = latestByDevice(controls.counted(live.messages))
69
+ .map(message => picksOf(message.data?.value, several).filter(pick => choices.includes(pick)))
70
+ .filter(picks => picks.length)
71
+ const counts = choices.map(choice => votes.filter(picks => picks.includes(choice)).length)
63
72
  const max = Math.max(1, ...counts)
64
73
  const code = wantsQr(qr)
65
74
  const still = live.offline && !votes.length
66
- return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}${resultsShown ? '' : ' is-results-hidden'}`} ref={ref}>
75
+ return <div class={`poll${controls.revealed ? ' is-solved' : ''}${still ? ' is-static' : ''}${controls.results ? '' : ' is-results-hidden'}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
67
76
  <div class="poll-main">
68
- {question && <p class="poll-question">{question}</p>}
77
+ <ActivityQuestion text={question} />
78
+ {several && <p class="poll-several">{t('poll.several')}</p>}
69
79
  <div class="poll-bars" role="list">
70
80
  {choices.map((choice, i) => <div class={`poll-row${correct.includes(choice) ? ' is-correct' : ''}`} role="listitem" key={choice}>
71
- <span class="poll-label">{choice}</span>
81
+ <span class="poll-label">{keys && <span class="poll-key">{keys[i]}</span>}<span dangerouslySetInnerHTML={{ __html: slideHtml(choice) }} /></span>
72
82
  <span class="poll-track"><span class="poll-fill" style={{ width: `${(counts[i] / max) * 100}%` }} /></span>
73
83
  <span class="poll-count">{counts[i]}</span>
74
84
  </div>)}
75
85
  </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>}
86
+ <ActivityFooter count={votes.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
87
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} results solve={correct.length > 0} />
83
88
  </ActivityFooter>
84
89
  </div>
85
90
  <JoinCode room={room} {...live} qr={code} />
86
91
  </div>
87
92
  }
88
93
 
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) })
94
+ // What the phones show while this poll is on screen. `optionsHtml` are the
95
+ // options drawn, with maths as MathML; with `buttons`, phones show `keys`
96
+ // instead. An answer page from before them shows `question` and `options`.
97
+ Poll.phone = ({ question, multiple, buttons, ...attributes }, { slideTitle = '' } = {}) => {
98
+ const { choices } = choicesFrom(attributes)
99
+ const keys = keysFor(buttons, choices.length)
100
+ return {
101
+ type: 'choice', ...phoneQuestion(question, slideTitle), options: choices,
102
+ ...(keys ? { keys } : { optionsHtml: choices.map(phoneHtml) }),
103
+ ...(isSet(multiple) ? { multiple: true } : {}),
104
+ }
105
+ }
@@ -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
  }