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.
Files changed (91) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +15 -4
  3. package/assets/base.css +6 -1
  4. package/docs/reference/authoring.md +111 -9
  5. package/docs/site/assets/site.css +2 -0
  6. package/docs/site/content/appearance.md +1 -1
  7. package/docs/site/content/audience.md +36 -8
  8. package/docs/site/content/commands.md +6 -3
  9. package/docs/site/content/drawing.md +5 -5
  10. package/docs/site/content/getting-started.md +2 -32
  11. package/docs/site/content/install.md +49 -0
  12. package/docs/site/content/media.md +20 -1
  13. package/docs/site/content/presenting.md +67 -9
  14. package/docs/site/content/sharing.md +1 -0
  15. package/docs/site/content/welcome.md +21 -1
  16. package/docs/site/content/writing.md +27 -0
  17. package/docs/site/images/features/hotspots.webp +0 -0
  18. package/docs/site/images/features/languages.webp +0 -0
  19. package/docs/site/pages.js +1 -0
  20. package/examples/custom-layouts/slides.md +21 -9
  21. package/examples/showcase/refs.bib +21 -0
  22. package/examples/showcase/slides.ar.drawings.json +11 -0
  23. package/examples/showcase/slides.ar.md +567 -0
  24. package/examples/showcase/slides.de.drawings.json +11 -0
  25. package/examples/showcase/slides.de.md +567 -0
  26. package/examples/showcase/slides.de.results.json +57 -0
  27. package/examples/showcase/slides.en.drawings.json +11 -0
  28. package/examples/showcase/{slides.md → slides.en.md} +112 -5
  29. package/package.json +1 -1
  30. package/skills/write-slides/SKILL.md +14 -2
  31. package/src/build/check.js +34 -7
  32. package/src/build/components.js +5 -3
  33. package/src/build/config.js +2 -0
  34. package/src/build/homePlugin.js +64 -14
  35. package/src/build/include.js +249 -0
  36. package/src/build/inkPlugin.js +12 -2
  37. package/src/build/relayAccess.js +3 -1
  38. package/src/build/renderCheck.js +23 -3
  39. package/src/build/slidesPlugin.js +70 -22
  40. package/src/build/svgCheck.js +65 -0
  41. package/src/build/timingPlugin.js +96 -0
  42. package/src/cli/args.js +6 -5
  43. package/src/cli/index.js +77 -21
  44. package/src/components/Bibliography.jsx +7 -2
  45. package/src/components/Poll.jsx +2 -2
  46. package/src/components/QrCodeTag.jsx +2 -2
  47. package/src/components/VideoPlayer.jsx +6 -1
  48. package/src/components/WordCloud.jsx +7 -6
  49. package/src/components/activity.jsx +11 -8
  50. package/src/components/poll.css +2 -0
  51. package/src/core/editDeck.js +44 -7
  52. package/src/core/icons.js +9 -1
  53. package/src/core/labels.js +78 -0
  54. package/src/core/parseSlides.js +16 -6
  55. package/src/core/reactions.js +27 -0
  56. package/src/core/resultsReport.js +153 -0
  57. package/src/core/source.js +8 -1
  58. package/src/core/timing.js +83 -0
  59. package/src/core/validateDeck.js +11 -2
  60. package/src/editor/components/DeckSettings.jsx +1 -0
  61. package/src/editor/components/MetaFields.jsx +1 -0
  62. package/src/home/Home.jsx +39 -93
  63. package/src/home/home.css +5 -17
  64. package/src/layouts/layoutApi.jsx +51 -8
  65. package/src/layouts/renderSlide.jsx +13 -1
  66. package/src/live/answer/answer.css +9 -0
  67. package/src/live/answer/answer.js +32 -4
  68. package/src/live/client.js +23 -4
  69. package/src/live/rooms.js +18 -6
  70. package/src/runtime/KeyHelp.jsx +115 -0
  71. package/src/runtime/PresenterChrome.jsx +168 -0
  72. package/src/runtime/Reactions.jsx +56 -0
  73. package/src/runtime/RehearsalSummary.jsx +129 -0
  74. package/src/runtime/component.js +27 -0
  75. package/src/runtime/deck-stage.js +371 -37
  76. package/src/runtime/helpKeys.js +75 -0
  77. package/src/runtime/ink/bus.js +13 -5
  78. package/src/runtime/ink/outbox.js +27 -0
  79. package/src/runtime/ink/room.js +37 -12
  80. package/src/runtime/ink/segments.js +21 -0
  81. package/src/runtime/main.jsx +508 -286
  82. package/src/runtime/shared.js +77 -0
  83. package/src/runtime/slideControls.js +60 -0
  84. package/src/runtime/steps.js +36 -0
  85. package/src/runtime/useTalkTimer.js +67 -0
  86. package/examples/citations/refs.bib +0 -30
  87. package/examples/citations/refs.yml +0 -21
  88. package/examples/citations/slides.md +0 -43
  89. package/examples/citations/slides.pdf +0 -0
  90. package/examples/poll/slides.md +0 -127
  91. package/examples/showcase/slides.drawings.json +0 -11
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
@@ -48,11 +50,14 @@ export const ICONS = {
48
50
  highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
49
51
  marker: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>', // sparkles
50
52
  select: '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>', // square-dashed
51
- laser: '<line x1="2" x2="5" y1="12" y2="12"/><line x1="19" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="5"/><line x1="12" x2="12" y1="19" y2="22"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/>', // locate-fixed
53
+ laser: '<rect width="5" height="13" x="3.5" y="9.5" rx="2.5" transform="rotate(45 6 16)"/><path d="m11.5 12.5 3-3" stroke-dasharray="0.1 2.6"/><circle cx="17.5" cy="6.5" r="3"/><path d="M17.5 1v0.5"/><path d="M23 6.5h-.5"/><path d="m21.4 2.6-.35.35"/>', // drawn for mdeck on Lucide's grid: a laser pointer (Lucide has none; its "locate" read as a map pin)
52
54
  fold: '<path d="m7 7 10 10"/><path d="M17 7v10H7"/>', // arrow-down-right
53
55
  eraser: '<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/>', // eraser
56
+ join: '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>', // qr-code
57
+ settings: '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>', // sliders-horizontal
54
58
  people: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>', // users
55
59
  tablet: '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>', // tablet
60
+ phone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', // smartphone
56
61
  speaker: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>', // panel-right
57
62
  fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', // maximize
58
63
  sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', // sun
@@ -60,6 +65,9 @@ export const ICONS = {
60
65
  '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
66
  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
67
  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
68
+ 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
69
+ blank: '<path d="M17 17H4a2 2 0 0 1-2-2V5c0-1.5 1-2 1-2"/><path d="M22 15V5a2 2 0 0 0-2-2H9"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m2 2 20 20"/>', // monitor-off
70
+ overview: '<rect width="18" height="14" x="3" y="3" rx="2"/><path d="M4 21h1"/><path d="M9 21h1"/><path d="M14 21h1"/><path d="M19 21h1"/>', // gallery-horizontal-end
63
71
  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
72
  loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
65
73
  }
@@ -51,6 +51,7 @@ export const LABELS = {
51
51
  'respond.waiting': 'The next question will appear here.',
52
52
  'respond.send': 'Send',
53
53
  'respond.sent': 'Sent. Thank you!',
54
+ 'respond.react': 'React',
54
55
  'join.scan': 'Scan to join',
55
56
  'follow.scan': 'Scan to follow the slides',
56
57
  'follow.open': 'Follow the slides',
@@ -124,6 +125,7 @@ export const LABELS = {
124
125
  'respond.waiting': 'Die nächste Frage erscheint hier.',
125
126
  'respond.send': 'Senden',
126
127
  'respond.sent': 'Gesendet. Danke!',
128
+ 'respond.react': 'Reagieren',
127
129
  'join.scan': 'Scannen und mitmachen',
128
130
  'follow.scan': 'Scannen und Folien mitverfolgen',
129
131
  'follow.open': 'Folien mitverfolgen',
@@ -151,6 +153,80 @@ export const LABELS = {
151
153
  'question.empty': 'Hier erscheinen die Antworten.',
152
154
  'scale.average': 'Durchschnitt {n}',
153
155
  },
156
+ ar: {
157
+ 'reader.outline': 'المخطط',
158
+ 'reader.untitled': 'الشرائح',
159
+ 'reader.slides': 'الشرائح',
160
+ 'reader.read': 'قراءة',
161
+ 'reader.appearance': 'فاتح أو داكن',
162
+ 'reader.light': 'فاتح',
163
+ 'reader.dark': 'داكن',
164
+ 'reader.drawings': 'الرسوم',
165
+ 'reader.hideDrawings': 'إخفاء الرسوم',
166
+ 'reader.showDrawings': 'إظهار الرسوم',
167
+ 'reader.downloadPdf': 'تنزيل PDF',
168
+ 'reader.savePdf': 'حفظ بصيغة PDF…',
169
+ 'reader.savePdfHint': 'يفتح نافذة الطباعة في المتصفح؛ اختر الحفظ بصيغة PDF',
170
+ 'reader.present': 'عرض',
171
+ 'reader.previous': 'السابق',
172
+ 'reader.next': 'التالي',
173
+ 'reader.copyLink': 'نسخ رابط هذه الشريحة',
174
+ 'reader.linkCopied': 'نُسخ الرابط',
175
+ 'reader.slide': 'الشريحة {n}',
176
+ 'deck.overview': 'نظرة عامة',
177
+ 'slide.chapter': 'الفصل',
178
+ 'deck.controls': 'أدوات التحكم في العرض',
179
+ 'deck.previous': 'الشريحة السابقة',
180
+ 'deck.next': 'الشريحة التالية',
181
+ 'deck.reset': 'من البداية',
182
+ 'deck.resetHint': 'العودة إلى الشريحة الأولى',
183
+ 'poll.scan': 'امسح الرمز للتصويت',
184
+ 'video.sound': 'انقر لتشغيل الصوت',
185
+ 'poll.answer': 'إجابة واحدة',
186
+ 'poll.answers': '{n} إجابات',
187
+ 'poll.reset': 'من البداية',
188
+ 'poll.showAnswer': 'إظهار الإجابة الصحيحة',
189
+ 'poll.hideAnswer': 'إخفاء الإجابة الصحيحة',
190
+ 'poll.showResults': 'إظهار النتائج',
191
+ 'poll.hideResults': 'إخفاء النتائج',
192
+ 'poll.live': 'مباشر',
193
+ 'poll.offline': 'غير متصل بالخادم',
194
+ 'poll.unreachable': 'لا تستطيع الهواتف الوصول إلى هذا الحاسوب. ابدأ بالأمر {command}، أو اضبط {setting}.',
195
+ 'poll.tryHere': 'جرّب صفحة الإجابة هنا',
196
+ 'poll.static': 'أُجيب عنه مباشرة أثناء المحاضرة.',
197
+ 'poll.pick': 'اختر إجابة واحدة.',
198
+ 'poll.thanks': 'شكرًا! اخترت «{choice}». اختر غيرها لتغيير إجابتك.',
199
+ 'respond.waiting': 'سيظهر السؤال التالي هنا.',
200
+ 'respond.send': 'إرسال',
201
+ 'respond.sent': 'أُرسلت. شكرًا لك!',
202
+ 'respond.react': 'تفاعل',
203
+ 'join.scan': 'امسح الرمز للانضمام',
204
+ 'follow.scan': 'امسح الرمز لمتابعة الشرائح',
205
+ 'follow.open': 'متابعة الشرائح',
206
+ 'follow.live': 'مباشر',
207
+ 'follow.back': 'العودة إلى البث المباشر',
208
+ 'follow.waiting': 'لم تبدأ المحاضرة بعد.',
209
+ 'follow.tryHere': 'جرّب المتابعة هنا',
210
+ 'join.static': 'أثناء المحاضرة انضمت الهواتف من هنا للإجابة.',
211
+ 'poll.answeredOf': 'أجاب {n} من {of}',
212
+ 'poll.close': 'إغلاق: لا تُحتسب الإجابات اللاحقة',
213
+ 'poll.open': 'فتح من جديد',
214
+ 'poll.closed': 'مغلق',
215
+ 'poll.several': 'يمكن اختيار أكثر من إجابة.',
216
+ 'poll.pickSeveral': 'اختر كل ما ينطبق.',
217
+ 'poll.thanksSeveral': 'شكرًا! اخترت {choice}. اختر مجددًا للتغيير.',
218
+ 'respond.closed': 'أُغلق: لا مزيد من الإجابات.',
219
+ 'respond.right': 'صحيح!',
220
+ 'respond.wrong': 'ليس تمامًا. الإجابة الصحيحة: {answer}',
221
+ 'respond.solution': 'الإجابة الصحيحة: {answer}',
222
+ 'numeric.placeholder': 'رقم',
223
+ 'numeric.invalid': 'أدخل رقمًا، مثل 0.5 أو 1/2.',
224
+ 'numeric.sent': 'أُرسل: {value}. أرسل رقمًا آخر للتغيير.',
225
+ 'numeric.other': 'أخرى',
226
+ 'numeric.right': '{n} صحيحة',
227
+ 'question.empty': 'تظهر الإجابات هنا.',
228
+ 'scale.average': 'المتوسط {n}',
229
+ },
154
230
  }
155
231
 
156
232
  export const LABEL_KEYS = Object.keys(LABELS.en)
@@ -162,6 +238,8 @@ export const CALLOUT_LABELS = {
162
238
  definition: 'Definition', theorem: 'Theorem', lemma: 'Lemma', corollary: 'Corollary', proof: 'Proof', example: 'Example', remark: 'Remark' },
163
239
  de: { note: 'Hinweis', tip: 'Tipp', important: 'Wichtig', warning: 'Achtung', caution: 'Vorsicht',
164
240
  definition: 'Definition', theorem: 'Satz', lemma: 'Lemma', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel', remark: 'Bemerkung' },
241
+ ar: { note: 'ملاحظة', tip: 'نصيحة', important: 'مهم', warning: 'تحذير', caution: 'انتبه',
242
+ definition: 'تعريف', theorem: 'مبرهنة', lemma: 'تمهيدية', corollary: 'نتيجة', proof: 'برهان', example: 'مثال', remark: 'ملحوظة' },
165
243
  }
166
244
 
167
245
  export const CALLOUT_TYPES = Object.keys(CALLOUT_LABELS.en)
@@ -1,9 +1,9 @@
1
1
  import yaml from 'js-yaml'
2
- import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
2
+ import { sourceLines, fenceState, scanDirectives, diagnostic, isIncludeLine } 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 reactions'.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
@@ -32,7 +32,7 @@ function segmentsOf(source) {
32
32
  const before = fence
33
33
  fence = fenceState(line.text, fence)
34
34
  if (before || fence) continue
35
- if (/^:::\s*[\w-]+/.test(line.text)) depth++
35
+ if (/^:::\s*[\w-]+/.test(line.text) && !isIncludeLine(line.text)) depth++
36
36
  else if (/^:::\s*$/.test(line.text)) depth = Math.max(0, depth - 1)
37
37
  if (!depth && /^---[ \t]*$/.test(line.text)) {
38
38
  segments.push({ start, end: line.start, delimiterEnd: line.end })
@@ -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)
@@ -103,6 +103,15 @@ function extractBody(source, body, diagnostics) {
103
103
  return { content, notes: notes.join('\n\n'), regions, blocks }
104
104
  }
105
105
 
106
+ // What a slide that is nothing but `:::include <file> [slides=3-7]` includes:
107
+ // { file, slides } (slides as written, or null), or null for any other slide.
108
+ export function includeOf(content) {
109
+ const match = /^:::\s*include\s+(?:"([^"]+)"|(\S+))((?:\s+[\w-]+=\S+)*)\s*$/.exec(String(content ?? '').trim())
110
+ if (!match) return null
111
+ const options = Object.fromEntries([...match[3].matchAll(/([\w-]+)=(\S+)/g)].map(([, key, value]) => [key, value]))
112
+ return { file: match[1] ?? match[2], slides: options.slides ?? null, options }
113
+ }
114
+
106
115
  export function parseSlides(source) {
107
116
  const segments = segmentsOf(source)
108
117
  const diagnostics = []
@@ -150,9 +159,10 @@ export function parseSlides(source) {
150
159
  // source editing and validation, separate from the resolved block notes.
151
160
  delete meta.notes
152
161
  if (notes) meta.notes = notes
162
+ const include = Object.keys(authoredMeta).length ? null : includeOf(content)
153
163
  slides.push({ id: meta.id ?? `slide-${slides.length + 1}`, meta, authoredMeta, content, regions, blocks,
154
164
  source: { start: segment.start, end: body?.end ?? segment.end },
155
- metaSource, bodySource: body ? { start: body.start, end: body.end } : null })
165
+ metaSource, bodySource: body ? { start: body.start, end: body.end } : null, ...(include ? { include } : {}) })
156
166
  }
157
167
  let currentSection = null
158
168
  for (const slide of slides) {
@@ -0,0 +1,27 @@
1
+ // Reactions: people tap an emoji on the answer page, and it floats up on the
2
+ // presenter's screen for a moment, never on the projector. A deck turns them
3
+ // on with `reactions: true` (the set below) or its own list of up to eight,
4
+ // such as `reactions: [👍, 🤔, 🐢]`. They go to the session's `reactions`
5
+ // room, which the server passes on without keeping (src/live/rooms.js).
6
+
7
+ export const REACTIONS_ROOM = 'reactions'
8
+ // 🐢: "slower, please".
9
+ export const DEFAULT_REACTIONS = ['👍', '👏', '❤️', '😮', '🤔', '🐢']
10
+ const MAX = 8
11
+ const MAX_LENGTH = 16
12
+
13
+ /** Whether `value` is a usable `reactions` setting; a problem as text, or null. */
14
+ export function reactionsProblem(value) {
15
+ if (value == null || typeof value === 'boolean') return null
16
+ if (!Array.isArray(value) || !value.length || value.length > MAX) return `reactions must be true, false, or a list of 1 to ${MAX} emoji or short words`
17
+ const bad = value.find(item => typeof item !== 'string' && typeof item !== 'number' || !String(item).trim() || String(item).trim().length > MAX_LENGTH)
18
+ return bad !== undefined ? `reactions: each one is an emoji or a word of up to ${MAX_LENGTH} characters, not ${JSON.stringify(bad)}` : null
19
+ }
20
+
21
+ /** The reactions a deck offers, or null when it offers none. */
22
+ export function reactionsOf(config) {
23
+ const value = config?.reactions
24
+ if (value === true) return DEFAULT_REACTIONS
25
+ if (reactionsProblem(value) || !Array.isArray(value)) return null
26
+ return [...new Set(value.map(item => String(item).trim()))]
27
+ }
@@ -0,0 +1,153 @@
1
+ // The answers kept in a talk (<deck>.results.json, results.js) read against
2
+ // the deck they were given in: which slide asked, what, and how it came out.
3
+ // `mdeck results` and the launch page export them from here, as the answers
4
+ // one by one (CSV), one row per option, word or number (summary CSV), or
5
+ // all of it (JSON).
6
+ //
7
+ // The summary counts as the slide does: for a poll, a scale, a number and a
8
+ // word cloud, each phone's latest answer; for open questions, every answer. Phones stay numbered per question; no device ids are kept.
9
+ import { tagsIn } from './tags.js'
10
+ import { expandPollLists, pollChoices } from './pollList.js'
11
+ import { choicesOf } from '../components/inlineText.js'
12
+ import { parseNumber, groupNumbers } from '../components/numbers.js'
13
+
14
+ const KINDS = { poll: 'poll', scale: 'scale', numeric: 'number', wordcloud: 'words', question: 'question' }
15
+
16
+ const plain = text => String(text ?? '').replace(/\$([^$]+)\$/g, '$1').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
17
+
18
+ function headingOf(slide) {
19
+ const heading = String(slide?.content ?? '').replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]
20
+ return heading ? plain(heading) : plain(slide?.meta?.title ?? '')
21
+ }
22
+
23
+ const scaleBounds = ({ min, max }) => {
24
+ const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
25
+ const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
26
+ return high > low && high - low <= 10 ? [low, high] : [1, 5]
27
+ }
28
+
29
+ /**
30
+ * The activities of a parsed deck, in order: [{ room, slide, kind, tag,
31
+ * question, options?, multiple?, min?, max? }]. `slide` counts from 1; a
32
+ * room asked on several slides is listed where it is first asked.
33
+ */
34
+ export function activitiesIn(deck) {
35
+ const found = new Map()
36
+ ;(deck?.slides ?? []).forEach((slide, i) => {
37
+ const texts = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
38
+ for (const text of texts) {
39
+ for (const tag of tagsIn(expandPollLists(text))) {
40
+ const room = tag.attrs.room
41
+ if (tag.closing || !room || found.has(room)) continue
42
+ const kind = KINDS[tag.name] ?? tag.name
43
+ const entry = { room, slide: i + 1, kind, tag: tag.name, question: plain(tag.attrs.question) || headingOf(slide) }
44
+ if (tag.name === 'poll') {
45
+ const listed = tag.attrs['data-choices'] != null && pollChoices(tag.attrs['data-choices'])
46
+ entry.options = listed ? listed.map(choice => choice.text) : choicesOf(tag.attrs.options)
47
+ if (tag.attrs.multiple != null && tag.attrs.multiple !== 'false') entry.multiple = true
48
+ }
49
+ if (tag.name === 'scale') [entry.min, entry.max] = scaleBounds(tag.attrs)
50
+ found.set(room, entry)
51
+ }
52
+ }
53
+ })
54
+ return [...found.values()]
55
+ }
56
+
57
+ // Each phone's last answer, in the order the phones first answered.
58
+ function latestPerPhone(answers) {
59
+ const latest = new Map()
60
+ for (const answer of answers) latest.set(answer.phone, answer)
61
+ return [...latest.values()]
62
+ }
63
+
64
+ const round = value => Math.round(value * 100) / 100
65
+ function stats(values) {
66
+ if (!values.length) return {}
67
+ const sorted = [...values].sort((a, b) => a - b)
68
+ const middle = sorted.length >> 1
69
+ const median = sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
70
+ return { average: round(values.reduce((sum, value) => sum + value, 0) / values.length), median: round(median), lowest: sorted[0], highest: sorted.at(-1) }
71
+ }
72
+
73
+ /** How one activity came out: { phones, answers, counts: [{ answer, count }], average?, median?, lowest?, highest? }. */
74
+ export function summarize(activity, answers = []) {
75
+ const phones = new Set(answers.map(answer => answer.phone)).size
76
+ const base = { phones, answers: answers.length }
77
+ const tag = activity?.tag
78
+ if (tag === 'poll') {
79
+ const options = activity.options ?? []
80
+ const votes = latestPerPhone(answers)
81
+ .map(answer => activity.multiple ? (Array.isArray(answer.value) ? answer.value : [answer.value]) : (Array.isArray(answer.value) ? [] : [answer.value]))
82
+ .map(picks => picks.filter(pick => options.includes(pick)))
83
+ .filter(picks => picks.length)
84
+ return { ...base, voted: votes.length, counts: options.map(option => ({ answer: option, count: votes.filter(picks => picks.includes(option)).length })) }
85
+ }
86
+ if (tag === 'scale') {
87
+ const steps = Array.from({ length: activity.max - activity.min + 1 }, (_, i) => activity.min + i)
88
+ const values = latestPerPhone(answers).map(answer => Number(answer.value)).filter(value => steps.includes(value))
89
+ return { ...base, voted: values.length, counts: steps.map(step => ({ answer: String(step), count: values.filter(value => value === step).length })), ...stats(values) }
90
+ }
91
+ if (tag === 'numeric') {
92
+ const texts = latestPerPhone(answers).map(answer => answer.value).filter(value => parseNumber(value) != null)
93
+ return { ...base, voted: texts.length, counts: groupNumbers(texts).map(group => ({ answer: group.text, count: group.n })), ...stats(texts.map(parseNumber)) }
94
+ }
95
+ // Words (each phone's latest) and open answers (every one), the same words together.
96
+ const counts = new Map()
97
+ for (const answer of tag === 'wordcloud' ? latestPerPhone(answers) : answers) {
98
+ const text = String(answer.value ?? '').trim().replace(/\s+/g, ' ')
99
+ if (!text) continue
100
+ const key = text.toLocaleLowerCase()
101
+ const entry = counts.get(key) ?? { answer: text, count: 0 }
102
+ entry.count++
103
+ counts.set(key, entry)
104
+ }
105
+ return { ...base, counts: [...counts.values()].sort((a, b) => b.count - a.count || a.answer.localeCompare(b.answer)) }
106
+ }
107
+
108
+ /**
109
+ * The kept answers against the deck: [{ room, slide, kind, question,
110
+ * closed, savedAt, summary, answers }], in the order of the slides; rooms
111
+ * the deck no longer asks come last, with what is known of them.
112
+ */
113
+ export function resultsReport(results, deck) {
114
+ const rooms = results?.rooms ?? {}
115
+ const activities = activitiesIn(deck)
116
+ const asked = activities.filter(activity => rooms[activity.room])
117
+ const gone = Object.keys(rooms).filter(room => !activities.some(activity => activity.room === room)).map(room => ({ room, slide: null, kind: null, tag: null, question: '' }))
118
+ return [...asked, ...gone].map(activity => {
119
+ const entry = rooms[activity.room]
120
+ const { tag, options, multiple, min, max, ...shown } = activity
121
+ return { ...shown, closed: entry.closed, savedAt: entry.savedAt, summary: summarize(activity, entry.answers), answers: entry.answers }
122
+ })
123
+ }
124
+
125
+ const csvCell = value => {
126
+ const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
127
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
128
+ }
129
+ const csv = rows => rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
130
+
131
+ /** Every answer with its question: slide, room, kind, question, phone, time, answer (several picks joined by |). */
132
+ export function reportAnswersCsv(report) {
133
+ const rows = [['slide', 'room', 'kind', 'question', 'phone', 'at', 'answer']]
134
+ for (const entry of report) for (const answer of entry.answers) rows.push([entry.slide, entry.room, entry.kind, entry.question, answer.phone, answer.at, answer.value])
135
+ return csv(rows)
136
+ }
137
+
138
+ /** One row per option, step, number or word: slide, room, kind, question, answer, count, and its share of the phones that answered. */
139
+ export function reportSummaryCsv(report) {
140
+ const rows = [['slide', 'room', 'kind', 'question', 'answer', 'count', 'share']]
141
+ for (const entry of report) {
142
+ const { counts = [], voted } = entry.summary
143
+ const total = voted ?? counts.reduce((sum, row) => sum + row.count, 0)
144
+ for (const row of counts) rows.push([entry.slide, entry.room, entry.kind, entry.question, row.answer, row.count, total ? `${Math.round((row.count / total) * 100)}%` : ''])
145
+ for (const key of ['average', 'median']) if (entry.summary[key] != null) rows.push([entry.slide, entry.room, entry.kind, entry.question, key, entry.summary[key], ''])
146
+ }
147
+ return csv(rows)
148
+ }
149
+
150
+ /** All of it, for other programs: the deck's title, when, and each question with its summary and answers. */
151
+ export function reportJson(report, { title = '', exportedAt = new Date().toISOString() } = {}) {
152
+ return JSON.stringify({ version: 1, title, exportedAt, questions: report }, null, 2) + '\n'
153
+ }
@@ -24,6 +24,11 @@ export function diagnostic(source, code, message, offset = 0, severity = 'error'
24
24
  }
25
25
 
26
26
  // Balanced directive scanner, shared by the document parser and Markdown lexer.
27
+ // `:::include ./part.md`, alone on a slide, stands for the slides of another
28
+ // deck file (build/include.js puts them in its place). Without a path, `:::include`
29
+ // opens a block as any other name does (a callout of the deck's own).
30
+ export const isIncludeLine = text => /^:::\s*include\s+\S/.test(text)
31
+
27
32
  export function scanDirectives(source) {
28
33
  const nodes = []
29
34
  const stack = []
@@ -38,7 +43,9 @@ export function scanDirectives(source) {
38
43
  const node = { name: open[1], argument: open[2].trim(), start: line.start, bodyStart: line.end, children: [] }
39
44
  if (stack.length) stack.at(-1).children.push(node)
40
45
  else nodes.push(node)
41
- stack.push(node)
46
+ // :::include is one line: it has no body and no closing fence.
47
+ if (isIncludeLine(line.text)) Object.assign(node, { bodyEnd: line.end, end: line.end })
48
+ else stack.push(node)
42
49
  } else if (/^:::[ \t]*$/.test(line.text)) {
43
50
  const node = stack.pop()
44
51
  if (!node) diagnostics.push(diagnostic(source, 'unexpected-close', 'Directive closing fence has no opening fence', line.start))
@@ -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,13 @@ 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'
9
+ import { reactionsProblem } from './reactions.js'
8
10
 
9
11
  // Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
10
12
  const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
13
+ // Timing (core/timing.js): how long a slide, or the whole talk, may take.
14
+ const TIME_HINT = value => `time: ${JSON.stringify(value)} is not a time; write it as 90s, 2m, 1m30s or 1.5m`
11
15
 
12
16
  // `layouts`, `themes` and `palettes` are manifest maps from the extension
13
17
  // registry (see src/extensions/discover.js). Checks for a kind are skipped when
@@ -52,6 +56,8 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
52
56
  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
57
  }
54
58
  if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
59
+ if (config.time != null && parseTime(config.time) == null) add('invalid-config', TIME_HINT(config.time), deck.configSource?.start)
60
+ if (reactionsProblem(config.reactions)) add('invalid-config', reactionsProblem(config.reactions), deck.configSource?.start)
55
61
  const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
56
62
  const switches = ['citations']
57
63
  if (isPlainObject(config.show)) {
@@ -96,7 +102,9 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
96
102
  if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
97
103
  if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
98
104
  if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
105
+ if (slide.meta.time != null && parseTime(slide.meta.time) == null) add('invalid-metadata', TIME_HINT(slide.meta.time), offset)
99
106
  for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
107
+ if (node.name === 'include' && !slide.include) add('misplaced-include', ':::include stands alone on its slide, with no settings or other content: :::include ./part.md, or :::include ./part.md slides=2-5', slide.source.start + node.start, 'warning')
100
108
  if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
101
109
  const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
102
110
  add('unknown-callout', `Unknown block "${node.name}"${near ? `; did you mean "${near}"?` : ''} It shows as a plain callout. Known: ${CALLOUT_TYPES.join(', ')}; name your own under callouts: in the settings`, slide.source.start + node.start, 'warning')
@@ -129,7 +137,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
129
137
  }
130
138
 
131
139
  // The `:::` blocks mdeck reads itself; any other name is a callout type.
132
- const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns']
140
+ const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns', 'include']
133
141
 
134
142
  function directivesIn(source) {
135
143
  const all = []
@@ -154,6 +162,7 @@ function closest(name, names) {
154
162
  return best && best[1] <= 2 ? best[0] : null
155
163
  }
156
164
 
165
+ // A diagnostic with a `file` of its own (one in an included deck) names it.
157
166
  export function formatDiagnostics(diagnostics, filename = 'slides.md') {
158
- return diagnostics.map(d => `${filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
167
+ return diagnostics.map(d => `${d.file ?? filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
159
168
  }
@@ -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>