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,132 @@
1
+ // Drawing on a deck: turns the stage's ink events into changes of the ink,
2
+ // with undo and redo of this device's own changes; `publish(op)` passes each
3
+ // change on to the other windows (bus.js). Every change goes to
4
+ // `save(op)` when a server keeps the ink file; otherwise it is kept in this
5
+ // browser (localStorage) and the file can be downloaded.
6
+ import { currentInk, changeInk, slideStrokes } from './store.js'
7
+ import { simplify, hitTest, serializeInk } from '../../core/ink.js'
8
+ import { inkFileName } from 'virtual:deck-ink'
9
+
10
+ function deviceId() {
11
+ try {
12
+ let id = localStorage.getItem('mdeck-ink-device')
13
+ if (!id) localStorage.setItem('mdeck-ink-device', id = Math.random().toString(36).slice(2, 8))
14
+ return id
15
+ } catch { return (deviceId.fallback ??= Math.random().toString(36).slice(2, 8)) }
16
+ }
17
+
18
+ export const readLog = key => { try { return JSON.parse(localStorage.getItem(key) ?? '[]') } catch { return [] } }
19
+ const writeLog = (key, log) => { try { localStorage.setItem(key, JSON.stringify(log)); return true } catch { return false } }
20
+
21
+ export function createInkController({ storageKey, save = null, publish = () => {} } = {}) {
22
+ const device = deviceId()
23
+ let counter = 0
24
+ const undoStack = [], redoStack = []
25
+ const listeners = new Set()
26
+ const notify = () => { for (const listener of listeners) listener(state()) }
27
+ let saving = save
28
+
29
+ // Changes made in this browser without a server, from earlier visits too.
30
+ let log = saving ? [] : readLog(storageKey)
31
+ for (const op of log) changeInk(op)
32
+
33
+ const state = () => ({ canUndo: undoStack.length > 0, canRedo: redoStack.length > 0, unsaved: !saving && log.length > 0, saving: !!saving })
34
+
35
+ function send(ops) {
36
+ // Another window of this deck may have added to the log meanwhile.
37
+ if (!saving) log = readLog(storageKey)
38
+ for (const op of ops) {
39
+ changeInk(op)
40
+ publish(op)
41
+ if (saving) saving(op)
42
+ else log.push(op)
43
+ }
44
+ if (!saving) writeLog(storageKey, log)
45
+ }
46
+
47
+ // A history entry is the list of changes that undoes it. Changes with the
48
+ // same `gesture` (one stroke of the eraser) are one entry.
49
+ function commit(ops, inverse, gesture = null) {
50
+ if (!ops.length) return
51
+ send(ops)
52
+ const last = undoStack[undoStack.length - 1]
53
+ if (gesture != null && last?.gesture === gesture) {
54
+ last.ops.push(...ops)
55
+ last.inverse.unshift(...inverse)
56
+ } else undoStack.push({ ops, inverse, gesture })
57
+ redoStack.length = 0
58
+ notify()
59
+ }
60
+
61
+ return {
62
+ device,
63
+ subscribe(listener) { listeners.add(listener); listener(state()); return () => listeners.delete(listener) },
64
+ /** Changes kept in this browser, not saved anywhere yet. */
65
+ localChanges: () => log.slice(),
66
+ /** Tells the other windows about a change already made here (a slide's new id). */
67
+ share(op) { publish(op) },
68
+ /** Sends a change without adding it to the undo history. */
69
+ replay(op) { send([op]) },
70
+ /** From here on, changes go to `save(op)` and the local copy is dropped. */
71
+ useServer(saveOp) {
72
+ saving = saveOp
73
+ log = []
74
+ try { localStorage.removeItem(storageKey) } catch {}
75
+ notify()
76
+ },
77
+
78
+ addStroke({ slideId, tool, color, size, points }) {
79
+ const stroke = { id: `${device}:${Date.now().toString(36)}${(counter++).toString(36)}`, tool, color, size, points: simplify(points, 0.5) }
80
+ commit([{ type: 'add', slideId, stroke }], [{ type: 'remove', slideId, ids: [stroke.id] }])
81
+ },
82
+
83
+ erase({ slideId, point, radius, gesture = null }) {
84
+ const hits = slideStrokes(slideId).filter(stroke => hitTest(stroke, point, radius))
85
+ if (!hits.length) return
86
+ commit([{ type: 'remove', slideId, ids: hits.map(stroke => stroke.id) }], hits.map(stroke => ({ type: 'add', slideId, stroke })), gesture == null ? null : `erase:${gesture}`)
87
+ },
88
+
89
+ /** Puts changed copies of strokes (the same ids) in place of the originals: moving a selection. */
90
+ replaceStrokes({ slideId, before, after }) {
91
+ if (!after.length) return
92
+ commit(after.map(stroke => ({ type: 'add', slideId, stroke })), before.map(stroke => ({ type: 'add', slideId, stroke })))
93
+ },
94
+
95
+ removeStrokes({ slideId, strokes }) {
96
+ if (!strokes.length) return
97
+ commit([{ type: 'remove', slideId, ids: strokes.map(stroke => stroke.id) }], strokes.map(stroke => ({ type: 'add', slideId, stroke })))
98
+ },
99
+
100
+ clearSlide(slideId) {
101
+ const strokes = slideStrokes(slideId)
102
+ if (!strokes.length) return
103
+ commit([{ type: 'clear', slideId, ids: strokes.map(stroke => stroke.id) }], strokes.map(stroke => ({ type: 'add', slideId, stroke })))
104
+ },
105
+
106
+ undo() {
107
+ const entry = undoStack.pop()
108
+ if (!entry) return
109
+ send(entry.inverse)
110
+ redoStack.push(entry)
111
+ notify()
112
+ },
113
+
114
+ redo() {
115
+ const entry = redoStack.pop()
116
+ if (!entry) return
117
+ send(entry.ops)
118
+ undoStack.push(entry)
119
+ notify()
120
+ },
121
+
122
+ /** Saves the ink as its file, for putting it next to the deck. */
123
+ download() {
124
+ const blob = new Blob([serializeInk(currentInk())], { type: 'application/json' })
125
+ const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: inkFileName })
126
+ document.body.appendChild(link)
127
+ link.click()
128
+ link.remove()
129
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000)
130
+ },
131
+ }
132
+ }
@@ -0,0 +1,101 @@
1
+ // The talk across devices, through the deck's stage room on the server
2
+ // (built into `mdeck run`, or `mdeck server`): an iPad draws and steers, the
3
+ // laptop's audience window on the projector follows, and navigation on the
4
+ // laptop reaches the iPad too.
5
+ //
6
+ // Main slide frames and audience windows share one connection for ink and
7
+ // position. Next-slide previews do not connect, since browsers allow few
8
+ // connections per server.
9
+ import { stageRoom } from '../../live/client.js'
10
+ import { presenceQuery } from '../../live/presence.js'
11
+
12
+ const BATCH_MS = 80
13
+
14
+ /**
15
+ * An ink bus transport through the stage room; `listen` only when `receive`.
16
+ * `view` counts this window as connected; `onPresence` hears who else is.
17
+ */
18
+ export function roomTransport({ receive = false, view = null, onPresence = null } = {}) {
19
+ const room = stageRoom()
20
+ let queue = [], timer = null, allowed = null, listeners = 1, source = null
21
+
22
+ async function flush() {
23
+ timer = null
24
+ if (!queue.length) return
25
+ const messages = queue.splice(0, 100)
26
+ if (allowed === null) {
27
+ const info = await room.info()
28
+ allowed = info.reachable ? !!info.canReset : null
29
+ if (!allowed) { queue = []; return }
30
+ }
31
+ try {
32
+ const response = await fetch(`${room.url}/ink`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ messages }) })
33
+ if (response.status === 403) { allowed = false; queue = []; return }
34
+ if (response.ok) listeners = (await response.json()).listeners ?? 1
35
+ } catch {}
36
+ if (queue.length) timer = setTimeout(flush, BATCH_MS)
37
+ }
38
+
39
+ return {
40
+ send(message) {
41
+ if (allowed === false) return
42
+ // Nobody on another device watches: strokes in progress can stay here.
43
+ // Strokes in progress, the laser's dot and the zoom only matter to
44
+ // someone watching.
45
+ const live = message.type === 'segment' || message.type === 'dot' || message.type === 'zoom'
46
+ if (live && listeners === 0) return
47
+ queue.push(message)
48
+ timer ??= setTimeout(flush, live ? BATCH_MS : 0)
49
+ },
50
+ listen(handler) {
51
+ if (!receive || typeof EventSource === 'undefined') return
52
+ source = new EventSource(`${room.url}/events?${presenceQuery({ view, listen: !!onPresence })}`)
53
+ source.addEventListener('snapshot', event => {
54
+ const snapshot = JSON.parse(event.data)
55
+ for (const message of snapshot.ink ?? []) handler(message)
56
+ if (snapshot.presence) onPresence?.(snapshot.presence)
57
+ })
58
+ if (onPresence) source.addEventListener('presence', event => onPresence(JSON.parse(event.data)))
59
+ source.addEventListener('ink', event => { for (const message of JSON.parse(event.data).messages) handler(message) })
60
+ for (const listener of stateListeners) source.addEventListener('snapshot', event => listener(JSON.parse(event.data).state))
61
+ for (const listener of stateListeners) source.addEventListener('state', event => listener(JSON.parse(event.data).state))
62
+ },
63
+ close() { source?.close() },
64
+ }
65
+ }
66
+
67
+ // Position followers, attached before the transport listens.
68
+ const stateListeners = new Set()
69
+
70
+ /** Follow navigation from another device in either kind of main slide window. */
71
+ export function followStageRoom(stage) {
72
+ stateListeners.add(state => {
73
+ if (!state || state.screen === stageRoom().screen || !Number.isInteger(state.index)) return
74
+ const current = stage.state
75
+ if (current?.index === state.index && current?.step === state.step) return
76
+ stage.setState({ index: state.index, step: state.step ?? -1, slideId: state.slideId })
77
+ })
78
+ }
79
+
80
+ /** Publish navigation made here; received positions never echo back. */
81
+ export function announceStagePosition(stage) {
82
+ const room = stageRoom()
83
+ let allowed = null, last = null
84
+ async function post(event) {
85
+ const { index, step, slideId } = stage.state
86
+ const key = `${slideId}:${index}:${step}`
87
+ if (event?.detail.reason === 'sync') { last = key; return }
88
+ if (key === last || allowed === false) return
89
+ // Opening a window must not move the projector; only changes count.
90
+ const at = !event || event.detail.reason === 'init' ? 0 : Date.now()
91
+ last = key
92
+ if (allowed === null) {
93
+ const info = await room.info()
94
+ allowed = info.reachable && !!info.canReset
95
+ if (!allowed) return
96
+ }
97
+ fetch(`${room.url}/state`, { method: 'POST', headers: room.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { index, step, slideId, at, screen: room.screen } }) }).catch(() => {})
98
+ }
99
+ stage.addEventListener('statechange', post)
100
+ post()
101
+ }
@@ -0,0 +1,124 @@
1
+ // Selecting and moving drawn strokes. The select tool's lasso (`inkselect`,
2
+ // { phase, slideId, point, points } in design pixels) selects the strokes at
3
+ // least half inside it, its tap the topmost stroke under it; a finger's tap
4
+ // while drawing (`inktap`) selects the stroke under the finger. A selection
5
+ // then takes the pen that lands on it, whatever the tool: inside its box the
6
+ // pen moves it, and a straight line (two points) shows handles at its ends
7
+ // that the pen moves one at a time. Changes show at once in this window and
8
+ // become one change (undo puts the strokes back) when the pen lifts.
9
+ import { slideStrokes, changeInk, onInkChange } from './store.js'
10
+ import { strokesInLasso, strokesBox, translateStroke, hitTest } from '../../core/ink.js'
11
+
12
+ const PAD = 14 // design pixels around a selection
13
+ const TAP = 10 // a lasso smaller than this is a tap
14
+ const HANDLE = 40 // design pixels around an end point that grab it
15
+
16
+ export function createInkSelection(stage, controller) {
17
+ let slideId = null, ids = new Set(), drag = null
18
+ const listeners = new Set()
19
+ const selected = () => slideStrokes(slideId).filter(stroke => ids.has(stroke.id))
20
+ // The end points of a lone straight line.
21
+ const handles = () => { const strokes = selected(); return strokes.length === 1 && strokes[0].points.length === 2 ? strokes[0].points : [] }
22
+
23
+ function show(lasso = null) {
24
+ const strokes = slideId ? selected() : []
25
+ stage.showSelection({ box: strokes.length ? strokesBox(strokes, PAD) : null, lasso, handles: drag?.handle == null ? handles() : [] })
26
+ }
27
+ function select(id, strokes) {
28
+ slideId = id
29
+ ids = new Set(strokes.map(stroke => stroke.id))
30
+ for (const listener of listeners) listener(ids.size)
31
+ show()
32
+ }
33
+ const handleAt = point => handles().findIndex(([x, y]) => Math.hypot(point[0] - x, point[1] - y) <= HANDLE)
34
+ const insideBox = point => {
35
+ const box = ids.size ? strokesBox(selected(), PAD) : null
36
+ return !!box && point[0] >= box[0] && point[0] <= box[2] && point[1] >= box[1] && point[1] <= box[3]
37
+ }
38
+
39
+ // The stage asks before it lets the pen draw.
40
+ stage.inkSelectionHit = point => !!ids.size && stage._inkSlideId?.() === slideId && (handleAt(point) >= 0 || insideBox(point))
41
+ stage.inkSelectionClear = () => clear()
42
+
43
+ stage.addEventListener('inktap', ({ detail }) => {
44
+ const hit = [...slideStrokes(detail.slideId)].reverse().find(stroke => hitTest(stroke, detail.point, detail.radius ?? 22))
45
+ select(detail.slideId, hit ? [hit] : [])
46
+ })
47
+
48
+ stage.addEventListener('inkselect', ({ detail }) => {
49
+ const { phase, point, points } = detail
50
+ if (phase === 'down') {
51
+ const handle = detail.slideId === slideId ? handleAt(point) : -1
52
+ if (handle >= 0) {
53
+ drag = { handle, original: selected()[0] }
54
+ } else if (detail.slideId === slideId && insideBox(point)) {
55
+ drag = { from: point, originals: selected() }
56
+ } else {
57
+ drag = null
58
+ select(detail.slideId, [])
59
+ }
60
+ show()
61
+ } else if (phase === 'move') {
62
+ if (drag?.handle != null) {
63
+ changeInk({ type: 'add', slideId, stroke: moveEnd(drag.original, drag.handle, point) })
64
+ show()
65
+ } else if (drag) {
66
+ const dx = point[0] - drag.from[0], dy = point[1] - drag.from[1]
67
+ for (const stroke of drag.originals) changeInk({ type: 'add', slideId, stroke: translateStroke(stroke, dx, dy) })
68
+ show()
69
+ } else show(points)
70
+ } else if (phase === 'up') {
71
+ if (drag?.handle != null) {
72
+ const { original, handle } = drag
73
+ drag = null
74
+ const moved = moveEnd(original, handle, point)
75
+ if (moved.points[handle][0] !== original.points[handle][0] || moved.points[handle][1] !== original.points[handle][1]) {
76
+ controller.replaceStrokes({ slideId, before: [original], after: [moved] })
77
+ }
78
+ show()
79
+ return
80
+ }
81
+ if (drag) {
82
+ const dx = point[0] - drag.from[0], dy = point[1] - drag.from[1]
83
+ const { originals } = drag
84
+ drag = null
85
+ if (dx || dy) controller.replaceStrokes({ slideId, before: originals, after: originals.map(stroke => translateStroke(stroke, dx, dy)) })
86
+ show()
87
+ return
88
+ }
89
+ const strokes = slideStrokes(detail.slideId)
90
+ const span = strokesBox([{ points, size: 0 }])
91
+ const tap = span[2] - span[0] < TAP && span[3] - span[1] < TAP
92
+ select(detail.slideId, tap ? [...strokes].reverse().filter(stroke => hitTest(stroke, point, 12)).slice(0, 1) : strokesInLasso(strokes, points))
93
+ }
94
+ })
95
+
96
+ // Strokes that went away (eraser, undo, another window) leave the selection.
97
+ onInkChange(() => {
98
+ if (!ids.size || drag) return
99
+ const present = new Set(selected().map(stroke => stroke.id))
100
+ if (present.size !== ids.size) select(slideId, selected())
101
+ else show()
102
+ })
103
+ function clear() { drag = null; if (ids.size || slideId) select(null, []) }
104
+ stage.addEventListener('slidechange', clear)
105
+ stage.addEventListener('inkmode', event => { if (!event.detail.inking) clear() })
106
+
107
+ return {
108
+ /** Calls `listener(count)` whenever the selection changes. */
109
+ subscribe(listener) { listeners.add(listener); listener(ids.size); return () => listeners.delete(listener) },
110
+ clear,
111
+ count: () => ids.size,
112
+ remove() {
113
+ const strokes = selected()
114
+ if (!strokes.length) return
115
+ controller.removeStrokes({ slideId, strokes })
116
+ select(slideId, [])
117
+ },
118
+ }
119
+ }
120
+
121
+ // The stroke with one end point moved to `point` (its pressure kept).
122
+ function moveEnd(stroke, index, [x, y]) {
123
+ return { ...stroke, points: stroke.points.map((p, i) => i === index ? [x, y, p[2]] : p) }
124
+ }
@@ -0,0 +1,75 @@
1
+ // Saving ink through `mdeck run` (src/build/inkPlugin.js). When the page was
2
+ // served by a dev server that lets it save, the controller's changes go
3
+ // there, batched, and changes from other windows arrive as `mdeck:ink`.
4
+ // Anywhere else (a built deck, a device that may not save) this returns false
5
+ // and the ink stays in this browser.
6
+ import { replaceInk, renameSlide } from './store.js'
7
+ import { pairHeaders } from '../../live/pairing.js'
8
+ import { devPath } from '../../core/devPath.js'
9
+
10
+ const BASE = devPath('__mdeck/ink')
11
+
12
+ // A paired iPad sends its token; this computer needs none.
13
+ export async function connectInkServer(controller, { headers = () => pairHeaders() } = {}) {
14
+ let response
15
+ try { response = await fetch(BASE, { cache: 'no-store', headers: headers() }) } catch { return false }
16
+ if (!response.ok) return false
17
+ let { ink, deckHash } = await response.json()
18
+
19
+ // Changes made in this browser before the server was there go to it first.
20
+ const earlier = controller.localChanges()
21
+ replaceInk(ink)
22
+ let queue = [], sending = false, stale = false
23
+
24
+ async function refresh() {
25
+ if (sending || queue.length) { stale = true; return }
26
+ try {
27
+ const fresh = await (await fetch(BASE, { cache: 'no-store', headers: headers() })).json()
28
+ if (sending || queue.length) { stale = true; return }
29
+ deckHash = fresh.deckHash
30
+ replaceInk(fresh.ink)
31
+ } catch {}
32
+ }
33
+
34
+ async function flush() {
35
+ if (sending || !queue.length) return
36
+ sending = true
37
+ const ops = queue
38
+ queue = []
39
+ try {
40
+ const reply = await fetch(`${BASE}/ops`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers() }, body: JSON.stringify({ deckHash, ops }) })
41
+ const body = await reply.json().catch(() => ({}))
42
+ if (reply.ok) {
43
+ deckHash = body.deckHash
44
+ // A slide got its id with this stroke: the audience window and other
45
+ // devices must use it too, or later strokes, the laser and the zoom
46
+ // would name a slide they do not know.
47
+ for (const { from, to } of body.renamed ?? []) { renameSlide(from, to); controller.share({ type: 'rename', from, to }) }
48
+ } else {
49
+ // The deck changed under this page: take the server's ink.
50
+ console.warn('mdeck: ink not saved:', body.error ?? reply.status)
51
+ stale = true
52
+ }
53
+ } catch (error) {
54
+ console.warn('mdeck: ink not saved:', error.message)
55
+ queue = [...ops, ...queue]
56
+ } finally {
57
+ sending = false
58
+ }
59
+ if (queue.length) flush()
60
+ else if (stale) { stale = false; refresh() }
61
+ }
62
+
63
+ const save = op => {
64
+ queue.push(op)
65
+ queueMicrotask(flush)
66
+ }
67
+ controller.useServer(save)
68
+ for (const op of earlier) controller.replay(op)
69
+
70
+ import.meta.hot?.on('mdeck:ink', ({ renamed = [] } = {}) => {
71
+ for (const { from, to } of renamed) renameSlide(from, to)
72
+ refresh()
73
+ })
74
+ return true
75
+ }
@@ -0,0 +1,55 @@
1
+ // The deck's ink in the browser: what the ink file held when the page was
2
+ // built, plus changes made since. Components subscribe to one slide.
3
+ import { useEffect, useState } from 'preact/hooks'
4
+ import bundled from 'virtual:deck-ink'
5
+ import { applyOp } from '../../core/ink.js'
6
+
7
+ let ink = bundled
8
+ const listeners = new Set()
9
+ // Slides that got an id while the page was open: the rendered slides still
10
+ // know their old id (slide-3), the ink uses the new one.
11
+ const aliases = new Map()
12
+ export const resolveSlideId = slideId => aliases.get(slideId) ?? slideId
13
+
14
+ export const currentInk = () => ink
15
+ export const slideStrokes = slideId => ink.slides[resolveSlideId(slideId)] ?? []
16
+
17
+ /** Replaces all ink, for example after the server sent the saved file. */
18
+ export function replaceInk(next) {
19
+ ink = next
20
+ for (const listener of listeners) listener(ink)
21
+ }
22
+
23
+ /** Applies one change (see applyOp in core/ink.js) and tells subscribers. */
24
+ export function changeInk(op) {
25
+ const next = applyOp(ink, op.slideId ? { ...op, slideId: resolveSlideId(op.slideId) } : op)
26
+ if (next !== ink) replaceInk(next)
27
+ return next
28
+ }
29
+
30
+ /** A slide got a new id: its ink and the rendered slide follow. */
31
+ export function renameSlide(from, to) {
32
+ if (!from || !to || from === to) return
33
+ aliases.set(from, to)
34
+ for (const [old, current] of aliases) if (current === from) aliases.set(old, to)
35
+ for (const section of document.querySelectorAll(`[data-slide-id="${CSS.escape(from)}"]`)) section.dataset.slideId = to
36
+ changeInk({ type: 'rename', from, to })
37
+ replaceInk(ink)
38
+ }
39
+
40
+ /** Calls `listener(ink)` after every change; returns the way to stop. */
41
+ export function onInkChange(listener) {
42
+ listeners.add(listener)
43
+ return () => listeners.delete(listener)
44
+ }
45
+
46
+ export function useSlideInk(slideId) {
47
+ const [strokes, setStrokes] = useState(() => slideStrokes(slideId))
48
+ useEffect(() => {
49
+ const listener = () => setStrokes(slideStrokes(slideId))
50
+ listeners.add(listener)
51
+ listener()
52
+ return () => listeners.delete(listener)
53
+ }, [slideId])
54
+ return strokes
55
+ }
@@ -0,0 +1,79 @@
1
+ /* The floating ink toolbar, in the dark chrome of the other tools. It sits
2
+ above the stage and its ink layers, and the stage leaves its input alone. */
3
+ .ink-toolbar {
4
+ position: fixed; left: 0; right: 0; bottom: max(16px, env(safe-area-inset-bottom)); margin: 0 auto; width: fit-content;
5
+ z-index: 2147483000;
6
+ display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
7
+ max-width: calc(100vw - 24px);
8
+ padding: 6px; border: 1px solid #3a3a3a; border-radius: 12px;
9
+ background: rgba(22, 22, 22, 0.94); color: #e6e6e6;
10
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
11
+ font: 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
12
+ /* Taps only: a pinch here must not zoom the page, and the toolbar with it. */
13
+ touch-action: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
14
+ }
15
+ /* Folded: one button in the bottom right corner, with the tool's colour. */
16
+ .ink-toolbar.is-collapsed {
17
+ left: auto; right: max(16px, env(safe-area-inset-right)); margin: 0;
18
+ padding: 4px; border-radius: 50%;
19
+ }
20
+ .ink-toolbar.is-collapsed .ink-btn { border-radius: 50%; position: relative; }
21
+ .ink-expand[style*="--tool-color"]::after {
22
+ content: ''; position: absolute; right: 5px; bottom: 5px; width: 10px; height: 10px; border-radius: 50%;
23
+ background: var(--tool-color); box-shadow: 0 0 0 1.5px rgba(22, 22, 22, 0.94);
24
+ }
25
+ .ink-group { display: flex; align-items: center; gap: 4px; padding: 0 4px; }
26
+ .ink-group + .ink-group { border-left: 1px solid #333; }
27
+ .ink-btn, .ink-color, .ink-size {
28
+ display: grid; place-items: center; width: 40px; height: 40px; padding: 0;
29
+ border: 1px solid transparent; border-radius: 8px; background: none; color: inherit; cursor: pointer;
30
+ -webkit-tap-highlight-color: transparent;
31
+ }
32
+ .ink-btn:hover:not(:disabled), .ink-size:hover { background: #262626; }
33
+ .ink-btn.is-active, .ink-size.is-active { background: #27436a; border-color: #3d6094; color: #fff; }
34
+ .ink-btn:disabled { opacity: 0.35; cursor: default; }
35
+ .ink-color::after { content: ''; width: 22px; height: 22px; border-radius: 50%; background: var(--swatch); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); }
36
+ .ink-color.is-active { border-color: #e6e6e6; }
37
+ .ink-size span { display: block; border-radius: 50%; background: currentColor; }
38
+ /* Narrow windows, such as the presenter view's slide beside the notes: a
39
+ little more compact, so the toolbar stays on one line. */
40
+ @media (max-width: 1000px) { .ink-btn, .ink-color, .ink-size { width: 34px; height: 34px; } .ink-group { gap: 2px; padding: 0 3px; } .ink-toolbar { gap: 4px; padding: 5px; } }
41
+ /* Fingers need bigger targets than a mouse. */
42
+ @media (pointer: coarse) { .ink-btn, .ink-color, .ink-size { width: 44px; height: 44px; } }
43
+ /* The small confirmation popover over the clear button. */
44
+ .ink-anchor { position: relative; display: flex; }
45
+ .ink-pop {
46
+ position: absolute; bottom: calc(100% + 12px); left: 50%; transform: translateX(calc(-50% + var(--shift, 0px)));
47
+ display: flex; align-items: center; gap: 6px; white-space: nowrap;
48
+ padding: 6px 6px 6px 12px; border: 1px solid #3a3a3a; border-radius: 10px;
49
+ background: #1c1c1c; color: #e6e6e6; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
50
+ animation: ink-pop-in 100ms ease-out;
51
+ }
52
+ /* A caret pointing at the button; it stays on the button when the popover is shifted. */
53
+ .ink-pop::after {
54
+ content: ''; position: absolute; top: 100%; left: calc(50% - var(--shift, 0px)); width: 9px; height: 9px;
55
+ margin: -5px 0 0 -5px; transform: rotate(45deg);
56
+ background: #1c1c1c; border: solid #3a3a3a; border-width: 0 1px 1px 0;
57
+ }
58
+ .ink-pop-text { padding-right: 4px; font-size: 13px; color: #cfcfcf; }
59
+ .ink-pop-btn {
60
+ height: 30px; padding: 0 12px; border: 1px solid #3a3a3a; border-radius: 7px;
61
+ background: #262626; color: inherit; font: 500 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif; cursor: pointer;
62
+ -webkit-tap-highlight-color: transparent;
63
+ }
64
+ .ink-pop-btn:hover { background: #303030; }
65
+ .ink-pop-btn.is-danger { background: #e11d48; border-color: #e11d48; color: #fff; }
66
+ .ink-pop-btn.is-danger:hover { background: #f43f5e; border-color: #f43f5e; }
67
+ .ink-pop-btn:focus-visible { outline: 2px solid #6ea0e8; outline-offset: 1px; }
68
+ @keyframes ink-pop-in { from { opacity: 0; margin-bottom: -4px; } }
69
+ @media (pointer: coarse) { .ink-pop-btn { height: 40px; padding: 0 16px; } .ink-pop-text { font-size: 14px; } }
70
+ @media (prefers-reduced-motion: reduce) { .ink-pop { animation: none; } }
71
+ /* While drawing, touches neither pan, zoom the page, rubber-band nor select
72
+ text: the pencil's strokes must not move the page (or, in the presenter
73
+ view, the page around the slide). Two fingers zoom the slide itself (the
74
+ stage's zoom), and taps still reach buttons and the stage. */
75
+ html.is-inking, html.is-inking body {
76
+ overscroll-behavior: none; overflow: hidden; touch-action: none;
77
+ -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
78
+ }
79
+ @media print { .ink-toolbar { display: none !important; } }