mdeck 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +33 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +89 -6
  8. package/docs/site/content/audience.md +41 -4
  9. package/docs/site/content/commands.md +30 -4
  10. package/docs/site/content/drawing.md +1 -1
  11. package/docs/site/content/more-content.md +22 -0
  12. package/docs/site/content/sharing.md +1 -0
  13. package/docs/site/content/troubleshooting.md +1 -1
  14. package/docs/site/content/views.md +3 -1
  15. package/examples/citations/refs.bib +30 -0
  16. package/examples/citations/refs.yml +21 -0
  17. package/examples/citations/slides.md +43 -0
  18. package/examples/citations/slides.pdf +0 -0
  19. package/package.json +5 -1
  20. package/skills/write-slides/SKILL.md +7 -3
  21. package/src/build/bibliography.js +307 -0
  22. package/src/build/check.js +12 -0
  23. package/src/build/renderCheck.js +98 -0
  24. package/src/build/resultsPlugin.js +77 -0
  25. package/src/build/slidesPlugin.js +70 -7
  26. package/src/build/themeCheck.js +8 -5
  27. package/src/cli/args.js +22 -5
  28. package/src/cli/index.js +54 -12
  29. package/src/components/Bibliography.jsx +23 -0
  30. package/src/components/Numeric.jsx +59 -0
  31. package/src/components/Poll.jsx +59 -33
  32. package/src/components/QrCodeTag.jsx +11 -2
  33. package/src/components/Question.jsx +10 -6
  34. package/src/components/Scale.jsx +13 -8
  35. package/src/components/VideoPlayer.jsx +16 -0
  36. package/src/components/WordCloud.jsx +10 -6
  37. package/src/components/activity.jsx +90 -9
  38. package/src/components/citation.css +28 -0
  39. package/src/components/inlineText.js +40 -0
  40. package/src/components/numbers.js +39 -0
  41. package/src/components/poll.css +27 -7
  42. package/src/core/citations.js +108 -0
  43. package/src/core/editDeck.js +43 -0
  44. package/src/core/icons.js +2 -0
  45. package/src/core/ink.js +16 -0
  46. package/src/core/labels.js +48 -0
  47. package/src/core/parseSlides.js +1 -1
  48. package/src/core/results.js +64 -0
  49. package/src/core/urls.js +2 -2
  50. package/src/core/validateDeck.js +7 -1
  51. package/src/editor/sampleDeck.js +34 -8
  52. package/src/layouts/layoutApi.jsx +14 -3
  53. package/src/live/answer/answer.css +20 -0
  54. package/src/live/answer/answer.js +149 -24
  55. package/src/live/client.js +75 -7
  56. package/src/live/roomTag.js +6 -2
  57. package/src/live/rooms.js +6 -1
  58. package/src/runtime/ReaderView.jsx +2 -1
  59. package/src/runtime/citations.js +70 -0
  60. package/src/runtime/deck-stage.js +5 -2
  61. package/src/runtime/editorBridge.js +7 -1
  62. package/src/runtime/follow.js +89 -0
  63. package/src/runtime/ink/bus.js +7 -6
  64. package/src/runtime/ink/room.js +8 -0
  65. package/src/runtime/ink/select.js +4 -2
  66. package/src/runtime/main.jsx +22 -8
  67. package/src/runtime/registry.jsx +4 -0
@@ -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
+ }
@@ -12,7 +12,8 @@
12
12
  // a hovering pen moves, at most every ~50 ms
13
13
  // { type: 'zoom', key, slideId, scale, x, y } the part of the slide this
14
14
  // window shows (stage zoom), at most every ~50 ms
15
- // { type: 'reveal', key, room, shown } a poll's right answer shown or hidden
15
+ // { type: 'controls', key, room, controls } an activity closed or opened,
16
+ // its right answer or results shown or hidden (components/activity.jsx)
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'
@@ -49,8 +50,8 @@ export function createInkBus(stage, transports) {
49
50
  if (message.from <= known.points.length) known.points = [...known.points.slice(0, message.from), ...message.points]
50
51
  incoming.set(message.key, known)
51
52
  stage.liveStroke(message.key, known)
52
- } else if (message.type === 'reveal') {
53
- stage.dispatchEvent(new CustomEvent('pollreveal', { detail: { room: message.room, shown: !!message.shown } }))
53
+ } else if (message.type === 'controls') {
54
+ stage.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room: message.room, controls: message.controls } }))
54
55
  } else if (message.type === 'zoom') {
55
56
  if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
56
57
  } else if (message.type === 'dot') {
@@ -99,9 +100,9 @@ export function createInkBus(stage, transports) {
99
100
  dotTimer ??= setTimeout(sendDot, DOT_MS)
100
101
  })
101
102
 
