mdeck 3.7.0 → 3.8.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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -4
  3. package/assets/base.css +9 -5
  4. package/assets/extensions/themes/academic/styles.css +22 -22
  5. package/assets/extensions/themes/glass/styles.css +17 -17
  6. package/assets/extensions/themes/minimal/styles.css +14 -14
  7. package/assets/extensions/themes/neue/styles.css +14 -14
  8. package/assets/extensions/themes/plain/styles.css +17 -17
  9. package/assets/extensions/themes/work/styles.css +26 -26
  10. package/docs/reference/authoring.md +12 -0
  11. package/docs/site/content/drawing.md +6 -2
  12. package/docs/site/content/media.md +3 -1
  13. package/docs/site/content/presenting.md +47 -0
  14. package/examples/python/slides.de.md +493 -0
  15. package/examples/python/{slides.md → slides.en.md} +1 -1
  16. package/examples/python/slides.fa.md +492 -0
  17. package/examples/python/slides.hi.md +492 -0
  18. package/examples/showcase/slides.md +33 -0
  19. package/package.json +1 -1
  20. package/skills/write-slides/SKILL.md +1 -0
  21. package/src/build/config.js +1 -1
  22. package/src/build/slidesPlugin.js +34 -4
  23. package/src/components/VideoPlayer.jsx +70 -6
  24. package/src/components/callout.css +10 -10
  25. package/src/components/citation.css +2 -2
  26. package/src/components/code-block.css +3 -3
  27. package/src/components/poll.css +6 -6
  28. package/src/components/video-player.css +15 -0
  29. package/src/core/editDeck.js +3 -2
  30. package/src/core/icons.js +1 -0
  31. package/src/core/labels.js +2 -0
  32. package/src/core/languages.js +51 -0
  33. package/src/core/parseSlides.js +15 -3
  34. package/src/core/validateDeck.js +5 -0
  35. package/src/editor/components/DeckSettings.jsx +4 -0
  36. package/src/editor/components/MetaFields.jsx +7 -0
  37. package/src/layouts/layoutApi.jsx +7 -1
  38. package/src/live/answer/answer.css +2 -2
  39. package/src/runtime/chrome.jsx +6 -7
  40. package/src/runtime/deck-stage.js +249 -11
  41. package/src/runtime/hotspots.js +98 -0
  42. package/src/runtime/ink/bus.js +21 -1
  43. package/src/runtime/ink/room.js +20 -8
  44. package/src/runtime/main.jsx +179 -23
  45. package/src/runtime/zoom/camera.js +77 -0
  46. /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { t } from '../core/labels.js'
3
4
  import './video-player.css'
4
5
 
5
6
  function getEmbedUrl(rawUrl, autoplay = false) {
@@ -58,6 +59,66 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
58
59
  return () => stage.removeEventListener('slidechange', onSlideChange)
59
60
  }, [play, src])
60
61
 
