@heyramzi/ui 1.54.2 → 2.0.1

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 (363) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +205 -13
  4. package/scripts/sync-shadcn.mjs +2 -0
  5. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  6. package/src/ambient.d.ts +8 -0
  7. package/src/brand/brand.test.ts +67 -0
  8. package/src/brand/index.ts +137 -0
  9. package/src/canvas/README.md +236 -0
  10. package/src/canvas/card/card-frame.svelte +61 -0
  11. package/src/canvas/card/card.css +39 -0
  12. package/src/canvas/card/resize-handles.svelte +119 -0
  13. package/src/canvas/card/resize.ts +33 -0
  14. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  15. package/src/canvas/chrome/chrome.css +231 -0
  16. package/src/canvas/chrome/lock-badge.svelte +18 -0
  17. package/src/canvas/chrome/side-handles.svelte +30 -0
  18. package/src/canvas/chrome/size-chip.svelte +45 -0
  19. package/src/canvas/chrome/snap-guides.svelte +69 -0
  20. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  21. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  22. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  23. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  24. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  25. package/src/canvas/edges/bend-handle.svelte +76 -0
  26. package/src/canvas/edges/edge-gradient.svelte +30 -0
  27. package/src/canvas/edges/edge-path.test.ts +58 -0
  28. package/src/canvas/edges/edge-path.ts +293 -0
  29. package/src/canvas/edges/gradient-edge.svelte +43 -0
  30. package/src/canvas/editor/context-menu.test.ts +65 -0
  31. package/src/canvas/editor/context-menu.ts +48 -0
  32. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  33. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  34. package/src/canvas/editor/editor-context.ts +62 -0
  35. package/src/canvas/editor/editor-mode.ts +67 -0
  36. package/src/canvas/editor/frame.ts +98 -0
  37. package/src/canvas/editor/hand-context.ts +15 -0
  38. package/src/canvas/editor/history-core.test.ts +239 -0
  39. package/src/canvas/editor/history-core.ts +247 -0
  40. package/src/canvas/editor/keymap.test.ts +161 -0
  41. package/src/canvas/editor/keymap.ts +151 -0
  42. package/src/canvas/editor/layout-actions.test.ts +156 -0
  43. package/src/canvas/editor/layout-actions.ts +219 -0
  44. package/src/canvas/editor/lock.test.ts +68 -0
  45. package/src/canvas/editor/lock.ts +87 -0
  46. package/src/canvas/editor/rebase.test.ts +208 -0
  47. package/src/canvas/editor/rebase.ts +272 -0
  48. package/src/canvas/editor/style.ts +184 -0
  49. package/src/canvas/editor/text-field-context.ts +41 -0
  50. package/src/canvas/editor/z-order.test.ts +75 -0
  51. package/src/canvas/editor/z-order.ts +87 -0
  52. package/src/canvas/headless/alignment.test.ts +59 -0
  53. package/src/canvas/headless/alignment.ts +23 -0
  54. package/src/canvas/headless/break-apart-find.ts +418 -0
  55. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  56. package/src/canvas/headless/break-apart-plan.ts +439 -0
  57. package/src/canvas/headless/break-apart-style.ts +106 -0
  58. package/src/canvas/headless/break-apart.ts +244 -0
  59. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  60. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  61. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  62. package/src/canvas/headless/edge-anchors.ts +143 -0
  63. package/src/canvas/headless/edge-memo.test.ts +58 -0
  64. package/src/canvas/headless/edge-memo.ts +32 -0
  65. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  66. package/src/canvas/headless/edge-serialization.ts +32 -0
  67. package/src/canvas/headless/figure-geometry.ts +293 -0
  68. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  69. package/src/canvas/headless/fit-viewport.ts +137 -0
  70. package/src/canvas/headless/grid.test.ts +34 -0
  71. package/src/canvas/headless/grid.ts +94 -0
  72. package/src/canvas/headless/icon-node.test.ts +23 -0
  73. package/src/canvas/headless/icon-node.ts +66 -0
  74. package/src/canvas/headless/index.ts +9 -0
  75. package/src/canvas/headless/linkify.test.ts +23 -0
  76. package/src/canvas/headless/linkify.ts +25 -0
  77. package/src/canvas/headless/pen-marks.ts +133 -0
  78. package/src/canvas/headless/sketch.test.ts +99 -0
  79. package/src/canvas/headless/sketch.ts +302 -0
  80. package/src/canvas/headless/snap-guides.test.ts +61 -0
  81. package/src/canvas/headless/snap-guides.ts +146 -0
  82. package/src/canvas/headless/svg-figure.test.ts +49 -0
  83. package/src/canvas/headless/svg-figure.ts +176 -0
  84. package/src/canvas/headless/table-layout.test.ts +158 -0
  85. package/src/canvas/headless/table-layout.ts +309 -0
  86. package/src/canvas/index.ts +35 -0
  87. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  88. package/src/canvas/lod/lod-switch.svelte +140 -0
  89. package/src/canvas/lod/render-all.svelte.ts +52 -0
  90. package/src/canvas/ops/arrange.ts +108 -0
  91. package/src/canvas/ops/ops.test.ts +98 -0
  92. package/src/canvas/ops/ops.ts +347 -0
  93. package/src/canvas/pen/PenField.svelte +128 -0
  94. package/src/canvas/pen/PenField.test.ts +87 -0
  95. package/src/canvas/pen/PenOverlay.svelte +172 -0
  96. package/src/canvas/pen/ShapeNode.svelte +64 -0
  97. package/src/canvas/pen/TextNode.svelte +97 -0
  98. package/src/canvas/pen/colors.ts +28 -0
  99. package/src/canvas/pen/eraser.ts +46 -0
  100. package/src/canvas/pen/geometry.ts +148 -0
  101. package/src/canvas/pen/host-field.test.svelte +13 -0
  102. package/src/canvas/pen/index.ts +40 -0
  103. package/src/canvas/pen/ops.ts +16 -0
  104. package/src/canvas/pen/pen-session.ts +177 -0
  105. package/src/canvas/pen/pen.css +149 -0
  106. package/src/canvas/pen/plugin.ts +139 -0
  107. package/src/canvas/pen/shapes.ts +104 -0
  108. package/src/canvas/pen/stroke-path.ts +54 -0
  109. package/src/canvas/pen/types.ts +96 -0
  110. package/src/canvas/pen/ui.ts +26 -0
  111. package/src/canvas/plugin.test.ts +45 -0
  112. package/src/canvas/plugin.ts +159 -0
  113. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  114. package/src/canvas/screen/PartView.svelte +176 -0
  115. package/src/canvas/screen/ScreenBody.svelte +142 -0
  116. package/src/canvas/screen/ScreenCard.svelte +32 -0
  117. package/src/canvas/screen/glyphs/button.svg +1 -0
  118. package/src/canvas/screen/glyphs/close.svg +1 -0
  119. package/src/canvas/screen/glyphs/down.svg +1 -0
  120. package/src/canvas/screen/glyphs/header.svg +1 -0
  121. package/src/canvas/screen/glyphs/hills.svg +1 -0
  122. package/src/canvas/screen/glyphs/image.svg +1 -0
  123. package/src/canvas/screen/glyphs/input.svg +1 -0
  124. package/src/canvas/screen/glyphs/list.svg +1 -0
  125. package/src/canvas/screen/glyphs/modal.svg +1 -0
  126. package/src/canvas/screen/glyphs/plus.svg +1 -0
  127. package/src/canvas/screen/glyphs/up.svg +1 -0
  128. package/src/canvas/screen/glyphs.ts +40 -0
  129. package/src/canvas/screen/labels.ts +41 -0
  130. package/src/canvas/screen/parts.ts +164 -0
  131. package/src/canvas/screen/plugin.ts +67 -0
  132. package/src/canvas/screen/screen.css +593 -0
  133. package/src/canvas/screen/types.ts +47 -0
  134. package/src/canvas/screen/ui.ts +13 -0
  135. package/src/canvas/test-utils.ts +18 -0
  136. package/src/canvas/types.ts +53 -0
  137. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  138. package/src/canvas/ui/FrameLayer.svelte +116 -0
  139. package/src/canvas/ui/PenLayer.svelte +39 -0
  140. package/src/canvas/ui/StylePanel.svelte +268 -0
  141. package/src/canvas/ui/canvas-grid.svelte +156 -0
  142. package/src/canvas/ui/canvas-ui.css +350 -0
  143. package/src/canvas/ui/forward-wheel.ts +29 -0
  144. package/src/canvas/ui/icons.ts +124 -0
  145. package/src/canvas/ui/place-panel.ts +80 -0
  146. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  147. package/src/canvas/ui/shortcut-tip.ts +17 -0
  148. package/src/canvas/ui/tools.ts +146 -0
  149. package/src/charts/README.md +104 -0
  150. package/src/charts/ambient.d.ts +4 -0
  151. package/src/charts/animated-value.css +7 -0
  152. package/src/charts/animated-value.svelte +110 -0
  153. package/src/charts/area-chart.css +29 -0
  154. package/src/charts/area-chart.svelte +204 -0
  155. package/src/charts/bar-chart.css +119 -0
  156. package/src/charts/bar-chart.svelte +275 -0
  157. package/src/charts/charts.css +201 -0
  158. package/src/charts/crosshair.svelte +35 -0
  159. package/src/charts/dashboard/chart.ts +5 -0
  160. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  161. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  162. package/src/charts/dashboard/data-panel.svelte +81 -0
  163. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  164. package/src/charts/dashboard/data-table.svelte +278 -0
  165. package/src/charts/dashboard/data-table.ts +26 -0
  166. package/src/charts/dashboard/index.ts +25 -0
  167. package/src/charts/dashboard/panel-grid.svelte +43 -0
  168. package/src/charts/dashboard/period-filter.svelte +53 -0
  169. package/src/charts/dashboard/rating-chip.svelte +35 -0
  170. package/src/charts/dashboard/stat-band.svelte +47 -0
  171. package/src/charts/dashboard/stat-tile.svelte +176 -0
  172. package/src/charts/dashboard/windows.ts +8 -0
  173. package/src/charts/donut-chart.css +56 -0
  174. package/src/charts/donut-chart.svelte +158 -0
  175. package/src/charts/edge-fade.svelte +43 -0
  176. package/src/charts/edge-fade.ts +16 -0
  177. package/src/charts/figure.css +100 -0
  178. package/src/charts/figure.svelte +122 -0
  179. package/src/charts/gauge-chart.css +94 -0
  180. package/src/charts/gauge-chart.svelte +302 -0
  181. package/src/charts/hover-band.test.ts +30 -0
  182. package/src/charts/hover-band.ts +21 -0
  183. package/src/charts/hover-focus.svelte +85 -0
  184. package/src/charts/index.ts +31 -0
  185. package/src/charts/legend.css +136 -0
  186. package/src/charts/legend.svelte +112 -0
  187. package/src/charts/line-chart.css +26 -0
  188. package/src/charts/line-chart.svelte +256 -0
  189. package/src/charts/motion.ts +19 -0
  190. package/src/charts/number-chart.css +179 -0
  191. package/src/charts/number-chart.svelte +187 -0
  192. package/src/charts/primitives/chart-container.svelte +57 -0
  193. package/src/charts/primitives/chart-style.svelte +36 -0
  194. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  195. package/src/charts/primitives/chart-utils.ts +54 -0
  196. package/src/charts/primitives/index.ts +11 -0
  197. package/src/charts/reference-line.css +18 -0
  198. package/src/charts/reference-line.svelte +31 -0
  199. package/src/charts/reveal.svelte +61 -0
  200. package/src/charts/ring-chart.css +104 -0
  201. package/src/charts/ring-chart.svelte +166 -0
  202. package/src/charts/sparkline.css +45 -0
  203. package/src/charts/sparkline.svelte +141 -0
  204. package/src/charts/stat-card.css +105 -0
  205. package/src/charts/stat-card.svelte +267 -0
  206. package/src/charts/time-series-chart.css +28 -0
  207. package/src/charts/time-series-chart.svelte +179 -0
  208. package/src/charts/trend-pill.css +38 -0
  209. package/src/charts/trend-pill.svelte +48 -0
  210. package/src/components/copy/copy-button.svelte +1 -1
  211. package/src/components/docs/doc-page.svelte +1 -1
  212. package/src/components/docs/on-this-page.svelte +2 -2
  213. package/src/components/list-page/empty-state.svelte +1 -1
  214. package/src/components/list-page/filter-popover.svelte +1 -1
  215. package/src/components/list-page/section-label.svelte +1 -1
  216. package/src/components/segmented/segment.svelte +4 -4
  217. package/src/components/theme/theme-provider.svelte +6 -4
  218. package/src/components/toolbar/index.ts +5 -0
  219. package/src/components/toolbar/toolbar.svelte +168 -0
  220. package/src/components/toolbar/toolbar.test.ts +66 -0
  221. package/src/editor/README.md +58 -0
  222. package/src/editor/ambient.d.ts +1 -0
  223. package/src/editor/block-id.ts +77 -0
  224. package/src/editor/block-ops.test.ts +237 -0
  225. package/src/editor/block-ops.ts +157 -0
  226. package/src/editor/content.css +418 -0
  227. package/src/editor/editor-toolbar.svelte +303 -0
  228. package/src/editor/editor.svelte +469 -0
  229. package/src/editor/extensions.ts +145 -0
  230. package/src/editor/image-toolbar.svelte +123 -0
  231. package/src/editor/index.ts +17 -0
  232. package/src/editor/inline-editor.svelte +261 -0
  233. package/src/editor/markdown-escaping.test.ts +158 -0
  234. package/src/editor/markdown-marks.test.ts +89 -0
  235. package/src/editor/markdown-test-helpers.ts +13 -0
  236. package/src/editor/markdown-youtube.test.ts +93 -0
  237. package/src/editor/markdown.test.ts +250 -0
  238. package/src/editor/markdown.ts +511 -0
  239. package/src/editor/renderer.svelte +92 -0
  240. package/src/editor/serializer.test.ts +133 -0
  241. package/src/editor/serializer.ts +370 -0
  242. package/src/editor/slash-items.ts +115 -0
  243. package/src/editor/slash-menu.svelte +159 -0
  244. package/src/editor/youtube-url.ts +35 -0
  245. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  246. package/src/gallery.test.ts +20 -1
  247. package/src/lib/result.ts +19 -0
  248. package/src/onboarding/README.md +120 -0
  249. package/src/onboarding/define-tours.test.ts +50 -0
  250. package/src/onboarding/define-tours.ts +31 -0
  251. package/src/onboarding/demo.svelte +86 -0
  252. package/src/onboarding/index.ts +12 -0
  253. package/src/onboarding/onboarding-provider.svelte +120 -0
  254. package/src/onboarding/onboarding.svelte.ts +24 -0
  255. package/src/onboarding/pick.test.ts +79 -0
  256. package/src/onboarding/pick.ts +36 -0
  257. package/src/onboarding/stores.test.ts +103 -0
  258. package/src/onboarding/stores.ts +58 -0
  259. package/src/onboarding/tour-card.svelte +70 -0
  260. package/src/onboarding/tour-runner.svelte +197 -0
  261. package/src/onboarding/types.ts +34 -0
  262. package/src/onboarding/wait.ts +29 -0
  263. package/src/shadcn/command/command-input.svelte +1 -1
  264. package/src/slides/Controls.svelte +138 -0
  265. package/src/slides/Deck.svelte +271 -0
  266. package/src/slides/Fragment.svelte +71 -0
  267. package/src/slides/Overview.svelte +113 -0
  268. package/src/slides/Presenter.svelte +292 -0
  269. package/src/slides/README.md +89 -0
  270. package/src/slides/Slide.svelte +179 -0
  271. package/src/slides/SlideList.svelte +19 -0
  272. package/src/slides/ambient.d.ts +1 -0
  273. package/src/slides/bridges.ts +69 -0
  274. package/src/slides/deck-controller.svelte.ts +292 -0
  275. package/src/slides/demo-slides.svelte +358 -0
  276. package/src/slides/demo.svelte +176 -0
  277. package/src/slides/export/capture.ts +113 -0
  278. package/src/slides/export/ffmpeg.ts +120 -0
  279. package/src/slides/export/index.ts +9 -0
  280. package/src/slides/export/launch.ts +10 -0
  281. package/src/slides/export/pdf.ts +152 -0
  282. package/src/slides/export/types.ts +48 -0
  283. package/src/slides/index.ts +19 -0
  284. package/src/slides/labels.ts +48 -0
  285. package/src/slides/layout.ts +51 -0
  286. package/src/slides/model.ts +47 -0
  287. package/src/slides/navigation.ts +100 -0
  288. package/src/slides/theme.css +205 -0
  289. package/src/slides/types.ts +86 -0
  290. package/src/tasks/README.md +312 -0
  291. package/src/tasks/ambient.d.ts +3 -0
  292. package/src/tasks/board-order.ts +137 -0
  293. package/src/tasks/dates.ts +31 -0
  294. package/src/tasks/define-statuses.ts +32 -0
  295. package/src/tasks/demo.svelte +389 -0
  296. package/src/tasks/detail/detail-demo.svelte +186 -0
  297. package/src/tasks/detail/detail-types.ts +54 -0
  298. package/src/tasks/detail/task-chip.svelte +28 -0
  299. package/src/tasks/detail/task-detail.svelte +315 -0
  300. package/src/tasks/detail/task-property.svelte +17 -0
  301. package/src/tasks/detail/task-thread.svelte +258 -0
  302. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  303. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  304. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  305. package/src/tasks/gantt/gantt-header.svelte +71 -0
  306. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  307. package/src/tasks/gantt/gantt.css +22 -0
  308. package/src/tasks/gantt/gantt.test.ts +77 -0
  309. package/src/tasks/gantt/logic.test.ts +163 -0
  310. package/src/tasks/gantt/logic.ts +248 -0
  311. package/src/tasks/inbox/inbox-card.svelte +283 -0
  312. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  313. package/src/tasks/inbox/inbox-logic.ts +55 -0
  314. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  315. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  316. package/src/tasks/inbox/inbox-types.ts +108 -0
  317. package/src/tasks/inbox/inbox.svelte +203 -0
  318. package/src/tasks/inbox/index.ts +23 -0
  319. package/src/tasks/index.ts +85 -0
  320. package/src/tasks/kanban-board.svelte +561 -0
  321. package/src/tasks/kanban-column.svelte +117 -0
  322. package/src/tasks/kanban-drag.ts +38 -0
  323. package/src/tasks/kanban-types.ts +65 -0
  324. package/src/tasks/presets/sales.ts +16 -0
  325. package/src/tasks/record/demo-data.ts +286 -0
  326. package/src/tasks/record/field-section.svelte +53 -0
  327. package/src/tasks/record/fields.ts +142 -0
  328. package/src/tasks/record/format.ts +87 -0
  329. package/src/tasks/record/gutter.ts +2 -0
  330. package/src/tasks/record/index.ts +54 -0
  331. package/src/tasks/record/info-list.svelte +316 -0
  332. package/src/tasks/record/kinds.ts +42 -0
  333. package/src/tasks/record/logic.ts +324 -0
  334. package/src/tasks/record/next-up.svelte +70 -0
  335. package/src/tasks/record/project-list.svelte +133 -0
  336. package/src/tasks/record/record-demo.svelte +324 -0
  337. package/src/tasks/record/record-header.svelte +70 -0
  338. package/src/tasks/record/record-shell.svelte +138 -0
  339. package/src/tasks/record/record-stats.svelte +48 -0
  340. package/src/tasks/record/record-timeline.svelte +231 -0
  341. package/src/tasks/record/record.test.ts +350 -0
  342. package/src/tasks/record/steps-rail.svelte +474 -0
  343. package/src/tasks/record/types.ts +280 -0
  344. package/src/tasks/record/where-we-are.svelte +121 -0
  345. package/src/tasks/reorder.ts +19 -0
  346. package/src/tasks/side-panel-types.ts +16 -0
  347. package/src/tasks/side-panel.svelte +193 -0
  348. package/src/tasks/status-badge.svelte +63 -0
  349. package/src/tasks/status-dot.svelte +228 -0
  350. package/src/tasks/status-icon.svelte +30 -0
  351. package/src/tasks/status-types.ts +40 -0
  352. package/src/tasks/task-gantt.svelte +230 -0
  353. package/src/tasks/task-list.svelte +470 -0
  354. package/src/tasks/task-types.ts +33 -0
  355. package/src/tasks/time/duration.ts +69 -0
  356. package/src/tasks/time/time-chip.svelte +62 -0
  357. package/src/tasks/time/time-dot.svelte +58 -0
  358. package/src/tasks/time/time-ring.svelte +74 -0
  359. package/src/tasks/time/time-tracker.svelte +565 -0
  360. package/src/tasks/time/time-types.ts +74 -0
  361. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  362. package/src/tokens/base.css +5 -5
  363. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import type { CanvasNode } from "../types.js";
