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
@@ -32,3 +32,23 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
32
32
  .answer-text button[type=submit] { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
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
+ .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)); }
@@ -7,13 +7,25 @@
7
7
  const code = location.pathname.split('/').filter(Boolean).pop()
8
8
  const root = document.getElementById('answer')
9
9
  const FALLBACK = {
10
- en: { waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…' },
11
- de: { waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…' },
10
+ en: {
11
+ waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…',
12
+ pickSeveral: 'Tap all that apply.', thanksSeveral: 'Thanks! You chose {choice}. Tap to change.', closed: 'Closed: no more answers.',
13
+ right: 'Right!', wrong: 'Not quite. The right answer: {answer}', solution: 'The right answer: {answer}',
14
+ number: 'A number', invalid: 'Enter a number, such as 0.5 or 1/2.', numberSent: 'Sent: {value}. Send another to change.',
15
+ },
16
+ de: {
17
+ waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…',
18
+ pickSeveral: 'Alle zutreffenden antippen.', thanksSeveral: 'Danke! Gewählt: {choice}. Zum Ändern antippen.', closed: 'Geschlossen: keine weiteren Antworten.',
19
+ right: 'Richtig!', wrong: 'Leider nicht. Richtig ist: {answer}', solution: 'Richtig ist: {answer}',
20
+ number: 'Eine Zahl', invalid: 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.', numberSent: 'Gesendet: {value}. Zum Ändern eine andere senden.',
21
+ },
12
22
  }
13
23
  let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
14
24
  let current = null
15
25
  let connected = false
16
26
  let status = ''
27
+ // What is typed into a number field, kept while the page redraws.
28
+ let draft = ''
17
29
 
18
30
  const me = (() => {
19
31
  try {
@@ -33,7 +45,7 @@
33
45
  if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
34
46
  else if (value != null && value !== false) node.setAttribute(key, value === true ? '' : value)
35
47
  }
36
- node.append(...children.flat().filter(child => child != null))
48
+ node.append(...children.flat(Infinity).filter(child => child != null))
37
49
  return node
38
50
  }
39
51
 
@@ -50,37 +62,146 @@
50
62
  for (const url of wanted) if (!document.querySelector(`link[data-theme-font][href="${CSS.escape(url)}"]`)) document.head.append(el('link', { rel: 'stylesheet', href: url, 'data-theme-font': true }))
51
63
  }
52
64
 
65
+ // A question or option drawn by the deck (Markdown and maths as MathML,
66
+ // src/components/inlineText.js), kept to text, emphasis and MathML. The
67
+ // nodes are moved out of the parsed document, never parsed again.
68
+ const SAFE_TAGS = new Set('em strong b i code del s sub sup br math semantics annotation mrow mi mo mn ms mtext mspace msup msub msubsup mfrac msqrt mroot mstyle mover munder munderover mtable mtr mtd mpadded mphantom menclose'.split(' '))
69
+ const SAFE_ATTRIBUTES = new Set('mathvariant display displaystyle scriptlevel stretchy fence separator symmetric largeop movablelimits accent accentunder lspace rspace minsize maxsize linethickness width height depth voffset columnalign rowalign columnspacing rowspacing notation encoding'.split(' '))
70
+ const DROPPED = new Set('script style template noscript iframe object embed svg textarea title'.split(' '))
71
+ function clean(node) {
72
+ for (const child of [...node.childNodes]) {
73
+ if (child.nodeType === Node.TEXT_NODE) continue
74
+ if (child.nodeType !== Node.ELEMENT_NODE || DROPPED.has(child.localName)) { child.remove(); continue }
75
+ clean(child)
76
+ if (!SAFE_TAGS.has(child.localName)) { child.replaceWith(...child.childNodes); continue }
77
+ for (const { name } of [...child.attributes]) if (!SAFE_ATTRIBUTES.has(name)) child.removeAttribute(name)
78
+ }
79
+ return node
80
+ }
81
+ const drawn = (html, text) => {
82
+ if (typeof html !== 'string') return text
83
+ const body = clean(new DOMParser().parseFromString(html, 'text/html').body)
84
+ return el('span', { class: 'answer-rich' }, ...body.childNodes)
85
+ }
86
+ // An option as this phone shows it: its key (A, B… with `buttons`), or drawn.
87
+ const shown = (activity, value) => {
88
+ const i = activity.options.indexOf(value)
89
+ return activity.keys?.[i] ?? drawn(activity.optionsHtml?.[i], value)
90
+ }
91
+ // A sentence with nodes put in for {name}; without {name}, the sentence.
92
+ const sentence = (text, name, part) => {
93
+ if (!text.includes(`{${name}}`)) return text
94
+ const [before, after] = text.split(`{${name}}`)
95
+ return [before, part, after]
96
+ }
97
+ const listed = parts => parts.flatMap((part, i) => i ? [', ', part] : [part])
98
+
99
+ // A number as src/components/Numeric.jsx reads it: 0.5, 0,5, −3, 1e-3, 1/2.
100
+ function parseNumber(text) {
101
+ const plain = String(text ?? '').trim().replace(/\s+/g, '').replace(/\u2212/g, '-').replace(/(\d),(\d)/g, '$1.$2')
102
+ const parts = plain.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(?:\/((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?$/i)
103
+ if (!parts) return null
104
+ const value = parts[2] ? Number(parts[1]) / Number(parts[2]) : Number(parts[1])
105
+ return Number.isFinite(value) ? value : null
106
+ }
107
+
108
+ // The activity's controls (src/components/activity.jsx), from its room's
109
+ // state: `closed`, and `revealed` with the `solution` once the right
110
+ // answer shows. Only the state, not everyone's answers (?only=state).
111
+ let controls = {}, controlRoom = null, controlSource = null
112
+ function followControls(room) {
113
+ if (room === controlRoom) return
114
+ controlSource?.close()
115
+ controlSource = null
116
+ controls = {}
117
+ controlRoom = room
118
+ if (!room) return
119
+ controlSource = new EventSource(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}/events?only=state`, base))
120
+ const take = event => {
121
+ const state = JSON.parse(event.data).state
122
+ controls = state && typeof state === 'object' ? state : {}
123
+ render()
124
+ }
125
+ controlSource.addEventListener('snapshot', take)
126
+ controlSource.addEventListener('state', take)
127
+ }
128
+ // Once the right answer shows: whether this phone's was right, or what it is.
129
+ const verdict = (answered, right, solution) => el('p', { class: `answer-verdict${answered ? (right ? ' is-right' : ' is-wrong') : ''}`, role: 'status' },
130
+ answered ? (right ? words.right : sentence(words.wrong, 'answer', solution)) : sentence(words.solution, 'answer', solution))
131
+ const statusLine = text => el('p', { class: 'answer-status', role: 'status' }, status || (controls.closed ? words.closed : text))
132
+
53
133
  async function answer(room, value) {
54
134
  const response = await fetch(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
55
135
  if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? String(response.status))
56
136
  }
57
137
 
58
138
  const FORMS = {
59
- // { question, options: [..] }: one of several answers, which can be changed.
139
+ // { question, options, optionsHtml | keys, multiple }: one answer, or with
140
+ // `multiple` several, which can be changed. With `keys` the buttons show
141
+ // A, B… (or 1, 2…) and the options are on the slide.
60
142
  choice(activity, room) {
61
- const picked = remembered(room)
143
+ const several = !!activity.multiple
144
+ const saved = remembered(room)
145
+ let picks = []
146
+ if (several) { try { picks = JSON.parse(saved ?? '[]') } catch {} }
147
+ else if (saved != null) picks = [saved]
148
+ picks = activity.options.filter(option => Array.isArray(picks) && picks.includes(option))
149
+ const vote = async next => {
150
+ try {
151
+ await answer(room, several ? next : next[0])
152
+ remember(room, several ? JSON.stringify(next) : next[0])
153
+ status = ''
154
+ } catch (error) { status = error.message }
155
+ render()
156
+ }
157
+ const solution = controls.revealed && Array.isArray(controls.solution) ? activity.options.filter(option => controls.solution.includes(option)) : null
158
+ const right = solution && (several ? picks.length === solution.length && picks.every(pick => solution.includes(pick)) : solution.includes(picks[0]))
62
159
  return [
63
- el('div', { class: 'answer-options' }, activity.options.map(option => el('button', {
64
- class: option === picked ? 'is-picked' : null, 'aria-pressed': String(option === picked),
65
- onclick: async () => {
66
- try { await answer(room, option); remember(room, option); status = fill(words.thanks, { choice: option }) } catch (error) { status = error.message }
67
- render()
68
- },
69
- }, option))),
70
- el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
160
+ el('div', { class: activity.keys ? 'answer-keys' : 'answer-options' }, activity.options.map(option => {
161
+ const on = picks.includes(option)
162
+ return el('button', {
163
+ class: on ? 'is-picked' : null, 'aria-pressed': String(on), disabled: !!controls.closed,
164
+ onclick: () => vote(several ? activity.options.filter(o => o === option ? !on : picks.includes(o)) : [option]),
165
+ }, several && !activity.keys ? el('span', { class: 'answer-check', 'aria-hidden': 'true' }) : null, shown(activity, option))
166
+ })),
167
+ statusLine(picks.length
168
+ ? sentence(several ? words.thanksSeveral : words.thanks, 'choice', listed(picks.map(pick => shown(activity, pick))))
169
+ : several ? words.pickSeveral : words.pick),
170
+ solution?.length ? verdict(picks.length > 0, right, listed(solution.map(option => shown(activity, option)))) : null,
171
+ ]
172
+ },
173
+ // { question, placeholder }: a number, typed; the latest one counts.
174
+ numeric(activity, room) {
175
+ const sent = remembered(room)
176
+ const input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', enterkeyhint: 'send', placeholder: activity.placeholder || words.number, disabled: !!controls.closed })
177
+ input.value = draft
178
+ input.addEventListener('input', () => { draft = input.value })
179
+ const form = el('form', { class: 'answer-number', onsubmit: async event => {
180
+ event.preventDefault()
181
+ const text = input.value.trim()
182
+ if (parseNumber(text) == null) { status = words.invalid; render(); return }
183
+ try { await answer(room, text); remember(room, text); draft = ''; status = '' } catch (error) { status = error.message }
184
+ render()
185
+ } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
186
+ const solution = controls.revealed && controls.solution && Number.isFinite(controls.solution.value) ? controls.solution : null
187
+ const right = solution && sent != null && Math.abs(parseNumber(sent) - solution.value) <= (solution.tolerance ?? 0)
188
+ return [
189
+ form,
190
+ statusLine(sent != null ? fill(words.numberSent, { value: sent }) : ''),
191
+ solution ? verdict(sent != null, right, drawn(solution.html, solution.text)) : null,
71
192
  ]
72
193
  },
73
194
  // { question, placeholder, maxLength }: short text, as often as people like.
74
195
  text(activity, room) {
75
- const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '' })
196
+ const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
76
197
  const form = el('form', { class: 'answer-text', onsubmit: async event => {
77
198
  event.preventDefault()
78
199
  const value = input.value.trim()
79
200
  if (!value) return
80
201
  try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
81
202
  render()
82
- } }, input, el('button', { type: 'submit' }, words.send))
83
- return [form, el('p', { class: 'answer-status', role: 'status' }, status)]
203
+ } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
204
+ return [form, statusLine('')]
84
205
  },
85
206
  // { question, min, max, minLabel, maxLabel }: a number on a scale.
86
207
  scale(activity, room) {
@@ -89,14 +210,14 @@
89
210
  for (let n = activity.min ?? 1; n <= (activity.max ?? 5); n++) steps.push(String(n))
90
211
  return [
91
212
  el('div', { class: 'answer-scale' }, steps.map(step => el('button', {
92
- class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked),
213
+ class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked), disabled: !!controls.closed,
93
214
  onclick: async () => {
94
- try { await answer(room, Number(step)); remember(room, step); status = fill(words.thanks, { choice: step }) } catch (error) { status = error.message }
215
+ try { await answer(room, Number(step)); remember(room, step); status = '' } catch (error) { status = error.message }
95
216
  render()
96
217
  },
97
218
  }, step))),
98
- (activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, activity.minLabel ?? ''), el('span', {}, activity.maxLabel ?? '')),
99
- el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
219
+ (activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, drawn(activity.minLabelHtml, activity.minLabel ?? '')), el('span', {}, drawn(activity.maxLabelHtml, activity.maxLabel ?? ''))),
220
+ statusLine(picked ? fill(words.thanks, { choice: picked }) : words.pick),
100
221
  ]
101
222
  },
102
223
  }
@@ -104,12 +225,15 @@
104
225
  function render() {
105
226
  const activity = current?.activity
106
227
  const form = activity && FORMS[activity.type]
107
- root.replaceChildren(
228
+ // Without the filter, replaceChildren() writes a missing part out as "null".
229
+ root.replaceChildren(...[
108
230
  form
109
- ? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
231
+ ? el('main', { class: 'answer' }, activity.question && el('h1', {}, drawn(activity.questionHtml, activity.question)), form(activity, current.room))
110
232
  : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
111
233
  current?.title ? el('footer', {}, current.title) : null,
112
- )
234
+ // The presenter's screen sends the link when other devices can follow the slides.
235
+ /^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
236
+ ].filter(Boolean))
113
237
  }
114
238
 
115
239
  function follow(state) {
@@ -117,7 +241,8 @@
117
241
  current = state
118
242
  if (state?.labels) words = { ...words, ...state.labels }
119
243
  applyLook(state?.look, state?.lang)
120
- if (state?.room !== previous) status = ''
244
+ if (state?.room !== previous) { status = ''; draft = '' }
245
+ followControls(state?.activity ? state.room : null)
121
246
  render()
122
247
  }
123
248
 
@@ -9,7 +9,8 @@
9
9
  // answers.
10
10
  import { useCallback, useEffect, useState } from 'preact/hooks'
11
11
  import { sessionCode } from './code.js'
12
- import { pairToken } from './pairing.js'
12
+ import { pairToken, pairHeaders } from './pairing.js'
13
+ import { keptAnswers, answersAsMessages } from '../core/results.js'
13
14
  import { devPath } from '../core/devPath.js'
14
15
  import { t, deckLanguage } from '../core/labels.js'
15
16
 
@@ -114,36 +115,82 @@ function joinUrl(info) {
114
115
  return info.network ? new URL(`__mdeck/live/${settings.code}`, info.network).href : null
115
116
  }
116
117
  const localJoinUrl = () => `${serverBase()}/${settings.code}`
118
+
119
+ // The link for following the slides on another device (?view=follow): this
120
+ // deck's page, where other devices reach both the page and the stage room
121
+ // that carries the presenter's position. That is `mdeck run --network`'s
122
+ // network address, or a deck hosted with a `server` setting, whose stage room
123
+ // is on that server. null when only this computer could follow; localFollowUrl
124
+ // then opens the follow view here, for trying it out.
125
+ const relayed = () => /^\/t\/[^/]+\/$/.test(devPath(''))
126
+ function followUrl(info) {
127
+ if (relayed()) return null
128
+ if (import.meta.env?.DEV || proxyControls) return info.network ? new URL(`${devPath('')}?view=follow`, info.network).href : null
129
+ return settings.server ? `${window.location.origin}${window.location.pathname}?view=follow` : null
130
+ }
131
+ const localFollowUrl = () => `${window.location.origin}${devPath('')}?view=follow`
117
132
  // Until the server has answered or failed, `known` is false and activities
118
133
  // show no join code, so a quick capture never shows a passing warning.
119
134
  const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
120
135
 
136
+ // The answers kept in the talk (<deck>.results.json), set by the runtime: a
137
+ // build shows them where no room server answers, instead of no answers.
138
+ let savedRooms = {}
139
+ export function setSavedResults(results) { savedRooms = results?.rooms ?? {} }
140
+
141
+ // While `mdeck run` runs, the presenter's screens keep each activity's
142
+ // answers in that file (src/build/resultsPlugin.js), a moment after the last
143
+ // one came in. Elsewhere there is nothing to keep them in.
144
+ const keeping = new Map()
145
+ export function keepAnswers(room, messages, { closed = false } = {}) {
146
+ if (!import.meta.env?.DEV) return
147
+ clearTimeout(keeping.get(room))
148
+ keeping.set(room, setTimeout(() => {
149
+ keeping.delete(room)
150
+ fetch(devPath('__mdeck/results'), { method: 'POST', headers: pairHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ room, closed, answers: keptAnswers(messages) }) }).catch(() => {})
151
+ }, 800))
152
+ }
153
+
121
154
  /**
122
155
  * Follow a room's answers on a slide.
123
156
  * messages every answer so far: { n, at, from, data: { value } }
124
157
  * joinUrl the deck's join link for the QR code, or null if phones cannot
125
158
  * reach the server; localJoinUrl works on this computer
126
159
  * canReset / reset() presenter only
160
+ * state the room's state, which only the presenter sets (setState):
161
+ * an activity's controls, such as closed or revealed
162
+ * present how many phones (and followers) are connected to the deck,
163
+ * or null before the server said
127
164
  * connected false until the live stream is open
128
165
  * offline no room server answered: a PDF, a file sent to readers or a
129
166
  * build hosted without one. Activities then show their question
130
- * as a record of the talk, without a join code.
167
+ * as a record of the talk, without a join code, and `messages`
168
+ * are the answers kept in the talk, if any.
131
169
  * Off the current slide the room keeps its last messages but disconnects.
132
170
  */
133
171
  export function useRoom(room) {
134
172
  const live = useActive(room)
135
173
  const [messages, setMessages] = useState([])
136
174
  const [connected, setConnected] = useState(false)
175
+ const [state, setRoomState] = useState(null)
176
+ const [present, setPresent] = useState(null)
137
177
  const [info, setInfo] = useState({})
138
178
 
139
179
  useEffect(() => { serverInfo().then(setInfo) }, [])
140
180
 
141
181
  useEffect(() => {
142
182
  if (!live || !room || typeof EventSource === 'undefined') return
143
- const source = new EventSource(`${roomPath(room)}/events`)
183
+ const source = new EventSource(`${roomPath(room)}/events?presence=1`)
144
184
  source.addEventListener('open', () => setConnected(true))
145
185
  source.addEventListener('error', () => setConnected(false))
146
- source.addEventListener('snapshot', event => setMessages(JSON.parse(event.data).messages))
186
+ source.addEventListener('snapshot', event => {
187
+ const snapshot = JSON.parse(event.data)
188
+ setMessages(snapshot.messages)
189
+ setRoomState(snapshot.state ?? null)
190
+ if (snapshot.presence) setPresent(snapshot.presence.phones)
191
+ })
192
+ source.addEventListener('state', event => setRoomState(JSON.parse(event.data).state ?? null))
193
+ source.addEventListener('presence', event => setPresent(JSON.parse(event.data).phones))
147
194
  source.addEventListener('message', event => { const message = JSON.parse(event.data); setMessages(list => list.some(m => m.n === message.n) ? list : [...list, message]) })
148
195
  source.addEventListener('reset', () => setMessages([]))
149
196
  return () => { source.close(); setConnected(false) }
@@ -154,7 +201,14 @@ export function useRoom(room) {
154
201
  if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not reset')
155
202
  }, [room])
156
203
 
157
- return { messages, reset, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
204
+ // Ordered like the session's state: the latest change of any screen wins.
205
+ const setState = useCallback(async next => {
206
+ const response = await fetch(`${controlRoomPath(room)}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { ...next, at: Date.now(), screen: SCREEN } }) })
207
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not change the room')
208
+ }, [room])
209
+
210
+ const kept = info.reachable === false ? savedRooms[room] : null
211
+ return { messages: kept ? answersAsMessages(kept.answers) : messages, reset, state, setState, present, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
158
212
  }
