mdeck 3.2.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +33 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +89 -6
  8. package/docs/site/content/audience.md +41 -4
  9. package/docs/site/content/commands.md +30 -4
  10. package/docs/site/content/drawing.md +1 -1
  11. package/docs/site/content/more-content.md +22 -0
  12. package/docs/site/content/sharing.md +1 -0
  13. package/docs/site/content/troubleshooting.md +1 -1
  14. package/docs/site/content/views.md +3 -1
  15. package/examples/citations/refs.bib +30 -0
  16. package/examples/citations/refs.yml +21 -0
  17. package/examples/citations/slides.md +43 -0
  18. package/examples/citations/slides.pdf +0 -0
  19. package/package.json +5 -1
  20. package/skills/write-slides/SKILL.md +7 -3
  21. package/src/build/bibliography.js +307 -0
  22. package/src/build/check.js +12 -0
  23. package/src/build/renderCheck.js +98 -0
  24. package/src/build/resultsPlugin.js +77 -0
  25. package/src/build/slidesPlugin.js +70 -7
  26. package/src/build/themeCheck.js +8 -5
  27. package/src/cli/args.js +22 -5
  28. package/src/cli/index.js +54 -12
  29. package/src/components/Bibliography.jsx +23 -0
  30. package/src/components/Numeric.jsx +59 -0
  31. package/src/components/Poll.jsx +59 -33
  32. package/src/components/QrCodeTag.jsx +11 -2
  33. package/src/components/Question.jsx +10 -6
  34. package/src/components/Scale.jsx +13 -8
  35. package/src/components/VideoPlayer.jsx +16 -0
  36. package/src/components/WordCloud.jsx +10 -6
  37. package/src/components/activity.jsx +90 -9
  38. package/src/components/citation.css +28 -0
  39. package/src/components/inlineText.js +40 -0
  40. package/src/components/numbers.js +39 -0
  41. package/src/components/poll.css +27 -7
  42. package/src/core/citations.js +108 -0
  43. package/src/core/editDeck.js +43 -0
  44. package/src/core/icons.js +2 -0
  45. package/src/core/ink.js +16 -0
  46. package/src/core/labels.js +48 -0
  47. package/src/core/parseSlides.js +1 -1
  48. package/src/core/results.js +64 -0
  49. package/src/core/urls.js +2 -2
  50. package/src/core/validateDeck.js +7 -1
  51. package/src/editor/sampleDeck.js +34 -8
  52. package/src/layouts/layoutApi.jsx +14 -3
  53. package/src/live/answer/answer.css +20 -0
  54. package/src/live/answer/answer.js +149 -24
  55. package/src/live/client.js +75 -7
  56. package/src/live/roomTag.js +6 -2
  57. package/src/live/rooms.js +6 -1
  58. package/src/runtime/ReaderView.jsx +2 -1
  59. package/src/runtime/citations.js +70 -0
  60. package/src/runtime/deck-stage.js +5 -2
  61. package/src/runtime/editorBridge.js +7 -1
  62. package/src/runtime/follow.js +89 -0
  63. package/src/runtime/ink/bus.js +7 -6
  64. package/src/runtime/ink/room.js +8 -0
  65. package/src/runtime/ink/select.js +4 -2
  66. package/src/runtime/main.jsx +22 -8
  67. package/src/runtime/registry.jsx +4 -0
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import { currentBibliography, onBibliography } from '../runtime/citations.js'
3
+
4
+ // <bibliography /> on a slide: the deck's reference list, the works it cites
5
+ // plus `nocite`, in the citation style's order. A long list goes over several
6
+ // slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
7
+
8
+ function slice(order, part) {
9
+ const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
10
+ if (!match) return order
11
+ const [index, count] = [Number(match[1]), Number(match[2])]
12
+ if (!count || index < 1 || index > count) return order
13
+ const size = Math.ceil(order.length / count)
14
+ return order.slice((index - 1) * size, index * size)
15
+ }
16
+
17
+ export default function Bibliography({ part }) {
18
+ const [bib, setBib] = useState(currentBibliography)
19
+ useEffect(() => onBibliography(setBib), [])
20
+ 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 html = slice(bib.order, part).map(id => bib.entries[id]).join('')
22
+ return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
23
+ }
@@ -0,0 +1,59 @@
1
+ import { h } from 'preact'
2
+ import { useRoom, latestByDevice } from '../live/client.js'
3
+ import { t } from '../core/labels.js'
4
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
5
+ import { slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
6
+ import { parseNumber, groupNumbers, isRightNumber } from './numbers.js'
7
+ import './poll.css'
8
+
9
+ // <numeric room="limit" question="What is $\lim_{x\to 0} \frac{\sin x}{x}$?" answer="1" tolerance="0.01" />
10
+ // Phones type a number (0.5, 0,5 or 1/2); each phone's latest one counts.
11
+ // The slide shows the most frequent answers as bars (`limit`, 6 by default)
12
+ // and the rest as Other. `answer` and `tolerance` (0 by default) say which
13
+ // answers are right: the tick outlines them, says how many were right, and
14
+ // tells each phone whether its answer was. Close, Reset and the results
15
+ // button work as for a poll.
16
+
17
+ export default function Numeric({ room = 'numeric', question = '', answer = '', tolerance = '', limit = '6', results = '', qr }) {
18
+ const right = parseNumber(answer)
19
+ const within = Math.max(0, parseNumber(tolerance) ?? 0)
20
+ const live = useRoom(room)
21
+ const controls = useControls(room, live, { results: results !== 'hidden', solution: solutionFor(answer, tolerance, phoneHtml) })
22
+ const texts = latestByDevice(controls.counted(live.messages)).map(message => message.data?.value).filter(value => parseNumber(value) != null)
23
+ const groups = groupNumbers(texts)
24
+ const shown = groups.slice(0, Math.max(1, Number(limit) || 6))
25
+ const other = groups.slice(shown.length).reduce((sum, group) => sum + group.n, 0)
26
+ const rows = [...shown.map(group => ({ ...group, correct: isRightNumber(group.value, right, within) })), ...(other ? [{ text: t('numeric.other'), n: other, other: true }] : [])]
27
+ const max = Math.max(1, ...rows.map(row => row.n))
28
+ const rightCount = right == null ? 0 : groups.filter(group => isRightNumber(group.value, right, within)).reduce((sum, group) => sum + group.n, 0)
29
+ const still = live.offline && !texts.length
30
+ return <div class={`poll poll--numeric${controls.revealed ? ' is-solved' : ''}${still ? ' is-static' : ''}${controls.results ? '' : ' is-results-hidden'}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
31
+ <div class="poll-main">
32
+ <ActivityQuestion text={question} />
33
+ <div class="poll-bars" role="list">
34
+ {rows.map(row => <div class={`poll-row${row.correct ? ' is-correct' : ''}${row.other ? ' is-other' : ''}`} role="listitem" key={row.other ? '' : row.value}>
35
+ <span class="poll-label">{row.text}</span>
36
+ <span class="poll-track"><span class="poll-fill" style={{ width: `${(row.n / max) * 100}%` }} /></span>
37
+ <span class="poll-count">{row.n}</span>
38
+ </div>)}
39
+ </div>
40
+ {!rows.length && !live.offline && <p class="question-empty">{t('question.empty')}</p>}
41
+ {right != null && controls.revealed && <p class="numeric-answer">{t('respond.solution', { answer: <span><span dangerouslySetInnerHTML={{ __html: slideHtml(answer) }} />{within > 0 && ` ± ${tolerance}`}</span> })} · {t('numeric.right', { n: rightCount })}</p>}
42
+ <ActivityFooter count={texts.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
43
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} results solve={right != null} />
44
+ </ActivityFooter>
45
+ </div>
46
+ <JoinCode room={room} {...live} qr={wantsQr(qr)} size={240} />
47
+ </div>
48
+ }
49
+
50
+ // What phones get once the right answer shows: the number, how far off still
51
+ // counts, and the answer as written, drawn.
52
+ function solutionFor(answer, tolerance, draw) {
53
+ const value = parseNumber(answer)
54
+ if (value == null) return null
55
+ const within = Math.max(parseNumber(tolerance) ?? 0, 1e-9 * Math.max(1, Math.abs(value)))
56
+ return { value, tolerance: within, text: String(answer), html: draw(String(answer)) }
57
+ }
58
+
59
+ Numeric.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'numeric', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '' })
@@ -1,8 +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'
4
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr, isSet } from './activity.jsx'
5
+ import { choicesOf, slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
6
6
  import './poll.css'
7
7
 
8
8
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
@@ -12,56 +12,82 @@ import './poll.css'
12
12
  // as described by Poll.phone. Each device has one vote and can change it; the
13
13
  // latest one counts.
14
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
+ //
15
22
  // `answer="Thai"` (several: "Thai|Pizza") marks the right answer: a button
16
- // with a tick outlines its label, bar and count in green. Showing it is an
17
- // event on the stage (`pollreveal`, { room, shown }), which the ink bus
18
- // passes to the audience window and other devices.
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).
31
+ //
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.
35
+
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
+ }
19
44
 
20
- const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
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])
21
48
 
22
- export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '' }) {
49
+ export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '', multiple, buttons }) {
23
50
  const choices = choicesOf(options)
51
+ const several = isSet(multiple)
52
+ const keys = keysFor(buttons, choices.length)
24
53
  const correct = choicesOf(answer).filter(choice => choices.includes(choice))
25
- const [shown, setShown] = useState(false)
26
- const ref = useRef()
27
- const stage = () => ref.current?.closest('deck-stage')
28
- useEffect(() => {
29
- const target = stage()
30
- if (!target || !correct.length) return
31
- const onReveal = event => { if (event.detail?.room === room) setShown(!!event.detail.shown) }
32
- target.addEventListener('pollreveal', onReveal)
33
- return () => target.removeEventListener('pollreveal', onReveal)
34
- }, [room, correct.length])
35
- const toggle = () => {
36
- const next = !shown
37
- setShown(next)
38
- stage()?.dispatchEvent(new CustomEvent('pollreveal', { detail: { room, shown: next, local: true } }))
39
- }
40
54
  const live = useRoom(room)
41
- const votes = latestByDevice(live.messages).map(message => message.data?.value).filter(vote => choices.includes(vote))
42
- const counts = choices.map(choice => votes.filter(vote => vote === choice).length)
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)
43
60
  const max = Math.max(1, ...counts)
44
61
  const code = wantsQr(qr)
45
62
  const still = live.offline && !votes.length
46
- return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}`} ref={ref}>
63
+ return <div class={`poll${controls.revealed ? ' is-solved' : ''}${still ? ' is-static' : ''}${controls.results ? '' : ' is-results-hidden'}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
47
64
  <div class="poll-main">
48
- {question && <p class="poll-question">{question}</p>}
65
+ <ActivityQuestion text={question} />
66
+ {several && <p class="poll-several">{t('poll.several')}</p>}
49
67
  <div class="poll-bars" role="list">
50
68
  {choices.map((choice, i) => <div class={`poll-row${correct.includes(choice) ? ' is-correct' : ''}`} role="listitem" key={choice}>
51
- <span class="poll-label">{choice}</span>
69
+ <span class="poll-label">{keys && <span class="poll-key">{keys[i]}</span>}<span dangerouslySetInnerHTML={{ __html: slideHtml(choice) }} /></span>
52
70
  <span class="poll-track"><span class="poll-fill" style={{ width: `${(counts[i] / max) * 100}%` }} /></span>
53
71
  <span class="poll-count">{counts[i]}</span>
54
72
  </div>)}
55
73
  </div>
56
- <ActivityFooter count={votes.length} {...live}>
57
- {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}>
58
- <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>
59
- </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} />
60
76
  </ActivityFooter>
61
77
  </div>
62
78
  <JoinCode room={room} {...live} qr={code} />
63
79
  </div>
64
80
  }
65
81
 
66
- // What the phones show while this poll is on screen.
67
- Poll.phone = ({ question, options }, { slideTitle = '' } = {}) => ({ type: 'choice', question: question || slideTitle, options: choicesOf(options) })
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,6 @@
1
1
  import { h } from 'preact'
2
2
  import QrCode from './QrCode.jsx'
3
- import { useJoinLink } from '../live/client.js'
3
+ import { useJoinLink, useFollowLink } from '../live/client.js'
4
4
  import { JoinCode } from './activity.jsx'
5
5
  import { t } from '../core/labels.js'
6
6
  import './poll.css'
@@ -9,6 +9,7 @@ import './poll.css'
9
9
  // <qrcode join />: the deck's join link for audience questions, with the
10
10
  // short link below it, so a slide early in the talk can invite everyone once
11
11
  // and the questions after it can leave out their own code (`qr="false"`).
12
+ // <qrcode follow />: the link for following the slides on a phone or laptop.
12
13
 
13
14
  function JoinQr({ size }) {
14
15
  const link = useJoinLink()
@@ -18,10 +19,18 @@ function JoinQr({ size }) {
18
19
  </div>
19
20
  }
20
21
 
22
+ function FollowQr({ size }) {
23
+ const link = useFollowLink()
24
+ return <div class="poll-join-slide">
25
+ <JoinCode {...link} size={Number(size) || 420} label={t('follow.scan')} tryHere={t('follow.tryHere')} />
26
+ </div>
27
+ }
28
+
21
29
  // A bare `join` attribute arrives as "", so only "false" and the like turn it off.
22
30
  const isOn = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
23
31
 
24
- export default function QrCodeTag({ url, join, size }) {
32
+ export default function QrCodeTag({ url, join, follow, size }) {
25
33
  if (isOn(join)) return <JoinQr size={size} />
34
+ if (isOn(follow)) return <FollowQr size={size} />
26
35
  return <QrCode url={url} size={size ?? '200'} />
27
36
  }
@@ -1,6 +1,7 @@
1
1
  import { h } from 'preact'
2
2
  import { useRoom } from '../live/client.js'
3
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
3
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
4
+ import { phoneQuestion } from './inlineText.js'
4
5
  import { t } from '../core/labels.js'
5
6
  import './poll.css'
6
7
 
@@ -10,19 +11,22 @@ import './poll.css'
10
11
 
11
12
  export default function Question({ room = 'question', question = '', limit = '8', qr }) {
12
13
  const live = useRoom(room)
13
- const answers = live.messages.map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
14
+ const controls = useControls(room, live)
15
+ const answers = controls.counted(live.messages).map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
14
16
  const shown = answers.slice(-Math.max(1, Number(limit) || 8)).reverse()
15
17
  const code = wantsQr(qr)
16
- return <div class="poll poll--question">
18
+ return <div class={`poll poll--question${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
17
19
  <div class="poll-main">
18
- {question && <p class="poll-question">{question}</p>}
20
+ <ActivityQuestion text={question} />
19
21
  {shown.length
20
22
  ? <ul class="question-cards">{shown.map(answer => <li key={answer.n}>{answer.text}</li>)}</ul>
21
23
  : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
22
- <ActivityFooter count={answers.length} {...live} />
24
+ <ActivityFooter count={answers.length} {...live} reset={controls.reset} closed={controls.closed}>
25
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
26
+ </ActivityFooter>
23
27
  </div>
24
28
  <JoinCode room={room} {...live} qr={code} size={240} />
25
29
  </div>
26
30
  }
27
31
 
28
- Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 200 })
32
+ Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 200 })
@@ -1,12 +1,14 @@
1
1
  import { h } from 'preact'