62
+ // Local video between the screens of a talk: play, pause or a jump on one
63
+ // (an iPad presenting) does the same on the others (the projector),
64
+ // through the stage event `mediacontrol`, which the ink bus carries
65
+ // (src/runtime/ink/bus.js). A video is known by its slide and its place on
66
+ // it. Only the slide on screen follows, and a phone following the talk
67
+ // does not. The presenter view's own copy goes silent while another
68
+ // screen shows the deck, so the sound comes from there. A screen that may
69
+ // not start sound yet (nobody clicked it) plays silently and offers sound.
70
+ const [offerSound, setOfferSound] = useState(false)
71
+ useEffect(() => {
72
+ const video = videoRef.current
73
+ const stage = containerRef.current?.closest('deck-stage')
74
+ if (!src || !video || !stage || new URLSearchParams(location.search).get('view') === 'follow') return
75
+ const slide = () => containerRef.current?.closest('deck-stage > *')
76
+ const key = () => {
77
+ const own = slide()
78
+ if (!own) return null
79
+ return `${[...stage.children].indexOf(own)}:${[...own.querySelectorAll('.video-player-container')].indexOf(containerRef.current)}`
80
+ }
81
+ // Changes made to follow another screen are not sent back.
82
+ let following = 0
83
+ const tell = action => {
84
+ if (Date.now() < following || !slide()?.hasAttribute('data-deck-active')) return
85
+ if (action === 'play' && stage.hasAttribute('data-others-watching')) video.muted = true
86
+ stage.dispatchEvent(new CustomEvent('mediacontrol', { detail: { video: key(), action, time: video.currentTime, local: true } }))
87
+ }
88
+ const onPlay = () => tell('play')
89
+ const onPause = () => tell('pause')
90
+ const onSeeked = () => tell(video.paused ? 'pause' : 'play')
91
+ const follow = ({ detail }) => {
92
+ if (!detail || detail.local || detail.video !== key() || !slide()?.hasAttribute('data-deck-active')) return
93
+ following = Date.now() + 600
94
+ if (Number.isFinite(detail.time) && Math.abs(video.currentTime - detail.time) > 0.4) video.currentTime = detail.time
95
+ if (detail.action === 'pause') { video.pause(); return }
96
+ // Only a refusal to start sound is retried silently; a pause that came
97
+ // in while it was starting cancels it (AbortError) and stays.
98
+ video.play().catch(error => {
99
+ if (error?.name !== 'NotAllowedError') return
100
+ following = Date.now() + 600
101
+ video.muted = true
102
+ setOfferSound(true)
103
+ return video.play()
104
+ }).catch(() => {})
105
+ }
106
+ video.addEventListener('play', onPlay)
107
+ video.addEventListener('pause', onPause)
108
+ video.addEventListener('seeked', onSeeked)
109
+ stage.addEventListener('mediacontrol', follow)
110
+ return () => {
111
+ video.removeEventListener('play', onPlay)
112
+ video.removeEventListener('pause', onPause)
113
+ video.removeEventListener('seeked', onSeeked)
114
+ stage.removeEventListener('mediacontrol', follow)
115
+ }
116
+ }, [src])
117
+ const turnSoundOn = () => {
118
+ if (videoRef.current) videoRef.current.muted = false
119
+ setOfferSound(false)
120
+ }
121
+
61
122
  // Web video auto mode: clear/restore src on slide change to stop/start playback
62
123
  useEffect(() => {
63
124
  if (play !== 'auto' || !url) return
@@ -112,12 +173,15 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
112
173
  style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
113
174
  : <div class="video-player-placeholder" />
114
175
  ) : (
115
- <video
116
- ref={videoRef}
117
- src={src}
118
- controls
119
- style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', display: 'block' }}
120
- />
176
+ <>
177
+ <video
178
+ ref={videoRef}
179
+ src={src}
180
+ controls
181
+ style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', display: 'block' }}
182
+ />
183
+ {offerSound && <button type="button" class="video-player-sound" onClick={turnSoundOn}>{t('video.sound')}</button>}
184
+ </>
121
185
  )}
122
186
  </div>
123
187
  )
@@ -2,7 +2,7 @@
2
2
  something important are emphasis, in the accent; a warning and a caution
3
3
  call for attention, in the third accent. */
4
4
  .callout {
5
- border-left: 4px solid var(--rule);
5
+ border-inline-start: 4px solid var(--rule);
6
6
  padding: 20px 28px;
7
7
  background: var(--surface, var(--bg));
8
8
  margin: var(--gap-item) 0;
@@ -14,10 +14,10 @@
14
14
  text-transform: uppercase;
15
15
  margin: 0 0 8px 0;
16
16
  }
17
- .callout p { margin: 0; color: var(--ink-soft); padding-left: 28px; }
17
+ .callout p { margin: 0; color: var(--ink-soft); padding-inline-start: 28px; }
18
18
 
