mdeck 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,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
+ }
@@ -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