mdeck 3.3.0 → 3.5.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 +25 -0
- package/README.md +1 -1
- package/assets/base.css +23 -2
- package/assets/extensions/palettes/cobalt/extension.toml +30 -0
- package/assets/extensions/palettes/greyscale/extension.toml +57 -0
- package/assets/extensions/themes/academic/styles.css +18 -5
- package/assets/extensions/themes/glass/extension.toml +59 -0
- package/assets/extensions/themes/glass/styles.css +754 -0
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/assets/extensions/themes/plain/extension.toml +58 -0
- package/assets/extensions/themes/plain/styles.css +471 -0
- package/assets/extensions/themes/work/extension.toml +57 -0
- package/assets/extensions/themes/work/styles.css +512 -0
- package/docs/reference/authoring.md +50 -10
- package/docs/reference/palettes.md +4 -3
- package/docs/reference/themes.md +5 -3
- package/docs/site/content/appearance.md +24 -10
- package/docs/site/content/audience.md +49 -4
- package/docs/site/content/commands.md +2 -0
- package/docs/site/content/theme-repository.md +7 -7
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/glass-dark.webp +0 -0
- package/docs/site/images/themes/glass-light.webp +0 -0
- package/docs/site/images/themes/glass.webp +0 -0
- package/docs/site/images/themes/minimal-dark.webp +0 -0
- package/docs/site/images/themes/minimal-light.webp +0 -0
- package/docs/site/images/themes/minimal.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/plain-dark.webp +0 -0
- package/docs/site/images/themes/plain-light.webp +0 -0
- package/docs/site/images/themes/plain.webp +0 -0
- package/docs/site/images/themes/work-dark.webp +0 -0
- package/docs/site/images/themes/work-light.webp +0 -0
- package/docs/site/images/themes/work.webp +0 -0
- package/examples/showcase/slides.md +2 -2
- package/package.json +4 -1
- package/skills/write-slides/SKILL.md +2 -2
- package/src/build/bibliography.js +29 -11
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +42 -18
- 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 +72 -57
- 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 +63 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +26 -13
- package/src/core/editDeck.js +5 -6
- package/src/core/icons.js +2 -0
- package/src/core/labels.js +32 -0
- package/src/core/pollList.js +66 -0
- package/src/core/results.js +64 -0
- package/src/core/tags.js +195 -0
- package/src/editor/sampleDeck.js +34 -8
- package/src/extensions/discover.js +2 -2
- package/src/extensions/moved.js +4 -4
- package/src/layouts/layoutApi.jsx +24 -13
- 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 +11 -15
- 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 +5 -2
- package/src/runtime/markedSetup.js +3 -0
- package/src/runtime/registry.jsx +2 -0
- package/assets/extensions/palettes/neon/extension.toml +0 -30
- package/assets/extensions/themes/aurora/extension.toml +0 -40
- package/assets/extensions/themes/aurora/styles.css +0 -644
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
|
@@ -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: {
|
|
11
|
-
|
|
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
|
|
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
|
|
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 =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
},
|
|
69
|
-
}
|
|
70
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,14 +225,15 @@
|
|
|
104
225
|
function render() {
|
|
105
226
|
const activity = current?.activity
|
|
106
227
|
const form = activity && FORMS[activity.type]
|
|
107
|
-
|
|
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.
|
|
113
235
|
/^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
|
|
114
|
-
)
|
|
236
|
+
].filter(Boolean))
|
|
115
237
|
}
|
|
116
238
|
|
|
117
239
|
function follow(state) {
|
|
@@ -119,7 +241,8 @@
|
|
|
119
241
|
current = state
|
|
120
242
|
if (state?.labels) words = { ...words, ...state.labels }
|
|
121
243
|
applyLook(state?.look, state?.lang)
|
|
122
|
-
if (state?.room !== previous) status = ''
|
|
244
|
+
if (state?.room !== previous) { status = ''; draft = '' }
|
|
245
|
+
followControls(state?.activity ? state.room : null)
|
|
123
246
|
render()
|
|
124
247
|
}
|
|
125
248
|
|
package/src/live/client.js
CHANGED
|
@@ -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
|
|
|
@@ -132,32 +133,64 @@ const localFollowUrl = () => `${window.location.origin}${devPath('')}?view=follo
|
|
|
132
133
|
// show no join code, so a quick capture never shows a passing warning.
|
|
133
134
|
const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
|
|
134
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
|
+
|
|
135
154
|
/**
|
|
136
155
|
* Follow a room's answers on a slide.
|
|
137
156
|
* messages every answer so far: { n, at, from, data: { value } }
|
|
138
157
|
* joinUrl the deck's join link for the QR code, or null if phones cannot
|
|
139
158
|
* reach the server; localJoinUrl works on this computer
|
|
140
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
|
|
141
164
|
* connected false until the live stream is open
|
|
142
165
|
* offline no room server answered: a PDF, a file sent to readers or a
|
|
143
166
|
* build hosted without one. Activities then show their question
|
|
144
|
-
* 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.
|
|
145
169
|
* Off the current slide the room keeps its last messages but disconnects.
|
|
146
170
|
*/
|
|
147
171
|
export function useRoom(room) {
|
|
148
172
|
const live = useActive(room)
|
|
149
173
|
const [messages, setMessages] = useState([])
|
|
150
174
|
const [connected, setConnected] = useState(false)
|
|
175
|
+
const [state, setRoomState] = useState(null)
|
|
176
|
+
const [present, setPresent] = useState(null)
|
|
151
177
|
const [info, setInfo] = useState({})
|
|
152
178
|
|
|
153
179
|
useEffect(() => { serverInfo().then(setInfo) }, [])
|
|
154
180
|
|
|
155
181
|
useEffect(() => {
|
|
156
182
|
if (!live || !room || typeof EventSource === 'undefined') return
|
|
157
|
-
const source = new EventSource(`${roomPath(room)}/events`)
|
|
183
|
+
const source = new EventSource(`${roomPath(room)}/events?presence=1`)
|
|
158
184
|
source.addEventListener('open', () => setConnected(true))
|
|
159
185
|
source.addEventListener('error', () => setConnected(false))
|
|
160
|
-
source.addEventListener('snapshot', event =>
|
|
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))
|
|
161
194
|
source.addEventListener('message', event => { const message = JSON.parse(event.data); setMessages(list => list.some(m => m.n === message.n) ? list : [...list, message]) })
|
|
162
195
|
source.addEventListener('reset', () => setMessages([]))
|
|
163
196
|
return () => { source.close(); setConnected(false) }
|
|
@@ -168,7 +201,14 @@ export function useRoom(room) {
|
|
|
168
201
|
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not reset')
|
|
169
202
|
}, [room])
|
|
170
203
|
|
|
171
|
-
|
|
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) }
|
|
172
212
|
}
|
|
173
213
|
|
|
174
214
|
/** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
|
|
@@ -218,7 +258,12 @@ export function captureLook(doc = lookSource()) {
|
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
// The words the answer page shows, in the deck's language.
|
|
221
|
-
const phoneWords = () => ({
|
|
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
|
+
})
|
|
222
267
|
|
|
223
268
|
// The presenter's screen announces the activity on the current slide and
|
|
224
269
|
// repeats it every few seconds, so phones that join late and a server
|
package/src/live/roomTag.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
// Finds the component tag a phone should see: the one whose `room` attribute
|
|
2
2
|
// matches, with its content if it has any.
|
|
3
|
-
|
|
3
|
+
import { tagsIn, closingTagOf } from '../core/tags.js'
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
const blank = text => text.replace(/[^\n]/g, ' ')
|
|
8
|
-
export function maskCode(source = '') {
|
|
9
|
-
return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Every activity in a source, in order: [{ tag, room }].
|
|
5
|
+
// Every activity in a source, in order: [{ tag, room }]. Tags in code are
|
|
6
|
+
// not activities (src/core/tags.js).
|
|
13
7
|
export function roomsIn(source = '') {
|
|
14
|
-
return
|
|
8
|
+
return tagsIn(source).filter(tag => !tag.closing && tag.attrs.room).map(tag => ({ tag: tag.name, room: tag.attrs.room }))
|
|
15
9
|
}
|
|
16
10
|
|
|
17
11
|
// The rooms on one slide, from its body and its named regions (the body is
|
|
@@ -30,9 +24,11 @@ export function slideTitleFor(deck, room) {
|
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export function findRoomTag(source, room) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
const text = String(source ?? '').replace(/\r\n?/g, '\n')
|
|
28
|
+
const tags = tagsIn(text)
|
|
29
|
+
const at = tags.findIndex(tag => !tag.closing && tag.attrs.room === room)
|
|
30
|
+
if (at < 0) return null
|
|
31
|
+
const open = tags[at]
|
|
32
|
+
const close = open.start == null ? null : closingTagOf(tags, at)
|
|
33
|
+
return close?.end == null ? open.raw : text.slice(open.start, close.end)
|
|
38
34
|
}
|
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
|
-
|
|
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)
|
package/src/runtime/citations.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import initial from 'virtual:bibliography'
|
|
1
|
+
import initial, { sample as initialSample } from 'virtual:bibliography'
|
|
2
2
|
import { scanCitations } from '../core/citations.js'
|
|
3
|
+
import { SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
|
|
3
4
|
import '../components/citation.css'
|
|
4
5
|
|
|
5
6
|
// The deck's formatted references (built by src/build/bibliography.js), or
|
|
6
7
|
// null without a `bibliography`. The editor receives new ones as the deck or
|
|
7
8
|
// its reference file changes.
|
|
8
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
|
|
9
13
|
const listeners = new Set()
|
|
10
14
|
|
|
11
|
-
|
|
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
|
+
}
|
|
12
21
|
export function onBibliography(listener) {
|
|
13
22
|
listeners.add(listener)
|
|
14
23
|
return () => listeners.delete(listener)
|
|
@@ -18,7 +27,8 @@ if (import.meta.hot) {
|
|
|
18
27
|
import.meta.hot.accept('virtual:bibliography', next => {
|
|
19
28
|
if (!next) return
|
|
20
29
|
bibliography = next.default
|
|
21
|
-
|
|
30
|
+
sample = next.sample ?? null
|
|
31
|
+
for (const listener of listeners) listener(currentBibliography())
|
|
22
32
|
})
|
|
23
33
|
}
|
|
24
34
|
|
|
@@ -33,7 +43,7 @@ function fallback(cluster, bib) {
|
|
|
33
43
|
|
|
34
44
|
// Puts each citation's formatted text in place; returns the keys the text
|
|
35
45
|
// cites, in order, for the slide's reference footer.
|
|
36
|
-
export function renderCitations(markdown, bib =
|
|
46
|
+
export function renderCitations(markdown, bib = currentBibliography()) {
|
|
37
47
|
if (!markdown || !markdown.includes('@')) return { markdown, ids: [] }
|
|
38
48
|
const known = new Set(bib?.ids ?? [])
|
|
39
49
|
const clusters = scanCitations(markdown, { isKey: id => known.has(id) })
|
|
@@ -54,7 +64,7 @@ export function renderCitations(markdown, bib = bibliography) {
|
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// The full references of `ids`, for a slide's footer.
|
|
57
|
-
export function citationsHtml(ids, bib =
|
|
67
|
+
export function citationsHtml(ids, bib = currentBibliography()) {
|
|
58
68
|
const entries = ids.map(id => bib?.entries[id]).filter(Boolean)
|
|
59
69
|
return entries.length ? `<div class="csl-bib-body">${entries.join('')}</div>` : ''
|
|
60
70
|
}
|
package/src/runtime/ink/bus.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// a hovering pen moves, at most every ~50 ms
|
|
13
13
|
// { type: 'zoom', key, slideId, scale, x, y } the part of the slide this
|
|
14
14
|
// window shows (stage zoom), at most every ~50 ms
|
|
15
|
-
// { type: '
|
|
16
|
-
//
|
|
15
|
+
// { type: 'controls', key, room, controls } an activity closed or opened,
|
|
16
|
+
// its right answer or results shown or hidden (components/activity.jsx)
|
|
17
17
|
// { type: 'op', op } a change of the saved ink (applyOp)
|
|
18
18
|
// Keys start with the sending window's id, so strokes never mix.
|
|
19
19
|
import { changeInk, renameSlide } from './store.js'
|
|
@@ -50,10 +50,8 @@ export function createInkBus(stage, transports) {
|
|
|
50
50
|
if (message.from <= known.points.length) known.points = [...known.points.slice(0, message.from), ...message.points]
|
|
51
51
|
incoming.set(message.key, known)
|
|
52
52
|
stage.liveStroke(message.key, known)
|
|
53
|
-
} else if (message.type === '
|
|
54
|
-
stage.dispatchEvent(new CustomEvent('
|
|
55
|
-
} else if (message.type === 'results') {
|
|
56
|
-
stage.dispatchEvent(new CustomEvent('pollresults', { detail: { room: message.room, shown: !!message.shown } }))
|
|
53
|
+
} else if (message.type === 'controls') {
|
|
54
|
+
stage.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room: message.room, controls: message.controls } }))
|
|
57
55
|
} else if (message.type === 'zoom') {
|
|
58
56
|
if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
|
|
59
57
|
} else if (message.type === 'dot') {
|
|
@@ -102,13 +100,9 @@ export function createInkBus(stage, transports) {
|
|
|
102
100
|
dotTimer ??= setTimeout(sendDot, DOT_MS)
|
|
103
101
|
})
|
|
104
102
|
|
|
105
|
-
//
|
|
106
|
-
stage.addEventListener('
|
|
107
|
-
if (detail?.local) send({ type: '
|
|
108
|
-
})
|
|
109
|
-
// A poll's results shown or hidden here: the others follow.
|
|
110
|
-
stage.addEventListener('pollresults', ({ detail }) => {
|
|
111
|
-
if (detail?.local) send({ type: 'results', key: `${me}:results`, room: detail.room, shown: !!detail.shown })
|
|
103
|
+
// An activity's controls changed here: the others follow.
|
|
104
|
+
stage.addEventListener('pollcontrols', ({ detail }) => {
|
|
105
|
+
if (detail?.local) send({ type: 'controls', key: `${me}:controls`, room: detail.room, controls: detail.controls })
|
|
112
106
|
})
|
|
113
107
|
|
|
114
108
|
// The zoom: the latest view, not every step of a pinch.
|
package/src/runtime/main.jsx
CHANGED
|
@@ -13,7 +13,7 @@ import { S, PaletteSwatches } from './chrome.jsx'
|
|
|
13
13
|
import { SlideErrorBoundary } from './SlideErrorBoundary.jsx'
|
|
14
14
|
import { createEditorBridge } from './editorBridge.js'
|
|
15
15
|
import { ReaderView } from './ReaderView.jsx'
|
|
16
|
-
import { configureLive, announce, setLookSource, actAsPresenter, useSteering } from '../live/client.js'
|
|
16
|
+
import { configureLive, announce, setLookSource, actAsPresenter, useSteering, setSavedResults } from '../live/client.js'
|
|
17
17
|
import { registry } from './registry'
|
|
18
18
|
import { followActiveRooms } from '../live/follow.js'
|
|
19
19
|
import { createOrder } from './syncOrder.js'
|
|
@@ -24,7 +24,9 @@ import { startFollowing } from './follow.js'
|
|
|
24
24
|
import { claimPairing } from '../live/pairing.js'
|
|
25
25
|
import { strokePath } from '../core/ink.js'
|
|
26
26
|
import { inkFileName } from 'virtual:deck-ink'
|
|
27
|
+
import savedResults from 'virtual:deck-results'
|
|
27
28
|
import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
|
|
29
|
+
import { expandPollLists } from '../core/pollList.js'
|
|
28
30
|
import './reader.css'
|
|
29
31
|
import { SlideRenderer, manifests } from '../layouts/renderSlide'
|
|
30
32
|
import { setCalloutLabels } from './markedSetup'
|
|
@@ -80,7 +82,7 @@ function activityOn(deck, slide) {
|
|
|
80
82
|
const room = roomsOnSlide(slide)[0]
|
|
81
83
|
if (!room) return { room: null, activity: null }
|
|
82
84
|
const tag = findRoomTag(slide.content ?? '', room) ?? Object.values(slide.regions ?? {}).map(region => findRoomTag(region.content ?? '', room)).find(Boolean)
|
|
83
|
-
const element = tag && new DOMParser().parseFromString(tag, 'text/html').body.firstElementChild
|
|
85
|
+
const element = tag && new DOMParser().parseFromString(expandPollLists(tag), 'text/html').body.firstElementChild
|
|
84
86
|
const component = element && registry[element.localName]
|
|
85
87
|
if (typeof component?.phone !== 'function') return { room, activity: null }
|
|
86
88
|
const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
|
|
@@ -693,6 +695,7 @@ async function init() {
|
|
|
693
695
|
setDeckLanguage(deckConfig)
|
|
694
696
|
document.documentElement.lang = deckLanguage()
|
|
695
697
|
configureLive(deckConfig)
|
|
698
|
+
setSavedResults(savedResults)
|
|
696
699
|
const { slides } = parsed
|
|
697
700
|
const presenterMode = view === 'presenter'
|
|
698
701
|
const audienceMode = view === 'audience'
|
|
@@ -5,6 +5,7 @@ import '../components/callout.css'
|
|
|
5
5
|
import '../components/columns.css'
|
|
6
6
|
import { scanDirectives, sourceLines, fenceState } from '../core/source.js'
|
|
7
7
|
import { CALLOUT_LABELS, labelSetFor } from '../core/labels.js'
|
|
8
|
+
import { expandPollLists } from '../core/pollList.js'
|
|
8
9
|
|
|
9
10
|
function readDirective(src, name) {
|
|
10
11
|
if (!/^:::\s*[\w-]+/.test(src)) return null
|
|
@@ -36,6 +37,8 @@ function escapeHtml(text) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
marked.use(markedKatex({ throwOnError: false, output: 'html' }))
|
|
40
|
+
// A poll's options written as a list inside the tag (src/core/pollList.js).
|
|
41
|
+
marked.use({ hooks: { preprocess: expandPollLists } })
|
|
39
42
|
|
|
40
43
|
// marked.use() prepends via unshift, so last-registered = highest priority.
|
|
41
44
|
// Register callout first (lowest priority) so columns and steps match before it.
|
package/src/runtime/registry.jsx
CHANGED
|
@@ -5,6 +5,7 @@ import Poll from '../components/Poll.jsx'
|
|
|
5
5
|
import Question from '../components/Question.jsx'
|
|
6
6
|
import WordCloud from '../components/WordCloud.jsx'
|
|
7
7
|
import Scale from '../components/Scale.jsx'
|
|
8
|
+
import Numeric from '../components/Numeric.jsx'
|
|
8
9
|
import Bibliography from '../components/Bibliography.jsx'
|
|
9
10
|
import deckComponents from 'virtual:deck-components'
|
|
10
11
|
|
|
@@ -23,6 +24,7 @@ export const registry = {
|
|
|
23
24
|
question: Question,
|
|
24
25
|
wordcloud: WordCloud,
|
|
25
26
|
scale: Scale,
|
|
27
|
+
numeric: Numeric,
|
|
26
28
|
bibliography: Bibliography,
|
|
27
29
|
...deckComponents,
|
|
28
30
|
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
schema = 1
|
|
2
|
-
kind = "palette"
|
|
3
|
-
id = "neon"
|
|
4
|
-
title = "Neon"
|
|
5
|
-
description = "Violet and magenta; loud, for workshops."
|
|
6
|
-
version = "1.0.0"
|
|
7
|
-
author = "Tilman Schieber"
|
|
8
|
-
license = "MIT"
|
|
9
|
-
|
|
10
|
-
[light]
|
|
11
|
-
"--bg" = "#ffffff"
|
|
12
|
-
"--surface" = "#f4f0fb"
|
|
13
|
-
"--ink" = "#14072b"
|
|
14
|
-
"--ink-soft" = "#3a2d55"
|
|
15
|
-
"--muted" = "#6a6084"
|
|
16
|
-
"--rule" = "#e4ddf3"
|
|
17
|
-
"--accent" = "#9b00d9"
|
|
18
|
-
"--accent-2" = "#d6106f"
|
|
19
|
-
"--on-accent" = "#ffffff"
|
|
20
|
-
|
|
21
|
-
[dark]
|
|
22
|
-
"--bg" = "#0d0d0d"
|
|
23
|
-
"--surface" = "#1a1a1a"
|
|
24
|
-
"--ink" = "#f0f0f0"
|
|
25
|
-
"--ink-soft" = "#cccccc"
|
|
26
|
-
"--muted" = "#8a8a8a"
|
|
27
|
-
"--rule" = "#2a2a2a"
|
|
28
|
-
"--accent" = "#cc17ff"
|
|
29
|
-
"--accent-2" = "#ff1791"
|
|
30
|
-
"--on-accent" = "#0d0d0d"
|