102
- // A poll's right answer, shown here: the others show it too.
103
- stage.addEventListener('pollreveal', ({ detail }) => {
104
- if (detail?.local) send({ type: 'reveal', key: `${me}:reveal`, room: detail.room, shown: !!detail.shown })
103
+ // An activity's controls changed here: the others follow.
104
+ stage.addEventListener('pollcontrols', ({ detail }) => {
105
+ if (detail?.local) send({ type: 'controls', key: `${me}:controls`, room: detail.room, controls: detail.controls })
105
106
  })
106
107
 
107
108
  // The zoom: the latest view, not every step of a pinch.
@@ -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'
@@ -12,16 +13,18 @@ import { S, PaletteSwatches } from './chrome.jsx'
12
13
  import { SlideErrorBoundary } from './SlideErrorBoundary.jsx'
13
14
  import { createEditorBridge } from './editorBridge.js'
14
15
  import { ReaderView } from './ReaderView.jsx'
15
- import { configureLive, announce, setLookSource, actAsPresenter, useSteering } from '../live/client.js'
16
+ import { configureLive, announce, setLookSource, actAsPresenter, useSteering, setSavedResults } from '../live/client.js'
16
17
  import { registry } from './registry'
17
18
  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'
27
+ import savedResults from 'virtual:deck-results'
25
28
  import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
26
29
  import './reader.css'
27
30
  import { SlideRenderer, manifests } from '../layouts/renderSlide'
@@ -33,7 +36,7 @@ import './deck-stage.js'
33
36
 
34
37
  const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
35
38
 
36
- // One address parameter selects the view: ?view=deck, reader, presenter or audience.
39
+ // One address parameter selects the view: ?view=deck, reader, presenter, audience or follow.
37
40
  export function requestedView(url, fallback = 'deck') {
38
41
  return url.searchParams.get('view') ?? fallback
39
42
  }
@@ -486,7 +489,7 @@ function PresenterView({ deckConfig, slides }) {
486
489
  }}
487
490
  dangerouslySetInnerHTML={{
488
491
  __html: note
489
- ? marked.parse(note)
492
+ ? marked.parse(renderCitations(note).markdown)
490
493
  : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
491
494
  }}
492
495
  />
@@ -691,14 +694,17 @@ async function init() {
691
694
  setDeckLanguage(deckConfig)
692
695
  document.documentElement.lang = deckLanguage()
693
696
  configureLive(deckConfig)
697
+ setSavedResults(savedResults)
694
698
  const { slides } = parsed
695
699
  const presenterMode = view === 'presenter'
696
700
  const audienceMode = view === 'audience'
701
+ // A phone or laptop in the room following the talk: shows, never sends.
702
+ const followMode = view === 'follow'
697
703
  const embedded = url.searchParams.get('embedded') === '1'
698
704
  const readerMode = view === 'reader' && !editorMode && !embedded
699
705
  // The deck window and the presenter's main frame draw; the audience window
700
706
  // and the next-slide preview show what is drawn.
701
- const drawHere = !audienceMode && (!embedded || url.searchParams.get('draw') === '1')
707
+ const drawHere = !audienceMode && !followMode && (!embedded || url.searchParams.get('draw') === '1')
702
708
  const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
703
709
 
704
710
  injectSpeakerNotes(slides)
@@ -748,6 +754,8 @@ async function init() {
748
754
  if (event.source !== window.parent || event.origin !== window.location.origin) return
749
755
  if (!bridge.handleMessage(event.data)) handleDeckControl(event.data?.deckControl)
750
756
  })
757
+ // Saving the deck or its reference file reformats the citations.
758
+ onBibliography(() => bridge.refresh())
751
759
  await bridge.render(slidesContent)
752
760
  post({ deckEditorReady: true })
753
761
  return
@@ -762,7 +770,7 @@ async function init() {
762
770
 
763
771
  await loadTheme(deckConfig)
764
772
  mountDeck({ deck: parsed, deckConfig })
765
- if (!embedded && !audienceMode) {
773
+ if (!embedded && !audienceMode && !followMode) {
766
774
  // A quiet way into the reader view for anyone who opened the file directly.
767
775
  const entry = document.createElement('a')
768
776
  entry.className = 'reader-entry'
@@ -801,14 +809,20 @@ async function init() {
801
809
  }
802
810
  window.addEventListener('pointerdown', onPen, true)
803
811
  }
812
+ } else if (inkStage && followMode) {
813
+ // The presenter's slide and ink, read from the stage room; counted as a phone.
814
+ inkStage.setAttribute('data-follower', '')
815
+ startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
816
+ watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
804
817
  } else if (inkStage && audienceMode) {
805
818
  // The audience window shows the presenter's zoom and a poll's answer, without their controls.
806
819
  inkStage.setAttribute('data-follower', '')
807
820
  watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'audience' })] })
808
821
  } else if (inkStage) watchInk(inkStage, { storageKey: inkStorageKey })
809
822
 
810
- // A full deck or audience window is a presenter's screen; previews are embedded.
811
- if (!embedded) {
823
+ // A full deck or audience window is a presenter's screen; previews are
824
+ // embedded, and a follower only watches.
825
+ if (!embedded && !followMode) {
812
826
  const stage = document.querySelector('deck-stage')
813
827
  stage?.addEventListener('slidechange', event => announceSlide(parsed, event.detail.index, { initial: event.detail.reason === 'init' }))
814
828
  if (stage) announceSlide(parsed, stage.index, { initial: true })
@@ -5,6 +5,8 @@ import Poll from '../components/Poll.jsx'
5
5
  import Question from '../components/Question.jsx'
6
6
  import WordCloud from '../components/WordCloud.jsx'
7
7
  import Scale from '../components/Scale.jsx'
8
+ import Numeric from '../components/Numeric.jsx'
9
+ import Bibliography from '../components/Bibliography.jsx'
8
10
  import deckComponents from 'virtual:deck-components'
9
11
 
10
12
  // Maps lowercase HTML tag names to Preact components.
@@ -22,5 +24,7 @@ export const registry = {
22
24
  question: Question,
23
25
  wordcloud: WordCloud,
24
26
  scale: Scale,
27
+ numeric: Numeric,
28
+ bibliography: Bibliography,
25
29
  ...deckComponents,
26
30
  }