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.
- package/CHANGELOG.md +23 -0
- package/README.md +13 -2
- package/assets/base.css +4 -1
- package/docs/reference/authoring.md +65 -4
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/commands.md +3 -1
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/install.md +47 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +39 -2
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/images/features/hotspots.webp +0 -0
- package/docs/site/images/features/languages.webp +0 -0
- package/docs/site/pages.js +1 -0
- package/examples/custom-layouts/slides.md +21 -9
- package/examples/showcase/refs.bib +21 -0
- package/examples/showcase/slides.ar.drawings.json +11 -0
- package/examples/showcase/slides.ar.md +567 -0
- package/examples/showcase/slides.de.drawings.json +11 -0
- package/examples/showcase/slides.de.md +567 -0
- package/examples/showcase/slides.en.drawings.json +11 -0
- package/examples/showcase/{slides.md → slides.en.md} +112 -5
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +10 -1
- package/src/build/check.js +7 -1
- package/src/build/config.js +2 -0
- package/src/build/slidesPlugin.js +6 -6
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +80 -0
- package/src/cli/args.js +4 -3
- package/src/cli/index.js +34 -11
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +3 -0
- package/src/core/labels.js +75 -0
- package/src/core/parseSlides.js +3 -3
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +5 -0
- package/src/editor/components/DeckSettings.jsx +1 -0
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/home/Home.jsx +1 -0
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +49 -20
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +31 -6
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +157 -28
- package/src/runtime/shared.js +77 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +65 -0
- package/examples/citations/refs.bib +0 -30
- package/examples/citations/refs.yml +0 -21
- package/examples/citations/slides.md +0 -43
- package/examples/citations/slides.pdf +0 -0
- package/examples/poll/slides.md +0 -127
- 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,
|
|
21
|
-
defs[label] =
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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((
|
|
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.
|
|
1594
|
-
//
|
|
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
|
-
|
|
1608
|
-
|
|
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[
|
|
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
|
}
|
package/src/runtime/ink/bus.js
CHANGED
|
@@ -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 ~
|
|
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 =
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
}
|
package/src/runtime/ink/room.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|