mdeck 3.10.1 → 3.12.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 (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
@@ -0,0 +1,75 @@
1
+ // The keys that do something right now, for the help that ? shows over the
2
+ // presenter view and the deck window. Each window has its own keys
3
+ // (main.jsx for the presenter view, deck-stage.js and ink/attach.js for the
4
+ // deck window); what is listed depends on the moment: the number keys only
5
+ // while the slide shown has hotspots (without them they do nothing), the
6
+ // drawing keys while drawing, L only with several
7
+ // languages, F only where the browser can go full screen.
8
+ //
9
+ // `view` is 'presenter' or 'deck'; `hotspots` whether the number keys zoom on
10
+ // the slide shown; `hotspotSwitch` whether the presenter view's switch (H) is
11
+ // on, which a slide's own setting can overrule; `inking` whether drawing is
12
+ // on; `canDraw` whether D works in this window; `fullscreen` whether F does;
13
+ // `drawer` whether N does (the presenter view's slide-only layout);
14
+ // `languages` whether L does; `blank` whether the screen is black (B);
15
+ // `overview` whether the grid of all slides (O) is open; `join` whether J
16
+ // works (a deck phones join) and `joinShown` whether
17
+ // the join code is over the slide;
18
+ // `mac` names the command key ⌘ instead of Ctrl.
19
+ // Returns groups: [{ title, keys: [{ keys: ['→', 'Space'], label }] }].
20
+
21
+ export const HELP_KEY = '?'
22
+
23
+ export function keyHelp({ view = 'deck', blank = false, join = false, joinShown = false, overview = false, hotspots = false, hotspotSwitch = hotspots, inking = false, canDraw = true, fullscreen = false, drawer = false, languages = false, mac = false } = {}) {
24
+ const presenter = view === 'presenter'
25
+ const command = mac ? '⌘' : 'Ctrl'
26
+ const entry = (keys, label) => ({ keys, label })
27
+ const groups = []
28
+
29
+ groups.push({ title: 'Slides', keys: [
30
+ entry(['→', 'Space', 'PgDn'], 'Next step or slide'),
31
+ entry(['←', 'PgUp'], 'Previous step or slide'),
32
+ ...(presenter
33
+ ? [entry(['Home', 'R'], 'Back to the first slide')]
34
+ : [entry(['Home'], 'First slide'), entry(['End'], 'Last slide'), entry(['R'], 'Back to the first slide, steps hidden again')]),
35
+ ] })
36
+
37
+ groups.push({ title: 'Presenting', keys: [
38
+ ...(presenter ? [entry(['T'], 'Start or pause the timer')] : []),
39
+ entry(['O'], overview ? 'Close the overview' : 'All slides, to pick one'),
40
+ entry(['B', '.'], blank ? 'Show the slide again' : 'Black screen for the audience'),
41
+ ...(join ? [entry(['J'], joinShown ? 'Hide the join code' : 'Show the join code over the slide')] : []),
42
+ ...(presenter && drawer ? [entry(['N'], 'Show or hide the notes')] : []),
43
+ ...(presenter ? [entry(['H'], hotspotSwitch ? 'Turn hotspots off' : 'Turn hotspots on')] : []),
44
+ ...(presenter && languages ? [entry(['L'], 'Next language')] : []),
45
+ ...(canDraw ? [entry(['D'], inking ? 'Stop drawing' : 'Draw on the slide')] : []),
46
+ ...(!presenter ? [entry(['I'], 'Show or hide the drawings')] : []),
47
+ ...(fullscreen ? [entry(['F'], 'Full screen')] : []),
48
+ ] })
49
+
50
+ if (overview) groups.push({ title: 'All slides', keys: [
51
+ entry(['←', '→', '↑', '↓'], 'Move between the slides'),
52
+ entry(['Enter'], 'Go to that slide'),
53
+ entry(['O', 'Esc'], 'Back to the slide shown'),
54
+ ] })
55
+
56
+ if (hotspots && !overview) groups.push({ title: 'Hotspots', keys: [
57
+ entry(['1–9'], 'Zoom to a part of the slide'),
58
+ entry(['0', 'Esc'], 'Show the whole slide'),
59
+ ] })
60
+
61
+ // The drawing keys are those of the deck window's own frame; the presenter
62
+ // view passes on only D.
63
+ if (inking && !presenter) groups.push({ title: 'Drawing', keys: [
64
+ entry(['Esc'], 'Stop drawing'),
65
+ entry([`${command} Z`], 'Undo'),
66
+ entry([`Shift ${command} Z`], 'Redo'),
67
+ entry(['Delete'], 'Remove the selected drawing'),
68
+ ] })
69
+
70
+ groups.push({ title: 'Help', keys: [entry([HELP_KEY], 'Show or hide these keys'), entry(['Esc'], 'Close')] })
71
+ return groups.filter(group => group.keys.length)
72
+ }
73
+
74
+ /** Whether a key event is the one that shows the help: ? without Ctrl, ⌘ or Alt. */
75
+ export const isHelpKey = event => event.key === HELP_KEY && !event.metaKey && !event.ctrlKey && !event.altKey
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Messages, the same on every transport:
7
7
  // { type: 'segment', key, slideId, tool, color, size, from, points }
8
- // points `from` index on, every ~60 ms while drawing
8
+ // points `from` index on, every ~25 ms while drawing
9
9
  // { type: 'end', key, fade, stroke? } the stroke is finished; a laser
10
10
  // trail (tool 'laser', never saved) retracts on its own
11
11
  // { type: 'dot', key, slideId, point } the laser's dot (null: hidden), as
@@ -17,10 +17,15 @@
17
17
  // { type: 'media', key, video, action, time } a video in the deck played,
18
18
  // paused or moved to `time` (components/VideoPlayer.jsx)
19
19
  // { type: 'op', op } a change of the saved ink (applyOp)
20
+ // { type: 'shared', key, name, value, replay? } a deck component's shared
21
+ // state (../shared.js); `replay` answers a window that opened later
22
+ // { type: 'shared-ask', key } a window that opened asks for it
20
23
  // Keys start with the sending window's id, so strokes never mix.
21
24
  import { changeInk, renameSlide } from './store.js'
25
+ import { joinSegment } from './segments.js'
26
+ import { connectShared, receiveShared } from '../shared.js'
22
27
 
23
- const SEGMENT_MS = 60
28
+ const SEGMENT_MS = 25
24
29
  const DOT_MS = 50
25
30
 
26
31
  export function broadcastTransport(name) {
@@ -47,13 +52,14 @@ export function createInkBus(stage, transports) {
47
52
  else changeInk(message.op)
48
53
  }
49
54
  else if (message.type === 'segment') {
50
- const known = incoming.get(message.key) ?? { ...message, points: [] }
51
- // A lost segment leaves a gap until the end message repairs it.
52
- if (message.from <= known.points.length) known.points = [...known.points.slice(0, message.from), ...message.points]
55
+ // Out of order, a segment waits for the one before it (segments.js).
56
+ const known = joinSegment(incoming.get(message.key) ?? { ...message, points: [] }, message)
53
57
  incoming.set(message.key, known)
54
58
  stage.liveStroke(message.key, known)
55
59
  } else if (message.type === 'controls') {
56
60
  stage.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room: message.room, controls: message.controls } }))
