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,96 @@
|
|
|
1
|
+
// Saving a rehearsal's times while `mdeck run` runs: the presenter view
|
|
2
|
+
// sends each slide's new time, and it goes into the slide's settings as
|
|
3
|
+
// `time: 1m30s` (core/timing.js). null takes a slide's time out.
|
|
4
|
+
//
|
|
5
|
+
// GET /__mdeck/timing → { ids }
|
|
6
|
+
// POST /__mdeck/timing { ids, times: { [slideId]: '1m30s' | null } } → { ok, changed }
|
|
7
|
+
//
|
|
8
|
+
// Slides without an `id:` are named by position (slide-3), so the times are
|
|
9
|
+
// only written when the deck still has the slides the page saw (`ids`);
|
|
10
|
+
// otherwise the answer is 409. The deck is copied to .mdeck-backups/ before
|
|
11
|
+
// the first write, and the write does not reload the open windows
|
|
12
|
+
// (ownWrites.js). A slide from another file the deck includes (include.js)
|
|
13
|
+
// gets its time in that file, as that file's own slide.
|
|
14
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
17
|
+
import { setSlideMeta } from '../core/editDeck.js'
|
|
18
|
+
import { parseTime, formatTime } from '../core/timing.js'
|
|
19
|
+
import { isAllowedRequest, send, readJson, backupDeck } from './editorPlugin.js'
|
|
20
|
+
import { markOwnWrite } from './ownWrites.js'
|
|
21
|
+
import { expandDeck } from './include.js'
|
|
22
|
+
|
|
23
|
+
export function createTimingFile(deckPath) {
|
|
24
|
+
const abs = resolve(deckPath)
|
|
25
|
+
const backedUp = new Set()
|
|
26
|
+
// The deck as shown, and for each slide the file it is written in.
|
|
27
|
+
const shown = () => {
|
|
28
|
+
const { source, origins } = expandDeck(abs)
|
|
29
|
+
return { deck: parseSlides(source), origins }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
path: abs,
|
|
34
|
+
ids: () => shown().deck.slides.map(slide => slide.id),
|
|
35
|
+
save(ids, times) {
|
|
36
|
+
const { deck: whole, origins } = shown()
|
|
37
|
+
const current = whole.slides.map(slide => slide.id)
|
|
38
|
+
if (!Array.isArray(ids) || ids.join('\n') !== current.join('\n')) return { status: 409, error: 'The deck changed since the rehearsal began; rehearse again', ids: current }
|
|
39
|
+
// The new times, by the file the slide is written in and its id there.
|
|
40
|
+
const files = new Map()
|
|
41
|
+
for (const [id, value] of Object.entries(times ?? {})) {
|
|
42
|
+
if (!current.includes(id)) return { status: 400, error: `No slide "${id}" in the deck` }
|
|
43
|
+
const seconds = value == null ? null : parseTime(value)
|
|
44
|
+
if (value != null && seconds == null) return { status: 400, error: `"${value}" is not a time` }
|
|
45
|
+
const time = seconds == null ? undefined : formatTime(seconds)
|
|
46
|
+
if (whole.slides.find(slide => slide.id === id).meta.time === time) continue
|
|
47
|
+
const { file, id: own } = origins?.[id] ?? { file: abs, id }
|
|
48
|
+
if (!files.has(file)) files.set(file, [])
|
|
49
|
+
files.get(file).push([own, time])
|
|
50
|
+
}
|
|
51
|
+
let changed = 0
|
|
52
|
+
for (const [file, entries] of files) {
|
|
53
|
+
const before = readFileSync(file, 'utf8')
|
|
54
|
+
let source = before, deck = parseSlides(source)
|
|
55
|
+
for (const [id, time] of entries) {
|
|
56
|
+
source = setSlideMeta(deck, id, { time })
|
|
57
|
+
deck = parseSlides(source)
|
|
58
|
+
changed++
|
|
59
|
+
}
|
|
60
|
+
if (source === before) continue
|
|
61
|
+
if (!backedUp.has(file)) { backupDeck(file, before); backedUp.add(file) }
|
|
62
|
+
markOwnWrite(file, source)
|
|
63
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
64
|
+
writeFileSync(temp, source, 'utf8')
|
|
65
|
+
renameSync(temp, file)
|
|
66
|
+
}
|
|
67
|
+
return { status: 200, ok: true, changed }
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function timingMiddleware(file, { authorize = isAllowedRequest } = {}) {
|
|
73
|
+
return async (request, response, next) => {
|
|
74
|
+
const { pathname } = new URL(request.url, 'http://localhost')
|
|
75
|
+
if (pathname !== '/') return next()
|
|
76
|
+
if (!authorize(request)) return send(response, 403, { error: 'Only the presenter can save times' })
|
|
77
|
+
try {
|
|
78
|
+
if (request.method === 'GET') return send(response, 200, { ids: file.ids() })
|
|
79
|
+
if (request.method !== 'POST') { response.setHeader('Allow', 'GET, POST'); return send(response, 405, { error: 'Method not allowed' }) }
|
|
80
|
+
const { ids, times } = await readJson(request)
|
|
81
|
+
const { status, ...result } = file.save(ids, times)
|
|
82
|
+
return send(response, status, result)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return send(response, error.status ?? 500, { error: error.message })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function timingPlugin(slidesPath, { authorize } = {}) {
|
|
90
|
+
return {
|
|
91
|
+
name: 'vite-plugin-mdeck-timing',
|
|
92
|
+
configureServer(server) {
|
|
93
|
+
server.middlewares.use('/__mdeck/timing', timingMiddleware(createTimingFile(slidesPath), { authorize }))
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/cli/args.js
CHANGED
|
@@ -4,14 +4,15 @@ const common = { '--port': value, '--no-open': flag }
|
|
|
4
4
|
const output = { '--output': value, '-o': '--output', '--no-drawings': flag, '--no-results': flag }
|
|
5
5
|
const commands = {
|
|
6
6
|
new: { max: 0 },
|
|
7
|
-
run: { ...common, '--network': flag, '--server': optional, max: 1 },
|
|
7
|
+
run: { ...common, '--network': flag, '--server': optional, '--no-server': flag, max: 1 },
|
|
8
|
+
rehearse: { ...common, '--network': flag, '--server': optional, '--no-server': flag, max: 1 },
|
|
8
9
|
edit: { ...common, max: 1 },
|
|
9
10
|
design: { ...common, max: 1 },
|
|
10
11
|
themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
|
|
11
12
|
palettes: { '--local': flag, '--force': flag, max: 4 },
|
|
12
|
-
build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, '--no-polls': flag, max: 1 },
|
|
13
|
-
send: { ...output, '--notes': flag, '--no-polls': flag, '--no-pdf': flag, max: 1 },
|
|
14
|
-
pdf: { ...output, '--no-polls': flag, max: 1 },
|
|
13
|
+
build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, '--no-polls': flag, '--slide': value, max: 1 },
|
|
14
|
+
send: { ...output, '--notes': flag, '--no-polls': flag, '--no-pdf': flag, '--slide': value, max: 1 },
|
|
15
|
+
pdf: { ...output, '--no-polls': flag, '--slide': value, max: 1 },
|
|
15
16
|
preview: { ...common, max: 1 },
|
|
16
17
|
check: { '--strict': flag, '--render': flag, max: 1 },
|
|
17
18
|
snapshot: { '--output': value, '-o': '--output', '--slide': value, '--dark': flag, '--light': flag, '--sheet': flag, max: 1 },
|
|
@@ -21,7 +22,7 @@ const commands = {
|
|
|
21
22
|
server: { '--port': value, '--host': optional, max: 0 },
|
|
22
23
|
skill: { '--print': flag, '--install': 'list', '--project': flag, max: 0 },
|
|
23
24
|
migrate: { '--dry-run': flag, max: 1 },
|
|
24
|
-
results: { '--output': value, '-o': '--output', max: 1 },
|
|
25
|
+
results: { '--output': value, '-o': '--output', '--summary': flag, '--json': flag, max: 1 },
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function validateServerOrigin(address) {
|
package/src/cli/index.js
CHANGED
|
@@ -13,9 +13,11 @@ import { BACKUP_DIR } from '../build/editorPlugin.js'
|
|
|
13
13
|
import { createEditorServer, createDesignServer } from '../build/editorServer.js'
|
|
14
14
|
import { homePlugin } from '../build/homePlugin.js'
|
|
15
15
|
import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
|
|
16
|
+
import { expandedSource } from '../build/include.js'
|
|
16
17
|
import { livePlugin, startLiveServer } from '../live/server.js'
|
|
17
18
|
import { inkPlugin } from '../build/inkPlugin.js'
|
|
18
19
|
import { resultsPlugin } from '../build/resultsPlugin.js'
|
|
20
|
+
import { timingPlugin } from '../build/timingPlugin.js'
|
|
19
21
|
import { createPairing, pairingPlugin } from '../build/pairing.js'
|
|
20
22
|
import { isAllowedRequest } from '../build/editorPlugin.js'
|
|
21
23
|
import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
|
|
@@ -26,10 +28,11 @@ import { paramKind } from '../extensions/params.js'
|
|
|
26
28
|
|
|
27
29
|
import { frameworkRoot } from '../paths.js'
|
|
28
30
|
import { parseSlides } from '../core/parseSlides.js'
|
|
29
|
-
import { stripNotes as stripNotesFrom,
|
|
31
|
+
import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
|
|
30
32
|
import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
|
|
31
33
|
import { assertDrawings } from '../build/drawings.js'
|
|
32
|
-
import { resultsFileFor, normalizeResults
|
|
34
|
+
import { resultsFileFor, normalizeResults } from '../core/results.js'
|
|
35
|
+
import { resultsReport, reportAnswersCsv, reportSummaryCsv, reportJson } from '../core/resultsReport.js'
|
|
33
36
|
import { createRelayAccess } from '../build/relayAccess.js'
|
|
34
37
|
import { sessionCode } from '../live/code.js'
|
|
35
38
|
import { migrateDeck } from './migrate.js'
|
|
@@ -58,7 +61,25 @@ function hasFlag(name, short = null) { return !!(parsed.options[name] ?? parsed.
|
|
|
58
61
|
const positionals = () => parsed.positionals
|
|
59
62
|
const outputOption = () => parsed.options['--output'] ?? null
|
|
60
63
|
const serverOption = () => parsed.options['--server']
|
|
64
|
+
// Whether a poll server answers at `address`, within a few seconds.
|
|
65
|
+
async function serverAnswers(address) {
|
|
66
|
+
try {
|
|
67
|
+
validateServerOrigin(address)
|
|
68
|
+
const response = await fetch(new URL('info', address.replace(/\/*$/, '/')), { signal: AbortSignal.timeout(3000) })
|
|
69
|
+
return response.ok
|
|
70
|
+
} catch { return false }
|
|
71
|
+
}
|
|
61
72
|
const portOption = () => parsed.options['--port'] ?? null
|
|
73
|
+
// --slide 3,5-7 for build, send and pdf: only these slides, numbered as written.
|
|
74
|
+
function slideOption(input) {
|
|
75
|
+
const given = parsed.options['--slide']
|
|
76
|
+
if (given == null) return null
|
|
77
|
+
try {
|
|
78
|
+
const slides = parseSlideNumbers(given)
|
|
79
|
+
shareSlides(readFileSync(resolve(input), 'utf-8'), { slides })
|
|
80
|
+
return slides
|
|
81
|
+
} catch (error) { err(error.message); process.exit(1) }
|
|
82
|
+
}
|
|
62
83
|
|
|
63
84
|
// Layouts, themes and palettes all come from one registry: built-ins plus the
|
|
64
85
|
// extensions/ folder beside the deck. Manifest problems are reported and stop
|
|
@@ -181,13 +202,14 @@ async function runNewWizard() {
|
|
|
181
202
|
}
|
|
182
203
|
}
|
|
183
204
|
|
|
184
|
-
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
|
|
205
|
+
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false, slides = null } = {}) {
|
|
185
206
|
const absSlides = resolve(slidesPath)
|
|
186
207
|
const deckDir = dirname(absSlides)
|
|
187
208
|
const absOutDir = resolve(outDir)
|
|
188
|
-
|
|
209
|
+
// The deck as shown: pictures of included slides are copied too.
|
|
210
|
+
const raw = expandedSource(absSlides)
|
|
189
211
|
let markdown = stripNotes ? stripNotesFrom(raw) : raw
|
|
190
|
-
if (stripActivities) markdown =
|
|
212
|
+
if (stripActivities || slides) markdown = shareSlides(markdown, { activities: stripActivities, slides }).source
|
|
191
213
|
|
|
192
214
|
for (const ref of collectLocalAssetRefs(markdown)) {
|
|
193
215
|
const source = resolve(deckDir, ref)
|
|
@@ -312,8 +334,13 @@ const HELP = `
|
|
|
312
334
|
--network reach the slides from phones and tablets on the same network
|
|
313
335
|
--server [address] present from an iPad on any network, through your server
|
|
314
336
|
(the address, or the deck's server setting, or MDECK_SERVER;
|
|
315
|
-
the key in MDECK_SERVER_KEY)
|
|
337
|
+
the key in MDECK_SERVER_KEY). With both MDECK_SERVER and
|
|
338
|
+
MDECK_SERVER_KEY set, the server is used without --server
|
|
339
|
+
when it answers
|
|
340
|
+
--no-server present from this computer only, even with MDECK_SERVER set
|
|
316
341
|
--no-open --port <number>
|
|
342
|
+
${c.green}mdeck rehearse${c.reset} [slides.md] Like run, but opens the presenter view rehearsing: it times
|
|
343
|
+
each slide and saves the times into the deck (time: 1m30s)
|
|
317
344
|
${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
|
|
318
345
|
${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
|
|
319
346
|
saves to the folder's extensions/ (default: this folder)
|
|
@@ -332,6 +359,8 @@ const HELP = `
|
|
|
332
359
|
--notes keep the speaker notes in the file
|
|
333
360
|
--no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
|
|
334
361
|
--no-pdf do not render the PDF inside
|
|
362
|
+
--slide 3,5-7 for build, send and pdf: only these slides (numbered as in the deck,
|
|
363
|
+
with their drawings; styles from slides left out stay)
|
|
335
364
|
${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
|
|
336
365
|
--no-drawings for build, send and pdf: leave out <slides>.drawings.json
|
|
337
366
|
--no-results for build, send and pdf: leave out <slides>.results.json (the answers kept in the talk)
|
|
@@ -346,7 +375,9 @@ const HELP = `
|
|
|
346
375
|
--slide <numbers> only these slides: 3, 3,5 or 2-4
|
|
347
376
|
--sheet one picture of them all in a grid, each under its number and heading
|
|
348
377
|
--dark | --light in this appearance instead of the deck's own -o <folder>
|
|
349
|
-
${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV
|
|
378
|
+
${c.green}mdeck results${c.reset} [slides.md] [-o answers.csv] The answers kept in the talk, as CSV: slide, question, phone, time, answer
|
|
379
|
+
--summary one row per option, number or word, with how many chose it
|
|
380
|
+
--json everything as JSON: each question, its summary and its answers (also -o answers.json)
|
|
350
381
|
${c.green}mdeck list${c.reset} [layouts|themes|palettes] [slides.md] [--json]
|
|
351
382
|
List built-in and deck-local layouts, themes and palettes
|
|
352
383
|
${c.green}mdeck starter${c.reset} <layout> [slides.md] Print starter Markdown for a layout
|
|
@@ -402,7 +433,7 @@ if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); process
|
|
|
402
433
|
let parsed
|
|
403
434
|
try {
|
|
404
435
|
parsed = parseArgs(command, argv)
|
|
405
|
-
if (['run', 'server'].includes(command) && Object.hasOwn(process.env, 'MDECK_LIVE_KEY')) throw new Error('MDECK_LIVE_KEY is now MDECK_SERVER_KEY. Rename the environment variable before starting mdeck.')
|
|
436
|
+
if (['run', 'rehearse', 'server'].includes(command) && Object.hasOwn(process.env, 'MDECK_LIVE_KEY')) throw new Error('MDECK_LIVE_KEY is now MDECK_SERVER_KEY. Rename the environment variable before starting mdeck.')
|
|
406
437
|
} catch (error) {
|
|
407
438
|
err(error.message)
|
|
408
439
|
if (Object.hasOwn(RENAMED_COMMANDS, command)) tip(`mdeck ${command} is now ${RENAMED_COMMANDS[command]}.`)
|
|
@@ -476,11 +507,18 @@ if (command === 'new') {
|
|
|
476
507
|
const input = requireInput('results')
|
|
477
508
|
const file = resultsFileFor(resolve(input))
|
|
478
509
|
if (!existsSync(file)) { err(`No answers kept for ${basename(input)} yet: ${basename(file)} appears beside it once people answer during mdeck run.`); process.exit(1) }
|
|
479
|
-
let
|
|
480
|
-
try {
|
|
510
|
+
let results
|
|
511
|
+
try { results = normalizeResults(JSON.parse(readFileSync(file, 'utf8'))) } catch (error) { err(`${basename(file)}: ${error.message}`); process.exit(1) }
|
|
512
|
+
// Read against the deck as shown, included slides too, for the slide and the question.
|
|
513
|
+
let deck
|
|
514
|
+
try { deck = parseSlides(expandedSource(resolve(input))) } catch { deck = parseSlides(readFileSync(resolve(input), 'utf8')) }
|
|
515
|
+
const report = resultsReport(results, deck)
|
|
481
516
|
const out = outputOption()
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
const json = hasFlag('--json') || /\.json$/i.test(out ?? '')
|
|
518
|
+
const text = json ? reportJson(report, { title: deck.deckConfig?.meta?.title ?? basename(input).replace(/\.md$/i, '') })
|
|
519
|
+
: hasFlag('--summary') ? reportSummaryCsv(report) : reportAnswersCsv(report)
|
|
520
|
+
if (out) { writeFileSync(out, text); ok(`Saved ${relative(process.cwd(), resolve(out))}`) }
|
|
521
|
+
else process.stdout.write(text)
|
|
484
522
|
} else if (command === 'check') {
|
|
485
523
|
const input = requireInput('check')
|
|
486
524
|
const registry = registryFor(input)
|
|
@@ -538,7 +576,7 @@ if (command === 'new') {
|
|
|
538
576
|
else process.stdout.write(layouts[name].starter)
|
|
539
577
|
|
|
540
578
|
// ── run ───────────────────────────────────────────────────────────────────────
|
|
541
|
-
} else if (command === 'run') {
|
|
579
|
+
} else if (command === 'run' || command === 'rehearse') {
|
|
542
580
|
const input = requireInput(command)
|
|
543
581
|
const abs = resolve(input)
|
|
544
582
|
try { assertDrawings(abs) } catch (error) { err(error.message); process.exit(1) }
|
|
@@ -550,8 +588,18 @@ if (command === 'new') {
|
|
|
550
588
|
// (src/build/tunnelClient.js). The dev server then serves under /t/<id>/, the
|
|
551
589
|
// address the iPad uses on the server, and its own addresses follow it. The
|
|
552
590
|
// address is the flag's value, else the deck's server setting, else MDECK_SERVER.
|
|
591
|
+
// With MDECK_SERVER and MDECK_SERVER_KEY set, the server is used without
|
|
592
|
+
// --server when it answers; otherwise this computer presents alone.
|
|
593
|
+
// --no-server keeps it out.
|
|
553
594
|
let relay = null
|
|
554
|
-
|
|
595
|
+
let serverFlag = serverOption()
|
|
596
|
+
if (serverFlag === undefined && !hasFlag('--no-server') && process.env.MDECK_SERVER && process.env.MDECK_SERVER_KEY) {
|
|
597
|
+
const address = process.env.MDECK_SERVER
|
|
598
|
+
if (await serverAnswers(address)) {
|
|
599
|
+
serverFlag = address
|
|
600
|
+
tip(`Using your server from MDECK_SERVER (${address}); --no-server presents without it.`)
|
|
601
|
+
} else tip(`Your server (MDECK_SERVER, ${address}) does not answer: presenting from this computer only.`)
|
|
602
|
+
}
|
|
555
603
|
if (serverFlag !== undefined) {
|
|
556
604
|
const inDeck = parseSlides(readFileSync(abs, 'utf8')).deckConfig?.server
|
|
557
605
|
const address = serverFlag ?? (typeof inDeck === 'string' ? inDeck : null) ?? process.env.MDECK_SERVER ?? null
|
|
@@ -584,13 +632,13 @@ if (command === 'new') {
|
|
|
584
632
|
...base,
|
|
585
633
|
// A paired iPad may save ink and steer the rooms, like this computer.
|
|
586
634
|
...(relay ? { base: relay.base } : {}),
|
|
587
|
-
plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), resultsPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
|
|
635
|
+
plugins: [...(relay ? [stripRelayBase(relay.base)] : []), ...base.plugins, homePlugin(abs, { services, pairing, relay, server: relay?.server ?? null }), pairingPlugin(pairing), livePlugin({ pairing, upstream, key: process.env.MDECK_SERVER_KEY, session: () => sessionCode(parseSlides(readFileSync(abs, 'utf8')).deckConfig) }), inkPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), resultsPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) }), timingPlugin(abs, { authorize: request => isAllowedRequest(request) || pairing.allows(request) })],
|
|
588
636
|
publicDir: dirname(abs),
|
|
589
637
|
server: {
|
|
590
638
|
...base.server,
|
|
591
639
|
...(port ? { port, strictPort: true } : {}),
|
|
592
640
|
host: exposed ? true : base.server.host,
|
|
593
|
-
open: hasFlag('--no-open') ? false : `${mountBase}home.html`,
|
|
641
|
+
open: hasFlag('--no-open') ? false : command === 'rehearse' ? `${mountBase}?view=presenter&rehearse=1` : `${mountBase}home.html`,
|
|
594
642
|
},
|
|
595
643
|
})
|
|
596
644
|
await server.listen()
|
|
@@ -624,11 +672,12 @@ if (command === 'new') {
|
|
|
624
672
|
console.log()
|
|
625
673
|
ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
|
|
626
674
|
tip(`Presenter: ${new URL('?view=presenter', local).href}`)
|
|
675
|
+
tip(`Rehearse: ${new URL('?view=presenter&rehearse=1', local).href}`)
|
|
627
676
|
tip(`Audience: ${new URL('?view=audience', local).href}`)
|
|
628
677
|
// Phones in the room follow the talk (the reader view would show slides ahead).
|
|
629
678
|
if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
|
|
630
679
|
console.log()
|
|
631
|
-
if (relay) tip(`iPad:
|
|
680
|
+
if (relay) tip(`iPad: in the presenter view, click the connection icons and choose "iPad" or "Phone clicker"; it opens ${relay.url}`)
|
|
632
681
|
ok(`Watching ${c.cyan}${abs}${c.reset}`)
|
|
633
682
|
tip('Edit and save to reload.\n')
|
|
634
683
|
|
|
@@ -696,6 +745,7 @@ if (command === 'new') {
|
|
|
696
745
|
// Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
|
|
697
746
|
const stripActivities = reader && hasFlag('--no-polls')
|
|
698
747
|
const wantPdf = sending && !hasFlag('--no-pdf')
|
|
748
|
+
const slides = slideOption(input)
|
|
699
749
|
|
|
700
750
|
if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
|
|
701
751
|
const given = outputOption()
|
|
@@ -709,7 +759,7 @@ if (command === 'new') {
|
|
|
709
759
|
try {
|
|
710
760
|
await build({
|
|
711
761
|
...baseConfig(input, { selfContained, defaultView: reader ? 'reader' : 'deck' }),
|
|
712
|
-
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
|
|
762
|
+
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: selfContained, inlineMedia: selfContained, embedFonts: selfContained, stripNotes, stripActivities, slides, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: reader }), viteSingleFile()],
|
|
713
763
|
build: {
|
|
714
764
|
outDir,
|
|
715
765
|
emptyOutDir: !tempDir,
|
|
@@ -727,7 +777,7 @@ if (command === 'new') {
|
|
|
727
777
|
await mkdir(finalOutDir, { recursive: true })
|
|
728
778
|
|
|
729
779
|
if (!selfContained) {
|
|
730
|
-
await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
|
|
780
|
+
await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities, slides })
|
|
731
781
|
if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
|
|
732
782
|
}
|
|
733
783
|
|
|
@@ -751,7 +801,12 @@ if (command === 'new') {
|
|
|
751
801
|
} finally { await rm(pdfDir, { recursive: true, force: true }) }
|
|
752
802
|
}
|
|
753
803
|
if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
|
|
754
|
-
|
|
804
|
+
// Only polls among the slides chosen with --slide were left out for --no-polls.
|
|
805
|
+
if (stripActivities) {
|
|
806
|
+
const raw = readFileSync(resolve(input), 'utf-8')
|
|
807
|
+
if (shareSlides(raw, { activities: true, slides }).removed.length > (slides ? shareSlides(raw, { slides }).removed.length : 0)) tip('Slides with polls were left out (--no-polls).')
|
|
808
|
+
}
|
|
809
|
+
if (slides) tip(`Only slides ${parsed.options['--slide']} (--slide).`)
|
|
755
810
|
ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
|
|
756
811
|
|
|
757
812
|
// ── skill ─────────────────────────────────────────────────────────────────────
|
|
@@ -777,11 +832,12 @@ if (command === 'new') {
|
|
|
777
832
|
const pdfFile = resolve(process.cwd(), outputOption() ?? basename(input).replace(/\.md$/i, '') + '.pdf')
|
|
778
833
|
if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
|
|
779
834
|
if (!findChrome()) { err('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.'); process.exit(1) }
|
|
835
|
+
const slides = slideOption(input)
|
|
780
836
|
const tempDir = mkdtempSync(resolve(tmpdir(), 'mdeck-pdf-'))
|
|
781
837
|
try {
|
|
782
838
|
await build({
|
|
783
839
|
...baseConfig(input, { selfContained: true }),
|
|
784
|
-
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: true }), viteSingleFile()],
|
|
840
|
+
plugins: [preact(), slidesPlugin(resolve(input), { inlineImages: true, inlineMedia: true, embedFonts: true, stripActivities: hasFlag('--no-polls'), slides, ink: !hasFlag('--no-drawings'), results: !hasFlag('--no-results'), deckLook: true }), viteSingleFile()],
|
|
785
841
|
build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
|
|
786
842
|
logLevel: 'warn',
|
|
787
843
|
})
|
|
@@ -4,6 +4,9 @@ import { currentBibliography, onBibliography } from '../runtime/citations.js'
|
|
|
4
4
|
// <bibliography /> on a slide: the deck's reference list, the works it cites
|
|
5
5
|
// plus `nocite`, in the citation style's order. A long list goes over several
|
|
6
6
|
// slides with <bibliography part="1/2" /> and <bibliography part="2/2" />.
|
|
7
|
+
// `keys="a b"` lists only these works; in a deck that includes another, that
|
|
8
|
+
// deck's own list gets the keys it cites (build/include.js), so it shows what
|
|
9
|
+
// it shows alone.
|
|
7
10
|
|
|
8
11
|
function slice(order, part) {
|
|
9
12
|
const match = String(part ?? '').match(/^\s*(\d+)\s*\/\s*(\d+)\s*$/)
|
|
@@ -14,10 +17,12 @@ function slice(order, part) {
|
|
|
14
17
|
return order.slice((index - 1) * size, index * size)
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
export default function Bibliography({ part }) {
|
|
20
|
+
export default function Bibliography({ part, keys }) {
|
|
18
21
|
const [bib, setBib] = useState(currentBibliography)
|
|
19
22
|
useEffect(() => onBibliography(setBib), [])
|
|
20
23
|
if (!bib) return <p class="bibliography-hint">Name a reference file in the deck settings, e.g. <code>bibliography: refs.bib</code>.</p>
|
|
21
|
-
const
|
|
24
|
+
const only = keys == null ? null : new Set(String(keys).split(/[\s,]+/).map(key => key.replace(/^@/, '')).filter(Boolean))
|
|
25
|
+
const order = only ? bib.order.filter(id => only.has(id)) : bib.order
|
|
26
|
+
const html = slice(order, part).map(id => bib.entries[id]).join('')
|
|
22
27
|
return <div class="bibliography csl-bib-body" dangerouslySetInnerHTML={{ __html: html }} />
|
|
23
28
|
}
|
package/src/components/Poll.jsx
CHANGED
|
@@ -7,8 +7,8 @@ import { pollChoices } from '../core/pollList.js'
|
|
|
7
7
|
import './poll.css'
|
|
8
8
|
|
|
9
9
|
// <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
|
|
10
|
-
// On the slide: live bars
|
|
11
|
-
//
|
|
10
|
+
// On the slide: live bars and the number of answers; the deck's join code
|
|
11
|
+
// beside them only with `qr` (the presenter shows it over the slide, J).
|
|
12
12
|
// On the phones, the server's answer page shows one button per option,
|
|
13
13
|
// as described by Poll.phone. Each device has one vote and can change it; the
|
|
14
14
|
// latest one counts.
|
|
@@ -7,8 +7,8 @@ import './poll.css'
|
|
|
7
7
|
|
|
8
8
|
// <qrcode url="https://example.com" size="240" /> on a slide.
|
|
9
9
|
// <qrcode join />: the deck's join link for audience questions, with the
|
|
10
|
-
// short link below it,
|
|
11
|
-
//
|
|
10
|
+
// short link below it, for a slide that invites everyone (the presenter view
|
|
11
|
+
// can also put it over any slide, J).
|
|
12
12
|
// <qrcode follow />: the link for following the slides on a phone or laptop.
|
|
13
13
|
|
|
14
14
|
function JoinQr({ size }) {
|
|
@@ -169,7 +169,12 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
|
|
|
169
169
|
>
|
|
170
170
|
{url ? (
|
|
171
171
|
iframeSrc
|
|
172
|
-
|
|
172
|
+
// A deck served through a server (an iPad paired over the internet)
|
|
173
|
+
// comes with "Referrer-Policy: no-referrer", so pairing links never
|
|
174
|
+
// leak; YouTube refuses to play an embed that names no page
|
|
175
|
+
// ("This video is unavailable"). The player alone sends the
|
|
176
|
+
// deck's origin, never its path.
|
|
177
|
+
? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen referrerpolicy="strict-origin"
|
|
173
178
|
style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
|
|
174
179
|
: <div class="video-player-placeholder" />
|
|
175
180
|
) : (
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
3
|
import cloud from 'd3-cloud'
|
|
4
|
-
import { useRoom } from '../live/client.js'
|
|
4
|
+
import { useRoom, latestByDevice } from '../live/client.js'
|
|
5
5
|
import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
|
|
6
6
|
import { phoneQuestion } from './inlineText.js'
|
|
7
7
|
import { t } from '../core/labels.js'
|
|
8
8
|
import './poll.css'
|
|
9
9
|
|
|
10
10
|
// <wordcloud room="mood" question="One word for today?" />
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// Each phone sends a word or two; a new one replaces its earlier word. The
|
|
12
|
+
// slide packs the answers into a cloud around its middle: the more often it was sent, the
|
|
13
13
|
// larger; some stand upright. Answers that differ only in case or spacing
|
|
14
14
|
// count together. `height` is the cloud's height in slide pixels (520),
|
|
15
15
|
// `limit` the most words shown (60).
|
|
@@ -66,7 +66,7 @@ function layout(words, { width, height, font }) {
|
|
|
66
66
|
export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
|
|
67
67
|
const live = useRoom(room)
|
|
68
68
|
const controls = useControls(room, live)
|
|
69
|
-
const all = countWords(controls.counted(live.messages))
|
|
69
|
+
const all = countWords(latestByDevice(controls.counted(live.messages)))
|
|
70
70
|
const words = all.slice(0, Math.max(1, Number(limit) || 60))
|
|
71
71
|
const total = all.reduce((sum, word) => sum + word.n, 0)
|
|
72
72
|
const cloudHeight = Math.max(200, Number(height) || 520)
|
|
@@ -99,7 +99,7 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
|
|
|
99
99
|
</svg>
|
|
100
100
|
: !live.offline && <p class="question-empty">{t('question.empty')}</p>}
|
|
101
101
|
</div>
|
|
102
|
-
<ActivityFooter count={total} {...live} reset={controls.reset} closed={controls.closed}>
|
|
102
|
+
<ActivityFooter count={total} {...live} perPhone reset={controls.reset} closed={controls.closed}>
|
|
103
103
|
<ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
|
|
104
104
|
</ActivityFooter>
|
|
105
105
|
</div>
|
|
@@ -107,4 +107,5 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
|
|
|
107
107
|
</div>
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
// `once`: the phone's latest word counts (a server before 3.12 ignores it and lets phones send more).
|
|
111
|
+
WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40, once: true })
|
|
@@ -12,9 +12,10 @@ import 'katex/dist/katex.min.css'
|
|
|
12
12
|
// the number of answers, the live dot, Reset and the presenter's controls.
|
|
13
13
|
|
|
14
14
|
export const shortLink = url => url.replace(/^https?:\/\//, '')
|
|
15
|
-
//
|
|
16
|
-
// slide
|
|
17
|
-
|
|
15
|
+
// An activity shows no join code of its own: the presenter puts it over the
|
|
16
|
+
// slide when it is wanted (J), and phones that joined follow along. `qr` (or
|
|
17
|
+
// `qr="true"`) still puts the code beside the question.
|
|
18
|
+
export const wantsQr = qr => qr != null && !/^(false|no|off|0)$/i.test(String(qr))
|
|
18
19
|
// A switch such as `multiple`: on when given, unless it says false.
|
|
19
20
|
export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
|
|
20
21
|
|
|
@@ -86,11 +87,13 @@ export function useControls(room, live, { results = true, solution = null } = {}
|
|
|
86
87
|
* A record of the talk (`offline`) can still show the answer and results.
|
|
87
88
|
*/
|
|
88
89
|
export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
|
|
89
|
-
|
|
90
|
+
// data-presenter-control: the phone clicker offers them too (runtime/slideControls.js).
|
|
91
|
+
const button = (name, on, label, onClick, icon, iconName) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} data-presenter-control="" data-presenter-icon={iconName} onClick={onClick}>{icon}</button>
|
|
92
|
+
const lock = controls.closed ? 'lock' : 'lock-open', eye = controls.results ? 'eye' : 'eye-off'
|
|
90
93
|
return <>
|
|
91
|
-
{!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={
|
|
92
|
-
{results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={
|
|
93
|
-
{solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg
|
|
94
|
+
{!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={lock} size={22} />, lock)}
|
|
95
|
+
{results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={eye} size={24} />, eye)}
|
|
96
|
+
{solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>, 'check')}
|
|
94
97
|
</>
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -110,7 +113,7 @@ export function ActivityFooter({ count, connected, canReset, reset, offline, pre
|
|
|
110
113
|
{of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
|
|
111
114
|
{closed && <span class="poll-closed">{t('poll.closed')}</span>}
|
|
112
115
|
<span class="poll-spacer" />
|
|
113
|
-
{canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
116
|
+
{canReset && <button class="poll-reset" disabled={count === 0 && !closed} data-presenter-control="" data-presenter-icon="reset" aria-label={t('poll.reset')} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
|
|
114
117
|
{children}
|
|
115
118
|
</p>
|
|
116
119
|
}
|
package/src/components/poll.css
CHANGED
|
@@ -31,6 +31,8 @@ deck-stage[data-follower] .poll-reset { display: none; }
|
|
|
31
31
|
.poll-control:hover, .poll-control:focus-visible { opacity: 1; }
|
|
32
32
|
.poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
|
|
33
33
|
deck-stage[data-follower] .poll-control { display: none; }
|
|
34
|
+
/* The presenter view has them in its toolbar (runtime/PresenterChrome.jsx). */
|
|
35
|
+
deck-stage[data-presenter] :is(.poll-control, .poll-reset) { display: none; }
|
|
34
36
|
/* Closed: no more answers count. */
|
|
35
37
|
.poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
|
|
36
38
|
/* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
|
package/src/core/editDeck.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// identical; callers reparse the result.
|
|
5
5
|
import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
|
|
6
6
|
import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
|
|
7
|
-
import { openTagsIn } from './tags.js'
|
|
7
|
+
import { openTagsIn, tagsIn, closingTagOf } from './tags.js'
|
|
8
|
+
import { planFor, formatTime } from './timing.js'
|
|
8
9
|
|
|
9
10
|
const REGION_RE = /^[a-z][a-z0-9-]*$/
|
|
10
11
|
|
|
@@ -247,11 +248,27 @@ export function hasActivity(slide) {
|
|
|
247
248
|
// to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
|
|
248
249
|
// that are gone, so saved drawings can follow their slides.
|
|
249
250
|
export function stripActivities(source) {
|
|
251
|
+
return shareSlides(source, { activities: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The slides a shared build keeps: those numbered in `slides` (1-based, as
|
|
255
|
+
// written; null for all) and, with `activities`, none with a live activity.
|
|
256
|
+
// A deck of nothing but activities keeps them rather than becoming empty. A
|
|
257
|
+
// <style> on a slide left out styles the whole deck, so it moves to the
|
|
258
|
+
// first kept slide. The deck's `time` becomes what the kept slides were
|
|
259
|
+
// planned to take, so each keeps its share. Returns { source, ids, removed }
|
|
260
|
+
// as stripActivities.
|
|
261
|
+
export function shareSlides(source, { activities = false, slides = null } = {}) {
|
|
250
262
|
const deck = parseSlides(source)
|
|
251
|
-
const dropped = deck.slides.filter(hasActivity)
|
|
252
263
|
const same = () => ({ source, ids: Object.fromEntries(deck.slides.map(slide => [slide.id, slide.id])), removed: [] })
|
|
253
|
-
|
|
254
|
-
|
|
264
|
+
if (slides) {
|
|
265
|
+
const beyond = slides.filter(n => n > deck.slides.length)
|
|
266
|
+
if (beyond.length) throw new Error(`The deck has ${deck.slides.length} slides; there is no slide ${beyond.join(', ')}`)
|
|
267
|
+
}
|
|
268
|
+
let chosen = deck.slides.filter((slide, i) => !slides || slides.includes(i + 1))
|
|
269
|
+
if (activities && !chosen.every(hasActivity)) chosen = chosen.filter(slide => !hasActivity(slide))
|
|
270
|
+
const dropped = deck.slides.filter(slide => !chosen.includes(slide))
|
|
271
|
+
if (!dropped.length) return same()
|
|
255
272
|
const ranges = dropped.map(slide => {
|
|
256
273
|
const bounds = slideBounds(deck, slide.id)
|
|
257
274
|
const next = deck.slides[deck.slides.indexOf(slide) + 1]
|
|
@@ -266,8 +283,28 @@ export function stripActivities(source) {
|
|
|
266
283
|
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
|
|
267
284
|
else merged.push({ ...range })
|
|
268
285
|
}
|
|
269
|
-
|
|
286
|
+
let next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
|
|
287
|
+
const styles = dropped.flatMap(slide => styleBlocks(deck.source.slice(slide.source.start, slide.source.end)))
|
|
288
|
+
if (styles.length) {
|
|
289
|
+
const kept = parseSlides(next)
|
|
290
|
+
const nl = detectNewline(next)
|
|
291
|
+
next = appendBlock(kept, kept.slides[0], styles.join(nl) + nl)
|
|
292
|
+
}
|
|
293
|
+
if (deck.deckConfig.time != null) {
|
|
294
|
+
const plan = planFor(deck.slides, deck.deckConfig)
|
|
295
|
+
const kept = chosen.reduce((sum, slide) => sum + plan.perSlide[deck.slides.indexOf(slide)], 0)
|
|
296
|
+
if (plan.budget) next = setDeckConfig(parseSlides(next), { time: kept ? formatTime(kept) : undefined })
|
|
297
|
+
}
|
|
270
298
|
const after = parseSlides(next).slides
|
|
271
|
-
|
|
272
|
-
|
|
299
|
+
return { source: next, ids: Object.fromEntries(chosen.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The <style> elements in a slide's text, found as the slide finds them (not in code).
|
|
303
|
+
function styleBlocks(text) {
|
|
304
|
+
const tags = tagsIn(text)
|
|
305
|
+
return tags.flatMap((tag, at) => {
|
|
306
|
+
if (tag.name !== 'style' || tag.closing || tag.start == null) return []
|
|
307
|
+
const close = closingTagOf(tags, at)
|
|
308
|
+
return close?.end != null ? [text.slice(tag.start, close.end)] : []
|
|
309
|
+
})
|
|
273
310
|
}
|