mdeck 3.11.0 → 3.12.1
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 +28 -0
- package/README.md +3 -3
- package/assets/base.css +2 -0
- package/docs/reference/authoring.md +48 -7
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +4 -2
- package/docs/site/content/drawing.md +4 -4
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +3 -1
- package/docs/site/content/presenting.md +29 -8
- package/docs/site/content/writing.md +27 -0
- package/examples/showcase/slides.ar.md +5 -5
- package/examples/showcase/slides.de.md +5 -5
- package/examples/showcase/slides.de.results.json +27 -0
- package/examples/showcase/slides.en.md +5 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +4 -1
- package/src/build/check.js +27 -6
- package/src/build/components.js +5 -3
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +64 -16
- package/src/build/timingPlugin.js +34 -18
- package/src/cli/args.js +3 -3
- package/src/cli/index.js +49 -10
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/icons.js +6 -1
- package/src/core/labels.js +3 -0
- package/src/core/parseSlides.js +14 -4
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/validateDeck.js +6 -2
- package/src/home/Home.jsx +38 -93
- package/src/home/home.css +5 -17
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +12 -1
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +29 -7
- package/src/live/server.js +15 -1
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/deck-stage.js +322 -17
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/room.js +6 -6
- package/src/runtime/main.jsx +396 -303
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/useTalkTimer.js +4 -2
package/src/cli/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { BACKUP_DIR } from '../build/editorPlugin.js'
|
|
|
13
13
|
import { createEditorServer, createDesignServer } from '../build/editorServer.js'
|
|
14
14
|
import { homePlugin } from '../build/homePlugin.js'
|
|
15
15
|
import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
|
|
16
|
+
import { expandedSource } from '../build/include.js'
|
|
16
17
|
import { livePlugin, startLiveServer } from '../live/server.js'
|
|
17
18
|
import { inkPlugin } from '../build/inkPlugin.js'
|
|
18
19
|
import { resultsPlugin } from '../build/resultsPlugin.js'
|
|
@@ -30,7 +31,8 @@ import { parseSlides } from '../core/parseSlides.js'
|
|
|
30
31
|
import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
|
|
31
32
|
import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
|
|
32
33
|
import { assertDrawings } from '../build/drawings.js'
|
|
33
|
-
import { resultsFileFor, normalizeResults
|
|
34
|
+
import { resultsFileFor, normalizeResults } from '../core/results.js'
|
|
35
|
+
import { resultsReport, reportAnswersCsv, reportSummaryCsv, reportJson } from '../core/resultsReport.js'
|
|
34
36
|
import { createRelayAccess } from '../build/relayAccess.js'
|
|
35
37
|
import { sessionCode } from '../live/code.js'
|
|
36
38
|
import { migrateDeck } from './migrate.js'
|
|
@@ -59,6 +61,14 @@ function hasFlag(name, short = null) { return !!(parsed.options[name] ?? parsed.
|
|
|
59
61
|
const positionals = () => parsed.positionals
|
|
60
62
|
const outputOption = () => parsed.options['--output'] ?? null
|
|
61
63
|
const serverOption = () => parsed.options['--server']
|
|
64
|
+
// Whether a poll server answers at `address`, within a few seconds.
|
|
65
|
+
async function serverAnswers(address) {
|
|
66
|
+
try {
|
|
67
|
+
validateServerOrigin(address)
|
|
68
|
+
const response = await fetch(new URL('info', address.replace(/\/*$/, '/')), { signal: AbortSignal.timeout(3000) })
|
|
69
|
+
return response.ok
|
|
70
|
+
} catch { return false }
|
|
71
|
+
}
|
|
62
72
|
const portOption = () => parsed.options['--port'] ?? null
|
|
63
73
|
// --slide 3,5-7 for build, send and pdf: only these slides, numbered as written.
|
|
64
74
|
function slideOption(input) {
|
|
@@ -196,7 +206,8 @@ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripAc
|
|
|
196
206
|
const absSlides = resolve(slidesPath)
|
|
197
207
|
const deckDir = dirname(absSlides)
|
|
198
208
|
const absOutDir = resolve(outDir)
|
|
199
|
-
|
|
209
|
+
// The deck as shown: pictures of included slides are copied too.
|
|
210
|
+
const raw = expandedSource(absSlides)
|
|
200
211
|
let markdown = stripNotes ? stripNotesFrom(raw) : raw
|
|
201
212
|
if (stripActivities || slides) markdown = shareSlides(markdown, { activities: stripActivities, slides }).source
|
|
202
213
|
|
|
@@ -323,7 +334,10 @@ const HELP = `
|
|
|
323
334
|
--network reach the slides from phones and tablets on the same network
|
|
324
335
|
--server [address] present from an iPad on any network, through your server
|
|
325
336
|
(the address, or the deck's server setting, or MDECK_SERVER;
|
|
326
|
-
the key in MDECK_SERVER_KEY)
|
|
337
|
+
the key in MDECK_SERVER_KEY). With both MDECK_SERVER and
|
|
338
|
+
MDECK_SERVER_KEY set, the server is used without --server
|
|
339
|
+
when it answers
|
|
340
|
+
--no-server present from this computer only, even with MDECK_SERVER set
|
|
327
341
|
--no-open --port <number>
|
|
328
342
|
${c.green}mdeck rehearse${c.reset} [slides.md] Like run, but opens the presenter view rehearsing: it times
|
|
329
343
|
each slide and saves the times into the deck (time: 1m30s)
|
|
@@ -361,7 +375,9 @@ const HELP = `
|
|
|
361
375
|
--slide <numbers> only these slides: 3, 3,5 or 2-4
|
|
362
376
|
--sheet one picture of them all in a grid, each under its number and heading
|
|
363
377
|
--dark | --light in this appearance instead of the deck's own -o <folder>
|
|
364
|
-
${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV
|
|
378
|
+
${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV: slide, question, phone, time, answer
|
|
379
|
+
--summary one row per option, number or word, with how many chose it
|
|
380
|
+
--json everything as JSON: each question, its summary and its answers (also -o answers.json)
|
|
365
381
|
${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
|
|
366
382
|
List built-in and deck-local layouts, themes and palettes
|
|
367
383
|
${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
|
|
@@ -375,6 +391,7 @@ const HELP = `
|
|
|
375
391
|
through it (MDECK_SERVER_KEY); put a web server in front
|
|
376
392
|
|
|
377
393
|
Commands that take ${c.bold}slides.md${c.reset} use the one in the current folder when you leave it out.
|
|
394
|
+
${c.green}mdeck --version${c.reset} shows which mdeck this is.
|
|
378
395
|
|
|
379
396
|
${c.dim}Install the${c.reset} ${c.bold}mdeck${c.reset} ${c.dim}command globally:${c.reset}
|
|
380
397
|
npm link
|
|
@@ -386,6 +403,11 @@ const HELP = `
|
|
|
386
403
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
387
404
|
const [command, ...argv] = process.argv.slice(2)
|
|
388
405
|
|
|
406
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
407
|
+
console.log(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version)
|
|
408
|
+
process.exit(0)
|
|
409
|
+
}
|
|
410
|
+
|
|
389
411
|
if (!command || command === '--help' || command === '-h') {
|
|
390
412
|
console.log(HELP)
|
|
391
413
|
process.exit(0)
|
|
@@ -491,11 +513,18 @@ if (command === 'new') {
|
|
|
491
513
|
const input = requireInput('results')
|
|
492
514
|
const file = resultsFileFor(resolve(input))
|
|
493
515
|
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) }
|
|
494
|
-
let
|
|
495
|
-
try {
|
|
516
|
+
let results
|
|
517
|
+
try { results = normalizeResults(JSON.parse(readFileSync(file, 'utf8'))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
|
|
518
|
+
// Read against the deck as shown, included slides too, for the slide and the question.
|
|
519
|
+
let deck
|
|
520
|
+
try { deck = parseSlides(expandedSource(resolve(input))) } catch { deck = parseSlides(readFileSync(resolve(input), 'utf8')) }
|
|
521
|
+
const report = resultsReport(results, deck)
|
|
496
522
|
const out = outputOption()
|
|
497
|
-
|
|
498
|
-
|
|
523
|
+
const json = hasFlag('--json') || /\.json$/i.test(out ?? '')
|
|
524
|
+
const text = json ? reportJson(report, { title: deck.deckConfig?.meta?.title ?? basename(input).replace(/\.md$/i, '') })
|
|
525
|
+
: hasFlag('--summary') ? reportSummaryCsv(report) : reportAnswersCsv(report)
|
|
526
|
+
if (out) { writeFileSync(out, text); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
|
|
527
|
+
else process.stdout.write(text)
|
|
499
528
|
} else if (command === 'check') {
|
|
500
529
|
const input = requireInput('check')
|
|
501
530
|
const registry = registryFor(input)
|
|
@@ -565,8 +594,18 @@ if (command === 'new') {
|
|
|
565
594
|
// (src/build/tunnelClient.js). The dev server then serves under /t/<id>/, the
|
|
566
595
|
// address the iPad uses on the server, and its own addresses follow it. The
|
|
567
596
|
// address is the flag's value, else the deck's server setting, else MDECK_SERVER.
|
|
597
|
+
// With MDECK_SERVER and MDECK_SERVER_KEY set, the server is used without
|
|
598
|
+
// --server when it answers; otherwise this computer presents alone.
|
|
599
|
+
// --no-server keeps it out.
|
|
568
600
|
let relay = null
|
|
569
|
-
|
|
601
|
+
let serverFlag = serverOption()
|
|
602
|
+
if (serverFlag === undefined && !hasFlag('--no-server') && process.env.MDECK_SERVER && process.env.MDECK_SERVER_KEY) {
|
|
603
|
+
const address = process.env.MDECK_SERVER
|
|
604
|
+
if (await serverAnswers(address)) {
|
|
605
|
+
serverFlag = address
|
|
606
|
+
tip(`Using your server from MDECK_SERVER (${address}); --no-server presents without it.`)
|
|
607
|
+
} else tip(`Your server (MDECK_SERVER, ${address}) does not answer: presenting from this computer only.`)
|
|
608
|
+
}
|
|
570
609
|
if (serverFlag !== undefined) {
|
|
571
610
|
const inDeck = parseSlides(readFileSync(abs, 'utf8')).deckConfig?.server
|
|
572
611
|
const address = serverFlag ?? (typeof inDeck === 'string' ? inDeck : null) ?? process.env.MDECK_SERVER ?? null
|
|
@@ -644,7 +683,7 @@ if (command === 'new') {
|
|
|
644
683
|
// Phones in the room follow the talk (the reader view would show slides ahead).
|
|
645
684
|
if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
|
|
646
685
|
console.log()
|
|
647
|
-
if (relay) tip(`iPad:
|
|
686
|
+
if (relay) tip(`iPad: in the presenter view, click the connection icons and choose "iPad" or "Phone clicker"; it opens ${relay.url}`)
|
|
648
687
|
ok(`Watching ${c.cyan}${abs}${c.reset}`)
|
|
649
688
|
tip('Edit and save to reload.\n')
|
|
650
689
|
|
|
@@ -4,6 +4,9 @@ import { currentBibliography, onBibliography } from '../runtime/citations.js'
|
|
|
4
4
|
// <bibliography /> on a slide: the deck's reference list, the works it cites
|
|
5
5
|
// plus `nocite`, in the citation style's order. A long list goes over several
|
|
6
6
|
// slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
|
|
7
|
+
// `keys="a b"` lists only these works; in a deck that includes another, that
|
|
8
|
+
// deck's own list gets the keys it cites (build/include.js), so it shows what
|
|
9
|
+
// it shows alone.
|
|
7
10
|
|
|
8
11
|
function slice(order, part) {
|
|
9
12
|
const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
|
|
@@ -14,10 +17,12 @@ function slice(order, part) {
|
|
|
14
17
|
return order.slice((index - 1) * size, index * size)
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
export default function Bibliography({ part }) {
|
|
20
|
+
export default function Bibliography({ part, keys }) {
|
|
18
21
|
const [bib, setBib] = useState(currentBibliography)
|
|
19
22
|
useEffect(() => onBibliography(setBib), [])
|
|
20
23
|
if (!bib) return <p class="bibliography-hint">Name a reference file in the deck settings, e.g. <code>bibliography: refs.bib</code>.</p>
|
|
21
|
-
const
|
|
24
|
+
const only = keys == null ? null : new Set(String(keys).split(/[\s,]+/).map(key => key.replace(/^@/, '')).filter(Boolean))
|
|
25
|
+
const order = only ? bib.order.filter(id => only.has(id)) : bib.order
|
|
26
|
+
const html = slice(order, part).map(id => bib.entries[id]).join('')
|
|
22
27
|
return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
|
|
23
28
|
}
|
package/src/components/Poll.jsx
CHANGED
|
@@ -7,8 +7,8 @@ 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" />
|
|
10
|
-
// On the slide: live bars
|
|
11
|
-
//
|
|
10
|
+
// On the slide: live bars and the number of answers; the deck's join code
|
|
11
|
+
// beside them only with `qr` (the presenter shows it over the slide, J).
|
|
12
12
|
// On the phones, the server's answer page shows one button per option,
|
|
13
13
|
// as described by Poll.phone. Each device has one vote and can change it; the
|
|
14
14
|
// latest one counts.
|
|
@@ -7,8 +7,8 @@ import './poll.css'
|
|
|
7
7
|
|
|
8
8
|
// <qrcode url="https://example.com" size="240" /> on a slide.
|
|
9
9
|
// <qrcode join />: the deck's join link for audience questions, with the
|
|
10
|
-
// short link below it,
|
|
11
|
-
//
|
|
10
|
+
// short link below it, for a slide that invites everyone (the presenter view
|
|
11
|
+
// can also put it over any slide, J).
|
|
12
12
|
// <qrcode follow />: the link for following the slides on a phone or laptop.
|
|
13
13
|
|
|
14
14
|
function JoinQr({ size }) {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
3
|
import cloud from 'd3-cloud'
|
|
4
|
-
import { useRoom } from '../live/client.js'
|
|
4
|
+
import { useRoom, latestByDevice } from '../live/client.js'
|
|
5
5
|
import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
|
|
6
6
|
import { phoneQuestion } from './inlineText.js'
|
|
7
7
|
import { t } from '../core/labels.js'
|
|
8
8
|
import './poll.css'
|
|
9
9
|
|
|
10
10
|
// <wordcloud room="mood" question="One word for today?" />
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// Each phone sends a word or two; a new one replaces its earlier word. The
|
|
12
|
+
// slide packs the answers into a cloud around its middle: the more often it was sent, the
|
|
13
13
|
// larger; some stand upright. Answers that differ only in case or spacing
|
|
14
14
|
// count together. `height` is the cloud's height in slide pixels (520),
|
|
15
15
|
// `limit` the most words shown (60).
|
|
@@ -66,7 +66,7 @@ function layout(words, { width, height, font }) {
|
|
|
66
66
|
export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
|
|
67
67
|
const live = useRoom(room)
|
|
68
68
|
const controls = useControls(room, live)
|
|
69
|
-
const all = countWords(controls.counted(live.messages))
|
|
69
|
+
const all = countWords(latestByDevice(controls.counted(live.messages)))
|
|
70
70
|
const words = all.slice(0, Math.max(1, Number(limit) || 60))
|
|
71
71
|
const total = all.reduce((sum, word) => sum + word.n, 0)
|
|
72
72
|
const cloudHeight = Math.max(200, Number(height) || 520)
|
|
@@ -99,7 +99,7 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
|
|
|
99
99
|
</svg>
|
|
100
100
|
: !live.offline && <p class="question-empty">{t('question.empty')}</p>}
|
|
101
101
|
</div>
|
|
102
|
-
<ActivityFooter count={total} {...live} reset={controls.reset} closed={controls.closed}>
|
|
102
|
+
<ActivityFooter count={total} {...live} perPhone reset={controls.reset} closed={controls.closed}>
|
|
103
103
|
<ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
|
|
104
104
|
</ActivityFooter>
|
|
105
105
|
</div>
|
|
@@ -107,4 +107,5 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
|
|
|
107
107
|
</div>
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
// `once`: the phone's latest word counts (a server before 3.12 ignores it and lets phones send more).
|
|
111
|
+
WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40, once: true })
|
|
@@ -12,9 +12,10 @@ import 'katex/dist/katex.min.css'
|
|
|
12
12
|
// the number of answers, the live dot, Reset and the presenter's controls.
|
|
13
13
|
|
|
14
14
|
export const shortLink = url => url.replace(/^https?:\/\//, '')
|
|
15
|
-
//
|
|
16
|
-
// slide
|
|
17
|
-
|
|
15
|
+
// An activity shows no join code of its own: the presenter puts it over the
|
|
16
|
+
// slide when it is wanted (J), and phones that joined follow along. `qr` (or
|
|
17
|
+
// `qr="true"`) still puts the code beside the question.
|
|
18
|
+
export const wantsQr = qr => qr != null && !/^(false|no|off|0)$/i.test(String(qr))
|
|
18
19
|
// A switch such as `multiple`: on when given, unless it says false.
|
|
19
20
|
export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
|
|
20
21
|
|
|
@@ -86,11 +87,13 @@ export function useControls(room, live, { results = true, solution = null } = {}
|
|
|
86
87
|
* A record of the talk (`offline`) can still show the answer and results.
|
|
87
88
|
*/
|
|
88
89
|
export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
|
|
89
|
-
|
|
90
|
+
// data-presenter-control: the phone clicker offers them too (runtime/slideControls.js).
|
|
91
|
+
const button = (name, on, label, onClick, icon, iconName) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} data-presenter-control="" data-presenter-icon={iconName} onClick={onClick}>{icon}</button>
|
|
92
|
+
const lock = controls.closed ? 'lock' : 'lock-open', eye = controls.results ? 'eye' : 'eye-off'
|
|
90
93
|
return <>
|
|
91
|
-
{!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={
|
|
92
|
-
{results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={
|
|
93
|
-
{solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <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
|
|
94
|
+
{!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={lock} size={22} />, lock)}
|
|
95
|
+
{results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={eye} size={24} />, eye)}
|
|
96
|
+
{solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <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>, 'check')}
|
|
94
97
|
</>
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -110,7 +113,7 @@ export function ActivityFooter({ count, connected, canReset, reset, offline, pre
|
|
|
110
113
|
{of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
|
|
111
114
|
{closed && <span class="poll-closed">{t('poll.closed')}</span>}
|
|
112
115
|
<span class="poll-spacer" />
|
|
113
|
-
{canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
116
|
+
{canReset && <button class="poll-reset" disabled={count === 0 && !closed} data-presenter-control="" data-presenter-icon="reset" aria-label={t('poll.reset')} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
114
117
|
{children}
|
|
115
118
|
</p>
|
|
116
119
|
}
|
package/src/components/poll.css
CHANGED
|
@@ -31,6 +31,8 @@ deck-stage[data-follower] .poll-reset { display: none; }
|
|
|
31
31
|
.poll-control:hover, .poll-control:focus-visible { opacity: 1; }
|
|
32
32
|
.poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
|
|
33
33
|
deck-stage[data-follower] .poll-control { display: none; }
|
|
34
|
+
/* The presenter view has them in its toolbar (runtime/PresenterChrome.jsx). */
|
|
35
|
+
deck-stage[data-presenter] :is(.poll-control, .poll-reset) { display: none; }
|
|
34
36
|
/* Closed: no more answers count. */
|
|
35
37
|
.poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
|
|
36
38
|
/* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
|
package/src/core/icons.js
CHANGED
|
@@ -50,11 +50,14 @@ export const ICONS = {
|
|
|
50
50
|
highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
|
|
51
51
|
marker: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>', // sparkles
|
|
52
52
|
select: '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>', // square-dashed
|
|
53
|
-
laser: '<
|
|
53
|
+
laser: '<rect width="5" height="13" x="3.5" y="9.5" rx="2.5" transform="rotate(45 6 16)"/><path d="m11.5 12.5 3-3" stroke-dasharray="0.1 2.6"/><circle cx="17.5" cy="6.5" r="3"/><path d="M17.5 1v0.5"/><path d="M23 6.5h-.5"/><path d="m21.4 2.6-.35.35"/>', // drawn for mdeck on Lucide's grid: a laser pointer (Lucide has none; its "locate" read as a map pin)
|
|
54
54
|
fold: '<path d="m7 7 10 10"/><path d="M17 7v10H7"/>', // arrow-down-right
|
|
55
55
|
eraser: '<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/>', // eraser
|
|
56
|
+
join: '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>', // qr-code
|
|
57
|
+
settings: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>', // sliders-horizontal
|
|
56
58
|
people: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>', // users
|
|
57
59
|
tablet: '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>', // tablet
|
|
60
|
+
phone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', // smartphone
|
|
58
61
|
speaker: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>', // panel-right
|
|
59
62
|
fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', // maximize
|
|
60
63
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', // sun
|
|
@@ -63,6 +66,8 @@ export const ICONS = {
|
|
|
63
66
|
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
|
|
64
67
|
hotspots: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/>', // scan-search
|
|
65
68
|
rehearse: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>', // timer
|
|
69
|
+
blank: '<path d="M17 17H4a2 2 0 0 1-2-2V5c0-1.5 1-2 1-2"/><path d="M22 15V5a2 2 0 0 0-2-2H9"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m2 2 20 20"/>', // monitor-off
|
|
70
|
+
overview: '<rect width="18" height="14" x="3" y="3" rx="2"/><path d="M4 21h1"/><path d="M9 21h1"/><path d="M14 21h1"/><path d="M19 21h1"/>', // gallery-horizontal-end
|
|
66
71
|
pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
|
|
67
72
|
loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
|
|
68
73
|
}
|
package/src/core/labels.js
CHANGED
|
@@ -51,6 +51,7 @@ export const LABELS = {
|
|
|
51
51
|
'respond.waiting': 'The next question will appear here.',
|
|
52
52
|
'respond.send': 'Send',
|
|
53
53
|
'respond.sent': 'Sent. Thank you!',
|
|
54
|
+
'respond.react': 'React',
|
|
54
55
|
'join.scan': 'Scan to join',
|
|
55
56
|
'follow.scan': 'Scan to follow the slides',
|
|
56
57
|
'follow.open': 'Follow the slides',
|
|
@@ -124,6 +125,7 @@ export const LABELS = {
|
|
|
124
125
|
'respond.waiting': 'Die nächste Frage erscheint hier.',
|
|
125
126
|
'respond.send': 'Senden',
|
|
126
127
|
'respond.sent': 'Gesendet. Danke!',
|
|
128
|
+
'respond.react': 'Reagieren',
|
|
127
129
|
'join.scan': 'Scannen und mitmachen',
|
|
128
130
|
'follow.scan': 'Scannen und Folien mitverfolgen',
|
|
129
131
|
'follow.open': 'Folien mitverfolgen',
|
|
@@ -197,6 +199,7 @@ export const LABELS = {
|
|
|
197
199
|
'respond.waiting': 'سيظهر السؤال التالي هنا.',
|
|
198
200
|
'respond.send': 'إرسال',
|
|
199
201
|
'respond.sent': 'أُرسلت. شكرًا لك!',
|
|
202
|
+
'respond.react': 'تفاعل',
|
|
200
203
|
'join.scan': 'امسح الرمز للانضمام',
|
|
201
204
|
'follow.scan': 'امسح الرمز لمتابعة الشرائح',
|
|
202
205
|
'follow.open': 'متابعة الشرائح',
|
package/src/core/parseSlides.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import yaml from 'js-yaml'
|
|
2
|
-
import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
|
|
2
|
+
import { sourceLines, fenceState, scanDirectives, diagnostic, isIncludeLine } from './source.js'
|
|
3
3
|
export { sourceLines, fenceState } from './source.js'
|
|
4
4
|
|
|
5
|
-
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time'.split(' '))
|
|
5
|
+
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time reactions'.split(' '))
|
|
6
6
|
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots time'.split(' '))
|
|
7
7
|
|
|
8
8
|
// Names from before 2.0. They are still recognised, so that an old deck gets a
|
|
@@ -32,7 +32,7 @@ function segmentsOf(source) {
|
|
|
32
32
|
const before = fence
|
|
33
33
|
fence = fenceState(line.text, fence)
|
|
34
34
|
if (before || fence) continue
|
|
35
|
-
if (/^:::\s*[\w-]+/.test(line.text)) depth++
|
|
35
|
+
if (/^:::\s*[\w-]+/.test(line.text) && !isIncludeLine(line.text)) depth++
|
|
36
36
|
else if (/^:::\s*$/.test(line.text)) depth = Math.max(0, depth - 1)
|
|
37
37
|
if (!depth && /^---[ \t]*$/.test(line.text)) {
|
|
38
38
|
segments.push({ start, end: line.start, delimiterEnd: line.end })
|
|
@@ -103,6 +103,15 @@ function extractBody(source, body, diagnostics) {
|
|
|
103
103
|
return { content, notes: notes.join('\n\n'), regions, blocks }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// What a slide that is nothing but `:::include <file> [slides=3-7]` includes:
|
|
107
|
+
// { file, slides } (slides as written, or null), or null for any other slide.
|
|
108
|
+
export function includeOf(content) {
|
|
109
|
+
const match = /^:::\s*include\s+(?:"([^"]+)"|(\S+))((?:\s+[\w-]+=\S+)*)\s*$/.exec(String(content ?? '').trim())
|
|
110
|
+
if (!match) return null
|
|
111
|
+
const options = Object.fromEntries([...match[3].matchAll(/([\w-]+)=(\S+)/g)].map(([, key, value]) => [key, value]))
|
|
112
|
+
return { file: match[1] ?? match[2], slides: options.slides ?? null, options }
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
export function parseSlides(source) {
|
|
107
116
|
const segments = segmentsOf(source)
|
|
108
117
|
const diagnostics = []
|
|
@@ -150,9 +159,10 @@ export function parseSlides(source) {
|
|
|
150
159
|
// source editing and validation, separate from the resolved block notes.
|
|
151
160
|
delete meta.notes
|
|
152
161
|
if (notes) meta.notes = notes
|
|
162
|
+
const include = Object.keys(authoredMeta).length ? null : includeOf(content)
|
|
153
163
|
slides.push({ id: meta.id ?? `slide-${slides.length + 1}`, meta, authoredMeta, content, regions, blocks,
|
|
154
164
|
source: { start: segment.start, end: body?.end ?? segment.end },
|
|
155
|
-
metaSource, bodySource: body ? { start: body.start, end: body.end } : null })
|
|
165
|
+
metaSource, bodySource: body ? { start: body.start, end: body.end } : null, ...(include ? { include } : {}) })
|
|
156
166
|
}
|
|
157
167
|
let currentSection = null
|
|
158
168
|
for (const slide of slides) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Reactions: people tap an emoji on the answer page, and it floats up on the
|
|
2
|
+
// presenter's screen for a moment, never on the projector. A deck turns them
|
|
3
|
+
// on with `reactions: true` (the set below) or its own list of up to eight,
|
|
4
|
+
// such as `reactions: [👍, 🤔, 🐢]`. They go to the session's `reactions`
|
|
5
|
+
// room, which the server passes on without keeping (src/live/rooms.js).
|
|
6
|
+
|
|
7
|
+
export const REACTIONS_ROOM = 'reactions'
|
|
8
|
+
// 🐢: "slower, please".
|
|
9
|
+
export const DEFAULT_REACTIONS = ['👍', '👏', '❤️', '😮', '🤔', '🐢']
|
|
10
|
+
const MAX = 8
|
|
11
|
+
const MAX_LENGTH = 16
|
|
12
|
+
|
|
13
|
+
/** Whether `value` is a usable `reactions` setting; a problem as text, or null. */
|
|
14
|
+
export function reactionsProblem(value) {
|
|
15
|
+
if (value == null || typeof value === 'boolean') return null
|
|
16
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX) return `reactions must be true, false, or a list of 1 to ${MAX} emoji or short words`
|
|
17
|
+
const bad = value.find(item => typeof item !== 'string' && typeof item !== 'number' || !String(item).trim() || String(item).trim().length > MAX_LENGTH)
|
|
18
|
+
return bad !== undefined ? `reactions: each one is an emoji or a word of up to ${MAX_LENGTH} characters, not ${JSON.stringify(bad)}` : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The reactions a deck offers, or null when it offers none. */
|
|
22
|
+
export function reactionsOf(config) {
|
|
23
|
+
const value = config?.reactions
|
|
24
|
+
if (value === true) return DEFAULT_REACTIONS
|
|
25
|
+
if (reactionsProblem(value) || !Array.isArray(value)) return null
|
|
26
|
+
return [...new Set(value.map(item => String(item).trim()))]
|
|
27
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The answers kept in a talk (<deck>.results.json, results.js) read against
|
|
2
|
+
// the deck they were given in: which slide asked, what, and how it came out.
|
|
3
|
+
// `mdeck results` and the launch page export them from here, as the answers
|
|
4
|
+
// one by one (CSV), one row per option, word or number (summary CSV), or
|
|
5
|
+
// all of it (JSON).
|
|
6
|
+
//
|
|
7
|
+
// The summary counts as the slide does: for a poll, a scale, a number and a
|
|
8
|
+
// word cloud, each phone's latest answer; for open questions, every answer. Phones stay numbered per question; no device ids are kept.
|
|
9
|
+
import { tagsIn } from './tags.js'
|
|
10
|
+
import { expandPollLists, pollChoices } from './pollList.js'
|
|
11
|
+
import { choicesOf } from '../components/inlineText.js'
|
|
12
|
+
import { parseNumber, groupNumbers } from '../components/numbers.js'
|
|
13
|
+
|
|
14
|
+
const KINDS = { poll: 'poll', scale: 'scale', numeric: 'number', wordcloud: 'words', question: 'question' }
|
|
15
|
+
|
|
16
|
+
const plain = text => String(text ?? '').replace(/\$([^$]+)\$/g, '$1').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
|
|
17
|
+
|
|
18
|
+
function headingOf(slide) {
|
|
19
|
+
const heading = String(slide?.content ?? '').replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]
|
|
20
|
+
return heading ? plain(heading) : plain(slide?.meta?.title ?? '')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const scaleBounds = ({ min, max }) => {
|
|
24
|
+
const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
|
|
25
|
+
const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
|
|
26
|
+
return high > low && high - low <= 10 ? [low, high] : [1, 5]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The activities of a parsed deck, in order: [{ room, slide, kind, tag,
|
|
31
|
+
* question, options?, multiple?, min?, max? }]. `slide` counts from 1; a
|
|
32
|
+
* room asked on several slides is listed where it is first asked.
|
|
33
|
+
*/
|
|
34
|
+
export function activitiesIn(deck) {
|
|
35
|
+
const found = new Map()
|
|
36
|
+
;(deck?.slides ?? []).forEach((slide, i) => {
|
|
37
|
+
const texts = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
|
|
38
|
+
for (const text of texts) {
|
|
39
|
+
for (const tag of tagsIn(expandPollLists(text))) {
|
|
40
|
+
const room = tag.attrs.room
|
|
41
|
+
if (tag.closing || !room || found.has(room)) continue
|
|
42
|
+
const kind = KINDS[tag.name] ?? tag.name
|
|
43
|
+
const entry = { room, slide: i + 1, kind, tag: tag.name, question: plain(tag.attrs.question) || headingOf(slide) }
|
|
44
|
+
if (tag.name === 'poll') {
|
|
45
|
+
const listed = tag.attrs['data-choices'] != null && pollChoices(tag.attrs['data-choices'])
|
|
46
|
+
entry.options = listed ? listed.map(choice => choice.text) : choicesOf(tag.attrs.options)
|
|
47
|
+
if (tag.attrs.multiple != null && tag.attrs.multiple !== 'false') entry.multiple = true
|
|
48
|
+
}
|
|
49
|
+
if (tag.name === 'scale') [entry.min, entry.max] = scaleBounds(tag.attrs)
|
|
50
|
+
found.set(room, entry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
return [...found.values()]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Each phone's last answer, in the order the phones first answered.
|
|
58
|
+
function latestPerPhone(answers) {
|
|
59
|
+
const latest = new Map()
|
|
60
|
+
for (const answer of answers) latest.set(answer.phone, answer)
|
|
61
|
+
return [...latest.values()]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const round = value => Math.round(value * 100) / 100
|
|
65
|
+
function stats(values) {
|
|
66
|
+
if (!values.length) return {}
|
|
67
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
68
|
+
const middle = sorted.length >> 1
|
|
69
|
+
const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
|
|
70
|
+
return { average: round(values.reduce((sum, value) => sum + value, 0) / values.length), median: round(median), lowest: sorted[0], highest: sorted.at(-1) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** How one activity came out: { phones, answers, counts: [{ answer, count }], average?, median?, lowest?, highest? }. */
|
|
74
|
+
export function summarize(activity, answers = []) {
|
|
75
|
+
const phones = new Set(answers.map(answer => answer.phone)).size
|
|
76
|
+
const base = { phones, answers: answers.length }
|
|
77
|
+
const tag = activity?.tag
|
|
78
|
+
if (tag === 'poll') {
|
|
79
|
+
const options = activity.options ?? []
|
|
80
|
+
const votes = latestPerPhone(answers)
|
|
81
|
+
.map(answer => activity.multiple ? (Array.isArray(answer.value) ? answer.value : [answer.value]) : (Array.isArray(answer.value) ? [] : [answer.value]))
|
|
82
|
+
.map(picks => picks.filter(pick => options.includes(pick)))
|
|
83
|
+
.filter(picks => picks.length)
|
|
84
|
+
return { ...base, voted: votes.length, counts: options.map(option => ({ answer: option, count: votes.filter(picks => picks.includes(option)).length })) }
|
|
85
|
+
}
|
|
86
|
+
if (tag === 'scale') {
|
|
87
|
+
const steps = Array.from({ length: activity.max - activity.min + 1 }, (_, i) => activity.min + i)
|
|
88
|
+
const values = latestPerPhone(answers).map(answer => Number(answer.value)).filter(value => steps.includes(value))
|
|
89
|
+
return { ...base, voted: values.length, counts: steps.map(step => ({ answer: String(step), count: values.filter(value => value === step).length })), ...stats(values) }
|
|
90
|
+
}
|
|
91
|
+
if (tag === 'numeric') {
|
|
92
|
+
const texts = latestPerPhone(answers).map(answer => answer.value).filter(value => parseNumber(value) != null)
|
|
93
|
+
return { ...base, voted: texts.length, counts: groupNumbers(texts).map(group => ({ answer: group.text, count: group.n })), ...stats(texts.map(parseNumber)) }
|
|
94
|
+
}
|
|
95
|
+
// Words (each phone's latest) and open answers (every one), the same words together.
|
|
96
|
+
const counts = new Map()
|
|
97
|
+
for (const answer of tag === 'wordcloud' ? latestPerPhone(answers) : answers) {
|
|
98
|
+
const text = String(answer.value ?? '').trim().replace(/\s+/g, ' ')
|
|
99
|
+
if (!text) continue
|
|
100
|
+
const key = text.toLocaleLowerCase()
|
|
101
|
+
const entry = counts.get(key) ?? { answer: text, count: 0 }
|
|
102
|
+
entry.count++
|
|
103
|
+
counts.set(key, entry)
|
|
104
|
+
}
|
|
105
|
+
return { ...base, counts: [...counts.values()].sort((a, b) => b.count - a.count || a.answer.localeCompare(b.answer)) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The kept answers against the deck: [{ room, slide, kind, question,
|
|
110
|
+
* closed, savedAt, summary, answers }], in the order of the slides; rooms
|
|
111
|
+
* the deck no longer asks come last, with what is known of them.
|
|
112
|
+
*/
|
|
113
|
+
export function resultsReport(results, deck) {
|
|
114
|
+
const rooms = results?.rooms ?? {}
|
|
115
|
+
const activities = activitiesIn(deck)
|
|
116
|
+
const asked = activities.filter(activity => rooms[activity.room])
|
|
117
|
+
const gone = Object.keys(rooms).filter(room => !activities.some(activity => activity.room === room)).map(room => ({ room, slide: null, kind: null, tag: null, question: '' }))
|
|
118
|
+
return [...asked, ...gone].map(activity => {
|
|
119
|
+
const entry = rooms[activity.room]
|
|
120
|
+
const { tag, options, multiple, min, max, ...shown } = activity
|
|
121
|
+
return { ...shown, closed: entry.closed, savedAt: entry.savedAt, summary: summarize(activity, entry.answers), answers: entry.answers }
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const csvCell = value => {
|
|
126
|
+
const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
|
|
127
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
128
|
+
}
|
|
129
|
+
const csv = rows => rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
|
|
130
|
+
|
|
131
|
+
/** Every answer with its question: slide, room, kind, question, phone, time, answer (several picks joined by |). */
|
|
132
|
+
export function reportAnswersCsv(report) {
|
|
133
|
+
const rows = [['slide', 'room', 'kind', 'question', 'phone', 'at', 'answer']]
|
|
134
|
+
for (const entry of report) for (const answer of entry.answers) rows.push([entry.slide, entry.room, entry.kind, entry.question, answer.phone, answer.at, answer.value])
|
|
135
|
+
return csv(rows)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One row per option, step, number or word: slide, room, kind, question, answer, count, and its share of the phones that answered. */
|
|
139
|
+
export function reportSummaryCsv(report) {
|
|
140
|
+
const rows = [['slide', 'room', 'kind', 'question', 'answer', 'count', 'share']]
|
|
141
|
+
for (const entry of report) {
|
|
142
|
+
const { counts = [], voted } = entry.summary
|
|
143
|
+
const total = voted ?? counts.reduce((sum, row) => sum + row.count, 0)
|
|
144
|
+
for (const row of counts) rows.push([entry.slide, entry.room, entry.kind, entry.question, row.answer, row.count, total ? `${Math.round((row.count / total) * 100)}%` : ''])
|
|
145
|
+
for (const key of ['average', 'median']) if (entry.summary[key] != null) rows.push([entry.slide, entry.room, entry.kind, entry.question, key, entry.summary[key], ''])
|
|
146
|
+
}
|
|
147
|
+
return csv(rows)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** All of it, for other programs: the deck's title, when, and each question with its summary and answers. */
|
|
151
|
+
export function reportJson(report, { title = '', exportedAt = new Date().toISOString() } = {}) {
|
|
152
|
+
return JSON.stringify({ version: 1, title, exportedAt, questions: report }, null, 2) + '\n'
|
|
153
|
+
}
|
package/src/core/source.js
CHANGED
|
@@ -24,6 +24,11 @@ export function diagnostic(source, code, message, offset = 0, severity = 'error'
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Balanced directive scanner, shared by the document parser and Markdown lexer.
|
|
27
|
+
// `:::include ./part.md`, alone on a slide, stands for the slides of another
|
|
28
|
+
// deck file (build/include.js puts them in its place). Without a path, `:::include`
|
|
29
|
+
// opens a block as any other name does (a callout of the deck's own).
|
|
30
|
+
export const isIncludeLine = text => /^:::\s*include\s+\S/.test(text)
|
|
31
|
+
|
|
27
32
|
export function scanDirectives(source) {
|
|
28
33
|
const nodes = []
|
|
29
34
|
const stack = []
|
|
@@ -38,7 +43,9 @@ export function scanDirectives(source) {
|
|
|
38
43
|
const node = { name: open[1], argument: open[2].trim(), start: line.start, bodyStart: line.end, children: [] }
|
|
39
44
|
if (stack.length) stack.at(-1).children.push(node)
|
|
40
45
|
else nodes.push(node)
|
|
41
|
-
|
|
46
|
+
// :::include is one line: it has no body and no closing fence.
|
|
47
|
+
if (isIncludeLine(line.text)) Object.assign(node, { bodyEnd: line.end, end: line.end })
|
|
48
|
+
else stack.push(node)
|
|
42
49
|
} else if (/^:::[ \t]*$/.test(line.text)) {
|
|
43
50
|
const node = stack.pop()
|
|
44
51
|
if (!node) diagnostics.push(diagnostic(source, 'unexpected-close', 'Directive closing fence has no opening fence', line.start))
|