61
+ } else if (message.type === 'shared' || message.type === 'shared-ask') {
62
+ receiveShared(message)
57
63
  } else if (message.type === 'media') {
58
64
  stage.dispatchEvent(new CustomEvent('mediacontrol', { detail: { video: message.video, action: message.action, time: message.time } }))
59
65
  } else if (message.type === 'zoom') {
@@ -71,6 +77,8 @@ export function createInkBus(stage, transports) {
71
77
  }
72
78
  }
73
79
  for (const transport of transports) transport.listen(receive)
80
+ // Deck components' shared state rides along; this window asks for it now.
81
+ connectShared(message => send({ ...message, key: `${me}:shared` }))
74
82
 
75
83
  // Own strokes in progress, sent in small pieces.
76
84
  const outgoing = new Map()
@@ -0,0 +1,27 @@
1
+ // What live ink does while the network is gone (room.js): it waits longer
2
+ // after each failed attempt, and the messages it holds stay few. Segments
3
+ // and finished strokes must all arrive, or the others keep a gap; the
4
+ // laser's dot and the zoom only say where they are now, so the last of each
5
+ // is enough.
6
+
7
+ const RETRY_MIN_MS = 250
8
+ const RETRY_MAX_MS = 5000
9
+ // Past this many held messages the oldest strokes in progress go first: a
10
+ // stroke drawn long ago in a dead WLAN is not worth a flood when it returns.
11
+ export const OUTBOX_LIMIT = 2000
12
+
13
+ // The wait before the next attempt after `failures` failed ones in a row.
14
+ export function retryDelay(failures) {
15
+ return failures ? Math.min(RETRY_MAX_MS, RETRY_MIN_MS * 2 ** (failures - 1)) : 0
16
+ }
17
+
18
+ // The messages worth sending, in their order: only the last dot and zoom of
19
+ // each sender, and no more than `limit`, dropping the oldest segments first.
20
+ export function compactOutbox(messages, limit = OUTBOX_LIMIT) {
21
+ const last = new Map()
22
+ messages.forEach((message, i) => { if (message.type === 'dot' || message.type === 'zoom') last.set(`${message.type}:${message.key}`, i) })
23
+ let kept = messages.filter((message, i) => !(message.type === 'dot' || message.type === 'zoom') || last.get(`${message.type}:${message.key}`) === i)
24
+ let over = kept.length - limit
25
+ if (over > 0) kept = kept.filter(message => !(message.type === 'segment' && over-- > 0))
26
+ return kept
27
+ }
@@ -8,8 +8,11 @@
8
8
  // connections per server.
