mdeck 3.1.0 → 3.3.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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/base.css +24 -0
  3. package/assets/extensions/themes/academic/styles.css +15 -0
  4. package/docs/reference/authoring.md +75 -2
  5. package/docs/reference/extensions.md +3 -1
  6. package/docs/site/content/audience.md +6 -0
  7. package/docs/site/content/commands.md +28 -4
  8. package/docs/site/content/drawing.md +1 -1
  9. package/docs/site/content/more-content.md +22 -0
  10. package/docs/site/content/sharing.md +1 -0
  11. package/docs/site/content/troubleshooting.md +1 -1
  12. package/docs/site/content/views.md +3 -1
  13. package/examples/citations/refs.bib +30 -0
  14. package/examples/citations/refs.yml +21 -0
  15. package/examples/citations/slides.md +43 -0
  16. package/examples/citations/slides.pdf +0 -0
  17. package/package.json +5 -1
  18. package/skills/write-slides/SKILL.md +6 -2
  19. package/src/build/bibliography.js +289 -0
  20. package/src/build/check.js +12 -0
  21. package/src/build/renderCheck.js +98 -0
  22. package/src/build/slidesPlugin.js +47 -7
  23. package/src/build/themeCheck.js +18 -6
  24. package/src/cli/args.js +20 -4
  25. package/src/cli/index.js +39 -10
  26. package/src/cli/themes.js +9 -3
  27. package/src/components/Bibliography.jsx +23 -0
  28. package/src/components/Poll.jsx +25 -2
  29. package/src/components/QrCodeTag.jsx +11 -2
  30. package/src/components/VideoPlayer.jsx +16 -0
  31. package/src/components/activity.jsx +2 -2
  32. package/src/components/citation.css +28 -0
  33. package/src/components/poll.css +12 -2
  34. package/src/core/citations.js +108 -0
  35. package/src/core/editDeck.js +43 -0
  36. package/src/core/ink.js +16 -0
  37. package/src/core/labels.js +16 -0
  38. package/src/core/parseSlides.js +1 -1
  39. package/src/core/urls.js +2 -2
  40. package/src/core/validateDeck.js +7 -1
  41. package/src/layouts/layoutApi.jsx +13 -3
  42. package/src/live/answer/answer.css +1 -0
  43. package/src/live/answer/answer.js +2 -0
  44. package/src/live/client.js +25 -2
  45. package/src/runtime/ReaderView.jsx +2 -1
  46. package/src/runtime/citations.js +60 -0
  47. package/src/runtime/deck-stage.js +5 -2
  48. package/src/runtime/editorBridge.js +7 -1
  49. package/src/runtime/follow.js +89 -0
  50. package/src/runtime/ink/bus.js +7 -0
  51. package/src/runtime/ink/room.js +8 -0
  52. package/src/runtime/ink/select.js +4 -2
  53. package/src/runtime/main.jsx +19 -7
  54. package/src/runtime/markedSetup.js +7 -0
  55. package/src/runtime/registry.jsx +2 -0
