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.
- package/CHANGELOG.md +44 -0
- package/README.md +15 -4
- package/assets/base.css +6 -1
- package/docs/reference/authoring.md +111 -9
- package/docs/site/assets/site.css +2 -0
- package/docs/site/content/appearance.md +1 -1
- package/docs/site/content/audience.md +36 -8
- package/docs/site/content/commands.md +6 -3
- package/docs/site/content/drawing.md +5 -5
- package/docs/site/content/getting-started.md +2 -32
- package/docs/site/content/install.md +49 -0
- package/docs/site/content/media.md +20 -1
- package/docs/site/content/presenting.md +67 -9
- package/docs/site/content/sharing.md +1 -0
- package/docs/site/content/welcome.md +21 -1
- package/docs/site/content/writing.md +27 -0
- 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.de.results.json +57 -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 +14 -2
- package/src/build/check.js +34 -7
- package/src/build/components.js +5 -3
- package/src/build/config.js +2 -0
- package/src/build/homePlugin.js +64 -14
- package/src/build/include.js +249 -0
- package/src/build/inkPlugin.js +12 -2
- package/src/build/relayAccess.js +3 -1
- package/src/build/renderCheck.js +23 -3
- package/src/build/slidesPlugin.js +70 -22
- package/src/build/svgCheck.js +65 -0
- package/src/build/timingPlugin.js +96 -0
- package/src/cli/args.js +6 -5
- package/src/cli/index.js +77 -21
- package/src/components/Bibliography.jsx +7 -2
- package/src/components/Poll.jsx +2 -2
- package/src/components/QrCodeTag.jsx +2 -2
- package/src/components/VideoPlayer.jsx +6 -1
- package/src/components/WordCloud.jsx +7 -6
- package/src/components/activity.jsx +11 -8
- package/src/components/poll.css +2 -0
- package/src/core/editDeck.js +44 -7
- package/src/core/icons.js +9 -1
- package/src/core/labels.js +78 -0
- package/src/core/parseSlides.js +16 -6
- package/src/core/reactions.js +27 -0
- package/src/core/resultsReport.js +153 -0
- package/src/core/source.js +8 -1
- package/src/core/timing.js +83 -0
- package/src/core/validateDeck.js +11 -2
- package/src/editor/components/DeckSettings.jsx +1 -0
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/home/Home.jsx +39 -93
- package/src/home/home.css +5 -17
- package/src/layouts/layoutApi.jsx +51 -8
- package/src/layouts/renderSlide.jsx +13 -1
- package/src/live/answer/answer.css +9 -0
- package/src/live/answer/answer.js +32 -4
- package/src/live/client.js +23 -4
- package/src/live/rooms.js +18 -6
- package/src/runtime/KeyHelp.jsx +115 -0
- package/src/runtime/PresenterChrome.jsx +168 -0
- package/src/runtime/Reactions.jsx +56 -0
- package/src/runtime/RehearsalSummary.jsx +129 -0
- package/src/runtime/component.js +27 -0
- package/src/runtime/deck-stage.js +371 -37
- package/src/runtime/helpKeys.js +75 -0
- package/src/runtime/ink/bus.js +13 -5
- package/src/runtime/ink/outbox.js +27 -0
- package/src/runtime/ink/room.js +37 -12
- package/src/runtime/ink/segments.js +21 -0
- package/src/runtime/main.jsx +508 -286
- package/src/runtime/shared.js +77 -0
- package/src/runtime/slideControls.js +60 -0
- package/src/runtime/steps.js +36 -0
- package/src/runtime/useTalkTimer.js +67 -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
|
@@ -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
|
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 {
|
|
@@ -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
|
+
}
|