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