mdeck 3.10.1 → 3.11.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 (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +13 -2
  3. package/assets/base.css +4 -1
  4. package/docs/reference/authoring.md +65 -4
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/commands.md +3 -1
  7. package/docs/site/content/drawing.md +1 -1
  8. package/docs/site/content/install.md +47 -0
  9. package/docs/site/content/media.md +20 -1
  10. package/docs/site/content/presenting.md +39 -2
  11. package/docs/site/content/sharing.md +1 -0
  12. package/docs/site/content/welcome.md +21 -1
  13. package/docs/site/images/features/hotspots.webp +0 -0
  14. package/docs/site/images/features/languages.webp +0 -0
  15. package/docs/site/pages.js +1 -0
  16. package/examples/custom-layouts/slides.md +21 -9
  17. package/examples/showcase/refs.bib +21 -0
  18. package/examples/showcase/slides.ar.drawings.json +11 -0
  19. package/examples/showcase/slides.ar.md +567 -0
  20. package/examples/showcase/slides.de.drawings.json +11 -0
  21. package/examples/showcase/slides.de.md +567 -0
  22. package/examples/showcase/slides.en.drawings.json +11 -0
  23. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  24. package/package.json +1 -1
  25. package/skills/write-slides/SKILL.md +10 -1
  26. package/src/build/check.js +7 -1
  27. package/src/build/config.js +2 -0
  28. package/src/build/slidesPlugin.js +6 -6
  29. package/src/build/svgCheck.js +65 -0
  30. package/src/build/timingPlugin.js +80 -0
  31. package/src/cli/args.js +4 -3
  32. package/src/cli/index.js +34 -11
  33. package/src/components/VideoPlayer.jsx +6 -1
  34. package/src/core/editDeck.js +44 -7
  35. package/src/core/icons.js +3 -0
  36. package/src/core/labels.js +75 -0
  37. package/src/core/parseSlides.js +3 -3
  38. package/src/core/timing.js +83 -0
  39. package/src/core/validateDeck.js +5 -0
  40. package/src/editor/components/DeckSettings.jsx +1 -0
  41. package/src/editor/components/MetaFields.jsx +1 -0
  42. package/src/home/Home.jsx +1 -0
  43. package/src/layouts/layoutApi.jsx +51 -8
  44. package/src/runtime/RehearsalSummary.jsx +129 -0
  45. package/src/runtime/component.js +27 -0
  46. package/src/runtime/deck-stage.js +49 -20
  47. package/src/runtime/ink/bus.js +13 -5
  48. package/src/runtime/ink/outbox.js +27 -0
  49. package/src/runtime/ink/room.js +31 -6
  50. package/src/runtime/ink/segments.js +21 -0
  51. package/src/runtime/main.jsx +157 -28
  52. package/src/runtime/shared.js +77 -0
  53. package/src/runtime/steps.js +36 -0
  54. package/src/runtime/useTalkTimer.js +65 -0
  55. package/examples/citations/refs.bib +0 -30
  56. package/examples/citations/refs.yml +0 -21
  57. package/examples/citations/slides.md +0 -43
  58. package/examples/citations/slides.pdf +0 -0
  59. package/examples/poll/slides.md +0 -127
  60. package/examples/showcase/slides.drawings.json +0 -11
@@ -6,6 +6,7 @@ import { registry } from '../runtime/registry'
6
6
  import { scanTags, closeTags } from '../core/tags.js'
7
7
  import { expandPollLists } from '../core/pollList.js'
8
8
  import { textDirection } from '../core/languages.js'
9
+ import { sourceLines, fenceState } from '../core/source.js'
9
10
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
10
11
  import themedSvgs from 'virtual:deck-svgs'
11
12
  import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
@@ -15,19 +16,32 @@ import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '
15
16
  const FN_DEF_RE = /^\[\^([^\]\n]+)\]:\s+([^\n]+)/gm