19
- .callout:is(.callout-note, .callout-tip, .callout-important) { border-left-color: var(--accent); }
20
- .callout:is(.callout-warning, .callout-caution) { border-left-color: var(--accent-3); }
19
+ .callout:is(.callout-note, .callout-tip, .callout-important) { border-inline-start-color: var(--accent); }
20
+ .callout:is(.callout-warning, .callout-caution) { border-inline-start-color: var(--accent-3); }
21
21
 
22
22
  /* Titles are text: the accent mixed with the text colour, darker on light
23
23
  slides and lighter on dark ones, so they can be read on both. The third
@@ -32,8 +32,8 @@
32
32
  into its text, and a square ends it. Themes with their own callout style
33
33
  colour these blocks their way; the proof is shared, and themes that set
34
34
  it differently (academic, sketch) use the same selectors. */
35
- .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-left-color: var(--accent); }
36
- .callout.callout-definition { border-left-color: var(--accent-2); }
35
+ .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-inline-start-color: var(--accent); }
36
+ .callout.callout-definition { border-inline-start-color: var(--accent-2); }
37
37
  .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) .callout-title { color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
38
38
  .callout.callout-definition .callout-title { color: color-mix(in oklab, var(--accent-2) 75%, var(--ink)); }
39
39
  .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) > :not(.callout-title) { font-style: italic; }
@@ -50,8 +50,8 @@
50
50
  .slide-body .callout.callout-proof::before { display: none; }
51
51
  .slide-body .callout.callout-proof .callout-title {
52
52
  display: inline;
53
- float: left;
54
- margin: 0 0.4em 0 0;
53
+ float: inline-start;
54
+ margin-block: 0; margin-inline: 0 0.4em;
55
55
  padding: 0;
56
56
  background: none;
57
57
  font-size: inherit;
@@ -64,5 +64,5 @@
64
64
  }
65
65
  .slide-body .callout.callout-proof .callout-title::before { content: none; }
66
66
  .slide-body .callout.callout-proof .callout-title::after { content: '.'; }
67
- .slide-body .callout.callout-proof > :not(.callout-title) { padding-left: 0; font-style: normal; }
68
- .slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-left: 0.6em; color: var(--ink); }
67
+ .slide-body .callout.callout-proof > :not(.callout-title) { padding-inline-start: 0; font-style: normal; }
68
+ .slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-inline-start: 0.6em; color: var(--ink); }
@@ -18,8 +18,8 @@
18
18
  .slide-citations .csl-bib-body { display: flex; flex-direction: column; gap: 4px; }
19
19
 
20
20
  /* Entries hang; numeric styles put the number in a margin column. */
21
- .csl-entry { line-height: 1.4; padding-left: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
22
- .csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-left: 0; text-indent: 0; }
21
+ .csl-entry { line-height: 1.4; padding-inline-start: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
22
+ .csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-inline-start: 0; text-indent: 0; }
23
23
  .csl-left-margin { color: var(--muted); font-variant-numeric: tabular-nums; }
24
24
  .csl-entry a { color: inherit; text-decoration: none; }
25
25
 
@@ -25,7 +25,7 @@ code[class*="language-"] { color: var(--token-default); }
25
25
  margin: 40px 0;
26
26
  width: 100%;
27
27
  /* Centred layouts (focus, title) must not centre the lines of code. */
28
- text-align: left;
28
+ text-align: start;
29
29
  }
30
30
 
31
31
  /* ── Action buttons ─────────────────────────────────────────────────────── */
@@ -103,11 +103,11 @@ code[class*="language-"] { color: var(--token-default); }
103
103
  max-height: 10em;
104
104
  overflow-y: auto;
105
105
  border-radius: 0 8px 8px 8px;
106
- border-left: 4px solid var(--rule);
106
+ border-inline-start: 4px solid var(--rule);
107
107
  }