@@ -35,6 +35,8 @@ export const LABELS = {
35
35
  'poll.reset': 'Reset',
36
36
  'poll.showAnswer': 'Show the right answer',
37
37
  'poll.hideAnswer': 'Hide the right answer',
38
+ 'poll.showResults': 'Show the results',
39
+ 'poll.hideResults': 'Hide the results',
38
40
  'poll.live': 'Live',
39
41
  'poll.offline': 'Not connected to the server',
40
42
  'poll.unreachable': 'Phones cannot reach this computer. Start with {command}, or set {setting}.',
@@ -46,6 +48,12 @@ export const LABELS = {
46
48
  'respond.send': 'Send',
47
49
  'respond.sent': 'Sent. Thank you!',
48
50
  'join.scan': 'Scan to join',
51
+ 'follow.scan': 'Scan to follow the slides',
52
+ 'follow.open': 'Follow the slides',
53
+ 'follow.live': 'Live',
54
+ 'follow.back': 'Back to live',
55
+ 'follow.waiting': 'The talk has not started yet.',
56
+ 'follow.tryHere': 'Try following here',
49
57
  'join.static': 'During the talk, phones joined here to answer.',
50
58
  'question.empty': 'Answers appear here.',
51
59
  'scale.average': 'Average {n}',
@@ -80,6 +88,8 @@ export const LABELS = {
80
88
  'poll.reset': 'Zurücksetzen',
81
89
  'poll.showAnswer': 'Richtige Antwort zeigen',
82
90
  'poll.hideAnswer': 'Richtige Antwort ausblenden',
91
+ 'poll.showResults': 'Ergebnis zeigen',
92
+ 'poll.hideResults': 'Ergebnis ausblenden',
83
93
  'poll.live': 'Live',
84
94
  'poll.offline': 'Keine Verbindung zum Server',
85
95
  'poll.unreachable': 'Handys erreichen diesen Computer nicht. Mit {command} starten oder {setting} setzen.',
@@ -91,6 +101,12 @@ export const LABELS = {
91
101
  'respond.send': 'Senden',
92
102
  'respond.sent': 'Gesendet. Danke!',
93
103
  'join.scan': 'Scannen und mitmachen',
104
+ 'follow.scan': 'Scannen und Folien mitverfolgen',
105
+ 'follow.open': 'Folien mitverfolgen',
106
+ 'follow.live': 'Live',
107
+ 'follow.back': 'Zur aktuellen Folie',
108
+ 'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
109
+ 'follow.tryHere': 'Mitverfolgen hier ausprobieren',
94
110
  'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
95
111
  'question.empty': 'Hier erscheinen die Antworten.',
96
112
  'scale.average': 'Durchschnitt {n}',
@@ -2,7 +2,7 @@ import yaml from 'js-yaml'
2
2
  import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
3
3
  export { sourceLines, fenceState } from './source.js'
4
4
 
5
- export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session'.split(' '))
5
+ export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite'.split(' '))
6
6
  export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.split(' '))
7
7
 
8
8
  // Names from before 2.0. They are still recognised, so that an old deck gets a
package/src/core/urls.js CHANGED
@@ -1,4 +1,4 @@
1
- const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience'])
1
+ const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience', 'follow'])
2
2
  const OLD_VIEWS = { d: 'deck', s: 'reader', p: 'presenter', a: 'audience', share: 'reader' }
3
3
 
4
4
  // Old URLs must be corrected explicitly: guessing a view can expose notes or
@@ -21,7 +21,7 @@ export function validatePageUrl(url) {
21
21
  for (const secret of ['serverkey', 'livekey', 'pair']) corrected.searchParams.delete(secret)
22
22
  if (replacements.length) return { message: `This URL uses settings from before API 2.0: ${replacements.join(', ')}. Open the updated address below. If you supplied a server key, add it again as ?serverkey=… on your own computer.`, corrected: corrected.href }
23
23
  const view = url.searchParams.get('view')
24
- if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter or audience.` }
24
+ if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter, audience or follow.` }
25
25
  return null
26
26
  }
27
27
 
@@ -38,14 +38,20 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
38
38
  else if (typeof value !== 'string') add('invalid-config', `labels.${key} must be text`, deck.configSource?.start)
39
39
  }
40
40
  }