16
17
  const FN_REF_RE = /\[\^([^\]\n]+)\]/g
17
18
 
19
+ // `change(text, lineStart)` applied to the prose of `markdown` only: fenced
20
+ // code blocks and `inline code` keep what is written in them.
21
+ function inProse(markdown, change) {
22
+ let fence = null
23
+ return sourceLines(markdown).map(line => {
24
+ const raw = markdown.slice(line.start, line.end)
25
+ const before = fence
26
+ fence = fenceState(line.text, fence)
27
+ if (before || fence) return raw
28
+ return raw.split(/((`+)[^`]*?\2)/).map((part, i) => i % 3 === 0 ? change(part, i === 0) : i % 3 === 1 ? part : '').join('')
29
+ }).join('')
30
+ }
31
+
18
32
  function preprocessFootnotes(markdown) {
19
33
  const defs = {}
20
- const stripped = markdown.replace(FN_DEF_RE, (_, label, text) => {
21
- defs[label] = text.trim()
34
+ const stripped = inProse(markdown, (text, lineStart) => lineStart ? text.replace(FN_DEF_RE, (_, label, note) => {
35
+ defs[label] = note.trim()
22
36
  return ''
23
- })
37
+ }) : text)
24
38
 
25
39
  let counter = 0
26
40
  const labelToNum = {}
27
- const processed = stripped.replace(FN_REF_RE, (_, label) => {
41
+ const processed = inProse(stripped, text => text.replace(FN_REF_RE, (_, label) => {
28
42
  if (labelToNum[label] === undefined) labelToNum[label] = ++counter
29
43
  return `<sup>${labelToNum[label]}</sup>`
30
- })
44
+ }))
31
45
 
32
46
  if (counter === 0) return { processed: markdown, footnotesHtml: '' }
33
47
 
@@ -94,15 +108,39 @@ export function inlineThemedPictures(html) {
94
108
  return out + html.slice(last)
95
109
  }
96
110
 
111
+ // A drawing in the page more than once (on two slides, or in a slide shown
112
+ // twice) would repeat its ids, and url(#…) and href="#…" find the first copy,
113
+ // which may be on a hidden slide: each copy gets ids of its own.
114
+ let drawingCopies = 0
115
+ export function ownIds(svg) {
116
+ const named = svg.querySelectorAll('[id]')
117
+ if (!named.length || svg.hasAttribute('data-own-ids')) return
118
+ const suffix = `--${++drawingCopies}`
119
+ const renamed = new Map([...named].map(element => [element.id, element.id + suffix]))
120
+ for (const element of named) element.id = renamed.get(element.id)
121
+ const swap = text => text.replace(/url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/g, (match, quote, id) => renamed.has(id) ? `url(#${renamed.get(id)})` : match)
122
+ for (const element of svg.querySelectorAll('*')) {
123
+ for (const attribute of [...element.attributes]) {
124
+ const local = attribute.value.startsWith('#') && renamed.get(attribute.value.slice(1))
125
+ if (attribute.localName === 'href' && local) attribute.value = '#' + local
126
+ else if (attribute.value.includes('url(')) attribute.value = swap(attribute.value)
127
+ }
128
+ if (element.localName === 'style') element.textContent = swap(element.textContent)
129
+ }
130
+ svg.setAttribute('data-own-ids', '')
131
+ }
132
+
97
133
  // A picture from the slide's settings; `fit: cover` fills its box like a
98
134
  // photo, otherwise the drawing fits inside.
99
135
  export function Picture({ src, alt, fit, position, class: className, ...attributes }) {
136
+ const ref = useRef()
100
137
  const markup = themedSvg(src)
138
+ useEffect(() => { const svg = ref.current?.querySelector(':scope > svg'); if (svg) ownIds(svg) }, [markup])
101
139
  if (!markup) return <img class={className} src={src} alt={alt} style={{ objectFit: fit, objectPosition: position }} {...attributes} />
102
140
  const aspect = fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet'
103
141
  // The box sets the size; the drawing scales inside it.
104
142
  const html = markup.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height|preserveAspectRatio)="[^"]*"/g, '').replace('<svg', `<svg preserveAspectRatio="${aspect}"`))
