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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- 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
|
+
}
|