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
package/src/cli/index.js CHANGED
@@ -12,7 +12,7 @@ import { loadRegistry, manifestsOf, serializeRegistry } from '../extensions/disc
12
12
  import { BACKUP_DIR } from '../build/editorPlugin.js'
13
13
  import { createEditorServer, createDesignServer } from '../build/editorServer.js'
14
14
  import { homePlugin } from '../build/homePlugin.js'
15
- import { checkDeck } from '../build/check.js'
15
+ import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
16
16
  import { livePlugin, startLiveServer } from '../live/server.js'
17
17
  import { inkPlugin } from '../build/inkPlugin.js'
18
18
  import { createPairing, pairingPlugin } from '../build/pairing.js'
@@ -24,8 +24,8 @@ import { ManifestError, KINDS } from '../extensions/manifest.js'
24
24
 
25
25
  import { frameworkRoot } from '../paths.js'
26
26
  import { parseSlides } from '../core/parseSlides.js'
27
- import { stripNotes as stripNotesFrom } from '../core/editDeck.js'
28
- import { parseArgs, validateServerOrigin } from './args.js'
27
+ import { stripNotes as stripNotesFrom, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
28
+ import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
29
29
  import { assertDrawings } from '../build/drawings.js'
30
30
  import { createRelayAccess } from '../build/relayAccess.js'
31
31
  import { sessionCode } from '../live/code.js'
@@ -178,12 +178,13 @@ async function runNewWizard() {
178
178
  }
179
179
  }
180
180
 