41
+ // References: bibliography.js reads the files and reports what it cannot use.
42
+ if (config.bibliography != null && ![config.bibliography].flat().every(file => typeof file === 'string' && file.trim())) add('invalid-config', 'bibliography must be a file name such as refs.bib, or a list of them', deck.configSource?.start)
43
+ if (config.csl != null && (typeof config.csl !== 'string' || !config.csl.trim())) add('invalid-config', 'csl must be a style name (apa, vancouver, harvard1) or a .csl file', deck.configSource?.start)
44
+ if (config.nocite != null && (!Array.isArray(config.nocite) || config.nocite.some(key => typeof key !== 'string'))) add('invalid-config', "nocite must be a list of reference keys, or ['*'] for all", deck.configSource?.start)
41
45
  if (config.server != null && (typeof config.server !== 'string' || !/^https?:\/\//.test(config.server))) add('invalid-config', 'server must be an http:// or https:// address', deck.configSource?.start)
42
46
  if (isPlainObject(config.session)) {
43
47
  for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
44
48
  if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
45
49
  }
46
50
  const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
51
+ const switches = ['citations']
47
52
  if (isPlainObject(config.show)) {
48
- for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key)) add('invalid-config', `show.${key} is not a setting; available: ${Object.keys(enums).join(', ')}`, deck.configSource?.start, 'warning')
53
+ for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key) && !switches.includes(key)) add('invalid-config', `show.${key} is not a setting; available: ${[...Object.keys(enums), ...switches].join(', ')}`, deck.configSource?.start, 'warning')
54
+ for (const key of switches) if (config.show[key] != null && typeof config.show[key] !== 'boolean') add('invalid-config', `show.${key} must be true or false`, deck.configSource?.start)
49
55
  for (const [key, values] of Object.entries(enums)) {
50
56
  if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
51
57
  }
@@ -5,6 +5,7 @@ import { marked, Parser } from 'marked'
5
5
  import { registry } from '../runtime/registry'
6
6
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
7
7
  import themedSvgs from 'virtual:deck-svgs'
8
+ import { renderCitations, citationsHtml as referencesOf } from '../runtime/citations.js'
8
9
 
9
10
  // ─── Content extraction ────────────────────────────────────────────────────
10
11
 
@@ -170,6 +171,12 @@ function SlideFootnotes({ html }) {
170
171
  return <div class="slide-footnotes" dangerouslySetInnerHTML={{ __html: html }} />
171
172
  }
172
173
 
174
+ // The full references of the works this slide cites (show.citations: false hides them).
175
+ function SlideCitations({ html }) {
176
+ if (!html) return null
177
+ return <div class="slide-citations" dangerouslySetInnerHTML={{ __html: html }} />
178
+ }
179
+
173
180
  function slideNum(index) {
174
181
  return String(index + 1).padStart(2, '0')
175
182
  }
@@ -181,10 +188,12 @@ export function prepareSlide(slide) {
181
188
  .sort((a, b) => position(a[1]) - position(b[1]))
182
189
  let separator = '\u0000mdeck-region\u0000'
183
190
  while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
184
- const { processed, footnotesHtml } = preprocessFootnotes(entries.map(([, r]) => r.content).join(separator))
191
+ const cited = renderCitations(entries.map(([, r]) => r.content).join(separator))
192
+ const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
185
193
  const pieces = processed.split(separator)
186
194
  const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
187
- return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml }
195
+ const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids)
196
+ return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
188
197
  }
189
198
 
190
199
  export function MarkdownRegion({ region, content, class: className, ...attributes }) {
@@ -194,7 +203,7 @@ export function MarkdownRegion({ region, content, class: className, ...attribute
194
203
  }
195
204
 
196
205
  // Layouts supply body content; every layout receives the same outer frame.
197
- export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
206
+ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, citationsHtml, children }) {
198
207
  const layout = manifest.id === 'generic' ? meta.layout : manifest.id
199
208
  const frame = manifest.frame ?? 'standard'
200
209
  const title = frame === 'title'
@@ -207,6 +216,7 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
207
216
  isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
208
217
  {children}
209
218
  <SlideFootnotes html={footnotesHtml} />
219
+ <SlideCitations html={citationsHtml} />
210
220
  {frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
211
221
  <InkLayer slideId={id} />
212
222
  </section>
@@ -32,3 +32,4 @@ 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; }
@@ -109,6 +109,8 @@
109
109
  ? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
110
110
  : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
111
111
  current?.title ? el('footer', {}, current.title) : null,
112
+ // The presenter's screen sends the link when other devices can follow the slides.
113
+ /^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
112
114
  )
113
115
  }
114
116
 
