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.
- package/CHANGELOG.md +27 -0
- package/assets/base.css +23 -2
- package/assets/extensions/themes/academic/styles.css +33 -5
- package/assets/extensions/themes/aurora/styles.css +32 -6
- package/assets/extensions/themes/minimal/styles.css +16 -4
- package/assets/extensions/themes/neue/styles.css +17 -4
- package/docs/reference/authoring.md +89 -6
- package/docs/site/content/audience.md +41 -4
- package/docs/site/content/commands.md +30 -4
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/more-content.md +22 -0
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/troubleshooting.md +1 -1
- package/docs/site/content/views.md +3 -1
- package/examples/citations/refs.bib +30 -0
- package/examples/citations/refs.yml +21 -0
- package/examples/citations/slides.md +43 -0
- package/examples/citations/slides.pdf +0 -0
- package/package.json +5 -1
- package/skills/write-slides/SKILL.md +7 -3
- package/src/build/bibliography.js +307 -0
- package/src/build/check.js +12 -0
- package/src/build/renderCheck.js +98 -0
- package/src/build/resultsPlugin.js +77 -0
- package/src/build/slidesPlugin.js +70 -7
- package/src/build/themeCheck.js +8 -5
- package/src/cli/args.js +22 -5
- package/src/cli/index.js +54 -12
- package/src/components/Bibliography.jsx +23 -0
- package/src/components/Numeric.jsx +59 -0
- package/src/components/Poll.jsx +59 -33
- package/src/components/QrCodeTag.jsx +11 -2
- package/src/components/Question.jsx +10 -6
- package/src/components/Scale.jsx +13 -8
- package/src/components/VideoPlayer.jsx +16 -0
- package/src/components/WordCloud.jsx +10 -6
- package/src/components/activity.jsx +90 -9
- package/src/components/citation.css +28 -0
- package/src/components/inlineText.js +40 -0
- package/src/components/numbers.js +39 -0
- package/src/components/poll.css +27 -7
- package/src/core/citations.js +108 -0
- package/src/core/editDeck.js +43 -0
- package/src/core/icons.js +2 -0
- package/src/core/ink.js +16 -0
- package/src/core/labels.js +48 -0
- package/src/core/parseSlides.js +1 -1
- package/src/core/results.js +64 -0
- package/src/core/urls.js +2 -2
- package/src/core/validateDeck.js +7 -1
- package/src/editor/sampleDeck.js +34 -8
- package/src/layouts/layoutApi.jsx +14 -3
- package/src/live/answer/answer.css +20 -0
- package/src/live/answer/answer.js +149 -24
- package/src/live/client.js +75 -7
- package/src/live/roomTag.js +6 -2
- package/src/live/rooms.js +6 -1
- package/src/runtime/ReaderView.jsx +2 -1
- package/src/runtime/citations.js +70 -0
- package/src/runtime/deck-stage.js +5 -2
- package/src/runtime/editorBridge.js +7 -1
- package/src/runtime/follow.js +89 -0
- package/src/runtime/ink/bus.js +7 -6
- package/src/runtime/ink/room.js +8 -0
- package/src/runtime/ink/select.js +4 -2
- package/src/runtime/main.jsx +22 -8
- 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: {
|
|
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,12 +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.
|
|
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
|
|
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
|
|
|
@@ -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 =>
|
|
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
|
-
|
|
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 = () => ({
|
|
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
|
-
|
|
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(() => ({})) : {}
|
package/src/live/roomTag.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
-
|
|
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
|
-
|
|
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
|
}
|