2
2
  import { useRoom, latestByDevice } from '../live/client.js'
3
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
3
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
4
+ import { slideHtml, phoneHtml, phoneQuestion } from './inlineText.js'
4
5
  import { t } from '../core/labels.js'
5
6
  import './poll.css'
6
7
 
7
8
  // <scale room="pace" question="How is the pace?" min="1" max="5" low="Too slow" high="Too fast" />
8
9
  // Phones pick a number; each device's latest answer counts. The slide shows
9
- // how many chose each number, and the average.
10
+ // how many chose each number, and the average. The question and the labels
11
+ // are Markdown with $…$ maths, like a poll's.
10
12
 
11
13
  const bounds = ({ min, max }) => {
12
14
  const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
@@ -18,14 +20,15 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
18
20
  const [min, max] = bounds(rest)
19
21
  const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
20
22
  const live = useRoom(room)
21
- const values = latestByDevice(live.messages).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
23
+ const controls = useControls(room, live)
24
+ const values = latestByDevice(controls.counted(live.messages)).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
22
25
  const counts = steps.map(step => values.filter(value => value === step).length)
23
26
  const most = Math.max(1, ...counts)
24
27
  const average = values.length ? (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1) : null
25
28
  const code = wantsQr(qr)
26
- return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}`}>
29
+ return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
27
30
  <div class="poll-main">
28
- {question && <p class="poll-question">{question}</p>}
31
+ <ActivityQuestion text={question} />
29
32
  <div class="scale-columns" role="list">
30
33
  {steps.map((step, i) => <div class="scale-column" role="listitem" key={step}>
31
34
  <span class="scale-count">{counts[i]}</span>
@@ -33,8 +36,10 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
33
36
  <span class="scale-step">{step}</span>
34
37
  </div>)}
35
38
  </div>
36
- {(low || high) && <p class="scale-labels"><span>{low}</span><span>{high}</span></p>}
37
- <ActivityFooter count={values.length} {...live} />
39
+ {(low || high) && <p class="scale-labels"><span dangerouslySetInnerHTML={{ __html: slideHtml(low) }} /><span dangerouslySetInnerHTML={{ __html: slideHtml(high) }} /></p>}
40
+ <ActivityFooter count={values.length} {...live} perPhone reset={controls.reset} closed={controls.closed}>
41
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
42
+ </ActivityFooter>
38
43
  {average && <p class="scale-average">{t('scale.average', { n: average })}</p>}
39
44
  </div>
40
45
  <JoinCode room={room} {...live} qr={code} size={240} />
@@ -43,5 +48,5 @@ export default function Scale({ room = 'scale', question = '', low = '', high =
43
48
 
44
49
  Scale.phone = ({ question, low, high, ...rest }, { slideTitle = '' } = {}) => {
45
50
  const [min, max] = bounds(rest)
46
- return { type: 'scale', question: question || slideTitle, min, max, minLabel: low ?? '', maxLabel: high ?? '' }
51
+ return { type: 'scale', ...phoneQuestion(question, slideTitle), min, max, minLabel: low ?? '', maxLabel: high ?? '', ...(low ? { minLabelHtml: phoneHtml(low) } : {}), ...(high ? { maxLabelHtml: phoneHtml(high) } : {}) }
47
52
  }
@@ -77,6 +77,22 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
77
77
  return () => stage.removeEventListener('slidechange', onSlideChange)
78
78
  }, [play, url])
79
79
 
80
+ // Web video click mode: slides stay mounted, so a playing video would go on
81
+ // (with sound) on the next slide. Leaving the slide unloads the player;
82
+ // coming back shows it ready to play again.
83
+ useEffect(() => {
84
+ if (play !== 'click' || !url) return
85
+ const stage = document.querySelector('deck-stage')
86
+ if (!stage) return
87
+ const embedUrl = getEmbedUrl(url, false)
88
+ function onSlideChange(e) {
89
+ const active = e.detail.slide?.contains(containerRef.current)
90
+ setIframeSrc(current => active ? (current ?? embedUrl) : null)
91
+ }
92
+ stage.addEventListener('slidechange', onSlideChange)
93
+ return () => stage.removeEventListener('slidechange', onSlideChange)
94
+ }, [play, url])
95
+
80
96
  const fixedAspect = typeof aspect === 'string' && aspect.trim() !== ''
81
97
 
82
98
  return (
@@ -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 all = countWords(live.messages)
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="poll poll--cloud">
90
+ return <div class={`poll poll--cloud${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
89
91
  <div class="poll-main">
90
- {question && <p class="poll-question">{question}</p>}
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: question || slideTitle, placeholder: placeholder ?? '', maxLength: 40 })
110
+ WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40 })
@@ -1,20 +1,30 @@
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 and the line with the number of
7
- // answers, the live dot and Reset.
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
- export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan') }) {
25
+ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
16
26
  if (!qr || offline || (!known && !joinUrl)) return null