108
108
  .code-output--error {
109
109
  color: #f87171;
110
- border-left-color: #ef4444;
110
+ border-inline-start-color: #ef4444;
111
111
  }
112
112
 
113
113
  /* ── Code taller than its place on the slide (src/runtime/fit.js) ───────── */
@@ -9,14 +9,14 @@
9
9
  .poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
10
10
  .poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
11
11
  /* buttons="letters|numbers": the key phones show, before the option. */
12
- .poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-right: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
12
+ .poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-inline-end: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
13
13
  /* A picture as an option: a thumbnail beside its bar. */
14
14
  .poll-label:has(img) { white-space: normal; }
15
15
  .poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
16
16
  .poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
17
17
  .poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
18
18
  .poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
19
- .poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
19
+ .poll-count { text-align: end; font-variant-numeric: tabular-nums; font-weight: 600; }
20
20
  .poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
21
21
  .poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
22
22
  .poll-dot.is-live { background: #16a34a; opacity: 1; }
@@ -44,16 +44,16 @@ deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-fo
44
44
  .poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
45
45
  /* The join code in the slide's own colours (see QrCode.jsx). */
46
46
  .poll-join { flex: none; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--rule, rgba(0,0,0,.12)); border-radius: 12px; color: var(--ink, #111); text-decoration: none; font-size: var(--fs-small, 24px); }
47
- .poll-join--local { display: block; max-width: 460px; text-align: left; line-height: 1.4; color: var(--ink-soft, var(--ink, #333)); }
47
+ .poll-join--local { display: block; max-width: 460px; text-align: start; line-height: 1.4; color: var(--ink-soft, var(--ink, #333)); }
48
48
  .poll-join--local code { display: inline; font-size: .9em; }
49
49
  .poll-join--local a { color: var(--accent, #0d9488); }
50
50
 
51
51
  /* <question>: the newest answers as cards */
52
52
  .question-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
53
53
  .slide-body .question-cards li::before, .question-cards li::before, .question-cards li::marker { content: none; display: none; }
54
- .slide-body .question-cards li { padding-left: 22px; }
55
- .question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-left: 5px solid var(--accent, #0d9488); font-size: .8em; line-height: 1.35; overflow-wrap: anywhere; animation: poll-in .4s ease both; }
56
- .question-cards li:first-child { border-left-color: var(--accent-2, var(--accent, #0d9488)); }
54
+ .slide-body .question-cards li { padding-inline-start: 22px; }
55
+ .question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-inline-start: 5px solid var(--accent, #0d9488); font-size: .8em; line-height: 1.35; overflow-wrap: anywhere; animation: poll-in .4s ease both; }
56
+ .question-cards li:first-child { border-inline-start-color: var(--accent-2, var(--accent, #0d9488)); }
57
57
  .question-empty { margin: 0; color: var(--muted, #666); font-style: italic; }
58
58
  @keyframes poll-in { from { opacity: 0; transform: translateY(8px); } }
59
59
 
@@ -12,3 +12,18 @@
12
12
  height: 100%;
13
13
  background: #111;
14
14
  }
15
+
16
+ /* A screen that followed another into playing, silently because nobody has
17
+ clicked it yet: one click turns the sound on. */
18
+ .video-player-sound {
19
+ position: absolute;
20
+ top: 12px;
21
+ inset-inline-end: 12px;
22
+ padding: 8px 14px;
23
+ border: 0;
24
+ border-radius: 999px;
25
+ background: rgba(0, 0, 0, .72);
26
+ color: #fff;
27
+ font: 600 15px/1.2 system-ui, sans-serif;
28
+ cursor: pointer;
29
+ }
@@ -135,12 +135,13 @@ export function setSlideMeta(deck, slideId, patch) {
135
135
  if (yamlText) return edit(deck, [{ ...range, text: yamlText, expected: current }])
136
136
  return edit(deck, [{ start: slide.source.start, end: lineEnd(deck.source, range.end), text: '' }])
137
137
  }
138
- // Legacy `---` metadata: keep its form while a slide key leads, otherwise
138
+ // Legacy `---` metadata: keep its form while a slide-only key leads, otherwise
139
139
  // convert it into an explicit :::meta block (consuming the inner `---`).
140
140
  const yamlText = patchYamlMapping(current, patch, { newline: nl, leadKeys: SLIDE_KEYS })
141
141
  const end = slide.bodySource ? slide.bodySource.start : range.end
142
142
  if (!yamlText) return slide.bodySource ? edit(deck, [{ start: range.start, end, text: '' }]) : removeSlide(deck, slideId).source
143
- if (SLIDE_KEYS.has(firstYamlKey(yamlText))) return edit(deck, [{ ...range, text: yamlText, expected: current }])
143
+ const lead = firstYamlKey(yamlText)
144
+ if (SLIDE_KEYS.has(lead) && !DECK_KEYS.has(lead)) return edit(deck, [{ ...range, text: yamlText, expected: current }])
144
145
  return edit(deck, [{ start: range.start, end, text: block(yamlText) }])
145
146
  }
146
147
 
package/src/core/icons.js CHANGED
@@ -59,6 +59,7 @@ export const ICONS = {
59
59
  moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>', // moon
60
60
  '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
61
  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
+ 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
62
63
  pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
63
64
  loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
64
65
  }
@@ -33,6 +33,7 @@ export const LABELS = {
33
33
  'deck.reset': 'Reset',
34
34
  'deck.resetHint': 'Reset to first slide',
35
35
  'poll.scan': 'Scan to vote',
36
+ 'video.sound': 'Click for sound',
36
37
  'poll.answer': '1 answer',
37
38
  'poll.answers': '{n} answers',
38
39
  'poll.reset': 'Reset',
@@ -105,6 +106,7 @@ export const LABELS = {
105
106
  'deck.reset': 'Neustart',
106
107
  'deck.resetHint': 'Zurück zur ersten Folie',
107
108
  'poll.scan': 'Scannen und abstimmen',
109
+ 'video.sound': 'Klicken für Ton',
108
110
  'poll.answer': '1 Antwort',
109
111
  'poll.answers': '{n} Antworten',
110
112
  'poll.reset': 'Zurücksetzen',
@@ -0,0 +1,51 @@
1
+ // A deck in several languages is one file per language, side by side:
2
+ // talk.en.md and talk.de.md. The file name says the language; the part before
3
+ // it names the deck, so files with the same name in other languages belong
4
+ // together. A language is a two-letter code, with a region if needed (de-CH).
5
+
6
+ const LANGUAGE_FILE = /^(.+)\.([a-z]{2}(?:-[A-Za-z0-9]{2,8})?)\.md$/i
7
+
8
+ let names = null
9
+ try { names = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' }) } catch {}
10
+ // `talk.v1.md` or `talk.ok.md` is not a language; Intl knows which codes are.
11
+ const isLanguage = code => {
12
+ try { return !names || names.of(code) !== undefined } catch { return false }
13
+ }
14
+
15
+ /** `{ name, lang }` for `talk.de.md`, or null when the file name names no language. */
16
+ export function languageOfFile(fileName) {
17
+ const match = LANGUAGE_FILE.exec(fileName)
18
+ if (!match || !isLanguage(match[2])) return null
19
+ return { name: match[1], lang: match[2] }
20
+ }
21
+
22
+ /**
23
+ * The languages among `fileNames` (one folder's files) that `fileName` is
24
+ * written in, as `[{ lang, file }]` sorted by language, or an empty list when
25
+ * there is no second language.
26
+ */
27
+ export function languageFiles(fileName, fileNames) {
28
+ const own = languageOfFile(fileName)
29
+ if (!own) return []
30
+ const found = fileNames
31
+ .map(file => ({ file, language: languageOfFile(file) }))
32
+ .filter(({ language }) => language && language.name === own.name)
33
+ .map(({ file, language }) => ({ lang: language.lang, file }))
34
+ .sort((a, b) => a.lang.localeCompare(b.lang))
35
+ return found.length > 1 ? found : []
36
+ }
37
+
38
+ // Scripts written from right to left, by language; Intl knows it where the
39
+ // browser offers getTextInfo (not Firefox).
40
+ const RIGHT_TO_LEFT = new Set(['ar', 'arc', 'ckb', 'dv', 'fa', 'he', 'iw', 'ks', 'ku', 'nqo', 'ps', 'sd', 'syr', 'ug', 'ur', 'yi'])
41
+
42
+ /** 'rtl' for a language written from right to left (fa, ar, he …), else 'ltr'. */
43
+ export function textDirection(lang) {
44
+ if (typeof lang !== 'string' || !lang.trim()) return 'ltr'
45
+ try {
46
+ const locale = new Intl.Locale(lang.trim())
47
+ const info = locale.getTextInfo?.() ?? locale.textInfo
48
+ if (info?.direction) return info.direction
49
+ } catch {}
50
+ return RIGHT_TO_LEFT.has(lang.trim().toLowerCase().split('-')[0]) ? 'rtl' : 'ltr'
51
+ }
@@ -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'.split(' '))
6
- export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.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'.split(' '))
6
+ export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots'.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
@@ -43,6 +43,18 @@ function segmentsOf(source) {
43
43
  return segments.filter(s => source.slice(s.start, s.end).trim())
44
44
  }
45
45
 
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
48
+ // key makes it the first slide's, otherwise it is the deck's.
49
+ function isDeckBlock(text) {
50
+ const keys = sourceLines(text).map(line => line.text.match(/^([\w-]+):/)?.[1]).filter(Boolean)
51
+ const deckOnly = key => ANY_DECK_KEYS.has(key) && !SLIDE_KEYS.has(key)
52
+ if (!keys.length || deckOnly(keys[0])) return !!keys.length
53
+ if (!ANY_DECK_KEYS.has(keys[0])) return false
54
+ if (keys.some(deckOnly)) return true
55
+ return !keys.some(key => SLIDE_KEYS.has(key) && !ANY_DECK_KEYS.has(key))
56
+ }
57
+
46
58
  export function looksLikeMeta(text, keys, { frontmatter = false } = {}) {
47
59
  const first = sourceLines(text).find(line => line.text.trim() && !/^\s*#/.test(line.text))
48
60
  const key = first?.text.match(/^([\w-]+):/)
@@ -109,7 +121,7 @@ export function parseSlides(source) {
109
121
  let deckConfig = {}
110
122
  let configSource = null
111
123
  let i = 0
112
- if (segments[0] && /^\s*---[ \t]*\r?\n/.test(source) && looksLikeMeta(text(segments[0]), ANY_DECK_KEYS, { frontmatter: true }) && !looksLikeMeta(text(segments[0]), SLIDE_KEYS)) {
124
+ if (segments[0] && /^\s*---[ \t]*\r?\n/.test(source) && looksLikeMeta(text(segments[0]), ANY_DECK_KEYS, { frontmatter: true }) && isDeckBlock(text(segments[0]))) {
113
125
  deckConfig = readMeta(segments[0])
114
126
  configSource = { start: segments[0].start, end: segments[0].end }
115
127
  i++
@@ -5,6 +5,9 @@ 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
 
8
+ // Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
9
+ const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
10
+
8
11
  // `layouts`, `themes` and `palettes` are manifest maps from the extension
9
12
  // registry (see src/extensions/discover.js). Checks for a kind are skipped when
10
13
  // its map is not supplied.
@@ -47,6 +50,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
47
50
  for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
48
51
  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)
49
52
  }
53
+ if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
50
54
  const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
51
55
  const switches = ['citations']
52
56
  if (isPlainObject(config.show)) {
@@ -84,6 +88,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
84
88
  }
85
89
  if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
86
90
  if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
91
+ if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
87
92
  for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
88
93
  if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
89
94
  const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
@@ -9,6 +9,7 @@ import { Diagnostics } from './Diagnostics.jsx'
9
9
  import { PalettePicker } from './PalettePicker.jsx'
10
10
  import { LookThumbnail } from './LookThumbnail.jsx'
11
11
  import { designUrl, DESIGN_TAB } from '../designLink.js'
12
+ import { normalMode } from '../../runtime/hotspots.js'
12
13
 
13
14
  // What the `show` setting controls: the label for each choice and its values.
14
15
  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']] }
@@ -93,6 +94,9 @@ export function DeckSettings({ state, edit }) {
93
94
  {Object.entries(SHOW).map(([key, [label, options]]) => <Field key={key} label={label}>
94
95
  <select value={config.show?.[key] ?? ''} onChange={e => show(key, e.currentTarget.value)}><option value="">default</option>{options.map(o => <option key={o} value={o}>{o}</option>)}</select>
95
96
  </Field>)}
97
+ <Field label="Hotspots" hint="1–9 zoom in on a slide's pictures, code, tables and other parts while presenting; marked: only parts marked data-hotspot">
98
+ <select value={[null, 'off'].includes(normalMode(config.hotspots)) ? '' : normalMode(config.hotspots)} onChange={e => text('hotspots', e.currentTarget.value)}><option value="">off</option>{['auto', 'marked'].map(o => <option key={o} value={o}>{o}</option>)}</select>
99
+ </Field>
96
100
  <Field label="Language" hint="of the deck, e.g. en, de or de-CH"><input type="text" value={config.lang ?? ''} placeholder="en" onInput={e => text('lang', e.currentTarget.value)} /></Field>
97
101
  </div>
98
102
  </div>
@@ -1,6 +1,7 @@
1
1
  import { h } from 'preact'
2
2
  import { setSlideMeta, setSlideNotes } from '../../core/editDeck.js'
3
3
  import { Field, TextArea } from './Field.jsx'
4
+ import { normalMode } from '../../runtime/hotspots.js'
4
5
 
5
6
  const MORE = [['section', 'Section label shown in the header'], ['number', 'Chapter number'], ['part', 'Part name for chapter slides'], ['label', 'Replaces the word "Chapter"'], ['description', 'Chapter description'], ['eyebrow', 'Small line above a heading'], ['attribution', 'Source of a quote']]
6
7
 
@@ -13,6 +14,12 @@ export function MetaFields({ slide, slideEdit }) {
13
14
  }} />
14
15
  return <>
15
16
  <Field label="Slide id" hint="stable anchor and link target">{text('id')}</Field>
17
+ <Field label="Hotspots" hint="number keys zoom in on this slide's parts while presenting; default: the deck's setting">
18
+ <select value={normalMode(meta.hotspots) ?? ''} onChange={event => {
19
+ const value = event.currentTarget.value
20
+ slideEdit((deck, id) => setSlideMeta(deck, id, { hotspots: value === '' ? undefined : value === 'off' ? false : value }), { group: 'meta:hotspots' })
21
+ }}><option value="">the deck's</option>{['auto', 'marked', 'off'].map(o => <option key={o} value={o}>{o}</option>)}</select>
22
+ </Field>
16
23
  <Field label="Speaker notes"><TextArea rows={4} value={slide.meta.notes ?? ''} onInput={value => slideEdit((deck, id) => setSlideNotes(deck, id, value), { group: 'notes' })} /></Field>
17
24
  <details class="more" open={MORE.some(([key]) => meta[key] != null)}>
18
25
  <summary>More metadata</summary>
@@ -5,6 +5,7 @@ import { marked, Parser } from 'marked'
5
5
  import { registry } from '../runtime/registry'
6
6
  import { scanTags, closeTags } from '../core/tags.js'
7
7
  import { expandPollLists } from '../core/pollList.js'
8
+ import { textDirection } from '../core/languages.js'
8
9
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
9
10
  import themedSvgs from 'virtual:deck-svgs'
10
11
  import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
@@ -222,7 +223,12 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
222
223
  const classes = ['slide', layout && 'slide--' + String(layout).replace(/[^a-zA-Z0-9_-]/g, '-'),
223
224
  image && ['title', 'chapter'].includes(layout) && 'has-image',
224
225
  layout === 'full-bleed-image' && props.overlay && 'has-overlay'].filter(Boolean).join(' ')
225
- return <section class={classes} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`}>
226
+ // A deck in Farsi, Arabic or Hebrew runs from right to left; mdeck's own
227
+ // controls around the slides keep their direction.
228
+ const dir = textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined
229
+ // Its own hotspots setting, if any (runtime/hotspots.js).
230
+ const hotspots = meta.hotspots == null ? undefined : String(meta.hotspots)
231
+ return <section class={classes} dir={dir} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`} data-hotspots={hotspots}>
226
232
  {frame !== 'none' && <SlideHeader deckConfig={deckConfig} logo={title ? deckConfig.meta?.logo : undefined}
227
233
  isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
228
234
  {children}
@@ -21,7 +21,7 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
21
21
  .answer-scale { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); }
22
22
  .answer button {
23
23
  padding: 18px 20px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink);
24
- font: inherit; font-size: 20px; font-weight: 600; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
24
+ font: inherit; font-size: 20px; font-weight: 600; text-align: start; cursor: pointer; -webkit-tap-highlight-color: transparent;
25
25
  }
26
26
  .answer-scale button { padding: 16px 0; text-align: center; }
27
27
  .answer button:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--rule)); }
@@ -39,7 +39,7 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
39
39
  .answer-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 12px; }
40
40
  .answer .answer-keys button { padding: 22px 0; text-align: center; font-size: 28px; }
41
41
  /* `multiple`: a box that is ticked when picked. */
42
- .answer-check { display: inline-block; width: 1em; height: 1em; margin-right: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
42
+ .answer-check { display: inline-block; width: 1em; height: 1em; margin-inline-end: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
43
43
  .answer button.is-picked .answer-check { opacity: 1; background: currentColor; box-shadow: inset 0 0 0 2px var(--accent); }
44
44
  /* Closed: no more answers. */
45
45
  .answer button:disabled, .answer textarea:disabled, .answer input:disabled { opacity: .45; cursor: default; }
@@ -42,14 +42,13 @@ export const S = {
42
42
  },
43
43
  }
44
44
 
45
+ // A palette as the theme gallery shows it (mdeck-themes): one chip of its
46
+ // background, accent and second accent.
45
47
  export function PaletteSwatches({ tokens }) {
46
- const keys = ['--bg', '--surface', '--rule', '--accent', '--accent-2', '--accent-3', '--ink']
48
+ const stripe = key => <i style={{ display: 'block', background: tokens[key] ?? tokens['--accent'] ?? 'transparent' }} />
47
49
  return (
48
- <div style={{ display: 'flex', gap: '2px', alignItems: 'center', flexShrink: 0 }}>
49
- {keys.map(k => tokens[k]
50
- ? <div key={k} style={{ width: '10px', height: '10px', borderRadius: '2px', background: tokens[k], border: '1px solid rgba(255,255,255,0.08)', flexShrink: 0 }} />
51
- : null
52
- )}
53
- </div>
50
+ <span aria-hidden="true" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', width: '30px', height: '18px', borderRadius: '999px', overflow: 'hidden', border: '1px solid rgba(127,127,127,0.35)', flexShrink: 0 }}>
51
+ {stripe('--bg')}{stripe('--accent')}{stripe('--accent-2')}
52
+ </span>
54
53
  )
55
54
  }