@heyramzi/ui 1.54.2 → 2.0.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 (361) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +198 -6
  4. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  5. package/src/ambient.d.ts +8 -0
  6. package/src/brand/brand.test.ts +67 -0
  7. package/src/brand/index.ts +137 -0
  8. package/src/canvas/README.md +236 -0
  9. package/src/canvas/card/card-frame.svelte +61 -0
  10. package/src/canvas/card/card.css +39 -0
  11. package/src/canvas/card/resize-handles.svelte +119 -0
  12. package/src/canvas/card/resize.ts +33 -0
  13. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  14. package/src/canvas/chrome/chrome.css +231 -0
  15. package/src/canvas/chrome/lock-badge.svelte +18 -0
  16. package/src/canvas/chrome/side-handles.svelte +30 -0
  17. package/src/canvas/chrome/size-chip.svelte +45 -0
  18. package/src/canvas/chrome/snap-guides.svelte +69 -0
  19. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  20. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  21. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  22. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  23. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  24. package/src/canvas/edges/bend-handle.svelte +76 -0
  25. package/src/canvas/edges/edge-gradient.svelte +30 -0
  26. package/src/canvas/edges/edge-path.test.ts +58 -0
  27. package/src/canvas/edges/edge-path.ts +293 -0
  28. package/src/canvas/edges/gradient-edge.svelte +43 -0
  29. package/src/canvas/editor/context-menu.test.ts +65 -0
  30. package/src/canvas/editor/context-menu.ts +48 -0
  31. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  32. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  33. package/src/canvas/editor/editor-context.ts +62 -0
  34. package/src/canvas/editor/editor-mode.ts +67 -0
  35. package/src/canvas/editor/frame.ts +98 -0
  36. package/src/canvas/editor/hand-context.ts +15 -0
  37. package/src/canvas/editor/history-core.test.ts +239 -0
  38. package/src/canvas/editor/history-core.ts +247 -0
  39. package/src/canvas/editor/keymap.test.ts +161 -0
  40. package/src/canvas/editor/keymap.ts +151 -0
  41. package/src/canvas/editor/layout-actions.test.ts +156 -0
  42. package/src/canvas/editor/layout-actions.ts +219 -0
  43. package/src/canvas/editor/lock.test.ts +68 -0
  44. package/src/canvas/editor/lock.ts +87 -0
  45. package/src/canvas/editor/rebase.test.ts +208 -0
  46. package/src/canvas/editor/rebase.ts +272 -0
  47. package/src/canvas/editor/style.ts +184 -0
  48. package/src/canvas/editor/text-field-context.ts +41 -0
  49. package/src/canvas/editor/z-order.test.ts +75 -0
  50. package/src/canvas/editor/z-order.ts +87 -0
  51. package/src/canvas/headless/alignment.test.ts +59 -0
  52. package/src/canvas/headless/alignment.ts +23 -0
  53. package/src/canvas/headless/break-apart-find.ts +418 -0
  54. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  55. package/src/canvas/headless/break-apart-plan.ts +439 -0
  56. package/src/canvas/headless/break-apart-style.ts +106 -0
  57. package/src/canvas/headless/break-apart.ts +244 -0
  58. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  59. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  60. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  61. package/src/canvas/headless/edge-anchors.ts +143 -0
  62. package/src/canvas/headless/edge-memo.test.ts +58 -0
  63. package/src/canvas/headless/edge-memo.ts +32 -0
  64. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  65. package/src/canvas/headless/edge-serialization.ts +32 -0
  66. package/src/canvas/headless/figure-geometry.ts +293 -0
  67. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  68. package/src/canvas/headless/fit-viewport.ts +137 -0
  69. package/src/canvas/headless/grid.test.ts +34 -0
  70. package/src/canvas/headless/grid.ts +94 -0
  71. package/src/canvas/headless/icon-node.test.ts +23 -0
  72. package/src/canvas/headless/icon-node.ts +66 -0
  73. package/src/canvas/headless/index.ts +9 -0
  74. package/src/canvas/headless/linkify.test.ts +23 -0
  75. package/src/canvas/headless/linkify.ts +25 -0
  76. package/src/canvas/headless/pen-marks.ts +133 -0
  77. package/src/canvas/headless/sketch.test.ts +99 -0
  78. package/src/canvas/headless/sketch.ts +302 -0
  79. package/src/canvas/headless/snap-guides.test.ts +61 -0
  80. package/src/canvas/headless/snap-guides.ts +146 -0
  81. package/src/canvas/headless/svg-figure.test.ts +49 -0
  82. package/src/canvas/headless/svg-figure.ts +176 -0
  83. package/src/canvas/headless/table-layout.test.ts +158 -0
  84. package/src/canvas/headless/table-layout.ts +309 -0
  85. package/src/canvas/index.ts +35 -0
  86. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  87. package/src/canvas/lod/lod-switch.svelte +140 -0
  88. package/src/canvas/lod/render-all.svelte.ts +52 -0
  89. package/src/canvas/ops/arrange.ts +108 -0
  90. package/src/canvas/ops/ops.test.ts +98 -0
  91. package/src/canvas/ops/ops.ts +347 -0
  92. package/src/canvas/pen/PenField.svelte +128 -0
  93. package/src/canvas/pen/PenField.test.ts +87 -0
  94. package/src/canvas/pen/PenOverlay.svelte +172 -0
  95. package/src/canvas/pen/ShapeNode.svelte +64 -0
  96. package/src/canvas/pen/TextNode.svelte +97 -0
  97. package/src/canvas/pen/colors.ts +28 -0
  98. package/src/canvas/pen/eraser.ts +46 -0
  99. package/src/canvas/pen/geometry.ts +148 -0
  100. package/src/canvas/pen/host-field.test.svelte +13 -0
  101. package/src/canvas/pen/index.ts +40 -0
  102. package/src/canvas/pen/ops.ts +16 -0
  103. package/src/canvas/pen/pen-session.ts +177 -0
  104. package/src/canvas/pen/pen.css +149 -0
  105. package/src/canvas/pen/plugin.ts +139 -0
  106. package/src/canvas/pen/shapes.ts +104 -0
  107. package/src/canvas/pen/stroke-path.ts +54 -0
  108. package/src/canvas/pen/types.ts +96 -0
  109. package/src/canvas/pen/ui.ts +26 -0
  110. package/src/canvas/plugin.test.ts +45 -0
  111. package/src/canvas/plugin.ts +159 -0
  112. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  113. package/src/canvas/screen/PartView.svelte +176 -0
  114. package/src/canvas/screen/ScreenBody.svelte +142 -0
  115. package/src/canvas/screen/ScreenCard.svelte +32 -0
  116. package/src/canvas/screen/glyphs/button.svg +1 -0
  117. package/src/canvas/screen/glyphs/close.svg +1 -0
  118. package/src/canvas/screen/glyphs/down.svg +1 -0
  119. package/src/canvas/screen/glyphs/header.svg +1 -0
  120. package/src/canvas/screen/glyphs/hills.svg +1 -0
  121. package/src/canvas/screen/glyphs/image.svg +1 -0
  122. package/src/canvas/screen/glyphs/input.svg +1 -0
  123. package/src/canvas/screen/glyphs/list.svg +1 -0
  124. package/src/canvas/screen/glyphs/modal.svg +1 -0
  125. package/src/canvas/screen/glyphs/plus.svg +1 -0
  126. package/src/canvas/screen/glyphs/up.svg +1 -0
  127. package/src/canvas/screen/glyphs.ts +40 -0
  128. package/src/canvas/screen/labels.ts +41 -0
  129. package/src/canvas/screen/parts.ts +164 -0
  130. package/src/canvas/screen/plugin.ts +67 -0
  131. package/src/canvas/screen/screen.css +593 -0
  132. package/src/canvas/screen/types.ts +47 -0
  133. package/src/canvas/screen/ui.ts +13 -0
  134. package/src/canvas/test-utils.ts +18 -0
  135. package/src/canvas/types.ts +53 -0
  136. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  137. package/src/canvas/ui/FrameLayer.svelte +116 -0
  138. package/src/canvas/ui/PenLayer.svelte +39 -0
  139. package/src/canvas/ui/StylePanel.svelte +268 -0
  140. package/src/canvas/ui/canvas-grid.svelte +156 -0
  141. package/src/canvas/ui/canvas-ui.css +350 -0
  142. package/src/canvas/ui/forward-wheel.ts +29 -0
  143. package/src/canvas/ui/icons.ts +124 -0
  144. package/src/canvas/ui/place-panel.ts +80 -0
  145. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  146. package/src/canvas/ui/shortcut-tip.ts +17 -0
  147. package/src/canvas/ui/tools.ts +146 -0
  148. package/src/charts/README.md +104 -0
  149. package/src/charts/ambient.d.ts +4 -0
  150. package/src/charts/animated-value.css +7 -0
  151. package/src/charts/animated-value.svelte +110 -0
  152. package/src/charts/area-chart.css +29 -0
  153. package/src/charts/area-chart.svelte +204 -0
  154. package/src/charts/bar-chart.css +119 -0
  155. package/src/charts/bar-chart.svelte +275 -0
  156. package/src/charts/charts.css +201 -0
  157. package/src/charts/crosshair.svelte +35 -0
  158. package/src/charts/dashboard/chart.ts +5 -0
  159. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  160. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  161. package/src/charts/dashboard/data-panel.svelte +81 -0
  162. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  163. package/src/charts/dashboard/data-table.svelte +278 -0
  164. package/src/charts/dashboard/data-table.ts +26 -0
  165. package/src/charts/dashboard/index.ts +25 -0
  166. package/src/charts/dashboard/panel-grid.svelte +43 -0
  167. package/src/charts/dashboard/period-filter.svelte +53 -0
  168. package/src/charts/dashboard/rating-chip.svelte +35 -0
  169. package/src/charts/dashboard/stat-band.svelte +47 -0
  170. package/src/charts/dashboard/stat-tile.svelte +176 -0
  171. package/src/charts/dashboard/windows.ts +8 -0
  172. package/src/charts/donut-chart.css +56 -0
  173. package/src/charts/donut-chart.svelte +158 -0
  174. package/src/charts/edge-fade.svelte +43 -0
  175. package/src/charts/edge-fade.ts +16 -0
  176. package/src/charts/figure.css +100 -0
  177. package/src/charts/figure.svelte +122 -0
  178. package/src/charts/gauge-chart.css +94 -0
  179. package/src/charts/gauge-chart.svelte +302 -0
  180. package/src/charts/hover-band.test.ts +30 -0
  181. package/src/charts/hover-band.ts +21 -0
  182. package/src/charts/hover-focus.svelte +85 -0
  183. package/src/charts/index.ts +31 -0
  184. package/src/charts/legend.css +136 -0
  185. package/src/charts/legend.svelte +112 -0
  186. package/src/charts/line-chart.css +26 -0
  187. package/src/charts/line-chart.svelte +256 -0
  188. package/src/charts/motion.ts +19 -0
  189. package/src/charts/number-chart.css +179 -0
  190. package/src/charts/number-chart.svelte +187 -0
  191. package/src/charts/primitives/chart-container.svelte +57 -0
  192. package/src/charts/primitives/chart-style.svelte +36 -0
  193. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  194. package/src/charts/primitives/chart-utils.ts +54 -0
  195. package/src/charts/primitives/index.ts +11 -0
  196. package/src/charts/reference-line.css +18 -0
  197. package/src/charts/reference-line.svelte +31 -0
  198. package/src/charts/reveal.svelte +61 -0
  199. package/src/charts/ring-chart.css +104 -0
  200. package/src/charts/ring-chart.svelte +166 -0
  201. package/src/charts/sparkline.css +45 -0
  202. package/src/charts/sparkline.svelte +141 -0
  203. package/src/charts/stat-card.css +105 -0
  204. package/src/charts/stat-card.svelte +267 -0
  205. package/src/charts/time-series-chart.css +28 -0
  206. package/src/charts/time-series-chart.svelte +179 -0
  207. package/src/charts/trend-pill.css +38 -0
  208. package/src/charts/trend-pill.svelte +48 -0
  209. package/src/components/copy/copy-button.svelte +1 -1
  210. package/src/components/docs/doc-page.svelte +1 -1
  211. package/src/components/docs/on-this-page.svelte +2 -2
  212. package/src/components/list-page/empty-state.svelte +1 -1
  213. package/src/components/list-page/filter-popover.svelte +1 -1
  214. package/src/components/list-page/section-label.svelte +1 -1
  215. package/src/components/segmented/segment.svelte +4 -4
  216. package/src/components/theme/theme-provider.svelte +6 -4
  217. package/src/components/toolbar/index.ts +5 -0
  218. package/src/components/toolbar/toolbar.svelte +168 -0
  219. package/src/components/toolbar/toolbar.test.ts +66 -0
  220. package/src/editor/README.md +58 -0
  221. package/src/editor/ambient.d.ts +1 -0
  222. package/src/editor/block-id.ts +77 -0
  223. package/src/editor/block-ops.test.ts +237 -0
  224. package/src/editor/block-ops.ts +157 -0
  225. package/src/editor/content.css +418 -0
  226. package/src/editor/editor-toolbar.svelte +303 -0
  227. package/src/editor/editor.svelte +462 -0
  228. package/src/editor/extensions.ts +145 -0
  229. package/src/editor/image-toolbar.svelte +123 -0
  230. package/src/editor/index.ts +17 -0
  231. package/src/editor/inline-editor.svelte +261 -0
  232. package/src/editor/markdown-escaping.test.ts +158 -0
  233. package/src/editor/markdown-marks.test.ts +89 -0
  234. package/src/editor/markdown-test-helpers.ts +13 -0
  235. package/src/editor/markdown-youtube.test.ts +93 -0
  236. package/src/editor/markdown.test.ts +250 -0
  237. package/src/editor/markdown.ts +511 -0
  238. package/src/editor/renderer.svelte +92 -0
  239. package/src/editor/serializer.test.ts +133 -0
  240. package/src/editor/serializer.ts +370 -0
  241. package/src/editor/slash-items.ts +115 -0
  242. package/src/editor/slash-menu.svelte +159 -0
  243. package/src/editor/youtube-url.ts +35 -0
  244. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  245. package/src/lib/result.ts +19 -0
  246. package/src/onboarding/README.md +120 -0
  247. package/src/onboarding/define-tours.test.ts +50 -0
  248. package/src/onboarding/define-tours.ts +31 -0
  249. package/src/onboarding/demo.svelte +86 -0
  250. package/src/onboarding/index.ts +12 -0
  251. package/src/onboarding/onboarding-provider.svelte +120 -0
  252. package/src/onboarding/onboarding.svelte.ts +24 -0
  253. package/src/onboarding/pick.test.ts +79 -0
  254. package/src/onboarding/pick.ts +36 -0
  255. package/src/onboarding/stores.test.ts +103 -0
  256. package/src/onboarding/stores.ts +58 -0
  257. package/src/onboarding/tour-card.svelte +70 -0
  258. package/src/onboarding/tour-runner.svelte +197 -0
  259. package/src/onboarding/types.ts +34 -0
  260. package/src/onboarding/wait.ts +29 -0
  261. package/src/shadcn/command/command-input.svelte +1 -1
  262. package/src/slides/Controls.svelte +138 -0
  263. package/src/slides/Deck.svelte +271 -0
  264. package/src/slides/Fragment.svelte +71 -0
  265. package/src/slides/Overview.svelte +113 -0
  266. package/src/slides/Presenter.svelte +292 -0
  267. package/src/slides/README.md +89 -0
  268. package/src/slides/Slide.svelte +179 -0
  269. package/src/slides/SlideList.svelte +19 -0
  270. package/src/slides/ambient.d.ts +1 -0
  271. package/src/slides/bridges.ts +69 -0
  272. package/src/slides/deck-controller.svelte.ts +292 -0
  273. package/src/slides/demo-slides.svelte +358 -0
  274. package/src/slides/demo.svelte +176 -0
  275. package/src/slides/export/capture.ts +113 -0
  276. package/src/slides/export/ffmpeg.ts +120 -0
  277. package/src/slides/export/index.ts +9 -0
  278. package/src/slides/export/launch.ts +10 -0
  279. package/src/slides/export/pdf.ts +152 -0
  280. package/src/slides/export/types.ts +48 -0
  281. package/src/slides/index.ts +19 -0
  282. package/src/slides/labels.ts +48 -0
  283. package/src/slides/layout.ts +51 -0
  284. package/src/slides/model.ts +47 -0
  285. package/src/slides/navigation.ts +100 -0
  286. package/src/slides/theme.css +205 -0
  287. package/src/slides/types.ts +86 -0
  288. package/src/tasks/README.md +312 -0
  289. package/src/tasks/ambient.d.ts +3 -0
  290. package/src/tasks/board-order.ts +137 -0
  291. package/src/tasks/dates.ts +31 -0
  292. package/src/tasks/define-statuses.ts +32 -0
  293. package/src/tasks/demo.svelte +389 -0
  294. package/src/tasks/detail/detail-demo.svelte +186 -0
  295. package/src/tasks/detail/detail-types.ts +54 -0
  296. package/src/tasks/detail/task-chip.svelte +28 -0
  297. package/src/tasks/detail/task-detail.svelte +315 -0
  298. package/src/tasks/detail/task-property.svelte +17 -0
  299. package/src/tasks/detail/task-thread.svelte +258 -0
  300. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  301. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  302. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  303. package/src/tasks/gantt/gantt-header.svelte +71 -0
  304. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  305. package/src/tasks/gantt/gantt.css +22 -0
  306. package/src/tasks/gantt/gantt.test.ts +77 -0
  307. package/src/tasks/gantt/logic.test.ts +163 -0
  308. package/src/tasks/gantt/logic.ts +248 -0
  309. package/src/tasks/inbox/inbox-card.svelte +283 -0
  310. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  311. package/src/tasks/inbox/inbox-logic.ts +55 -0
  312. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  313. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  314. package/src/tasks/inbox/inbox-types.ts +108 -0
  315. package/src/tasks/inbox/inbox.svelte +203 -0
  316. package/src/tasks/inbox/index.ts +23 -0
  317. package/src/tasks/index.ts +85 -0
  318. package/src/tasks/kanban-board.svelte +561 -0
  319. package/src/tasks/kanban-column.svelte +117 -0
  320. package/src/tasks/kanban-drag.ts +38 -0
  321. package/src/tasks/kanban-types.ts +65 -0
  322. package/src/tasks/presets/sales.ts +16 -0
  323. package/src/tasks/record/demo-data.ts +286 -0
  324. package/src/tasks/record/field-section.svelte +53 -0
  325. package/src/tasks/record/fields.ts +142 -0
  326. package/src/tasks/record/format.ts +87 -0
  327. package/src/tasks/record/gutter.ts +2 -0
  328. package/src/tasks/record/index.ts +54 -0
  329. package/src/tasks/record/info-list.svelte +316 -0
  330. package/src/tasks/record/kinds.ts +42 -0
  331. package/src/tasks/record/logic.ts +324 -0
  332. package/src/tasks/record/next-up.svelte +70 -0
  333. package/src/tasks/record/project-list.svelte +133 -0
  334. package/src/tasks/record/record-demo.svelte +324 -0
  335. package/src/tasks/record/record-header.svelte +70 -0
  336. package/src/tasks/record/record-shell.svelte +138 -0
  337. package/src/tasks/record/record-stats.svelte +48 -0
  338. package/src/tasks/record/record-timeline.svelte +231 -0
  339. package/src/tasks/record/record.test.ts +350 -0
  340. package/src/tasks/record/steps-rail.svelte +474 -0
  341. package/src/tasks/record/types.ts +280 -0
  342. package/src/tasks/record/where-we-are.svelte +121 -0
  343. package/src/tasks/reorder.ts +19 -0
  344. package/src/tasks/side-panel-types.ts +16 -0
  345. package/src/tasks/side-panel.svelte +193 -0
  346. package/src/tasks/status-badge.svelte +63 -0
  347. package/src/tasks/status-dot.svelte +228 -0
  348. package/src/tasks/status-icon.svelte +30 -0
  349. package/src/tasks/status-types.ts +40 -0
  350. package/src/tasks/task-gantt.svelte +230 -0
  351. package/src/tasks/task-list.svelte +470 -0
  352. package/src/tasks/task-types.ts +33 -0
  353. package/src/tasks/time/duration.ts +69 -0
  354. package/src/tasks/time/time-chip.svelte +62 -0
  355. package/src/tasks/time/time-dot.svelte +58 -0
  356. package/src/tasks/time/time-ring.svelte +74 -0
  357. package/src/tasks/time/time-tracker.svelte +565 -0
  358. package/src/tasks/time/time-types.ts +74 -0
  359. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  360. package/src/tokens/base.css +5 -5
  361. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,244 @@