@@ -114,6 +114,20 @@ function joinUrl(info) {
114
114
  return info.network ? new URL(`__mdeck/live/${settings.code}`, info.network).href : null
115
115
  }
116
116
  const localJoinUrl = () => `${serverBase()}/${settings.code}`
117
+
118
+ // The link for following the slides on another device (?view=follow): this
119
+ // deck's page, where other devices reach both the page and the stage room
120
+ // that carries the presenter's position. That is `mdeck run --network`'s
121
+ // network address, or a deck hosted with a `server` setting, whose stage room
122
+ // is on that server. null when only this computer could follow; localFollowUrl
123
+ // then opens the follow view here, for trying it out.
124
+ const relayed = () => /^\/t\/[^/]+\/$/.test(devPath(''))
125
+ function followUrl(info) {
126
+ if (relayed()) return null
127
+ if (import.meta.env?.DEV || proxyControls) return info.network ? new URL(`${devPath('')}?view=follow`, info.network).href : null
128
+ return settings.server ? `${window.location.origin}${window.location.pathname}?view=follow` : null
129
+ }
130
+ const localFollowUrl = () => `${window.location.origin}${devPath('')}?view=follow`
117
131
  // Until the server has answered or failed, `known` is false and activities
118
132
  // show no join code, so a quick capture never shows a passing warning.
119
133
  const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
