@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,143 @@
1
+ /** Which side of each box an edge leaves from and arrives at. Sides are xyflow `Position` values. */
2
+
3
+ export type Side = "top" | "right" | "bottom" | "left";
4
+ export type Rect = { x: number; y: number; width: number; height: number };
5
+ export type Anchor = { x: number; y: number; side: Side };
6
+
7
+ function center(rect: Rect) {
8
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
9
+ }
10
+
11
+ function anchorOn(rect: Rect, side: Side): Anchor {
12
+ const c = center(rect);
13
+ switch (side) {
14
+ case "top":
15
+ return { x: c.x, y: rect.y, side };
16
+ case "bottom":
17
+ return { x: c.x, y: rect.y + rect.height, side };
18
+ case "left":
19
+ return { x: rect.x, y: c.y, side };
20
+ case "right":
21
+ return { x: rect.x + rect.width, y: c.y, side };
22
+ }
23
+ }
24
+
25
+ /** The side a stored handle id names, or null for an id that isn't one of the four. */
26
+ export function sideFromHandle(id: string | null | undefined): Side | null {
27
+ return id === "top" || id === "right" || id === "bottom" || id === "left" ? id : null;
28
+ }
29
+
30
+ /** The side of `rect` that looks at `point`: the wider pull, scaled by the box's own shape, wins. */
31
+ function sideFacing(rect: Rect, point: { x: number; y: number }): Side {
32
+ const c = center(rect);
33
+ const dx = (point.x - c.x) / rect.width;
34
+ const dy = (point.y - c.y) / rect.height;
35
+ if (Math.abs(dx) > Math.abs(dy)) return dx >= 0 ? "right" : "left";
36
+ return dy >= 0 ? "bottom" : "top";
37
+ }
38
+
39
+ type Pins = { source?: Side | null; target?: Side | null };
40
+
41
+ /** The anchors when the author pinned an end, or null for a link with no pin. */
42
+ function pinnedAnchors(
43
+ source: Rect,
44
+ target: Rect,
45
+ pins: Pins,
46
+ ): { source: Anchor; target: Anchor } | null {
47
+ if (pins.source && pins.target) {
48
+ return { source: anchorOn(source, pins.source), target: anchorOn(target, pins.target) };
49
+ }
50
+ if (pins.source) {
51
+ const from = anchorOn(source, pins.source);
52
+ return { source: from, target: anchorOn(target, sideFacing(target, from)) };
53
+ }
54
+ if (pins.target) {
55
+ const to = anchorOn(target, pins.target);
56
+ return { source: anchorOn(source, sideFacing(source, to)), target: to };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** The sides that face each other. A pin is the dot the author dragged from: it wins, and the free end faces it. */
62
+ export function bestAnchors(
63
+ source: Rect,
64
+ target: Rect,
65
+ pins: Pins = {},
66
+ ): { source: Anchor; target: Anchor } {
67
+ const pinned = pinnedAnchors(source, target, pins);
68
+ if (pinned) return pinned;
69
+
70
+ const gapX = Math.max(target.x - (source.x + source.width), source.x - (target.x + target.width));
71
+ const gapY = Math.max(
72
+ target.y - (source.y + source.height),
73
+ source.y - (target.y + target.height),
74
+ );
75
+
76
+ if (gapX <= 0 && gapY <= 0) {
77
+ const side = detourSide(source, target);
78
+ return { source: anchorOn(source, side), target: anchorOn(target, side) };
79
+ }
80
+
81
+ if (gapX > gapY) {
82
+ const targetIsRight = center(target).x >= center(source).x;
83
+ return {
84
+ source: anchorOn(source, targetIsRight ? "right" : "left"),
85
+ target: anchorOn(target, targetIsRight ? "left" : "right"),
86
+ };
87
+ }
88
+
89
+ const targetIsBelow = center(target).y >= center(source).y;
90
+ return {
91
+ source: anchorOn(source, targetIsBelow ? "bottom" : "top"),
92
+ target: anchorOn(target, targetIsBelow ? "top" : "bottom"),
93
+ };
94
+ }
95
+
96
+ // Which side an overlapping pair travels on: the wider overhang wins, ties go right.
97
+ function detourSide(source: Rect, target: Rect): "right" | "left" {
98
+ const sL = source.x;
99
+ const sR = source.x + source.width;
100
+ const tL = target.x;
101
+ const tR = target.x + target.width;
102
+ if (sL <= tL && sR >= tR) {
103
+ return sR - tR >= tL - sL ? "right" : "left";
104
+ }
105
+ if (tL <= sL && tR >= sR) {
106
+ return tR - sR >= sL - tL ? "right" : "left";
107
+ }
108
+ return "right";
109
+ }
110
+
111
+ /** The x (left, right) or y (top, bottom) a same-side path runs along: one margin past the further box edge. */
112
+ export function sideDetour(anchors: { source: Anchor; target: Anchor }, margin = 40): number {
113
+ const { source, target } = anchors;
114
+ switch (source.side) {
115
+ case "left":
116
+ return Math.min(source.x, target.x) - margin;
117
+ case "right":
118
+ return Math.max(source.x, target.x) + margin;
119
+ case "top":
120
+ return Math.min(source.y, target.y) - margin;
121
+ case "bottom":
122
+ return Math.max(source.y, target.y) + margin;
123
+ }
124
+ }
125
+
126
+ /** The outline an arrow binds to: a box, or the ellipse drawn round it at `rx`, `ry`. */
127
+ export type Outline = { cx: number; cy: number; rx: number; ry: number; round: boolean };
128
+
129
+ /** Where the line from this outline's centre toward `toward` crosses the outline, `gap` further out. */
130
+ export function outlinePoint(o: Outline, toward: { x: number; y: number }, gap = 0) {
131
+ const dx = toward.x - o.cx;
132
+ const dy = toward.y - o.cy;
133
+ const len = Math.hypot(dx, dy);
134
+ if (len === 0) return { x: o.cx, y: o.cy };
135
+ const t = o.round
136
+ ? 1 / Math.hypot(dx / o.rx, dy / o.ry)
137
+ : Math.min(
138
+ dx === 0 ? Infinity : o.rx / Math.abs(dx),
139
+ dy === 0 ? Infinity : o.ry / Math.abs(dy),
140
+ );
141
+ const reach = t * len + gap;
142
+ return { x: o.cx + (dx / len) * reach, y: o.cy + (dy / len) * reach };
143
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from "vite-plus/test";
2
+ import { boxKey, createMemo, parseBox } from "./edge-memo.js";
3
+
4
+ describe("boxKey", () => {
5
+ it("round-trips a box, fractions and negatives included", () => {
6
+ const box = { x: -12.5, y: 40, width: 220, height: 96.25 };
7
+ expect(parseBox(boxKey(box))).toEqual(box);
8
+ });
9
+
10
+ it("reads a card with no size as no box", () => {
11
+ expect(boxKey(null)).toBe("");
12
+ expect(parseBox("")).toBeNull();
13
+ });
14
+
15
+ it("is equal for equal boxes and different when any side moves", () => {
16
+ const base = { x: 1, y: 2, width: 3, height: 4 };
17
+ expect(boxKey({ ...base })).toBe(boxKey(base));
18
+ for (const key of ["x", "y", "width", "height"] as const) {
19
+ expect(boxKey({ ...base, [key]: base[key] + 1 })).not.toBe(boxKey(base));
20
+ }
21
+ });
22
+ });
23
+
24
+ describe("createMemo", () => {
25
+ it("computes once per key", () => {
26
+ const memo = createMemo<number>(10);
27
+ const compute = vi.fn(() => 7);
28
+ expect(memo("a", compute)).toBe(7);
29
+ expect(memo("a", compute)).toBe(7);
30
+ expect(compute).toHaveBeenCalledTimes(1);
31
+ });
32
+
33
+ it("returns the very same object on a hit, so a derived reading it stays equal", () => {
34
+ const memo = createMemo<{ d: string }>(10);
35
+ const first = memo("a", () => ({ d: "M0 0" }));
36
+ expect(memo("a", () => ({ d: "other" }))).toBe(first);
37
+ });
38
+
39
+ it("drops the least recently read key past the limit", () => {
40
+ const memo = createMemo<number>(2);
41
+ memo("a", () => 1);
42
+ memo("b", () => 2);
43
+ memo("a", () => 99);
44
+ memo("c", () => 3);
45
+ const again = vi.fn(() => 0);
46
+ memo("a", again);
47
+ memo("b", again);
48
+ expect(again).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("keeps a falsy result", () => {
52
+ const memo = createMemo<number>(2);
53
+ const compute = vi.fn(() => 0);
54
+ memo("zero", compute);
55
+ memo("zero", compute);
56
+ expect(compute).toHaveBeenCalledTimes(1);
57
+ });
58
+ });
@@ -0,0 +1,32 @@
1
+ // What an arrow needs to know whether to redraw. A box reduces to a string, so a derived
2
+ // reading it stays equal when only another card moved.
3
+
4
+ import type { Rect } from "./edge-anchors.js";
5
+
6
+ /** A box as `x,y,width,height`, or "" for a card with no size yet. */
7
+ export function boxKey(rect: Rect | null | undefined): string {
8
+ return rect ? `${rect.x},${rect.y},${rect.width},${rect.height}` : "";
9
+ }
10
+
11
+ export function parseBox(key: string): Rect | null {
12
+ if (!key) return null;
13
+ const [x, y, width, height] = key.split(",").map(Number);
14
+ return { x, y, width, height };
15
+ }
16
+
17
+ /** Remembers up to `limit` results by key, dropping the least recently read first. */
18
+ export function createMemo<T>(limit: number) {
19
+ const seen = new Map<string, T>();
20
+ return (key: string, compute: () => T): T => {
21
+ const hit = seen.get(key);
22
+ if (hit !== undefined) {
23
+ seen.delete(key);
24
+ seen.set(key, hit);
25
+ return hit;
26
+ }
27
+ const value = compute();
28
+ seen.set(key, value);
29
+ if (seen.size > limit) seen.delete(seen.keys().next().value as string);
30
+ return value;
31
+ };
32
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { serializeFlowEdge } from "./edge-serialization.js";
3
+ import type { Edge } from "@xyflow/svelte";
4
+
5
+ function makeFlowEdge(overrides: Partial<Edge> = {}): Edge {
6
+ return {
7
+ id: overrides.id ?? "e1",
8
+ source: overrides.source ?? "a",
9
+ target: overrides.target ?? "b",
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ describe("serializeFlowEdge", () => {
15
+ it("serializes minimal edge with id, source, target", () => {
16
+ const result = serializeFlowEdge(makeFlowEdge());
17
+ expect(result).toEqual({ id: "e1", source: "a", target: "b" });
18
+ });
19
+
20
+ it("includes type when present", () => {
21
+ const result = serializeFlowEdge(makeFlowEdge({ type: "smoothstep" }));
22
+ expect(result.type).toBe("smoothstep");
23
+ });
24
+
25
+ it("includes label when present", () => {
26
+ const result = serializeFlowEdge(makeFlowEdge({ label: "connects to" }));
27
+ expect(result.label).toBe("connects to");
28
+ });
29
+
30
+ it("includes animated when present", () => {
31
+ const result = serializeFlowEdge(makeFlowEdge({ animated: true }));
32
+ expect(result.animated).toBe(true);
33
+ });
34
+
35
+ it("includes sourceHandle and targetHandle when present", () => {
36
+ const result = serializeFlowEdge(makeFlowEdge({ sourceHandle: "out-1", targetHandle: "in-1" }));
37
+ expect(result.sourceHandle).toBe("out-1");
38
+ expect(result.targetHandle).toBe("in-1");
39
+ });
40
+
41
+ it("includes data when present", () => {
42
+ const result = serializeFlowEdge(makeFlowEdge({ data: { custom: true } }));
43
+ expect(result.data).toEqual({ custom: true });
44
+ });
45
+
46
+ it("omits falsy optional fields", () => {
47
+ const result = serializeFlowEdge(
48
+ makeFlowEdge({ type: undefined, label: undefined, animated: undefined }),
49
+ );
50
+ expect(result).toEqual({ id: "e1", source: "a", target: "b" });
51
+ expect("type" in result).toBe(false);
52
+ expect("label" in result).toBe(false);
53
+ expect("animated" in result).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,32 @@
1
+ import type { Edge } from "@xyflow/svelte";
2
+ import type { CanvasEdge } from "../types.js";
3
+
4
+ export function serializeFlowEdge(edge: Edge): CanvasEdge {
5
+ return {
6
+ id: edge.id,
7
+ source: edge.source,
8
+ target: edge.target,
9
+ ...(edge.type && { type: edge.type }),
10
+ ...(edge.label && { label: edge.label }),
11
+ ...(edge.animated && { animated: edge.animated }),
12
+ ...(edge.sourceHandle && {
13
+ sourceHandle: edge.sourceHandle,
14
+ }),
15
+ ...(edge.targetHandle && {
16
+ targetHandle: edge.targetHandle,
17
+ }),
18
+ ...(edge.style && {
19
+ // WHY: xyflow types `Edge["style"]` as a CSS-properties object (or a
20
+ // string, in older shapes); the persisted `CanvasEdge` just wants the
21
+ // properties bag. Narrowed once here.
22
+ style: edge.style as unknown as Record<string, unknown>,
23
+ }),
24
+ ...(edge.data && {
25
+ data: edge.data,
26
+ }),
27
+ };
28
+ }
29
+
30
+ export function serializeFlowEdges(edges: Edge[]): CanvasEdge[] {
31
+ return edges.map(serializeFlowEdge);
32
+ }
@@ -0,0 +1,293 @@
1
+ // A drawn figure as numbers: its parsed SVG tree and the box each element takes up.
2
+
3
+ /** One SVG element. Text between tags is a plain string child. */
4
+ export type FigureEl = { tag: string; attrs: Record<string, string>; children: FigureChild[] };
5
+ export type FigureChild = FigureEl | string;
6
+
7
+ export type Box = { x1: number; y1: number; x2: number; y2: number };
8
+ export type Pt = { x: number; y: number };
9
+
10
+ const FALLBACK_FONT = 16;
11
+ const ORIGIN: Pt = { x: 0, y: 0 };
12
+
13
+ export function isEl(child: FigureChild): child is FigureEl {
14
+ return typeof child !== "string";
15
+ }
16
+
17
+ export function num(value: string | undefined, fallback = 0): number {
18
+ const parsed = Number.parseFloat(value ?? "");
19
+ return Number.isFinite(parsed) ? parsed : fallback;
20
+ }
21
+
22
+ /** A paint that draws: set, and not `none`. */
23
+ export function solid(value: string | undefined): boolean {
24
+ return Boolean(value) && value !== "none";
25
+ }
26
+
27
+ export function plus(a: Pt, b: Pt): Pt {
28
+ return { x: a.x + b.x, y: a.y + b.y };
29
+ }
30
+
31
+ /** `translate(a b)` and nothing else. Any other transform makes the geometry unreadable: null. */
32
+ export function translateOf(el: FigureEl): Pt | null {
33
+ const raw = el.attrs.transform?.trim();
34
+ if (!raw) return { x: 0, y: 0 };
35
+ const match = raw.match(/^translate\(\s*(-?[\d.]+)[\s,]*(-?[\d.]+)?\s*\)$/);
36
+ return match ? { x: Number(match[1]), y: Number(match[2] ?? 0) } : null;
37
+ }
38
+
39
+ export function union(a: Box | null, b: Box | null): Box | null {
40
+ if (!a) return b;
41
+ if (!b) return a;
42
+ return {
43
+ x1: Math.min(a.x1, b.x1),
44
+ y1: Math.min(a.y1, b.y1),
45
+ x2: Math.max(a.x2, b.x2),
46
+ y2: Math.max(a.y2, b.y2),
47
+ };
48
+ }
49
+
50
+ export function boxOf(points: Pt[]): Box | null {
51
+ if (points.length === 0) return null;
52
+ return {
53
+ x1: Math.min(...points.map((p) => p.x)),
54
+ y1: Math.min(...points.map((p) => p.y)),
55
+ x2: Math.max(...points.map((p) => p.x)),
56
+ y2: Math.max(...points.map((p) => p.y)),
57
+ };
58
+ }
59
+
60
+ export function within(inner: Box, outer: Box, tolerance = 1): boolean {
61
+ return (
62
+ inner.x1 >= outer.x1 - tolerance &&
63
+ inner.y1 >= outer.y1 - tolerance &&
64
+ inner.x2 <= outer.x2 + tolerance &&
65
+ inner.y2 <= outer.y2 + tolerance
66
+ );
67
+ }
68
+
69
+ export function contains(box: Box, point: Pt, tolerance: number): boolean {
70
+ return (
71
+ point.x >= box.x1 - tolerance &&
72
+ point.x <= box.x2 + tolerance &&
73
+ point.y >= box.y1 - tolerance &&
74
+ point.y <= box.y2 + tolerance
75
+ );
76
+ }
77
+
78
+ export const areaOf = (b: Box) => (b.x2 - b.x1) * (b.y2 - b.y1);
79
+ export const centerOf = (b: Box): Pt => ({ x: (b.x1 + b.x2) / 2, y: (b.y1 + b.y2) / 2 });
80
+
81
+ export function distanceToBox(box: Box, p: Pt): number {
82
+ return Math.hypot(
83
+ Math.max(box.x1 - p.x, 0, p.x - box.x2),
84
+ Math.max(box.y1 - p.y, 0, p.y - box.y2),
85
+ );
86
+ }
87
+
88
+ export function distanceToSegment(a: Pt, b: Pt, p: Pt): number {
89
+ const dx = b.x - a.x;
90
+ const dy = b.y - a.y;
91
+ const len = dx * dx + dy * dy;
92
+ const t = len === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len));
93
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
94
+ }
95
+
96
+ // Numbers per path command; the last two (or the one for H and V) are the end point.
97
+ const PATH_ARITY: Record<string, number> = {
98
+ M: 2,
99
+ L: 2,
100
+ H: 1,
101
+ V: 1,
102
+ C: 6,
103
+ S: 4,
104
+ Q: 4,
105
+ T: 2,
106
+ A: 7,
107
+ Z: 0,
108
+ };
109
+
110
+ /** Where one command ends, given its arguments and where the pen was. */
111
+ function pathEnd(upper: string, rel: boolean, args: number[], cur: Pt): Pt {
112
+ const at = (from: number, value: number) => (rel ? from + value : value);
113
+ if (upper === "H") return { x: at(cur.x, args[0]), y: cur.y };
114
+ if (upper === "V") return { x: cur.x, y: at(cur.y, args[0]) };
115
+ return { x: at(cur.x, args[args.length - 2]), y: at(cur.y, args[args.length - 1]) };
116
+ }
117
+
118
+ /** The vertices a path passes through. Curve control points are skipped: the ends bound it well enough. */
119
+ export function pathPoints(d: string): Pt[] {
120
+ const out: Pt[] = [];
121
+ let cur: Pt = { x: 0, y: 0 };
122
+ let start: Pt = { x: 0, y: 0 };
123
+ for (const part of d.matchAll(/([a-zA-Z])([^a-zA-Z]*)/g)) {
124
+ const cmd = part[1];
125
+ const upper = cmd.toUpperCase();
126
+ const size = PATH_ARITY[upper];
127
+ if (size === undefined) continue;
128
+ const nums = (part[2].match(/-?(?:\d+\.?\d*|\.\d+)(?:e-?\d+)?/gi) ?? []).map(Number);
129
+ if (upper === "Z") {
130
+ cur = start;
131
+ out.push(cur);
132
+ continue;
133
+ }
134
+ for (let i = 0; i + size <= nums.length; i += size) {
135
+ cur = pathEnd(upper, cmd !== upper, nums.slice(i, i + size), cur);
136
+ if (upper === "M" && i === 0) start = cur;
137
+ out.push(cur);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export function pointList(raw: string | undefined): Pt[] {
144
+ const nums = (raw ?? "").match(/-?(?:\d+\.?\d*|\.\d+)/g)?.map(Number) ?? [];
145
+ const out: Pt[] = [];
146
+ for (let i = 0; i + 1 < nums.length; i += 2) out.push({ x: nums[i], y: nums[i + 1] });
147
+ return out;
148
+ }
149
+
150
+ export function textLines(el: FigureEl): string[] {
151
+ const lines: string[] = [];
152
+ let line = "";
153
+ const flush = () => {
154
+ const clean = line.replace(/\s+/g, " ").trim();
155
+ if (clean) lines.push(clean);
156
+ line = "";
157
+ };
158
+ for (const child of el.children) {
159
+ if (typeof child === "string") line += child;
160
+ else if (child.tag === "tspan") {
161
+ if ("dy" in child.attrs || "y" in child.attrs) flush();
162
+ line += child.children.map((c) => (typeof c === "string" ? c : "")).join("");
163
+ }
164
+ }
165
+ flush();
166
+ return lines;
167
+ }
168
+
169
+ export function isBold(el: FigureEl): boolean {
170
+ const weight = el.attrs["font-weight"];
171
+ return num(weight) >= 600 || weight === "bold";
172
+ }
173
+
174
+ export function fontSize(el: FigureEl): number {
175
+ return num(el.attrs["font-size"], FALLBACK_FONT);
176
+ }
177
+
178
+ function rectBox({ attrs: a }: FigureEl): Box {
179
+ return { x1: num(a.x), y1: num(a.y), x2: num(a.x) + num(a.width), y2: num(a.y) + num(a.height) };
180
+ }
181
+
182
+ function ellipseBox({ attrs: a }: FigureEl): Box {
183
+ const rx = num(a.rx, num(a.r));
184
+ const ry = num(a.ry, num(a.r));
185
+ return { x1: num(a.cx) - rx, y1: num(a.cy) - ry, x2: num(a.cx) + rx, y2: num(a.cy) + ry };
186
+ }
187
+
188
+ function lineBox({ attrs: a }: FigureEl): Box | null {
189
+ return boxOf([
190
+ { x: num(a.x1), y: num(a.y1) },
191
+ { x: num(a.x2), y: num(a.y2) },
192
+ ]);
193
+ }
194
+
195
+ function polyBox({ attrs: a }: FigureEl): Box | null {
196
+ return boxOf(pointList(a.points));
197
+ }
198
+
199
+ function pathBox({ attrs: a }: FigureEl): Box | null {
200
+ return boxOf(pathPoints(a.d ?? ""));
201
+ }
202
+
203
+ function textLeft(x: number, width: number, anchor: string | undefined): number {
204
+ if (anchor === "middle") return x - width / 2;
205
+ return anchor === "end" ? x - width : x;
206
+ }
207
+
208
+ function textBox(el: FigureEl): Box {
209
+ const lines = textLines(el);
210
+ const size = fontSize(el);
211
+ const width = Math.max(0, ...lines.map((l) => l.length)) * size * (isBold(el) ? 0.6 : 0.55);
212
+ const left = textLeft(num(el.attrs.x), width, el.attrs["text-anchor"]);
213
+ const y = num(el.attrs.y);
214
+ return {
215
+ x1: left,
216
+ y1: y - size * 0.8,
217
+ x2: left + width,
218
+ y2: y + size * 0.2 + Math.max(0, lines.length - 1) * size * 1.2,
219
+ };
220
+ }
221
+
222
+ function groupBox(el: FigureEl): Box | null {
223
+ let box: Box | null = null;
224
+ for (const child of el.children) {
225
+ if (isEl(child)) box = union(box, boxOfEl(child, ORIGIN));
226
+ }
227
+ return box;
228
+ }
229
+
230
+ const MEASURE = new Map<string, (el: FigureEl) => Box | null>([
231
+ ["rect", rectBox],
232
+ ["circle", ellipseBox],
233
+ ["ellipse", ellipseBox],
234
+ ["line", lineBox],
235
+ ["polyline", polyBox],
236
+ ["polygon", polyBox],
237
+ ["path", pathBox],
238
+ ["text", textBox],
239
+ ["g", groupBox],
240
+ ["svg", groupBox],
241
+ ]);
242
+
243
+ /** The ink a shape takes, in the figure's own units. Text is estimated: no DOM here to measure it. */
244
+ export function boxOfEl(el: FigureEl, offset: Pt): Box | null {
245
+ const box = MEASURE.get(el.tag)?.(el);
246
+ if (!box) return null;
247
+ const at = plus(offset, translateOf(el) ?? ORIGIN);
248
+ return { x1: box.x1 + at.x, y1: box.y1 + at.y, x2: box.x2 + at.x, y2: box.y2 + at.y };
249
+ }
250
+
251
+ export function visible(el: FigureEl): boolean {
252
+ const fill = el.attrs.fill;
253
+ const stroke = el.attrs.stroke;
254
+ return (fill !== undefined && fill !== "none") || (stroke !== undefined && stroke !== "none");
255
+ }
256
+
257
+ export function viewBoxOf(root: FigureEl): Box | null {
258
+ const raw = root.attrs.viewBox ?? root.attrs.viewbox;
259
+ if (raw) {
260
+ const [x, y, w, h] = raw
261
+ .trim()
262
+ .split(/[\s,]+/)
263
+ .map(Number);
264
+ if (w > 0 && h > 0) return { x1: x, y1: y, x2: x + w, y2: y + h };
265
+ }
266
+ const w = num(root.attrs.width);
267
+ const h = num(root.attrs.height);
268
+ return w > 0 && h > 0 ? { x1: 0, y1: 0, x2: w, y2: h } : null;
269
+ }
270
+
271
+ export function hasText(el: FigureEl): boolean {
272
+ return el.tag === "text" && textLines(el).length > 0;
273
+ }
274
+
275
+ /** Where a line, polyline or path starts and stops, on the figure. Null for one with a transform we can't read. */
276
+ export function strokeEnds(el: FigureEl, offset: Pt): [Pt, Pt] | null {
277
+ const shift = translateOf(el);
278
+ if (!shift) return null;
279
+ const a = el.attrs;
280
+ let pts: Pt[] = [];
281
+ if (el.tag === "line") {
282
+ pts = [
283
+ { x: num(a.x1), y: num(a.y1) },
284
+ { x: num(a.x2), y: num(a.y2) },
285
+ ];
286
+ } else if (el.tag === "polyline") pts = pointList(a.points);
287
+ else if (el.tag === "path") pts = pathPoints(a.d ?? "");
288
+ if (pts.length < 2) return null;
289
+ const at = plus(offset, shift);
290
+ const first = pts[0];
291
+ const last = pts[pts.length - 1];
292
+ return [plus(first, at), plus(last, at)];
293
+ }