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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/assets/base.css +23 -2
- package/assets/extensions/palettes/cobalt/extension.toml +30 -0
- package/assets/extensions/palettes/greyscale/extension.toml +57 -0
- package/assets/extensions/themes/academic/styles.css +18 -5
- package/assets/extensions/themes/glass/extension.toml +59 -0
- package/assets/extensions/themes/glass/styles.css +754 -0
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/assets/extensions/themes/plain/extension.toml +58 -0
- package/assets/extensions/themes/plain/styles.css +471 -0
- package/assets/extensions/themes/work/extension.toml +57 -0
- package/assets/extensions/themes/work/styles.css +512 -0
- package/docs/reference/authoring.md +50 -10
- package/docs/reference/palettes.md +4 -3
- package/docs/reference/themes.md +5 -3
- package/docs/site/content/appearance.md +24 -10
- package/docs/site/content/audience.md +49 -4
- package/docs/site/content/commands.md +2 -0
- package/docs/site/content/theme-repository.md +7 -7
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/glass-dark.webp +0 -0
- package/docs/site/images/themes/glass-light.webp +0 -0
- package/docs/site/images/themes/glass.webp +0 -0
- package/docs/site/images/themes/minimal-dark.webp +0 -0
- package/docs/site/images/themes/minimal-light.webp +0 -0
- package/docs/site/images/themes/minimal.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/plain-dark.webp +0 -0
- package/docs/site/images/themes/plain-light.webp +0 -0
- package/docs/site/images/themes/plain.webp +0 -0
- package/docs/site/images/themes/work-dark.webp +0 -0
- package/docs/site/images/themes/work-light.webp +0 -0
- package/docs/site/images/themes/work.webp +0 -0
- package/examples/showcase/slides.md +2 -2
- package/package.json +4 -1
- package/skills/write-slides/SKILL.md +2 -2
- package/src/build/bibliography.js +29 -11
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +42 -18
- package/src/build/themeCheck.js +2 -1
- package/src/cli/args.js +2 -1
- package/src/cli/index.js +17 -4
- package/src/components/Numeric.jsx +59 -0
- package/src/components/Poll.jsx +72 -57
- package/src/components/Question.jsx +10 -6
- package/src/components/Scale.jsx +13 -8
- package/src/components/WordCloud.jsx +10 -6
- package/src/components/activity.jsx +88 -7
- package/src/components/inlineText.js +63 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +26 -13
- package/src/core/editDeck.js +5 -6
- package/src/core/icons.js +2 -0
- package/src/core/labels.js +32 -0
- package/src/core/pollList.js +66 -0
- package/src/core/results.js +64 -0
- package/src/core/tags.js +195 -0
- package/src/editor/sampleDeck.js +34 -8
- package/src/extensions/discover.js +2 -2
- package/src/extensions/moved.js +4 -4
- package/src/layouts/layoutApi.jsx +24 -13
- package/src/live/answer/answer.css +19 -0
- package/src/live/answer/answer.js +147 -24
- package/src/live/client.js +51 -6
- package/src/live/roomTag.js +11 -15
- package/src/live/rooms.js +6 -1
- package/src/runtime/citations.js +15 -5
- package/src/runtime/ink/bus.js +7 -13
- package/src/runtime/main.jsx +5 -2
- package/src/runtime/markedSetup.js +3 -0
- package/src/runtime/registry.jsx +2 -0
- package/assets/extensions/palettes/neon/extension.toml +0 -30
- package/assets/extensions/themes/aurora/extension.toml +0 -40
- package/assets/extensions/themes/aurora/styles.css +0 -644
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- 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 {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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])
|
package/src/build/themeCheck.js
CHANGED
|
@@ -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 ?? '' })
|
package/src/components/Poll.jsx
CHANGED
|
@@ -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 {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
62
|
-
const
|
|
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${
|
|
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
|
-
|
|
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">{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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=
|
|
18
|
+
return <div class={`poll poll--question${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
|
|
17
19
|
<div class="poll-main">
|
|
18
|
-
|
|
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
|
|
32
|
+
Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 200 })
|
package/src/components/Scale.jsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
51
|
+
return { type: 'scale', ...phoneQuestion(question, slideTitle), min, max, minLabel: low ?? '', maxLabel: high ?? '', ...(low ? { minLabelHtml: phoneHtml(low) } : {}), ...(high ? { maxLabelHtml: phoneHtml(high) } : {}) }
|
|
47
52
|
}
|