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,230 @@
|
|
|
1
|
+
// Deck-level editing operations on the parsed document model. Every function
|
|
2
|
+
// takes the deck from parseSlides, never mutates it, and returns the new
|
|
3
|
+
// source (or { source, index } for structural changes). Untouched bytes stay
|
|
4
|
+
// identical; callers reparse the result.
|
|
5
|
+
import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
|
|
6
|
+
import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
|
|
7
|
+
|
|
8
|
+
const REGION_RE = /^[a-z][a-z0-9-]*$/
|
|
9
|
+
|
|
10
|
+
export function findSlide(deck, slideId) {
|
|
11
|
+
const slide = deck.slides.find(slide => slide.id === slideId)
|
|
12
|
+
if (!slide) throw new Error(`No slide "${slideId}"`)
|
|
13
|
+
return slide
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function slideSourceText(deck, slideId) {
|
|
17
|
+
const { source } = findSlide(deck, slideId)
|
|
18
|
+
return deck.source.slice(source.start, source.end)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lineEnd(source, offset) {
|
|
22
|
+
const index = source.indexOf('\n', offset)
|
|
23
|
+
return index < 0 ? source.length : index + 1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `delimiterStart` is the start of the `---` line that introduces the slide,
|
|
27
|
+
// or null when there is none or when it is the deck config's closing line.
|
|
28
|
+
export function slideBounds(deck, slideId) {
|
|
29
|
+
const { source } = findSlide(deck, slideId)
|
|
30
|
+
let delimiterStart = null
|
|
31
|
+
if (source.start > 0) {
|
|
32
|
+
const lineStart = deck.source.lastIndexOf('\n', source.start - 2) + 1
|
|
33
|
+
const line = deck.source.slice(lineStart, source.start)
|
|
34
|
+
if (/^---[ \t]*\r?\n$/.test(line) && deck.configSource?.end !== lineStart) delimiterStart = lineStart
|
|
35
|
+
}
|
|
36
|
+
return { start: source.start, end: source.end, delimiterStart }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function edit(deck, edits) { return applySourceEdits(deck.source, edits) }
|
|
40
|
+
const expectedText = (deck, range) => deck.source.slice(range.start, range.end)
|
|
41
|
+
|
|
42
|
+
// Text to put before something appended at `offset` so it starts on its own
|
|
43
|
+
// line with one blank line separating it from previous content.
|
|
44
|
+
function separatorBefore(source, offset, nl) {
|
|
45
|
+
if (offset === 0) return ''
|
|
46
|
+
const before = source.slice(0, offset)
|
|
47
|
+
if (!before.endsWith('\n')) return nl + nl
|
|
48
|
+
return /\r?\n\r?\n$/.test(before) ? '' : nl
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function appendBlock(deck, slide, block) {
|
|
52
|
+
const nl = detectNewline(deck.source)
|
|
53
|
+
const at = slide.source.end
|
|
54
|
+
// A legacy meta-only slide needs its `---` line before any body content.
|
|
55
|
+
const pre = slide.bodySource === null && slide.metaSource ? (deck.source.slice(0, at).endsWith('\n') ? '' : nl) + '---' + nl : separatorBefore(deck.source, at, nl)
|
|
56
|
+
const post = at < deck.source.length ? nl : ''
|
|
57
|
+
return edit(deck, [{ start: at, end: at, text: pre + block + post }])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setRegion(deck, slideId, name, markdown) {
|
|
61
|
+
if (!REGION_RE.test(name)) throw new Error(`Invalid region name "${name}"`)
|
|
62
|
+
const slide = findSlide(deck, slideId)
|
|
63
|
+
const region = slide.regions[name]
|
|
64
|
+
const nl = detectNewline(deck.source)
|
|
65
|
+
if (region?.explicit) return replaceRegion(deck, slideId, name, markdown)
|
|
66
|
+
const content = normalizeBlock(markdown, nl)
|
|
67
|
+
if (!region) return appendBlock(deck, slide, `:::slot ${name}${nl}${content}:::${nl}`)
|
|
68
|
+
// Implicit body: rewrite the first content-bearing range, collapse other
|
|
69
|
+
// content ranges to one blank line, leave whitespace-only ranges alone.
|
|
70
|
+
const ranges = region.ranges.filter(range => range.end > range.start)
|
|
71
|
+
if (!ranges.length) {
|
|
72
|
+
if (slide.bodySource === null) return appendBlock(deck, slide, content)
|
|
73
|
+
const at = slide.bodySource.start
|
|
74
|
+
return edit(deck, [{ start: at, end: at, text: content + (at < slide.source.end ? nl : '') }])
|
|
75
|
+
}
|
|
76
|
+
const hasText = range => deck.source.slice(range.start, range.end).trim()
|
|
77
|
+
const target = ranges.find(hasText) ?? ranges[0]
|
|
78
|
+
const edits = []
|
|
79
|
+
for (const range of ranges) {
|
|
80
|
+
const slice = deck.source.slice(range.start, range.end)
|
|
81
|
+
if (range === target) {
|
|
82
|
+
const lead = slice.match(/^(\r?\n)*/)[0]
|
|
83
|
+
const blank = /\r?\n\r?\n$/.test(slice) ? nl : ''
|
|
84
|
+
const atEnd = range.end === deck.source.length && !slice.endsWith('\n')
|
|
85
|
+
let text
|
|
86
|
+
if (!content) text = lead || (range.end < slide.source.end ? nl : '')
|
|
87
|
+
else text = lead + (atEnd ? content.slice(0, -nl.length) : content + blank)
|
|
88
|
+
edits.push({ ...range, text, expected: slice })
|
|
89
|
+
} else if (hasText(range)) edits.push({ ...range, text: nl, expected: slice })
|
|
90
|
+
}
|
|
91
|
+
return edit(deck, edits)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function removeRegion(deck, slideId, name) {
|
|
95
|
+
const slide = findSlide(deck, slideId)
|
|
96
|
+
const block = slide.blocks.find(block => block.type === 'slot' && block.name === name)
|
|
97
|
+
if (!block) throw new Error(`No explicit region "${name}" on slide "${slideId}"`)
|
|
98
|
+
return removeBlock(deck, block)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function blockRemoval(deck, block) {
|
|
102
|
+
let { start } = block.source
|
|
103
|
+
const { end } = block.source
|
|
104
|
+
// Take one preceding blank line with the block.
|
|
105
|
+
const before = deck.source.slice(0, start)
|
|
106
|
+
const blank = before.match(/\r?\n(\r?\n)$/)
|
|
107
|
+
if (blank) start -= blank[1].length
|
|
108
|
+
return { start, end, text: '', expected: deck.source.slice(start, end) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeBlock(deck, block) {
|
|
112
|
+
return edit(deck, [blockRemoval(deck, block)])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function metaForm(deck, slide) {
|
|
116
|
+
if (!slide.metaSource) return 'none'
|
|
117
|
+
return slide.metaSource.start === slide.source.start ? 'legacy' : 'explicit'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function setSlideMeta(deck, slideId, patch) {
|
|
121
|
+
const slide = findSlide(deck, slideId)
|
|
122
|
+
const nl = detectNewline(deck.source)
|
|
123
|
+
const form = metaForm(deck, slide)
|
|
124
|
+
const block = yamlText => `:::meta${nl}${yamlText}:::${nl}`
|
|
125
|
+
if (form === 'none') {
|
|
126
|
+
const yamlText = patchYamlMapping('', patch, { newline: nl })
|
|
127
|
+
if (!yamlText) return deck.source
|
|
128
|
+
return edit(deck, [{ start: slide.source.start, end: slide.source.start, text: block(yamlText) }])
|
|
129
|
+
}
|
|
130
|
+
const range = slide.metaSource
|
|
131
|
+
const current = expectedText(deck, range)
|
|
132
|
+
if (form === 'explicit') {
|
|
133
|
+
const yamlText = patchYamlMapping(current, patch, { newline: nl })
|
|
134
|
+
if (yamlText) return edit(deck, [{ ...range, text: yamlText, expected: current }])
|
|
135
|
+
return edit(deck, [{ start: slide.source.start, end: lineEnd(deck.source, range.end), text: '' }])
|
|
136
|
+
}
|
|
137
|
+
// Legacy `---` metadata: keep its form while a slide key leads, otherwise
|
|
138
|
+
// convert it into an explicit :::meta block (consuming the inner `---`).
|
|
139
|
+
const yamlText = patchYamlMapping(current, patch, { newline: nl, leadKeys: SLIDE_KEYS })
|
|
140
|
+
const end = slide.bodySource ? slide.bodySource.start : range.end
|
|
141
|
+
if (!yamlText) return slide.bodySource ? edit(deck, [{ start: range.start, end, text: '' }]) : removeSlide(deck, slideId).source
|
|
142
|
+
if (SLIDE_KEYS.has(firstYamlKey(yamlText))) return edit(deck, [{ ...range, text: yamlText, expected: current }])
|
|
143
|
+
return edit(deck, [{ start: range.start, end, text: block(yamlText) }])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function notesSource(deck, slideId) {
|
|
147
|
+
const slide = findSlide(deck, slideId)
|
|
148
|
+
return slide.blocks.some(block => block.type === 'notes') ? 'block' : null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function setSlideNotes(deck, slideId, markdown) {
|
|
152
|
+
const slide = findSlide(deck, slideId)
|
|
153
|
+
const nl = detectNewline(deck.source)
|
|
154
|
+
const content = normalizeBlock(markdown, nl)
|
|
155
|
+
const block = slide.blocks.find(block => block.type === 'notes')
|
|
156
|
+
if (block) {
|
|
157
|
+
if (!content) return removeBlock(deck, block)
|
|
158
|
+
return edit(deck, [{ ...block.bodySource, text: content, expected: expectedText(deck, block.bodySource) }])
|
|
159
|
+
}
|
|
160
|
+
if (!content) return deck.source
|
|
161
|
+
return appendBlock(deck, slide, `:::notes${nl}${content}:::${nl}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setDeckConfig(deck, patch) {
|
|
165
|
+
const nl = detectNewline(deck.source)
|
|
166
|
+
const range = deck.configSource
|
|
167
|
+
if (!range) {
|
|
168
|
+
const yamlText = patchYamlMapping('', patch, { newline: nl, leadKeys: DECK_KEYS })
|
|
169
|
+
if (!yamlText) return deck.source
|
|
170
|
+
const at = deck.source.startsWith('') ? 1 : 0
|
|
171
|
+
return edit(deck, [{ start: at, end: at, text: `---${nl}${yamlText}---${nl}${nl}` }])
|
|
172
|
+
}
|
|
173
|
+
const current = expectedText(deck, range)
|
|
174
|
+
const yamlText = patchYamlMapping(current, patch, { newline: nl, leadKeys: DECK_KEYS })
|
|
175
|
+
if (yamlText) return edit(deck, [{ ...range, text: yamlText, expected: current }])
|
|
176
|
+
const open = deck.source.lastIndexOf('---', range.start)
|
|
177
|
+
let end = lineEnd(deck.source, range.end)
|
|
178
|
+
const blank = deck.source.slice(end).match(/^\r?\n/)
|
|
179
|
+
if (blank) end += blank[0].length
|
|
180
|
+
return edit(deck, [{ start: open, end, text: '' }])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function insertSlide(deck, index, markdown) {
|
|
184
|
+
const nl = detectNewline(deck.source)
|
|
185
|
+
const text = normalizeBlock(markdown, nl)
|
|
186
|
+
const count = deck.slides.length
|
|
187
|
+
index = Math.max(0, Math.min(count, index))
|
|
188
|
+
if (index < count) {
|
|
189
|
+
const at = deck.slides[index].source.start
|
|
190
|
+
return { source: edit(deck, [{ start: at, end: at, text: `${text}${nl}---${nl}` }]), index }
|
|
191
|
+
}
|
|
192
|
+
const at = count ? deck.slides[count - 1].source.end : deck.source.length
|
|
193
|
+
const pre = at > 0 && !deck.source.slice(0, at).endsWith('\n') ? nl : ''
|
|
194
|
+
return { source: edit(deck, [{ start: at, end: at, text: `${pre}---${nl}${text}` }]), index }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function removeSlide(deck, slideId) {
|
|
198
|
+
const bounds = slideBounds(deck, slideId)
|
|
199
|
+
const index = deck.slides.findIndex(slide => slide.id === slideId)
|
|
200
|
+
const next = deck.slides[index + 1]
|
|
201
|
+
const range = bounds.delimiterStart != null
|
|
202
|
+
? { start: bounds.delimiterStart, end: bounds.end }
|
|
203
|
+
: { start: bounds.start, end: next ? next.source.start : deck.source.length }
|
|
204
|
+
const source = edit(deck, [{ ...range, text: '', expected: expectedText(deck, range) }])
|
|
205
|
+
return { source, index: Math.min(index, deck.slides.length - 2) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function moveSlide(deck, slideId, toIndex) {
|
|
209
|
+
const text = slideSourceText(deck, slideId)
|
|
210
|
+
const removed = removeSlide(deck, slideId)
|
|
211
|
+
return insertSlide(parseSlides(removed.source), toIndex, text)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function replaceSlideSource(deck, slideId, text) {
|
|
215
|
+
const slide = findSlide(deck, slideId)
|
|
216
|
+
const nl = detectNewline(deck.source)
|
|
217
|
+
const current = slideSourceText(deck, slideId)
|
|
218
|
+
const trailing = current.match(/(\r?\n)*$/)[0]
|
|
219
|
+
const body = text.replace(/\r?\n/g, nl).replace(/[\r\n]*$/, '')
|
|
220
|
+
return edit(deck, [{ ...slide.source, text: body + (body ? trailing : ''), expected: current }])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Removes every speaker notes block for shared builds.
|
|
224
|
+
export function stripNotes(source) {
|
|
225
|
+
const deck = parseSlides(source)
|
|
226
|
+
const edits = deck.slides.flatMap(slide => slide.blocks
|
|
227
|
+
.filter(block => block.type === 'notes')
|
|
228
|
+
.map(block => blockRemoval(deck, block)))
|
|
229
|
+
return edit(deck, edits)
|
|
230
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The one icon set for every part of mdeck: line icons from Lucide
|
|
2
|
+
// (https://lucide.dev, ISC license, Copyright (c) Lucide Icons and Contributors),
|
|
3
|
+
// all on the same 24px grid and drawn with the same stroke. Names say what the
|
|
4
|
+
// button does; the comment names the Lucide icon. To add one, copy the inner
|
|
5
|
+
// markup of the Lucide SVG.
|
|
6
|
+
|
|
7
|
+
export const ICONS = {
|
|
8
|
+
presenter: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/>', // presentation
|
|
9
|
+
projector: '<path d="M5 7 3 5"/><path d="M9 6V3"/><path d="m13 7 2-2"/><circle cx="9" cy="13" r="3"/><path d="M11.83 12H20a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h2.17"/><path d="M16 16h2"/>', // projector
|
|
10
|
+
reader: '<path d="M12 5v16"/><path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z"/>', // book-open
|
|
11
|
+
editor: '<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/>', // square-pen
|
|
12
|
+
guides: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>', // circle-help
|
|
13
|
+
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>', // folder
|
|
14
|
+
'folder-open': '<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>', // folder-open
|
|
15
|
+
'file-code': '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/>', // file-code
|
|
16
|
+
'file-text': '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>', // file-text
|
|
17
|
+
check: '<path d="M20 6 9 17l-5-5"/>', // check
|
|
18
|
+
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>', // triangle-alert
|
|
19
|
+
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', // copy
|
|
20
|
+
external: '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>', // arrow-up-right
|
|
21
|
+
prev: '<path d="m15 18-6-6 6-6"/>', // chevron-left
|
|
22
|
+
next: '<path d="m9 18 6-6-6-6"/>', // chevron-right
|
|
23
|
+
expand: '<path d="m9 18 6-6-6-6"/>', // chevron-right
|
|
24
|
+
collapse: '<path d="m6 9 6 6 6-6"/>', // chevron-down
|
|
25
|
+
dropdown: '<path d="m6 9 6 6 6-6"/>', // chevron-down
|
|
26
|
+
up: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>', // arrow-up
|
|
27
|
+
down: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>', // arrow-down
|
|
28
|
+
add: '<path d="M5 12h14"/><path d="M12 5v14"/>', // plus
|
|
29
|
+
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', // x
|
|
30
|
+
grid: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>', // layout-grid
|
|
31
|
+
menu: '<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>', // menu
|
|
32
|
+
slides: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>', // monitor
|
|
33
|
+
read: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>', // scroll-text
|
|
34
|
+
look: '<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>', // palette
|
|
35
|
+
download: '<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>', // download
|
|
36
|
+
present: '<path d="M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z"/><path d="M12 17v4"/><path d="M8 21h8"/><rect x="2" y="3" width="20" height="14" rx="2"/>', // monitor-play
|
|
37
|
+
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', // link
|
|
38
|
+
reset: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>', // rotate-ccw
|
|
39
|
+
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>', // undo-2
|
|
40
|
+
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13"/>', // redo-2
|
|
41
|
+
trash: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', // trash-2
|
|
42
|
+
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', // eye
|
|
43
|
+
'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', // eye-off
|
|
44
|
+
finger: '<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', // pointer
|
|
45
|
+
pen: '<path d="M13 21h8"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>', // pen-line
|
|
46
|
+
highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
|
|
47
|
+
marker: '<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/>', // sparkles
|
|
48
|
+
select: '<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>', // square-dashed
|
|
49
|
+
laser: '<line x1="2" x2="5" y1="12" y2="12"/><line x1="19" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="5"/><line x1="12" x2="12" y1="19" y2="22"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/>', // locate-fixed
|
|
50
|
+
fold: '<path d="m7 7 10 10"/><path d="M17 7v10H7"/>', // arrow-down-right
|
|
51
|
+
eraser: '<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/>', // eraser
|
|
52
|
+
people: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>', // users
|
|
53
|
+
tablet: '<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/>', // tablet
|
|
54
|
+
speaker: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>', // panel-right
|
|
55
|
+
fullscreen: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>', // maximize
|
|
56
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', // sun
|
|
57
|
+
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>', // moon
|
|
58
|
+
'fullscreen-exit': '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>', // minimize
|
|
59
|
+
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
|
|
60
|
+
pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
|
|
61
|
+
loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const ICON_STROKE = 1.75
|
|
65
|
+
|
|
66
|
+
// The icon as an SVG string, for markup built outside Preact (<deck-stage>).
|
|
67
|
+
export function iconSvg(name, size = 16) {
|
|
68
|
+
return `<svg class="icon" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${ICON_STROKE}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
|
69
|
+
}
|
package/src/core/ink.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Slide annotations ("ink"): the file format and the operations on it, as
|
|
2
|
+
// pure functions for the browser and for Node.
|
|
3
|
+
//
|
|
4
|
+
// A deck's ink (its drawings) lives in `<deck>.drawings.json` beside `<deck>.md`:
|
|
5
|
+
// { version: 1, width, height, slides: { <slideId>: [stroke, …] } }
|
|
6
|
+
// stroke = { id: '<device>:<n>', tool: 'pen' | 'highlighter', color, size,
|
|
7
|
+
// points: [[x, y, pressure], …] }
|
|
8
|
+
// Points are in the deck's design pixels (width × height). Ink belongs to a
|
|
9
|
+
// slide, not to one of its steps.
|
|
10
|
+
import { getStroke } from 'perfect-freehand'
|
|
11
|
+
|
|
12
|
+
export const INK_VERSION = 1
|
|
13
|
+
export const INK_TOOLS = ['pen', 'highlighter']
|
|
14
|
+
const COLOR_RE = /^#[0-9a-f]{3,8}$|^[a-z]+$/i
|
|
15
|
+
|
|
16
|
+
/** The drawings file that belongs to a deck file. */
|
|
17
|
+
export const inkFileFor = deckPath => String(deckPath).replace(/\.md$/i, '') + '.drawings.json'
|
|
18
|
+
|
|
19
|
+
/** Where drawings were kept before 2.0; nothing reads it any more. */
|
|
20
|
+
export const oldInkFileFor = deckPath => String(deckPath).replace(/\.md$/i, '') + '.ink.json'
|
|
21
|
+
|
|
22
|
+
export function emptyInk({ width = 1920, height = 1080 } = {}) {
|
|
23
|
+
return { version: INK_VERSION, width, height, slides: {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const round = (n, digits = 0) => { const f = 10 ** digits; return Math.round(n * f) / f }
|
|
27
|
+
|
|
28
|
+
function cleanStroke(stroke, scaleX = 1, scaleY = 1) {
|
|
29
|
+
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points)) return null
|
|
30
|
+
const points = stroke.points
|
|
31
|
+
.filter(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))
|
|
32
|
+
.map(([x, y, pressure = 0.5]) => [round(x * scaleX), round(y * scaleY), round(Math.min(1, Math.max(0, Number(pressure) || 0.5)), 2)])
|
|
33
|
+
if (!points.length) return null
|
|
34
|
+
return {
|
|
35
|
+
id: String(stroke.id ?? ''),
|
|
36
|
+
tool: INK_TOOLS.includes(stroke.tool) ? stroke.tool : 'pen',
|
|
37
|
+
color: typeof stroke.color === 'string' && COLOR_RE.test(stroke.color) ? stroke.color : '#e11d48',
|
|
38
|
+
size: Math.min(80, Math.max(1, Number(stroke.size) || 6)) * Math.sqrt(scaleX * scaleY),
|
|
39
|
+
points,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A usable ink object from anything read from disk or sent by a client:
|
|
45
|
+
* unknown fields dropped, strokes without points skipped, and points scaled
|
|
46
|
+
* when the deck's design size differs from the one the ink was drawn at.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeInk(raw, { width = 1920, height = 1080 } = {}) {
|
|
49
|
+
const ink = emptyInk({ width, height })
|
|
50
|
+
if (!raw || typeof raw !== 'object') return ink
|
|
51
|
+
const scaleX = Number(raw.width) > 0 ? width / raw.width : 1
|
|
52
|
+
const scaleY = Number(raw.height) > 0 ? height / raw.height : 1
|
|
53
|
+
for (const [slideId, strokes] of Object.entries(raw.slides ?? {})) {
|
|
54
|
+
if (!Array.isArray(strokes)) continue
|
|
55
|
+
const clean = strokes.map(stroke => cleanStroke(stroke, scaleX, scaleY)).filter(Boolean)
|
|
56
|
+
if (clean.length) ink.slides[slideId] = clean
|
|
57
|
+
}
|
|
58
|
+
return ink
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Problems with a raw ink file, as { message } objects; empty when it is fine. */
|
|
62
|
+
export function validateInk(raw) {
|
|
63
|
+
const problems = []
|
|
64
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ message: 'The drawings file must hold a JSON object' }]
|
|
65
|
+
if (raw.version !== INK_VERSION) problems.push({ message: `Unknown drawings file version ${raw.version}; expected ${INK_VERSION}` })
|
|
66
|
+
if (raw.slides != null && (typeof raw.slides !== 'object' || Array.isArray(raw.slides))) problems.push({ message: 'slides must map slide ids to lists of strokes' })
|
|
67
|
+
for (const [slideId, strokes] of Object.entries(raw.slides ?? {})) {
|
|
68
|
+
if (!Array.isArray(strokes)) { problems.push({ message: `Ink for slide "${slideId}" is not a list` }); continue }
|
|
69
|
+
strokes.forEach((stroke, i) => { if (!cleanStroke(stroke)) problems.push({ message: `Stroke ${i + 1} on slide "${slideId}" has no points` }) })
|
|
70
|
+
}
|
|
71
|
+
return problems
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Slide ids that have ink but are not in the deck any more. */
|
|
75
|
+
export function orphanIds(ink, slides) {
|
|
76
|
+
const ids = new Set(slides.map(slide => slide.id))
|
|
77
|
+
return Object.keys(ink?.slides ?? {}).filter(id => !ids.has(id))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Applies one change and returns a new ink object; applying the same change
|
|
82
|
+
* twice has no further effect.
|
|
83
|
+
* { type: 'add', slideId, stroke } adds, or replaces the stroke with that id
|
|
84
|
+
* { type: 'remove', slideId, ids } removes those strokes
|
|
85
|
+
* { type: 'clear', slideId, ids } the same, for the strokes a device knew about
|
|
86
|
+
* { type: 'rename', from, to } moves a slide's ink to a new id
|
|
87
|
+
*/
|
|
88
|
+
export function applyOp(ink, op) {
|
|
89
|
+
const next = { ...ink, slides: { ...ink.slides } }
|
|
90
|
+
if (op?.type === 'add') {
|
|
91
|
+
const stroke = cleanStroke(op.stroke)
|
|
92
|
+
if (!stroke || !stroke.id || !op.slideId) return ink
|
|
93
|
+
const list = (next.slides[op.slideId] ?? []).filter(s => s.id !== stroke.id)
|
|
94
|
+
next.slides[op.slideId] = [...list, stroke]
|
|
95
|
+
} else if (op?.type === 'remove' || op?.type === 'clear') {
|
|
96
|
+
const gone = new Set(op.ids ?? [])
|
|
97
|
+
const list = (next.slides[op.slideId] ?? []).filter(stroke => !gone.has(stroke.id))
|
|
98
|
+
if (list.length) next.slides[op.slideId] = list
|
|
99
|
+
else delete next.slides[op.slideId]
|
|
100
|
+
} else if (op?.type === 'rename') {
|
|
101
|
+
if (!op.from || !op.to || op.from === op.to || !next.slides[op.from]) return ink
|
|
102
|
+
// A stroke already under the new id (the same change heard twice, say
|
|
103
|
+
// from a window that replays the room's history) is not doubled.
|
|
104
|
+
const known = new Set((next.slides[op.to] ?? []).map(stroke => stroke.id))
|
|
105
|
+
next.slides[op.to] = [...(next.slides[op.to] ?? []), ...next.slides[op.from].filter(stroke => !known.has(stroke.id))]
|
|
106
|
+
delete next.slides[op.from]
|
|
107
|
+
} else {
|
|
108
|
+
return ink
|
|
109
|
+
}
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ramer–Douglas–Peucker on x/y, keeping each kept point's pressure.
|
|
114
|
+
function distanceToSegment([px, py], [ax, ay], [bx, by]) {
|
|
115
|
+
const dx = bx - ax, dy = by - ay
|
|
116
|
+
const length = dx * dx + dy * dy
|
|
117
|
+
const t = length ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length)) : 0
|
|
118
|
+
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
|
119
|
+
}
|
|
120
|
+
/** Fewer points for a finished stroke, within `tolerance` design pixels. */
|
|
121
|
+
export function simplify(points, tolerance = 0.5) {
|
|
122
|
+
if (points.length < 3) return points.slice()
|
|
123
|
+
let farthest = 0, index = 0
|
|
124
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
125
|
+
const d = distanceToSegment(points[i], points[0], points[points.length - 1])
|
|
126
|
+
if (d > farthest) { farthest = d; index = i }
|
|
127
|
+
}
|
|
128
|
+
if (farthest <= tolerance) return [points[0], points[points.length - 1]]
|
|
129
|
+
return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** True when `point` lies within `radius` design pixels of the stroke. */
|
|
133
|
+
export function hitTest(stroke, point, radius = 12) {
|
|
134
|
+
const reach = radius + (stroke.size ?? 6) / 2
|
|
135
|
+
const points = stroke.points
|
|
136
|
+
if (points.length === 1) return Math.hypot(point[0] - points[0][0], point[1] - points[0][1]) <= reach
|
|
137
|
+
for (let i = 1; i < points.length; i++) if (distanceToSegment(point, points[i - 1], points[i]) <= reach) return true
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TOOL_OPTIONS = {
|
|
142
|
+
pen: { thinning: 0.6, smoothing: 0.5, streamline: 0.3 },
|
|
143
|
+
highlighter: { thinning: 0, smoothing: 0.6, streamline: 0.4, start: { cap: false }, end: { cap: false } },
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The SVG path of a stroke's outline. Pen strokes follow the pen's pressure;
|
|
148
|
+
* strokes drawn with a finger or mouse (constant pressure) get simulated
|
|
149
|
+
* pressure from their speed.
|
|
150
|
+
*/
|
|
151
|
+
export function strokePath(stroke, { last = true } = {}) {
|
|
152
|
+
// A straight line (two points) keeps its pressure: simulated from speed,
|
|
153
|
+
// its one long segment would come out hairline thin.
|
|
154
|
+
const constant = stroke.points.length > 2 && stroke.points.every(point => point[2] === stroke.points[0][2])
|
|
155
|
+
// `last`: a finished stroke runs right to its final point instead of
|
|
156
|
+
// trailing behind it, as streamline does for a stroke still being drawn.
|
|
157
|
+
const outline = getStroke(stroke.points, { size: stroke.size, simulatePressure: constant, last, ...TOOL_OPTIONS[stroke.tool] ?? TOOL_OPTIONS.pen })
|
|
158
|
+
if (!outline.length) return ''
|
|
159
|
+
const d = outline.reduce((acc, [x0, y0], i, all) => {
|
|
160
|
+
const [x1, y1] = all[(i + 1) % all.length]
|
|
161
|
+
acc.push(round(x0, 1), round(y0, 1), round((x0 + x1) / 2, 1), round((y0 + y1) / 2, 1))
|
|
162
|
+
return acc
|
|
163
|
+
}, ['M', ...outline[0].map(n => round(n, 1)), 'Q'])
|
|
164
|
+
return d.join(' ') + ' Z'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The ink file's text: one stroke per line, so it reads well in a diff. */
|
|
168
|
+
export function serializeInk(ink) {
|
|
169
|
+
const slides = Object.entries(ink.slides ?? {}).filter(([, strokes]) => strokes.length)
|
|
170
|
+
const body = slides.map(([id, strokes]) => ` ${JSON.stringify(id)}: [\n${strokes.map(stroke => ' ' + JSON.stringify(stroke)).join(',\n')}\n ]`).join(',\n')
|
|
171
|
+
return `{\n "version": ${INK_VERSION},\n "width": ${ink.width},\n "height": ${ink.height},\n "slides": {${body ? '\n' + body + '\n ' : ''}}\n}\n`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The CSS colour a stroke is drawn with: "accent" is the theme's accent. */
|
|
175
|
+
// The pen's own colours follow the slide's appearance: on dark slides red,
|
|
176
|
+
// blue and green are lighter (--pen-* in DARK_TOKENS) and black turns into
|
|
177
|
+
// the palette's text colour, so a drawing stays readable when a talk
|
|
178
|
+
// switches to dark. "accent" is the theme's accent colour.
|
|
179
|
+
const PEN_PAINT = {
|
|
180
|
+
accent: 'var(--accent, #e11d48)',
|
|
181
|
+
'#e11d48': 'var(--pen-red, #e11d48)',
|
|
182
|
+
'#2563eb': 'var(--pen-blue, #2563eb)',
|
|
183
|
+
'#16a34a': 'var(--pen-green, #16a34a)',
|
|
184
|
+
'#111111': 'var(--ink, #111111)',
|
|
185
|
+
}
|
|
186
|
+
export const inkPaint = color => PEN_PAINT[color] ?? color
|
|
187
|
+
|
|
188
|
+
/** Strokes in drawing order: highlighter below the pen, as on paper. */
|
|
189
|
+
export const inkOrder = strokes => [...strokes.filter(s => s.tool === 'highlighter'), ...strokes.filter(s => s.tool !== 'highlighter')]
|
|
190
|
+
|
|
191
|
+
/** The stroke moved by dx, dy design pixels; the same id, so it replaces the original. */
|
|
192
|
+
export const translateStroke = (stroke, dx, dy) => ({ ...stroke, points: stroke.points.map(([x, y, p]) => [x + dx, y + dy, p]) })
|
|
193
|
+
|
|
194
|
+
/** Whether a point lies inside a polygon (ray casting). */
|
|
195
|
+
export function insidePolygon([x, y], polygon) {
|
|
196
|
+
let inside = false
|
|
197
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
198
|
+
const [ax, ay] = polygon[i], [bx, by] = polygon[j]
|
|
199
|
+
if ((ay > y) !== (by > y) && x < (bx - ax) * (y - ay) / (by - ay) + ax) inside = !inside
|
|
200
|
+
}
|
|
201
|
+
return inside
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Strokes with at least half their points inside a lasso. */
|
|
205
|
+
export const strokesInLasso = (strokes, lasso) => lasso.length < 3 ? []
|
|
206
|
+
: strokes.filter(stroke => stroke.points.filter(point => insidePolygon(point, lasso)).length * 2 >= stroke.points.length)
|
|
207
|
+
|
|
208
|
+
/** [x0, y0, x1, y1] around strokes, widened by `pad` and half their size; null for none. */
|
|
209
|
+
export function strokesBox(strokes, pad = 0) {
|
|
210
|
+
let box = null
|
|
211
|
+
for (const stroke of strokes) {
|
|
212
|
+
const reach = pad + (stroke.size ?? 6) / 2
|
|
213
|
+
for (const [x, y] of stroke.points) {
|
|
214
|
+
box = box ? [Math.min(box[0], x - reach), Math.min(box[1], y - reach), Math.max(box[2], x + reach), Math.max(box[3], y + reach)] : [x - reach, y - reach, x + reach, y + reach]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return box
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Opacity the SVG layer draws a tool with. */
|
|
221
|
+
export const toolOpacity = tool => tool === 'highlighter' ? 0.35 : 1
|