3
+ import { diagramBounds, fitViewport, viewShowsContent } from "./fit-viewport.js";
4
+
5
+ function node(id: string, x: number, y: number, size?: Partial<CanvasNode>): CanvasNode {
6
+ return { id, position: { x, y }, data: {}, ...size } as CanvasNode;
7
+ }
8
+
9
+ describe("diagramBounds", () => {
10
+ it("spans every top-level node, size read off the style object", () => {
11
+ const bounds = diagramBounds([
12
+ node("a", 0, 0, { style: { width: 520, height: 1484 } }),
13
+ node("b", 1600, 0, { style: { width: 460, height: 446 } }),
14
+ ]);
15
+ expect(bounds).toEqual({ x: 0, y: 0, width: 2060, height: 1484 });
16
+ });
17
+
18
+ it("ignores children, whose positions are relative to their parent", () => {
19
+ const bounds = diagramBounds([
20
+ node("parent", 0, 0, { style: { width: 500, height: 500 } }),
21
+ node("child", 20, 20, { parentId: "parent", style: { width: 460, height: 200 } }),
22
+ ]);
23
+ expect(bounds).toEqual({ x: 0, y: 0, width: 500, height: 500 });
24
+ });
25
+
26
+ it("returns null when there is nothing to frame", () => {
27
+ expect(diagramBounds([])).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe("fitViewport", () => {
32
+ it("scales to the tighter axis and centres what is left over", () => {
33
+ const nodes = [node("a", 0, 0, { style: { width: 1000, height: 500 } })];
34
+ const viewport = fitViewport(nodes, { width: 500, height: 500 }, 0);
35
+
36
+ expect(viewport?.zoom).toBe(0.5);
37
+ expect(viewport?.x).toBe(0);
38
+ // 500 tall at zoom 0.5 is 250, so 125 of slack above and below.
39
+ expect(viewport?.y).toBe(125);
40
+ });
41
+
42
+ it("pulls a negative origin back into frame", () => {
43
+ const nodes = [node("a", -280, 120, { style: { width: 1000, height: 500 } })];
44
+ const viewport = fitViewport(nodes, { width: 1000, height: 500 }, 0);
45
+
46
+ expect(viewport?.zoom).toBe(1);
47
+ expect(viewport?.x).toBe(280);
48
+ expect(viewport?.y).toBe(-120);
49
+ });
50
+
51
+ it("honours padding as a fraction of the container", () => {
52
+ const nodes = [node("a", 0, 0, { style: { width: 1000, height: 1000 } })];
53
+ expect(fitViewport(nodes, { width: 500, height: 500 }, 0.1)?.zoom).toBeCloseTo(0.4);
54
+ });
55
+
56
+ it("returns null when the container has no size yet", () => {
57
+ const nodes = [node("a", 0, 0, { style: { width: 100, height: 100 } })];
58
+ expect(fitViewport(nodes, { width: 0, height: 0 })).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("viewShowsContent", () => {
63
+ const nodes = [{ position: { x: 0, y: 0 }, width: 400, height: 200 }];
64
+ const pane = { width: 1000, height: 700 };
65
+
66
+ it("accepts a view that frames the board", () => {
67
+ expect(viewShowsContent(nodes, { x: 200, y: 100, zoom: 1 }, pane, { left: 88 })).toBe(true);
68
+ });
69
+
70
+ it("rejects a view with no card on screen", () => {
71
+ expect(viewShowsContent(nodes, { x: -5000, y: 0, zoom: 1 }, pane)).toBe(false);
72
+ });
73
+
74
+ it("rejects a board that fits but sits cut off on the left", () => {
75
+ expect(viewShowsContent(nodes, { x: -60, y: 100, zoom: 1 }, pane, { left: 88 })).toBe(false);
76
+ });
77
+
78
+ it("lets a deliberate zoom into a part of a big board stand", () => {
79
+ expect(viewShowsContent(nodes, { x: -300, y: 0, zoom: 4 }, pane, { left: 88 })).toBe(true);
80
+ });
81
+
82
+ it("has nothing to miss on an empty board", () => {
83
+ expect(viewShowsContent([], { x: 9999, y: 0, zoom: 1 }, pane)).toBe(true);
84
+ });
85
+ });
@@ -0,0 +1,137 @@
1
+ // WHY not SvelteFlow's `fitView`: it needs a paint to measure, and a deck slide
2
+ // reads at once, so it blew up one corner of the drawing.
3
+
4
+ // The saved diagram holds the geometry, so the viewport is computed from it.
5
+
6
+ import type { CanvasViewport } from "../types.js";
7
+
8
+ /** What a node occupies when the saved data does not say. */
9
+ const FALLBACK_NODE = { width: 240, height: 80 };
10
+
11
+ type Sized = { x: number; y: number; width: number; height: number };
12
+
13
+ // WHY not `CanvasNode`: bounds only read geometry, so an xyflow `Node` fits too
14
+ // and neither side of the boundary needs a cast.
15
+ type GeometryNode = {
16
+ parentId?: string;
17
+ position: { x: number; y: number };
18
+ width?: number | null;
19
+ height?: number | null;
20
+ style?: unknown;
21
+ };
22
+
23
+ type Size = { width: number; height: number };
24
+
25
+ function num(value: unknown): number | null {
26
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
27
+ }
28
+
29
+ function cssDim(css: string, prop: "width" | "height"): number | null {
30
+ const found = css.match(new RegExp(`(?:^|[;\\s])${prop}:\\s*(\\d+(?:\\.\\d+)?)px`))?.[1];
31
+ return found ? Number(found) : null;
32
+ }
33
+
34
+ /** A stored node often has a width and no height (the DOM measures it), so each side is read alone. */
35
+ function styleDim(style: unknown, prop: "width" | "height"): number | null {
36
+ if (typeof style === "string") return cssDim(style, prop);
37
+ if (!style || typeof style !== "object") return null;
38
+ return num((style as Record<string, unknown>)[prop]);
39
+ }
40
+
41
+ function readSize(node: GeometryNode): Size {
42
+ return {
43
+ width: num(node.width) ?? styleDim(node.style, "width") ?? FALLBACK_NODE.width,
44
+ height: num(node.height) ?? styleDim(node.style, "height") ?? FALLBACK_NODE.height,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * The box every top-level node sits in. Children are parent-relative, so
50
+ * counting them would push the bounds out and shrink the drawing.
51
+ */
52
+ export function diagramBounds(nodes: GeometryNode[]): Sized | null {
53
+ const boxes = nodes
54
+ .filter((node) => !node.parentId)
55
+ .map((node) => ({ ...readSize(node), x: node.position.x, y: node.position.y }));
56
+ if (boxes.length === 0) return null;
57
+
58
+ const minX = Math.min(...boxes.map((box) => box.x));
59
+ const minY = Math.min(...boxes.map((box) => box.y));
60
+ const maxX = Math.max(...boxes.map((box) => box.x + box.width));
61
+ const maxY = Math.max(...boxes.map((box) => box.y + box.height));
62
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
63
+ }
64
+
65
+ /**
66
+ * The viewport that centres `nodes` inside a `container` of the given size.
67
+ * `padding` is a fraction of the container, matching fitView's own meaning.
68
+ */
69
+ export function fitViewport(
70
+ nodes: GeometryNode[],
71
+ container: { width: number; height: number },
72
+ padding = 0.04,
73
+ ): CanvasViewport | null {
74
+ const bounds = diagramBounds(nodes);
75
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return null;
76
+ if (container.width <= 0 || container.height <= 0) return null;
77
+
78
+ const usableWidth = container.width * (1 - padding * 2);
79
+ const usableHeight = container.height * (1 - padding * 2);
80
+ const zoom = Math.min(usableWidth / bounds.width, usableHeight / bounds.height);
81
+
82
+ return {
83
+ x: (container.width - bounds.width * zoom) / 2 - bounds.x * zoom,
84
+ y: (container.height - bounds.height * zoom) / 2 - bounds.y * zoom,
85
+ zoom,
86
+ };
87
+ }
88
+
89
+ /** Below this a whole-board fit is too small to read a card's text. */
90
+ const MIN_READABLE_ZOOM = 0.3;
91
+
92
+ /*
93
+ * Where a board with no view opens: a board that fits reads whole, but a tall
94
+ * wall fits at 8%, so it fits its width and starts at the top.
95
+ */
96
+ export function openingViewport(
97
+ nodes: GeometryNode[],
98
+ container: { width: number; height: number },
99
+ padding = 0.04,
100
+ gutter = 0,
101
+ ): CanvasViewport | null {
102
+ const whole = fitViewport(nodes, container, padding);
103
+ if (!whole || whole.zoom >= MIN_READABLE_ZOOM) return whole;
104
+
105
+ const bounds = diagramBounds(nodes);
106
+ if (!bounds) return null;
107
+ const inset = container.width * padding;
108
+ const left = inset + gutter;
109
+ const zoom = Math.min((container.width - left - inset) / bounds.width, 1);
110
+ return { x: left - bounds.x * zoom, y: inset - bounds.y * zoom, zoom };
111
+ }
112
+
113
+ /**
114
+ * Whether a saved view still shows the board: a card is on screen, and a fitting board isn't
115
+ * pushed off the left. `inset` is the tool rail's strip.
116
+ */
117
+ export function viewShowsContent(
118
+ nodes: GeometryNode[],
119
+ viewport: CanvasViewport,
120
+ container: { width: number; height: number },
121
+ inset = { left: 0 },
122
+ ): boolean {
123
+ const bounds = diagramBounds(nodes);
124
+ if (!bounds || viewport.zoom <= 0) return true;
125
+ const left = (inset.left - viewport.x) / viewport.zoom;
126
+ const right = (container.width - viewport.x) / viewport.zoom;
127
+ const top = -viewport.y / viewport.zoom;
128
+ const bottom = (container.height - viewport.y) / viewport.zoom;
129
+ const onScreen =
130
+ bounds.x < right &&
131
+ bounds.x + bounds.width > left &&
132
+ bounds.y < bottom &&
133
+ bounds.y + bounds.height > top;
134
+ if (!onScreen) return false;
135
+ const fits = bounds.width * viewport.zoom <= container.width - inset.left;
136
+ return !(fits && bounds.x < left - 4 / viewport.zoom);
137
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { GRID_SIZE, snapWrittenNodes } from "./grid.js";
3
+
4
+ const node = (id: string, x: number, y: number, extra: Record<string, unknown> = {}) => ({
5
+ id,
6
+ type: "note",
7
+ position: { x, y },
8
+ data: {},
9
+ ...extra,
10
+ });
11
+
12
+ // An agent wrote --x=103 --y=57 and the card sat between the dots.
13
+ describe("snapWrittenNodes", () => {
14
+ it("snaps what a write created or moved and leaves the rest where it was", () => {
15
+ const before = [node("old", 103, 57), node("moved", 0, 0), node("frame", 1003, 57)];
16
+ const after = [
17
+ node("old", 103, 57),
18
+ node("moved", 117, 251),
19
+ node("frame", 1003, 57),
20
+ node("fresh", 103, 57),
21
+ node("inside", 43.5, 17, { parentId: "frame", extent: "parent" }),
22
+ node("pen", 103, 57, { type: "stroke" }),
23
+ ];
24
+ const byId = Object.fromEntries(snapWrittenNodes(before, after).map((n) => [n.id, n.position]));
25
+
26
+ expect(GRID_SIZE).toBe(20);
27
+ expect(byId.old).toEqual({ x: 103, y: 57 });
28
+ expect(byId.moved).toEqual({ x: 120, y: 260 });
29
+ expect(byId.fresh).toEqual({ x: 100, y: 60 });
30
+ // A frame at 1003,57 isn't on a dot, so a child snaps on the page, where the dots are: 1046.5 becomes 1040.
31
+ expect((byId.inside as { x: number }).x + 1003).toBe(1040);
32
+ expect(byId.pen).toEqual({ x: 103, y: 57 });
33
+ });
34
+ });
@@ -0,0 +1,94 @@
1
+ // The magnets: the grid the editor snaps cards to. A write that isn't a drag snaps through here.
2
+
3
+ import { STROKE_NODE } from "../pen/types.js";
4
+
5
+ /** Flow units between two dots at the finest level. */
6
+ export const GRID_SIZE = 20;
7
+
8
+ /** The value xyflow's `snapGrid` prop takes. */
9
+ export const SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE];
10
+
11
+ /** Node types that keep their exact points. A stroke's origin is its bounding box, so moving it shifts the drawing. */
12
+ export const FREEHAND_NODE_TYPES: ReadonlySet<string> = new Set([STROKE_NODE]);
13
+
14
+ /** The nearest dot on one axis, rounded the way xyflow rounds a drag. */
15
+ export function snapToGrid(value: number): number {
16
+ return GRID_SIZE * Math.round(value / GRID_SIZE);
17
+ }
18
+
19
+ type RawNode = Record<string, unknown>;
20
+ type Point = { x: number; y: number };
21
+
22
+ function pointOf(value: unknown): Point | null {
23
+ if (typeof value !== "object" || value === null) return null;
24
+ const { x, y } = value as Record<string, unknown>;
25
+ return typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)
26
+ ? { x, y }
27
+ : null;
28
+ }
29
+
30
+ function stringOf(value: unknown): string | undefined {
31
+ return typeof value === "string" ? value : undefined;
32
+ }
33
+
34
+ /** Snaps the nodes a write created or moved and leaves the rest. A child snaps on the page, then goes back to relative. */
35
+ export function snapWrittenNodes<N extends RawNode>(
36
+ before: readonly RawNode[],
37
+ after: readonly N[],
38
+ exempt: ReadonlySet<string> = FREEHAND_NODE_TYPES,
39
+ ): N[] {
40
+ const prior = new Map<string, { at: Point | null; parent?: string }>();
41
+ for (const node of before) {
42
+ const id = stringOf(node.id);
43
+ if (id) prior.set(id, { at: pointOf(node.position), parent: stringOf(node.parentId) });
44
+ }
45
+ const byId = new Map<string, N>();
46
+ for (const node of after) {
47
+ const id = stringOf(node.id);
48
+ if (id) byId.set(id, node);
49
+ }
50
+
51
+ const written = (node: N): boolean => {
52
+ const was = prior.get(String(node.id));
53
+ const at = pointOf(node.position);
54
+ const type = stringOf(node.type);
55
+ if (!at || (type !== undefined && exempt.has(type))) return false;
56
+ if (!was?.at) return true;
57
+ return was.at.x !== at.x || was.at.y !== at.y || was.parent !== stringOf(node.parentId);
58
+ };
59
+
60
+ // Parents settle first, because a child's page position rides on its parent's.
61
+ const settled = new Map<string, { local: Point; page: Point }>();
62
+ const visiting = new Set<string>();
63
+
64
+ const settle = (node: N): { local: Point; page: Point } => {
65
+ const id = String(node.id);
66
+ const done = settled.get(id);
67
+ if (done) return done;
68
+ const local = pointOf(node.position) ?? { x: 0, y: 0 };
69
+ const parentId = stringOf(node.parentId);
70
+ const parent = parentId ? byId.get(parentId) : undefined;
71
+ let origin: Point = { x: 0, y: 0 };
72
+ if (parent && !visiting.has(id)) {
73
+ visiting.add(id);
74
+ origin = settle(parent).page;
75
+ visiting.delete(id);
76
+ }
77
+ const page = { x: origin.x + local.x, y: origin.y + local.y };
78
+ const result = written(node)
79
+ ? {
80
+ local: { x: snapToGrid(page.x) - origin.x, y: snapToGrid(page.y) - origin.y },
81
+ page: { x: snapToGrid(page.x), y: snapToGrid(page.y) },
82
+ }
83
+ : { local, page };
84
+ settled.set(id, result);
85
+ return result;
86
+ };
87
+
88
+ return after.map((node) => {
89
+ if (!stringOf(node.id)) return node;
90
+ const at = pointOf(node.position);
91
+ const next = settle(node).local;
92
+ return at && (next.x !== at.x || next.y !== at.y) ? { ...node, position: next } : node;
93
+ });
94
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { ICON_SIZE, ICON_W, iconGeometry } from "./icon-node.js";
3
+
4
+ describe("iconGeometry", () => {
5
+ it("centres a labelled-below mark and hands off at its own edges", () => {
6
+ const geo = iconGeometry({ label: "Acme" });
7
+ expect(geo.markX).toBe((ICON_W - ICON_SIZE) / 2);
8
+ expect(geo.left).toEqual({ x: geo.markX, y: ICON_SIZE / 2 });
9
+ expect(geo.right).toEqual({ x: geo.markX + ICON_SIZE, y: ICON_SIZE / 2 });
10
+ expect(geo.height).toBeGreaterThan(ICON_SIZE);
11
+ });
12
+
13
+ it("leaves a right-labelled mark from the box edge, level with the mark's centre", () => {
14
+ const geo = iconGeometry(
15
+ { label: "Delivery", lines: ["a", "b"], labelSide: "right", size: 60 },
16
+ 400,
17
+ );
18
+ expect(geo.left.x).toBe(0);
19
+ expect(geo.right.x).toBe(400);
20
+ expect(geo.left.y).toBe(geo.right.y);
21
+ expect(geo.left.y).toBe(geo.markY + 30);
22
+ });
23
+ });
@@ -0,0 +1,66 @@
1
+ // An icon node's geometry from its data alone. The node, the board compiler and the sizer
2
+ // all read it, so a curve lands on the mark, not a guess.
3
+
4
+ export type IconShape = "tile" | "ring";
5
+ export type IconLabelSide = "below" | "right";
6
+
7
+ export type IconGeometryInput = {
8
+ size?: number;
9
+ labelSide?: IconLabelSide;
10
+ label?: string;
11
+ lines?: string[];
12
+ };
13
+
14
+ export type IconGeometry = {
15
+ size: number;
16
+ /** Top-left of the mark inside the node box. */
17
+ markX: number;
18
+ markY: number;
19
+ height: number;
20
+ /** Where curves arrive (left) and leave (right), in node-local px. */
21
+ left: { x: number; y: number };
22
+ right: { x: number; y: number };
23
+ };
24
+
25
+ export const ICON_SIZE = 56;
26
+ export const ICON_W = 120;
27
+ /** Gap between the mark and its label, either side. */
28
+ export const ICON_LABEL_GAP = 12;
29
+ export const ICON_LABEL_H = 22;
30
+ export const ICON_LINE_H = 20;
31
+ /** A right-hand label is set larger: it names an area, not a tool. */
32
+ export const ICON_SIDE_LABEL_H = 28;
33
+ /** Space between the port line and the first line under it. */
34
+ export const ICON_PORT_GAP = 6;
35
+
36
+ /**
37
+ * A label below centres the mark and hands off at its edges. A right label starts it
38
+ * at x 0 and leaves from the box's right edge.
39
+ */
40
+ export function iconGeometry(icon: IconGeometryInput, width: number = ICON_W): IconGeometry {
41
+ const size = icon.size ?? ICON_SIZE;
42
+ const lines = icon.lines?.length ?? 0;
43
+ const mid = size / 2;
44
+ if (icon.labelSide === "right") {
45
+ // The label sits across the port line: name above it, lines below.
46
+ const top = Math.max(0, (icon.label ? ICON_SIDE_LABEL_H : 0) - mid);
47
+ return {
48
+ size,
49
+ markX: 0,
50
+ markY: top,
51
+ height: Math.max(top + size, top + mid + ICON_PORT_GAP + lines * ICON_LINE_H),
52
+ left: { x: 0, y: top + mid },
53
+ right: { x: Math.max(width, size), y: top + mid },
54
+ };
55
+ }
56
+ const markX = (Math.max(width, size) - size) / 2;
57
+ const text = (icon.label ? ICON_LABEL_H : 0) + lines * ICON_LINE_H;
58
+ return {
59
+ size,
60
+ markX,
61
+ markY: 0,
62
+ height: size + (text ? ICON_LABEL_GAP + text : 0),
63
+ left: { x: markX, y: mid },
64
+ right: { x: markX + size, y: mid },
65
+ };
66
+ }
@@ -0,0 +1,9 @@
1
+ // What a CLI or a server needs to build a board without a browser.
2
+
3
+ export {
4
+ compilePenMark,
5
+ penMarkSchema,
6
+ placeInFrame,
7
+ type FramePositions,
8
+ type PenMark,
9
+ } from "./pen-marks.js";
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { hasLink, linkify } from "./linkify.js";
3
+
4
+ describe("linkify", () => {
5
+ it("splits a link out of a line and leaves the words around it", () => {
6
+ expect(linkify("OPEN: the board, https://app.example.com/1/v/b/2 then go")).toEqual([
7
+ { text: "OPEN: the board, " },
8
+ { text: "https://app.example.com/1/v/b/2", href: "https://app.example.com/1/v/b/2" },
9
+ { text: " then go" },
10
+ ]);
11
+ });
12
+
13
+ it("keeps sentence punctuation out of the link", () => {
14
+ const parts = linkify("See https://example.com/a.");
15
+ expect(parts[1].href).toBe("https://example.com/a");
16
+ expect(parts[2].text).toBe(".");
17
+ });
18
+
19
+ it("returns plain text as one part", () => {
20
+ expect(linkify("no link here")).toEqual([{ text: "no link here" }]);
21
+ expect(hasLink("no link here")).toBe(false);
22
+ });
23
+ });
@@ -0,0 +1,25 @@
1
+ /** A run of text on a board line: plain, or a link the reader can click. */
2
+ export type LinkPart = { text: string; href?: string };
3
+
4
+ const URL_RE = /https?:\/\/[^\s<>"]+/g;
5
+ /** Sentence punctuation that follows a link without belonging to it. */
6
+ const TRAILING = /[.,;:!?)\]'”]+$/;
7
+
8
+ /** Splits a line so each http(s) link is its own part, minus the punctuation after it. */
9
+ export function linkify(text: string): LinkPart[] {
10
+ const parts: LinkPart[] = [];
11
+ let last = 0;
12
+ for (const match of text.matchAll(URL_RE)) {
13
+ const start = match.index ?? 0;
14
+ const href = match[0].replace(TRAILING, "");
15
+ if (start > last) parts.push({ text: text.slice(last, start) });
16
+ parts.push({ text: href, href });
17
+ last = start + href.length;
18
+ }
19
+ if (last < text.length) parts.push({ text: text.slice(last) });
20
+ return parts;
21
+ }
22
+
23
+ export function hasLink(text: string): boolean {
24
+ return /https?:\/\//.test(text);
25
+ }
@@ -0,0 +1,133 @@
1
+ // Pen marks written as JSON, built into the same nodes the pen tool draws,
2
+ // so a CLI can import a whole drawing in one call.
3
+
4
+ import { z } from "zod";
5
+ import { buildStrokeNode } from "../pen/geometry.js";
6
+ import { buildShapeNode, buildTextNode } from "../pen/shapes.js";
7
+ import { PEN_COLORS, SHAPE_KINDS, type PenNodeInput } from "../pen/types.js";
8
+ import type { CanvasNode } from "../types.js";
9
+
10
+ // =========================
11
+ // SCHEMAS
12
+ // =========================
13
+
14
+ const colorSchema = z.enum(PEN_COLORS);
15
+ const pointSchema = z.tuple([z.number(), z.number()]);
16
+
17
+ /** One stroke past this is a scribble pasted from a file, not a drawing. */
18
+ const MAX_POINTS = 5000;
19
+
20
+ /** The frame this mark sits in: its id, with the mark's coordinates still on the page. */
21
+ const inFrame = z.string().min(1).optional();
22
+
23
+ const strokeMark = z.object({
24
+ kind: z.literal("stroke"),
25
+ id: z.string().min(1).optional(),
26
+ in: inFrame,
27
+ /** Flow coordinates: x, y, then an optional 0 to 1 pressure. Simplified on the way in. */
28
+ points: z
29
+ .array(z.tuple([z.number(), z.number()]).rest(z.number()))
30
+ .min(1)
31
+ .max(MAX_POINTS),
32
+ color: colorSchema.optional(),
33
+ size: z.number().positive().optional(),
34
+ tool: z.enum(["pen", "highlighter"]).optional(),
35
+ });
36
+
37
+ const textMark = z.object({
38
+ kind: z.literal("text"),
39
+ id: z.string().min(1).optional(),
40
+ in: inFrame,
41
+ /** Top-left of the first line, as [x, y]. */
42
+ at: pointSchema,
43
+ text: z.string().min(1),
44
+ color: colorSchema.optional(),
45
+ fontSize: z.number().positive().optional(),
46
+ });
47
+
48
+ const shapeMark = z.object({
49
+ kind: z.literal("shape"),
50
+ id: z.string().min(1).optional(),
51
+ in: inFrame,
52
+ shape: z.enum(SHAPE_KINDS),
53
+ /** [x, y, width, height]. */
54
+ box: z.tuple([z.number(), z.number(), z.number().nonnegative(), z.number().nonnegative()]),
55
+ color: colorSchema.optional(),
56
+ filled: z.boolean().optional(),
57
+ label: z.string().optional(),
58
+ });
59
+
60
+ export const penMarkSchema = z.discriminatedUnion("kind", [strokeMark, textMark, shapeMark]);
61
+
62
+ export type PenMark = z.infer<typeof penMarkSchema>;
63
+
64
+ // =========================
65
+ // COMPILE
66
+ // =========================
67
+
68
+ /** Where each frame sits on the page, by id. A node inside one stores its place relative to it. */
69
+ export type FramePositions = ReadonlyMap<string, { x: number; y: number }>;
70
+
71
+ /** An unknown frame throws, so a typo in `in` stops the import instead of misplacing the mark. */
72
+ export function placeInFrame<N extends CanvasNode>(
73
+ node: N,
74
+ frameId: string,
75
+ frames: FramePositions,
76
+ where: string,
77
+ ): N {
78
+ const frame = frames.get(frameId);
79
+ if (!frame) throw new Error(`${where}: no frame "${frameId}" to put it in`);
80
+ return {
81
+ ...node,
82
+ parentId: frameId,
83
+ extent: "parent",
84
+ position: { x: node.position.x - frame.x, y: node.position.y - frame.y },
85
+ };
86
+ }
87
+
88
+ function toNode(node: PenNodeInput<Record<string, unknown>>, id: string): CanvasNode {
89
+ return {
90
+ id,
91
+ type: node.type,
92
+ position: node.position,
93
+ data: node.data,
94
+ width: node.width,
95
+ height: node.height,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Builds the node for one mark. A mark with no id gets `pen-<n>` from its place in the list,
101
+ * so a rerun of the same file writes the same ids.
102
+ */
103
+ export function compilePenMark(mark: PenMark, index: number, frames: FramePositions): CanvasNode {
104
+ const where = `marks[${index}]`;
105
+ const id = mark.id ?? `pen-${index + 1}`;
106
+ let node: CanvasNode;
107
+ if (mark.kind === "stroke") {
108
+ const built = buildStrokeNode(mark.points, {
109
+ color: mark.color,
110
+ size: mark.size,
111
+ tool: mark.tool,
112
+ });
113
+ if (!built) throw new Error(`${where}: a stroke needs at least one point`);
114
+ node = toNode(built, id);
115
+ } else if (mark.kind === "text") {
116
+ const [x, y] = mark.at;
117
+ node = toNode(
118
+ buildTextNode({ x, y }, { text: mark.text, color: mark.color, fontSize: mark.fontSize }),
119
+ id,
120
+ );
121
+ } else {
122
+ const [x, y, width, height] = mark.box;
123
+ node = toNode(
124
+ buildShapeNode(
125
+ mark.shape,
126
+ { x, y, width, height },
127
+ { color: mark.color, filled: mark.filled, label: mark.label },
128
+ ),
129
+ id,
130
+ );
131
+ }
132
+ return mark.in ? placeInFrame(node, mark.in, frames, where) : node;
133
+ }