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.
- package/CHANGELOG.md +17 -0
- package/assets/base.css +23 -2
- package/assets/extensions/themes/academic/styles.css +18 -5
- package/assets/extensions/themes/aurora/styles.css +32 -6
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/docs/reference/authoring.md +27 -4
- package/docs/site/content/audience.md +35 -4
- package/docs/site/content/commands.md +2 -0
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +1 -1
- package/src/build/bibliography.js +29 -11
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +28 -5
- 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 +58 -55
- 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 +40 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +23 -13
- package/src/core/editDeck.js +2 -2
- package/src/core/icons.js +2 -0
- package/src/core/labels.js +32 -0
- package/src/core/results.js +64 -0
- package/src/editor/sampleDeck.js +34 -8
- package/src/layouts/layoutApi.jsx +4 -3
- 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 +6 -2
- 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 +3 -1
- 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
|
-
|
|
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
|
-
|
|
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])
|
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,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 {
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
-
const
|
|
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${
|
|
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
|
-
|
|
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">{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
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=
|
|
90
|
+
return <div class={`poll poll--cloud${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
|
|
89
91
|
<div class="poll-main">
|
|
90
|
-
|
|
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
|
|
110
|
+
WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40 })
|