@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,347 @@
1
+ // Granular canvas mutations: pure functions over a board's nodes and edges. These are the
2
+ // ops every canvas has. A plugin adds its own kinds through `OpDefinition` (see plugin.ts).
3
+
4
+ import { z } from "zod";
5
+ import type { OpDefinition, OpLike, OpState } from "../plugin.js";
6
+ import type { RawCanvasData } from "../types.js";
7
+
8
+ // =========================
9
+ // SCHEMAS
10
+ // =========================
11
+
12
+ const positionSchema = z.object({ x: z.number(), y: z.number() });
13
+
14
+ export const addNodeOp = z.object({
15
+ kind: z.literal("addNode"),
16
+ node: z.object({
17
+ id: z.string().min(1).optional(),
18
+ type: z.string().min(1),
19
+ position: positionSchema.optional(),
20
+ data: z.record(z.string(), z.unknown()),
21
+ // A container is created at its real size in one op, with no second
22
+ // setParent op per child.
23
+ style: z.record(z.string(), z.unknown()).optional(),
24
+ width: z.number().optional(),
25
+ height: z.number().optional(),
26
+ parentId: z.string().min(1).optional(),
27
+ }),
28
+ });
29
+
30
+ export const removeNodeOp = z.object({
31
+ kind: z.literal("removeNode"),
32
+ nodeId: z.string().min(1),
33
+ });
34
+
35
+ export const patchNodeOp = z.object({
36
+ kind: z.literal("patchNode"),
37
+ nodeId: z.string().min(1),
38
+ data: z.record(z.string(), z.unknown()).optional(),
39
+ // Merged into the node's style object. A legacy CSS-string style is replaced
40
+ // wholesale (there is nothing sane to merge into).
41
+ style: z.record(z.string(), z.unknown()).optional(),
42
+ width: z.number().optional(),
43
+ height: z.number().optional(),
44
+ });
45
+
46
+ export const moveNodeOp = z.object({
47
+ kind: z.literal("moveNode"),
48
+ nodeId: z.string().min(1),
49
+ position: positionSchema,
50
+ });
51
+
52
+ export const setParentOp = z.object({
53
+ kind: z.literal("setParent"),
54
+ nodeId: z.string().min(1),
55
+ // null detaches the node from its parent; "parent" extent confines it to the parent bounds.
56
+ parentId: z.string().min(1).nullable(),
57
+ extent: z.literal("parent").nullable().optional(),
58
+ });
59
+
60
+ export const addEdgeOp = z.object({
61
+ kind: z.literal("addEdge"),
62
+ edge: z.object({
63
+ id: z.string().min(1).optional(),
64
+ source: z.string().min(1),
65
+ target: z.string().min(1),
66
+ sourceHandle: z.string().optional(),
67
+ targetHandle: z.string().optional(),
68
+ type: z.string().optional(),
69
+ label: z.string().optional(),
70
+ animated: z.boolean().optional(),
71
+ style: z.record(z.string(), z.unknown()).optional(),
72
+ data: z.record(z.string(), z.unknown()).optional(),
73
+ }),
74
+ });
75
+
76
+ export const removeEdgeOp = z.object({
77
+ kind: z.literal("removeEdge"),
78
+ edgeId: z.string().min(1),
79
+ });
80
+
81
+ export const patchEdgeOp = z.object({
82
+ kind: z.literal("patchEdge"),
83
+ edgeId: z.string().min(1),
84
+ patch: z
85
+ .object({
86
+ label: z.string().optional(),
87
+ animated: z.boolean().optional(),
88
+ type: z.string().optional(),
89
+ style: z.record(z.string(), z.unknown()).optional(),
90
+ /** Replaces the edge's data whole: send every field it should keep. */
91
+ data: z.record(z.string(), z.unknown()).optional(),
92
+ sourceHandle: z.string().optional(),
93
+ targetHandle: z.string().optional(),
94
+ /** Re-point the edge. The new endpoint must be a node on the board. */
95
+ source: z.string().min(1).optional(),
96
+ target: z.string().min(1).optional(),
97
+ })
98
+ .refine((v) => Object.keys(v).length > 0, { message: "patch must include at least one field" }),
99
+ });
100
+
101
+ /** Every schema the canvas ships, for an app that builds its own discriminated union. */
102
+ export const CORE_OP_SCHEMAS = [
103
+ addNodeOp,
104
+ removeNodeOp,
105
+ patchNodeOp,
106
+ moveNodeOp,
107
+ setParentOp,
108
+ addEdgeOp,
109
+ removeEdgeOp,
110
+ patchEdgeOp,
111
+ ] as const;
112
+
113
+ export const canvasOpSchema = z.discriminatedUnion("kind", [...CORE_OP_SCHEMAS]);
114
+
115
+ export type CanvasOp = z.infer<typeof canvasOpSchema>;
116
+
117
+ // =========================
118
+ // ERRORS AND HELPERS
119
+ // =========================
120
+
121
+ export type { OpLike };
122
+ export type GenericRecord = Record<string, unknown>;
123
+
124
+ export class CanvasOpError extends Error {
125
+ constructor(
126
+ message: string,
127
+ readonly opIndex: number,
128
+ ) {
129
+ super(message);
130
+ this.name = "CanvasOpError";
131
+ }
132
+ }
133
+
134
+ export function randomId(): string {
135
+ return crypto.randomUUID();
136
+ }
137
+
138
+ export function getNodeId(node: GenericRecord): string | undefined {
139
+ return typeof node.id === "string" ? node.id : undefined;
140
+ }
141
+
142
+ export function getNodeData(node: GenericRecord): GenericRecord {
143
+ return typeof node.data === "object" && node.data !== null ? (node.data as GenericRecord) : {};
144
+ }
145
+
146
+ export function hasNode(nodes: GenericRecord[], id: string): boolean {
147
+ return nodes.some((n) => getNodeId(n) === id);
148
+ }
149
+
150
+ /** Every node-targeting op shares one find-map-or-throw, so each handler only states its transform. */
151
+ export function mapNodeOrThrow(
152
+ nodes: GenericRecord[],
153
+ nodeId: string,
154
+ opIndex: number,
155
+ transform: (node: GenericRecord) => GenericRecord,
156
+ ): GenericRecord[] {
157
+ let found = false;
158
+ const next = nodes.map((n) => {
159
+ if (getNodeId(n) !== nodeId) return n;
160
+ found = true;
161
+ return transform(n);
162
+ });
163
+ if (!found) throw new CanvasOpError(`Unknown nodeId: ${nodeId}`, opIndex);
164
+ return next;
165
+ }
166
+
167
+ /** A copy of `list` with `item` inserted at `index`, clamped, or appended. */
168
+ export function insertAt<T>(list: T[], item: T, index: number | undefined): T[] {
169
+ const next = [...list];
170
+ next.splice(Math.min(index ?? next.length, next.length), 0, item);
171
+ return next;
172
+ }
173
+
174
+ export function assertIndex(index: number, length: number, what: string, opIndex: number): void {
175
+ if (index >= length) throw new CanvasOpError(`${what} index ${index} out of bounds`, opIndex);
176
+ }
177
+
178
+ /** Replace some of a node's data fields, throwing if `nodeId` is unknown. */
179
+ export function patchData(
180
+ state: OpState,
181
+ nodeId: string,
182
+ opIndex: number,
183
+ fields: (data: GenericRecord) => GenericRecord,
184
+ ): void {
185
+ state.nodes = mapNodeOrThrow(state.nodes, nodeId, opIndex, (n) => {
186
+ const nodeData = getNodeData(n);
187
+ return { ...n, data: { ...nodeData, ...fields(nodeData) } };
188
+ });
189
+ }
190
+
191
+ type Op<K extends CanvasOp["kind"]> = Extract<CanvasOp, { kind: K }>;
192
+
193
+ // =========================
194
+ // NODE OPS
195
+ // =========================
196
+
197
+ function assertParentExists(nodes: GenericRecord[], nodeId: string, parentId: string, i: number) {
198
+ if (parentId === nodeId) throw new CanvasOpError(`Node cannot be its own parent: ${nodeId}`, i);
199
+ if (!hasNode(nodes, parentId)) throw new CanvasOpError(`Unknown parentId: ${parentId}`, i);
200
+ }
201
+
202
+ function addNode(state: OpState, op: Op<"addNode">, i: number): void {
203
+ const { node } = op;
204
+ const id = node.id ?? randomId();
205
+ if (hasNode(state.nodes, id)) throw new CanvasOpError(`Node id already exists: ${id}`, i);
206
+ if (node.parentId) assertParentExists(state.nodes, id, node.parentId, i);
207
+ state.nodes = [
208
+ ...state.nodes,
209
+ {
210
+ id,
211
+ type: node.type,
212
+ position: node.position ?? { x: 0, y: 0 },
213
+ data: node.data,
214
+ ...(node.style && { style: node.style }),
215
+ ...(node.width != null && { width: node.width }),
216
+ ...(node.height != null && { height: node.height }),
217
+ // A parented node with no extent escapes its container in xyflow, so
218
+ // parentId implies extent right here at creation.
219
+ ...(node.parentId && { parentId: node.parentId, extent: "parent" }),
220
+ },
221
+ ];
222
+ }
223
+
224
+ function removeNode(state: OpState, op: Op<"removeNode">, i: number): void {
225
+ if (!hasNode(state.nodes, op.nodeId)) {
226
+ throw new CanvasOpError(`Unknown nodeId: ${op.nodeId}`, i);
227
+ }
228
+ state.nodes = state.nodes.filter((n) => getNodeId(n) !== op.nodeId);
229
+ state.edges = state.edges.filter((e) => e.source !== op.nodeId && e.target !== op.nodeId);
230
+ }
231
+
232
+ function patchNodeRecord(n: GenericRecord, op: Op<"patchNode">): GenericRecord {
233
+ const next: GenericRecord = { ...n };
234
+ if (op.data) next.data = { ...getNodeData(n), ...op.data };
235
+ if (op.style) {
236
+ const existing =
237
+ typeof n.style === "object" && n.style !== null ? (n.style as GenericRecord) : {};
238
+ next.style = { ...existing, ...op.style };
239
+ }
240
+ if (op.width != null) next.width = op.width;
241
+ if (op.height != null) next.height = op.height;
242
+ return next;
243
+ }
244
+
245
+ function patchNode(state: OpState, op: Op<"patchNode">, i: number): void {
246
+ if (!op.data && !op.style && op.width == null && op.height == null) {
247
+ throw new CanvasOpError("patchNode requires data, style, width or height", i);
248
+ }
249
+ state.nodes = mapNodeOrThrow(state.nodes, op.nodeId, i, (n) => patchNodeRecord(n, op));
250
+ }
251
+
252
+ function moveNode(state: OpState, op: Op<"moveNode">, i: number): void {
253
+ state.nodes = mapNodeOrThrow(state.nodes, op.nodeId, i, (n) => ({
254
+ ...n,
255
+ position: { x: op.position.x, y: op.position.y },
256
+ }));
257
+ }
258
+
259
+ function setParent(state: OpState, op: Op<"setParent">, i: number): void {
260
+ const { parentId } = op;
261
+ if (parentId !== null) assertParentExists(state.nodes, op.nodeId, parentId, i);
262
+ state.nodes = mapNodeOrThrow(state.nodes, op.nodeId, i, (n) => {
263
+ const { parentId: _p, extent: _e, ...rest } = n;
264
+ if (parentId === null) return rest;
265
+ const extent = op.extent === null ? undefined : (op.extent ?? "parent");
266
+ return extent ? { ...rest, parentId, extent } : { ...rest, parentId };
267
+ });
268
+ }
269
+
270
+ // =========================
271
+ // EDGE OPS
272
+ // =========================
273
+
274
+ function addEdge(state: OpState, op: Op<"addEdge">, i: number): void {
275
+ const id = op.edge.id ?? randomId();
276
+ if (state.edges.some((e) => e.id === id)) {
277
+ throw new CanvasOpError(`Edge id already exists: ${id}`, i);
278
+ }
279
+ if (!hasNode(state.nodes, op.edge.source)) {
280
+ throw new CanvasOpError(`Edge source not found: ${op.edge.source}`, i);
281
+ }
282
+ if (!hasNode(state.nodes, op.edge.target)) {
283
+ throw new CanvasOpError(`Edge target not found: ${op.edge.target}`, i);
284
+ }
285
+ state.edges = [...state.edges, { id, ...op.edge }];
286
+ }
287
+
288
+ function removeEdge(state: OpState, op: Op<"removeEdge">, i: number): void {
289
+ if (!state.edges.some((e) => e.id === op.edgeId)) {
290
+ throw new CanvasOpError(`Unknown edgeId: ${op.edgeId}`, i);
291
+ }
292
+ state.edges = state.edges.filter((e) => e.id !== op.edgeId);
293
+ }
294
+
295
+ function patchEdge(state: OpState, op: Op<"patchEdge">, i: number): void {
296
+ const { source, target } = op.patch;
297
+ if (source !== undefined && !hasNode(state.nodes, source)) {
298
+ throw new CanvasOpError(`Edge source not found: ${source}`, i);
299
+ }
300
+ if (target !== undefined && !hasNode(state.nodes, target)) {
301
+ throw new CanvasOpError(`Edge target not found: ${target}`, i);
302
+ }
303
+ let found = false;
304
+ state.edges = state.edges.map((e) => {
305
+ if (e.id !== op.edgeId) return e;
306
+ found = true;
307
+ return { ...e, ...op.patch };
308
+ });
309
+ if (!found) throw new CanvasOpError(`Unknown edgeId: ${op.edgeId}`, i);
310
+ }
311
+
312
+ // =========================
313
+ // APPLY
314
+ // =========================
315
+
316
+ const CORE_APPLY: Record<string, (state: OpState, op: never, i: number) => void> = {
317
+ addNode,
318
+ removeNode,
319
+ patchNode,
320
+ moveNode,
321
+ setParent,
322
+ addEdge,
323
+ removeEdge,
324
+ patchEdge,
325
+ };
326
+
327
+ /**
328
+ * Apply `ops` in order and return the new board. `definitions` are the op kinds a plugin
329
+ * adds. Unknown kinds throw, so a typo never passes as a no-op.
330
+ */
331
+ export function applyOps(
332
+ data: RawCanvasData,
333
+ ops: readonly OpLike[],
334
+ definitions: readonly OpDefinition[] = [],
335
+ ): RawCanvasData {
336
+ const state: OpState = { nodes: [...data.nodes], edges: [...data.edges] };
337
+ const byKind = new Map(definitions.map((def) => [def.kind, def]));
338
+ ops.forEach((op, i) => {
339
+ const core = CORE_APPLY[op.kind];
340
+ if (core) return core(state, op as never, i);
341
+ const def = byKind.get(op.kind);
342
+ if (!def) throw new CanvasOpError(`Unknown op kind: ${op.kind}`, i);
343
+ def.apply(state, op, i);
344
+ });
345
+ const { nodes, edges } = state;
346
+ return data.viewport ? { nodes, edges, viewport: data.viewport } : { nodes, edges };
347
+ }
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { useTextField, type TextFieldHandle } from "../editor/text-field-context.js";
6
+ import { useHandDrawn } from "../editor/hand-context.js";
7
+ import "./pen.css";
8
+
9
+ // The words of a pen text or a shape label: the host's field when it gave one (formatted text,
10
+ // its own bar and undo), else a plain textarea.
11
+
12
+ // =========================
13
+ // TYPES
14
+ // =========================
15
+ interface Props {
16
+ value: string;
17
+ /** Which pen element it sits in. Sets the size and the box it keeps. */
18
+ variant: "text" | "label";
19
+ onCommit: (next: string, live: boolean) => void;
20
+ ariaLabel: string;
21
+ /** Typing needs the canvas editor; without one the words are shown, not changed. */
22
+ readonly?: boolean;
23
+ selected?: boolean;
24
+ color?: string;
25
+ fontSize?: number;
26
+ fontWeight?: number;
27
+ center?: boolean;
28
+ column?: boolean;
29
+ /** Called when the field closes. */
30
+ onExit?: () => void;
31
+ }
32
+
33
+ // =========================
34
+ // PROPS
35
+ // =========================
36
+ let {
37
+ value,
38
+ variant,
39
+ onCommit,
40
+ ariaLabel,
41
+ readonly = false,
42
+ selected = false,
43
+ color,
44
+ fontSize,
45
+ fontWeight,
46
+ center = false,
47
+ column = false,
48
+ onExit,
49
+ }: Props = $props();
50
+
51
+ // =========================
52
+ // STATE
53
+ // =========================
54
+ const Field = useTextField();
55
+ const handDrawn = useHandDrawn();
56
+ let textarea = $state<HTMLTextAreaElement>();
57
+ let field = $state<TextFieldHandle>();
58
+ let editing = $state(false);
59
+
60
+ // =========================
61
+ // DERIVED
62
+ // =========================
63
+ let style = $derived(
64
+ [color && `color: ${color}`, fontSize && `font-size: ${fontSize}px`, fontWeight && `font-weight: ${fontWeight}`, center && "text-align: center"]
65
+ .filter(Boolean)
66
+ .join("; "),
67
+ );
68
+
69
+ // =========================
70
+ // FUNCTIONS
71
+ // =========================
72
+ /** Open the field for typing. A host field opens where `point` landed, else at the end. */
73
+ export function edit(point?: { x: number; y: number }) {
74
+ if (readonly) return;
75
+ if (field) return field.edit(point);
76
+ textarea?.focus();
77
+ }
78
+
79
+ function close() {
80
+ editing = false;
81
+ onExit?.();
82
+ }
83
+
84
+ // A text you aren't typing in drags like any card, so a first press must not focus it.
85
+ function press(event: MouseEvent) {
86
+ if (variant === "text" && !editing) event.preventDefault();
87
+ }
88
+ </script>
89
+
90
+ <!-- =========================== -->
91
+ <!-- MARKUP -->
92
+ <!-- =========================== -->
93
+ {#if Field}
94
+ <Field
95
+ bind:this={field}
96
+ {value}
97
+ {onCommit}
98
+ {ariaLabel}
99
+ {readonly}
100
+ {style}
101
+ class="pen-field is-{variant} is-rich {handDrawn() ? 'hand' : ''} {selected ? 'selected' : ''} {column ? 'column' : ''}"
102
+ clickToEdit={variant === "label"}
103
+ onExit={close}
104
+ />
105
+ {:else}
106
+ <textarea
107
+ bind:this={textarea}
108
+ class="pen-field is-{variant}"
109
+ class:selected
110
+ class:hand={handDrawn()}
111
+ class:column
112
+ class:nodrag={variant === "label" || editing}
113
+ rows="1"
114
+ {value}
115
+ {style}
116
+ {readonly}
117
+ aria-label={ariaLabel}
118
+ oninput={(event) => onCommit(event.currentTarget.value, true)}
119
+ onfocus={() => (editing = true)}
120
+ onmousedown={press}
121
+ onblur={close}
122
+ onkeydown={(event) => {
123
+ // Typing must never reach the canvas shortcuts, where Delete would remove the node.
124
+ event.stopPropagation();
125
+ if (event.key === "Escape") event.currentTarget.blur();
126
+ }}
127
+ ></textarea>
128
+ {/if}
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
+ import { flushSync, mount, unmount } from "svelte";
3
+ import PenField from "./PenField.svelte";
4
+ import HostField from "./host-field.test.svelte";
5
+ import { TEXT_FIELD_KEY, type TextFieldComponent } from "../editor/text-field-context.js";
6
+
7
+ let app: ReturnType<typeof mount> | null = null;
8
+ const target = () => document.body.appendChild(document.createElement("div"));
9
+
10
+ function mountField(props: Record<string, unknown>, host?: TextFieldComponent) {
11
+ app = mount(PenField, {
12
+ target: target(),
13
+ props: {
14
+ value: "**hi**",
15
+ variant: "text",
16
+ ariaLabel: "Text",
17
+ onCommit: () => {},
18
+ ...props,
19
+ } as never,
20
+ ...(host && { context: new Map([[TEXT_FIELD_KEY, host]]) }),
21
+ });
22
+ flushSync();
23
+ return app as unknown as { edit: (point?: { x: number; y: number }) => void };
24
+ }
25
+
26
+ afterEach(() => {
27
+ if (app) unmount(app);
28
+ app = null;
29
+ document.body.innerHTML = "";
30
+ });
31
+
32
+ describe("the pen's text field slot", () => {
33
+ it("draws a plain textarea when the host gave no field", () => {
34
+ mountField({});
35
+ const area = document.querySelector("textarea");
36
+ expect(area?.value).toBe("**hi**");
37
+ expect(document.querySelector("[data-host-field]")).toBeNull();
38
+ });
39
+
40
+ it("writes typing through onCommit as a live edit", () => {
41
+ const onCommit = vi.fn();
42
+ mountField({ onCommit });
43
+ const area = document.querySelector("textarea") as HTMLTextAreaElement;
44
+ area.value = "new words";
45
+ area.dispatchEvent(new Event("input", { bubbles: true }));
46
+ expect(onCommit).toHaveBeenCalledWith("new words", true);
47
+ });
48
+
49
+ it("keeps the textarea read-only where the canvas can't edit", () => {
50
+ mountField({ readonly: true });
51
+ expect((document.querySelector("textarea") as HTMLTextAreaElement).readOnly).toBe(true);
52
+ });
53
+
54
+ it("opens the textarea on edit() and reports the close", () => {
55
+ const onExit = vi.fn();
56
+ const field = mountField({ onExit });
57
+ field.edit();
58
+ const area = document.querySelector("textarea") as HTMLTextAreaElement;
59
+ expect(document.activeElement).toBe(area);
60
+ area.blur();
61
+ expect(onExit).toHaveBeenCalledOnce();
62
+ });
63
+
64
+ it("renders the host's field in place of the textarea when one is given", () => {
65
+ mountField({ variant: "label" }, HostField as unknown as TextFieldComponent);
66
+ expect(document.querySelector("textarea")).toBeNull();
67
+ const host = document.querySelector("[data-host-field]");
68
+ expect(host?.textContent).toBe("**hi**");
69
+ expect(host?.className).toContain("pen-field is-label");
70
+ });
71
+
72
+ it("hands edit() to the host's field, and never to a read-only one", () => {
73
+ const field = mountField({}, HostField as unknown as TextFieldComponent);
74
+ field.edit();
75
+ flushSync();
76
+ expect(document.querySelector("[data-host-field]")?.getAttribute("data-opened")).toBe("1");
77
+ if (app) unmount(app);
78
+ const locked = mountField({ readonly: true }, HostField as unknown as TextFieldComponent);
79
+ locked.edit();
80
+ flushSync();
81
+ expect(
82
+ document
83
+ .querySelector("[data-host-field][data-readonly='true']")
84
+ ?.getAttribute("data-opened"),
85
+ ).toBe("0");
86
+ });
87
+ });