mdeck 2.3.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 (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,134 @@
1
+ import { h, Fragment } from 'preact'
2
+ import { Icon } from '../../components/Icon.jsx'
3
+ import { CORE_TOKENS } from '../extensions.js'
4
+ import { Field, TextArea } from './Field.jsx'
5
+ import { PalettePicker, Swatches } from './PalettePicker.jsx'
6
+
7
+ const isColor = value => /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim())
8
+ const update = (list, index, patch) => list.map((item, i) => i === index ? { ...item, ...patch } : item)
9
+ const without = (list, index) => list.filter((_, i) => i !== index)
10
+
11
+ function Identity({ model, onChange }) {
12
+ return <>
13
+ <div class="grid-2">
14
+ <Field label="Title"><input type="text" value={model.title} onInput={e => onChange({ title: e.currentTarget.value })} /></Field>
15
+ <Field label="Id" hint="folder name"><input type="text" value={model.id} disabled /></Field>
16
+ </div>
17
+ <Field label="Description"><input type="text" value={model.description ?? ''} onInput={e => onChange({ description: e.currentTarget.value })} /></Field>
18
+ </>
19
+ }
20
+
21
+ export function TokenList({ tokens, onChange, suggestions = CORE_TOKENS, title = 'Tokens' }) {
22
+ const missing = suggestions.filter(name => !tokens.some(token => token.name === name))
23
+ return <>
24
+ <p class="section-title">{title}</p>
25
+ {tokens.map((token, index) => <div class="field" key={index}>
26
+ <div class="row">
27
+ <input type="text" value={token.name} placeholder="--name" style={{ flex: '0 0 140px', fontFamily: 'ui-monospace, monospace' }} onInput={e => onChange(update(tokens, index, { name: e.currentTarget.value }))} />
28
+ {isColor(token.value) && <input type="color" value={token.value.length === 4 ? '#' + [...token.value.slice(1)].map(c => c + c).join('') : token.value.slice(0, 7)} style={{ flex: '0 0 auto' }} onInput={e => onChange(update(tokens, index, { value: e.currentTarget.value }))} />}
29
+ <input type="text" value={token.value ?? ''} style={{ fontFamily: 'ui-monospace, monospace' }} onInput={e => onChange(update(tokens, index, { value: e.currentTarget.value }))} />
30
+ <button class="btn is-small is-icon" title="Remove token" aria-label="Remove token" onClick={() => onChange(without(tokens, index))}><Icon name="close" size={14} /></button>
31
+ </div>
32
+ </div>)}
33
+ <div class="row" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
34
+ {missing.map(name => <button key={name} class="btn is-small" onClick={() => onChange([...tokens, { name, value: '#888888' }])}>+ {name}</button>)}
35
+ <button class="btn is-small" onClick={() => onChange([...tokens, { name: '', value: '' }])}>+ other</button>
36
+ </div>
37
+ </>
38
+ }
39
+
40
+ // A palette has every colour twice: light (for bright rooms) and dark. Themes
41
+ // use the other variant for inverted slides.
42
+ export function PaletteForm({ model, onChange }) {
43
+ return <>
44
+ <Identity model={model} onChange={onChange} />
45
+ <Field label="Only for the theme" hint="leave empty to offer it to every theme"><input type="text" value={model.theme ?? ''} placeholder="theme id" onInput={e => onChange({ theme: e.currentTarget.value.trim() })} /></Field>
46
+ <TokenList title="Light colours" tokens={model.light} onChange={light => onChange({ light })} />
47
+ <TokenList title="Dark colours" tokens={model.dark} onChange={dark => onChange({ dark })} />
48
+ </>
49
+ }
50
+
51
+ // Theme tokens in groups, so fonts, sizes and spacing are found where one
52
+ // looks for them. Each group suggests the tokens themes usually set.
53
+ const TOKEN_GROUPS = [
54
+ ['Fonts', name => name.startsWith('--font-'), ['--font-display', '--font-body', '--font-mono']],
55
+ ['Sizes', name => name.startsWith('--fs-'), ['--fs-display', '--fs-title', '--fs-h', '--fs-body', '--fs-small']],
56
+ ['Spacing', name => name.startsWith('--pad-'), ['--pad-x', '--pad-y']],
57
+ ]
58
+ const groupOf = name => TOKEN_GROUPS.findIndex(([, test]) => test(name))
59
+
60
+ function TokenRow({ token, onChange, onRemove }) {
61
+ return <div class="field">
62
+ <div class="row">
63
+ <input type="text" value={token.name} placeholder="--name" style={{ flex: '0 0 140px', fontFamily: 'ui-monospace, monospace' }} onInput={e => onChange({ name: e.currentTarget.value })} />
64
+ {isColor(token.value) && <input type="color" value={token.value.length === 4 ? '#' + [...token.value.slice(1)].map(c => c + c).join('') : token.value.slice(0, 7)} style={{ flex: '0 0 auto' }} onInput={e => onChange({ value: e.currentTarget.value })} />}
65
+ <input type="text" value={token.value ?? ''} style={{ fontFamily: 'ui-monospace, monospace' }} onInput={e => onChange({ value: e.currentTarget.value })} />
66
+ <button class="btn is-small is-icon" title="Remove token" aria-label="Remove token" onClick={onRemove}><Icon name="close" size={14} /></button>
67
+ </div>
68
+ </div>
69
+ }
70
+
71
+ function ThemeTokens({ tokens, onChange, children }) {
72
+ const set = (index, patch) => onChange(update(tokens, index, patch))
73
+ const rows = test => tokens.map((token, index) => [token, index]).filter(([token]) => test(token))
74
+ .map(([token, index]) => <TokenRow key={index} token={token} onChange={patch => set(index, patch)} onRemove={() => onChange(without(tokens, index))} />)
75
+ const add = name => onChange([...tokens, { name, value: '' }])
76
+ return <>
77
+ {TOKEN_GROUPS.map(([title, , suggested], group) => <Fragment key={title}>
78
+ <p class="section-title">{title}</p>
79
+ {group === 0 && children}
80
+ {rows(token => groupOf(token.name) === group)}
81
+ <div class="row" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
82
+ {suggested.filter(name => !tokens.some(token => token.name === name)).map(name => <button key={name} class="btn is-small" onClick={() => add(name)}>+ {name}</button>)}
83
+ </div>
84
+ </Fragment>)}
85
+ <p class="section-title">Other tokens</p>
86
+ {rows(token => groupOf(token.name) < 0)}
87
+ <div><button class="btn is-small" onClick={() => add('')}>+ token</button></div>
88
+ </>
89
+ }
90
+
91
+ export function ThemeForm({ model, files, palettes = {}, onChange, onFile }) {
92
+ const tokenNames = model.tokens.map(token => token.name).filter(Boolean)
93
+ // Palettes this theme may offer: shared ones and its own private ones.
94
+ const usable = Object.values(palettes).filter(palette => !palette.theme || palette.theme === model.id)
95
+ const offered = model.palettes?.length ? usable.filter(palette => model.palettes.includes(palette.id)) : usable
96
+ const appearance = model.appearance ?? 'light'
97
+ const toggle = (id, on) => onChange({ palettes: on ? [...(model.palettes ?? []), id] : (model.palettes ?? []).filter(other => other !== id) })
98
+ return <>
99
+ <Identity model={model} onChange={onChange} />
100
+ <p class="section-title">Colours</p>
101
+ <p class="hint-text">A theme takes all its colours from palettes; the deck can choose another one it offers.</p>
102
+ <div class="grid-2">
103
+ <Field label="Default palette">
104
+ <PalettePicker offered={offered} selected={palettes[model.palette] ?? null} appearance={appearance} onChoose={id => onChange({ palette: id })} />
105
+ </Field>
106
+ <Field label="Starts"><select value={appearance} onChange={e => onChange({ appearance: e.currentTarget.value })}><option value="light">Light</option><option value="dark">Dark</option></select></Field>
107
+ </div>
108
+ <Field label="Offers these palettes" hint="none ticked offers every palette">
109
+ <div class="palette-checks">
110
+ {usable.map(palette => <label key={palette.id} class="palette-check">
111
+ <input type="checkbox" checked={(model.palettes ?? []).includes(palette.id)} onChange={e => toggle(palette.id, e.currentTarget.checked)} />
112
+ <span class="palette-name">{palette.title}</span><Swatches tokens={palette[appearance] ?? {}} />
113
+ </label>)}
114
+ </div>
115
+ </Field>
116
+ <ThemeTokens tokens={model.tokens} onChange={tokens => onChange({ tokens })}>
117
+ <Field label="Font stylesheets" hint="one URL per line, e.g. from Google Fonts"><TextArea rows={2} value={model.fonts.join('\n')} onInput={text => onChange({ fonts: text.split(/\r?\n/).map(line => line.trim()).filter(Boolean) })} /></Field>
118
+ </ThemeTokens>
119
+ <p class="section-title">Author settings (params)</p>
120
+ {model.params.map((param, index) => <div class="field" key={index}>
121
+ <div class="row">
122
+ <input type="text" value={param.name} placeholder="fontBody" onInput={e => onChange({ params: update(model.params, index, { name: e.currentTarget.value }) })} />
123
+ <select value={param.token ?? ''} onChange={e => onChange({ params: update(model.params, index, { token: e.currentTarget.value }) })}><option value="">token…</option>{tokenNames.map(name => <option key={name} value={name}>{name}</option>)}</select>
124
+ <input type="text" value={param.title ?? ''} placeholder="Title" onInput={e => onChange({ params: update(model.params, index, { title: e.currentTarget.value }) })} />
125
+ <button class="btn is-small is-icon" title="Remove setting" aria-label="Remove setting" onClick={() => onChange({ params: without(model.params, index) })}><Icon name="close" size={14} /></button>
126
+ </div>
127
+ </div>)}
128
+ <div><button class="btn is-small" onClick={() => onChange({ params: [...model.params, { name: '', token: tokenNames[0] ?? '', title: '' }] })}>+ setting</button></div>
129
+ <details class="advanced">
130
+ <summary>Advanced: styles.css</summary>
131
+ <Field label="styles.css" hint="rules only; token defaults come from the lists above"><TextArea tall rows={20} value={files['styles.css'] ?? ''} onInput={text => onFile('styles.css', text)} /></Field>
132
+ </details>
133
+ </>
134
+ }
@@ -0,0 +1,114 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import { ID_RE } from '../extensions.js'
4
+ import { Icon } from '../../components/Icon.jsx'
5
+ import { LookThumbnail } from './LookThumbnail.jsx'
6
+ import { loadThemeIndex } from '../api.js'
7
+
8
+ const KINDS = [['theme', 'Themes'], ['palette', 'Palettes']]
9
+
10
+ // The deck's own themes or palettes, to change; those installed for every
11
+ // deck; packs from the theme repository; and the built-in ones to start
12
+ // from. A new theme always starts as a copy; a palette can also start from
13
+ // readable plain colours. The pictures show the sample deck as `view` says:
14
+ // palettes in the preview's theme where it offers them, themes with its
15
+ // palette where they offer it, both in its light or dark.
16
+ export function ExtensionsPanel({ registry, view = {}, owner = "This deck's", selected, onSelect, onCreate, onInstall }) {
17
+ const [kind, setKind] = useState(selected?.kind ?? 'theme')
18
+ useEffect(() => { if (selected?.kind) setKind(selected.kind) }, [selected?.kind])
19
+ const items = registry?.[`${kind}s`] ?? []
20
+ const own = items.filter(item => item.source === 'local')
21
+ const installed = items.filter(item => item.source === 'user')
22
+ const builtIn = items.filter(item => item.source === 'built-in')
23
+ const name = `${kind}s`
24
+ const row = item => <li key={item.id} class={`outline-item has-thumb${selected?.kind === kind && selected?.id === item.id ? ' is-selected' : ''}`}
25
+ onClick={() => onSelect(kind, item.id)} style={{ gridTemplateColumns: 'minmax(0, 1fr) auto' }}>
26
+ {kind === 'theme' && <LookThumbnail sample theme={item.id} palette={view.paletteFor?.(item.id) ?? ''} appearance={view.appearance} />}
27
+ {kind === 'palette' && <LookThumbnail sample theme={view.themeFor?.(item.id) ?? ''} palette={item.id} appearance={view.appearance} />}
28
+ <span><span class="title" style={{ display: 'block' }}>{item.title}</span><span class="layout">{item.id}</span></span>
29
+ </li>
30
+ return <aside class="editor-panel">
31
+ <div class="editor-panel-head extensions-tabs">
32
+ <div class="tabs">{KINDS.map(([id, label]) => <button key={id} class={kind === id ? 'is-active' : ''} onClick={() => setKind(id)}>{label}</button>)}</div>
33
+ </div>
34
+ <div class="panel-section-head">
35
+ <p class="section-title">{owner} {name}</p>
36
+ {kind === 'palette' && <button class="btn is-small is-icon" title="New palette" aria-label="New palette" onClick={() => onCreate(kind)}><Icon name="add" size={14} /></button>}
37
+ </div>
38
+ {own.length
39
+ ? <ol class="outline-list">{own.map(row)}</ol>
40
+ : <p class="panel-hint">None yet. Start from a built-in one below{kind === 'palette' ? ', or with + from plain colours' : ''}.</p>}
41
+ {installed.length > 0 && <>
42
+ <div class="panel-section-head"><p class="section-title">Installed for every deck</p></div>
43
+ <ol class="outline-list">{installed.map(row)}</ol>
44
+ </>}
45
+ <OnlinePacks key="online" kind={kind} owner={owner} onInstall={onInstall} />
46
+ <div class="panel-section-head"><p class="section-title">Built in, to start from</p></div>
47
+ <ol class="outline-list">{builtIn.map(row)}</ol>
48
+ </aside>
49
+ }
50
+
51
+ // Packs from the theme repository with this kind of extension, loaded on
52
+ // request since it needs the network. Installing puts a pack beside the deck.
53
+ function OnlinePacks({ kind, owner, onInstall }) {
54
+ const [state, setState] = useState({ status: 'idle' })
55
+ const [busy, setBusy] = useState(null)
56
+ const load = () => {
57
+ setState({ status: 'loading' })
58
+ loadThemeIndex().then(index => setState({ status: 'ready', index }), error => setState({ status: 'error', error: error.message }))
59
+ }
60
+ const install = async pack => {
61
+ setBusy(pack.id)
62
+ try {
63
+ await onInstall(pack, kind)
64
+ setState(prev => ({ ...prev, index: { ...prev.index, installed: { ...prev.index.installed, [pack.id]: { version: pack.version } } } }))
65
+ } catch (error) { setState(prev => ({ ...prev, error: error.message })) }
66
+ setBusy(null)
67
+ }
68
+ const head = <div class="panel-section-head"><p class="section-title">From the theme repository</p></div>
69
+ if (state.status !== 'ready') return <>{head}<div class="panel-hint">
70
+ {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what others made</button>}
71
+ {state.error && <p class="online-error">{state.error}</p>}
72
+ </div></>
73
+ const { index } = state
74
+ const packs = index.packs.filter(pack => pack[`${kind}s`].length)
75
+ return <>{head}
76
+ {state.error && <p class="panel-hint online-error">{state.error}</p>}
77
+ {!packs.length && <p class="panel-hint">No {kind}s there yet.</p>}
78
+ <ol class="outline-list">{packs.map(pack => {
79
+ const id = pack[`${kind}s`][0]
80
+ const have = index.installed?.[pack.id]
81
+ return <li key={pack.id} class="outline-item has-thumb online-pack">
82
+ {pack.previews?.[id] && <div class="thumb"><img src={new URL(pack.previews[id], index.url).href} alt="" loading="lazy" /></div>}
83
+ <span><span class="title" style={{ display: 'block' }}>{pack.title} <span class="muted">{pack.version}</span></span>
84
+ <span class="online-meta">{[...pack.themes, ...pack.palettes].join(', ')} · {pack.author}</span></span>
85
+ {have
86
+ ? <span class="muted">{`Installed${have.version !== pack.version ? ` (${have.version})` : ''} in ${owner === "This folder's" ? 'this folder' : 'this deck'}`}</span>
87
+ : <button class="btn is-small" disabled={busy === pack.id} onClick={() => install(pack)}>{busy === pack.id ? 'Installing…' : 'Install'}</button>}
88
+ </li>
89
+ })}</ol>
90
+ </>
91
+ }
92
+
93
+ export function NewExtensionDialog({ kind, registry, existing, onCreate, onClose, initial = null }) {
94
+ const [id, setId] = useState(initial?.id ?? '')
95
+ const [title, setTitle] = useState(initial?.title ?? '')
96
+ const [from, setFrom] = useState(initial?.from ?? '')
97
+ const taken = existing.has(id)
98
+ const valid = ID_RE.test(id) && !taken && title.trim() && (from || kind !== 'theme')
99
+ const options = registry?.[`${kind}s`] ?? []
100
+ return <div class="modal-scrim" onClick={onClose}>
101
+ <div class="modal" onClick={event => event.stopPropagation()}>
102
+ <h2>New {kind}</h2>
103
+ <div class="form" style={{ padding: 0 }}>
104
+ <div class="field"><label>Title</label><input type="text" value={title} autofocus onInput={e => { setTitle(e.currentTarget.value); if (!initial?.id) setId(e.currentTarget.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '')) }} /></div>
105
+ <div class="field"><label>Id <span class="hint">folder name in extensions/</span></label><input type="text" value={id} onInput={e => setId(e.currentTarget.value)} />{id && !ID_RE.test(id) && <span class="error">Use lowercase letters, digits and hyphens, starting with a letter</span>}{taken && <span class="error">That id is already in use</span>}</div>
106
+ <div class="field"><label>Start from</label><select value={from} onChange={e => setFrom(e.currentTarget.value)}>{kind !== 'theme' && <option value="">Plain colours</option>}{options.map(item => <option key={item.id} value={item.id}>{item.title}</option>)}</select></div>
107
+ <div class="row" style={{ display: 'flex', gap: '8px' }}>
108
+ <button class="btn is-primary" disabled={!valid} onClick={() => onCreate({ kind, id, title: title.trim(), from: from || null })}>Create</button>
109
+ <button class="btn" onClick={onClose}>Cancel</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ }
@@ -0,0 +1,24 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+
4
+ const normalize = text => String(text ?? '').replace(/\r?\n/g, '\n').trim()
5
+
6
+ // A textarea whose value derives from the deck source. It keeps a local
7
+ // draft so trimming and newline normalization never move the caret, and only
8
+ // resyncs when the model really differs from what is being typed.
9
+ export function TextArea({ value, onInput, rows = 4, tall = false, ...rest }) {
10
+ const [draft, setDraft] = useState(value ?? '')
11
+ useEffect(() => { if (normalize(value) !== normalize(draft)) setDraft(value ?? '') }, [value])
12
+ // Grow with the text so nothing has to be scrolled inside a small box.
13
+ const lines = draft.split('\n').length + 1
14
+ return <textarea rows={tall ? rows : Math.min(40, Math.max(rows, lines))} class={tall ? 'is-tall' : ''} value={draft} spellcheck={false}
15
+ onInput={event => { setDraft(event.currentTarget.value); onInput(event.currentTarget.value) }} {...rest} />
16
+ }
17
+
18
+ export function Field({ label, required, hint, error, children }) {
19
+ return <div class="field">
20
+ {label && <label>{label}{required && <span class="req">required</span>}{hint && <span class="hint">{hint}</span>}</label>}
21
+ {children}
22
+ {error && <span class="error">{error}</span>}
23
+ </div>
24
+ }
@@ -0,0 +1,36 @@
1
+ import { h } from 'preact'
2
+ import { slideDiagnostics } from '../model.js'
3
+ import { LayoutSelect } from './LayoutSelect.jsx'
4
+ import { RegionEditor } from './RegionEditor.jsx'
5
+ import { PropsForm } from './PropsForm.jsx'
6
+ import { MetaFields } from './MetaFields.jsx'
7
+ import { RawSlideEditor } from './RawSlideEditor.jsx'
8
+ import { Diagnostics } from './Diagnostics.jsx'
9
+
10
+ export function Inspector({ state, dispatch, edit }) {
11
+ const { deck, manifests, selectedIndex, inspectorMode, diagnostics } = state
12
+ const slide = deck.slides[selectedIndex]
13
+ if (!slide) return <div class="form"><p class="empty">Select a slide to edit it.</p></div>
14
+ // Helpers receive the slide as it exists when the edit is applied, so ids
15
+ // shifted by earlier edits never go stale.
16
+ const slideEdit = (fn, options = {}) => edit(d => { const current = d.slides[selectedIndex]; return current ? fn(d, current.id, current) : d.source }, { ...options, group: options.group ? `${options.group}:${selectedIndex}` : undefined })
17
+ const manifest = manifests.layouts[slide.meta.layout ?? 'generic']
18
+ const problems = slideDiagnostics(diagnostics, slide)
19
+ return <>
20
+ <div class="tabs">
21
+ <button class={inspectorMode === 'form' ? 'is-active' : ''} onClick={() => dispatch({ type: 'setInspectorMode', mode: 'form' })}>Fields</button>
22
+ <button class={inspectorMode === 'raw' ? 'is-active' : ''} onClick={() => dispatch({ type: 'setInspectorMode', mode: 'raw' })}>Markdown</button>
23
+ </div>
24
+ <div class="form">
25
+ <Diagnostics items={problems} />
26
+ {inspectorMode === 'raw'
27
+ ? <RawSlideEditor deck={deck} slide={slide} slideEdit={slideEdit} />
28
+ : <>
29
+ <LayoutSelect slide={slide} manifests={manifests} slideEdit={slideEdit} />
30
+ <RegionEditor slide={slide} manifest={manifest} slideEdit={slideEdit} />
31
+ <PropsForm slide={slide} manifest={manifest} slideEdit={slideEdit} />
32
+ <MetaFields slide={slide} slideEdit={slideEdit} />
33
+ </>}
34
+ </div>
35
+ </>
36
+ }
@@ -0,0 +1,23 @@
1
+ import { h } from 'preact'
2
+ import { useEffect } from 'preact/hooks'
3
+
4
+ export function LayoutPicker({ layouts, onPick, onClose }) {
5
+ useEffect(() => {
6
+ const onKey = event => { if (event.key === 'Escape') onClose() }
7
+ window.addEventListener('keydown', onKey)
8
+ return () => window.removeEventListener('keydown', onKey)
9
+ }, [])
10
+ return <div class="modal-scrim" onClick={onClose}>
11
+ <div class="modal" onClick={event => event.stopPropagation()}>
12
+ <h2>Add a slide</h2>
13
+ <ul class="picker">
14
+ {Object.values(layouts).map(item => <li key={item.id}>
15
+ <button class="btn" onClick={() => onPick(item)}>
16
+ <strong>{item.title}</strong>
17
+ {item.description && <small>{item.description}</small>}
18
+ </button>
19
+ </li>)}
20
+ </ul>
21
+ </div>
22
+ </div>
23
+ }
@@ -0,0 +1,25 @@
1
+ import { h } from 'preact'
2
+ import { parseSlides } from '../../core/parseSlides.js'
3
+ import { setSlideMeta, setRegion } from '../../core/editDeck.js'
4
+ import { Field } from './Field.jsx'
5
+
6
+ export function LayoutSelect({ slide, manifests, slideEdit }) {
7
+ const current = slide.meta.layout ?? 'generic'
8
+ const manifest = manifests.layouts[current]
9
+ const missing = Object.entries(manifest?.regions ?? {}).filter(([name, region]) => region.required && !slide.regions[name]?.content.trim()).map(([name]) => name)
10
+ const known = Object.hasOwn(manifests.layouts, current)
11
+ const addMissing = () => slideEdit((deck, id) => {
12
+ let source = deck.source
13
+ for (const name of missing) source = setRegion(parseSlides(source), id, name, '')
14
+ return source
15
+ })
16
+ return <Field label="Layout" hint={manifest?.description} error={known ? null : `Unknown layout "${current}"; the content renders with the plain layout`}>
17
+ <div class="row">
18
+ <select value={current} onChange={event => { const layout = event.currentTarget.value; slideEdit((deck, id) => setSlideMeta(deck, id, { layout: layout === 'generic' ? undefined : layout })) }}>
19
+ {!known && <option value={current}>{current}</option>}
20
+ {Object.values(manifests.layouts).map(item => <option key={item.id} value={item.id}>{item.title}</option>)}
21
+ </select>
22
+ {missing.length > 0 && <button class="btn is-small" onClick={addMissing} title={`Add ${missing.join(', ')}`}>Add areas</button>}
23
+ </div>
24
+ </Field>
25
+ }
@@ -0,0 +1,8 @@
1
+ import { h } from 'preact'
2
+
3
+ // The first slide in a theme and palette, as a small live picture: the
4
+ // deck's, or with `sample` the sample deck's. Empty values mean the deck's
5
+ // own, and light or dark stays the deck's unless `appearance` is given.
6
+ export function LookThumbnail({ theme = '', palette = '', appearance = '', sample = false }) {
7
+ return <div class="thumb"><iframe src={`/?embedded=1${sample ? '&sample=1' : ''}&theme=${encodeURIComponent(theme)}&palette=${encodeURIComponent(palette)}${appearance ? `&appearance=${appearance}` : ''}`} title="" tabIndex={-1} loading="lazy" scrolling="no" /></div>
8
+ }
@@ -0,0 +1,24 @@
1
+ import { h } from 'preact'
2
+ import { setSlideMeta, setSlideNotes } from '../../core/editDeck.js'
3
+ import { Field, TextArea } from './Field.jsx'
4
+
5
+ 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
+ export function MetaFields({ slide, slideEdit }) {
8
+ const meta = slide.authoredMeta
9
+ const text = (key, numeric = false) => <input type={numeric ? 'number' : 'text'} value={meta[key] ?? ''} onInput={event => {
10
+ const raw = event.currentTarget.value
11
+ const value = raw === '' ? undefined : numeric && Number.isFinite(Number(raw)) ? Number(raw) : raw
12
+ slideEdit((deck, id) => setSlideMeta(deck, id, { [key]: value }), { group: `meta:${key}` })
13
+ }} />
14
+ return <>
15
+ <Field label="Slide id" hint="stable anchor and link target">{text('id')}</Field>
16
+ <Field label="Speaker notes"><TextArea rows={4} value={slide.meta.notes ?? ''} onInput={value => slideEdit((deck, id) => setSlideNotes(deck, id, value), { group: 'notes' })} /></Field>
17
+ <details class="more" open={MORE.some(([key]) => meta[key] != null)}>
18
+ <summary>More metadata</summary>
19
+ <div class="form">
20
+ {MORE.map(([key, hint]) => <Field key={key} label={key} hint={hint}>{text(key, key === 'number')}</Field>)}
21
+ </div>
22
+ </details>
23
+ </>
24
+ }
@@ -0,0 +1,28 @@
1
+ import { h } from 'preact'
2
+ import { Icon } from '../../components/Icon.jsx'
3
+ import { slideTitle, slideDiagnostics } from '../model.js'
4
+
5
+ export function Outline({ deck, manifests, diagnostics, selectedIndex, onSelect, onAdd, onDuplicate, onRemove, onMove }) {
6
+ const count = deck.slides.length
7
+ return <aside class="editor-panel">
8
+ <div class="editor-panel-head"><span>Slides</span><button class="btn is-small is-primary" onClick={onAdd} title="Add a slide after the selected one (⌘↩)"><Icon name="add" size={14} />Add</button></div>
9
+ {count === 0 && <p class="empty">No slides yet. Add one to get started.</p>}
10
+ <ol class="outline-list">
11
+ {deck.slides.map((slide, index) => {
12
+ const manifest = manifests.layouts[slide.meta.layout ?? 'generic']
13
+ const errors = slideDiagnostics(diagnostics, slide).some(d => d.severity === 'error')
14
+ return <li key={index} class={`outline-item${index === selectedIndex ? ' is-selected' : ''}`} onClick={() => onSelect(index)}>
15
+ <span class="num">{String(index + 1).padStart(2, '0')}</span>
16
+ <span><span class="title" style={{ display: 'block' }}>{slideTitle(slide, index, manifest)}</span><span class="layout">{manifest?.title ?? slide.meta.layout ?? 'content'}</span></span>
17
+ {errors ? <span class="dot" title="This slide has errors" /> : <span />}
18
+ </li>
19
+ })}
20
+ </ol>
21
+ {count > 0 && <div class="outline-tools">
22
+ <button class="btn is-small is-icon" onClick={() => onMove(-1)} disabled={selectedIndex <= 0} title="Move up" aria-label="Move up"><Icon name="up" size={14} /></button>
23
+ <button class="btn is-small is-icon" onClick={() => onMove(1)} disabled={selectedIndex >= count - 1} title="Move down" aria-label="Move down"><Icon name="down" size={14} /></button>
24
+ <button class="btn is-small" onClick={onDuplicate} title="Duplicate">Duplicate</button>
25
+ <button class="btn is-small" onClick={onRemove} title="Remove (⌘⌫). Undo with ⌘Z">Remove</button>
26
+ </div>}
27
+ </aside>
28
+ }
@@ -0,0 +1,32 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { Icon } from '../../components/Icon.jsx'
4
+
5
+ export function Swatches({ tokens = {} }) {
6
+ return <span class="swatches">{['--bg', '--surface', '--rule', '--accent', '--accent-2', '--ink'].map(key => tokens[key] ? <i key={key} style={{ background: tokens[key] }} /> : null)}</span>
7
+ }
8
+
9
+ // The palettes a theme offers, each with its colours in the light or dark
10
+ // the deck shows, so they can be compared before choosing.
11
+ export function PalettePicker({ offered, selected, defaultId, appearance, onChoose }) {
12
+ const [open, setOpen] = useState(false)
13
+ const ref = useRef(null)
14
+ useEffect(() => {
15
+ if (!open) return
16
+ const close = event => { if (event.type === 'keydown' ? event.key === 'Escape' : !ref.current?.contains(event.target)) setOpen(false) }
17
+ document.addEventListener('pointerdown', close)
18
+ document.addEventListener('keydown', close)
19
+ return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', close) }
20
+ }, [open])
21
+ const label = palette => <><span class="palette-name">{palette.title}{palette.id === defaultId && <span class="palette-default"> · theme default</span>}</span><Swatches tokens={palette[appearance] ?? {}} /></>
22
+ return <div class="palette-picker" ref={ref}>
23
+ <button type="button" class="palette-current" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(o => !o)}>
24
+ {selected ? label(selected) : <span class="palette-name" />}
25
+ <Icon name="dropdown" size={14} />
26
+ </button>
27
+ {open && <div class="palette-list" role="listbox" aria-label="Palette">
28
+ {offered.map(palette => <button type="button" key={palette.id} role="option" aria-selected={palette.id === selected?.id} title={palette.description} class={palette.id === selected?.id ? 'is-active' : ''}
29
+ onClick={() => { setOpen(false); onChoose(palette.id) }}>{label(palette)}</button>)}
30
+ </div>}
31
+ </div>
32
+ }
@@ -0,0 +1,63 @@
1
+ import { h } from 'preact'
2
+ import { Icon } from '../../components/Icon.jsx'
3
+ import { useEffect, useRef, useState } from 'preact/hooks'
4
+
5
+ const PREVIEW_URL = '/?editor=1&embedded=1'
6
+
7
+ // Hosts the deck runtime in editor mode and keeps it fed with the current
8
+ // source. Navigation inside the preview reports back so the outline follows.
9
+ export function Preview({ source, selection, config = null, overrides = null, reloadKey = 0, width, height, onState, onRendered, bar = null, total = 0 }) {
10
+ const frame = useRef(null)
11
+ const [ready, setReady] = useState(false)
12
+ const reported = useRef(null)
13
+ const post = message => frame.current?.contentWindow?.postMessage(message, location.origin)
14
+
15
+ useEffect(() => {
16
+ const onMessage = event => {
17
+ if (event.source !== frame.current?.contentWindow || event.origin !== location.origin) return
18
+ const data = event.data ?? {}
19
+ if (data.deckEditorReady) setReady(true)
20
+ if (data.deckRendered) onRendered(data.deckRendered)
21
+ if (data.deckStateChanged) {
22
+ reported.current = data.deckStateChanged.index
23
+ if (!['init', 'sync'].includes(data.reason)) onState(data.deckStateChanged.index)
24
+ }
25
+ }
26
+ window.addEventListener('message', onMessage)
27
+ return () => window.removeEventListener('message', onMessage)
28
+ }, [])
29
+
30
+ // Coalesce bursts of keystrokes into one push per tick. A timer rather than
31
+ // requestAnimationFrame, which stalls in background tabs.
32
+ const overridesKey = JSON.stringify({ config, overrides })
33
+ useEffect(() => {
34
+ if (!ready) return
35
+ const id = setTimeout(() => post({ deckSource: { source, selection, config, overrides } }), 0)
36
+ return () => clearTimeout(id)
37
+ }, [ready, source, overridesKey])
38
+
39
+ // Layout code is code inside the preview's module graph: after they
40
+ // change on disk the frame reloads and reports ready again.
41
+ useEffect(() => { if (reloadKey && frame.current) { setReady(false); frame.current.contentWindow?.location.reload() } }, [reloadKey])
42
+
43
+ useEffect(() => {
44
+ if (ready && reported.current !== selection.index) post({ deckControl: { command: 'setState', value: { index: selection.index, slideId: selection.slideId, step: -1 } } })
45
+ }, [ready, selection.index])
46
+
47
+ const control = command => post({ deckControl: { command } })
48
+ return <section class="preview" data-ready={String(ready)}>
49
+ {bar && <div class="preview-bar">{bar}</div>}
50
+ <div class="preview-frame">
51
+ {/* The runtime's listener exists once the frame has loaded; its own ready
52
+ message may arrive before this component listens, so both count. */}
53
+ <iframe ref={frame} title="Slide preview" src={PREVIEW_URL} onLoad={() => setReady(true)} style={{ aspectRatio: `${width} / ${height}` }} />
54
+ </div>
55
+ <div class="preview-tools">
56
+ <button class="btn is-small" onClick={() => control('prev')}><Icon name="prev" size={14} />Previous</button>
57
+ <button class="btn is-small" onClick={() => control('next')}>Next<Icon name="next" size={14} /></button>
58
+ <button class="btn is-small" onClick={() => control('reset')}>Reset</button>
59
+ <span class="spacer" />
60
+ <span>Slide {selection.index + 1}{total ? ` of ${total}` : ''}</span>
61
+ </div>
62
+ </section>
63
+ }
@@ -0,0 +1,46 @@
1
+ import { h } from 'preact'
2
+ import { Icon } from '../../components/Icon.jsx'
3
+ import { useState } from 'preact/hooks'
4
+ import { resolveLayoutProps, propertyErrors } from '../../layouts/layoutProps.js'
5
+ import { setSlideMeta } from '../../core/editDeck.js'
6
+ import { fieldKind, parseFieldValue, formatFieldValue } from '../schema.js'
7
+ import { propSource } from '../model.js'
8
+ import { Field, TextArea } from './Field.jsx'
9
+
10
+ function PropControl({ name, schema, value, authored, onChange }) {
11
+ const kind = fieldKind(schema)
12
+ const [error, setError] = useState(null)
13
+ const commit = raw => {
14
+ const parsed = parseFieldValue(kind, raw, schema)
15
+ setError(parsed.error ?? null)
16
+ if (!parsed.error) onChange(parsed.value)
17
+ }
18
+ const errors = value === undefined ? [] : propertyErrors(value, schema, name)
19
+ const shown = error ?? errors[0]?.replace(`${name} `, '') ?? null
20
+ let control
21
+ if (kind === 'select') control = <select value={value ?? ''} onChange={e => commit(e.currentTarget.value)}>{schema.enum.map(option => <option key={option} value={option}>{String(option)}</option>)}</select>
22
+ else if (kind === 'checkbox') control = <label class="row" style={{ textTransform: 'none', letterSpacing: 0, color: '#e0e0e0', fontSize: '13px' }}><input type="checkbox" checked={Boolean(value)} onChange={e => commit(e.currentTarget.checked)} style={{ flex: '0 0 auto' }} /> <span>{value ? 'on' : 'off'}</span></label>
23
+ else if (kind === 'number') control = <input type="number" value={value ?? ''} min={schema.minimum} max={schema.maximum} step={schema.type === 'integer' ? 1 : 'any'} onInput={e => commit(e.currentTarget.value)} />
24
+ else if (kind === 'yaml') control = <TextArea rows={2} value={formatFieldValue(kind, value)} onInput={commit} />
25
+ else control = <input type="text" value={value ?? ''} onInput={e => commit(e.currentTarget.value)} />
26
+ return <Field label={schema.title ?? name} required={schema.required} hint={schema.description} error={shown}>
27
+ <div class="row">{control}{authored && <button class="btn is-small is-icon" title="Back to the default" aria-label="Back to the default" onClick={() => onChange(undefined)}><Icon name="close" size={14} /></button>}</div>
28
+ </Field>
29
+ }
30
+
31
+ export function PropsForm({ slide, manifest, slideEdit }) {
32
+ const properties = manifest?.properties ?? {}
33
+ if (!Object.keys(properties).length) return null
34
+ const values = resolveLayoutProps(manifest, slide.meta)
35
+ const write = (key, value) => slideEdit((deck, id, current) => {
36
+ if (propSource(current.authoredMeta, key) === 'legacy') return setSlideMeta(deck, id, { [key]: value })
37
+ const props = { ...(current.authoredMeta.props ?? {}) }
38
+ if (value === undefined) delete props[key]
39
+ else props[key] = value
40
+ return setSlideMeta(deck, id, { props: Object.keys(props).length ? props : undefined })
41
+ }, { group: `prop:${key}` })
42
+ return <>
43
+ <p class="section-title">Settings</p>
44
+ {Object.entries(properties).map(([name, schema]) => <PropControl key={name} name={name} schema={schema} value={values[name]} authored={Boolean(propSource(slide.authoredMeta, name))} onChange={value => write(name, value)} />)}
45
+ </>
46
+ }
@@ -0,0 +1,9 @@
1
+ import { h } from 'preact'
2
+ import { replaceSlideSource, slideSourceText } from '../../core/editDeck.js'
3
+ import { Field, TextArea } from './Field.jsx'
4
+
5
+ export function RawSlideEditor({ deck, slide, slideEdit }) {
6
+ return <Field label="Slide Markdown" hint="everything between the --- lines">
7
+ <TextArea tall rows={18} value={slideSourceText(deck, slide.id)} onInput={text => slideEdit((d, id) => replaceSlideSource(d, id, text), { group: 'raw' })} />
8
+ </Field>
9
+ }
@@ -0,0 +1,15 @@
1
+ import { h } from 'preact'
2
+ import { setRegion, removeRegion } from '../../core/editDeck.js'
3
+ import { regionsFor } from '../model.js'
4
+ import { Field, TextArea } from './Field.jsx'
5
+
6
+ export function RegionEditor({ slide, manifest, slideEdit }) {
7
+ const regions = regionsFor(slide, manifest)
8
+ return <>
9
+ {regions.map(region => <Field key={region.name} label={region.name === 'body' ? 'Content' : region.name} required={region.required && !region.content.trim()}
10
+ hint={region.unknown ? 'not part of this layout' : region.description}>
11
+ <TextArea rows={region.name === 'body' ? 8 : 5} value={region.content} onInput={text => slideEdit((deck, id) => setRegion(deck, id, region.name, text), { group: `region:${region.name}` })} />
12
+ {region.unknown && <div><button class="btn is-small" onClick={() => slideEdit((deck, id) => removeRegion(deck, id, region.name))}>Remove this area</button></div>}
13
+ </Field>)}
14
+ </>
15
+ }
@@ -0,0 +1,18 @@
1
+ import { h } from 'preact'
2
+
3
+ // Drag handle on the left edge of the inspector column.
4
+ export function Resizer({ onResize, min = 320, max = () => window.innerWidth * 0.7 }) {
5
+ const start = event => {
6
+ event.preventDefault()
7
+ const move = e => onResize(Math.max(min, Math.min(max(), window.innerWidth - e.clientX)))
8
+ const stop = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', stop); document.body.style.cursor = '' }
9
+ document.body.style.cursor = 'col-resize'
10
+ window.addEventListener('mousemove', move)
11
+ window.addEventListener('mouseup', stop)
12
+ }
13
+ return <div class="resizer" onMouseDown={start} title="Drag to resize" />
14
+ }
15
+
16
+ const KEY = 'mdeck-editor-inspector-width'
17
+ export function readInspectorWidth() { try { const value = Number(localStorage.getItem(KEY)); return value >= 320 ? value : 400 } catch { return 400 } }
18
+ export function storeInspectorWidth(width) { try { localStorage.setItem(KEY, String(width)) } catch {} }