159
213
 
160
214
  /** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
@@ -164,6 +218,13 @@ export function useJoinLink() {
164
218
  return { joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
165
219
  }
166
220
 
221
+ /** The link for following the slides, for a slide that shows it (<qrcode follow />). */
222
+ export function useFollowLink() {
223
+ const [info, setInfo] = useState({})
224
+ useEffect(() => { serverInfo().then(setInfo) }, [])
225
+ return { joinUrl: followUrl(info), localJoinUrl: localFollowUrl(), code: settings.code, ...reach(info) }
226
+ }
227
+
167
228
  /** The latest answer of each device, e.g. to count votes that can be changed. */
168
229
  export function latestByDevice(messages) {
169
230
  const latest = new Map()
@@ -197,7 +258,12 @@ export function captureLook(doc = lookSource()) {
197
258
  }
198
259
 
199
260
  // The words the answer page shows, in the deck's language.
200
- const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent') })
261
+ const phoneWords = () => ({
262
+ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'),
263
+ pickSeveral: t('poll.pickSeveral'), thanksSeveral: t('poll.thanksSeveral'), several: t('poll.several'),
264
+ closed: t('respond.closed'), right: t('respond.right'), wrong: t('respond.wrong'), solution: t('respond.solution'),
265
+ number: t('numeric.placeholder'), invalid: t('numeric.invalid'), numberSent: t('numeric.sent'),
266
+ })
201
267
 
202
268
  // The presenter's screen announces the activity on the current slide and
203
269
  // repeats it every few seconds, so phones that join late and a server
@@ -236,7 +302,9 @@ async function sendCurrent() {
236
302
  if (!info.canReset) return report({ ok: false, reason: presenterKey() ? 'wrong-code' : 'no-code' })
237
303
  const look = captureLook()
238
304
  if (look) lastLook = look
239
- const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords() }
305
+ // Phones that answer can also follow the slides, where that works.
306
+ const follow = followUrl(info)
307
+ const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...(follow ? { follow } : {}) }
240
308
  try {
241
309
  const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
242
310
  const body = response.ok ? await response.json().catch(() => ({})) : {}
@@ -9,9 +9,13 @@ export function maskCode(source = '') {
9
9
  return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
10
10
  }
11
11
 
12
+ // Inside a tag: anything but its end, with quoted values whole, so a `>` in
13
+ // an attribute (question="Is $x > 0$?") does not end the tag.
14
+ const INSIDE = `(?:[^>"']|"[^"]*"|'[^']*')`
15
+
12
16
  // Every activity in a source, in order: [{ tag, room }].
13
17
  export function roomsIn(source = '') {
14
- return [...maskCode(source).matchAll(/<([a-z][a-z0-9-]*)\b[^>]*?\broom\s*=\s*(["'])([^"']+)\2/gi)].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
18
+ return [...maskCode(source).matchAll(new RegExp(`<([a-z][a-z0-9-]*)\\b${INSIDE}*?\\broom\\s*=\\s*(["'])([^"']+)\\2`, 'gi'))].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
15
19
  }
16
20
 
17
21
  // The rooms on one slide, from its body and its named regions (the body is
@@ -30,7 +34,7 @@ export function slideTitleFor(deck, room) {
30
34
  }
31
35
 
32
36
  export function findRoomTag(source, room) {
33
- const open = new RegExp(`<([a-z][a-z0-9-]*)\\b[^>]*?\\broom\\s*=\\s*(["'])${escape(room)}\\2[^>]*>`, 'i').exec(maskCode(source))
37
+ const open = new RegExp(`<([a-z][a-z0-9-]*)\\b${INSIDE}*?\\broom\\s*=\\s*(["'])${escape(room)}\\2${INSIDE}*>`, 'i').exec(maskCode(source))
34
38
  if (!open) return null
35
39
  if (open[0].endsWith('/>')) return open[0]
36
40
  const close = source.indexOf(`</${open[1]}>`, open.index + open[0].length)
package/src/live/rooms.js CHANGED
@@ -179,6 +179,7 @@ function reply(response, status, body) {
179
179
  // GET /rooms/<id>/events Server-Sent Events: snapshot, message, reset, state;
180
180
  // ?view=…&device=… counts this window as connected,
181
181
  // ?presence=1 adds who is connected to the session
182
+ // ?only=state sends the room's state alone
182
183
  // GET /rooms/<id>/presence who is connected to the session, once
183
184
  // POST /rooms/<id> { from, data } → { n }
184
185
  // POST /rooms/<id>/reset presenter only
@@ -211,9 +212,13 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
211
212
  const view = searchParams.get('view'), device = searchParams.get('device')
212
213
  const present = VIEWS.includes(view) ? { view, device: DEVICES.includes(device) ? device : 'computer' } : null
213
214
  const wantsPresence = searchParams.get('presence') === '1'
214
- write('snapshot', { messages: rooms.snapshot(id), state: rooms.state(id), ...(ink.length ? { ink } : {}), ...(wantsPresence ? { presence: rooms.presence(id) } : {}) })
215
+ // ?only=state: the room's state alone, as phones follow an activity's
216
+ // controls (closed, the right answer) without everyone's answers.
217
+ const onlyState = searchParams.get('only') === 'state'
218
+ write('snapshot', { messages: onlyState ? [] : rooms.snapshot(id), state: rooms.state(id), ...(ink.length ? { ink } : {}), ...(wantsPresence ? { presence: rooms.presence(id) } : {}) })
215
219
  const unsubscribe = rooms.subscribe(id, event => {
216
220
  if (event.type === 'presence') { if (wantsPresence) write('presence', event.presence); return }
221
+ if (onlyState && event.type !== 'state') return
217
222
  write(event.type, event.type === 'message' ? event.message : event.type === 'state' ? { state: event.state } : event.type === 'ink' ? { messages: event.messages } : {})
218
223
  }, present)
219
224
  const beat = setInterval(() => response.write(': ping\n\n'), heartbeatMs)
@@ -2,6 +2,7 @@ import { readerLink } from '../core/urls.js'
2
2
  import { h } from 'preact'
3
3
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
4
4
  import { marked } from 'marked'
5
+ import { renderCitations } from './citations.js'
5
6
  import { deckOutline } from '../core/outline.js'
6
7
  import { manifests, SlideRenderer } from '../layouts/renderSlide'
7
8
  import { loadTheme, THEME_METAS } from './themeLoader'
@@ -49,7 +50,7 @@ function ReadPage({ slide, index, total, deckConfig, width, height, scale, showN
49
50
  <SlideRenderer id={slide.id} regions={slide.regions} meta={slide.meta} content={slide.content} deckConfig={deckConfig} index={index} total={total} />
50
51
  </div>
51
52
  </div>
52
- {showNotes && notes && <div class="reader-page-notes" dangerouslySetInnerHTML={{ __html: marked.parse(notes) }} />}
53
+ {showNotes && notes && <div class="reader-page-notes" dangerouslySetInnerHTML={{ __html: marked.parse(renderCitations(notes).markdown) }} />}
53
54
  </article>
54
55
  }
55
56
 
@@ -0,0 +1,70 @@
1
+ import initial, { sample as initialSample } from 'virtual:bibliography'
2
+ import { scanCitations } from '../core/citations.js'
3
+ import { SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
4
+ import '../components/citation.css'
5
+
6
+ // The deck's formatted references (built by src/build/bibliography.js), or
7
+ // null without a `bibliography`. The editor receives new ones as the deck or
8
+ // its reference file changes.
9
+ let bibliography = initial
10
+ // The sample deck's, on the dev and edit servers (the design page shows it).
11
+ let sample = initialSample ?? null
12
+ let showsSample = false
13
+ const listeners = new Set()
14
+
15
+ // The references of the deck on screen: the sample deck's for the sample.
16
+ export const currentBibliography = () => showsSample && sample ? sample : bibliography
17
+ export function bibliographyOf(deckConfig) {
18
+ showsSample = [deckConfig?.bibliography].flat().includes(SAMPLE_REFERENCES_FILE)
19
+ return currentBibliography()
20
+ }
21
+ export function onBibliography(listener) {
22
+ listeners.add(listener)
23
+ return () => listeners.delete(listener)
24
+ }
25
+
26
+ if (import.meta.hot) {
27
+ import.meta.hot.accept('virtual:bibliography', next => {
28
+ if (!next) return
29
+ bibliography = next.default
30
+ sample = next.sample ?? null
31
+ for (const listener of listeners) listener(currentBibliography())
32
+ })
33
+ }
34
+
35
+ const escape = text => String(text).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
36
+
37
+ // A citation the build has not formatted: one typed in the editor since the
38
+ // last save is put together from its works; an unknown key is marked.
39
+ function fallback(cluster, bib) {
40
+ return cluster.items.map(({ id }) => bib?.singles[id]
41
+ ?? `<span class="citation-key${bib?.ids.includes(id) ? '' : ' citation-key--missing'}" title="${escape(bib ? `@${id} is not in the bibliography` : 'This deck has no bibliography')}">@${escape(id)}${bib?.ids.includes(id) ? '' : '?'}</span>`).join('; ')
42
+ }
43
+
44
+ // Puts each citation's formatted text in place; returns the keys the text
45
+ // cites, in order, for the slide's reference footer.
46
+ export function renderCitations(markdown, bib = currentBibliography()) {
47
+ if (!markdown || !markdown.includes('@')) return { markdown, ids: [] }
48
+ const known = new Set(bib?.ids ?? [])
49
+ const clusters = scanCitations(markdown, { isKey: id => known.has(id) })
50
+ if (!clusters.length) return { markdown, ids: [] }
51
+ let out = ''
52
+ let at = 0
53
+ const ids = []
54
+ for (const cluster of clusters) {
55
+ const formatted = bib?.clusters[cluster.raw]
56
+ const unknown = cluster.items.filter(item => !known.has(item.id))
57
+ const html = formatted == null ? fallback(cluster, bib) : unknown.length ? `${formatted}; ${fallback({ items: unknown }, bib)}` : formatted
58
+ const keys = cluster.items.map(item => item.id)
59
+ out += markdown.slice(at, cluster.start) + `<cite class="citation" data-cites="${escape(keys.join(' '))}">${html}</cite>`
60
+ at = cluster.end
61
+ for (const id of keys) if (bib?.entries[id] && !ids.includes(id)) ids.push(id)
62
+ }
63
+ return { markdown: out + markdown.slice(at), ids }
64
+ }
65
+
66
+ // The full references of `ids`, for a slide's footer.
67
+ export function citationsHtml(ids, bib = currentBibliography()) {
68
+ const entries = ids.map(id => bib?.entries[id]).filter(Boolean)
69
+ return entries.length ? `<div class="csl-bib-body">${entries.join('')}</div>` : ''
70
+ }
@@ -64,7 +64,7 @@
64
64
  */
65
65
 
66
66
  import { iconSvg } from '../core/icons.js'
67
- import { inkPaint } from '../core/ink.js'
67
+ import { inkPaint, snapAngle } from '../core/ink.js'
68
68
 
69
69
  (() => {
70
70
  const DESIGN_W_DEFAULT = 1920;
@@ -1025,8 +1025,11 @@ import { inkPaint } from '../core/ink.js'
1025
1025
  this._drawStraight(drawing, drawing.points[drawing.points.length - 1]);
1026
1026
  }
1027
1027
 
1028
- _drawStraight(drawing, [x, y]) {
1028
+ // The line's end snaps to steps of 15° (horizontal, vertical, 45° …)
1029
+ // when it is close to one, see snapAngle in core/ink.js.
1030
+ _drawStraight(drawing, end) {
1029
1031
  const [x0, y0] = drawing.points[0];
1032
+ const [x, y] = snapAngle([x0, y0], end);
1030
1033
  drawing.points = [[x0, y0, drawing.straight], [x, y, drawing.straight]];
1031
1034
  drawing.restart = true;
1032
1035
  this._drawLive(drawing.path, drawing.points, drawing.tool);
@@ -38,7 +38,9 @@ export function createEditorBridge({ parse, validate = () => [], loadTheme, setE
38
38
  }
39
39
 
40
40
  // Renders are serialized; while one is in flight only the newest request waits.
41
+ let latest = null
41
42
  function render(source, options = {}) {
43
+ latest = { source, options }
42
44
  if (running) { pending = { source, options }; return running }
43
45
  running = run(source, options).finally(() => {
44
46
  running = null
@@ -54,5 +56,9 @@ export function createEditorBridge({ parse, validate = () => [], loadTheme, setE
54
56
  return true
55
57
  }
56
58
 
57
- return { render, handleMessage, current: () => current }
59
+ // The newest request again, with its preview settings, e.g. after the
60
+ // references were reformatted.
61
+ const refresh = () => latest ? render(latest.source, latest.options) : null
62
+
63
+ return { render, refresh, handleMessage, current: () => current }
58
64
  }