17
- if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{t('poll.tryHere')}</a></p>
27
+ if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{tryHere}</a></p>
18
28
  return <a class="poll-join" href={joinUrl} target="_blank" rel="noopener" data-room={room}>
19
29
  <QrCode url={joinUrl} size={String(size)} />
20
30
  <span>{label}</span>
@@ -22,14 +32,85 @@ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = tru
22
32
  </a>
23
33
  }
24
34
 
25
- /** Number of answers, whether the room is live, and Reset for the presenter; `children` go at the end. */
26
- export function ActivityFooter({ count, connected, canReset, reset, offline, children }) {
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
- <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />
31
- {count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
32
- {canReset && <button class="poll-reset" disabled={count === 0} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
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,28 @@
1
+ /* ── Citations and references (src/runtime/citations.js) ────────────────── */
2
+ .citation { font-style: normal; }
3
+ .citation-key--missing {
4
+ color: var(--accent);
5
+ text-decoration: underline wavy var(--accent);
6
+ text-underline-offset: 4px;
7
+ }
8
+
9
+ /* A slide's references sit with its footnotes, above the footer. */
10
+ .slide-citations {
11
+ font-size: var(--fs-small);
12
+ color: var(--muted);
13
+ border-top: 1px solid var(--rule);
14
+ padding-top: 20px;
15
+ margin-top: auto;
16
+ }
17
+ .slide-footnotes + .slide-citations { margin-top: 0; border-top: 0; padding-top: 8px; }
18
+ .slide-citations .csl-bib-body { display: flex; flex-direction: column; gap: 4px; }
19
+
20
+ /* Entries hang; numeric styles put the number in a margin column. */
21
+ .csl-entry { line-height: 1.4; padding-left: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
22
+ .csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-left: 0; text-indent: 0; }
23
+ .csl-left-margin { color: var(--muted); font-variant-numeric: tabular-nums; }
24
+ .csl-entry a { color: inherit; text-decoration: none; }
25
+
26
+ /* <bibliography />: the reference list on its slide. */
27
+ .bibliography { display: flex; flex-direction: column; gap: 0.6em; font-size: var(--fs-small); color: var(--ink-soft); }
28
+ .bibliography-hint { color: var(--muted); font-style: italic; }
@@ -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
+ }