mdeck 3.10.1 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +13 -2
  3. package/assets/base.css +4 -1
  4. package/docs/reference/authoring.md +65 -4
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/commands.md +3 -1
  7. package/docs/site/content/drawing.md +1 -1
  8. package/docs/site/content/install.md +47 -0
  9. package/docs/site/content/media.md +20 -1
  10. package/docs/site/content/presenting.md +39 -2
  11. package/docs/site/content/sharing.md +1 -0
  12. package/docs/site/content/welcome.md +21 -1
  13. package/docs/site/images/features/hotspots.webp +0 -0
  14. package/docs/site/images/features/languages.webp +0 -0
  15. package/docs/site/pages.js +1 -0
  16. package/examples/custom-layouts/slides.md +21 -9
  17. package/examples/showcase/refs.bib +21 -0
  18. package/examples/showcase/slides.ar.drawings.json +11 -0
  19. package/examples/showcase/slides.ar.md +567 -0
  20. package/examples/showcase/slides.de.drawings.json +11 -0
  21. package/examples/showcase/slides.de.md +567 -0
  22. package/examples/showcase/slides.en.drawings.json +11 -0
  23. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  24. package/package.json +1 -1
  25. package/skills/write-slides/SKILL.md +10 -1
  26. package/src/build/check.js +7 -1
  27. package/src/build/config.js +2 -0
  28. package/src/build/slidesPlugin.js +6 -6
  29. package/src/build/svgCheck.js +65 -0
  30. package/src/build/timingPlugin.js +80 -0
  31. package/src/cli/args.js +4 -3
  32. package/src/cli/index.js +34 -11
  33. package/src/components/VideoPlayer.jsx +6 -1
  34. package/src/core/editDeck.js +44 -7
  35. package/src/core/icons.js +3 -0
  36. package/src/core/labels.js +75 -0
  37. package/src/core/parseSlides.js +3 -3
  38. package/src/core/timing.js +83 -0
  39. package/src/core/validateDeck.js +5 -0
  40. package/src/editor/components/DeckSettings.jsx +1 -0
  41. package/src/editor/components/MetaFields.jsx +1 -0
  42. package/src/home/Home.jsx +1 -0
  43. package/src/layouts/layoutApi.jsx +51 -8
  44. package/src/runtime/RehearsalSummary.jsx +129 -0
  45. package/src/runtime/component.js +27 -0
  46. package/src/runtime/deck-stage.js +49 -20
  47. package/src/runtime/ink/bus.js +13 -5
  48. package/src/runtime/ink/outbox.js +27 -0
  49. package/src/runtime/ink/room.js +31 -6
  50. package/src/runtime/ink/segments.js +21 -0
  51. package/src/runtime/main.jsx +157 -28
  52. package/src/runtime/shared.js +77 -0
  53. package/src/runtime/steps.js +36 -0
  54. package/src/runtime/useTalkTimer.js +65 -0
  55. package/examples/citations/refs.bib +0 -30
  56. package/examples/citations/refs.yml +0 -21
  57. package/examples/citations/slides.md +0 -43
  58. package/examples/citations/slides.pdf +0 -0
  59. package/examples/poll/slides.md +0 -127
  60. package/examples/showcase/slides.drawings.json +0 -11
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, stripActivities as stripActivitiesFrom } from '../core/editDeck.js'
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 = stripActivitiesFrom(markdown).source
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
- if (stripActivities && stripActivitiesFrom(readFileSync(resolve(input), 'utf-8')).removed.length) tip('Slides with polls were left out (--no-polls).')
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
- ? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen
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
  ) : (
@@ -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
- // A deck of nothing but activities stays as it is rather than becoming empty.
254
- if (!dropped.length || dropped.length === deck.slides.length) return same()
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
- const next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
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
- const kept = deck.slides.filter(slide => !dropped.includes(slide))
272
- return { source: next, ids: Object.fromEntries(kept.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
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
  }
@@ -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)
@@ -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
+ }
@@ -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])
@@ -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>
@@ -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/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>