@@ -164,6 +178,13 @@ export function useJoinLink() {
164
178
  return { joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
165
179
  }
166
180
 
181
+ /** The link for following the slides, for a slide that shows it (<qrcode follow />). */
182
+ export function useFollowLink() {
183
+ const [info, setInfo] = useState({})
184
+ useEffect(() => { serverInfo().then(setInfo) }, [])
185
+ return { joinUrl: followUrl(info), localJoinUrl: localFollowUrl(), code: settings.code, ...reach(info) }
186
+ }
187
+
167
188
  /** The latest answer of each device, e.g. to count votes that can be changed. */
168
189
  export function latestByDevice(messages) {
169
190
  const latest = new Map()
@@ -197,7 +218,7 @@ export function captureLook(doc = lookSource()) {
197
218
  }
198
219
 
199
220
  // 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') })
221
+ const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open') })
201
222
 
202
223
  // The presenter's screen announces the activity on the current slide and
203
224
  // repeats it every few seconds, so phones that join late and a server
@@ -236,7 +257,9 @@ async function sendCurrent() {
236
257
  if (!info.canReset) return report({ ok: false, reason: presenterKey() ? 'wrong-code' : 'no-code' })
237
258
  const look = captureLook()
238
259
  if (look) lastLook = look
239
- const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords() }
260
+ // Phones that answer can also follow the slides, where that works.
261
+ const follow = followUrl(info)
262
+ const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...(follow ? { follow } : {}) }
240
263
  try {
241
264
  const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
242
265
  const body = response.ok ? await response.json().catch(() => ({})) : {}
@@ -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,60 @@
1
+ import initial from 'virtual:bibliography'
2
+ import { scanCitations } from '../core/citations.js'
3
+ import '../components/citation.css'
4
+
5
+ // The deck's formatted references (built by src/build/bibliography.js), or
6
+ // null without a `bibliography`. The editor receives new ones as the deck or
7
+ // its reference file changes.
8
+ let bibliography = initial
9
+ const listeners = new Set()
10
+
11
+ export const currentBibliography = () => bibliography
12
+ export function onBibliography(listener) {
13
+ listeners.add(listener)
14
+ return () => listeners.delete(listener)
15
+ }
16
+
17
+ if (import.meta.hot) {
18
+ import.meta.hot.accept('virtual:bibliography', next => {
19
+ if (!next) return
20
+ bibliography = next.default
21
+ for (const listener of listeners) listener(bibliography)
22
+ })
23
+ }
24
+
25
+ const escape = text => String(text).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
26
+
27
+ // A citation the build has not formatted: one typed in the editor since the
28
+ // last save is put together from its works; an unknown key is marked.
29
+ function fallback(cluster, bib) {
30
+ return cluster.items.map(({ id }) => bib?.singles[id]
31
+ ?? `<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('; ')
32
+ }
33
+
34
+ // Puts each citation's formatted text in place; returns the keys the text
35
+ // cites, in order, for the slide's reference footer.
36
+ export function renderCitations(markdown, bib = bibliography) {
37
+ if (!markdown || !markdown.includes('@')) return { markdown, ids: [] }
38
+ const known = new Set(bib?.ids ?? [])
39
+ const clusters = scanCitations(markdown, { isKey: id => known.has(id) })
40
+ if (!clusters.length) return { markdown, ids: [] }
41
+ let out = ''
42
+ let at = 0
43
+ const ids = []
44
+ for (const cluster of clusters) {
45
+ const formatted = bib?.clusters[cluster.raw]
46
+ const unknown = cluster.items.filter(item => !known.has(item.id))
47
+ const html = formatted == null ? fallback(cluster, bib) : unknown.length ? `${formatted}; ${fallback({ items: unknown }, bib)}` : formatted
48
+ const keys = cluster.items.map(item => item.id)
49
+ out += markdown.slice(at, cluster.start) + `<cite class="citation" data-cites="${escape(keys.join(' '))}">${html}</cite>`
50
+ at = cluster.end
51
+ for (const id of keys) if (bib?.entries[id] && !ids.includes(id)) ids.push(id)
52
+ }
53
+ return { markdown: out + markdown.slice(at), ids }
54
+ }
55
+
56
+ // The full references of `ids`, for a slide's footer.
57
+ export function citationsHtml(ids, bib = bibliography) {
58
+ const entries = ids.map(id => bib?.entries[id]).filter(Boolean)
59
+ return entries.length ? `<div class="csl-bib-body">${entries.join('')}</div>` : ''
60
+ }
@@ -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
  }
@@ -0,0 +1,89 @@
1
+ // The follow view (?view=follow): a phone or laptop in the room shows the
2
+ // presenter's slide and ink, read from the stage room, and sends nothing.
3
+ // A follower may page back on their own and return with "Back to live";
4
+ // slides and steps the presenter has not shown yet stay hidden.
5
+
6
+ /** Where a follower may be: no further than the furthest the presenter has shown. */
7
+ export function clampToShown({ index, step }, furthest) {
8
+ if (!furthest) return { index: 0, step: -1 }
9
+ if (index > furthest.index) return { index: furthest.index, step: furthest.step }
10
+ if (index === furthest.index && step > furthest.step) return { index, step: furthest.step }
11
+ return { index, step }
12
+ }
13
+
14
+ // The furthest of two positions, slide first, then step.
15
+ const further = (a, b) => !a ? b : (b.index > a.index || (b.index === a.index && b.step > a.step)) ? b : a
16
+
17
+ /**
18
+ * The follower's state: the presenter's position, the furthest shown, and
19
+ * whether this device is with the presenter (`live`).
20
+ * presenter(position) the presenter moved: returns where to go, or null
21
+ * moved(position) the follower moved: returns where to go back to if
22
+ * they went past what was shown, else null
23
+ * backToLive() returns the presenter's position
24
+ */
25
+ export function createFollower() {
26
+ const state = { presenter: null, furthest: null, live: true }
27
+ const same = (a, b) => !!a && !!b && a.index === b.index && a.step === b.step
28
+ return {
29
+ state,
30
+ presenter(position) {
31
+ state.presenter = position
32
+ state.furthest = further(state.furthest, position)
33
+ return state.live ? position : null
34
+ },
35
+ moved(position) {
36
+ const allowed = clampToShown(position, state.furthest)
37
+ state.live = state.presenter ? same(allowed, state.presenter) : true
38
+ return same(allowed, position) ? null : allowed
39
+ },
40
+ backToLive() {
41
+ state.live = true
42
+ return state.presenter
43
+ },
44
+ }
45
+ }
46
+
47
+ const BAR_CSS = `
48
+ .follow-bar { position: fixed; left: 50%; top: max(14px, env(safe-area-inset-top)); transform: translateX(-50%); z-index: 2147483001;
49
+ display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border: 1px solid #3a3a3a; border-radius: 999px;
50
+ background: rgba(22, 22, 22, 0.92); color: #e6e6e6; font: 14px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif; white-space: nowrap; }
51
+ button.follow-bar { cursor: pointer; }
52
+ .follow-dot { width: 8px; height: 8px; border-radius: 50%; background: #e11d48; box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.25); }
53
+ `
54
+
55
+ /**
56
+ * Follow the presenter on `stage`. `onStagePosition` hears the positions the
57
+ * stage room carries; it must be called before the room is listened to.
58
+ * `words` are the labels: live, back, waiting.
59
+ */
60
+ export function startFollowing(stage, { onStagePosition, words }) {
61
+ const follower = createFollower()
62
+ const style = document.createElement('style')
63
+ style.textContent = BAR_CSS
64
+ document.head.appendChild(style)
65
+ let bar = null
66
+
67
+ function update() {
68
+ const { presenter, live } = follower.state
69
+ const next = !presenter ? document.createElement('div') : live ? document.createElement('div') : document.createElement('button')
70
+ next.className = 'follow-bar'
71
+ if (!presenter) next.textContent = words.waiting
72
+ else if (live) { const dot = document.createElement('span'); dot.className = 'follow-dot'; next.append(dot, words.live) }
73
+ else { next.textContent = words.back; next.addEventListener('click', () => { go(follower.backToLive()); update() }) }
74
+ bar ? bar.replaceWith(next) : document.body.appendChild(next)
75
+ bar = next
76
+ }
77
+ // Positions set from here arrive back as 'sync' and are not the follower's own.
78
+ const go = position => { if (position) stage.setState({ index: position.index, step: position.step }) }
79
+
80
+ onStagePosition(position => { go(follower.presenter(position)); update() })
81
+ stage.addEventListener('statechange', event => {
82
+ if (['init', 'sync'].includes(event.detail.reason)) return
83
+ go(follower.moved({ index: event.detail.index, step: event.detail.step }))
84
+ update()
85
+ })
86
+ // Until the presenter's position arrives, the title slide.
87
+ go(follower.moved(stage.state))
88
+ update()
89
+ }
@@ -13,6 +13,7 @@
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
15
  // { type: 'reveal', key, room, shown } a poll's right answer shown or hidden
16
+ // { type: 'results', key, room, shown } a poll's results shown or hidden
16
17
  // { type: 'op', op } a change of the saved ink (applyOp)
17
18
  // Keys start with the sending window's id, so strokes never mix.
18
19
  import { changeInk, renameSlide } from './store.js'
@@ -51,6 +52,8 @@ export function createInkBus(stage, transports) {
51
52
  stage.liveStroke(message.key, known)
52
53
  } else if (message.type === 'reveal') {
53
54
  stage.dispatchEvent(new CustomEvent('pollreveal', { detail: { room: message.room, shown: !!message.shown } }))
55
+ } else if (message.type === 'results') {
56
+ stage.dispatchEvent(new CustomEvent('pollresults', { detail: { room: message.room, shown: !!message.shown } }))
54
57
  } else if (message.type === 'zoom') {
55
58
  if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
56
59
  } else if (message.type === 'dot') {
@@ -103,6 +106,10 @@ export function createInkBus(stage, transports) {
103
106
  stage.addEventListener('pollreveal', ({ detail }) => {
104
107
  if (detail?.local) send({ type: 'reveal', key: `${me}:reveal`, room: detail.room, shown: !!detail.shown })
105
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 })
112
+ })
106
113
 
107
114
  // The zoom: the latest view, not every step of a pinch.
108
115
  let zoom = null, zoomTimer = null
@@ -77,6 +77,14 @@ export function followStageRoom(stage) {
77
77
  })
78
78
  }
79
79
 
80
+ /** Hear the position other devices announce (the follow view decides what to do with it). */
81
+ export function onStagePosition(listener) {
82
+ stateListeners.add(state => {
83
+ if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
84
+ listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
85
+ })
86
+ }
87
+
80
88
  /** Publish navigation made here; received positions never echo back. */
81
89
  export function announceStagePosition(stage) {
82
90
  const room = stageRoom()
@@ -7,7 +7,7 @@
7
7
  // that the pen moves one at a time. Changes show at once in this window and
8
8
  // become one change (undo puts the strokes back) when the pen lifts.
9
9
  import { slideStrokes, changeInk, onInkChange } from './store.js'
10
- import { strokesInLasso, strokesBox, translateStroke, hitTest } from '../../core/ink.js'
10
+ import { strokesInLasso, strokesBox, translateStroke, hitTest, snapAngle } from '../../core/ink.js'
11
11
 
12
12
  const PAD = 14 // design pixels around a selection
13
13
  const TAP = 10 // a lasso smaller than this is a tap
@@ -119,6 +119,8 @@ export function createInkSelection(stage, controller) {
119
119
  }
120
120
 
121
121
  // The stroke with one end point moved to `point` (its pressure kept).
122
- function moveEnd(stroke, index, [x, y]) {
122
+ // The other end stays put, and the moved one snaps to steps of 15° around it.
123
+ function moveEnd(stroke, index, point) {
124
+ const [x, y] = snapAngle(stroke.points[1 - index], point)
123
125
  return { ...stroke, points: stroke.points.map((p, i) => i === index ? [x, y, p[2]] : p) }
124
126
  }
@@ -2,6 +2,7 @@ import { validatePageUrl } from '../core/urls.js'
2
2
  import { h, render } from 'preact'
3
3
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
4
4
  import { marked } from 'marked'
5
+ import { renderCitations, onBibliography } from './citations.js'
5
6
  import deckFileContent from 'virtual:slides'
6
7
  import { parseSlides } from '../core/parseSlides'
7
8
  import { validateDeck } from '../core/validateDeck'
@@ -18,7 +19,8 @@ import { followActiveRooms } from '../live/follow.js'
18
19
  import { createOrder } from './syncOrder.js'
19
20
  import { fitDeck } from './fit.js'
20
21
  import { attachInk, watchInk } from './ink/attach.js'
21
- import { roomTransport, followStageRoom, announceStagePosition } from './ink/room.js'
22
+ import { roomTransport, followStageRoom, announceStagePosition, onStagePosition } from './ink/room.js'
23
+ import { startFollowing } from './follow.js'
22
24
  import { claimPairing } from '../live/pairing.js'
23
25
  import { strokePath } from '../core/ink.js'
24
26
  import { inkFileName } from 'virtual:deck-ink'
@@ -33,7 +35,7 @@ import './deck-stage.js'
33
35
 
34
36
  const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
35
37
 
36
- // One address parameter selects the view: ?view=deck, reader, presenter or audience.
38
+ // One address parameter selects the view: ?view=deck, reader, presenter, audience or follow.
37
39
  export function requestedView(url, fallback = 'deck') {
38
40
  return url.searchParams.get('view') ?? fallback
39
41
  }
@@ -486,7 +488,7 @@ function PresenterView({ deckConfig, slides }) {
486
488
  }}
487
489
  dangerouslySetInnerHTML={{
488
490
  __html: note
489
- ? marked.parse(note)
491
+ ? marked.parse(renderCitations(note).markdown)
490
492
  : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
491
493
  }}
492
494
  />
@@ -694,11 +696,13 @@ async function init() {
694
696
  const { slides } = parsed
695
697
  const presenterMode = view === 'presenter'
696
698
  const audienceMode = view === 'audience'
699
+ // A phone or laptop in the room following the talk: shows, never sends.
700
+ const followMode = view === 'follow'
697
701
  const embedded = url.searchParams.get('embedded') === '1'
698
702
  const readerMode = view === 'reader' && !editorMode && !embedded
699
703
  // The deck window and the presenter's main frame draw; the audience window
700
704
  // and the next-slide preview show what is drawn.
701
- const drawHere = !audienceMode && (!embedded || url.searchParams.get('draw') === '1')
705
+ const drawHere = !audienceMode && !followMode && (!embedded || url.searchParams.get('draw') === '1')
702
706
  const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
703
707
 
704
708
  injectSpeakerNotes(slides)
@@ -748,6 +752,8 @@ async function init() {
748
752
  if (event.source !== window.parent || event.origin !== window.location.origin) return
749
753
  if (!bridge.handleMessage(event.data)) handleDeckControl(event.data?.deckControl)
750
754
  })
755
+ // Saving the deck or its reference file reformats the citations.
756
+ onBibliography(() => bridge.refresh())
751
757
  await bridge.render(slidesContent)
752
758
  post({ deckEditorReady: true })
753
759
  return
@@ -762,7 +768,7 @@ async function init() {
762
768
 
763
769
  await loadTheme(deckConfig)
764
770
  mountDeck({ deck: parsed, deckConfig })
765
- if (!embedded && !audienceMode) {
771
+ if (!embedded && !audienceMode && !followMode) {
766
772
  // A quiet way into the reader view for anyone who opened the file directly.
767
773
  const entry = document.createElement('a')
768
774
  entry.className = 'reader-entry'
@@ -801,14 +807,20 @@ async function init() {
801
807
  }
802
808
  window.addEventListener('pointerdown', onPen, true)
803
809
  }
810
+ } else if (inkStage && followMode) {
811
+ // The presenter's slide and ink, read from the stage room; counted as a phone.
812
+ inkStage.setAttribute('data-follower', '')
813
+ startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
814
+ watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
804
815
  } else if (inkStage && audienceMode) {
805
816
  // The audience window shows the presenter's zoom and a poll's answer, without their controls.
806
817
  inkStage.setAttribute('data-follower', '')
807
818
  watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'audience' })] })
808
819
  } else if (inkStage) watchInk(inkStage, { storageKey: inkStorageKey })
809
820
 
810
- // A full deck or audience window is a presenter's screen; previews are embedded.
811
- if (!embedded) {
821
+ // A full deck or audience window is a presenter's screen; previews are
822
+ // embedded, and a follower only watches.
823
+ if (!embedded && !followMode) {
812
824
  const stage = document.querySelector('deck-stage')
813
825
  stage?.addEventListener('slidechange', event => announceSlide(parsed, event.detail.index, { initial: event.detail.reason === 'init' }))
814
826
  if (stage) announceSlide(parsed, stage.index, { initial: true })
@@ -143,5 +143,12 @@ marked.use({
143
143
  .replace(/>/g, '&gt;')
144
144
  return `<codeblock lang="${lang}"${attrs ? ' ' + attrs : ''}>${encoded}</codeblock>`
145
145
  },
146
+ // A paragraph of nothing but pictures (one, or several side by side) is
147
+ // marked, so themes can fit it into the slide or set it as a row; a
148
+ // sentence with an icon in it stays a sentence.
149
+ paragraph({ tokens }) {
150
+ const pictures = tokens.some(t => t.type === 'image') && tokens.every(t => t.type === 'image' || t.type === 'br' || (t.type === 'text' && !t.raw.trim()))
151
+ return `<p${pictures ? ' class="pictures"' : ''}>${this.parser.parseInline(tokens)}</p>\n`
152
+ },
146
153
  },
147
154
  })
@@ -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 Bibliography from '../components/Bibliography.jsx'
8
9
  import deckComponents from 'virtual:deck-components'
9
10
 
10
11
  // Maps lowercase HTML tag names to Preact components.
@@ -22,5 +23,6 @@ export const registry = {
22
23
  question: Question,
23
24
  wordcloud: WordCloud,
24
25
  scale: Scale,
26
+ bibliography: Bibliography,
25
27
  ...deckComponents,
26
28
  }