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
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
2
3
|
import QrCode from './QrCode.jsx'
|
|
4
|
+
import { Icon } from './Icon.jsx'
|
|
3
5
|
import { t } from '../core/labels.js'
|
|
6
|
+
import { keepAnswers } from '../live/client.js'
|
|
7
|
+
import { slideHtml } from './inlineText.js'
|
|
8
|
+
import 'katex/dist/katex.min.css'
|
|
4
9
|
|
|
5
10
|
// Parts every activity on a slide shares (<poll>, <question>, <wordcloud>,
|
|
6
|
-
// <scale>, and <qrcode join />): the deck's join code
|
|
7
|
-
// answers, the live dot and
|
|
11
|
+
// <scale>, <numeric> and <qrcode join />): the deck's join code, the line with
|
|
12
|
+
// the number of answers, the live dot, Reset and the presenter's controls.
|
|
8
13
|
|
|
9
14
|
export const shortLink = url => url.replace(/^https?:\/\//, '')
|
|
10
15
|
// `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
|
|
11
16
|
// slide (<qrcode join />), and people's phones follow along.
|
|
12
17
|
export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
|
|
18
|
+
// A switch such as `multiple`: on when given, unless it says false.
|
|
19
|
+
export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
|
|
20
|
+
|
|
21
|
+
/** An activity's question, Markdown with $…$ maths. */
|
|
22
|
+
export const ActivityQuestion = ({ text }) => text ? <p class="poll-question" dangerouslySetInnerHTML={{ __html: slideHtml(text) }} /> : null
|
|
13
23
|
|
|
14
24
|
/** The join code with the short link, or how to make phones reach the server. */
|
|
15
25
|
export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
|
|
@@ -22,14 +32,85 @@ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = tru
|
|
|
22
32
|
</a>
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
// What every screen of the talk shares about an activity: whether it still
|
|
36
|
+
// takes answers (`closed`; `upTo` is the last answer that counts), whether
|
|
37
|
+
// its right answer shows (`revealed`) and whether its results show. The
|
|
38
|
+
// activity room's state on the server holds them, so the audience window, an
|
|
39
|
+
// iPad, a reloaded presenter view and the phones follow: phones say the
|
|
40
|
+
// activity is closed and, once the answer shows, whether theirs was right
|
|
41
|
+
// (`solution`, sent only then). Between windows of one browser, and where
|
|
42
|
+
// this one may not change the room, the stage event `pollcontrols` carries
|
|
43
|
+
// them too (src/runtime/ink/bus.js).
|
|
44
|
+
const CONTROLS = ['closed', 'upTo', 'revealed', 'results']
|
|
45
|
+
const controlsIn = state => Object.fromEntries(CONTROLS.filter(key => state && typeof state === 'object' && key in state).map(key => [key, state[key]]))
|
|
46
|
+
|
|
47
|
+
export function useControls(room, live, { results = true, solution = null } = {}) {
|
|
48
|
+
const ref = useRef()
|
|
49
|
+
const [controls, setControls] = useState({ closed: false, upTo: 0, revealed: false, results })
|
|
50
|
+
const kept = JSON.stringify(controlsIn(live.state))
|
|
51
|
+
useEffect(() => { if (kept !== '{}') setControls(now => ({ ...now, ...JSON.parse(kept) })) }, [kept])
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const stage = ref.current?.closest('deck-stage')
|
|
54
|
+
if (!stage) return
|
|
55
|
+
const follow = event => { if (event.detail?.room === room && !event.detail.local) setControls(now => ({ ...now, ...controlsIn(event.detail.controls) })) }
|
|
56
|
+
stage.addEventListener('pollcontrols', follow)
|
|
57
|
+
return () => stage.removeEventListener('pollcontrols', follow)
|
|
58
|
+
}, [room])
|
|
59
|
+
const change = patch => {
|
|
60
|
+
const next = { ...controls, ...patch }
|
|
61
|
+
setControls(next)
|
|
62
|
+
ref.current?.closest('deck-stage')?.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room, controls: next, local: true } }))
|
|
63
|
+
if (live.canReset) live.setState({ ...next, ...(next.revealed && solution != null ? { solution } : {}) }).catch(() => {})
|
|
64
|
+
}
|
|
65
|
+
// The answers that count: once closed, those that came before.
|
|
66
|
+
const counted = messages => controls.closed ? messages.filter(message => message.n <= controls.upTo) : messages
|
|
67
|
+
// They are kept beside the deck while `mdeck run` runs. A room that
|
|
68
|
+
// starts empty (a new server) leaves what was kept alone; one that was
|
|
69
|
+
// reset takes it out.
|
|
70
|
+
const answered = useRef(false)
|
|
71
|
+
const counting = counted(live.messages)
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!live.canReset || !live.connected || live.offline || (!counting.length && !answered.current)) return
|
|
74
|
+
answered.current = true
|
|
75
|
+
keepAnswers(room, counting, { closed: controls.closed })
|
|
76
|
+
}, [room, counting.length, counting.at(-1)?.n, controls.closed, live.canReset, live.connected])
|
|
77
|
+
const close = messages => change(controls.closed ? { closed: false } : { closed: true, upTo: Math.max(0, ...messages.map(message => message.n)) })
|
|
78
|
+
// Reset starts over: open, the answer and results as at the start.
|
|
79
|
+
const reset = () => { change({ closed: false, upTo: 0, revealed: false, results }); return live.reset() }
|
|
80
|
+
return { ...controls, ref, change, counted, close, reset }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The presenter's buttons: close (a lock), show or hide the results (an
|
|
85
|
+
* eye, with `results`), show the right answer (a tick, with `solve`).
|
|
86
|
+
* A record of the talk (`offline`) can still show the answer and results.
|
|
87
|
+
*/
|
|
88
|
+
export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
|
|
89
|
+
const button = (name, on, label, onClick, icon) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} onClick={onClick}>{icon}</button>
|
|
90
|
+
return <>
|
|
91
|
+
{!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={controls.closed ? 'lock' : 'lock-open'} size={22} />)}
|
|
92
|
+
{results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={controls.results ? 'eye' : 'eye-off'} size={24} />)}
|
|
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
|
+
</>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Number of answers, whether the room is live, and Reset for the presenter;
|
|
99
|
+
* `children` go at the end. With `perPhone` (one answer per phone) the
|
|
100
|
+
* count says of how many phones; `closed` says the activity takes no more.
|
|
101
|
+
*/
|
|
102
|
+
export function ActivityFooter({ count, connected, canReset, reset, offline, present = null, perPhone = false, closed = false, children }) {
|
|
27
103
|
// Without a server this is a record of the talk: no count, dot or buttons.
|
|
28
104
|
if (offline && !count) return <p class="poll-total poll-static">{t('poll.static')}</p>
|
|
105
|
+
// Followers count as phones too; with more answers than phones, some left.
|
|
106
|
+
const of = perPhone && !offline && present > 0 && present >= count ? present : null
|
|
29
107
|
return <p class="poll-total">
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
{
|
|
108
|
+
{/* The answers kept in the talk, without a server: no live dot. */}
|
|
109
|
+
{!offline && <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />}
|
|
110
|
+
{of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
|
|
111
|
+
{closed && <span class="poll-closed">{t('poll.closed')}</span>}
|
|
112
|
+
<span class="poll-spacer" />
|
|
113
|
+
{canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
33
114
|
{children}
|
|
34
115
|
</p>
|
|
35
116
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
import markedKatex from 'marked-katex-extension'
|
|
3
|
+
|
|
4
|
+
// Short texts in a tag's attributes (a poll's question and options) as one
|
|
5
|
+
// line of Markdown with $…$ maths, like the text on a slide. The slide gets
|
|
6
|
+
// KaTeX's HTML; the phones' answer page has no KaTeX, so it gets MathML,
|
|
7
|
+
// which browsers draw themselves (src/live/answer/answer.js).
|
|
8
|
+
const slide = new Marked().use(markedKatex({ throwOnError: false, output: 'html' }))
|
|
9
|
+
const phone = new Marked().use(markedKatex({ throwOnError: false, output: 'mathml' }))
|
|
10
|
+
|
|
11
|
+
export const slideHtml = text => slide.parseInline(String(text ?? ''))
|
|
12
|
+
export const phoneHtml = text => phone.parseInline(String(text ?? ''))
|
|
13
|
+
|
|
14
|
+
/** An activity's question for the phones: the text, and drawn when the tag has one (else the slide's heading, as text). */
|
|
15
|
+
export const phoneQuestion = (question, slideTitle) => question ? { question, questionHtml: phoneHtml(question) } : { question: slideTitle }
|
|
16
|
+
|
|
17
|
+
// A formula, as KaTeX's inline rule finds it: $…$ or $$…$$, not starting
|
|
18
|
+
// with a space, and ending before a space, punctuation, a bar or the end.
|
|
19
|
+
const MATH = /^(\${1,2})(?!\$|\s)(?:\\.|[^\\\n$])*?\1(?=[\s?!.,:|]|$)/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* "A|B|C" as ["A", "B", "C"]. A bar in a formula belongs to it ($|x|$,
|
|
23
|
+
* $\{x \mid x > 0\}$), and \| is a bar in the text. "$5|$10" is two
|
|
24
|
+
* options: neither $ starts a formula.
|
|
25
|
+
*/
|
|
26
|
+
export function choicesOf(options) {
|
|
27
|
+
const text = String(options ?? '')
|
|
28
|
+
const parts = []
|
|
29
|
+
let part = ''
|
|
30
|
+
for (let i = 0; i < text.length; i++) {
|
|
31
|
+
const math = text[i] === '$' && text.slice(i).match(MATH)
|
|
32
|
+
if (math) { part += math[0]; i += math[0].length - 1 }
|
|
33
|
+
else if (text[i] === '\\' && text[i + 1] === '|') { part += '|'; i++ }
|
|
34
|
+
else if (text[i] === '\\' && i + 1 < text.length) { part += text[i] + text[i + 1]; i++ }
|
|
35
|
+
else if (text[i] === '|') { parts.push(part); part = '' }
|
|
36
|
+
else part += text[i]
|
|
37
|
+
}
|
|
38
|
+
parts.push(part)
|
|
39
|
+
return parts.map(choice => choice.trim()).filter(Boolean)
|
|
40
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Numbers typed on a phone for <numeric>. The answer page has its own copy
|
|
2
|
+
// of parseNumber (src/live/answer/answer.js), as it has no build step.
|
|
3
|
+
|
|
4
|
+
/** 0.5, 0,5, −3, 1e-3 or 1/2 as a number; null for anything else. */
|
|
5
|
+
export function parseNumber(text) {
|
|
6
|
+
const plain = String(text ?? '').trim().replace(/\s+/g, '').replace(/−/g, '-').replace(/(\d),(\d)/g, '$1.$2')
|
|
7
|
+
const parts = plain.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(?:\/((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?$/i)
|
|
8
|
+
if (!parts) return null
|
|
9
|
+
const value = parts[2] ? Number(parts[1]) / Number(parts[2]) : Number(parts[1])
|
|
10
|
+
return Number.isFinite(value) ? value : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The answers grouped by value, most frequent first: [{ value, text, n }].
|
|
15
|
+
* `text` is how most people typed it (0.5, or 1/2). Values that differ only
|
|
16
|
+
* in the last digits of a float (1/3 and 0.333333333333) count as one.
|
|
17
|
+
*/
|
|
18
|
+
export function groupNumbers(texts) {
|
|
19
|
+
const groups = new Map()
|
|
20
|
+
for (const text of texts) {
|
|
21
|
+
const value = parseNumber(text)
|
|
22
|
+
if (value == null) continue
|
|
23
|
+
const key = Number(value.toPrecision(12))
|
|
24
|
+
const group = groups.get(key) ?? { value, n: 0, spellings: new Map() }
|
|
25
|
+
const spelling = String(text).trim()
|
|
26
|
+
group.n++
|
|
27
|
+
group.spellings.set(spelling, (group.spellings.get(spelling) ?? 0) + 1)
|
|
28
|
+
groups.set(key, group)
|
|
29
|
+
}
|
|
30
|
+
return [...groups.values()]
|
|
31
|
+
.map(({ value, n, spellings }) => ({ value, n, text: [...spellings].sort((a, b) => b[1] - a[1])[0][0] }))
|
|
32
|
+
.sort((a, b) => b.n - a.n || a.value - b.value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `value` is the answer, give or take `tolerance` (0: equal, as far as floats go). */
|
|
36
|
+
export function isRightNumber(value, answer, tolerance = 0) {
|
|
37
|
+
if (value == null || answer == null) return false
|
|
38
|
+
return Math.abs(value - answer) <= Math.max(tolerance, 1e-9 * Math.max(1, Math.abs(answer)))
|
|
39
|
+
}
|
package/src/components/poll.css
CHANGED
|
@@ -5,32 +5,37 @@
|
|
|
5
5
|
.poll-bars { display: grid; gap: 20px; }
|
|
6
6
|
.poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
|
|
7
7
|
.poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
8
|
+
/* buttons="letters|numbers": the key phones show, before the option. */
|
|
9
|
+
.poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-right: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
|
|
10
|
+
/* A picture as an option: a thumbnail beside its bar. */
|
|
11
|
+
.poll-label:has(img) { white-space: normal; }
|
|
12
|
+
.poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
|
|
13
|
+
.poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
8
14
|
.poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
|
|
9
15
|
.poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
|
|
10
16
|
.poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
11
17
|
.poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
12
18
|
.poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
|
|
13
19
|
.poll-dot.is-live { background: #16a34a; opacity: 1; }
|
|
14
|
-
.poll-
|
|
20
|
+
.poll-spacer { flex: 1; }
|
|
21
|
+
.poll-reset { padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
15
22
|
.poll-reset:disabled { opacity: .4; cursor: default; }
|
|
16
23
|
/* The audience sees the results, not the presenter's controls. */
|
|
17
24
|
deck-stage[data-follower] .poll-reset { display: none; }
|
|
18
25
|
/* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
|
|
19
|
-
|
|
20
|
-
.poll-
|
|
21
|
-
.poll-
|
|
22
|
-
.poll-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
/* The presenter's controls: close (lock), results (eye), right answer (tick). */
|
|
27
|
+
.poll-control { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
|
|
28
|
+
.poll-control:hover, .poll-control:focus-visible { opacity: 1; }
|
|
29
|
+
.poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
|
|
30
|
+
deck-stage[data-follower] .poll-control { display: none; }
|
|
31
|
+
/* Closed: no more answers count. */
|
|
32
|
+
.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; }
|
|
26
33
|
/* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
|
|
27
34
|
.poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
|
|
28
35
|
.poll.is-results-hidden .poll-count { opacity: .35; }
|
|
29
36
|
deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
|
|
30
37
|
.poll-solve svg { width: 24px; height: 24px; }
|
|
31
|
-
.poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
|
|
32
38
|
.poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
|
|
33
|
-
deck-stage[data-follower] .poll-solve { display: none; }
|
|
34
39
|
.poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
|
|
35
40
|
.poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
|
|
36
41
|
.poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
|
|
@@ -76,16 +81,21 @@ deck-stage[data-follower] .poll-solve { display: none; }
|
|
|
76
81
|
.poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
|
|
77
82
|
.poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
|
|
78
83
|
.poll-join-slide .poll-link { font-size: 28px; }
|
|
79
|
-
@media print { .poll-reset, .poll-dot, .poll-
|
|
84
|
+
@media print { .poll-reset, .poll-dot, .poll-control { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
|
|
80
85
|
|
|
81
86
|
/* A record of the talk (no room server: a PDF, a file sent to readers): the
|
|
82
87
|
question and its options, without counts, the live dot or a join code. */
|
|
83
88
|
.poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
|
|
84
|
-
.poll.is-static .poll-
|
|
89
|
+
.poll.is-static .poll-close { display: none; }
|
|
85
90
|
.poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
|
|
86
91
|
.poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
|
|
87
92
|
|
|
88
93
|
/* On paper the join code and the presenter's controls have no use. */
|
|
89
94
|
@media print {
|
|
90
|
-
.poll-join, .poll-reset, .poll-
|
|
95
|
+
.poll-join, .poll-reset, .poll-dot, .poll-control { display: none !important; }
|
|
91
96
|
}
|
|
97
|
+
|
|
98
|
+
/* <numeric>: the most frequent answers; Other for the rest. */
|
|
99
|
+
.poll-row.is-other .poll-label { color: var(--muted, #666); font-style: italic; }
|
|
100
|
+
.poll--numeric .poll-label { font-variant-numeric: tabular-nums; }
|
|
101
|
+
.poll .numeric-answer { margin: 24px 0 0; font-weight: 600; color: var(--poll-correct, #16a34a); }
|
package/src/core/editDeck.js
CHANGED
|
@@ -233,7 +233,7 @@ export function stripNotes(source) {
|
|
|
233
233
|
// The live activities, and the code to join them (<qrcode join />): a slide
|
|
234
234
|
// with one of them is left out of shared builds. <qrcode follow /> and other
|
|
235
235
|
// QR codes stay.
|
|
236
|
-
const ACTIVITY_RE = /<(poll|question|wordcloud|scale)\b/i
|
|
236
|
+
const ACTIVITY_RE = /<(poll|question|wordcloud|scale|numeric)\b/i
|
|
237
237
|
const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
|
|
238
238
|
const joins = text => { const m = JOIN_RE.exec(text); return !!m && !/^(false|no|off|0)$/i.test(m[1] ?? m[2] ?? m[3] ?? '') }
|
|
239
239
|
export function hasActivity(slide) {
|
|
@@ -242,7 +242,7 @@ export function hasActivity(slide) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
|
|
245
|
-
// <scale>) for shared builds: a reader cannot vote, and the results belong to
|
|
245
|
+
// <scale>, <numeric>) for shared builds: a reader cannot vote, and the results belong to
|
|
246
246
|
// the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
|
|
247
247
|
// to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
|
|
248
248
|
// that are gone, so saved drawings can follow their slides.
|
package/src/core/icons.js
CHANGED
|
@@ -41,6 +41,8 @@ export const ICONS = {
|
|
|
41
41
|
trash: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', // trash-2
|
|
42
42
|
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', // eye
|
|
43
43
|
'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', // eye-off
|
|
44
|
+
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', // lock
|
|
45
|
+
'lock-open': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>', // lock-open
|
|
44
46
|
finger: '<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', // pointer
|
|
45
47
|
pen: '<path d="M13 21h8"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>', // pen-line
|
|
46
48
|
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
|
package/src/core/labels.js
CHANGED
|
@@ -55,6 +55,22 @@ export const LABELS = {
|
|
|
55
55
|
'follow.waiting': 'The talk has not started yet.',
|
|
56
56
|
'follow.tryHere': 'Try following here',
|
|
57
57
|
'join.static': 'During the talk, phones joined here to answer.',
|
|
58
|
+
'poll.answeredOf': '{n} of {of} answered',
|
|
59
|
+
'poll.close': 'Close: later answers do not count',
|
|
60
|
+
'poll.open': 'Open again',
|
|
61
|
+
'poll.closed': 'Closed',
|
|
62
|
+
'poll.several': 'Several answers possible.',
|
|
63
|
+
'poll.pickSeveral': 'Tap all that apply.',
|
|
64
|
+
'poll.thanksSeveral': 'Thanks! You chose {choice}. Tap to change.',
|
|
65
|
+
'respond.closed': 'Closed: no more answers.',
|
|
66
|
+
'respond.right': 'Right!',
|
|
67
|
+
'respond.wrong': 'Not quite. The right answer: {answer}',
|
|
68
|
+
'respond.solution': 'The right answer: {answer}',
|
|
69
|
+
'numeric.placeholder': 'A number',
|
|
70
|
+
'numeric.invalid': 'Enter a number, such as 0.5 or 1/2.',
|
|
71
|
+
'numeric.sent': 'Sent: {value}. Send another to change.',
|
|
72
|
+
'numeric.other': 'Other',
|
|
73
|
+
'numeric.right': '{n} right',
|
|
58
74
|
'question.empty': 'Answers appear here.',
|
|
59
75
|
'scale.average': 'Average {n}',
|
|
60
76
|
},
|
|
@@ -108,6 +124,22 @@ export const LABELS = {
|
|
|
108
124
|
'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
|
|
109
125
|
'follow.tryHere': 'Mitverfolgen hier ausprobieren',
|
|
110
126
|
'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
|
|
127
|
+
'poll.answeredOf': '{n} von {of} haben geantwortet',
|
|
128
|
+
'poll.close': 'Schliessen: spätere Antworten zählen nicht',
|
|
129
|
+
'poll.open': 'Wieder öffnen',
|
|
130
|
+
'poll.closed': 'Geschlossen',
|
|
131
|
+
'poll.several': 'Mehrere Antworten möglich.',
|
|
132
|
+
'poll.pickSeveral': 'Alle zutreffenden antippen.',
|
|
133
|
+
'poll.thanksSeveral': 'Danke! Gewählt: {choice}. Zum Ändern antippen.',
|
|
134
|
+
'respond.closed': 'Geschlossen: keine weiteren Antworten.',
|
|
135
|
+
'respond.right': 'Richtig!',
|
|
136
|
+
'respond.wrong': 'Leider nicht. Richtig ist: {answer}',
|
|
137
|
+
'respond.solution': 'Richtig ist: {answer}',
|
|
138
|
+
'numeric.placeholder': 'Eine Zahl',
|
|
139
|
+
'numeric.invalid': 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.',
|
|
140
|
+
'numeric.sent': 'Gesendet: {value}. Zum Ändern eine andere senden.',
|
|
141
|
+
'numeric.other': 'Andere',
|
|
142
|
+
'numeric.right': '{n} richtig',
|
|
111
143
|
'question.empty': 'Hier erscheinen die Antworten.',
|
|
112
144
|
'scale.average': 'Durchschnitt {n}',
|
|
113
145
|
},
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The answers given during a talk, kept beside the deck as
|
|
2
|
+
// <deck>.results.json, so they outlast the room server (which forgets them)
|
|
3
|
+
// and show in builds, PDFs and files sent to readers.
|
|
4
|
+
//
|
|
5
|
+
// { "version": 1, "rooms": { "<room>": { "savedAt": "…", "closed": false,
|
|
6
|
+
// "answers": [{ "phone": 1, "at": "…", "value": … }] } } }
|
|
7
|
+
//
|
|
8
|
+
// Phones are numbered per room in the order they first answered; their own
|
|
9
|
+
// ids never leave the room server. `answers` are those that counted: once an
|
|
10
|
+
// activity was closed, the ones before.
|
|
11
|
+
|
|
12
|
+
export const resultsFileFor = deckPath => String(deckPath).replace(/\.md$/i, '') + '.results.json'
|
|
13
|
+
|
|
14
|
+
const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
15
|
+
|
|
16
|
+
/** One room's answers as kept: room messages ({ n, at, from, data }) with numbered phones. */
|
|
17
|
+
export function keptAnswers(messages) {
|
|
18
|
+
const phones = new Map()
|
|
19
|
+
return messages.map(message => {
|
|
20
|
+
const from = message.from ?? `#${message.n}`
|
|
21
|
+
if (!phones.has(from)) phones.set(from, phones.size + 1)
|
|
22
|
+
return { phone: phones.get(from), at: message.at ?? null, value: message.data?.value ?? null }
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Kept answers as room messages again, for the slides that show them. */
|
|
27
|
+
export const answersAsMessages = answers => answers.map((answer, i) => ({ n: i + 1, at: answer.at, from: `phone-${answer.phone}`, data: { value: answer.value } }))
|
|
28
|
+
|
|
29
|
+
/** A results file as read, with anything unexpected left out. */
|
|
30
|
+
export function normalizeResults(data) {
|
|
31
|
+
const rooms = {}
|
|
32
|
+
for (const [room, entry] of Object.entries(data?.rooms ?? {})) {
|
|
33
|
+
if (!ROOM_RE.test(room) || !Array.isArray(entry?.answers)) continue
|
|
34
|
+
rooms[room] = {
|
|
35
|
+
savedAt: typeof entry.savedAt === 'string' ? entry.savedAt : null,
|
|
36
|
+
closed: !!entry.closed,
|
|
37
|
+
answers: entry.answers.filter(answer => answer && Number.isInteger(answer.phone)).map(({ phone, at, value }) => ({ phone, at: typeof at === 'string' ? at : null, value: value ?? null })),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { version: 1, rooms }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One room's answers in, or (none) out; returns the new results. */
|
|
44
|
+
export function withRoom(results, room, { answers, closed = false, savedAt = new Date().toISOString() }) {
|
|
45
|
+
if (!ROOM_RE.test(room)) throw Object.assign(new Error('Room names use letters, digits, dots, hyphens and underscores'), { status: 400 })
|
|
46
|
+
const rooms = { ...results.rooms }
|
|
47
|
+
if (Array.isArray(answers) && answers.length) rooms[room] = { savedAt, closed: !!closed, answers }
|
|
48
|
+
else delete rooms[room]
|
|
49
|
+
return normalizeResults({ rooms })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const serializeResults = results => JSON.stringify(results, null, 2) + '\n'
|
|
53
|
+
|
|
54
|
+
const csvCell = value => {
|
|
55
|
+
const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
|
|
56
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** All answers as CSV: room, phone, time, answer (several picks joined by |). */
|
|
60
|
+
export function resultsCsv(results) {
|
|
61
|
+
const rows = [['room', 'phone', 'at', 'answer']]
|
|
62
|
+
for (const [room, entry] of Object.entries(results.rooms)) for (const answer of entry.answers) rows.push([room, answer.phone, answer.at, answer.value])
|
|
63
|
+
return rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
|
|
64
|
+
}
|
package/src/editor/sampleDeck.js
CHANGED
|
@@ -6,6 +6,26 @@
|
|
|
6
6
|
// over hills under a sun, for every layout that shows a picture.
|
|
7
7
|
export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
|
|
8
8
|
|
|
9
|
+
// The references the sample deck cites. Builds find them in a file beside the
|
|
10
|
+
// deck; the dev and edit servers read them from here where there is none.
|
|
11
|
+
export const SAMPLE_REFERENCES_FILE = 'sample-references.bib'
|
|
12
|
+
export const SAMPLE_REFERENCES = `@article{doe2024,
|
|
13
|
+
author = {Doe, Jane and Roe, Richard},
|
|
14
|
+
title = {Measuring how slides are read},
|
|
15
|
+
journal = {Journal of Sample Studies},
|
|
16
|
+
year = {2024},
|
|
17
|
+
volume = {12},
|
|
18
|
+
pages = {1--10}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@book{roe2023,
|
|
22
|
+
author = {Roe, Richard},
|
|
23
|
+
title = {Quotations and Their Sources},
|
|
24
|
+
publisher = {Example Press},
|
|
25
|
+
year = {2023}
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
|
|
9
29
|
// `picture` is where the slides find the picture: a file beside the deck, or
|
|
10
30
|
// a data URL where there is no such file.
|
|
11
31
|
export function sampleDeck(picture = './picture.svg') {
|
|
@@ -15,6 +35,7 @@ meta:
|
|
|
15
35
|
author: "A. Author"
|
|
16
36
|
organization: "Institute"
|
|
17
37
|
date: "2026-10-06"
|
|
38
|
+
bibliography: ${SAMPLE_REFERENCES_FILE}
|
|
18
39
|
---
|
|
19
40
|
|
|
20
41
|
---
|
|
@@ -51,10 +72,10 @@ alt: "Shapes"
|
|
|
51
72
|
---
|
|
52
73
|
# Lists, emphasis and code
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
1. A step with *emphasis* and **strong** words
|
|
76
|
+
2. A step with \`inline code\` and a [link](https://example.com)
|
|
77
|
+
- A nested point
|
|
78
|
+
- Another nested point
|
|
58
79
|
|
|
59
80
|
\`\`\`js
|
|
60
81
|
const total = values.reduce((sum, value) => sum + value, 0)
|
|
@@ -69,7 +90,7 @@ const total = values.reduce((sum, value) => sum + value, 0)
|
|
|
69
90
|
| Treatment | 5.1 | 0.6 |
|
|
70
91
|
| Follow-up | 5.0 | 0.7 |
|
|
71
92
|
|
|
72
|
-
The difference is $\\Delta = 0.9
|
|
93
|
+
The difference is $\\Delta = 0.9$ [@doe2024, p. 4], with a footnote.[^1]
|
|
73
94
|
|
|
74
95
|
[^1]: A source for the claim.
|
|
75
96
|
|
|
@@ -115,9 +136,9 @@ $$
|
|
|
115
136
|
|
|
116
137
|
+++
|
|
117
138
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
> A quotation set apart from the text.
|
|
140
|
+
|
|
141
|
+
As @roe2023 notes, a quote needs its source.
|
|
121
142
|
:::
|
|
122
143
|
|
|
123
144
|
---
|
|
@@ -158,6 +179,11 @@ alt: "Shapes"
|
|
|
158
179
|
overlay: true
|
|
159
180
|
---
|
|
160
181
|
# A picture that fills the slide.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
# References
|
|
185
|
+
|
|
186
|
+
<bibliography />
|
|
161
187
|
`.replaceAll('./picture.svg', picture)
|
|
162
188
|
}
|
|
163
189
|
|
|
@@ -5,7 +5,7 @@ import { marked, Parser } from 'marked'
|
|
|
5
5
|
import { registry } from '../runtime/registry'
|
|
6
6
|
import { InkLayer } from '../runtime/ink/InkLayer.jsx'
|
|
7
7
|
import themedSvgs from 'virtual:deck-svgs'
|
|
8
|
-
import { renderCitations, citationsHtml as referencesOf } from '../runtime/citations.js'
|
|
8
|
+
import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
|
|
9
9
|
|
|
10
10
|
// ─── Content extraction ────────────────────────────────────────────────────
|
|
11
11
|
|
|
@@ -188,11 +188,12 @@ export function prepareSlide(slide) {
|
|
|
188
188
|
.sort((a, b) => position(a[1]) - position(b[1]))
|
|
189
189
|
let separator = '\u0000mdeck-region\u0000'
|
|
190
190
|
while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
|
|
191
|
-
const
|
|
191
|
+
const bib = bibliographyOf(slide.deckConfig)
|
|
192
|
+
const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
|
|
192
193
|
const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
|
|
193
194
|
const pieces = processed.split(separator)
|
|
194
195
|
const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
|
|
195
|
-
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids)
|
|
196
|
+
const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
|
|
196
197
|
return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -33,3 +33,22 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
|
|
|
33
33
|
.answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
|
|
34
34
|
footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
|
|
35
35
|
.answer-follow { display: block; margin: 0 20px 24px; padding: 12px 16px; text-align: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; }
|
|
36
|
+
/* Maths in a question or option (MathML, see answer.js). */
|
|
37
|
+
.answer-rich math { font-size: 1.1em; }
|
|
38
|
+
/* buttons="letters|numbers": big keys; the options are on the slide. */
|
|
39
|
+
.answer-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 12px; }
|
|
40
|
+
.answer .answer-keys button { padding: 22px 0; text-align: center; font-size: 28px; }
|
|
41
|
+
/* `multiple`: a box that is ticked when picked. */
|
|
42
|
+
.answer-check { display: inline-block; width: 1em; height: 1em; margin-right: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
|
|
43
|
+
.answer button.is-picked .answer-check { opacity: 1; background: currentColor; box-shadow: inset 0 0 0 2px var(--accent); }
|
|
44
|
+
/* Closed: no more answers. */
|
|
45
|
+
.answer button:disabled, .answer textarea:disabled, .answer input:disabled { opacity: .45; cursor: default; }
|
|
46
|
+
.answer button.is-picked:disabled { opacity: .75; }
|
|
47
|
+
/* Once the right answer shows. */
|
|
48
|
+
.answer-verdict { margin: 4px 0 0; padding: 14px 16px; border-radius: 12px; background: color-mix(in srgb, var(--ink) 6%, transparent); font-weight: 600; }
|
|
49
|
+
.answer-verdict.is-right { background: color-mix(in srgb, #16a34a 16%, transparent); color: color-mix(in srgb, #16a34a 75%, var(--ink)); }
|
|
50
|
+
.answer-verdict.is-wrong { background: color-mix(in srgb, #dc2626 12%, transparent); }
|
|
51
|
+
/* <numeric>: a number field and Send. */
|
|
52
|
+
.answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
|
|
53
|
+
.answer-number input { width: 100%; min-width: 0; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; font-size: 22px; font-variant-numeric: tabular-nums; }
|
|
54
|
+
.answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
|