1
+ // Break apart: a drawn figure's cards become real cards. Pure over a parsed tree, so a
2
+ // browser, a CLI and a server run the same code.
3
+
4
+ import type { CanvasOp } from "../ops/ops.js";
5
+ import { sanitizeFigureSvg } from "./svg-figure.js";
6
+ import { PEN_HEX } from "../pen/colors.js";
7
+ import type { PenColor } from "../pen/types.js";
8
+ import {
9
+ isEl,
10
+ translateOf,
11
+ union,
12
+ viewBoxOf,
13
+ boxOfEl,
14
+ type Box,
15
+ type FigureChild,
16
+ type FigureEl,
17
+ } from "./figure-geometry.js";
18
+ import { findPieces } from "./break-apart-pieces.js";
19
+ import { NOTE_BODY_PX, NOTE_TITLE_PX, round1 } from "./break-apart-style.js";
20
+ import {
21
+ addCards,
22
+ addEdges,
23
+ addFrames,
24
+ addShapes,
25
+ freshId,
26
+ linksOf,
27
+ placeBadges,
28
+ startPlan,
29
+ type Plan,
30
+ type PlanSource,
31
+ } from "./break-apart-plan.js";
32
+
33
+ // =========================
34
+ // TYPES
35
+ // =========================
36
+
37
+ /** What the tree reader needs from a DOM node, so no global `Element` is named here. */
38
+ export type DomLike = {
39
+ nodeType: number;
40
+ localName?: string;
41
+ textContent: string | null;
42
+ attributes?: ArrayLike<{ name: string; value: string }>;
43
+ childNodes?: ArrayLike<DomLike>;
44
+ };
45
+
46
+ export type BreakApartInput = PlanSource & {
47
+ /** The parsed `<svg>` root of the figure's markup, unsanitized: `data-pins` is read from it. */
48
+ tree: FigureEl;
49
+ };
50
+
51
+ export type BreakApartResult = {
52
+ ops: CanvasOp[];
53
+ cards: number;
54
+ edges: number;
55
+ /** Bare rectangles and number marks that became shapes. */
56
+ shapes: number;
57
+ /** Rectangles drawn round cards that became frames. */
58
+ frames: number;
59
+ /** The figure stays, smaller, because it still has panels or chrome. */
60
+ remains: boolean;
61
+ };
62
+
63
+ const TEXT_NODE = 3;
64
+ const ELEMENT_NODE = 1;
65
+ /** Space between the drawing and the caption set under it. */
66
+ const CAPTION_GAP = 8;
67
+
68
+ // =========================
69
+ // TREE
70
+ // =========================
71
+
72
+ /** Read a DOM element (or any node shaped like one) into a tree. */
73
+ export function treeFromElement(el: DomLike): FigureEl {
74
+ const attrs: Record<string, string> = {};
75
+ for (const attr of Array.from(el.attributes ?? [])) attrs[attr.name] = attr.value;
76
+ const children: FigureChild[] = [];
77
+ for (const child of Array.from(el.childNodes ?? [])) {
78
+ if (child.nodeType === TEXT_NODE) children.push(child.textContent ?? "");
79
+ else if (child.nodeType === ELEMENT_NODE) children.push(treeFromElement(child));
80
+ }
81
+ return { tag: el.localName ?? "", attrs, children };
82
+ }
83
+
84
+ function escapeText(value: string): string {
85
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
86
+ }
87
+
88
+ function serialize(child: FigureChild): string {
89
+ if (typeof child === "string") return escapeText(child);
90
+ const attrs = Object.entries(child.attrs)
91
+ .map(([name, value]) => ` ${name}="${value.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`)
92
+ .join("");
93
+ return `<${child.tag}${attrs}>${child.children.map(serialize).join("")}</${child.tag}>`;
94
+ }
95
+
96
+ // =========================
97
+ // WHAT IS LEFT
98
+ // =========================
99
+
100
+ function prune(el: FigureEl, removed: Set<FigureChild>): FigureEl {
101
+ const children: FigureChild[] = [];
102
+ for (const child of el.children) {
103
+ if (removed.has(child)) continue;
104
+ if (isEl(child) && child.tag === "g") {
105
+ const kept = prune(child, removed);
106
+ // A group left with no shape, only whitespace, is gone.
107
+ if (kept.children.some((c) => (isEl(c) ? true : c.trim() !== ""))) children.push(kept);
108
+ continue;
109
+ }
110
+ children.push(child);
111
+ }
112
+ return { ...el, children };
113
+ }
114
+
115
+ /** The ink left once the cards are out: drawable shapes and text, ignoring defs. */
116
+ function leftover(el: FigureEl): Box | null {
117
+ let box: Box | null = null;
118
+ for (const child of el.children) {
119
+ if (!isEl(child) || child.tag === "defs" || child.tag === "title" || child.tag === "desc")
120
+ continue;
121
+ if (child.tag === "g") {
122
+ const shift = translateOf(child) ?? { x: 0, y: 0 };
123
+ const inner = leftover(child);
124
+ if (inner) {
125
+ box = union(box, {
126
+ x1: inner.x1 + shift.x,
127
+ y1: inner.y1 + shift.y,
128
+ x2: inner.x2 + shift.x,
129
+ y2: inner.y2 + shift.y,
130
+ });
131
+ }
132
+ continue;
133
+ }
134
+ box = union(box, boxOfEl(child, { x: 0, y: 0 }));
135
+ }
136
+ return box;
137
+ }
138
+
139
+ /** Nothing is left to draw: the figure goes, and its title and caption stay as text above and below the pieces. */
140
+ function replaceWithText(plan: Plan) {
141
+ const { node, taken, ops, drawing, scale, artTop } = plan;
142
+ ops.push({ kind: "removeNode", nodeId: node.id });
143
+ const accent = node.data.accent as string | undefined;
144
+ const color = (Object.keys(PEN_HEX) as PenColor[]).find((c) => c === accent) ?? "ink";
145
+ const textWidth = Math.round(plan.artWidth);
146
+ const words: [string, number, number][] = [
147
+ [String(node.data.label ?? ""), NOTE_TITLE_PX, node.position.y],
148
+ [
149
+ String(node.data.caption ?? ""),
150
+ NOTE_BODY_PX,
151
+ Math.round(node.position.y + artTop + (drawing.y2 - drawing.y1) * scale + CAPTION_GAP),
152
+ ],
153
+ ];
154
+ for (const [i, [text, fontSize, y]] of words.entries()) {
155
+ if (!text) continue;
156
+ ops.push({
157
+ kind: "addNode",
158
+ node: {
159
+ id: freshId(`${node.id}-text-${i === 0 ? "title" : "caption"}`, taken),
160
+ type: "text",
161
+ position: { x: node.position.x, y },
162
+ data: { text, color, fontSize, fixedWidth: true },
163
+ width: textWidth,
164
+ height: Math.round(fontSize * 1.4),
165
+ ...(node.parentId ? { parentId: node.parentId } : {}),
166
+ },
167
+ });
168
+ }
169
+ }
170
+
171
+ /** The figure keeps its height, so its caption stays under the pieces; only the sides shrink. */
172
+ function shrinkFigure(plan: Plan, rest: FigureEl, ink: Box) {
173
+ const { node, drawing, scale, ops } = plan;
174
+ const crop: Box = {
175
+ x1: Math.max(ink.x1, drawing.x1),
176
+ y1: drawing.y1,
177
+ x2: Math.min(ink.x2, drawing.x2),
178
+ y2: drawing.y2,
179
+ };
180
+ const cropW = crop.x2 - crop.x1;
181
+ const cropH = crop.y2 - crop.y1;
182
+ const markup = sanitizeFigureSvg(
183
+ serialize({
184
+ ...rest,
185
+ attrs: {
186
+ ...rest.attrs,
187
+ viewBox: `${round1(crop.x1)} ${round1(crop.y1)} ${round1(cropW)} ${round1(cropH)}`,
188
+ },
189
+ }),
190
+ );
191
+ const width = Math.round(cropW * scale);
192
+ const board = node.data.board as { w?: number } | undefined;
193
+ ops.push({
194
+ kind: "patchNode",
195
+ nodeId: node.id,
196
+ data: { svg: markup ?? "", ...(board?.w ? { board: { ...board, w: width } } : {}) },
197
+ width,
198
+ style: { width },
199
+ });
200
+ const shift = {
201
+ x: Math.round(node.position.x + (crop.x1 - drawing.x1) * scale),
202
+ y: Math.round(node.position.y + (crop.y1 - drawing.y1) * scale),
203
+ };
204
+ if (shift.x !== node.position.x || shift.y !== node.position.y) {
205
+ ops.push({ kind: "moveNode", nodeId: node.id, position: shift });
206
+ }
207
+ }
208
+
209
+ // =========================
210
+ // BREAK APART
211
+ // =========================
212
+
213
+ /** Ops that swap a figure for cards, links, shapes and frames, or null with none in it. The rest stays as a smaller figure. */
214
+ export function breakApartFigure(input: BreakApartInput): BreakApartResult | null {
215
+ const { node, tree } = input;
216
+ const drawing = viewBoxOf(tree);
217
+ const artWidth = input.art?.width ?? node.width;
218
+ if (!drawing || !artWidth) return null;
219
+ const pieces = findPieces(tree, drawing);
220
+ if (!pieces) return null;
221
+
222
+ const plan = startPlan(input, pieces, drawing, artWidth);
223
+ addFrames(plan);
224
+ addShapes(plan);
225
+ const links = linksOf(plan);
226
+ const linkLabels = placeBadges(plan, links);
227
+ addCards(plan);
228
+ addEdges(plan, links, linkLabels);
229
+
230
+ const counts = {
231
+ cards: plan.cards.length,
232
+ edges: links.length,
233
+ shapes: plan.shapes,
234
+ frames: plan.loose.frames.length,
235
+ };
236
+ const rest = prune(tree, plan.removed);
237
+ const ink = leftover(rest);
238
+ if (!ink) {
239
+ replaceWithText(plan);
240
+ return { ops: plan.ops, ...counts, remains: false };
241
+ }
242
+ shrinkFigure(plan, rest, ink);
243
+ return { ops: plan.ops, ...counts, remains: true };
244
+ }
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { sortParentsFirst, stripCallbacks } from "./canvas-cleanup.js";
3
+ import { makeNode } from "../test-utils.js";
4
+ import type { CanvasNode } from "../types.js";
5
+
6
+ /** Read serialized data as a loose record: these tests exercise legacy fields
7
+ * that are no longer part of any per-type shape but must survive the denylist. */
8
+ function loose(node: { data: unknown }): Record<string, unknown> {
9
+ return node.data as Record<string, unknown>;
10
+ }
11
+
12
+ function makeImageNode(overrides: Partial<CanvasNode> = {}): CanvasNode {
13
+ return makeNode({
14
+ id: "img-1",
15
+ type: "image",
16
+ position: { x: 100, y: 200 },
17
+ data: {
18
+ label: "Image",
19
+ nodeType: "image",
20
+ imageUrl: "https://storage.example.com/diagram-images/abc123.avif",
21
+ imageWidth: 800,
22
+ imageHeight: 600,
23
+ // Callback that should be stripped
24
+ onDelete: () => {},
25
+ } as Record<string, unknown> & { onDelete: () => void },
26
+ ...overrides,
27
+ });
28
+ }
29
+
30
+ describe("stripCallbacks", () => {
31
+ it("preserves data fields it has never heard of (no silent allowlist loss)", () => {
32
+ const nodes = [
33
+ makeImageNode({
34
+ data: {
35
+ label: "Image",
36
+ nodeType: "image",
37
+ someFutureField: { nested: true },
38
+ anotherNewFlag: "keep-me",
39
+ } as Record<string, unknown> & Record<string, unknown>,
40
+ }),
41
+ ];
42
+ const result = stripCallbacks(nodes);
43
+ expect((result[0].data as Record<string, unknown>).someFutureField).toEqual({ nested: true });
44
+ expect((result[0].data as Record<string, unknown>).anotherNewFlag).toBe("keep-me");
45
+ });
46
+
47
+ it("preserves labelFr for bilingual nodes", () => {
48
+ const nodes: CanvasNode[] = [
49
+ {
50
+ id: "n-1",
51
+ type: "agent",
52
+ position: { x: 0, y: 0 },
53
+ data: {
54
+ label: "Delivery PM Agent",
55
+ labelFr: "Agent PM Livraison",
56
+ nodeType: "agent",
57
+ descriptionFr: "Description en français",
58
+ } satisfies Record<string, unknown>,
59
+ },
60
+ ];
61
+ const result = stripCallbacks(nodes);
62
+
63
+ expect(result[0].data.label).toBe("Delivery PM Agent");
64
+ expect(result[0].data.labelFr).toBe("Agent PM Livraison");
65
+ expect(result[0].data.descriptionFr).toBe("Description en français");
66
+ });
67
+
68
+ it("preserves editable node-specific fields during autosave serialization", () => {
69
+ const nodes: CanvasNode[] = [
70
+ {
71
+ id: "space-1",
72
+ type: "space",
73
+ position: { x: 0, y: 0 },
74
+ data: {
75
+ label: "Delivery",
76
+ nodeType: "space",
77
+ spaceChildren: [
78
+ {
79
+ id: "list-1",
80
+ type: "list",
81
+ label: "Implementation",
82
+ statuses: [{ name: "In progress", color: "blue" }],
83
+ },
84
+ ],
85
+ } as Record<string, unknown>,
86
+ },
87
+ {
88
+ id: "pw-1",
89
+ type: "pains-wishes",
90
+ position: { x: 100, y: 0 },
91
+ data: {
92
+ label: "Customer fit",
93
+ nodeType: "pains-wishes",
94
+ pains: [{ id: "pain-1", text: "Manual reporting" }],
95
+ wishes: [{ id: "wish-1", text: "Faster onboarding" }],
96
+ },
97
+ },
98
+ {
99
+ id: "mindmap-1",
100
+ type: "mindmap",
101
+ position: { x: 200, y: 0 },
102
+ data: {
103
+ label: "Launch plan",
104
+ nodeType: "mindmap",
105
+ leftChildCount: 2,
106
+ rightChildCount: 3,
107
+ note: "Needs owner",
108
+ status: "doing",
109
+ url: "https://example.com/brief",
110
+ expanded: false,
111
+ } as Record<string, unknown>,
112
+ },
113
+ ];
114
+
115
+ const result = stripCallbacks(nodes);
116
+
117
+ expect(loose(result[0]).spaceChildren).toEqual(loose(nodes[0]).spaceChildren);
118
+ expect(loose(result[1]).pains).toEqual(loose(nodes[1]).pains);
119
+ expect(loose(result[1]).wishes).toEqual(loose(nodes[1]).wishes);
120
+ // Derived mindmap counts are runtime-only and must NOT persist.
121
+ expect(result[2].data).not.toHaveProperty("leftChildCount");
122
+ expect(result[2].data).not.toHaveProperty("rightChildCount");
123
+ expect(loose(result[2]).note).toBe("Needs owner");
124
+ expect(loose(result[2]).status).toBe("doing");
125
+ expect(loose(result[2]).url).toBe("https://example.com/brief");
126
+ expect(loose(result[2]).expanded).toBe(false);
127
+ });
128
+
129
+ it("image node survives full round-trip (create -> serialize -> deserialize)", () => {
130
+ const imageNode: CanvasNode = {
131
+ id: "img-rt",
132
+ type: "image",
133
+ position: { x: 50, y: 100 },
134
+ style: { width: 400, height: 300 },
135
+ data: {
136
+ label: "Image",
137
+ nodeType: "image",
138
+ imageUrl: "https://storage.example.com/diagram-images/hash123.avif",
139
+ imageWidth: 1200,
140
+ imageHeight: 900,
141
+ onDelete: () => {},
142
+ locale: "en",
143
+ } as Record<string, unknown> & { onDelete: () => void; locale: string },
144
+ };
145
+
146
+ // Serialize (what happens on save / after move)
147
+ const serialized = stripCallbacks([imageNode]);
148
+
149
+ // Verify all critical fields survived
150
+ expect(serialized[0].type).toBe("image");
151
+ expect(loose(serialized[0]).imageUrl).toBe(
152
+ "https://storage.example.com/diagram-images/hash123.avif",
153
+ );
154
+ expect(loose(serialized[0]).imageWidth).toBe(1200);
155
+ expect(loose(serialized[0]).imageHeight).toBe(900);
156
+ expect(serialized[0].style).toEqual({ width: 400, height: 300 });
157
+ // Runtime-only fields must be gone
158
+ expect(serialized[0].data).not.toHaveProperty("onDelete");
159
+ expect(serialized[0].data).not.toHaveProperty("locale");
160
+ });
161
+ });
162
+
163
+ describe("sortParentsFirst", () => {
164
+ // Array order is paint order, so a reshuffle sends ink behind the card it was drawn over.
165
+ it("keeps roots in their saved order and puts each child after its parent", () => {
166
+ const nodes = [
167
+ { id: "table" },
168
+ { id: "frame" },
169
+ { id: "card", parentId: "frame" },
170
+ { id: "stroke" },
171
+ { id: "note", parentId: "frame" },
172
+ ];
173
+ expect(sortParentsFirst(nodes).map((n) => n.id)).toEqual([
174
+ "table",
175
+ "frame",
176
+ "card",
177
+ "note",
178
+ "stroke",
179
+ ]);
180
+ });
181
+ });
@@ -0,0 +1,102 @@
1
+ import type { CanvasEdge, CanvasNode } from "../types.js";
2
+
3
+ /**
4
+ * Data fields that exist only at runtime and must never persist. The tree child
5
+ * counts are recomputed on load; saved, they froze stale values.
6
+ */
7
+ const RUNTIME_DATA_FIELDS = new Set([
8
+ "leftChildCount",
9
+ "rightChildCount",
10
+ // Injected at render time by locale-aware hosts (deck slides); the persisted
11
+ // document stays locale-neutral.
12
+ "locale",
13
+ ]);
14
+
15
+ function persistentData(source: CanvasNode["data"] | undefined): CanvasNode["data"] {
16
+ const data: Record<string, unknown> = {};
17
+ for (const [key, value] of Object.entries(source ?? {})) {
18
+ if (RUNTIME_DATA_FIELDS.has(key)) continue;
19
+ if (typeof value === "function" || value === undefined) continue;
20
+ data[key] = value;
21
+ }
22
+ return data;
23
+ }
24
+
25
+ /** Strip runtime-only content from nodes for serialization. */
26
+ // Top-level fields are an allowlist, dropping SvelteFlow's runtime state. Data
27
+ // fields keep everything but functions and RUNTIME_DATA_FIELDS, so new fields survive saves.
28
+ export function stripCallbacks<N extends CanvasNode>(nodes: N[]): N[] {
29
+ return nodes.map((n) => {
30
+ // Rebuilt field-by-field; `N` is a subtype of the shape rebuilt here, so the
31
+ // narrowing back to `N` is the one place the type can't follow.
32
+ const result = {
33
+ id: n.id,
34
+ type: n.type,
35
+ position: n.position,
36
+ data: persistentData(n.data),
37
+ } as N;
38
+ if (n.parentId) result.parentId = n.parentId;
39
+ if (n.extent) result.extent = n.extent;
40
+ if (n.style) result.style = n.style;
41
+ if (typeof n.width === "number") result.width = n.width;
42
+ if (typeof n.height === "number") result.height = n.height;
43
+ if (typeof n.zIndex === "number") result.zIndex = n.zIndex;
44
+ return result;
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Remove edges that reference nodes not present in the node list.
50
+ */
51
+ export function removeOrphanedEdges<E extends CanvasEdge>(edges: E[], nodeIds: Set<string>): E[] {
52
+ return edges.filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target));
53
+ }
54
+
55
+ /**
56
+ * Parents before children, as SvelteFlow needs. Roots keep their order,
57
+ * because array order is paint order and flipping it buries new ink.
58
+ */
59
+ export function sortParentsFirst<T extends { id: string; parentId?: string }>(nodes: T[]): T[] {
60
+ const idSet = new Set(nodes.map((n) => n.id));
61
+ const roots: T[] = [];
62
+ const childrenOf = new Map<string, T[]>();
63
+
64
+ for (const n of nodes) {
65
+ if (!n.parentId || !idSet.has(n.parentId)) {
66
+ roots.push(n);
67
+ } else {
68
+ const siblings = childrenOf.get(n.parentId) ?? [];
69
+ siblings.push(n);
70
+ childrenOf.set(n.parentId, siblings);
71
+ }
72
+ }
73
+
74
+ const sorted: T[] = [];
75
+ const stack = roots.toReversed();
76
+ for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
77
+ sorted.push(node);
78
+ const children = childrenOf.get(node.id);
79
+ if (children) {
80
+ for (let i = children.length - 1; i >= 0; i--) {
81
+ stack.push(children[i]);
82
+ }
83
+ }
84
+ }
85
+
86
+ return sorted;
87
+ }
88
+
89
+ /**
90
+ * Fix broken parentId references by clearing parentId/extent
91
+ * when the referenced parent no longer exists.
92
+ */
93
+ export function fixBrokenParentIds<N extends CanvasNode>(nodes: N[]): N[] {
94
+ const nodeIds = new Set(nodes.map((n) => n.id));
95
+ return nodes.map((node) => {
96
+ if (node.parentId && !nodeIds.has(node.parentId)) {
97
+ const { parentId: _, extent: __, ...rest } = node;
98
+ return rest as unknown as N;
99
+ }
100
+ return node;
101
+ });
102
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { bestAnchors, sideDetour, type Rect } from "./edge-anchors.js";
3
+
4
+ const box = (x: number, y: number): Rect => ({ x, y, width: 200, height: 80 });
5
+
6
+ describe("bestAnchors", () => {
7
+ it("joins a box to the one below it, bottom to top, never round the side", () => {
8
+ const { source, target } = bestAnchors(box(0, 0), box(20, 300));
9
+ expect([source.side, target.side]).toEqual(["bottom", "top"]);
10
+ expect(source).toMatchObject({ x: 100, y: 80 });
11
+ expect(target).toMatchObject({ x: 120, y: 300 });
12
+ });
13
+
14
+ it("joins a box to the one above it, top to bottom", () => {
15
+ const { source, target } = bestAnchors(box(0, 300), box(0, 0));
16
+ expect([source.side, target.side]).toEqual(["top", "bottom"]);
17
+ const partial = bestAnchors(box(100, 300), box(0, 0));
18
+ expect([partial.source.side, partial.target.side]).toEqual(["top", "bottom"]);
19
+ });
20
+
21
+ it("meets right to left when the target is beside it", () => {
22
+ const { source, target } = bestAnchors(box(0, 0), box(500, 30));
23
+ expect([source.side, target.side]).toEqual(["right", "left"]);
24
+ expect(source.x).toBe(200);
25
+ expect(source.y).toBe(40);
26
+ expect(target.x).toBe(500);
27
+ });
28
+
29
+ it("meets left to right when the target is to the left", () => {
30
+ const { source, target } = bestAnchors(box(500, 0), box(0, 0));
31
+ expect([source.side, target.side]).toEqual(["left", "right"]);
32
+ });
33
+
34
+ it("takes the axis with the wider gap, not the wider centre offset", () => {
35
+ // 240px apart sideways, 400px apart vertically: the column wins.
36
+ const { source } = bestAnchors(box(0, 0), box(440, 480));
37
+ expect(source.side).toBe("bottom");
38
+ });
39
+
40
+ it("joins overlapping boxes by the side, not through the middle", () => {
41
+ const { source, target } = bestAnchors(box(0, 0), box(10, 10));
42
+ expect([source.side, target.side]).toEqual(["right", "right"]);
43
+ });
44
+ });
45
+
46
+ describe("sideDetour", () => {
47
+ const at = (
48
+ side: "left" | "right" | "top" | "bottom",
49
+ a: [number, number],
50
+ b: [number, number],
51
+ ) => ({
52
+ source: { x: a[0], y: a[1], side },
53
+ target: { x: b[0], y: b[1], side },
54
+ });
55
+
56
+ it("runs past the further right edge, plus the margin", () => {
57
+ const anchors = at("right", [200, 40], [220, 340]);
58
+ expect(sideDetour(anchors)).toBe(260);
59
+ expect(sideDetour(anchors, 10)).toBe(230);
60
+ });
61
+
62
+ it("runs past the further left edge, minus the margin", () => {
63
+ expect(sideDetour(at("left", [0, 40], [200, 340]))).toBe(-40);
64
+ });
65
+
66
+ it("runs past the higher top or lower bottom edge", () => {
67
+ expect(sideDetour(at("top", [100, 50], [300, 20]))).toBe(-20);
68
+ expect(sideDetour(at("bottom", [100, 50], [300, 20]))).toBe(90);
69
+ });
70
+ });