@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,469 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Editor } from "@tiptap/core";
6
+ import { untrack } from "svelte";
7
+ import "./content.css";
8
+ import { buildExtensions } from "./extensions.js";
9
+ import { buildSlashItems, type SlashItem } from "./slash-items.js";
10
+ import { parseYoutubeUrl } from "./youtube-url.js";
11
+ import EditorToolbar from "./editor-toolbar.svelte";
12
+ import SlashMenu from "./slash-menu.svelte";
13
+ import ImageToolbar from "./image-toolbar.svelte";
14
+
15
+ // =========================
16
+ // TYPES
17
+ // =========================
18
+ interface Props {
19
+ content?: Record<string, unknown> | null;
20
+ onchange?: (json: Record<string, unknown>) => void;
21
+ placeholder?: string;
22
+ minHeight?: string;
23
+ preset?: "full" | "notes";
24
+ blockIds?: boolean;
25
+ }
26
+
27
+ // =========================
28
+ // PROPS
29
+ // =========================
30
+ let { content = null, onchange, placeholder = "Start writing...", minHeight = "28rem", preset = "full", blockIds = false }: Props = $props();
31
+
32
+ // =========================
33
+ // STATE
34
+ // =========================
35
+ let editorElement: HTMLDivElement | undefined = $state();
36
+ let editorInstance = $state<Editor | null>(null);
37
+ let tick = $state(0);
38
+
39
+ // Slash commands
40
+ let showSlashMenu = $state(false);
41
+ let slashQuery = $state("");
42
+ let slashMenuPos = $state({ top: 0, left: 0 });
43
+ let slashSelectedIndex = $state(0);
44
+ let slashMenuEl: HTMLDivElement | undefined = $state();
45
+
46
+ // Image toolbar
47
+ let showImageToolbar = $state(false);
48
+ let imageToolbarPos = $state({ top: 0, left: 0 });
49
+ let activeImageElement: HTMLImageElement | null = $state(null);
50
+
51
+ // =========================
52
+ // DERIVED
53
+ // =========================
54
+ let editor: Editor | null = $derived(tick >= 0 ? editorInstance : null);
55
+
56
+ let slashItems = $derived.by((): SlashItem[] =>
57
+ editor ? buildSlashItems(editor, slashQuery, { preset, insertImage: insertImageFromSlash, insertYoutube: insertYoutubeVideo }) : [],
58
+ );
59
+
60
+ // =========================
61
+ // EFFECTS
62
+ // =========================
63
+ $effect(() => {
64
+ if (showSlashMenu) {
65
+ slashSelectedIndex = 0;
66
+ }
67
+ });
68
+
69
+ // Close menus on outside click
70
+ $effect(() => {
71
+ if (!showSlashMenu && !showImageToolbar) return;
72
+ function handleClick(e: MouseEvent) {
73
+ const target = e.target as Node;
74
+ if (slashMenuEl && !slashMenuEl.contains(target)) {
75
+ closeSlashMenu();
76
+ }
77
+ // Close image toolbar when clicking outside editor or on non-image
78
+ if (showImageToolbar && editorElement && !editorElement.contains(target)) {
79
+ showImageToolbar = false;
80
+ activeImageElement = null;
81
+ }
82
+ }
83
+ document.addEventListener("mousedown", handleClick);
84
+ return () => document.removeEventListener("mousedown", handleClick);
85
+ });
86
+
87
+ // =========================
88
+ // FUNCTIONS
89
+ // =========================
90
+ function insertImageFromSlash() {
91
+ const url = prompt("Image URL:");
92
+ if (url?.trim()) {
93
+ editor?.chain().focus().setImage({ src: url.trim() }).run();
94
+ }
95
+ }
96
+
97
+ function insertYoutubeVideo() {
98
+ const url = prompt("YouTube URL:");
99
+ if (!url?.trim()) return;
100
+ const parsed = parseYoutubeUrl(url.trim());
101
+ if (!parsed) {
102
+ alert("That is not a recognized YouTube URL.");
103
+ return;
104
+ }
105
+ const title = prompt("Video title:")?.trim() || "";
106
+ editor
107
+ ?.chain()
108
+ .focus()
109
+ .insertContent({ type: "youtube", attrs: { videoId: parsed.videoId, title, start: parsed.start } })
110
+ .run();
111
+ }
112
+
113
+ function closeSlashMenu() {
114
+ showSlashMenu = false;
115
+ slashQuery = "";
116
+ }
117
+
118
+ function executeSlashCommand(item: SlashItem) {
119
+ if (!editor) return;
120
+ const { from } = editor.state.selection;
121
+ const slashStart = from - slashQuery.length - 1;
122
+ editor.chain().focus().deleteRange({ from: slashStart, to: from }).run();
123
+ item.command();
124
+ closeSlashMenu();
125
+ }
126
+
127
+ function getCaretCoords(): { top: number; left: number } {
128
+ const sel = window.getSelection();
129
+ if (!sel || sel.rangeCount === 0) return { top: 0, left: 0 };
130
+ const range = sel.getRangeAt(0);
131
+ const rect = range.getBoundingClientRect();
132
+ const wrapperRect = editorElement?.getBoundingClientRect();
133
+ if (!wrapperRect) return { top: 0, left: 0 };
134
+ return {
135
+ top: rect.bottom - wrapperRect.top + 4,
136
+ left: rect.left - wrapperRect.left,
137
+ };
138
+ }
139
+
140
+ function setImageAlignment(align: "left" | "center" | "right") {
141
+ if (!editor || !activeImageElement) return;
142
+ const { state } = editor;
143
+ const { selection } = state;
144
+ const { from, to } = selection;
145
+
146
+ editor.chain().focus().command(({ tr }) => {
147
+ tr.doc.nodesBetween(from, to, (node, pos) => {
148
+ if (node.type.name === "image") {
149
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, align });
150
+ }
151
+ return true;
152
+ });
153
+ return true;
154
+ }).run();
155
+
156
+ showImageToolbar = false;
157
+ activeImageElement = null;
158
+ }
159
+
160
+ function deleteActiveImage() {
161
+ if (!editor || !activeImageElement) return;
162
+ const { state } = editor;
163
+ const { selection } = state;
164
+ const { from, to } = selection;
165
+
166
+ editor.chain().focus().command(({ tr }) => {
167
+ tr.doc.nodesBetween(from, to, (node, pos) => {
168
+ if (node.type.name === "image") {
169
+ tr.delete(pos, pos + node.nodeSize);
170
+ }
171
+ return false;
172
+ });
173
+ return true;
174
+ }).run();
175
+
176
+ showImageToolbar = false;
177
+ activeImageElement = null;
178
+ }
179
+
180
+ function mount() {
181
+ if (!editorElement || editorInstance) return;
182
+
183
+ const extensions = buildExtensions({ preset, placeholder, blockIds });
184
+
185
+ editorInstance = new Editor({
186
+ element: editorElement,
187
+ extensions,
188
+ content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
189
+ editorProps: {
190
+ handleKeyDown: (_view, event) => {
191
+ if (showSlashMenu) {
192
+ if (event.key === "ArrowDown") {
193
+ event.preventDefault();
194
+ slashSelectedIndex = (slashSelectedIndex + 1) % slashItems.length;
195
+ return true;
196
+ }
197
+ if (event.key === "ArrowUp") {
198
+ event.preventDefault();
199
+ slashSelectedIndex = (slashSelectedIndex - 1 + slashItems.length) % slashItems.length;
200
+ return true;
201
+ }
202
+ if (event.key === "Enter") {
203
+ event.preventDefault();
204
+ if (slashItems[slashSelectedIndex]) {
205
+ executeSlashCommand(slashItems[slashSelectedIndex]);
206
+ }
207
+ return true;
208
+ }
209
+ if (event.key === "Escape") {
210
+ event.preventDefault();
211
+ closeSlashMenu();
212
+ return true;
213
+ }
214
+ if (event.key === "Backspace" && slashQuery.length === 0) {
215
+ closeSlashMenu();
216
+ return false;
217
+ }
218
+ }
219
+ return false;
220
+ },
221
+ // handleClick, not handleClickOn: ProseMirror tries both in the same spot, and
222
+ // this one needs only the event.
223
+ handleClick(_view, _pos, event) {
224
+ const target = event.target as HTMLElement;
225
+ if (preset === "full" && target?.tagName === "IMG") {
226
+ activeImageElement = target as HTMLImageElement;
227
+ const rect = target.getBoundingClientRect();
228
+ const wrapperRect = editorElement?.getBoundingClientRect();
229
+ if (wrapperRect) {
230
+ imageToolbarPos = {
231
+ top: rect.top - wrapperRect.top - 40,
232
+ left: rect.left - wrapperRect.left + rect.width / 2 - 60,
233
+ };
234
+ showImageToolbar = true;
235
+ }
236
+ return true;
237
+ }
238
+ showImageToolbar = false;
239
+ activeImageElement = null;
240
+ return false;
241
+ },
242
+ },
243
+ onUpdate: ({ editor: e }) => {
244
+ onchange?.(e.getJSON());
245
+
246
+ const { from } = e.state.selection;
247
+ const textBefore = e.state.doc.textBetween(Math.max(0, from - 20), from, "\n");
248
+ const slashMatch = textBefore.match(/\/([a-zA-Z0-9 ]*)$/);
249
+
250
+ if (slashMatch) {
251
+ slashQuery = slashMatch[1];
252
+ slashMenuPos = getCaretCoords();
253
+ showSlashMenu = true;
254
+ } else if (showSlashMenu) {
255
+ closeSlashMenu();
256
+ }
257
+ },
258
+ onTransaction: () => {
259
+ tick++;
260
+ },
261
+ onFocus: () => {
262
+ tick++;
263
+ },
264
+ onBlur: () => {
265
+ tick++;
266
+ },
267
+ });
268
+ tick++;
269
+
270
+ return () => {
271
+ editorInstance?.destroy();
272
+ editorInstance = null;
273
+ };
274
+ }
275
+
276
+ // Only the element mounts the editor. Read tracked, `content` (fed back by onchange) and the
277
+ // `tick++` each transaction makes would destroy and rebuild it until Svelte stops the loop.
278
+ $effect(() => {
279
+ if (editorElement) return untrack(mount);
280
+ });
281
+ </script>
282
+
283
+ <!-- =========================== -->
284
+ <!-- MARKUP -->
285
+ <!-- =========================== -->
286
+ <div class="editor-wrapper" class:focused={editor?.isFocused} style="--editor-min-height: {minHeight};">
287
+ <EditorToolbar editor={editor} {tick} {preset} onInsertYoutube={insertYoutubeVideo} />
288
+
289
+ <!-- Editor surface -->
290
+ <div bind:this={editorElement} class="editor-surface tiptap-content"></div>
291
+
292
+ <!-- Slash command menu -->
293
+ {#if showSlashMenu && slashItems.length > 0}
294
+ <SlashMenu
295
+ items={slashItems}
296
+ pos={slashMenuPos}
297
+ bind:selectedIndex={slashSelectedIndex}
298
+ bind:el={slashMenuEl}
299
+ onselect={executeSlashCommand}
300
+ />
301
+ {/if}
302
+
303
+ <!-- Image toolbar (full preset only) -->
304
+ {#if preset === "full" && showImageToolbar}
305
+ <ImageToolbar pos={imageToolbarPos} onalign={setImageAlignment} ondelete={deleteActiveImage} />
306
+ {/if}
307
+
308
+ <!-- Status bar -->
309
+ {#if editor}
310
+ <div class="status-bar">
311
+ <span class="status-hint">Type <kbd>/</kbd> for commands</span>
312
+ </div>
313
+ {/if}
314
+ </div>
315
+
316
+ <!-- =========================== -->
317
+ <!-- STYLE -->
318
+ <!-- =========================== -->
319
+ <style>
320
+ /* ---- WRAPPER ---- */
321
+ .editor-wrapper {
322
+ position: relative;
323
+ border-radius: var(--radius-xl);
324
+ background: var(--color-background);
325
+ border: 1px solid var(--color-border);
326
+ transition: border-color var(--duration-quick) var(--ease-smooth-out), box-shadow var(--duration-quick) var(--ease-smooth-out);
327
+ }
328
+
329
+ .editor-wrapper.focused {
330
+ border-color: oklch(from var(--color-brand) l c h / 0.25);
331
+ box-shadow: 0 0 0 3px oklch(from var(--color-brand) l c h / 0.06);
332
+ }
333
+
334
+ /* ---- EDITOR SURFACE ---- */
335
+ .editor-surface {
336
+ min-height: var(--editor-min-height, 28rem);
337
+ }
338
+
339
+ .editor-surface :global(.tiptap) {
340
+ padding: 2rem 2.5rem;
341
+ outline: none;
342
+ min-height: var(--editor-min-height, 28rem);
343
+ font-size: 1rem;
344
+ line-height: 1.75;
345
+ color: var(--color-foreground);
346
+ caret-color: var(--color-brand);
347
+ }
348
+
349
+ /* Placeholder */
350
+ .editor-surface :global(.tiptap p.is-editor-empty:first-child::before) {
351
+ content: attr(data-placeholder);
352
+ color: var(--color-muted-foreground);
353
+ opacity: 0.4;
354
+ pointer-events: none;
355
+ float: left;
356
+ height: 0;
357
+ }
358
+
359
+ /* Node typography (h1-h4, p, ul/ol/li, blockquote, code, pre, a, img, hr, strong)
360
+ lives in ./content.css which is imported at the top of this component.
361
+ Editor-only chrome (cursor, hover outline) below. */
362
+ .editor-surface :global(.tiptap .image-wrapper) {
363
+ position: relative;
364
+ display: inline-block;
365
+ max-width: 100%;
366
+ }
367
+
368
+ .editor-surface :global(.tiptap img) {
369
+ transition: box-shadow var(--duration-quick) var(--ease-smooth-out);
370
+ cursor: pointer;
371
+ }
372
+
373
+ @media (hover: hover) and (pointer: fine) {
374
+ .editor-surface :global(.tiptap img:hover) {
375
+ box-shadow: 0 0 0 2px oklch(from var(--color-brand) l c h / 0.2);
376
+ }
377
+ }
378
+
379
+ /* ---- DRAG HANDLE ---- */
380
+ .editor-surface :global(.drag-handle) {
381
+ position: absolute;
382
+ top: 0.25rem;
383
+ left: -1.5rem;
384
+ width: 1.25rem;
385
+ height: 1.25rem;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ color: var(--color-muted-foreground);
390
+ cursor: grab;
391
+ opacity: 0;
392
+ transition: opacity var(--duration-fast) var(--ease-smooth-out);
393
+ border-radius: var(--radius-xs);
394
+ }
395
+
396
+ @media (hover: hover) and (pointer: fine) {
397
+ .editor-surface :global(.drag-handle:hover) {
398
+ background: var(--color-accent);
399
+ color: var(--color-foreground);
400
+ }
401
+ }
402
+
403
+ .editor-surface :global(.drag-handle:active) {
404
+ cursor: grabbing;
405
+ }
406
+
407
+ @media (hover: hover) and (pointer: fine) {
408
+ .editor-surface :global(.tiptap > *:hover .drag-handle) {
409
+ opacity: 0.5;
410
+ }
411
+ }
412
+
413
+ @media (hover: hover) and (pointer: fine) {
414
+ .editor-surface :global(.tiptap [data-node-view-wrapper]:hover .drag-handle) {
415
+ opacity: 0.5;
416
+ }
417
+ }
418
+
419
+ @media (hover: hover) and (pointer: fine) {
420
+ .editor-surface :global(.tiptap > *:hover .drag-handle:hover) {
421
+ opacity: 1;
422
+ }
423
+ }
424
+
425
+ @media (hover: hover) and (pointer: fine) {
426
+ .editor-surface :global(.tiptap [data-node-view-wrapper]:hover .drag-handle:hover) {
427
+ opacity: 1;
428
+ }
429
+ }
430
+
431
+ /* ---- STATUS BAR ---- */
432
+ .status-bar {
433
+ display: flex;
434
+ align-items: center;
435
+ justify-content: flex-end;
436
+ padding: 0.5rem 1.25rem;
437
+ border-top: 1px solid oklch(from var(--color-border) l c h / 0.5);
438
+ }
439
+
440
+ .status-hint {
441
+ font-size: 0.6875rem;
442
+ color: var(--color-muted-foreground);
443
+ opacity: 0.5;
444
+ }
445
+
446
+ .status-hint kbd {
447
+ display: inline-flex;
448
+ align-items: center;
449
+ justify-content: center;
450
+ min-width: 1.125rem;
451
+ height: 1.125rem;
452
+ padding: 0 0.25rem;
453
+ margin: 0 0.125rem;
454
+ border: 1px solid oklch(from var(--color-border) l c h / 0.6);
455
+ border-radius: var(--radius-xs);
456
+ background: var(--color-muted);
457
+ font-family: var(--font-mono);
458
+ font-size: 0.625rem;
459
+ font-weight: 500;
460
+ line-height: 1;
461
+ }
462
+
463
+ /* ---- REDUCED MOTION ---- */
464
+ @media (prefers-reduced-motion: reduce) {
465
+ .editor-wrapper {
466
+ transition: none;
467
+ }
468
+ }
469
+ </style>
@@ -0,0 +1,145 @@
1
+ import { Node as TiptapNode, mergeAttributes, type Extensions } from "@tiptap/core";
2
+ import { StarterKit } from "@tiptap/starter-kit";
3
+ import { Image } from "@tiptap/extension-image";
4
+ import { Placeholder } from "@tiptap/extension-placeholder";
5
+ import { DragHandle } from "@tiptap/extension-drag-handle";
6
+ import { BlockIdExtension } from "./block-id.js";
7
+
8
+ const CmsBlock = TiptapNode.create({
9
+ name: "cmsBlock",
10
+ group: "block",
11
+ atom: true,
12
+ draggable: true,
13
+ selectable: true,
14
+ addAttributes() {
15
+ return {
16
+ blockType: {
17
+ default: "unknown",
18
+ },
19
+ data: {
20
+ default: {},
21
+ },
22
+ };
23
+ },
24
+ parseHTML() {
25
+ return [{ tag: 'div[data-type="cms-block"]' }];
26
+ },
27
+ renderHTML({ HTMLAttributes }) {
28
+ const blockType =
29
+ typeof HTMLAttributes.blockType === "string" ? HTMLAttributes.blockType : "unknown";
30
+ const { data: _data, blockType: _blockType, ...attrs } = HTMLAttributes;
31
+ return [
32
+ "div",
33
+ mergeAttributes(attrs, {
34
+ class: "cms-block-node",
35
+ "data-type": "cms-block",
36
+ "data-block-type": blockType,
37
+ }),
38
+ ["strong", {}, "CMS block"],
39
+ ["span", {}, ` ${blockType}`],
40
+ ];
41
+ },
42
+ });
43
+
44
+ // WHY: the same facade markup as serializer.ts, so authors see what ships. The
45
+ // editor only needs an atomic node; click-to-load lives in renderer.svelte.
46
+ const Youtube = TiptapNode.create({
47
+ name: "youtube",
48
+ group: "block",
49
+ atom: true,
50
+ draggable: true,
51
+ selectable: true,
52
+ addAttributes() {
53
+ return {
54
+ videoId: { default: null },
55
+ title: { default: "" },
56
+ start: { default: 0 },
57
+ };
58
+ },
59
+ parseHTML() {
60
+ return [{ tag: "div[data-video-id]" }];
61
+ },
62
+ renderHTML({ HTMLAttributes }) {
63
+ const videoId = typeof HTMLAttributes.videoId === "string" ? HTMLAttributes.videoId : "";
64
+ const title = typeof HTMLAttributes.title === "string" ? HTMLAttributes.title : "";
65
+ const start = typeof HTMLAttributes.start === "number" ? HTMLAttributes.start : 0;
66
+ return [
67
+ "div",
68
+ mergeAttributes({
69
+ class: "tiptap-youtube",
70
+ "data-video-id": videoId,
71
+ "data-start": String(start),
72
+ }),
73
+ [
74
+ "button",
75
+ {
76
+ type: "button",
77
+ class: "tiptap-youtube-trigger",
78
+ "aria-label": `Play video: ${title}`,
79
+ },
80
+ [
81
+ "img",
82
+ {
83
+ class: "tiptap-youtube-poster",
84
+ src: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
85
+ alt: "",
86
+ loading: "lazy",
87
+ width: "480",
88
+ height: "360",
89
+ },
90
+ ],
91
+ ["span", { class: "tiptap-youtube-play", "aria-hidden": "true" }],
92
+ ["span", { class: "tiptap-youtube-title" }, title],
93
+ ],
94
+ ];
95
+ },
96
+ });
97
+
98
+ function dedupeExtensions(extensions: Extensions): Extensions {
99
+ const seen = new Set<string>();
100
+ return extensions.filter((extension) => {
101
+ if (!extension || typeof extension !== "object") return false;
102
+ const name =
103
+ "name" in extension && typeof (extension as { name?: unknown }).name === "string"
104
+ ? (extension as { name: string }).name
105
+ : "";
106
+ if (!name) return true;
107
+ if (seen.has(name)) return false;
108
+ seen.add(name);
109
+ return true;
110
+ });
111
+ }
112
+
113
+ const DRAG_DOTS = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="2.5" cy="2.5" r="1" fill="currentColor"/><circle cx="2.5" cy="6" r="1" fill="currentColor"/><circle cx="2.5" cy="9.5" r="1" fill="currentColor"/><circle cx="6" cy="2.5" r="1" fill="currentColor"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="9.5" r="1" fill="currentColor"/></svg>`;
114
+
115
+ function dragHandle() {
116
+ return DragHandle.configure({
117
+ render() {
118
+ const el = document.createElement("div");
119
+ el.classList.add("drag-handle");
120
+ el.innerHTML = DRAG_DOTS;
121
+ return el;
122
+ },
123
+ });
124
+ }
125
+
126
+ export type ExtensionOptions = {
127
+ preset: "full" | "notes";
128
+ placeholder: string;
129
+ blockIds: boolean;
130
+ };
131
+
132
+ /** The extension list for a preset: `full` adds CMS blocks, YouTube, images and H4. */
133
+ export function buildExtensions({ preset, placeholder, blockIds }: ExtensionOptions): Extensions {
134
+ const full = preset === "full";
135
+ return dedupeExtensions([
136
+ StarterKit.configure({
137
+ heading: { levels: full ? [1, 2, 3, 4] : [1, 2, 3] },
138
+ link: { openOnClick: false },
139
+ }),
140
+ ...(full ? [CmsBlock, Youtube, Image.configure({ inline: false })] : []),
141
+ Placeholder.configure({ placeholder }),
142
+ dragHandle(),
143
+ ...(blockIds ? [BlockIdExtension] : []),
144
+ ]);
145
+ }