181
- async function copyLocalAssets(slidesPath, outDir, { stripNotes = false } = {}) {
181
+ async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
182
182
  const absSlides = resolve(slidesPath)
183
183
  const deckDir = dirname(absSlides)
184
184
  const absOutDir = resolve(outDir)
185
185
  const raw = readFileSync(absSlides, 'utf-8')
186
- const markdown = stripNotes ? stripNotesFrom(raw) : raw
186
+ let markdown = stripNotes ? stripNotesFrom(raw) : raw
187
+ if (stripActivities) markdown = stripActivitiesFrom(markdown).source
187
188
 
188
189
  for (const ref of collectLocalAssetRefs(markdown)) {
189
190
  const source = resolve(deckDir, ref)
@@ -320,12 +321,13 @@ const HELP = `
320
321
 
321
322
  ${c.dim}Make something to hand out${c.reset}
322
323
  ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
323
- --reader reader view, notes removed unless --notes
324
+ --reader reader view, notes removed unless --notes, polls kept as a record unless --no-polls
324
325
  --single-file everything inlined into one file instead
325
326
  --launchers add macOS/Linux and Windows launchers (folders only)
326
327
  ${c.green}mdeck send${c.reset} [slides.md] [-o talk.html] One file to send: the reader view, speaker notes removed,
327
328
  with a PDF inside
328
329
  --notes keep the speaker notes in the file
330
+ --no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
329
331
  --no-pdf do not render the PDF inside
330
332
  ${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
331
333
  --no-drawings for build, send and pdf: leave out <slides>.drawings.json
@@ -334,6 +336,11 @@ const HELP = `
334
336
  ${c.dim}Check and look things up${c.reset}
335
337
  ${c.green}mdeck migrate${c.reset} [slides.md] [--dry-run] Update settings, local layouts and drawings to API 2.0
336
338
  ${c.green}mdeck check${c.reset} [slides.md] [--strict] Validate source and local assets
339
+ --render also show every slide in Chrome, all steps revealed: content that
340
+ does not fit, text cut off or hard to read, errors on the page
341
+ ${c.green}mdeck snapshot${c.reset} [slides.md] Pictures of slides as PNG files (default: .mdeck-snapshots/)
342
+ --slide <numbers> only these slides: 3, 3,5 or 2-4
343
+ --dark | --light in this appearance instead of the deck's own -o <folder>
337
344
  ${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
338
345
  List built-in and deck-local layouts, themes and palettes
339
346
  ${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
@@ -463,11 +470,29 @@ if (command === 'new') {
463
470
  const input = requireInput('check')
464
471
  const registry = registryFor(input)
465
472
  const { diagnostics } = checkDeck(input, registry)
473
+ diagnostics.push(...await bibliographyDiagnostics(input))
474
+ if (hasFlag('--render') && !diagnostics.some(d => d.severity === 'error')) {
475
+ tip('Building the deck and looking at every slide in Chrome…')
476
+ try {
477
+ const { renderCheck } = await import('../build/renderCheck.js')
478
+ diagnostics.push(...await renderCheck(input))
479
+ } catch (error) { diagnostics.push({ severity: 'error', code: 'render', message: error.message, line: 1 }) }
480
+ }
466
481
  if (diagnostics.length) console.log(formatDiagnostics(diagnostics, input))
467
482
  const warned = diagnostics.length || registry.warnings.length
468
483
  if (diagnostics.some(d => d.severity === 'error') || (warned && hasFlag('--strict'))) process.exitCode = 1
469
484
  else ok(`Checked ${input}${warned ? ' (with warnings)' : ''}`)
470
485
 
486
+ } else if (command === 'snapshot') {
487
+ const input = requireInput('snapshot')
488
+ try {
489
+ const slides = parseSlideNumbers(parsed.options['--slide'] ?? '')
490
+ const outDir = resolve(outputOption() ?? resolve(dirname(resolve(input)), '.mdeck-snapshots'))
491
+ const appearance = hasFlag('--dark') ? 'dark' : hasFlag('--light') ? 'light' : ''
492
+ const { snapshot } = await import('../build/renderCheck.js')
493
+ for (const file of await snapshot(input, { slides, outDir, appearance })) console.log(relative(process.cwd(), file))
494
+ } catch (error) { err(error.message); process.exitCode = 1 }
495
+
471
496
  } else if (command === 'list') {
472
497
  const KIND_WORDS = { layouts: 'layout', themes: 'theme', palettes: 'palette' }
473
498
  const [first, ...others] = positionals()
@@ -578,7 +603,8 @@ if (command === 'new') {
578
603
  ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
579
604
  tip(`Presenter: ${new URL('?view=presenter', local).href}`)
580
605
  tip(`Audience: ${new URL('?view=audience', local).href}`)
581
- if (exposed) for (const url of server.resolvedUrls.network) tip(`On a phone: ${new URL('?view=reader', url).href}`)
606
+ // Phones in the room follow the talk (the reader view would show slides ahead).
607
+ if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
582
608
  console.log()
583
609
  if (relay) tip(`iPad: choose "Show pairing code" on the launch page; it opens ${relay.url}`)
584
610
  ok(`Watching ${c.cyan}${abs}${c.reset}`)
@@ -645,6 +671,8 @@ if (command === 'new') {
645
671
  const launchers = !sending && hasFlag('--launchers')
646
672
  const reader = sending || hasFlag('--reader')
647
673
  const stripNotes = reader && !hasFlag('--notes')
674
+ // Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
675
+ const stripActivities = reader && hasFlag('--no-polls')
648
676
  const wantPdf = sending && !hasFlag('--no-pdf')
649
677
 
650
678
  if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
@@ -659,7 +687,7 @@ if (command === 'new') {
659
687
  try {
660
688
  await build({
661
689
  ...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
662
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
690
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), deckLook: reader }), viteSingleFile()],
663
691
  build: {
664
692
  outDir,
665
693
  emptyOutDir: !tempDir,
@@ -677,7 +705,7 @@ if (command === 'new') {
677
705
  await mkdir(finalOutDir, { recursive: true })
678
706
 
679
707
  if (!selfContained) {
680
- await copyLocalAssets(input, finalOutDir, { stripNotes })
708
+ await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
681
709
  if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
682
710
  }
683
711
 
@@ -701,6 +729,7 @@ if (command === 'new') {
701
729
  } finally { await rm(pdfDir, { recursive: true, force: true }) }
702
730
  }
703
731
  if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
732
+ if (stripActivities && stripActivitiesFrom(readFileSync(resolve(input), 'utf-8')).removed.length) tip('Slides with polls were left out (--no-polls).')
704
733
  ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
705
734
 
706
735
  // ── skill ─────────────────────────────────────────────────────────────────────
@@ -730,7 +759,7 @@ if (command === 'new') {
730
759
  try {
731
760
  await build({
732
761
  ...baseConfig(input, { selfContained: true }),
733
- plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
762
+ plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), deckLook: true }), viteSingleFile()],
734
763
  build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
735
764
  logLevel: 'warn',
736
765
  })
package/src/cli/themes.js CHANGED
@@ -25,11 +25,14 @@ const usage = kind => `search [words] | install <${kind}> | remove <${kind}> | u
25
25
  function looksOf(registry, catalogue) {
26
26
  const builtIn = new Set([...catalogue.themes, ...catalogue.palettes].filter(entry => entry.builtIn).map(entry => entry.id + (catalogue.themes.includes(entry) ? ':t' : ':p')))
27
27
  const palettes = manifestsOf(registry, 'palette')
28
+ // Each theme's icon for the gallery's list (see renderPreviews).
29
+ const icons = new Map(catalogue.themes.map(entry => [entry.id, entry.icon]))
28
30
  const colours = tokens => Object.fromEntries(['--bg', '--surface', '--ink', '--accent', '--accent-2'].map(key => [key, tokens?.[key] ?? null]))
29
31
  return {
30
32
  mdeck: VERSION,
31
33
  themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', builtIn: builtIn.has(theme.id + ':t'),
32
- palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id) })),
34
+ palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id),
35
+ ...(icons.get(theme.id) ? { icon: icons.get(theme.id) } : {}) })),
33
36
  palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', builtIn: builtIn.has(palette.id + ':p'),
34
37
  ...(palette.theme ? { theme: palette.theme } : {}), light: colours(palette.light), dark: colours(palette.dark) })),
35
38
  }
@@ -148,8 +151,11 @@ export async function runPackages({ kind, positionals, flag, output, ui: { ok, e
148
151
  ok(`Checked ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes, light and dark, on every kind of slide`)
149
152
  }
150
153
  // Themes on the title slide, palettes on the chapter slide (the third).
151
- await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.key}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
152
- for (const entry of catalogue.themes) entry.preview = `previews/themes/${entry.id}.webp`
154
+ // Themes also get an icon of their title slide for lists; a palette's
155
+ // colours are its icon.
156
+ await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.key}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1,
157
+ ...(look.palette ? {} : { icon: resolve(out, 'previews', `${look.key}-icon.webp`) }) })), { extensions })
158
+ for (const entry of catalogue.themes) Object.assign(entry, { preview: `previews/themes/${entry.id}.webp`, icon: `previews/themes/${entry.id}-icon.webp` })
153
159
  for (const entry of catalogue.palettes) entry.preview = `previews/palettes/${entry.id}.webp`
154
160
  writeFileSync(resolve(out, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
155
161
  // For the repository's theme browser: the sample deck with every theme
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import { currentBibliography, onBibliography } from '../runtime/citations.js'
3
+
4
+ // <bibliography /> on a slide: the deck's reference list, the works it cites
5
+ // plus `nocite`, in the citation style's order. A long list goes over several
6
+ // slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
7
+
8
+ function slice(order, part) {
9
+ const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
10
+ if (!match) return order
11
+ const [index, count] = [Number(match[1]), Number(match[2])]
12
+ if (!count || index < 1 || index > count) return order
13
+ const size = Math.ceil(order.length / count)
14
+ return order.slice((index - 1) * size, index * size)
15
+ }
16
+
17
+ export default function Bibliography({ part }) {
18
+ const [bib, setBib] = useState(currentBibliography)
19
+ useEffect(() => onBibliography(setBib), [])
20
+ if (!bib) return <p class="bibliography-hint">Name a reference file in the deck settings, e.g. <code>bibliography: refs.bib</code>.</p>
21
+ const html = slice(bib.order, part).map(id => bib.entries[id]).join('')
22
+ return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
23
+ }
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { useRoom, latestByDevice } from '../live/client.js'
4
4
  import { t } from '../core/labels.js'
5
5
  import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
6
+ import { Icon } from './Icon.jsx'
6
7
  import './poll.css'
7
8
 
8
9
  // <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
@@ -16,13 +17,20 @@ import './poll.css'
16
17
  // with a tick outlines its label, bar and count in green. Showing it is an
17
18
  // event on the stage (`pollreveal`, { room, shown }), which the ink bus
18
19
  // passes to the audience window and other devices.
20
+ //
21
+ // A button with an eye shows or hides the results (bars and counts), the
22
+ // same way (`pollresults`, { room, shown }). `results="hidden"` starts with
23
+ // them hidden, e.g. for peer instruction. Hidden, the audience window shows no
24
+ // bars; the presenter's own screens show them faint, so the presenter can
25
+ // still judge the votes.
19
26
 
20
27
  const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
21
28
 
22
- export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '' }) {
29
+ export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '', results = '' }) {
23
30
  const choices = choicesOf(options)
24
31
  const correct = choicesOf(answer).filter(choice => choices.includes(choice))
25
32
  const [shown, setShown] = useState(false)
33
+ const [resultsShown, setResultsShown] = useState(results !== 'hidden')
26
34
  const ref = useRef()
27
35
  const stage = () => ref.current?.closest('deck-stage')
28
36
  useEffect(() => {
@@ -32,18 +40,30 @@ export default function Poll({ room = 'poll', question = '', options = '', qr, a
32
40
  target.addEventListener('pollreveal', onReveal)
33
41
  return () => target.removeEventListener('pollreveal', onReveal)
34
42
  }, [room, correct.length])
43
+ useEffect(() => {
44
+ const target = stage()
45
+ if (!target) return
46
+ const onResults = event => { if (event.detail?.room === room) setResultsShown(!!event.detail.shown) }
47
+ target.addEventListener('pollresults', onResults)
48
+ return () => target.removeEventListener('pollresults', onResults)
49
+ }, [room])
35
50
  const toggle = () => {
36
51
  const next = !shown
37
52
  setShown(next)
38
53
  stage()?.dispatchEvent(new CustomEvent('pollreveal', { detail: { room, shown: next, local: true } }))
39
54
  }
55
+ const toggleResults = () => {
56
+ const next = !resultsShown
57
+ setResultsShown(next)
58
+ stage()?.dispatchEvent(new CustomEvent('pollresults', { detail: { room, shown: next, local: true } }))
59
+ }
40
60
  const live = useRoom(room)
41
61
  const votes = latestByDevice(live.messages).map(message => message.data?.value).filter(vote => choices.includes(vote))
42
62
  const counts = choices.map(choice => votes.filter(vote => vote === choice).length)
43
63
  const max = Math.max(1, ...counts)
44
64
  const code = wantsQr(qr)
45
65
  const still = live.offline && !votes.length
46
- return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}`} ref={ref}>
66
+ return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}${resultsShown ? '' : ' is-results-hidden'}`} ref={ref}>
47
67
  <div class="poll-main">
48
68
  {question && <p class="poll-question">{question}</p>}
49
69
  <div class="poll-bars" role="list">
@@ -54,6 +74,9 @@ export default function Poll({ room = 'poll', question = '', options = '', qr, a
54
74
  </div>)}
55
75
  </div>
56
76
  <ActivityFooter count={votes.length} {...live}>
77
+ <button type="button" class={`poll-results${resultsShown ? '' : ' is-off'}`} title={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-label={t(resultsShown ? 'poll.hideResults' : 'poll.showResults')} aria-pressed={!resultsShown} onClick={toggleResults}>
78
+ <Icon name={resultsShown ? 'eye' : 'eye-off'} size={24} class="poll-results-icon" />
79
+ </button>
57
80
  {correct.length > 0 && <button type="button" class={`poll-solve${shown ? ' is-on' : ''}`} title={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-label={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-pressed={shown} onClick={toggle}>
58
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>
59
82
  </button>}
@@ -1,6 +1,6 @@
1
1
  import { h } from 'preact'
2
2
  import QrCode from './QrCode.jsx'
3
- import { useJoinLink } from '../live/client.js'
3
+ import { useJoinLink, useFollowLink } from '../live/client.js'
4
4
  import { JoinCode } from './activity.jsx'
5
5
  import { t } from '../core/labels.js'
6
6
  import './poll.css'
@@ -9,6 +9,7 @@ import './poll.css'
9
9
  // <qrcode join />: the deck's join link for audience questions, with the
10
10
  // short link below it, so a slide early in the talk can invite everyone once
11
11
  // and the questions after it can leave out their own code (`qr="false"`).
12
+ // <qrcode follow />: the link for following the slides on a phone or laptop.
12
13
 
13
14
  function JoinQr({ size }) {
14
15
  const link = useJoinLink()
@@ -18,10 +19,18 @@ function JoinQr({ size }) {
18
19
  </div>
19
20
  }
20
21
 
22
+ function FollowQr({ size }) {
23
+ const link = useFollowLink()
24
+ return <div class="poll-join-slide">
25
+ <JoinCode {...link} size={Number(size) || 420} label={t('follow.scan')} tryHere={t('follow.tryHere')} />
26
+ </div>
27
+ }
28
+
21
29
  // A bare `join` attribute arrives as "", so only "false" and the like turn it off.
22
30
  const isOn = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
23
31
 
24
- export default function QrCodeTag({ url, join, size }) {
32
+ export default function QrCodeTag({ url, join, follow, size }) {
25
33
  if (isOn(join)) return <JoinQr size={size} />
34
+ if (isOn(follow)) return <FollowQr size={size} />
26
35
  return <QrCode url={url} size={size ?? '200'} />
27
36
  }
@@ -77,6 +77,22 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
77
77
  return () => stage.removeEventListener('slidechange', onSlideChange)
78
78
  }, [play, url])
79
79
 
80
+ // Web video click mode: slides stay mounted, so a playing video would go on
81
+ // (with sound) on the next slide. Leaving the slide unloads the player;
82
+ // coming back shows it ready to play again.
83
+ useEffect(() => {
84
+ if (play !== 'click' || !url) return
85
+ const stage = document.querySelector('deck-stage')
86
+ if (!stage) return
87
+ const embedUrl = getEmbedUrl(url, false)
88
+ function onSlideChange(e) {
89
+ const active = e.detail.slide?.contains(containerRef.current)
90
+ setIframeSrc(current => active ? (current ?? embedUrl) : null)
91
+ }
92
+ stage.addEventListener('slidechange', onSlideChange)
93
+ return () => stage.removeEventListener('slidechange', onSlideChange)
94
+ }, [play, url])
95
+
80
96
  const fixedAspect = typeof aspect === 'string' && aspect.trim() !== ''
81
97
 
82
98
  return (
@@ -12,9 +12,9 @@ export const shortLink = url => url.replace(/^https?:\/\//, '')
12
12
  export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
13
13
 
14
14
  /** The join code with the short link, or how to make phones reach the server. */
15
- export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan') }) {
15
+ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
16
16
  if (!qr || offline || (!known && !joinUrl)) return null
17
- if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{t('poll.tryHere')}</a></p>
17
+ if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{tryHere}</a></p>
18
18
  return <a class="poll-join" href={joinUrl} target="_blank" rel="noopener" data-room={room}>
19
19
  <QrCode url={joinUrl} size={String(size)} />
20
20
  <span>{label}</span>
@@ -0,0 +1,28 @@
1
+ /* ── Citations and references (src/runtime/citations.js) ────────────────── */
2
+ .citation { font-style: normal; }
3
+ .citation-key--missing {
4
+ color: var(--accent);
5
+ text-decoration: underline wavy var(--accent);
6
+ text-underline-offset: 4px;
7
+ }
8
+
9
+ /* A slide's references sit with its footnotes, above the footer. */
10
+ .slide-citations {
11
+ font-size: var(--fs-small);
12
+ color: var(--muted);
13
+ border-top: 1px solid var(--rule);
14
+ padding-top: 20px;
15
+ margin-top: auto;
16
+ }
17
+ .slide-footnotes + .slide-citations { margin-top: 0; border-top: 0; padding-top: 8px; }
18
+ .slide-citations .csl-bib-body { display: flex; flex-direction: column; gap: 4px; }
19
+
20
+ /* Entries hang; numeric styles put the number in a margin column. */
21
+ .csl-entry { line-height: 1.4; padding-left: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
22
+ .csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-left: 0; text-indent: 0; }
23
+ .csl-left-margin { color: var(--muted); font-variant-numeric: tabular-nums; }
24
+ .csl-entry a { color: inherit; text-decoration: none; }
25
+
26
+ /* <bibliography />: the reference list on its slide. */
27
+ .bibliography { display: flex; flex-direction: column; gap: 0.6em; font-size: var(--fs-small); color: var(--ink-soft); }
28
+ .bibliography-hint { color: var(--muted); font-style: italic; }
@@ -18,6 +18,15 @@ deck-stage[data-follower] .poll-reset { display: none; }
18
18
  /* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
19
19
  .poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
20
20
  .poll-reset + .poll-solve { margin-left: 0; }
21
+ .poll-results { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
22
+ .poll-reset + .poll-results, .poll-results + .poll-solve { margin-left: 0; }
23
+ .poll-results:hover, .poll-results:focus-visible { opacity: 1; }
24
+ .poll-results.is-off { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
25
+ deck-stage[data-follower] .poll-results { display: none; }
26
+ /* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
27
+ .poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
28
+ .poll.is-results-hidden .poll-count { opacity: .35; }
29
+ deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
21
30
  .poll-solve svg { width: 24px; height: 24px; }
22
31
  .poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
23
32
  .poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
@@ -67,15 +76,16 @@ deck-stage[data-follower] .poll-solve { display: none; }
67
76
  .poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
68
77
  .poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
69
78
  .poll-join-slide .poll-link { font-size: 28px; }
70
- @media print { .poll-reset, .poll-dot, .poll-solve { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
79
+ @media print { .poll-reset, .poll-dot, .poll-solve, .poll-results { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
71
80
 
72
81
  /* A record of the talk (no room server: a PDF, a file sent to readers): the
73
82
  question and its options, without counts, the live dot or a join code. */
74
83
  .poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
84
+ .poll.is-static .poll-results { display: none; }
75
85
  .poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
76
86
  .poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
77
87
 
78
88
  /* On paper the join code and the presenter's controls have no use. */
79
89
  @media print {
80
- .poll-join, .poll-reset, .poll-solve, .poll-dot { display: none !important; }
90
+ .poll-join, .poll-reset, .poll-solve, .poll-dot, .poll-results { display: none !important; }
81
91
  }
@@ -0,0 +1,108 @@
1
+ import { sourceLines, fenceState } from './source.js'
2
+
3
+ // Pandoc's citation syntax, shared by the build (which formats the deck's
4
+ // citations with citeproc) and the runtime (which puts the formatted text in
5
+ // place): `[@smith2020]`, `[see @smith2020, p. 12; @doe2019]`, `[-@smith2020]`
6
+ // and narrative `@smith2020` (`Smith (2020)`), optionally `@smith2020 [p. 12]`.
7
+ // A narrative `@key` counts only when `isKey` knows it, so e-mail addresses and
8
+ // handles stay text. Fenced and inline code are left alone.
9
+
10
+ const KEY = String.raw`[\w][\w:.#$%&+?<>~/-]*`
11
+ // Not a link (`](`, `][ref]`); a footnote may follow (`[@a][^1]`).
12
+ const NOT_LINK = String.raw`(?!\(|\[(?!\^))`
13
+ const BRACKETED = new RegExp(String.raw`\[([^\[\]\n]*?-?@[\w][^\[\]\n]*?)\]${NOT_LINK}`, 'g')
14
+ // The key starts a word: `[info@fhnw.ch]` is an address, not a citation.
15
+ const ITEM = new RegExp(String.raw`^((?:.*?\s)?)(-?)@(${KEY})(.*)$`, 's')
16
+ const NARRATIVE = new RegExp(String.raw`(?<![\w@\[\]\`])@(${KEY})(?:\s\[([^\[\]\n]+)\]${NOT_LINK})?`, 'g')
17
+
18
+ // Locator terms readers write after a comma, mapped to CSL labels.
19
+ const LOCATORS = [
20
+ [/^(pp?\.?|pages?|S\.|Seiten?)\s*/i, 'page'],
21
+ [/^(chaps?\.?|ch\.|chapters?|Kap\.)\s*/i, 'chapter'],
22
+ [/^(secs?\.?|sections?|§§?)\s*/i, 'section'],
23
+ [/^(figs?\.?|figures?|Abb\.)\s*/i, 'figure'],
24
+ [/^(vols?\.?|volumes?|Bd\.)\s*/i, 'volume'],
25
+ [/^(paras?\.?|paragraphs?|¶¶?)\s*/i, 'paragraph'],
26
+ [/^(nos?\.?|numbers?|Nr\.)\s*/i, 'issue'],
27
+ [/^(ll?\.|lines?)\s*/i, 'line'],
28
+ [/^(eqs?\.?|equations?)\s*/i, 'equation'],
29
+ ]
30
+
31
+ // Keys end before trailing punctuation: `@smith2020.` cites `smith2020`.
32
+ const trimKey = key => key.replace(/[:.#$%&+?<>~/-]+$/, '')
33
+
34
+ // `, p. 12, emphasis added` → locator `12`, label `page`, suffix `, emphasis added`.
35
+ export function parseLocator(text) {
36
+ const rest = text.replace(/^\s*,?\s*/, '')
37
+ if (!rest) return {}
38
+ for (const [pattern, label] of LOCATORS) {
39
+ const term = rest.match(pattern)
40
+ if (!term) continue
41
+ const value = rest.slice(term[0].length).match(/^[^\s,;]+(?:\s*[-–]\s*[^\s,;]+)?/)
42
+ if (value) return withSuffix({ label, locator: value[0] }, rest.slice(term[0].length + value[0].length))
43
+ }
44
+ const number = rest.match(/^\d[\w]*(?:\s*[-–]\s*\w+)?/)
45
+ if (number) return withSuffix({ label: 'page', locator: number[0] }, rest.slice(number[0].length))
46
+ return { suffix: text.trim() }
47
+ }
48
+
49
+ const withSuffix = (item, suffix) => suffix.trim() ? { ...item, suffix: suffix.trim() } : item
50
+
51
+ function parseItem(part) {
52
+ const match = part.match(ITEM)
53
+ if (!match) return null
54
+ const id = trimKey(match[3])
55
+ const after = match[3].slice(id.length) + match[4]
56
+ const item = { id, ...parseLocator(after) }
57
+ if (match[1].trim()) item.prefix = match[1].trim()
58
+ if (match[2]) item.suppressAuthor = true
59
+ return item
60
+ }
61
+
62
+ // Ranges of `code` and link targets in a line, which hold no citations.
63
+ function codeSpans(text) {
64
+ const spans = []
65
+ const pattern = /(`+)[\s\S]*?\1|\]\([^)\s]*(?:\s[^)]*)?\)/g
66
+ for (let match; (match = pattern.exec(text));) spans.push([match.index, match.index + match[0].length])
67
+ return spans
68
+ }
69
+
70
+ // Every citation in `markdown`, in order: `{ start, end, raw, items, narrative }`.
71
+ // `raw` is the text as written, the key the build's formatted output is filed under.
72
+ export function scanCitations(markdown, { isKey = () => true } = {}) {
73
+ const found = []
74
+ let fence = null
75
+ for (const line of sourceLines(markdown)) {
76
+ const before = fence
77
+ fence = fenceState(line.text, fence)
78
+ if (before || fence || !line.text.includes('@')) continue
79
+ const code = codeSpans(line.text)
80
+ const taken = []
81
+ const free = (start, end) => ![...code, ...taken].some(([a, b]) => start < b && end > a)
82
+ for (const match of line.text.matchAll(BRACKETED)) {
83
+ const end = match.index + match[0].length
84
+ // `[@key]: …` at the start of a line defines a link reference.
85
+ const definition = line.text[end] === ':' && !line.text.slice(0, match.index).trim()
86
+ if (line.text[match.index - 1] === '!' || definition || !free(match.index, end)) continue
87
+ const items = match[1].split(';').map(parseItem)
88
+ if (items.some(item => !item)) continue
89
+ taken.push([match.index, end])
90
+ found.push({ start: line.start + match.index, end: line.start + end, raw: match[0], items, narrative: false })
91
+ }
92
+ for (const match of line.text.matchAll(NARRATIVE)) {
93
+ const id = trimKey(match[1])
94
+ const keyEnd = match.index + 1 + id.length
95
+ const end = match[2] != null ? match.index + match[0].length : keyEnd
96
+ if (!isKey(id) || !free(match.index, end)) continue
97
+ taken.push([match.index, end])
98
+ const item = { id, ...(match[2] != null ? parseLocator(match[2]) : {}) }
99
+ found.push({ start: line.start + match.index, end: line.start + end, raw: line.text.slice(match.index, end), items: [item], narrative: true })
100
+ }
101
+ }
102
+ return found.sort((a, b) => a.start - b.start)
103
+ }
104
+
105
+ // The cited keys, each once, in order of first citation.
106
+ export function citedKeys(clusters) {
107
+ return [...new Set(clusters.flatMap(cluster => cluster.items.map(item => item.id)))]
108
+ }
@@ -4,6 +4,7 @@
4
4
  // identical; callers reparse the result.
5
5
  import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
6
6
  import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
7
+ import { maskCode } from '../live/roomTag.js'
7
8
 
8
9
  const REGION_RE = /^[a-z][a-z0-9-]*$/
9
10
 
@@ -228,3 +229,45 @@ export function stripNotes(source) {
228
229
  .map(block => blockRemoval(deck, block)))
229
230
  return edit(deck, edits)
230
231
  }
232
+
233
+ // The live activities, and the code to join them (<qrcode join />): a slide
234
+ // with one of them is left out of shared builds. <qrcode follow /> and other
235
+ // QR codes stay.
236
+ const ACTIVITY_RE = /<(poll|question|wordcloud|scale)\b/i
237
+ const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
238
+ const joins = text => { const m = JOIN_RE.exec(text); return !!m && !/^(false|no|off|0)$/i.test(m[1] ?? m[2] ?? m[3] ?? '') }
239
+ export function hasActivity(slide) {
240
+ return [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
241
+ .some(text => { const masked = maskCode(text); return ACTIVITY_RE.test(masked) || joins(masked) })
242
+ }
243
+
244
+ // Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
245
+ // <scale>) for shared builds: a reader cannot vote, and the results belong to
246
+ // the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
247
+ // to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
248
+ // that are gone, so saved drawings can follow their slides.
249
+ export function stripActivities(source) {
250
+ const deck = parseSlides(source)
251
+ const dropped = deck.slides.filter(hasActivity)
252
+ const same = () => ({ source, ids: Object.fromEntries(deck.slides.map(slide => [slide.id, slide.id])), removed: [] })
253
+ // A deck of nothing but activities stays as it is rather than becoming empty.
254
+ if (!dropped.length || dropped.length === deck.slides.length) return same()
255
+ const ranges = dropped.map(slide => {
256
+ const bounds = slideBounds(deck, slide.id)
257
+ const next = deck.slides[deck.slides.indexOf(slide) + 1]
258
+ return bounds.delimiterStart != null
259
+ ? { start: bounds.delimiterStart, end: bounds.end }
260
+ : { start: bounds.start, end: next ? next.source.start : deck.source.length }
261
+ }).sort((a, b) => a.start - b.start)
262
+ // Neighbouring slides can share a delimiter: join ranges that touch.
263
+ const merged = []
264
+ for (const range of ranges) {
265
+ const last = merged.at(-1)
266
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
267
+ else merged.push({ ...range })
268
+ }
269
+ const next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
270
+ const after = parseSlides(next).slides
271
+ const kept = deck.slides.filter(slide => !dropped.includes(slide))
272
+ return { source: next, ids: Object.fromEntries(kept.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
273
+ }
package/src/core/ink.js CHANGED
@@ -188,6 +188,22 @@ export const inkPaint = color => PEN_PAINT[color] ?? color
188
188
  /** Strokes in drawing order: highlighter below the pen, as on paper. */
189
189
  export const inkOrder = strokes => [...strokes.filter(s => s.tool === 'highlighter'), ...strokes.filter(s => s.tool !== 'highlighter')]
190
190
 
191
+ /**
192
+ * The end of a straight line from `from` to `to`, turned onto the nearest
193
+ * multiple of `step` degrees when it lies within `tolerance` of it (0°, 15°,
194
+ * 30° … by default); its length stays. Further off, the end stays as drawn.
195
+ */
196
+ export function snapAngle([x0, y0], [x, y], { step = 15, tolerance = 4 } = {}) {
197
+ const dx = x - x0, dy = y - y0
198
+ const length = Math.hypot(dx, dy)
199
+ if (!length) return [x, y]
200
+ const angle = Math.atan2(dy, dx) * 180 / Math.PI
201
+ const snapped = Math.round(angle / step) * step
202
+ if (Math.abs(angle - snapped) > tolerance) return [x, y]
203
+ const rad = snapped * Math.PI / 180
204
+ return [Math.round((x0 + Math.cos(rad) * length) * 10) / 10, Math.round((y0 + Math.sin(rad) * length) * 10) / 10]
205
+ }
206
+
191
207
  /** The stroke moved by dx, dy design pixels; the same id, so it replaces the original. */
192
208
  export const translateStroke = (stroke, dx, dy) => ({ ...stroke, points: stroke.points.map(([x, y, p]) => [x + dx, y + dy, p]) })
193
209