@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,172 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import "./pen.css";
6
+ import PreviewSvg from "../components/shared/svg/PreviewSvg.svelte";
7
+ import type { ErasableNode } from "./eraser.js";
8
+ import type { PenOp } from "./ops.js";
9
+ import { createPenSession, type PenPreview } from "./pen-session.js";
10
+ import type { PenColor, PenTool } from "./types.js";
11
+
12
+ // =========================
13
+ // TYPES
14
+ // =========================
15
+ interface Props {
16
+ tool: PenTool;
17
+ color?: PenColor;
18
+ /** Brush width in flow units. Unset takes the tool's default. */
19
+ size?: number;
20
+ /** The xyflow viewport, so screen pixels map to flow units. */
21
+ viewport: { x: number; y: number; zoom: number };
22
+ nodes: readonly ErasableNode[];
23
+ commit: (ops: PenOp[]) => void;
24
+ startGesture: () => () => void;
25
+ onTextPlaced?: (nodeId: string) => void;
26
+ }
27
+
28
+ // =========================
29
+ // PROPS
30
+ // =========================
31
+ let {
32
+ tool,
33
+ color = "ink",
34
+ size,
35
+ viewport,
36
+ nodes,
37
+ commit,
38
+ startGesture,
39
+ onTextPlaced,
40
+ }: Props = $props();
41
+
42
+ // =========================
43
+ // STATE
44
+ // =========================
45
+ let layer = $state<HTMLDivElement | null>(null);
46
+ let preview = $state<PenPreview | null>(null);
47
+ const session = createPenSession(
48
+ {
49
+ getNodes: () => nodes,
50
+ commit: (ops) => commit(ops),
51
+ startGesture: () => startGesture(),
52
+ onTextPlaced: (id) => onTextPlaced?.(id),
53
+ onPreview: (next) => {
54
+ preview = next;
55
+ },
56
+ },
57
+ () => ({ tool, color, size, zoom: viewport.zoom }),
58
+ );
59
+
60
+ // =========================
61
+ // EFFECTS
62
+ // =========================
63
+ // Switching tool mid-stroke unmounts the overlay, and an open gesture would block undo for good.
64
+ $effect(() => () => session.cancel());
65
+
66
+ // =========================
67
+ // FUNCTIONS
68
+ // =========================
69
+ function toFlow(event: { clientX: number; clientY: number }) {
70
+ const rect = layer?.getBoundingClientRect();
71
+ const left = rect?.left ?? 0;
72
+ const top = rect?.top ?? 0;
73
+ return {
74
+ x: (event.clientX - left - viewport.x) / viewport.zoom,
75
+ y: (event.clientY - top - viewport.y) / viewport.zoom,
76
+ };
77
+ }
78
+
79
+ function pressureOf(event: PointerEvent): number | undefined {
80
+ return event.pointerType === "pen" ? event.pressure : undefined;
81
+ }
82
+
83
+ function onpointerdown(event: PointerEvent) {
84
+ // A second finger or a right click mustn't start a stroke.
85
+ if (event.button !== 0 || !event.isPrimary) return;
86
+ layer?.setPointerCapture(event.pointerId);
87
+ session.down(toFlow(event), pressureOf(event));
88
+ }
89
+
90
+ function onpointermove(event: PointerEvent) {
91
+ if (!session.active) return;
92
+ // Coalesced events keep a fast pen stroke smooth between animation frames.
93
+ const batch = event.getCoalescedEvents?.() ?? [];
94
+ for (const sample of batch.length > 0 ? batch : [event]) {
95
+ session.move(toFlow(sample), pressureOf(sample));
96
+ }
97
+ }
98
+
99
+ function onpointerup(event: PointerEvent) {
100
+ if (!session.active) return;
101
+ layer?.releasePointerCapture(event.pointerId);
102
+ session.up(toFlow(event), { square: event.shiftKey });
103
+ }
104
+
105
+ function onkeydown(event: KeyboardEvent) {
106
+ if (event.key === "Escape") session.cancel();
107
+ }
108
+
109
+ // The overlay sits above xyflow's zoom pane, so a wheel or a trackpad pinch would
110
+ // never reach it. Hand those to the pane so you can zoom and pan mid-drawing.
111
+ function forwardWheel(node: HTMLElement) {
112
+ const handler = (event: WheelEvent) => {
113
+ const zoomPane = node.closest(".svelte-flow")?.querySelector(".svelte-flow__zoom");
114
+ if (!zoomPane) return;
115
+ event.preventDefault();
116
+ zoomPane.dispatchEvent(
117
+ new WheelEvent("wheel", {
118
+ deltaX: event.deltaX,
119
+ deltaY: event.deltaY,
120
+ deltaZ: event.deltaZ,
121
+ deltaMode: event.deltaMode,
122
+ clientX: event.clientX,
123
+ clientY: event.clientY,
124
+ screenX: event.screenX,
125
+ screenY: event.screenY,
126
+ ctrlKey: event.ctrlKey,
127
+ shiftKey: event.shiftKey,
128
+ altKey: event.altKey,
129
+ metaKey: event.metaKey,
130
+ bubbles: true,
131
+ cancelable: true,
132
+ }),
133
+ );
134
+ };
135
+ node.addEventListener("wheel", handler, { passive: false });
136
+ return () => node.removeEventListener("wheel", handler);
137
+ }
138
+
139
+ function cursorFor(current: PenTool): string {
140
+ if (current === "text") return "text";
141
+ return "crosshair";
142
+ }
143
+ </script>
144
+
145
+ <!-- =========================== -->
146
+ <!-- MARKUP -->
147
+ <!-- =========================== -->
148
+ <svelte:window {onkeydown} />
149
+
150
+ <div
151
+ bind:this={layer}
152
+ {@attach forwardWheel}
153
+ class="pen-overlay"
154
+ style:cursor={cursorFor(tool)}
155
+ role="presentation"
156
+ {onpointerdown}
157
+ {onpointermove}
158
+ {onpointerup}
159
+ onpointercancel={() => session.cancel()}
160
+ >
161
+ <PreviewSvg {preview} {viewport} />
162
+ </div>
163
+
164
+ <style>
165
+ .pen-overlay {
166
+ position: absolute;
167
+ inset: 0;
168
+ z-index: 5;
169
+ touch-action: none;
170
+ user-select: none;
171
+ }
172
+ </style>
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { NodeResizer, type NodeProps } from "@xyflow/svelte";
6
+ import ShapeOutline from "../components/shared/svg/ShapeOutline.svelte";
7
+ import PenField from "./PenField.svelte";
8
+ import { getDiagramEditorContext } from "../editor/editor-context.js";
9
+ import { useHandDrawn } from "../editor/hand-context.js";
10
+ import { isLocked } from "../editor/lock.js";
11
+ import "./pen.css";
12
+ import { penColorVar } from "./colors.js";
13
+ import { shapeOutline } from "./shapes.js";
14
+ import type { ShapeData } from "./types.js";
15
+
16
+ // =========================
17
+ // PROPS
18
+ // =========================
19
+ let { id, data, selected, width, height }: NodeProps = $props();
20
+
21
+ // =========================
22
+ // STATE
23
+ // =========================
24
+ const editor = getDiagramEditorContext();
25
+ const handDrawn = useHandDrawn();
26
+ let endResize: (() => void) | null = null;
27
+
28
+ // =========================
29
+ // DERIVED
30
+ // =========================
31
+ let shape = $derived(data as ShapeData);
32
+ let outline = $derived(
33
+ shapeOutline(shape.shape, width ?? 0, height ?? 0, shape.strokeWidth / 2),
34
+ );
35
+ let ink = $derived(penColorVar(shape.color));
36
+ </script>
37
+
38
+ <!-- =========================== -->
39
+ <!-- MARKUP -->
40
+ <!-- =========================== -->
41
+ <NodeResizer
42
+ isVisible={selected}
43
+ minWidth={24}
44
+ minHeight={24}
45
+ onResizeStart={() => {
46
+ endResize = editor?.startGesture() ?? null;
47
+ }}
48
+ onResizeEnd={() => {
49
+ endResize?.();
50
+ endResize = null;
51
+ }}
52
+ />
53
+ <ShapeOutline {shape} {outline} {ink} {width} {height} hand={handDrawn()} seed={id} />
54
+ {#if shape.label || selected}
55
+ <PenField
56
+ variant="label"
57
+ value={shape.label}
58
+ readonly={!editor || isLocked({ data })}
59
+ ariaLabel="Shape label"
60
+ color={ink}
61
+ onCommit={(next, live) => editor?.patchNodeData(id, { label: next }, live ? { coalesce: true } : undefined)}
62
+ onExit={() => editor?.endTextEdit()}
63
+ />
64
+ {/if}
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Handle, Position, type NodeProps } from "@xyflow/svelte";
6
+ import { onMount } from "svelte";
7
+ import TextRule from "../components/shared/svg/TextRule.svelte";
8
+ import PenField from "./PenField.svelte";
9
+ import { getDiagramEditorContext } from "../editor/editor-context.js";
10
+ import { useHandDrawn } from "../editor/hand-context.js";
11
+ import { isLocked } from "../editor/lock.js";
12
+ import { roughUnderline, seedFrom, sketchColors } from "../headless/sketch.js";
13
+ import "./pen.css";
14
+ import { penColorVar } from "./colors.js";
15
+ import type { TextData } from "./types.js";
16
+
17
+ // =========================
18
+ // PROPS
19
+ // =========================
20
+ let { id, data, selected }: NodeProps = $props();
21
+
22
+ // =========================
23
+ // STATE
24
+ // =========================
25
+ const editor = getDiagramEditorContext();
26
+ const handDrawn = useHandDrawn();
27
+ let field = $state<ReturnType<typeof PenField>>();
28
+ let boxWidth = $state(0);
29
+
30
+ // =========================
31
+ // DERIVED
32
+ // =========================
33
+ let text = $derived(data as TextData);
34
+ let ink = $derived(penColorVar(text.color));
35
+ let rule = $derived(
36
+ text.underline && boxWidth > 2
37
+ ? roughUnderline(
38
+ boxWidth,
39
+ { seed: seedFrom(id) + 7, colors: { ...sketchColors("ink", false), stroke: ink } },
40
+ { formal: !handDrawn() },
41
+ )
42
+ : [],
43
+ );
44
+
45
+ // =========================
46
+ // EFFECTS
47
+ // =========================
48
+ // The editor asks a freshly dropped text to open, so the first thing typed lands in it.
49
+ onMount(() => editor?.registerStartEdit(id, () => void field?.edit()));
50
+
51
+ // =========================
52
+ // FUNCTIONS
53
+ // =========================
54
+ function onExit() {
55
+ editor?.endTextEdit();
56
+ // A click that typed nothing leaves no empty node behind.
57
+ if (editor && text.text.trim() === "") editor.removeNode(id);
58
+ }
59
+ </script>
60
+
61
+ <!-- =========================== -->
62
+ <!-- MARKUP -->
63
+ <!-- =========================== -->
64
+ <Handle type="target" position={Position.Top} class="pen-handle" />
65
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
66
+ <div
67
+ class="pen-text-box"
68
+ class:column={text.fixedWidth}
69
+ bind:clientWidth={boxWidth}
70
+ ondblclick={(event) => field?.edit({ x: event.clientX, y: event.clientY })}
71
+ >
72
+ <PenField
73
+ bind:this={field}
74
+ variant="text"
75
+ value={text.text}
76
+ readonly={!editor || isLocked({ data })}
77
+ ariaLabel="Text"
78
+ {selected}
79
+ color={ink}
80
+ fontSize={text.fontSize}
81
+ fontWeight={text.fontWeight === "bold" ? 700 : undefined}
82
+ center={text.align === "center"}
83
+ column={text.fixedWidth}
84
+ onCommit={(next, live) => editor?.patchNodeData(id, { text: next }, live ? { coalesce: true } : undefined)}
85
+ {onExit}
86
+ />
87
+ {#if rule.length > 0}
88
+ <TextRule {rule} {ink} width={boxWidth} />
89
+ {/if}
90
+ </div>
91
+ <Handle type="source" position={Position.Bottom} class="pen-handle" />
92
+
93
+ <style>
94
+ .pen-text-box.column {
95
+ width: 100%;
96
+ }
97
+ </style>
@@ -0,0 +1,28 @@
1
+ // Pen colours as CSS variables. pen.css gives each variable a light and a dark
2
+ // value, and a hex fallback keeps the node readable when that file isn't loaded.
3
+
4
+ import type { PenColor } from "./types.js";
5
+
6
+ export const PEN_HEX: Record<PenColor, { light: string; dark: string }> = {
7
+ ink: { light: "#1e1e1e", dark: "#e9ecef" },
8
+ red: { light: "#e03131", dark: "#ff8787" },
9
+ blue: { light: "#1971c2", dark: "#74c0fc" },
10
+ green: { light: "#2f9e44", dark: "#69db7c" },
11
+ amber: { light: "#e8a33d", dark: "#ffd43b" },
12
+ violet: { light: "#6741d9", dark: "#b197fc" },
13
+ // The brand pair the email lesson figures are drawn in (headless/sketch.ts PALETTE).
14
+ indigo: { light: "#414fd2", dark: "#8b96ff" },
15
+ terra: { light: "#f0503d", dark: "#ff8a78" },
16
+ };
17
+
18
+ export function penColorVar(color: string): string {
19
+ const known = color in PEN_HEX ? (color as PenColor) : "ink";
20
+ return `var(--pen-${known}, ${PEN_HEX[known].light})`;
21
+ }
22
+
23
+ /** The `:root` block pen.css carries, built from the table so the two can't drift. */
24
+ export function penCssTokens(mode: "light" | "dark"): string {
25
+ return (Object.keys(PEN_HEX) as PenColor[])
26
+ .map((name) => `--pen-${name}: ${PEN_HEX[name][mode]};`)
27
+ .join(" ");
28
+ }
@@ -0,0 +1,46 @@
1
+ // The eraser hits whole strokes only, so a swipe across a big shape never wipes it.
2
+
3
+ import { distanceToStroke, type Pt } from "./geometry.js";
4
+ import { STROKE_NODE, type StrokeData } from "./types.js";
5
+
6
+ export interface ErasableNode {
7
+ id: string;
8
+ type?: string;
9
+ position: Pt;
10
+ data: Record<string, unknown>;
11
+ }
12
+
13
+ function isStrokeData(data: Record<string, unknown>): data is StrokeData {
14
+ return Array.isArray(data.points) && typeof data.size === "number";
15
+ }
16
+
17
+ /** Ids of the strokes whose line passes within `radius` of `at`, a flow-space point. */
18
+ export function strokesUnder(nodes: readonly ErasableNode[], at: Pt, radius: number): string[] {
19
+ const hits: string[] = [];
20
+ for (const node of nodes) {
21
+ if (node.type !== STROKE_NODE || !isStrokeData(node.data)) continue;
22
+ const local = { x: at.x - node.position.x, y: at.y - node.position.y };
23
+ if (distanceToStroke(node.data.points, local) <= radius + node.data.size / 2) {
24
+ hits.push(node.id);
25
+ }
26
+ }
27
+ return hits;
28
+ }
29
+
30
+ /** The same test along a swipe, so a fast pointer can't skip over a thin line. */
31
+ export function strokesAlong(
32
+ nodes: readonly ErasableNode[],
33
+ from: Pt,
34
+ to: Pt,
35
+ radius: number,
36
+ ): string[] {
37
+ const length = Math.hypot(to.x - from.x, to.y - from.y);
38
+ const steps = Math.max(1, Math.ceil(length / Math.max(radius / 2, 1)));
39
+ const hits = new Set<string>();
40
+ for (let i = 0; i <= steps; i++) {
41
+ const t = i / steps;
42
+ const at = { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
43
+ for (const id of strokesUnder(nodes, at, radius)) hits.add(id);
44
+ }
45
+ return [...hits];
46
+ }
@@ -0,0 +1,148 @@
1
+ // Stroke geometry with no DOM and no xyflow, so it runs headless.
2
+
3
+ import type {
4
+ PenColor,
5
+ PenNodeInput,
6
+ StrokeData,
7
+ StrokePoint,
8
+ StrokeSample,
9
+ } from "./types.js";
10
+ import { PEN_DEFAULTS, STROKE_NODE } from "./types.js";
11
+
12
+ export interface Pt {
13
+ x: number;
14
+ y: number;
15
+ }
16
+
17
+ export interface Box {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ /** How far, in screen pixels, a saved stroke may stray from what was drawn. */
25
+ const SIMPLIFY_TOLERANCE = 0.6;
26
+
27
+ function round(value: number, places: number): number {
28
+ const factor = 10 ** places;
29
+ return Math.round(value * factor) / factor;
30
+ }
31
+
32
+ /** Distance from p to the segment a-b. */
33
+ export function distanceToSegment(p: Pt, a: Pt, b: Pt): number {
34
+ const dx = b.x - a.x;
35
+ const dy = b.y - a.y;
36
+ const lengthSquared = dx * dx + dy * dy;
37
+ if (lengthSquared === 0) return Math.hypot(p.x - a.x, p.y - a.y);
38
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared));
39
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
40
+ }
41
+
42
+ /**
43
+ * Ramer-Douglas-Peucker over x and y, run on an index stack so a long scribble
44
+ * can't overflow the call stack. The first and last samples always stay.
45
+ */
46
+ export function simplifyPoints<P extends StrokeSample>(
47
+ points: readonly P[],
48
+ tolerance: number,
49
+ ): P[] {
50
+ if (points.length <= 2) return [...points];
51
+ const keep = new Uint8Array(points.length);
52
+ keep[0] = 1;
53
+ keep[points.length - 1] = 1;
54
+ const stack: Array<[number, number]> = [[0, points.length - 1]];
55
+ while (stack.length > 0) {
56
+ const [start, end] = stack.pop() as [number, number];
57
+ const a = { x: points[start][0], y: points[start][1] };
58
+ const b = { x: points[end][0], y: points[end][1] };
59
+ let farthest = -1;
60
+ let farthestDistance = tolerance;
61
+ for (let i = start + 1; i < end; i++) {
62
+ const distance = distanceToSegment({ x: points[i][0], y: points[i][1] }, a, b);
63
+ if (distance > farthestDistance) {
64
+ farthest = i;
65
+ farthestDistance = distance;
66
+ }
67
+ }
68
+ if (farthest === -1) continue;
69
+ keep[farthest] = 1;
70
+ stack.push([start, farthest], [farthest, end]);
71
+ }
72
+ return points.filter((_, i) => keep[i] === 1);
73
+ }
74
+
75
+ export function pointsBounds(points: readonly StrokeSample[]): Box {
76
+ let minX = Infinity;
77
+ let minY = Infinity;
78
+ let maxX = -Infinity;
79
+ let maxY = -Infinity;
80
+ for (const [x, y] of points) {
81
+ minX = Math.min(minX, x);
82
+ minY = Math.min(minY, y);
83
+ maxX = Math.max(maxX, x);
84
+ maxY = Math.max(maxY, y);
85
+ }
86
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
87
+ }
88
+
89
+ export interface BuildStrokeOptions {
90
+ color?: PenColor;
91
+ size?: number;
92
+ tool?: "pen" | "highlighter";
93
+ /** Flow units per screen pixel is 1 / zoom, so a zoomed-out stroke keeps its detail. */
94
+ zoom?: number;
95
+ id?: string;
96
+ }
97
+
98
+ /** Points are saved relative to the node, padded by the brush so the selection box holds the whole outline. */
99
+ export function buildStrokeNode(
100
+ samples: readonly StrokeSample[],
101
+ options: BuildStrokeOptions = {},
102
+ ): PenNodeInput<StrokeData> | null {
103
+ if (samples.length === 0) return null;
104
+ const tool = options.tool ?? "pen";
105
+ const size = options.size ?? PEN_DEFAULTS[tool].size;
106
+ const tolerance = SIMPLIFY_TOLERANCE / Math.max(options.zoom ?? 1, 0.1);
107
+ // A tap is one sample. Doubling it gives the outline a segment, so it draws as a dot.
108
+ const source = samples.length === 1 ? [samples[0], samples[0]] : samples;
109
+ const simplified = simplifyPoints(source, tolerance);
110
+ const hasPressure = simplified.some((point) => point[2] !== undefined);
111
+
112
+ const pad = Math.ceil(size / 2) + 2;
113
+ const box = pointsBounds(simplified);
114
+ const origin = { x: box.x - pad, y: box.y - pad };
115
+ const points: StrokePoint[] = simplified.map(([x, y, pressure]) => {
116
+ const px = round(x - origin.x, 1);
117
+ const py = round(y - origin.y, 1);
118
+ return hasPressure ? [px, py, round(pressure ?? 0.5, 2)] : [px, py];
119
+ });
120
+
121
+ return {
122
+ id: options.id,
123
+ type: STROKE_NODE,
124
+ position: { x: round(origin.x, 1), y: round(origin.y, 1) },
125
+ data: {
126
+ points,
127
+ color: options.color ?? PEN_DEFAULTS.color,
128
+ size,
129
+ tool,
130
+ simulatePressure: !hasPressure,
131
+ },
132
+ width: Math.ceil(box.width + pad * 2),
133
+ height: Math.ceil(box.height + pad * 2),
134
+ };
135
+ }
136
+
137
+ /** Smallest distance from p to the stroke's centre line, in the stroke's own coordinates. */
138
+ export function distanceToStroke(points: readonly StrokeSample[], p: Pt): number {
139
+ if (points.length === 0) return Infinity;
140
+ if (points.length === 1) return Math.hypot(p.x - points[0][0], p.y - points[0][1]);
141
+ let best = Infinity;
142
+ for (let i = 1; i < points.length; i++) {
143
+ const a = { x: points[i - 1][0], y: points[i - 1][1] };
144
+ const b = { x: points[i][0], y: points[i][1] };
145
+ best = Math.min(best, distanceToSegment(p, a, b));
146
+ }
147
+ return best;
148
+ }
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ // A host's text field for the PenField test: shows its value, records that it was opened.
3
+ import type { TextFieldProps } from "../editor/text-field-context.js";
4
+
5
+ let { value, readonly = false, class: klass = "" }: TextFieldProps = $props();
6
+ let opened = $state(0);
7
+
8
+ export function edit() {
9
+ opened++;
10
+ }
11
+ </script>
12
+
13
+ <div data-host-field class={klass} data-readonly={readonly} data-opened={opened}>{value}</div>
@@ -0,0 +1,40 @@
1
+ // The pen layer, server-safe. Svelte parts are in ./ui.js.
2
+
3
+ export { PEN_HEX, penColorVar } from "./colors.js";
4
+ export { strokesAlong, strokesUnder, type ErasableNode } from "./eraser.js";
5
+ export {
6
+ buildStrokeNode,
7
+ distanceToStroke,
8
+ pointsBounds,
9
+ simplifyPoints,
10
+ type Box,
11
+ type BuildStrokeOptions,
12
+ type Pt,
13
+ } from "./geometry.js";
14
+ export { addPenNode, removePenNode, type PenOp } from "./ops.js";
15
+ export {
16
+ createPenSession,
17
+ type PenHost,
18
+ type PenPreview,
19
+ type PenSettings,
20
+ } from "./pen-session.js";
21
+ export { penPlugin } from "./plugin.js";
22
+ export { buildShapeNode, buildTextNode, dragBox, shapeOutline } from "./shapes.js";
23
+ export { outlineToPath, strokePath } from "./stroke-path.js";
24
+ export {
25
+ HIGHLIGHTER_OPACITY,
26
+ PEN_COLORS,
27
+ PEN_DEFAULTS,
28
+ SHAPE_KINDS,
29
+ SHAPE_NODE,
30
+ STROKE_NODE,
31
+ TEXT_NODE,
32
+ type PenColor,
33
+ type PenNodeInput,
34
+ type PenTool,
35
+ type ShapeData,
36
+ type ShapeKind,
37
+ type StrokeData,
38
+ type StrokePoint,
39
+ type TextData,
40
+ } from "./types.js";
@@ -0,0 +1,16 @@
1
+ // The two ops the pen layer emits. They match the editor's addNode and removeNode
2
+ // ops, so a stroke goes through applyOps like any card and the history sees it.
3
+
4
+ import type { PenNodeInput } from "./types.js";
5
+
6
+ export type PenOp =
7
+ | { kind: "addNode"; node: PenNodeInput<Record<string, unknown>> }
8
+ | { kind: "removeNode"; nodeId: string };
9
+
10
+ export function addPenNode(node: PenNodeInput<Record<string, unknown>>): PenOp {
11
+ return { kind: "addNode", node: { ...node, id: node.id ?? crypto.randomUUID() } };
12
+ }
13
+
14
+ export function removePenNode(nodeId: string): PenOp {
15
+ return { kind: "removeNode", nodeId };
16
+ }