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,67 @@
1
+ // Talks to the editing API served by src/build/editorPlugin.js.
2
+ export class ConflictError extends Error {
3
+ constructor({ source, hash }) {
4
+ super('The file changed on disk')
5
+ this.conflict = true
6
+ this.source = source
7
+ this.hash = hash
8
+ }
9
+ }
10
+
11
+ async function failure(response) {
12
+ let message = response.statusText
13
+ try { message = (await response.json()).error ?? message } catch {}
14
+ return new Error(message)
15
+ }
16
+
17
+ export async function loadDeck() {
18
+ const response = await fetch('/__mdeck/deck', { headers: { Accept: 'application/json' } })
19
+ if (!response.ok) throw await failure(response)
20
+ return response.json()
21
+ }
22
+
23
+ export async function saveSource(source, base, { keepalive = false } = {}) {
24
+ const response = await fetch('/__mdeck/source', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, base }), keepalive })
25
+ if (response.status === 409) throw new ConflictError(await response.json())
26
+ if (!response.ok) throw await failure(response)
27
+ return response.json()
28
+ }
29
+
30
+ export function onServerEvent(name, callback) {
31
+ if (!import.meta.hot) return () => {}
32
+ import.meta.hot.on(name, callback)
33
+ return () => import.meta.hot.off?.(name, callback)
34
+ }
35
+
36
+ export async function loadExtension(kind, id) {
37
+ const response = await fetch(`/__mdeck/extension/${kind}/${id}`, { headers: { Accept: 'application/json' } })
38
+ if (!response.ok) throw await failure(response)
39
+ return response.json()
40
+ }
41
+
42
+ // files: { name: text | null }; null removes a file. Returns the new registry.
43
+ export async function saveExtension(kind, id, files) {
44
+ const response = await fetch(`/__mdeck/extension/${kind}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files }) })
45
+ if (!response.ok) throw await failure(response)
46
+ return response.json()
47
+ }
48
+
49
+ export async function deleteExtension(kind, id) {
50
+ const response = await fetch(`/__mdeck/extension/${kind}/${id}`, { method: 'DELETE', headers: { Accept: 'application/json' } })
51
+ if (!response.ok) throw await failure(response)
52
+ return response.json()
53
+ }
54
+
55
+ // The theme repository: its packs, with those installed beside the deck.
56
+ export async function loadThemeIndex() {
57
+ const response = await fetch('/__mdeck/themes/index', { headers: { Accept: 'application/json' } })
58
+ if (!response.ok) throw await failure(response)
59
+ return response.json()
60
+ }
61
+
62
+ // Installs a pack into the deck's extensions folder. Returns the new registry.
63
+ export async function installThemePack(id) {
64
+ const response = await fetch('/__mdeck/themes/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
65
+ if (!response.ok) throw await failure(response)
66
+ return response.json()
67
+ }
@@ -0,0 +1,75 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import { useDeckDocument } from '../useDeckDocument.js'
4
+ import { installShortcuts } from '../keyboard.js'
5
+ import { insertSlide, removeSlide, moveSlide, slideSourceText } from '../../core/editDeck.js'
6
+ import { Outline } from './Outline.jsx'
7
+ import { Preview } from './Preview.jsx'
8
+ import { Inspector } from './Inspector.jsx'
9
+ import { DeckSettings } from './DeckSettings.jsx'
10
+ import { LayoutPicker } from './LayoutPicker.jsx'
11
+ import { ConflictBanner } from './ConflictBanner.jsx'
12
+ import { Resizer, readInspectorWidth, storeInspectorWidth } from './Resizer.jsx'
13
+ import { DESIGN_URL, DESIGN_TAB } from '../designLink.js'
14
+ import markUrl from '../../../assets/logo/mark.svg'
15
+
16
+ const STATUS = { saved: 'Saved', unsaved: 'Unsaved changes', saving: 'Saving…', conflict: 'Conflict', error: 'Could not save' }
17
+
18
+ export function App() {
19
+ const { state, stateRef, dispatch, edit, resolve, loadError, previewKey } = useDeckDocument()
20
+ const [picker, setPicker] = useState(false)
21
+ const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
22
+ const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
23
+
24
+ const select = index => dispatch({ type: 'select', index })
25
+ const actions = {
26
+ undo: () => dispatch({ type: 'undo' }),
27
+ redo: () => dispatch({ type: 'redo' }),
28
+ prev: () => select(stateRef.current.selectedIndex - 1),
29
+ next: () => select(stateRef.current.selectedIndex + 1),
30
+ add: () => setPicker(true),
31
+ remove: () => { const s = stateRef.current; const slide = s.deck?.slides[s.selectedIndex]; if (slide) edit(d => removeSlide(d, d.slides[s.selectedIndex].id)) },
32
+ duplicate: () => { const s = stateRef.current; if (s.deck?.slides[s.selectedIndex]) edit(d => insertSlide(d, s.selectedIndex + 1, slideSourceText(d, d.slides[s.selectedIndex].id))) },
33
+ move: delta => { const s = stateRef.current; if (s.deck?.slides[s.selectedIndex]) edit(d => moveSlide(d, d.slides[s.selectedIndex].id, s.selectedIndex + delta)) },
34
+ pick: layout => { const s = stateRef.current; edit(d => insertSlide(d, s.deck.slides.length ? s.selectedIndex + 1 : 0, layout.starter)); setPicker(false) },
35
+ resolve,
36
+ }
37
+ useEffect(() => installShortcuts(actions), [])
38
+
39
+ if (loadError) return <div class="loading">Could not load the deck: {loadError}</div>
40
+ if (!state.loaded) return <div class="loading">Loading…</div>
41
+ const { deck, selectedIndex, tab } = state
42
+ const slide = deck.slides[selectedIndex]
43
+ const selection = { index: selectedIndex, slideId: slide?.id ?? null }
44
+
45
+ return <div class="editor">
46
+ <header class="editor-topbar">
47
+ <img src={markUrl} alt="" width="22" height="22" style={{ borderRadius: '5px' }} />
48
+ <h1 title={state.path}>{state.name}</h1>
49
+ <span class={`editor-status is-${state.status}`}>{STATUS[state.status]}{state.error ? `: ${state.error}` : ''}</span>
50
+ <span class="spacer" />
51
+ <button class="btn is-small" onClick={actions.undo} disabled={!state.history.past.length} title="Undo (⌘Z)">Undo</button>
52
+ <button class="btn is-small" onClick={actions.redo} disabled={!state.history.future.length} title="Redo (⇧⌘Z)">Redo</button>
53
+ <a class="btn is-small" href={DESIGN_URL} target={DESIGN_TAB} title="Make and change themes, palettes and layouts, on a sample deck">Design themes</a>
54
+ <a class="btn is-small" href="/?view=presenter" target="_blank" rel="noopener">Present</a>
55
+ <a class="btn is-small" href="/" target="_blank" rel="noopener">Open deck</a>
56
+ </header>
57
+ {state.status === 'conflict' && <ConflictBanner onReload={() => actions.resolve('reload')} onOverwrite={() => actions.resolve('overwrite')} />}
58
+ <div class="editor-main" style={{ gridTemplateColumns: `240px minmax(0, 1fr) ${inspectorWidth}px` }}>
59
+ <Outline deck={deck} manifests={state.manifests} diagnostics={state.diagnostics} selectedIndex={selectedIndex}
60
+ onSelect={select} onAdd={actions.add} onDuplicate={actions.duplicate} onRemove={actions.remove} onMove={actions.move} />
61
+ <Preview source={state.source} selection={selection} reloadKey={previewKey} total={deck.slides.length} width={deck.deckConfig.width ?? 1920} height={deck.deckConfig.height ?? 1080}
62
+ onState={index => { if (index !== stateRef.current.selectedIndex) select(index) }}
63
+ onRendered={info => dispatch({ type: 'previewRendered', ...info })} />
64
+ <aside class="editor-panel">
65
+ <Resizer onResize={resize} />
66
+ <div class="tabs">
67
+ <button class={tab === 'slide' ? 'is-active' : ''} onClick={() => dispatch({ type: 'setTab', tab: 'slide' })}>Slide</button>
68
+ <button class={tab === 'deck' ? 'is-active' : ''} onClick={() => dispatch({ type: 'setTab', tab: 'deck' })}>Deck</button>
69
+ </div>
70
+ {tab === 'deck' ? <DeckSettings state={state} edit={edit} /> : <Inspector state={state} dispatch={dispatch} edit={edit} />}
71
+ </aside>
72
+ </div>
73
+ {picker && <LayoutPicker layouts={state.manifests.layouts} onPick={actions.pick} onClose={() => setPicker(false)} />}
74
+ </div>
75
+ }
@@ -0,0 +1,10 @@
1
+ import { h } from 'preact'
2
+
3
+ export function ConflictBanner({ onReload, onOverwrite }) {
4
+ return <div class="banner">
5
+ <span>The slide file was changed by another program while you had unsaved edits here.</span>
6
+ <span class="spacer" />
7
+ <button class="btn is-small" onClick={onReload}>Use the file from disk</button>
8
+ <button class="btn is-small is-primary" onClick={onOverwrite}>Keep my edits</button>
9
+ </div>
10
+ }
@@ -0,0 +1,108 @@
1
+ import { h } from 'preact'
2
+ import { Icon } from '../../components/Icon.jsx'
3
+ import { setDeckConfig } from '../../core/editDeck.js'
4
+ import { resolvePalette } from '../../extensions/appearance.js'
5
+ import { palettesFor } from '../../extensions/tokens.js'
6
+ import { deckDiagnostics } from '../model.js'
7
+ import { Field } from './Field.jsx'
8
+ import { Diagnostics } from './Diagnostics.jsx'
9
+ import { PalettePicker } from './PalettePicker.jsx'
10
+ import { LookThumbnail } from './LookThumbnail.jsx'
11
+ import { designUrl, DESIGN_TAB } from '../designLink.js'
12
+
13
+ // What the `show` setting controls: the label for each choice and its values.
14
+ 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']] }
15
+ const META = [['title', 'Title'], ['author', 'Author'], ['organization', 'Organization'], ['date', 'Date'], ['logo', 'Logo path']]
16
+ const isColor = value => /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim())
17
+
18
+ export function DeckSettings({ state, edit }) {
19
+ const { deck, manifests, diagnostics, warnings } = state
20
+ const config = deck.deckConfig
21
+ const patch = (values, group) => edit(d => setDeckConfig(d, values), { group })
22
+ const themeId = config.theme ?? 'neue'
23
+ const theme = manifests.themes[themeId]
24
+ // The palettes this theme offers and the one the deck gets (its own, else the theme's).
25
+ // The theme's own palette comes first.
26
+ const offered = (theme ? palettesFor(theme, manifests.palettes) : Object.values(manifests.palettes))
27
+ .sort((a, b) => (b.id === theme?.palette) - (a.id === theme?.palette))
28
+ const shown = theme ? resolvePalette({ theme, palettes: manifests.palettes, palette: config.palette, appearance: config.appearance }) : { palette: null, appearance: 'light' }
29
+ const palette = shown.palette
30
+ const text = (key, value) => patch({ [key]: value === '' ? undefined : value }, `deck:${key}`)
31
+ const themeAppearance = theme?.appearance ?? 'light'
32
+ // A palette the new theme does not offer is dropped, so the deck gets that theme's own.
33
+ const offers = (next, paletteId) => !paletteId || !next || palettesFor(next, manifests.palettes).some(p => p.id === paletteId)
34
+ const chooseTheme = id => {
35
+ const keeps = offers(manifests.themes[id], config.palette)
36
+ patch({ theme: id, ...(keeps ? {} : { palette: undefined }) }, 'deck:theme')
37
+ }
38
+ const meta = (key, value) => { const next = { ...(config.meta ?? {}) }; if (value === '') delete next[key]; else next[key] = value; patch({ meta: Object.keys(next).length ? next : undefined }, `deck:meta:${key}`) }
39
+ const show = (key, value) => { const next = { ...(config.show ?? {}) }; if (value === '') delete next[key]; else next[key] = value; patch({ show: Object.keys(next).length ? next : undefined }, `deck:show:${key}`) }
40
+ const param = (key, value) => { const next = { ...(config.params ?? {}) }; if (value === '') delete next[key]; else next[key] = value; patch({ params: Object.keys(next).length ? next : undefined }, `deck:params:${key}`) }
41
+ const problems = deckDiagnostics(diagnostics, deck)
42
+ return <div class="form">
43
+ <Diagnostics items={[...warnings.map(message => ({ severity: 'warning', message, code: 'extension' })), ...problems]} />
44
+ <p class="section-title">Look</p>
45
+ <Field label="Theme" hint={theme?.description}>
46
+ <div class="theme-grid" role="radiogroup" aria-label="Theme">
47
+ {!theme && <button type="button" role="radio" aria-checked="true" class="theme-choice is-active"><span class="theme-choice-name">{themeId} (not found)</span></button>}
48
+ {Object.values(manifests.themes).map(t => <button type="button" key={t.id} role="radio" aria-checked={t.id === themeId} title={t.description}
49
+ class={`theme-choice${t.id === themeId ? ' is-active' : ''}`} onClick={() => chooseTheme(t.id)}>
50
+ <LookThumbnail theme={t.id} palette={offers(t, config.palette) ? config.palette ?? '' : ''} />
51
+ <span class="theme-choice-name">{t.title}{t.source === 'local' && <span class="palette-default"> · this deck</span>}</span>
52
+ </button>)}
53
+ </div>
54
+ </Field>
55
+ <Field label="Palette" hint={palette?.description}>
56
+ <PalettePicker offered={offered} selected={palette} defaultId={theme?.palette} appearance={shown.appearance}
57
+ onChoose={id => text('palette', id === theme?.palette ? '' : id)} />
58
+ </Field>
59
+ <Field label="Light or dark" hint="dark for dark rooms; inverted slides use the other one">
60
+ <div class="appearance-toggle" role="group" aria-label="Light or dark">
61
+ {['light', 'dark'].map(mode => <button type="button" key={mode} class={`btn${shown.appearance === mode ? ' is-active' : ''}`} aria-pressed={shown.appearance === mode}
62
+ onClick={() => text('appearance', mode === themeAppearance ? '' : mode)}>
63
+ <Icon name={mode === 'light' ? 'sun' : 'moon'} size={14} />{mode === 'light' ? 'Light' : 'Dark'}{mode === themeAppearance && <span class="palette-default"> · theme default</span>}
64
+ </button>)}
65
+ </div>
66
+ </Field>
67
+ <Field label="Make it your own" hint="opens the design page; a built-in one is copied into this deck's extensions folder first">
68
+ <div class="row" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
69
+ {theme && <DesignLink kind="theme" item={theme} />}
70
+ {palette && <DesignLink kind="palette" item={palette} />}
71
+ </div>
72
+ </Field>
73
+ {theme && Object.keys(theme.params).length > 0 && <>
74
+ <p class="section-title">Theme settings</p>
75
+ {Object.entries(theme.params).map(([key, def]) => {
76
+ const value = config.params?.[key]
77
+ const color = isColor(def.default)
78
+ return <Field key={key} label={def.title ?? key} hint={def.description}>
79
+ <div class="row">
80
+ {color ? <input type="color" value={isColor(value) ? value : def.default} onInput={e => param(key, e.currentTarget.value)} style={{ flex: '0 0 auto' }} /> : null}
81
+ <input type="text" value={value ?? ''} placeholder={def.default} onInput={e => param(key, e.currentTarget.value)} />
82
+ {value != null && <button class="btn is-small is-icon" title="Back to the default" aria-label="Back to the default" onClick={() => param(key, '')}><Icon name="close" size={14} /></button>}
83
+ </div>
84
+ </Field>
85
+ })}
86
+ </>}
87
+ <p class="section-title">About the talk</p>
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
+ <p class="section-title">Slide frame</p>
90
+ <div class="grid-2">
91
+ <Field label="Width"><input type="number" value={config.width ?? 1920} onInput={e => patch({ width: Number(e.currentTarget.value) || undefined }, 'deck:width')} /></Field>
92
+ <Field label="Height"><input type="number" value={config.height ?? 1080} onInput={e => patch({ height: Number(e.currentTarget.value) || undefined }, 'deck:height')} /></Field>
93
+ {Object.entries(SHOW).map(([key, [label, options]]) => <Field key={key} label={label}>
94
+ <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
+ </Field>)}
96
+ <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
+ </div>
98
+ </div>
99
+ }
100
+
101
+ // Opens the design page on the deck's own theme or palette, or on a new copy
102
+ // of a built-in one.
103
+ function DesignLink({ kind, item }) {
104
+ const own = item.source === 'local'
105
+ return <a class="btn is-small" target={DESIGN_TAB} href={designUrl(own ? { open: `${kind}:${item.id}` } : { copy: `${kind}:${item.id}` })}>
106
+ {own ? `Edit ${kind}` : `Customise ${kind}`} <span class="muted">{item.title}</span>
107
+ </a>
108
+ }
@@ -0,0 +1,252 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { useDeckDocument } from '../useDeckDocument.js'
4
+ import { loadExtension, saveExtension, deleteExtension, installThemePack } from '../api.js'
5
+ import { starterFiles, parseManifest, toModel, toRuntimeManifest, MANIFEST } from '../extensions.js'
6
+ import { sampleDeck, sampleDataUrl } from '../sampleDeck.js'
7
+ import { parseTarget } from '../designLink.js'
8
+ import { palettesFor } from '../../extensions/tokens.js'
9
+ import { parseSlides } from '../../core/parseSlides.js'
10
+ import { setDeckConfig } from '../../core/editDeck.js'
11
+ import { Icon } from '../../components/Icon.jsx'
12
+ import { ExtensionsPanel, NewExtensionDialog } from './ExtensionsPanel.jsx'
13
+ import { ExtensionEditor, BuiltInCard } from './ExtensionEditor.jsx'
14
+ import { Preview } from './Preview.jsx'
15
+ import { ConflictBanner } from './ConflictBanner.jsx'
16
+ import { Resizer, readInspectorWidth, storeInspectorWidth } from './Resizer.jsx'
17
+ import markUrl from '../../../assets/logo/mark.svg'
18
+
19
+ const SAVE_DELAY = 500
20
+ const SAMPLE = sampleDeck(sampleDataUrl())
21
+
22
+ // The design page: makes and changes the deck's own themes and palettes,
23
+ // previewed on a sample deck with every kind of slide, or on the deck itself.
24
+ // Layouts are code, written by hand or by an assistant, not here. Choosing the deck's look happens in the editor's Deck
25
+ // settings; here a finished theme or palette can be put to use in one step.
26
+ export function DesignApp() {
27
+ const { state, dispatch, edit, resolve, loadError, previewKey } = useDeckDocument()
28
+ const [current, setCurrent] = useState(null) // { kind, id, source, files, dirty: {} }
29
+ const [status, setStatus] = useState('saved')
30
+ const [error, setError] = useState(null)
31
+ const [dialog, setDialog] = useState(null)
32
+ // What the preview shows around the extension being edited: a theme, a
33
+ // palette and light or dark, starting as the deck's.
34
+ const [view, setView] = useState(null)
35
+ const [onDeck, setOnDeck] = useState(false)
36
+ const [slide, setSlide] = useState(0)
37
+ const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
38
+ const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
39
+ const timer = useRef(null)
40
+ const currentRef = useRef(current)
41
+ currentRef.current = current
42
+ const { manifests } = state
43
+ const deckConfig = state.deck?.deckConfig ?? {}
44
+ const offers = (theme, paletteId) => !paletteId || !theme || palettesFor(theme, manifests.palettes).some(p => p.id === paletteId)
45
+
46
+ // A theme to show a palette in: the given one where it offers it, else the
47
+ // palette's own theme, else the first that offers it.
48
+ const themeFor = (paletteId, preferred) => {
49
+ const palette = manifests.palettes[paletteId]
50
+ if (offers(manifests.themes[preferred], paletteId)) return preferred
51
+ if (palette?.theme && manifests.themes[palette.theme]) return palette.theme
52
+ return Object.values(manifests.themes).find(t => offers(t, paletteId))?.id ?? preferred
53
+ }
54
+
55
+ // The preview follows the extension opened: a theme is shown with the
56
+ // palette where it offers it, a palette in a theme that offers it.
57
+ const follow = (kind, id) => setView(prev => {
58
+ if (kind === 'theme') return { ...prev, theme: id, palette: offers(manifests.themes[id], prev.palette) ? prev.palette : '' }
59
+ if (kind === 'palette') {
60
+ return { ...prev, theme: themeFor(id, prev.theme), palette: id }
61
+ }
62
+ return prev
63
+ })
64
+
65
+ const show = (kind, id, extension) => {
66
+ setCurrent({ ...extension, dirty: {} }); setStatus('saved'); setError(null)
67
+ follow(kind, id)
68
+ }
69
+
70
+ const select = async (kind, id) => {
71
+ await flush()
72
+ try { show(kind, id, await loadExtension(kind, id)) } catch (caught) { setError(caught.message) }
73
+ }
74
+
75
+ const flush = async () => {
76
+ clearTimeout(timer.current)
77
+ const target = currentRef.current
78
+ if (!target || !Object.keys(target.dirty).length) return
79
+ setStatus('saving')
80
+ try {
81
+ const result = await saveExtension(target.kind, target.id, target.dirty)
82
+ dispatch({ type: 'setRegistry', registry: result.registry })
83
+ setCurrent(prev => prev && prev.id === target.id ? { ...prev, dirty: Object.fromEntries(Object.entries(prev.dirty).filter(([name, text]) => target.dirty[name] !== text)) } : prev)
84
+ setStatus('saved'); setError(null)
85
+ } catch (caught) { setStatus('error'); setError(caught.message) }
86
+ }
87
+
88
+ const onFiles = patch => {
89
+ setCurrent(prev => {
90
+ const files = { ...prev.files }
91
+ for (const [name, text] of Object.entries(patch)) { if (text === null) delete files[name]; else files[name] = text }
92
+ return { ...prev, files, dirty: { ...prev.dirty, ...patch } }
93
+ })
94
+ setStatus('unsaved')
95
+ clearTimeout(timer.current)
96
+ timer.current = setTimeout(flush, SAVE_DELAY)
97
+ }
98
+
99
+ const existing = new Set(Object.values(manifests).flatMap(byId => Object.keys(byId)))
100
+ const freeId = base => { let id = base, n = 2; while (existing.has(id)) id = `${base}-${n++}`; return id }
101
+ const copyDialog = (kind, from) => {
102
+ const item = manifests[`${kind}s`][from]
103
+ setDialog({ kind, initial: { from, title: `${item?.title ?? from} (custom)`, id: freeId(`${from}-custom`) } })
104
+ }
105
+
106
+ const create = async ({ kind, id, title, from }) => {
107
+ setDialog(null)
108
+ await flush()
109
+ const manifest = from ? manifests[`${kind}s`][from] : null
110
+ let fromFiles = {}
111
+ if (from) { try { fromFiles = (await loadExtension(kind, from)).files } catch {} }
112
+ const files = starterFiles(kind, id, title, manifest, fromFiles)
113
+ try {
114
+ const result = await saveExtension(kind, id, files)
115
+ dispatch({ type: 'setRegistry', registry: result.registry })
116
+ show(kind, id, { kind, id, source: 'local', dir: result.dir, files: result.files })
117
+ } catch (caught) { setError(caught.message) }
118
+ }
119
+
120
+ // A pack from the theme repository, installed beside the deck; its first
121
+ // theme or palette of the kind shown is opened.
122
+ const installOnline = async (pack, kind) => {
123
+ const result = await installThemePack(pack.id)
124
+ dispatch({ type: 'setRegistry', registry: result.registry })
125
+ const id = pack[`${kind}s`][0]
126
+ if (id) await select(kind, id)
127
+ }
128
+
129
+ const remove = async () => {
130
+ const target = current
131
+ try {
132
+ const result = await deleteExtension(target.kind, target.id)
133
+ dispatch({ type: 'setRegistry', registry: result.registry })
134
+ setCurrent(null)
135
+ } catch (caught) { setError(caught.message) }
136
+ }
137
+
138
+ // Once the deck is loaded: start from its look, then follow the link that
139
+ // opened the page (`?open=theme:mine`, `?copy=palette:lagoon`).
140
+ useEffect(() => {
141
+ if (!state.loaded || view) return
142
+ setView({ theme: deckConfig.theme ?? 'neue', palette: deckConfig.palette ?? '', appearance: deckConfig.appearance ?? '' })
143
+ const query = new URLSearchParams(location.search)
144
+ const open = parseTarget(query.get('open')), copy = parseTarget(query.get('copy'))
145
+ if (open || copy) history.replaceState(null, '', location.pathname)
146
+ if (open && open.kind !== 'layout') select(open.kind, open.id)
147
+ if (copy && copy.kind !== 'layout' && manifests[`${copy.kind}s`][copy.id]) { select(copy.kind, copy.id); copyDialog(copy.kind, copy.id) }
148
+ }, [state.loaded])
149
+
150
+ useEffect(() => () => clearTimeout(timer.current), [])
151
+ // Extension files changed on disk: pick up the new contents unless local
152
+ // edits are still waiting to be saved.
153
+ useEffect(() => {
154
+ const target = currentRef.current
155
+ if (!previewKey || !target || Object.keys(target.dirty).length) return
156
+ loadExtension(target.kind, target.id).then(fresh => setCurrent(prev => prev && prev.id === target.id && !Object.keys(prev.dirty).length ? { ...prev, ...fresh, dirty: {} } : prev)).catch(() => {})
157
+ }, [previewKey])
158
+ useEffect(() => { const handler = () => flush(); window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler) }, [])
159
+ useEffect(() => { document.title = state.name ? `Design · ${state.name}` : 'mdeck design' }, [state.name])
160
+
161
+ if (loadError) return <div class="loading">Could not load the deck: {loadError}</div>
162
+ if (!state.loaded || !view) return <div class="loading">Loading…</div>
163
+
164
+ // The preview: unsaved themes and palettes reach it as runtime overrides.
165
+ const parsed = current?.files?.[MANIFEST] ? parseManifest(current.files[MANIFEST]) : null
166
+ const model = parsed?.raw ? toModel({ ...parsed.raw, kind: current.kind }) : null
167
+ const editing = kind => current?.kind === kind && model ? current.id : null
168
+ let overrides = null, note = null
169
+ const themes = manifests.themes
170
+ const themeId = editing('theme') ?? (themes[view.theme] ? view.theme : 'neue')
171
+ const theme = editing('theme') ? toRuntimeManifest(model) : themes[themeId]
172
+ const offered = theme ? palettesFor(theme, manifests.palettes) : Object.values(manifests.palettes)
173
+ const paletteId = editing('palette') ?? (view.palette && offered.some(p => p.id === view.palette) ? view.palette : '')
174
+ if (editing('palette')) {
175
+ overrides = { palettes: { [current.id]: toRuntimeManifest(model) } }
176
+ if (theme && !offered.some(p => p.id === current.id)) note = `${theme.title} offers only its own colours, so the preview shows those. Choose another theme above the preview to see ${model.title || current.id}.`
177
+ }
178
+ if (editing('theme')) overrides = { themes: { [current.id]: { manifest: toRuntimeManifest(model), styles: current.files['styles.css'] ?? '' } } }
179
+ const source = onDeck ? state.source : SAMPLE
180
+ const config = { theme: themeId, palette: paletteId, ...(view.appearance ? { appearance: view.appearance } : {}) }
181
+ const appearance = view.appearance || theme?.appearance || 'light'
182
+ const slideCount = parseSlides(source).slides.length
183
+ const index = Math.min(slide, Math.max(0, slideCount - 1))
184
+
185
+ // Whether the deck uses the theme or palette being edited, and the one
186
+ // change that makes it.
187
+ const deckThemeId = deckConfig.theme ?? 'neue'
188
+ const deckTheme = themes[deckThemeId]
189
+ const deckPaletteId = deckConfig.palette && offers(deckTheme, deckConfig.palette) ? deckConfig.palette : deckTheme?.palette
190
+ const inDeck = current?.kind === 'theme' ? deckThemeId === current.id : current?.kind === 'palette' ? deckPaletteId === current.id : false
191
+ const useInDeck = () => {
192
+ if (current.kind === 'theme') edit(d => setDeckConfig(d, { theme: current.id, ...(offers(themes[current.id], deckConfig.palette) ? {} : { palette: undefined }) }), { group: 'design:use' })
193
+ else edit(d => setDeckConfig(d, { palette: current.id, ...(offers(deckTheme, current.id) ? {} : { theme: themeId }) }), { group: 'design:use' })
194
+ }
195
+
196
+ const bar = <>
197
+ {!state.designOnly && <div class="segmented" role="group" aria-label="Preview on">
198
+ <button type="button" class={!onDeck ? 'is-active' : ''} aria-pressed={!onDeck} onClick={() => { setOnDeck(false); setSlide(0) }}>Sample deck</button>
199
+ <button type="button" class={onDeck ? 'is-active' : ''} aria-pressed={onDeck} onClick={() => { setOnDeck(true); setSlide(0) }}>My deck</button>
200
+ </div>}
201
+ <span class="spacer" />
202
+ <label class="preview-choice">Theme
203
+ <select value={themeId} disabled={Boolean(editing('theme'))} onChange={e => follow('theme', e.currentTarget.value)}>
204
+ {editing('theme') && !themes[themeId] && <option value={themeId}>{model.title || themeId}</option>}
205
+ {Object.values(themes).map(t => <option key={t.id} value={t.id}>{t.title}</option>)}
206
+ </select>
207
+ </label>
208
+ <label class="preview-choice">Palette
209
+ <select value={paletteId} disabled={Boolean(editing('palette'))} onChange={e => setView(prev => ({ ...prev, palette: e.currentTarget.value }))}>
210
+ <option value="">{theme?.palette && manifests.palettes[theme.palette] ? `${manifests.palettes[theme.palette].title} (theme default)` : 'Theme default'}</option>
211
+ {editing('palette') && !offered.some(p => p.id === current.id) && <option value={current.id}>{model.title || current.id}</option>}
212
+ {offered.filter(p => p.id !== theme?.palette).map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
213
+ </select>
214
+ </label>
215
+ <div class="appearance-toggle is-compact" role="group" aria-label="Light or dark">
216
+ {['light', 'dark'].map(mode => <button type="button" key={mode} title={mode === 'light' ? 'Light' : 'Dark'} aria-label={mode === 'light' ? 'Light' : 'Dark'} aria-pressed={appearance === mode}
217
+ class={`btn is-small is-icon${appearance === mode ? ' is-active' : ''}`} onClick={() => setView(prev => ({ ...prev, appearance: mode }))}><Icon name={mode === 'light' ? 'sun' : 'moon'} size={14} /></button>)}
218
+ </div>
219
+ </>
220
+
221
+ const usage = !state.designOnly && current && current.source === 'local' && <div class="design-usage">
222
+ {inDeck
223
+ ? <span><Icon name="check" size={14} /> The deck uses this {current.kind}.</span>
224
+ : <><span class="muted">The deck does not use this {current.kind}.</span><button class="btn is-small is-primary" onClick={useInDeck}>Use in my deck</button></>}
225
+ </div>
226
+
227
+ return <div class="editor">
228
+ <header class="editor-topbar">
229
+ <img src={markUrl} alt="" width="22" height="22" style={{ borderRadius: '5px' }} />
230
+ <h1 title={state.path}>Design <span class="muted">· {state.name}</span></h1>
231
+ <span class="spacer" />
232
+ {state.designOnly
233
+ ? <span class="muted" title={`${state.path}/extensions`}>Saved in {state.name}/extensions</span>
234
+ : <a class="btn is-small" href="/editor.html">Back to the slides</a>}
235
+ </header>
236
+ {state.status === 'conflict' && <ConflictBanner onReload={() => resolve('reload')} onOverwrite={() => resolve('overwrite')} />}
237
+ <div class="editor-main" style={{ gridTemplateColumns: `290px minmax(0, 1fr) ${inspectorWidth}px` }}>
238
+ <ExtensionsPanel registry={state.registry} owner={state.designOnly ? "This folder's" : "This deck's"} view={{ appearance, themeFor: id => themeFor(id, themeId), paletteFor: id => offers(themes[id], paletteId) ? paletteId : '' }} selected={current} onSelect={select} onCreate={kind => setDialog({ kind })} onInstall={installOnline} />
239
+ <Preview source={source} selection={{ index, slideId: null }} config={config} overrides={overrides} bar={bar} reloadKey={previewKey} total={slideCount}
240
+ width={deckConfig.width ?? 1920} height={deckConfig.height ?? 1080} onState={setSlide} onRendered={info => dispatch({ type: 'previewRendered', ...info })} />
241
+ <aside class="editor-panel">
242
+ <Resizer onResize={resize} />
243
+ {!current
244
+ ? <div class="form"><p class="empty">Choose one of {state.designOnly ? "this folder's" : "this deck's"} themes and palettes on the left to change it, or start from a built-in one. What you change is saved as you type, in the <code>extensions</code> folder {state.designOnly ? 'of this folder, where decks in it find them' : "beside the deck. The deck's look is chosen in the editor's Deck settings"}.{error ? ` ${error}` : ''}</p></div>
245
+ : current.source === 'local'
246
+ ? <>{usage}<ExtensionEditor extension={current} palettes={manifests.palettes} status={status} error={error} note={note} onFiles={onFiles} onDelete={remove} onCopy={() => copyDialog(current.kind, current.id)} /></>
247
+ : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} />}
248
+ </aside>
249
+ </div>
250
+ {dialog && <NewExtensionDialog kind={dialog.kind} initial={dialog.initial ?? null} registry={state.registry} existing={existing} onCreate={create} onClose={() => setDialog(null)} />}
251
+ </div>
252
+ }
@@ -0,0 +1,10 @@
1
+ import { h } from 'preact'
2
+
3
+ export function Diagnostics({ items, onPick }) {
4
+ if (!items.length) return null
5
+ return <ul class="diagnostics">
6
+ {items.map((d, i) => <li key={i} class={d.severity === 'warning' ? 'is-warning' : ''} onClick={onPick ? () => onPick(d) : undefined} style={onPick ? { cursor: 'pointer' } : undefined}>
7
+ <code>{d.line ? `line ${d.line}` : d.code}</code>{d.message}
8
+ </li>)}
9
+ </ul>
10
+ }
@@ -0,0 +1,66 @@
1
+ import { h } from 'preact'
2
+ import { useState } from 'preact/hooks'
3
+ import { parseManifest, toModel, toToml, MANIFEST } from '../extensions.js'
4
+ import { PaletteForm, ThemeForm } from './ExtensionForms.jsx'
5
+ import { Diagnostics } from './Diagnostics.jsx'
6
+ import { Field, TextArea } from './Field.jsx'
7
+ import { Swatches } from './PalettePicker.jsx'
8
+
9
+ const KIND_LABEL = { palette: 'Palette', theme: 'Theme' }
10
+
11
+ // Edits one of the deck's own extensions. `files` is the source of truth; the
12
+ // form derives its model from extension.toml and writes TOML back.
13
+ export function ExtensionEditor({ extension, palettes = {}, status, error, note, onFiles, onDelete, onCopy }) {
14
+ const [mode, setMode] = useState('form')
15
+ const [confirm, setConfirm] = useState(false)
16
+ const { kind, id, files } = extension
17
+ const parsed = parseManifest(files[MANIFEST] ?? '')
18
+ const model = parsed.raw ? toModel({ ...parsed.raw, kind }) : null
19
+ const change = patch => onFiles({ [MANIFEST]: toToml({ ...model, ...patch }) })
20
+ const file = (name, text) => onFiles({ [name]: text })
21
+ const problems = [
22
+ ...(parsed.error ? [{ severity: 'error', message: parsed.error, line: parsed.line }] : []),
23
+ ...(error ? [{ severity: 'error', message: error, code: 'save' }] : []),
24
+ ]
25
+ return <>
26
+ <div class="tabs">
27
+ <button class={mode === 'form' ? 'is-active' : ''} onClick={() => setMode('form')}>{KIND_LABEL[kind]}</button>
28
+ <button class={mode === 'raw' ? 'is-active' : ''} onClick={() => setMode('raw')}>extension.toml</button>
29
+ <span style={{ flex: 1 }} />
30
+ <span class={`editor-status is-${status}`} style={{ alignSelf: 'center' }}>{{ saved: 'Saved', unsaved: 'Unsaved', saving: 'Saving…', error: 'Not saved' }[status]}</span>
31
+ </div>
32
+ <div class="form">
33
+ <Diagnostics items={problems} />
34
+ {note && <p class="note">{note}</p>}
35
+ {mode === 'raw' || !model
36
+ ? <Field label="extension.toml"><TextArea tall rows={24} value={files[MANIFEST] ?? ''} onInput={text => file(MANIFEST, text)} /></Field>
37
+ : kind === 'palette' ? <PaletteForm model={model} onChange={change} />
38
+ : <ThemeForm model={model} files={files} palettes={palettes} onChange={change} onFile={file} />}
39
+ <div class="row" style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
40
+ {confirm
41
+ ? <><span style={{ color: '#f28b82' }}>Delete the folder extensions/{id}?</span><button class="btn is-small" onClick={onDelete}>Yes, delete</button><button class="btn is-small" onClick={() => setConfirm(false)}>Keep</button></>
42
+ : <><button class="btn is-small" onClick={onCopy}>Make a copy</button><button class="btn is-small" onClick={() => setConfirm(true)}>Delete this {kind}</button></>}
43
+ </div>
44
+ </div>
45
+ </>
46
+ }
47
+
48
+ // A built-in or installed extension: what it is, and the one way to change
49
+ // it, a copy in the deck's extensions folder.
50
+ export function BuiltInCard({ extension, manifest, onCopy }) {
51
+ const { kind, id, files } = extension
52
+ const user = extension.source === 'user'
53
+ return <div class="form">
54
+ <p class="section-title">{user ? `Installed ${kind}, for every deck` : `Built-in ${kind}`}</p>
55
+ <h2 class="builtin-title">{manifest?.title ?? id} <span class="muted">{id}</span></h2>
56
+ {manifest?.description && <p class="builtin-description">{manifest.description}</p>}
57
+ {kind === 'palette' && manifest && ['light', 'dark'].map(mode => <Field key={mode} label={mode === 'light' ? 'Light' : 'Dark'}><Swatches tokens={manifest[mode] ?? {}} /></Field>)}
58
+ {kind === 'theme' && manifest?.palette && <p class="builtin-description">Its default palette is <code>{manifest.palette}</code>{manifest.palettes?.length ? `; it offers only ${manifest.palettes.join(', ')}` : ''}.</p>}
59
+ <p class="note">{user ? `Installed ${kind}s are changed only by updating them (mdeck themes update --global).` : `Built-in ${kind}s cannot be changed.`} Copy this one into the deck's <code>extensions</code> folder and change the copy. The preview shows every change as you make it.</p>
60
+ <div class="row" style={{ display: 'flex', gap: '8px' }}><button class="btn is-primary" onClick={onCopy}>Copy to customise</button></div>
61
+ <details class="builtin-source">
62
+ <summary>{MANIFEST}</summary>
63
+ <pre>{files?.[MANIFEST] ?? ''}</pre>
64
+ </details>
65
+ </div>
66
+ }