105
- return <div class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
143
+ return <div ref={ref} class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
106
144
  }
107
145
 
108
146
  // ─── Hydration for inline components ──────────────────────────────────────
@@ -116,6 +154,7 @@ export function HtmlContent({ html, class: className, ...attributes }) {
116
154
 
117
155
  useEffect(() => {
118
156
  if (!ref.current) return
157
+ ref.current.querySelectorAll('.themed-picture > svg').forEach(ownIds)
119
158
  const mounted = []
120
159
  for (const [name, Component] of Object.entries(registry)) {
121
160
  for (const el of [...ref.current.querySelectorAll(name)]) {
@@ -197,8 +236,12 @@ export function prepareSlide(slide) {
197
236
  const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
198
237
  const entries = Object.entries(slide.regions ?? { body: { name: 'body', content: slide.content } })
199
238
  .sort((a, b) => position(a[1]) - position(b[1]))
200
- let separator = '\u0000mdeck-region\u0000'
201
- while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
239
+ // The regions are scanned as one text, so numbered citations and footnotes
240
+ // run on across them. The separator stands on a line of its own: joined
241
+ // mid-line, a region's closing ``` fence read as an opening one, and the
242
+ // next region as code.
243
+ let separator = '\n\u0000mdeck-region\u0000\n'
244
+ while (entries.some(([, r]) => r.content.includes(separator.trim()))) separator = separator.replace('\u0000\n', '\u0000\u0000\n')
202
245
  const bib = bibliographyOf(slide.deckConfig)
203
246
  const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
204
247
  const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
@@ -0,0 +1,129 @@
1
+ // The end of a rehearsal: each slide's planned time, the time it got, and
2
+ // the time to save, which starts at the rehearsed time rounded to 15 s and
3
+ // can be moved in 15 s steps, put back to the plan, or taken out. Saving
4
+ // writes `time:` into the slides' settings through `mdeck run`
5
+ // (build/timingPlugin.js); a built deck can only copy the times.
6
+ import { h } from 'preact'
7
+ import { useEffect, useMemo, useState } from 'preact/hooks'
8
+ import { S } from './chrome.jsx'
9
+ import { Icon } from '../components/Icon.jsx'
10
+ import { slideTitle } from '../core/outline.js'
11
+ import { devPath } from '../core/devPath.js'
12
+ import { pairHeaders } from '../live/pairing.js'
13
+ import { formatTime, formatClock, roundTime, initialTimes, changedTimes, TIME_STEP } from '../core/timing.js'
14
+
15
+ const ENDPOINT = devPath('__mdeck/timing')
16
+
17
+ // Whether this page can save into the deck: only `mdeck run` answers.
18
+ async function canSaveTimes() {
19
+ try { return (await fetch(ENDPOINT, { cache: 'no-store', headers: pairHeaders() })).ok } catch { return false }
20
+ }
21
+
22
+ const dash = <span style={{ color: '#555' }}>—</span>
23
+ const sum = values => values.reduce((total, t) => total + (t ?? 0), 0)
24
+
25
+ export function RehearsalSummary({ slides, plan, spent, onSaved, onDiscard, onClose }) {
26
+ const own = plan.own
27
+ const [next, setNext] = useState(() => initialTimes(slides, spent, own))
28
+ const [canSave, setCanSave] = useState(null)
29
+ const [status, setStatus] = useState(null)
30
+ useEffect(() => { canSaveTimes().then(setCanSave) }, [])
31
+ const changes = useMemo(() => changedTimes(slides, own, next), [next])
32
+ const changeCount = Object.keys(changes).length
33
+ const setAt = (i, value) => setNext(times => times.map((t, j) => j === i ? value : t))
34
+ const step = (i, by) => setAt(i, Math.max(TIME_STEP, (next[i] ?? roundTime(spent[slides[i].id] ?? 0)) + by))
35
+
36
+ async function save() {
37
+ setStatus({ busy: true })
38
+ try {
39
+ const reply = await fetch(ENDPOINT, { method: 'POST', headers: pairHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ ids: slides.map(slide => slide.id), times: changes }) })
40
+ const body = await reply.json().catch(() => ({}))
41
+ if (!reply.ok) return setStatus({ error: body.error ?? `The deck was not saved (${reply.status})` })
42
+ onSaved(changes, body.changed ?? changeCount)
43
+ } catch (error) {
44
+ setStatus({ error: `The deck was not saved: ${error.message}` })
45
+ }
46
+ }
47
+
48
+ function copy() {
49
+ const text = slides.map((slide, i) => `${i + 1}. ${slideTitle(slide, i, null)}: ${next[i] == null ? '—' : `time: ${formatTime(next[i])}`}`).join('\n')
50
+ navigator.clipboard?.writeText(text).then(() => setStatus({ note: 'Copied the times.' }), () => setStatus({ error: 'The browser did not let this page copy.' }))
51
+ }
52
+
53
+ function onRowKey(event, i) {
54
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
55
+ event.preventDefault()
56
+ event.stopPropagation()
57
+ step(i, event.key === 'ArrowUp' ? TIME_STEP : -TIME_STEP)
58
+ }
59
+ }
60
+
61
+ const small = { ...S.btn, padding: 0, width: '26px', height: '26px' }
62
+ const cell = { padding: '6px 8px', borderBottom: '1px solid #222', verticalAlign: 'middle' }
63
+ const head = { ...cell, ...S.label, display: 'table-cell', marginBottom: 0, textAlign: 'start', position: 'sticky', top: 0, background: '#161616' }
64
+ const num = { ...cell, textAlign: 'end', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }
65
+
66
+ return (
67
+ <div class="rehearsal-summary" role="dialog" aria-modal="true" aria-label="Rehearsal" style={{ position: 'fixed', inset: 0, zIndex: 210, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', color: '#ccc' }}
68
+ onKeyDown={event => { event.stopPropagation(); if (event.key === 'Escape') onClose() }}>
69
+ <div style={{ background: '#161616', border: '1px solid #2a2a2a', borderRadius: '12px', padding: '20px 24px', width: 'min(94vw, 760px)', maxHeight: '88vh', display: 'flex', flexDirection: 'column', gap: '14px' }}>
70
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px' }}>
71
+ <span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '15px' }}>Rehearsal</span>
72
+ <span style={{ color: '#888' }}>New times start at the rehearsed time, rounded to {TIME_STEP} s. ↑ ↓ change the focused one.</span>
73
+ </div>
74
+ <div style={{ overflow: 'auto', minHeight: 0 }}>
75
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
76
+ <thead>
77
+ <tr>
78
+ <th style={head}>Slide</th>
79
+ <th style={{ ...head, textAlign: 'end' }}>Planned</th>
80
+ <th style={{ ...head, textAlign: 'end' }}>Rehearsed</th>
81
+ <th style={{ ...head, textAlign: 'center' }}>New time</th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ {slides.map((slide, i) => {
86
+ const changed = next[i] !== own[i]
87
+ return (
88
+ <tr key={`${slide.id}#${i}`} class="rehearsal-row" tabIndex={0} onKeyDown={event => onRowKey(event, i)} style={{ outline: 'none' }}>
89
+ <td style={{ ...cell, maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
90
+ <span style={{ color: '#666', marginInlineEnd: '8px', fontVariantNumeric: 'tabular-nums' }}>{i + 1}</span>{slideTitle(slide, i, null)}
91
+ </td>
92
+ <td style={{ ...num, color: '#888' }}>{own[i] == null ? dash : formatTime(own[i])}</td>
93
+ <td style={{ ...num, color: '#888' }}>{spent[slide.id] >= 1 ? formatClock(spent[slide.id]) : dash}</td>
94
+ <td style={{ ...cell, whiteSpace: 'nowrap' }}>
95
+ <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
96
+ <button style={small} title={`${TIME_STEP} s less`} aria-label={`${TIME_STEP} seconds less`} onClick={() => step(i, -TIME_STEP)}><Icon name="remove" size={14} /></button>
97
+ <span class="rehearsal-time" style={{ display: 'inline-block', minWidth: '56px', textAlign: 'center', fontVariantNumeric: 'tabular-nums', color: changed ? '#f0f0f0' : '#888', fontWeight: changed ? 600 : 400 }}>{next[i] == null ? dash : formatTime(next[i])}</span>
98
+ <button style={small} title={`${TIME_STEP} s more`} aria-label={`${TIME_STEP} seconds more`} onClick={() => step(i, TIME_STEP)}><Icon name="add" size={14} /></button>
99
+ <button style={{ ...small, visibility: changed ? 'visible' : 'hidden' }} title="Keep the planned time" aria-label="Keep the planned time" onClick={() => setAt(i, own[i])}><Icon name="undo" size={14} /></button>
100
+ <button style={{ ...small, visibility: next[i] == null ? 'hidden' : 'visible' }} title="No time for this slide" aria-label="No time for this slide" onClick={() => setAt(i, null)}><Icon name="close" size={14} /></button>
101
+ </span>
102
+ </td>
103
+ </tr>
104
+ )
105
+ })}
106
+ </tbody>
107
+ <tfoot>
108
+ <tr>
109
+ <td style={{ ...cell, color: '#888' }}>Total{plan.budget ? ` (deck time ${formatClock(plan.budget)})` : ''}</td>
110
+ <td style={num}>{formatClock(sum(own))}</td>
111
+ <td style={num}>{formatClock(sum(Object.values(spent)))}</td>
112
+ <td style={{ ...num, textAlign: 'center', color: plan.budget && sum(next) > plan.budget ? '#f28b82' : '#f0f0f0' }}>{formatClock(sum(next))}</td>
113
+ </tr>
114
+ </tfoot>
115
+ </table>
116
+ </div>
117
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
118
+ <span role="status" style={{ flex: 1, color: status?.error ? '#f28b82' : '#888' }}>
119
+ {status?.error ?? status?.note ?? (canSave === false ? 'Saving into the deck needs mdeck run; copy the times instead.' : `${changeCount} slide${changeCount === 1 ? '' : 's'} change.`)}
120
+ </span>
121
+ <button style={{ ...S.btn, color: '#888' }} title="Go on rehearsing (Esc)" onClick={onClose}>Back</button>
122
+ <button style={{ ...S.btn, color: '#888' }} title="End the rehearsal without saving" onClick={onDiscard}>Discard</button>
123
+ <button style={S.btn} onClick={copy}><Icon name="copy" size={14} />Copy</button>
124
+ <button class="rehearsal-save" style={{ ...S.btn, background: '#1c2a3a', borderColor: '#2f4a66', opacity: canSave && changeCount && !status?.busy ? 1 : 0.5 }} disabled={!canSave || !changeCount || status?.busy} onClick={save}>Save to deck</button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,27 @@
1
+ // `mdeck/component`: what a deck component may use from mdeck. Everything
2
+ // here is a promise kept across versions; anything else a component reaches
3
+ // (the page outside its slide, the stage, the presenter view) is not, and may
4
+ // change without notice. See "Deck-local components" in the authoring
5
+ // reference.
6
+ import { useEffect, useState } from 'preact/hooks'
7
+ import { getShared, setShared, subscribeShared, currentView } from './shared.js'
8
+
9
+ /**
10
+ * A value shared by every slide and every window of this talk, under `name`.
11
+ * Returns [value, setValue]; `initial` stands in until someone sets one.
12
+ * Values must survive JSON. The latest change wins.
13
+ */
14
+ export function useShared(name, initial = null) {
15
+ const [value, setValue] = useState(() => getShared(name))
16
+ useEffect(() => {
17
+ setValue(getShared(name))
18
+ return subscribeShared(name, setValue)
19
+ }, [name])
20
+ const current = () => getShared(name) === undefined ? initial : getShared(name)
21
+ return [value === undefined ? initial : value, next => setShared(name, typeof next === 'function' ? next(current()) : next)]
22
+ }
23
+
24
+ /** The kind of window this is: presenter, audience, deck, follow, preview or reader. */
25
+ export function useView() {
26
+ return currentView()
27
+ }
@@ -68,6 +68,7 @@
68
68
  import { iconSvg } from '../core/icons.js'
69
69
  import { inkPaint, snapAngle } from '../core/ink.js'
70
70
  import { findHotspots, hotspotMode, zoomToCamera, cameraToZoom } from './hotspots.js'
71
+ import { slideSteps } from './steps.js'
71
72
  import { flight, durationFor, ease } from './zoom/camera.js'
72
73
 
73
74
  (() => {
@@ -85,11 +86,14 @@ import { flight, durationFor, ease } from './zoom/camera.js'
85
86
  // Holding the pen still this long turns the stroke into a straight line.
86
87
  const STRAIGHTEN_MS = 500;
87
88
  const STRAIGHTEN_SLOP = 6; // CSS px the pen may tremble while held
88
- // Laser: a red glow with a white core; the trail lasts LASER_MS.
89
+ // Laser: a red glow with a white core. The trail stays while the pen is
90
+ // down; lifted, it retracts from its tail within LASER_MS.
89
91
  const LASER_GLOW = '#ff2a2a';
90
92
  const LASER_CORE = '#ffffff';
91
93
  const LASER_MS = 1200;
92
- const LASER_GAP_MS = 250;
94
+ // A trail whose end never arrives (the other window closed) goes after this.
95
+ const LASER_HELD_MS = 30000;
96
+ const LASER_STEP = 4; // design px between the points of a held trail
93
97
  // Zoom: up to this many times the fitted size.
94
98
  const ZOOM_MAX = 6;
95
99
  const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
@@ -792,7 +796,10 @@ import { flight, durationFor, ease } from './zoom/camera.js'
792
796
  }
793
797
 
794
798
  _broadcastState(reason) {
795
- const message = { deckStateChanged: this.state, reason };
799
+ // atEnd: nothing comes after this, which ends a rehearsal (main.jsx).
800
+ const state = this.state;
801
+ const atEnd = state.index === this._slides.length - 1 && state.step >= this._getSteps(this._slides[state.index]).length - 1;
802
+ const message = { deckStateChanged: state, atEnd, reason };
796
803
  window.postMessage(message, '*');
797
804
  if (window.parent !== window) window.parent.postMessage(message, '*');
798
805
  this.dispatchEvent(new CustomEvent('statechange', { detail: { ...this.state, reason } }));
@@ -949,9 +956,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
949
956
  // Step reveal helpers ---------------------------------------------------
950
957
 
951
958
  _getSteps(slide) {
952
- if (!slide) return [];
953
- // Multiple and nested reveal blocks follow document order.
954
- return [...slide.querySelectorAll('[data-step]')];
959
+ // Each step is a list of elements that appear together.
960
+ return slideSteps(slide);
955
961
  }
956
962
 
957
963
  _applySteps(slideIndex) {
@@ -959,9 +965,9 @@ import { flight, durationFor, ease } from './zoom/camera.js'
959
965
  if (!slide) return;
960
966
  const steps = this._getSteps(slide);
961
967
  const curr = this._printing ? steps.length - 1 : (this._stepMap.get(slideIndex) ?? -1);
962
- steps.forEach((el, i) =>
968
+ steps.forEach((elements, i) => elements.forEach(el =>
963
969
  i <= curr ? el.setAttribute('data-step-visible', '') : el.removeAttribute('data-step-visible')
964
- );
970
+ ));
965
971
  this._updateStepCount(slideIndex);
966
972
  if (slideIndex === this._index) this._queueHotspots();
967
973
  }
@@ -1020,7 +1026,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1020
1026
  get inking() { return this.hasAttribute('data-inking'); }
1021
1027
  set inking(on) {
1022
1028
  this.toggleAttribute('data-inking', !!on);
1023
- if (!on) { this._endInk(null); this.laserDot('local', null, null); this.showSelection(null); }
1029
+ if (!on) { this._endInk(null); this.laserRelease('local'); this.laserDot('local', null, null); this.showSelection(null); }
1024
1030
  this.dispatchEvent(new CustomEvent('inkmode', { detail: { inking: !!on }, bubbles: true, composed: true }));
1025
1031
  }
1026
1032
 
@@ -1076,7 +1082,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1076
1082
  const drawing = this._inkDrawing = { pointerId: e.pointerId, touch: e.pointerType === 'touch', tool, points: [point], slideId: this._inkSlideId(), gesture: this._inkGesture, key: `${this._inkGesture}` };
1077
1083
  if (tool.tool === 'eraser') { this._erase(point); return; }
1078
1084
  if (tool.tool === 'select') { this._inkSelect('down', point); return; }
1079
- if (tool.tool === 'laser') { this.laserTrail('local', drawing.slideId, [point]); this._inkProgress(drawing); return; }
1085
+ if (tool.tool === 'laser') { this.laserTrail('local', drawing.slideId, [point], { start: true }); this._inkProgress(drawing); return; }
1080
1086
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1081
1087
  this._styleLive(path, tool);
1082
1088
  this._appendLive(path);
@@ -1228,7 +1234,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1228
1234
  if (tool === 'select') { this._inkSelect('up', drawing.points[drawing.points.length - 1], drawing); return; }
1229
1235
  const detail = { key: drawing.key, slideId: drawing.slideId, tool, color, size, points: drawing.points };
1230
1236
  if (tool === 'laser') {
1231
- // Never saved: the trail retracts on its own.
1237
+ // Never saved: lifted, the trail retracts on its own.
1238
+ this.laserRelease('local');
1232
1239
  this.laserDot('local', drawing.slideId, null);
1233
1240
  this.dispatchEvent(new CustomEvent('inklaser', { detail, bubbles: true, composed: true }));
1234
1241
  return;
@@ -1590,8 +1597,10 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1590
1597
  // Laser -------------------------------------------------------------------
1591
1598
  //
1592
1599
  // Trails and dots by key ('local' for this window, others from the ink
1593
- // bus), in design pixels. A trail's points expire after LASER_MS, so it
1594
- // retracts from its tail; frames run only while some trail is left.
1600
+ // bus), in design pixels. While the pen is down a trail's points stay
1601
+ // (time Infinity); lifted, they are stamped so that they expire one after
1602
+ // the other within LASER_MS, and the trail retracts from its tail. Frames
1603
+ // run only while some trail is shown or retracting.
1595
1604
 
1596
1605
  _laserEntry(key, slideId) {
1597
1606
  let entry = this._lasers.get(key);
@@ -1600,16 +1609,34 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1600
1609
  return entry;
1601
1610
  }
1602
1611
 
1603
- /** Adds points to a laser trail; its dot moves to the last one. */
1604
- laserTrail(key, slideId, points) {
1612
+ /** Adds points to a laser trail, held until laserRelease; its dot moves to the last one. */
1613
+ laserTrail(key, slideId, points, { start = false } = {}) {
1605
1614
  if (!points?.length) return;
1606
1615
  const entry = this._laserEntry(key, slideId);
1607
- const t = performance.now();
1608
- for (const [x, y] of points) entry.points.push([x, y, t]);
1616
+ points.forEach(([x, y], i) => {
1617
+ // A held trail grows as long as the pen is down: skip points that
1618
+ // add nothing visible, so a long trail stays cheap to draw.
1619
+ const last = entry.points[entry.points.length - 1];
1620
+ const begins = start && i === 0;
1621
+ if (!begins && last && last[2] === Infinity && Math.hypot(x - last[0], y - last[1]) < LASER_STEP) return;
1622
+ entry.points.push([x, y, Infinity, begins]);
1623
+ });
1624
+ if (key !== 'local') { clearTimeout(entry.heldTimer); entry.heldTimer = setTimeout(() => this.laserRelease(key), LASER_HELD_MS); }
1609
1625
  entry.dot = points[points.length - 1];
1610
1626
  this._kickLaser();
1611
1627
  }
1612
1628
 
1629
+ /** The pen is lifted: the held trail retracts from its tail. */
1630
+ laserRelease(key) {
1631
+ const entry = this._lasers.get(key);
1632
+ if (!entry) return;
1633
+ const held = entry.points.filter(p => p[2] === Infinity);
1634
+ const now = performance.now();
1635
+ held.forEach((p, i) => { p[2] = now - LASER_MS + LASER_MS * (i + 1) / held.length; });
1636
+ clearTimeout(entry.heldTimer);
1637
+ this._kickLaser();
1638
+ }
1639
+
1613
1640
  /** Moves (or with null, hides) a laser dot. */
1614
1641
  laserDot(key, slideId, point) {
1615
1642
  const entry = this._lasers.get(key);
@@ -1647,6 +1674,8 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1647
1674
  for (const [key, entry] of this._lasers) {
1648
1675
  entry.points = entry.points.filter(p => now - p[2] < LASER_MS);
1649
1676
  if (!entry.points.length && !entry.dot) { this._lasers.delete(key); continue; }
1677
+ // A held trail needs frames too: a zoom, a new size or another slide
1678
+ // must move it while the pen stays still.
1650
1679
  if (entry.points.length) running = true;
1651
1680
  if (entry.slideId && slideId && entry.slideId !== slideId) continue;
1652
1681
  // A red glow with a white core, the core drawn second.
@@ -1657,7 +1686,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1657
1686
  ctx.beginPath();
1658
1687
  entry.points.forEach((p, i) => {
1659
1688
  const [x, y] = at(p);
1660
- if (i === 0 || p[2] - entry.points[i - 1][2] > LASER_GAP_MS) ctx.moveTo(x, y);
1689
+ if (i === 0 || p[3]) ctx.moveTo(x, y);
1661
1690
  else ctx.lineTo(x, y);
1662
1691
  });
1663
1692
  ctx.stroke();
@@ -1689,7 +1718,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1689
1718
  this._remoteInk ??= new Map();
1690
1719
  if (tool === 'laser') {
1691
1720
  const seen = this._remoteInk.get(key) ?? 0;
1692
- if (points.length > seen) this.laserTrail(key, slideId, points.slice(seen));
1721
+ if (points.length > seen) this.laserTrail(key, slideId, points.slice(seen), { start: seen === 0 });
1693
1722
  this._remoteInk.set(key, points.length);
1694
1723
  return;
1695
1724
  }
@@ -1708,7 +1737,7 @@ import { flight, durationFor, ease } from './zoom/camera.js'
1708
1737
  const path = this._remoteInk?.get(key);
1709
1738
  if (path == null) return;
1710
1739
  this._remoteInk.delete(key);
1711
- if (typeof path === 'number') { this.laserDot(key, null, null); return; }
1740
+ if (typeof path === 'number') { this.laserRelease(key); this.laserDot(key, null, null); return; }
1712
1741
  if (fade) { requestAnimationFrame(() => path.classList.add('fading')); setTimeout(() => path.remove(), 3200); }
1713
1742
  else this._removeLive(path);
1714
1743
  }
@@ -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 {
@@ -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
+ }