mdeck 3.10.0 → 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 +27 -0
- package/README.md +13 -2
- package/assets/base.css +4 -1
- package/docs/reference/authoring.md +66 -5
- package/docs/reference/extensions.md +1 -1
- package/docs/reference/themes.md +2 -1
- 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/theme-authoring.md +2 -1
- 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 +5 -4
- package/src/editor/components/ExtensionForms.jsx +1 -1
- package/src/editor/components/MetaFields.jsx +1 -0
- package/src/extensions/params.js +28 -1
- 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
package/src/cli/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { checkDeck, bibliographyDiagnostics } from '../build/check.js'
|
|
|
16
16
|
import { livePlugin, startLiveServer } from '../live/server.js'
|
|
17
17
|
import { inkPlugin } from '../build/inkPlugin.js'
|
|
18
18
|
import { resultsPlugin } from '../build/resultsPlugin.js'
|
|
19
|
+
import { timingPlugin } from '../build/timingPlugin.js'
|
|
19
20
|
import { createPairing, pairingPlugin } from '../build/pairing.js'
|
|
20
21
|
import { isAllowedRequest } from '../build/editorPlugin.js'
|
|
21
22
|
import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
|
|
@@ -26,7 +27,7 @@ import { paramKind } from '../extensions/params.js'
|
|
|
26
27
|
|
|
27
28
|
import { frameworkRoot } from '../paths.js'
|
|
28
29
|
import { parseSlides } from '../core/parseSlides.js'
|
|
29
|
-
import { stripNotes as stripNotesFrom,
|
|
30
|
+
import { stripNotes as stripNotesFrom, shareSlides } from '../core/editDeck.js'
|
|
30
31
|
import { parseArgs, parseSlideNumbers, validateServerOrigin } from './args.js'
|
|
31
32
|
import { assertDrawings } from '../build/drawings.js'
|
|
32
33
|
import { resultsFileFor, normalizeResults, resultsCsv } from '../core/results.js'
|
|
@@ -59,6 +60,16 @@ const positionals = () => parsed.positionals
|
|
|
59
60
|
const outputOption = () => parsed.options['--output'] ?? null
|
|
60
61
|
const serverOption = () => parsed.options['--server']
|
|
61
62
|
const portOption = () => parsed.options['--port'] ?? null
|
|
63
|
+
// --slide 3,5-7 for build, send and pdf: only these slides, numbered as written.
|
|
64
|
+
function slideOption(input) {
|
|
65
|
+
const given = parsed.options['--slide']
|
|
66
|
+
if (given == null) return null
|
|
67
|
+
try {
|
|
68
|
+
const slides = parseSlideNumbers(given)
|
|
69
|
+
shareSlides(readFileSync(resolve(input), 'utf-8'), { slides })
|
|
70
|
+
return slides
|
|
71
|
+
} catch (error) { err(error.message); process.exit(1) }
|
|
72
|
+
}
|
|
62
73
|
|
|
63
74
|
// Layouts, themes and palettes all come from one registry: built-ins plus the
|
|
64
75
|
// extensions/ folder beside the deck. Manifest problems are reported and stop
|
|
@@ -181,13 +192,13 @@ async function runNewWizard() {
|
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false } = {}) {
|
|
195
|
+
async function copyLocalAssets(slidesPath, outDir, { stripNotes = false, stripActivities = false, slides = null } = {}) {
|
|
185
196
|
const absSlides = resolve(slidesPath)
|
|
186
197
|
const deckDir = dirname(absSlides)
|
|
187
198
|
const absOutDir = resolve(outDir)
|
|
188
199
|
const raw = readFileSync(absSlides, 'utf-8')
|
|
189
200
|
let markdown = stripNotes ? stripNotesFrom(raw) : raw
|
|
190
|
-
if (stripActivities) markdown =
|
|
201
|
+
if (stripActivities || slides) markdown = shareSlides(markdown, { activities: stripActivities, slides }).source
|
|
191
202
|
|
|
192
203
|
for (const ref of collectLocalAssetRefs(markdown)) {
|
|
193
204
|
const source = resolve(deckDir, ref)
|
|
@@ -314,6 +325,8 @@ const HELP = `
|
|
|
314
325
|
(the address, or the deck's server setting, or MDECK_SERVER;
|
|
315
326
|
the key in MDECK_SERVER_KEY)
|
|
316
327
|
--no-open --port <number>
|
|
328
|
+
${c.green}mdeck rehearse${c.reset} [slides.md] Like run, but opens the presenter view rehearsing: it times
|
|
329
|
+
each slide and saves the times into the deck (time: 1m30s)
|
|
317
330
|
${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
|
|
318
331
|
${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
|
|
319
332
|
saves to the folder's extensions/ (default: this folder)
|
|
@@ -332,6 +345,8 @@ const HELP = `
|
|
|
332
345
|
--notes keep the speaker notes in the file
|
|
333
346
|
--no-polls for send, build --reader and pdf: leave out the slides with polls and join codes
|
|
334
347
|
--no-pdf do not render the PDF inside
|
|
348
|
+
--slide 3,5-7 for build, send and pdf: only these slides (numbered as in the deck,
|
|
349
|
+
with their drawings; styles from slides left out stay)
|
|
335
350
|
${c.green}mdeck pdf${c.reset} [slides.md] [-o talk.pdf] A PDF, one page per slide
|
|
336
351
|
--no-drawings for build, send and pdf: leave out <slides>.drawings.json
|
|
337
352
|
--no-results for build, send and pdf: leave out <slides>.results.json (the answers kept in the talk)
|
|
@@ -402,7 +417,7 @@ if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); process
|
|
|
402
417
|
let parsed
|
|
403
418
|
try {
|
|
404
419
|
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.')
|
|
420
|
+
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
421
|
} catch (error) {
|
|
407
422
|
err(error.message)
|
|
408
423
|
if (Object.hasOwn(RENAMED_COMMANDS, command)) tip(`mdeck ${command} is now ${RENAMED_COMMANDS[command]}.`)
|
|
@@ -538,7 +553,7 @@ if (command === 'new') {
|
|
|
538
553
|
else process.stdout.write(layouts[name].starter)
|
|
539
554
|
|
|
540
555
|
// ── run ───────────────────────────────────────────────────────────────────────
|
|
541
|
-
} else if (command === 'run') {
|
|
556
|
+
} else if (command === 'run' || command === 'rehearse') {
|
|
542
557
|
const input = requireInput(command)
|
|
543
558
|
const abs = resolve(input)
|
|
544
559
|
try { assertDrawings(abs) } catch (error) { err(error.message); process.exit(1) }
|
|
@@ -584,13 +599,13 @@ if (command === 'new') {
|
|
|
584
599
|
...base,
|
|
585
600
|
// A paired iPad may save ink and steer the rooms, like this computer.
|
|
586
601
|
...(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) })],
|
|
602
|
+
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
603
|
publicDir: dirname(abs),
|
|
589
604
|
server: {
|
|
590
605
|
...base.server,
|
|
591
606
|
...(port ? { port, strictPort: true } : {}),
|
|
592
607
|
host: exposed ? true : base.server.host,
|
|
593
|
-
open: hasFlag('--no-open') ? false : `${mountBase}home.html`,
|
|
608
|
+
open: hasFlag('--no-open') ? false : command === 'rehearse' ? `${mountBase}?view=presenter&rehearse=1` : `${mountBase}home.html`,
|
|
594
609
|
},
|
|
595
610
|
})
|
|
596
611
|
await server.listen()
|
|
@@ -624,6 +639,7 @@ if (command === 'new') {
|
|
|
624
639
|
console.log()
|
|
625
640
|
ok(`Launch page: ${c.cyan}${new URL('home.html', local).href}${c.reset}`)
|
|
626
641
|
tip(`Presenter: ${new URL('?view=presenter', local).href}`)
|
|
642
|
+
tip(`Rehearse: ${new URL('?view=presenter&rehearse=1', local).href}`)
|
|
627
643
|
tip(`Audience: ${new URL('?view=audience', local).href}`)
|
|
628
644
|
// Phones in the room follow the talk (the reader view would show slides ahead).
|
|
629
645
|
if (exposed) for (const url of server.resolvedUrls.network) tip(`Follow: ${new URL('?view=follow', url).href}`)
|
|
@@ -696,6 +712,7 @@ if (command === 'new') {
|
|
|
696
712
|
// Polls show as a record of the talk; --no-polls leaves their slides and join codes out.
|
|
697
713
|
const stripActivities = reader && hasFlag('--no-polls')
|
|
698
714
|
const wantPdf = sending && !hasFlag('--no-pdf')
|
|
715
|
+
const slides = slideOption(input)
|
|
699
716
|
|
|
700
717
|
if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
|
|
701
718
|
const given = outputOption()
|
|
@@ -709,7 +726,7 @@ if (command === 'new') {
|
|
|
709
726
|
try {
|
|
710
727
|
await build({
|
|
711
728
|
...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()],
|
|
729
|
+
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
730
|
build: {
|
|
714
731
|
outDir,
|
|
715
732
|
emptyOutDir: !tempDir,
|
|
@@ -727,7 +744,7 @@ if (command === 'new') {
|
|
|
727
744
|
await mkdir(finalOutDir, { recursive: true })
|
|
728
745
|
|
|
729
746
|
if (!selfContained) {
|
|
730
|
-
await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities })
|
|
747
|
+
await copyLocalAssets(input, finalOutDir, { stripNotes, stripActivities, slides })
|
|
731
748
|
if (launchers) await writePresenterLaunchers(finalOutDir, htmlFilename)
|
|
732
749
|
}
|
|
733
750
|
|
|
@@ -751,7 +768,12 @@ if (command === 'new') {
|
|
|
751
768
|
} finally { await rm(pdfDir, { recursive: true, force: true }) }
|
|
752
769
|
}
|
|
753
770
|
if (stripNotes) tip('Speaker notes were removed from this file (use --notes to keep them).')
|
|
754
|
-
|
|
771
|
+
// Only polls among the slides chosen with --slide were left out for --no-polls.
|
|
772
|
+
if (stripActivities) {
|
|
773
|
+
const raw = readFileSync(resolve(input), 'utf-8')
|
|
774
|
+
if (shareSlides(raw, { activities: true, slides }).removed.length > (slides ? shareSlides(raw, { slides }).removed.length : 0)) tip('Slides with polls were left out (--no-polls).')
|
|
775
|
+
}
|
|
776
|
+
if (slides) tip(`Only slides ${parsed.options['--slide']} (--slide).`)
|
|
755
777
|
ok(`${sending ? 'Made' : 'Built'}: ${c.cyan}${htmlFile}${c.reset}${reader ? ' — opens in the reader view' : ''}${selfContained ? ' (single file)' : ' + local assets'}\n`)
|
|
756
778
|
|
|
757
779
|
// ── skill ─────────────────────────────────────────────────────────────────────
|
|
@@ -777,11 +799,12 @@ if (command === 'new') {
|
|
|
777
799
|
const pdfFile = resolve(process.cwd(), outputOption() ?? basename(input).replace(/\.md$/i, '') + '.pdf')
|
|
778
800
|
if (!hasFlag('--no-drawings')) { try { assertDrawings(input) } catch (error) { err(error.message); process.exit(1) } }
|
|
779
801
|
if (!findChrome()) { err('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.'); process.exit(1) }
|
|
802
|
+
const slides = slideOption(input)
|
|
780
803
|
const tempDir = mkdtempSync(resolve(tmpdir(), 'mdeck-pdf-'))
|
|
781
804
|
try {
|
|
782
805
|
await build({
|
|
783
806
|
...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()],
|
|
807
|
+
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
808
|
build: { outDir: tempDir, emptyOutDir: true, target: 'esnext', assetsInlineLimit: 100 * 1024 * 1024 },
|
|
786
809
|
logLevel: 'warn',
|
|
787
810
|
})
|
|
@@ -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
|
) : (
|
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
|
}
|
package/src/core/icons.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// markup of the Lucide SVG.
|
|
6
6
|
|
|
7
7
|
export const ICONS = {
|
|
8
|
+
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>', // house
|
|
8
9
|
presenter: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>', // presentation
|
|
9
10
|
projector: '<path d="M5 7 3 5"/><path d="M9 6V3"/><path d="m13 7 2-2"/><circle cx="9" cy="13" r="3"/><path d="M11.83 12H20a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h2.17"/><path d="M16 16h2"/>', // projector
|
|
10
11
|
reader: '<path d="M12 5v16"/><path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"/>', // book-open
|
|
@@ -26,6 +27,7 @@ export const ICONS = {
|
|
|
26
27
|
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', // arrow-up
|
|
27
28
|
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', // arrow-down
|
|
28
29
|
add: '<path d="M5 12h14"/><path d="M12 5v14"/>', // plus
|
|
30
|
+
remove: '<path d="M5 12h14"/>', // minus
|
|
29
31
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', // x
|
|
30
32
|
grid: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>', // layout-grid
|
|
31
33
|
menu: '<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>', // menu
|
|
@@ -60,6 +62,7 @@ export const ICONS = {
|
|
|
60
62
|
'fullscreen-exit': '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>', // minimize
|
|
61
63
|
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
|
|
62
64
|
hotspots: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/>', // scan-search
|
|
65
|
+
rehearse: '<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>', // timer
|
|
63
66
|
pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
|
|
64
67
|
loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
|
|
65
68
|
}
|
package/src/core/labels.js
CHANGED
|
@@ -151,6 +151,79 @@ export const LABELS = {
|
|
|
151
151
|
'question.empty': 'Hier erscheinen die Antworten.',
|
|
152
152
|
'scale.average': 'Durchschnitt {n}',
|
|
153
153
|
},
|
|
154
|
+
ar: {
|
|
155
|
+
'reader.outline': 'المخطط',
|
|
156
|
+
'reader.untitled': 'الشرائح',
|
|
157
|
+
'reader.slides': 'الشرائح',
|
|
158
|
+
'reader.read': 'قراءة',
|
|
159
|
+
'reader.appearance': 'فاتح أو داكن',
|
|
160
|
+
'reader.light': 'فاتح',
|
|
161
|
+
'reader.dark': 'داكن',
|
|
162
|
+
'reader.drawings': 'الرسوم',
|
|
163
|
+
'reader.hideDrawings': 'إخفاء الرسوم',
|
|
164
|
+
'reader.showDrawings': 'إظهار الرسوم',
|
|
165
|
+
'reader.downloadPdf': 'تنزيل PDF',
|
|
166
|
+
'reader.savePdf': 'حفظ بصيغة PDF…',
|
|
167
|
+
'reader.savePdfHint': 'يفتح نافذة الطباعة في المتصفح؛ اختر الحفظ بصيغة PDF',
|
|
168
|
+
'reader.present': 'عرض',
|
|
169
|
+
'reader.previous': 'السابق',
|
|
170
|
+
'reader.next': 'التالي',
|
|
171
|
+
'reader.copyLink': 'نسخ رابط هذه الشريحة',
|
|
172
|
+
'reader.linkCopied': 'نُسخ الرابط',
|
|
173
|
+
'reader.slide': 'الشريحة {n}',
|
|
174
|
+
'deck.overview': 'نظرة عامة',
|
|
175
|
+
'slide.chapter': 'الفصل',
|
|
176
|
+
'deck.controls': 'أدوات التحكم في العرض',
|
|
177
|
+
'deck.previous': 'الشريحة السابقة',
|
|
178
|
+
'deck.next': 'الشريحة التالية',
|
|
179
|
+
'deck.reset': 'من البداية',
|
|
180
|
+
'deck.resetHint': 'العودة إلى الشريحة الأولى',
|
|
181
|
+
'poll.scan': 'امسح الرمز للتصويت',
|
|
182
|
+
'video.sound': 'انقر لتشغيل الصوت',
|
|
183
|
+
'poll.answer': 'إجابة واحدة',
|
|
184
|
+
'poll.answers': '{n} إجابات',
|
|
185
|
+
'poll.reset': 'من البداية',
|
|
186
|
+
'poll.showAnswer': 'إظهار الإجابة الصحيحة',
|
|
187
|
+
'poll.hideAnswer': 'إخفاء الإجابة الصحيحة',
|
|
188
|
+
'poll.showResults': 'إظهار النتائج',
|
|
189
|
+
'poll.hideResults': 'إخفاء النتائج',
|
|
190
|
+
'poll.live': 'مباشر',
|
|
191
|
+
'poll.offline': 'غير متصل بالخادم',
|
|
192
|
+
'poll.unreachable': 'لا تستطيع الهواتف الوصول إلى هذا الحاسوب. ابدأ بالأمر {command}، أو اضبط {setting}.',
|
|
193
|
+
'poll.tryHere': 'جرّب صفحة الإجابة هنا',
|
|
194
|
+
'poll.static': 'أُجيب عنه مباشرة أثناء المحاضرة.',
|
|
195
|
+
'poll.pick': 'اختر إجابة واحدة.',
|
|
196
|
+
'poll.thanks': 'شكرًا! اخترت «{choice}». اختر غيرها لتغيير إجابتك.',
|
|
197
|
+
'respond.waiting': 'سيظهر السؤال التالي هنا.',
|
|
198
|
+
'respond.send': 'إرسال',
|
|
199
|
+
'respond.sent': 'أُرسلت. شكرًا لك!',
|
|
200
|
+
'join.scan': 'امسح الرمز للانضمام',
|
|
201
|
+
'follow.scan': 'امسح الرمز لمتابعة الشرائح',
|
|
202
|
+
'follow.open': 'متابعة الشرائح',
|
|
203
|
+
'follow.live': 'مباشر',
|
|
204
|
+
'follow.back': 'العودة إلى البث المباشر',
|
|
205
|
+
'follow.waiting': 'لم تبدأ المحاضرة بعد.',
|
|
206
|
+
'follow.tryHere': 'جرّب المتابعة هنا',
|
|
207
|
+
'join.static': 'أثناء المحاضرة انضمت الهواتف من هنا للإجابة.',
|
|
208
|
+
'poll.answeredOf': 'أجاب {n} من {of}',
|
|
209
|
+
'poll.close': 'إغلاق: لا تُحتسب الإجابات اللاحقة',
|
|
210
|
+
'poll.open': 'فتح من جديد',
|
|
211
|
+
'poll.closed': 'مغلق',
|
|
212
|
+
'poll.several': 'يمكن اختيار أكثر من إجابة.',
|
|
213
|
+
'poll.pickSeveral': 'اختر كل ما ينطبق.',
|
|
214
|
+
'poll.thanksSeveral': 'شكرًا! اخترت {choice}. اختر مجددًا للتغيير.',
|
|
215
|
+
'respond.closed': 'أُغلق: لا مزيد من الإجابات.',
|
|
216
|
+
'respond.right': 'صحيح!',
|
|
217
|
+
'respond.wrong': 'ليس تمامًا. الإجابة الصحيحة: {answer}',
|
|
218
|
+
'respond.solution': 'الإجابة الصحيحة: {answer}',
|
|
219
|
+
'numeric.placeholder': 'رقم',
|
|
220
|
+
'numeric.invalid': 'أدخل رقمًا، مثل 0.5 أو 1/2.',
|
|
221
|
+
'numeric.sent': 'أُرسل: {value}. أرسل رقمًا آخر للتغيير.',
|
|
222
|
+
'numeric.other': 'أخرى',
|
|
223
|
+
'numeric.right': '{n} صحيحة',
|
|
224
|
+
'question.empty': 'تظهر الإجابات هنا.',
|
|
225
|
+
'scale.average': 'المتوسط {n}',
|
|
226
|
+
},
|
|
154
227
|
}
|
|
155
228
|
|
|
156
229
|
export const LABEL_KEYS = Object.keys(LABELS.en)
|
|
@@ -162,6 +235,8 @@ export const CALLOUT_LABELS = {
|
|
|
162
235
|
definition: 'Definition', theorem: 'Theorem', lemma: 'Lemma', corollary: 'Corollary', proof: 'Proof', example: 'Example', remark: 'Remark' },
|
|
163
236
|
de: { note: 'Hinweis', tip: 'Tipp', important: 'Wichtig', warning: 'Achtung', caution: 'Vorsicht',
|
|
164
237
|
definition: 'Definition', theorem: 'Satz', lemma: 'Lemma', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel', remark: 'Bemerkung' },
|
|
238
|
+
ar: { note: 'ملاحظة', tip: 'نصيحة', important: 'مهم', warning: 'تحذير', caution: 'انتبه',
|
|
239
|
+
definition: 'تعريف', theorem: 'مبرهنة', lemma: 'تمهيدية', corollary: 'نتيجة', proof: 'برهان', example: 'مثال', remark: 'ملحوظة' },
|
|
165
240
|
}
|
|
166
241
|
|
|
167
242
|
export const CALLOUT_TYPES = Object.keys(CALLOUT_LABELS.en)
|
package/src/core/parseSlides.js
CHANGED
|
@@ -2,8 +2,8 @@ import yaml from 'js-yaml'
|
|
|
2
2
|
import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
|
|
3
3
|
export { sourceLines, fenceState } from './source.js'
|
|
4
4
|
|
|
5
|
-
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots'.split(' '))
|
|
6
|
-
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots'.split(' '))
|
|
5
|
+
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots time'.split(' '))
|
|
6
|
+
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots time'.split(' '))
|
|
7
7
|
|
|
8
8
|
// Names from before 2.0. They are still recognised, so that an old deck gets a
|
|
9
9
|
// message naming the new setting (see validateDeck) instead of being read as
|
|
@@ -44,7 +44,7 @@ function segmentsOf(source) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// The first block is the deck's settings when its first key is only a deck
|
|
47
|
-
// key. A key both may have (hotspots) leaves it to the others: a slide's own
|
|
47
|
+
// key. A key both may have (hotspots, time) leaves it to the others: a slide's own
|
|
48
48
|
// key makes it the first slide's, otherwise it is the deck's.
|
|
49
49
|
function isDeckBlock(text) {
|
|
50
50
|
const keys = sourceLines(text).map(line => line.text.match(/^([\w-]+):/)?.[1]).filter(Boolean)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Timing a talk: how long each slide may take (`time: 2m` in its settings,
|
|
2
|
+
// `time: 45m` for the whole deck), and where the talk stands against that
|
|
3
|
+
// plan while it is given. A rehearsal (runtime/RehearsalSummary.jsx) writes
|
|
4
|
+
// the times it measured back with formatTime.
|
|
5
|
+
//
|
|
6
|
+
// Times are written 90s, 2m, 1m30s or 1.5m; a bare number is minutes.
|
|
7
|
+
|
|
8
|
+
export const TIME_STEP = 15
|
|
9
|
+
|
|
10
|
+
const TIME_RE = /^(?:(\d+(?:\.\d+)?)\s*m(?:in)?)?\s*(?:(\d+(?:\.\d+)?)\s*s(?:ec)?)?$/i
|
|
11
|
+
|
|
12
|
+
// Seconds, or null for something that is not a time.
|
|
13
|
+
export function parseTime(value) {
|
|
14
|
+
if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : null
|
|
15
|
+
if (typeof value !== 'string') return null
|
|
16
|
+
const text = value.trim()
|
|
17
|
+
if (/^\d+(\.\d+)?$/.test(text)) return parseTime(Number(text))
|
|
18
|
+
const match = text.match(TIME_RE)
|
|
19
|
+
if (!text || !match || (match[1] == null && match[2] == null)) return null
|
|
20
|
+
const seconds = Math.round(Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0))
|
|
21
|
+
return seconds > 0 ? seconds : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The way mdeck writes a time: 45s, 2m, 1m30s.
|
|
25
|
+
export function formatTime(seconds) {
|
|
26
|
+
const s = Math.max(0, Math.round(seconds))
|
|
27
|
+
const m = Math.floor(s / 60), rest = s % 60
|
|
28
|
+
if (!m) return `${rest}s`
|
|
29
|
+
return rest ? `${m}m${rest}s` : `${m}m`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A clock reading: 4:05, or 1:02:03 past an hour; negative values get a minus.
|
|
33
|
+
export function formatClock(seconds) {
|
|
34
|
+
const sign = seconds < 0 ? '-' : ''
|
|
35
|
+
const s = Math.abs(Math.round(seconds))
|
|
36
|
+
const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, rest = s % 60
|
|
37
|
+
const pad = n => String(n).padStart(2, '0')
|
|
38
|
+
return h ? `${sign}${h}:${pad(m)}:${pad(rest)}` : `${sign}${m}:${pad(rest)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A measured time to the nearest step, never less than one step.
|
|
42
|
+
export function roundTime(seconds, step = TIME_STEP) {
|
|
43
|
+
return Math.max(step, Math.round(seconds / step) * step)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Each slide's share of the talk. Slides without a time share what the
|
|
47
|
+
// deck's `time` leaves; without one they count as nothing. `times` replaces
|
|
48
|
+
// slides' own times by id (what a rehearsal just saved).
|
|
49
|
+
export function planFor(slides, deckConfig = {}, times = {}) {
|
|
50
|
+
const own = slides.map(slide => parseTime(Object.hasOwn(times, slide.id) ? times[slide.id] : slide.meta?.time))
|
|
51
|
+
const budget = parseTime(deckConfig?.time)
|
|
52
|
+
const set = own.reduce((sum, t) => sum + (t ?? 0), 0)
|
|
53
|
+
const untimed = own.filter(t => t == null).length
|
|
54
|
+
const share = budget && untimed ? Math.max(0, budget - set) / untimed : 0
|
|
55
|
+
const perSlide = own.map(t => t ?? share)
|
|
56
|
+
const start = []
|
|
57
|
+
let total = 0
|
|
58
|
+
for (const t of perSlide) { start.push(total); total += t }
|
|
59
|
+
return { perSlide, start, total, own, budget }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Where the talk stands on slide `index`: the time left on it (negative when
|
|
63
|
+
// over), and how far behind the plan the talk is (negative: ahead).
|
|
64
|
+
export function scheduleAt(plan, index, { totalElapsed, slideElapsed }) {
|
|
65
|
+
const planned = plan.perSlide[index] ?? 0
|
|
66
|
+
const slideLeft = planned - slideElapsed
|
|
67
|
+
const due = (plan.start[index] ?? plan.total) + Math.min(slideElapsed, planned)
|
|
68
|
+
return { planned, slideLeft, delta: totalElapsed - due }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A rehearsal's new time for each slide, in seconds or null for none: the
|
|
72
|
+
// time it got, rounded, or for a slide it did not reach, the one it has.
|
|
73
|
+
export function initialTimes(slides, spent, own = slides.map(slide => parseTime(slide.meta?.time))) {
|
|
74
|
+
return slides.map((slide, i) => spent[slide.id] >= 1 ? roundTime(spent[slide.id]) : own[i])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// What saving a rehearsal changes: { slideId: '1m30s' | null } for the slides
|
|
78
|
+
// whose new time is not the one they have.
|
|
79
|
+
export function changedTimes(slides, own, next) {
|
|
80
|
+
const times = {}
|
|
81
|
+
slides.forEach((slide, i) => { if (next[i] !== own[i]) times[slide.id] = next[i] == null ? null : formatTime(next[i]) })
|
|
82
|
+
return times
|
|
83
|
+
}
|
package/src/core/validateDeck.js
CHANGED
|
@@ -5,9 +5,12 @@ import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
|
|
|
5
5
|
import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
|
|
6
6
|
import { movedHint } from '../extensions/moved.js'
|
|
7
7
|
import { paramValue } from '../extensions/params.js'
|
|
8
|
+
import { parseTime } from './timing.js'
|
|
8
9
|
|
|
9
10
|
// Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
|
|
10
11
|
const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
|
|
12
|
+
// Timing (core/timing.js): how long a slide, or the whole talk, may take.
|
|
13
|
+
const TIME_HINT = value => `time: ${JSON.stringify(value)} is not a time; write it as 90s, 2m, 1m30s or 1.5m`
|
|
11
14
|
|
|
12
15
|
// `layouts`, `themes` and `palettes` are manifest maps from the extension
|
|
13
16
|
// registry (see src/extensions/discover.js). Checks for a kind are skipped when
|
|
@@ -52,6 +55,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
52
55
|
if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
|
|
53
56
|
}
|
|
54
57
|
if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
|
|
58
|
+
if (config.time != null && parseTime(config.time) == null) add('invalid-config', TIME_HINT(config.time), deck.configSource?.start)
|
|
55
59
|
const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
|
|
56
60
|
const switches = ['citations']
|
|
57
61
|
if (isPlainObject(config.show)) {
|
|
@@ -96,6 +100,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
96
100
|
if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
|
|
97
101
|
if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
|
|
98
102
|
if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
|
|
103
|
+
if (slide.meta.time != null && parseTime(slide.meta.time) == null) add('invalid-metadata', TIME_HINT(slide.meta.time), offset)
|
|
99
104
|
for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
|
|
100
105
|
if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
|
|
101
106
|
const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
|
|
@@ -10,7 +10,7 @@ import { PalettePicker } from './PalettePicker.jsx'
|
|
|
10
10
|
import { LookThumbnail } from './LookThumbnail.jsx'
|
|
11
11
|
import { designUrl, DESIGN_TAB } from '../designLink.js'
|
|
12
12
|
import { normalMode } from '../../runtime/hotspots.js'
|
|
13
|
-
import { paramValue } from '../../extensions/params.js'
|
|
13
|
+
import { paramValue, paramDefault } from '../../extensions/params.js'
|
|
14
14
|
|
|
15
15
|
// What the `show` setting controls: the label for each choice and its values.
|
|
16
16
|
const SHOW = { organization: ['Organization', ['title', 'all', 'none']], author: ['Author and date', ['title', 'all', 'none']], numbers: ['Slide numbers', ['slides', 'all', 'none']], sections: ['Section labels', ['all', 'none']] }
|
|
@@ -86,6 +86,7 @@ export function DeckSettings({ state, edit }) {
|
|
|
86
86
|
</>}
|
|
87
87
|
<p class="section-title">About the talk</p>
|
|
88
88
|
{META.map(([key, label]) => <Field key={key} label={label}><input type="text" value={config.meta?.[key] ?? ''} onInput={e => meta(key, e.currentTarget.value)} /></Field>)}
|
|
89
|
+
<Field label="Length" hint="how long the talk takes, e.g. 20m; slides without a time of their own share what is left"><input type="text" value={config.time ?? ''} placeholder="20m" onInput={e => text('time', e.currentTarget.value.trim())} /></Field>
|
|
89
90
|
<p class="section-title">Slide frame</p>
|
|
90
91
|
<div class="grid-2">
|
|
91
92
|
<Field label="Width"><input type="number" value={config.width ?? 1920} onInput={e => patch({ width: Number(e.currentTarget.value) || undefined }, 'deck:width')} /></Field>
|
|
@@ -112,8 +113,8 @@ function DesignLink({ kind, item }) {
|
|
|
112
113
|
|
|
113
114
|
// The control for one theme parameter, by its type: a slider for a size or a
|
|
114
115
|
// number with a range, a box to tick for a switch, a list for a choice, else
|
|
115
|
-
// text (with a colour picker when the default is a colour). An empty
|
|
116
|
-
// means the theme's own.
|
|
116
|
+
// a text box (with a colour picker when the default is a colour). An empty
|
|
117
|
+
// value means the theme's own; a text shows the theme's without its quotes.
|
|
117
118
|
function ParamInput({ def, value, onChange }) {
|
|
118
119
|
const current = value != null && !paramValue(def, value).problem ? paramValue(def, value).value : def.default
|
|
119
120
|
if (def.type === 'switch') return <>
|
|
@@ -137,6 +138,6 @@ function ParamInput({ def, value, onChange }) {
|
|
|
137
138
|
const color = isColor(def.default)
|
|
138
139
|
return <>
|
|
139
140
|
{color ? <input type="color" value={isColor(value) ? value : def.default} onInput={e => onChange(e.currentTarget.value)} style={{ flex: '0 0 auto' }} /> : null}
|
|
140
|
-
<input type="text" value={value ?? ''} placeholder={def
|
|
141
|
+
<input type="text" value={value ?? ''} placeholder={paramDefault(def)} onInput={e => onChange(e.currentTarget.value)} />
|
|
141
142
|
</>
|
|
142
143
|
}
|
|
@@ -8,7 +8,7 @@ const isColor = value => /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim())
|
|
|
8
8
|
const update = (list, index, patch) => list.map((item, i) => i === index ? { ...item, ...patch } : item)
|
|
9
9
|
const without = (list, index) => list.filter((_, i) => i !== index)
|
|
10
10
|
// The kinds of value a theme parameter takes (see src/extensions/params.js).
|
|
11
|
-
const PARAM_KINDS = [['', '
|
|
11
|
+
const PARAM_KINDS = [['', 'CSS value'], ['size', 'size'], ['number', 'number'], ['switch', 'switch'], ['choice', 'choice'], ['font', 'font'], ['text', 'text']]
|
|
12
12
|
|
|
13
13
|
function Identity({ model, onChange }) {
|
|
14
14
|
return <>
|
|
@@ -20,6 +20,7 @@ export function MetaFields({ slide, slideEdit }) {
|
|
|
20
20
|
slideEdit((deck, id) => setSlideMeta(deck, id, { hotspots: value === '' ? undefined : value === 'off' ? false : value }), { group: 'meta:hotspots' })
|
|
21
21
|
}}><option value="">the deck's</option>{['auto', 'marked', 'off'].map(o => <option key={o} value={o}>{o}</option>)}</select>
|
|
22
22
|
</Field>
|
|
23
|
+
<Field label="Time" hint="how long to spend on this slide: 90s, 2m or 1m30s; mdeck rehearse measures it">{text('time')}</Field>
|
|
23
24
|
<Field label="Speaker notes"><TextArea rows={4} value={slide.meta.notes ?? ''} onInput={value => slideEdit((deck, id) => setSlideNotes(deck, id, value), { group: 'notes' })} /></Field>
|
|
24
25
|
<details class="more" open={MORE.some(([key]) => meta[key] != null)}>
|
|
25
26
|
<summary>More metadata</summary>
|
package/src/extensions/params.js
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
// switch 1 or 0 (also true and false, on and off, yes and no)
|
|
8
8
|
// choice one of `choices`
|
|
9
9
|
// font a CSS font list
|
|
10
|
+
// text words to show, such as a prompt: the author writes them plain,
|
|
11
|
+
// mdeck quotes them; the theme's own value is a quoted CSS string
|
|
10
12
|
//
|
|
11
13
|
// The manifest check, `mdeck check`, the slides and the editor all use these
|
|
12
14
|
// functions, so they agree on what a value means.
|
|
13
15
|
|
|
14
|
-
export const PARAM_TYPES = ['size', 'number', 'switch', 'choice', 'font']
|
|
16
|
+
export const PARAM_TYPES = ['size', 'number', 'switch', 'choice', 'font', 'text']
|
|
15
17
|
export const PARAM_KEYS = ['token', 'title', 'description', 'type', 'min', 'max', 'step', 'choices']
|
|
16
18
|
const RANGED = ['size', 'number']
|
|
17
19
|
const NUMBER_RE = /^-?(\d+\.?\d*|\.\d+)$/
|
|
@@ -26,6 +28,25 @@ function pixels(value) {
|
|
|
26
28
|
|
|
27
29
|
const unit = param => param.type === 'size' ? 'px' : ''
|
|
28
30
|
|
|
31
|
+
// A CSS string literal ('…' or "…") and the text in it.
|
|
32
|
+
const STRING_RE = /^(['"])((?:\\[^]|(?!\1)[^\\\n])*)\1$/
|
|
33
|
+
|
|
34
|
+
/** Text as a CSS string: quoted, with backslashes, quotes and line breaks escaped. */
|
|
35
|
+
export function cssString(text) {
|
|
36
|
+
return '"' + String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\A ') + '"'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The text in a CSS string literal, or null when it is not one. */
|
|
40
|
+
export function unquote(css) {
|
|
41
|
+
const match = STRING_RE.exec(String(css).trim())
|
|
42
|
+
return match ? match[2].replace(/\\A ?/g, '\n').replace(/\\([^])/g, '$1') : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A parameter's default as an author would write it: a text without its quotes. */
|
|
46
|
+
export function paramDefault(param) {
|
|
47
|
+
return param.type === 'text' ? unquote(param.default) ?? param.default : param.default
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
/** The range a parameter was made for, as text ("6px to 20px", "at least 0"), or ''. */
|
|
30
51
|
export function paramRange(param) {
|
|
31
52
|
const { min, max } = param
|
|
@@ -54,6 +75,8 @@ export function paramValue(param, raw) {
|
|
|
54
75
|
const type = param.type
|
|
55
76
|
if (raw == null || (typeof raw === 'object')) return { problem: 'needs a single value' }
|
|
56
77
|
if (!type) return { value: String(raw) }
|
|
78
|
+
// Text keeps its spaces (a prompt ends in one) and may be empty.
|
|
79
|
+
if (type === 'text') return { value: cssString(raw) }
|
|
57
80
|
const text = String(raw).trim()
|
|
58
81
|
if (!text) return { problem: 'is empty' }
|
|
59
82
|
if (type === 'font') return { value: text }
|
|
@@ -98,6 +121,10 @@ export function paramSettingProblems(param, fallback) {
|
|
|
98
121
|
else if (new Set(choices).size !== choices.length) problems.push(['choices', 'lists a value twice'])
|
|
99
122
|
} else if (param.choices != null) problems.push(['choices', `is for a choice, not a ${type}`])
|
|
100
123
|
if (problems.length || fallback == null) return problems
|
|
124
|
+
if (type === 'text') {
|
|
125
|
+
if (unquote(fallback) == null) problems.push(['type', `a text's token is a quoted CSS string, such as "'~/talk $ '" (got ${JSON.stringify(fallback)})`])
|
|
126
|
+
return problems
|
|
127
|
+
}
|
|
101
128
|
// The default is the token's value: it has to be a value of this type too.
|
|
102
129
|
const result = paramValue(param, fallback)
|
|
103
130
|
if (result.problem) problems.push(['type', `the token's value ${JSON.stringify(fallback)} ${result.problem}`])
|
package/src/home/Home.jsx
CHANGED
|
@@ -331,6 +331,7 @@ export function Home() {
|
|
|
331
331
|
</div>
|
|
332
332
|
<div class="home-tiles">
|
|
333
333
|
<ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Switch to slides only or fullscreen." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
|
|
334
|
+
<ViewTile icon="rehearse" title="Rehearse" text="Go through the talk as if giving it: each slide is timed, and the times can be saved into the deck." href={`${BASE}?view=presenter&rehearse=1&session=${SESSION}${code}`} />
|
|
334
335
|
<ViewTile icon="projector" title="Audience window" text="Slides for the projector, without notes. Navigation stays in sync with the presenter." href={`${BASE}?view=audience&session=${SESSION}${code}`} open={openOn('audience')} />
|
|
335
336
|
<ViewTile icon="reader" title="Reader view" text="Outline, reading mode and look picker, as people you send it to see it." href={`${BASE}?view=reader`} />
|
|
336
337
|
</div>
|