9
9
  import { stageRoom } from '../../live/client.js'
10
10
  import { presenceQuery } from '../../live/presence.js'
11
+ import { retryDelay, compactOutbox } from './outbox.js'
11
12
 
12
- const BATCH_MS = 80
13
+ // Live ink waits this long to gather a batch; with one request at a time the
14
+ // network sets the pace, so a short wait adds little load and little lag.
15
+ const BATCH_MS = 25
13
16
 
14
17
  /**
15
18
  * An ink bus transport through the stage room; `listen` only when `receive`.
@@ -17,23 +20,45 @@ const BATCH_MS = 80
17
20
  */
18
21
  export function roomTransport({ receive = false, view = null, onPresence = null } = {}) {
19
22
  const room = stageRoom()
20
- let queue = [], timer = null, allowed = null, listeners = 1, source = null
23
+ let queue = [], timer = null, allowed = null, listeners = 1, source = null, sending = false, failures = 0
21
24
 
25
+ // One request at a time: two in flight could arrive in the other order.
22
26
  async function flush() {
23
27
  timer = null
24
- if (!queue.length) return
28
+ if (!queue.length || sending) return
29
+ sending = true
30
+ try { await deliver() } finally { sending = false }
31
+ // What came in meanwhile has waited already: send it right away, unless
32
+ // the last attempt failed; then wait longer each time.
33
+ if (queue.length) timer ??= setTimeout(flush, retryDelay(failures))
34
+ }
35
+
36
+ // The batch goes back to the front, to be sent again after a wait.
37
+ function retry(messages) {
38
+ failures++
39
+ queue = compactOutbox([...messages, ...queue])
40
+ }
41
+
42
+ async function deliver() {
25
43
  const messages = queue.splice(0, 100)
26
44
  if (allowed === null) {
27
- const info = await room.info()
45
+ let info
46
+ try { info = await room.info() } catch { return retry(messages) }
28
47
  allowed = info.reachable ? !!info.canReset : null
29
48
  if (!allowed) { queue = []; return }
30
49
  }
31
50
  try {
32
51
  const response = await fetch(`${room.url}/ink`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ messages }) })
33
52
  if (response.status === 403) { allowed = false; queue = []; return }
53
+ // A server that is busy or restarting gets them again, as a lost network does.
54
+ if (response.status >= 500 || response.status === 429) return retry(messages)
55
+ failures = 0
34
56
  if (response.ok) listeners = (await response.json()).listeners ?? 1
35
- } catch {}
36
- if (queue.length) timer = setTimeout(flush, BATCH_MS)
57
+ } catch {
58
+ // The network failed (an iPad's WLAN): send them again, or the others
59
+ // keep a gap. A repeated segment only writes the same points again.
60
+ retry(messages)
61
+ }
37
62
  }
38
63
 
39
64
  return {
@@ -75,10 +100,10 @@ const stateListeners = new Set()
75
100
  export function followStageRoom(stage, { onLanguage } = {}) {
76
101
  stateListeners.add(state => {
77
102
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
78
- const position = { index: state.index, step: state.step ?? -1, slideId: state.slideId }
103
+ const position = { index: state.index, step: state.step ?? -1, slideId: state.slideId, ...(typeof state.blank === 'boolean' ? { blank: state.blank } : {}) }
79
104
  if (state.lang && onLanguage?.(state.lang, position)) return
80
105
  const current = stage.state
81
- if (current?.index === state.index && current?.step === state.step) return
106
+ if (current?.index === state.index && current?.step === state.step && (position.blank === undefined || current?.blank === position.blank)) return
82
107
  stage.setState(position)
83
108
  })
84
109
  }
@@ -87,7 +112,7 @@ export function followStageRoom(stage, { onLanguage } = {}) {
87
112
  export function onStagePosition(listener) {
88
113
  stateListeners.add(state => {
89
114
  if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
90
- listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId, lang: state.lang })
115
+ listener({ index: state.index, step: state.step ?? -1, slideId: state.slideId, lang: state.lang, blank: state.blank })
91
116
  })
92
117
  }
93
118
 
@@ -100,9 +125,9 @@ export function announceStagePosition(stage, { language = () => null } = {}) {
100
125
  const room = stageRoom()
101
126
  let allowed = null, last = null
102
127
  async function post(event) {
103
- const { index, step, slideId } = stage.state
128
+ const { index, step, slideId, blank } = stage.state
104
129
  const lang = language() ?? undefined
105
- const key = `${slideId}:${index}:${step}:${lang}`
130
+ const key = `${slideId}:${index}:${step}:${lang}:${blank}`
106
131
  if (event?.detail.reason === 'sync') { last = key; return }
107
132
  if (key === last || allowed === false) return
108
133
  // Opening a window must not move the projector; only changes count.
@@ -113,7 +138,7 @@ export function announceStagePosition(stage, { language = () => null } = {}) {
113
138
  allowed = info.reachable && !!info.canReset
114
139
  if (!allowed) return
115
140
  }
116
- fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, lang, at, screen: room.screen } }) }).catch(() => {})
141
+ fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, lang, blank, at, screen: room.screen } }) }).catch(() => {})
117
142
  }
118
143
  stage.addEventListener('statechange', post)
119
144
  post()
@@ -0,0 +1,21 @@
1
+ // A stroke drawn in another window arrives in segments: { from, points },
2
+ // the points from index `from` on. Segments can arrive out of order, and one
3
+ // can be lost: one that starts past the points known waits in `early` until
4
+ // the gap is filled, rather than being dropped with all that follow it. A
5
+ // lost one leaves the gap until the stroke's end message repairs it.
6
+
7
+ /** Adds a segment to `known` ({ points, early }) and returns it. */
8
+ export function joinSegment(known, { from, points }) {
9
+ known.early ??= new Map()
10
+ known.early.set(from, points)
11
+ for (let joined = true; joined;) {
12
+ joined = false
13
+ for (const [at, more] of known.early) {
14
+ if (at > known.points.length) continue
15
+ known.points = [...known.points.slice(0, at), ...more]
16
+ known.early.delete(at)
17
+ joined = true
18
+ }
19
+ }
20
+ return known
21
+ }