@teamofsilicons/silicon-ui 0.0.0-stage → 0.1.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 (352) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +63 -2
  3. package/components/accordion/accordion.d.ts +13 -0
  4. package/components/accordion/accordion.js +35 -0
  5. package/components/accordion/accordion.module.css +15 -0
  6. package/components/action-button/action-button.d.ts +11 -0
  7. package/components/action-button/action-button.js +57 -0
  8. package/components/action-button/action-button.module.css +15 -0
  9. package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
  10. package/components/activity-heatmap/activity-heatmap.js +303 -0
  11. package/components/activity-heatmap/activity-heatmap.module.css +83 -0
  12. package/components/alert/alert.d.ts +13 -0
  13. package/components/alert/alert.js +79 -0
  14. package/components/alert/alert.module.css +15 -0
  15. package/components/animated-counter/animated-counter.d.ts +13 -0
  16. package/components/animated-counter/animated-counter.js +73 -0
  17. package/components/animated-counter/animated-counter.module.css +12 -0
  18. package/components/announcement-bar/announcement-bar.d.ts +53 -0
  19. package/components/announcement-bar/announcement-bar.js +187 -0
  20. package/components/announcement-bar/announcement-bar.module.css +60 -0
  21. package/components/avatar/avatar.d.ts +8 -0
  22. package/components/avatar/avatar.js +18 -0
  23. package/components/avatar/avatar.module.css +60 -0
  24. package/components/avatar-group/avatar-group.d.ts +13 -0
  25. package/components/avatar-group/avatar-group.js +24 -0
  26. package/components/avatar-group/avatar-group.module.css +27 -0
  27. package/components/badge/badge.d.ts +10 -0
  28. package/components/badge/badge.js +66 -0
  29. package/components/badge/badge.module.css +52 -0
  30. package/components/bar-chart/bar-chart.d.ts +32 -0
  31. package/components/bar-chart/bar-chart.js +327 -0
  32. package/components/bar-chart/bar-chart.module.css +49 -0
  33. package/components/billing-toggle/billing-toggle.d.ts +35 -0
  34. package/components/billing-toggle/billing-toggle.js +83 -0
  35. package/components/billing-toggle/billing-toggle.module.css +139 -0
  36. package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
  37. package/components/bottom-sheet/bottom-sheet.js +386 -0
  38. package/components/bottom-sheet/bottom-sheet.module.css +69 -0
  39. package/components/breadcrumb/breadcrumb.d.ts +13 -0
  40. package/components/breadcrumb/breadcrumb.js +19 -0
  41. package/components/breadcrumb/breadcrumb.module.css +12 -0
  42. package/components/brush-chart/brush-chart.d.ts +45 -0
  43. package/components/brush-chart/brush-chart.js +426 -0
  44. package/components/brush-chart/brush-chart.module.css +69 -0
  45. package/components/button/button.d.ts +10 -0
  46. package/components/button/button.js +97 -0
  47. package/components/button/button.module.css +39 -0
  48. package/components/button-group/button-group.d.ts +59 -0
  49. package/components/button-group/button-group.js +280 -0
  50. package/components/button-group/button-group.module.css +87 -0
  51. package/components/button-link/button-link.d.ts +24 -0
  52. package/components/button-link/button-link.js +11 -0
  53. package/components/button-link/button-link.module.css +7 -0
  54. package/components/calendar/calendar.d.ts +22 -0
  55. package/components/calendar/calendar.js +253 -0
  56. package/components/calendar/calendar.module.css +51 -0
  57. package/components/card/card.d.ts +19 -0
  58. package/components/card/card.js +108 -0
  59. package/components/card/card.module.css +51 -0
  60. package/components/card-stack/card-stack.d.ts +34 -0
  61. package/components/card-stack/card-stack.js +291 -0
  62. package/components/card-stack/card-stack.module.css +63 -0
  63. package/components/carousel/carousel.d.ts +28 -0
  64. package/components/carousel/carousel.js +332 -0
  65. package/components/carousel/carousel.module.css +42 -0
  66. package/components/chat-thread/chat-thread.d.ts +135 -0
  67. package/components/chat-thread/chat-thread.js +406 -0
  68. package/components/chat-thread/chat-thread.module.css +242 -0
  69. package/components/checkbox/checkbox.d.ts +7 -0
  70. package/components/checkbox/checkbox.js +23 -0
  71. package/components/checkbox/checkbox.module.css +15 -0
  72. package/components/chip-group/chip-group.d.ts +24 -0
  73. package/components/chip-group/chip-group.js +196 -0
  74. package/components/chip-group/chip-group.module.css +38 -0
  75. package/components/code-block/code-block.d.ts +11 -0
  76. package/components/code-block/code-block.js +133 -0
  77. package/components/code-block/code-block.module.css +43 -0
  78. package/components/color-picker/color-picker.d.ts +40 -0
  79. package/components/color-picker/color-picker.js +449 -0
  80. package/components/color-picker/color-picker.module.css +111 -0
  81. package/components/combobox/combobox.d.ts +19 -0
  82. package/components/combobox/combobox.js +167 -0
  83. package/components/combobox/combobox.module.css +28 -0
  84. package/components/command-palette/command-palette.d.ts +20 -0
  85. package/components/command-palette/command-palette.js +151 -0
  86. package/components/command-palette/command-palette.module.css +94 -0
  87. package/components/comment-thread/comment-thread.d.ts +120 -0
  88. package/components/comment-thread/comment-thread.js +343 -0
  89. package/components/comment-thread/comment-thread.module.css +195 -0
  90. package/components/confirm-morph/confirm-morph.d.ts +65 -0
  91. package/components/confirm-morph/confirm-morph.js +355 -0
  92. package/components/confirm-morph/confirm-morph.module.css +146 -0
  93. package/components/context-menu/context-menu.d.ts +18 -0
  94. package/components/context-menu/context-menu.js +148 -0
  95. package/components/context-menu/context-menu.module.css +17 -0
  96. package/components/copy-button/copy-button.d.ts +10 -0
  97. package/components/copy-button/copy-button.js +69 -0
  98. package/components/copy-button/copy-button.module.css +21 -0
  99. package/components/countdown/countdown.d.ts +31 -0
  100. package/components/countdown/countdown.js +177 -0
  101. package/components/countdown/countdown.module.css +49 -0
  102. package/components/date-picker/date-picker.d.ts +17 -0
  103. package/components/date-picker/date-picker.js +100 -0
  104. package/components/date-picker/date-picker.module.css +34 -0
  105. package/components/date-range-picker/date-range-picker.d.ts +44 -0
  106. package/components/date-range-picker/date-range-picker.js +534 -0
  107. package/components/date-range-picker/date-range-picker.module.css +214 -0
  108. package/components/dialog/dialog.d.ts +11 -0
  109. package/components/dialog/dialog.js +52 -0
  110. package/components/dialog/dialog.module.css +36 -0
  111. package/components/donut-chart/donut-chart.d.ts +46 -0
  112. package/components/donut-chart/donut-chart.js +382 -0
  113. package/components/donut-chart/donut-chart.module.css +48 -0
  114. package/components/drawer/drawer.d.ts +20 -0
  115. package/components/drawer/drawer.js +111 -0
  116. package/components/drawer/drawer.module.css +97 -0
  117. package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
  118. package/components/dropdown-menu/dropdown-menu.js +39 -0
  119. package/components/dropdown-menu/dropdown-menu.module.css +38 -0
  120. package/components/elastic-slider/elastic-slider.d.ts +39 -0
  121. package/components/elastic-slider/elastic-slider.js +261 -0
  122. package/components/elastic-slider/elastic-slider.module.css +40 -0
  123. package/components/empty-state/empty-state.d.ts +12 -0
  124. package/components/empty-state/empty-state.js +69 -0
  125. package/components/empty-state/empty-state.module.css +13 -0
  126. package/components/expandable-card/expandable-card.d.ts +13 -0
  127. package/components/expandable-card/expandable-card.js +89 -0
  128. package/components/expandable-card/expandable-card.module.css +22 -0
  129. package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
  130. package/components/expanding-button-group/expanding-button-group.js +258 -0
  131. package/components/expanding-button-group/expanding-button-group.module.css +44 -0
  132. package/components/expanding-search/expanding-search.d.ts +38 -0
  133. package/components/expanding-search/expanding-search.js +242 -0
  134. package/components/expanding-search/expanding-search.module.css +52 -0
  135. package/components/file-dropzone/file-dropzone.d.ts +44 -0
  136. package/components/file-dropzone/file-dropzone.js +379 -0
  137. package/components/file-dropzone/file-dropzone.module.css +115 -0
  138. package/components/file-upload/file-upload.d.ts +22 -0
  139. package/components/file-upload/file-upload.js +192 -0
  140. package/components/file-upload/file-upload.module.css +49 -0
  141. package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
  142. package/components/filter-toolbar/filter-toolbar.js +446 -0
  143. package/components/filter-toolbar/filter-toolbar.module.css +70 -0
  144. package/components/floating-button-group/floating-button-group.d.ts +51 -0
  145. package/components/floating-button-group/floating-button-group.js +215 -0
  146. package/components/floating-button-group/floating-button-group.module.css +74 -0
  147. package/components/gauge/gauge.d.ts +18 -0
  148. package/components/gauge/gauge.js +101 -0
  149. package/components/gauge/gauge.module.css +22 -0
  150. package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
  151. package/components/hold-to-confirm/hold-to-confirm.js +165 -0
  152. package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
  153. package/components/hover-card/hover-card.d.ts +38 -0
  154. package/components/hover-card/hover-card.js +145 -0
  155. package/components/hover-card/hover-card.module.css +20 -0
  156. package/components/image-compare/image-compare.d.ts +27 -0
  157. package/components/image-compare/image-compare.js +267 -0
  158. package/components/image-compare/image-compare.module.css +25 -0
  159. package/components/in-view-title/in-view-title.d.ts +17 -0
  160. package/components/in-view-title/in-view-title.js +70 -0
  161. package/components/in-view-title/in-view-title.module.css +13 -0
  162. package/components/inline-edit/inline-edit.d.ts +27 -0
  163. package/components/inline-edit/inline-edit.js +315 -0
  164. package/components/inline-edit/inline-edit.module.css +73 -0
  165. package/components/input/input.d.ts +7 -0
  166. package/components/input/input.js +60 -0
  167. package/components/input/input.module.css +22 -0
  168. package/components/json-viewer/json-viewer.d.ts +48 -0
  169. package/components/json-viewer/json-viewer.js +463 -0
  170. package/components/json-viewer/json-viewer.module.css +92 -0
  171. package/components/line-chart/line-chart.d.ts +58 -0
  172. package/components/line-chart/line-chart.js +383 -0
  173. package/components/line-chart/line-chart.module.css +60 -0
  174. package/components/mention-input/mention-input.d.ts +72 -0
  175. package/components/mention-input/mention-input.js +417 -0
  176. package/components/mention-input/mention-input.module.css +134 -0
  177. package/components/metric-card/metric-card.d.ts +8 -0
  178. package/components/metric-card/metric-card.js +44 -0
  179. package/components/metric-card/metric-card.module.css +18 -0
  180. package/components/money-input/money-input.d.ts +48 -0
  181. package/components/money-input/money-input.js +503 -0
  182. package/components/money-input/money-input.module.css +77 -0
  183. package/components/morph-select/morph-select.d.ts +50 -0
  184. package/components/morph-select/morph-select.js +395 -0
  185. package/components/morph-select/morph-select.module.css +88 -0
  186. package/components/multi-select/multi-select.d.ts +18 -0
  187. package/components/multi-select/multi-select.js +105 -0
  188. package/components/multi-select/multi-select.module.css +37 -0
  189. package/components/notification-center/notification-center.d.ts +24 -0
  190. package/components/notification-center/notification-center.js +146 -0
  191. package/components/notification-center/notification-center.module.css +79 -0
  192. package/components/number-field/number-field.d.ts +41 -0
  193. package/components/number-field/number-field.js +455 -0
  194. package/components/number-field/number-field.module.css +71 -0
  195. package/components/otp-input/otp-input.d.ts +13 -0
  196. package/components/otp-input/otp-input.js +129 -0
  197. package/components/otp-input/otp-input.module.css +28 -0
  198. package/components/pagination/pagination.d.ts +7 -0
  199. package/components/pagination/pagination.js +60 -0
  200. package/components/pagination/pagination.module.css +16 -0
  201. package/components/password-field/password-field.d.ts +14 -0
  202. package/components/password-field/password-field.js +51 -0
  203. package/components/password-field/password-field.module.css +27 -0
  204. package/components/password-strength/password-strength.d.ts +107 -0
  205. package/components/password-strength/password-strength.js +161 -0
  206. package/components/password-strength/password-strength.module.css +57 -0
  207. package/components/phone-input/phone-input.d.ts +73 -0
  208. package/components/phone-input/phone-input.js +612 -0
  209. package/components/phone-input/phone-input.module.css +98 -0
  210. package/components/popover/popover.d.ts +6 -0
  211. package/components/popover/popover.js +19 -0
  212. package/components/popover/popover.module.css +32 -0
  213. package/components/progress/progress.d.ts +8 -0
  214. package/components/progress/progress.js +42 -0
  215. package/components/progress/progress.module.css +1 -0
  216. package/components/radio-cards/radio-cards.d.ts +35 -0
  217. package/components/radio-cards/radio-cards.js +110 -0
  218. package/components/radio-cards/radio-cards.module.css +84 -0
  219. package/components/radio-group/radio-group.d.ts +13 -0
  220. package/components/radio-group/radio-group.js +55 -0
  221. package/components/radio-group/radio-group.module.css +30 -0
  222. package/components/resizable-panels/resizable-panels.d.ts +37 -0
  223. package/components/resizable-panels/resizable-panels.js +338 -0
  224. package/components/resizable-panels/resizable-panels.module.css +40 -0
  225. package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
  226. package/components/rich-text-editor/rich-text-editor.js +1733 -0
  227. package/components/rich-text-editor/rich-text-editor.module.css +201 -0
  228. package/components/ridgeline/ridgeline.d.ts +37 -0
  229. package/components/ridgeline/ridgeline.js +346 -0
  230. package/components/ridgeline/ridgeline.module.css +58 -0
  231. package/components/scroll-area/scroll-area.d.ts +40 -0
  232. package/components/scroll-area/scroll-area.js +153 -0
  233. package/components/scroll-area/scroll-area.module.css +79 -0
  234. package/components/search-field/search-field.d.ts +7 -0
  235. package/components/search-field/search-field.js +20 -0
  236. package/components/search-field/search-field.module.css +17 -0
  237. package/components/segmented-control/segmented-control.d.ts +16 -0
  238. package/components/segmented-control/segmented-control.js +56 -0
  239. package/components/segmented-control/segmented-control.module.css +15 -0
  240. package/components/select/select.d.ts +15 -0
  241. package/components/select/select.js +36 -0
  242. package/components/select/select.module.css +35 -0
  243. package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
  244. package/components/shortcut-recorder/shortcut-recorder.js +380 -0
  245. package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
  246. package/components/signature-pad/signature-pad.d.ts +72 -0
  247. package/components/signature-pad/signature-pad.js +450 -0
  248. package/components/signature-pad/signature-pad.module.css +84 -0
  249. package/components/skeleton/skeleton.d.ts +13 -0
  250. package/components/skeleton/skeleton.js +53 -0
  251. package/components/skeleton/skeleton.module.css +11 -0
  252. package/components/slider/slider.d.ts +45 -0
  253. package/components/slider/slider.js +347 -0
  254. package/components/slider/slider.module.css +45 -0
  255. package/components/slope-chart/slope-chart.d.ts +38 -0
  256. package/components/slope-chart/slope-chart.js +183 -0
  257. package/components/slope-chart/slope-chart.module.css +51 -0
  258. package/components/slot-text/slot-text.d.ts +26 -0
  259. package/components/slot-text/slot-text.js +119 -0
  260. package/components/slot-text/slot-text.module.css +19 -0
  261. package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
  262. package/components/sortable-data-table/sortable-data-table.js +240 -0
  263. package/components/sortable-data-table/sortable-data-table.module.css +116 -0
  264. package/components/sparkline/sparkline.d.ts +19 -0
  265. package/components/sparkline/sparkline.js +239 -0
  266. package/components/sparkline/sparkline.module.css +26 -0
  267. package/components/split-button/split-button.d.ts +19 -0
  268. package/components/split-button/split-button.js +33 -0
  269. package/components/split-button/split-button.module.css +51 -0
  270. package/components/stepper/stepper.d.ts +36 -0
  271. package/components/stepper/stepper.js +108 -0
  272. package/components/stepper/stepper.module.css +74 -0
  273. package/components/streamgraph/streamgraph.d.ts +51 -0
  274. package/components/streamgraph/streamgraph.js +376 -0
  275. package/components/streamgraph/streamgraph.module.css +55 -0
  276. package/components/swipe-actions/swipe-actions.d.ts +35 -0
  277. package/components/swipe-actions/swipe-actions.js +330 -0
  278. package/components/swipe-actions/swipe-actions.module.css +64 -0
  279. package/components/switch/switch.d.ts +7 -0
  280. package/components/switch/switch.js +45 -0
  281. package/components/switch/switch.module.css +12 -0
  282. package/components/tabs/tabs.d.ts +8 -0
  283. package/components/tabs/tabs.js +138 -0
  284. package/components/tabs/tabs.module.css +27 -0
  285. package/components/tag-input/tag-input.d.ts +15 -0
  286. package/components/tag-input/tag-input.js +150 -0
  287. package/components/tag-input/tag-input.module.css +34 -0
  288. package/components/text-morph/text-morph.d.ts +13 -0
  289. package/components/text-morph/text-morph.js +69 -0
  290. package/components/text-morph/text-morph.module.css +8 -0
  291. package/components/text-reveal/text-reveal.d.ts +14 -0
  292. package/components/text-reveal/text-reveal.js +20 -0
  293. package/components/text-reveal/text-reveal.module.css +13 -0
  294. package/components/text-shimmer/text-shimmer.d.ts +18 -0
  295. package/components/text-shimmer/text-shimmer.js +90 -0
  296. package/components/text-shimmer/text-shimmer.module.css +9 -0
  297. package/components/textarea/textarea.d.ts +7 -0
  298. package/components/textarea/textarea.js +56 -0
  299. package/components/textarea/textarea.module.css +22 -0
  300. package/components/theme-switch/theme-switch.d.ts +11 -0
  301. package/components/theme-switch/theme-switch.js +37 -0
  302. package/components/theme-switch/theme-switch.module.css +31 -0
  303. package/components/time-picker/time-picker.d.ts +13 -0
  304. package/components/time-picker/time-picker.js +95 -0
  305. package/components/time-picker/time-picker.module.css +23 -0
  306. package/components/timeline/timeline.d.ts +47 -0
  307. package/components/timeline/timeline.js +171 -0
  308. package/components/timeline/timeline.module.css +51 -0
  309. package/components/toast/toast.d.ts +8 -0
  310. package/components/toast/toast.js +118 -0
  311. package/components/toast/toast.module.css +100 -0
  312. package/components/toast-stack/toast-stack.d.ts +57 -0
  313. package/components/toast-stack/toast-stack.js +399 -0
  314. package/components/toast-stack/toast-stack.module.css +61 -0
  315. package/components/tooltip/tooltip.d.ts +8 -0
  316. package/components/tooltip/tooltip.js +62 -0
  317. package/components/tooltip/tooltip.module.css +18 -0
  318. package/components/tree-view/tree-view.d.ts +17 -0
  319. package/components/tree-view/tree-view.js +147 -0
  320. package/components/tree-view/tree-view.module.css +22 -0
  321. package/components/treemap/treemap.d.ts +35 -0
  322. package/components/treemap/treemap.js +375 -0
  323. package/components/treemap/treemap.module.css +59 -0
  324. package/components/usage-meter/usage-meter.d.ts +24 -0
  325. package/components/usage-meter/usage-meter.js +296 -0
  326. package/components/usage-meter/usage-meter.module.css +61 -0
  327. package/components/user-menu/user-menu.d.ts +62 -0
  328. package/components/user-menu/user-menu.js +316 -0
  329. package/components/user-menu/user-menu.module.css +97 -0
  330. package/components/waffle-chart/waffle-chart.d.ts +39 -0
  331. package/components/waffle-chart/waffle-chart.js +244 -0
  332. package/components/waffle-chart/waffle-chart.module.css +52 -0
  333. package/components.json +1330 -0
  334. package/lib/escape.d.ts +19 -0
  335. package/lib/escape.js +50 -0
  336. package/lib/media.d.ts +353 -0
  337. package/lib/media.js +65 -0
  338. package/lib/menu-highlight.d.ts +28 -0
  339. package/lib/menu-highlight.js +43 -0
  340. package/lib/motion-tokens.d.ts +60 -0
  341. package/lib/motion-tokens.js +25 -0
  342. package/lib/return-focus.d.ts +9 -0
  343. package/lib/return-focus.js +53 -0
  344. package/lib/silicon-provider.d.ts +60 -0
  345. package/lib/silicon-provider.js +47 -0
  346. package/lib/use-copy-feedback.d.ts +8 -0
  347. package/lib/use-copy-feedback.js +36 -0
  348. package/lib/use-today.d.ts +2 -0
  349. package/lib/use-today.js +29 -0
  350. package/package.json +66 -4
  351. package/styles.css +218 -0
  352. package/styles.css.d.ts +1 -0
@@ -0,0 +1,1733 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from "motion/react";
5
+ import { ArrowLeft, Bold, Check, Code, Heading1, Heading2, Heading3, Italic, Link, List, ListOrdered, Minus, Pilcrow, Quote, SquareCode, Strikethrough, Unlink } from "lucide-react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import styles from "./rich-text-editor.module.css";
8
+ const ZWSP = /\u200b/g;
9
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10
+ const safeUrl = (url) => /^(https?:|mailto:|tel:|\/|#)/i.test(url.trim());
11
+ /** Adds a scheme to bare domains so `example.com/docs` becomes a working link. */
12
+ export function normalizeUrl(input) {
13
+ const url = input.trim();
14
+ if (!url)
15
+ return "";
16
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("/") || url.startsWith("#"))
17
+ return url;
18
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url))
19
+ return `mailto:${url}`;
20
+ return `https://${url}`;
21
+ }
22
+ function inlineMarkdown(text) {
23
+ const codes = [];
24
+ let out = escapeHtml(text).replace(/`([^`]+)`/g, (_, code) => `\u0000${codes.push(code) - 1}\u0000`);
25
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => safeUrl(url) ? `<a href="${url}">${label}</a>` : label);
26
+ out = out.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>");
27
+ out = out.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "<s>$1</s>");
28
+ out = out.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?!\*)/g, "$1<em>$2</em>");
29
+ out = out.replace(/(^|[^\w_])_(?=\S)([^_]*?\S)_(?![\w_])/g, "$1<em>$2</em>");
30
+ return out.replace(/\u0000(\d+)\u0000/g, (_, index) => `<code>${codes[Number(index)]}</code>`);
31
+ }
32
+ function listMarkdown(lines) {
33
+ const items = lines.map(line => {
34
+ const match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
35
+ return { indent: match[1].replace(/\t/g, " ").length, tag: /\d/.test(match[2]) ? "ol" : "ul", text: match[3] };
36
+ });
37
+ let html = "";
38
+ const stack = [];
39
+ for (const item of items) {
40
+ while (stack.length > 1 && item.indent < stack[stack.length - 1].indent)
41
+ html += `</li></${stack.pop().tag}>`;
42
+ const top = stack[stack.length - 1];
43
+ if (!top || item.indent > top.indent) {
44
+ html += `<${item.tag}><li>`;
45
+ stack.push({ indent: item.indent, tag: item.tag });
46
+ }
47
+ else if (top.tag !== item.tag) {
48
+ html += `</li></${top.tag}><${item.tag}><li>`;
49
+ top.tag = item.tag;
50
+ }
51
+ else
52
+ html += "</li><li>";
53
+ html += inlineMarkdown(item.text);
54
+ }
55
+ while (stack.length)
56
+ html += `</li></${stack.pop().tag}>`;
57
+ return html;
58
+ }
59
+ /** Converts Markdown to the editor's HTML: headings (three levels), lists, quotes, fenced code, rules, and inline marks. */
60
+ export function markdownToHtml(markdown) {
61
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
62
+ const out = [];
63
+ const paragraph = [];
64
+ const flush = () => { if (paragraph.length) {
65
+ out.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
66
+ paragraph.length = 0;
67
+ } };
68
+ const isItem = (line) => /^\s*([-*+]|\d+[.)])\s+/.test(line);
69
+ for (let index = 0; index < lines.length;) {
70
+ const line = lines[index];
71
+ if (/^\s*```/.test(line)) {
72
+ flush();
73
+ const code = [];
74
+ index++;
75
+ while (index < lines.length && !/^\s*```/.test(lines[index]))
76
+ code.push(lines[index++]);
77
+ index++;
78
+ out.push(`<pre>${escapeHtml(code.join("\n")) || "<br>"}</pre>`);
79
+ continue;
80
+ }
81
+ if (!line.trim()) {
82
+ flush();
83
+ index++;
84
+ continue;
85
+ }
86
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
87
+ if (heading) {
88
+ flush();
89
+ const level = Math.min(3, heading[1].length);
90
+ out.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
91
+ index++;
92
+ continue;
93
+ }
94
+ if (/^ {0,3}([-*_])( *\1){2,} *$/.test(line)) {
95
+ flush();
96
+ out.push("<hr>");
97
+ index++;
98
+ continue;
99
+ }
100
+ if (/^\s*>/.test(line)) {
101
+ flush();
102
+ const quote = [];
103
+ while (index < lines.length && /^\s*>/.test(lines[index]))
104
+ quote.push(lines[index++].replace(/^\s*>\s?/, ""));
105
+ out.push(`<blockquote>${quote.map(inlineMarkdown).join("<br>")}</blockquote>`);
106
+ continue;
107
+ }
108
+ if (isItem(line)) {
109
+ flush();
110
+ const items = [];
111
+ while (index < lines.length && isItem(lines[index]))
112
+ items.push(lines[index++]);
113
+ out.push(listMarkdown(items));
114
+ continue;
115
+ }
116
+ paragraph.push(line.trim());
117
+ index++;
118
+ }
119
+ flush();
120
+ return out.join("") || "<p><br></p>";
121
+ }
122
+ function wrapMark(inner, mark) {
123
+ const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
124
+ return match[2] ? `${match[1]}${mark}${match[2]}${mark}${match[3]}` : inner;
125
+ }
126
+ function inlineToMarkdown(node) {
127
+ let out = "";
128
+ node.childNodes.forEach(child => {
129
+ if (child.nodeType === Node.TEXT_NODE) {
130
+ out += child.data.replace(ZWSP, "");
131
+ return;
132
+ }
133
+ if (child.nodeType !== Node.ELEMENT_NODE)
134
+ return;
135
+ const element = child;
136
+ switch (element.nodeName) {
137
+ case "BR":
138
+ out += "\n";
139
+ break;
140
+ case "STRONG":
141
+ case "B":
142
+ out += wrapMark(inlineToMarkdown(element), "**");
143
+ break;
144
+ case "EM":
145
+ case "I":
146
+ out += wrapMark(inlineToMarkdown(element), "_");
147
+ break;
148
+ case "S":
149
+ case "STRIKE":
150
+ case "DEL":
151
+ out += wrapMark(inlineToMarkdown(element), "~~");
152
+ break;
153
+ case "CODE":
154
+ out += `\`${(element.textContent ?? "").replace(ZWSP, "")}\``;
155
+ break;
156
+ case "A":
157
+ out += `[${inlineToMarkdown(element)}](${element.getAttribute("href") ?? ""})`;
158
+ break;
159
+ case "UL":
160
+ case "OL": break;
161
+ default: out += inlineToMarkdown(element);
162
+ }
163
+ });
164
+ return out;
165
+ }
166
+ function listToMarkdown(list, indent) {
167
+ let number = 1;
168
+ return Array.from(list.children).filter(item => item.nodeName === "LI").map(item => {
169
+ const marker = list.nodeName === "OL" ? `${number++}. ` : "- ";
170
+ const nested = Array.from(item.children).filter(child => child.nodeName === "UL" || child.nodeName === "OL").map(child => listToMarkdown(child, indent + " ".repeat(marker.length)));
171
+ return [`${indent}${marker}${inlineToMarkdown(item).trim()}`, ...nested].join("\n");
172
+ }).join("\n");
173
+ }
174
+ function blockToMarkdown(node) {
175
+ if (node.nodeType === Node.TEXT_NODE)
176
+ return node.data.replace(ZWSP, "").trim();
177
+ if (node.nodeType !== Node.ELEMENT_NODE)
178
+ return "";
179
+ const element = node;
180
+ switch (element.nodeName) {
181
+ case "H1": return `# ${inlineToMarkdown(element).trim()}`;
182
+ case "H2": return `## ${inlineToMarkdown(element).trim()}`;
183
+ case "H3": return `### ${inlineToMarkdown(element).trim()}`;
184
+ case "HR": return "---";
185
+ case "PRE": return `\`\`\`\n${(element.textContent ?? "").replace(ZWSP, "").replace(/\n$/, "")}\n\`\`\``;
186
+ case "UL":
187
+ case "OL": return listToMarkdown(element, "");
188
+ case "BLOCKQUOTE": {
189
+ const blocks = Array.from(element.children).some(child => BLOCK_TAGS.has(child.nodeName));
190
+ const body = blocks ? Array.from(element.childNodes).map(blockToMarkdown).filter(Boolean).join("\n\n") : inlineToMarkdown(element).trim();
191
+ return body.split("\n").map(line => line ? `> ${line}` : ">").join("\n");
192
+ }
193
+ default: return inlineToMarkdown(element).trim();
194
+ }
195
+ }
196
+ /** Converts editor HTML (or an element) to Markdown. */
197
+ export function htmlToMarkdown(input) {
198
+ const root = typeof input === "string" ? parseHtml(input) : input;
199
+ return Array.from(root.childNodes).map(blockToMarkdown).filter(Boolean).join("\n\n");
200
+ }
201
+ function parseHtml(html) {
202
+ const template = document.createElement("template");
203
+ template.innerHTML = html;
204
+ const root = document.createElement("div");
205
+ root.appendChild(template.content);
206
+ return root;
207
+ }
208
+ const insertAfter = (reference, ...nodes) => { let at = reference; nodes.forEach(node => { at.parentNode.insertBefore(node, at.nextSibling); at = node; }); };
209
+ const insertBefore = (reference, ...nodes) => nodes.forEach(node => reference.parentNode.insertBefore(node, reference));
210
+ const replaceNode = (old, ...nodes) => { insertBefore(old, ...nodes); old.parentNode.removeChild(old); };
211
+ const appendNodes = (parent, ...nodes) => nodes.forEach(node => parent.appendChild(node));
212
+ const INLINE_MAP = { B: "strong", STRONG: "strong", I: "em", EM: "em", S: "s", STRIKE: "s", DEL: "s", CODE: "code", A: "a", BR: "br" };
213
+ const BLOCK_MAP = { P: "p", H1: "h1", H2: "h2", H3: "h3", H4: "h3", H5: "h3", H6: "h3", UL: "ul", OL: "ol", LI: "li", BLOCKQUOTE: "blockquote", PRE: "pre", HR: "hr" };
214
+ const DROP = new Set(["SCRIPT", "STYLE", "META", "LINK", "TITLE", "TEMPLATE", "IFRAME", "OBJECT", "EMBED", "SVG", "CANVAS", "IMG", "VIDEO", "AUDIO", "INPUT", "BUTTON", "SELECT", "TEXTAREA", "HEAD"]);
215
+ const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "LI", "PRE", "BLOCKQUOTE", "DIV", "UL", "OL", "HR"]);
216
+ /** Keeps only the tags the editor understands and drops every attribute except safe link targets. Bold and italic spans from other editors become marks. */
217
+ export function sanitizeHtml(html) {
218
+ const source = new DOMParser().parseFromString(html, "text/html").body;
219
+ const out = document.createElement("div");
220
+ const convert = (from, into) => {
221
+ from.childNodes.forEach(child => {
222
+ if (child.nodeType === Node.TEXT_NODE) {
223
+ into.appendChild(document.createTextNode(child.data));
224
+ return;
225
+ }
226
+ if (child.nodeType !== Node.ELEMENT_NODE)
227
+ return;
228
+ const element = child;
229
+ if (DROP.has(element.nodeName))
230
+ return;
231
+ const style = element.getAttribute("style") ?? "";
232
+ let tag = INLINE_MAP[element.nodeName] ?? BLOCK_MAP[element.nodeName];
233
+ if (element.nodeName === "B" && /font-weight:\s*(normal|[1-4]00)/.test(style))
234
+ tag = "";
235
+ if (element.nodeName === "SPAN")
236
+ tag = /font-weight:\s*(bold|[6-9]00)/.test(style) ? "strong" : /font-style:\s*italic/.test(style) ? "em" : /line-through/.test(style) ? "s" : "";
237
+ if (!tag) {
238
+ convert(element, into);
239
+ return;
240
+ }
241
+ const next = document.createElement(tag);
242
+ if (tag === "a") {
243
+ const href = element.getAttribute("href") ?? "";
244
+ if (!safeUrl(href)) {
245
+ convert(element, into);
246
+ return;
247
+ }
248
+ next.setAttribute("href", href);
249
+ }
250
+ if (tag === "pre")
251
+ next.textContent = element.textContent ?? "";
252
+ else if (tag !== "br" && tag !== "hr")
253
+ convert(element, next);
254
+ into.appendChild(next);
255
+ });
256
+ };
257
+ convert(source, out);
258
+ // Inline runs at the top level become paragraphs; whitespace between blocks disappears.
259
+ const fixed = document.createElement("div");
260
+ let run = null;
261
+ Array.from(out.childNodes).forEach(node => {
262
+ const block = node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName);
263
+ if (block) {
264
+ run = null;
265
+ fixed.appendChild(node);
266
+ return;
267
+ }
268
+ if (node.nodeType === Node.TEXT_NODE && !node.data.trim() && !run)
269
+ return;
270
+ if (!run) {
271
+ run = document.createElement("p");
272
+ fixed.appendChild(run);
273
+ }
274
+ run.appendChild(node);
275
+ });
276
+ fixed.querySelectorAll("li > p").forEach(paragraph => replaceNode(paragraph, ...Array.from(paragraph.childNodes)));
277
+ // The editor keeps typed whitespace, so formatting whitespace between blocks and list items has to go.
278
+ [fixed, ...Array.from(fixed.querySelectorAll("ul, ol, blockquote, li"))].forEach(parent => Array.from(parent.childNodes).forEach(node => {
279
+ if (isText(node) && !node.data.trim() && (parent === fixed || parent.nodeName === "UL" || parent.nodeName === "OL" || (node.previousSibling && BLOCK_TAGS.has(node.previousSibling.nodeName)) || (node.nextSibling && BLOCK_TAGS.has(node.nextSibling.nodeName))))
280
+ node.remove();
281
+ }));
282
+ return fixed.innerHTML;
283
+ }
284
+ /* ------------------------------------------------------------------------------------------------
285
+ * DOM helpers. Positions are stored as character offsets where every block start and line break
286
+ * counts as one character, so the caret survives any rewrite that keeps the text.
287
+ * ---------------------------------------------------------------------------------------------- */
288
+ const COUNTED = new Set(["P", "H1", "H2", "H3", "LI", "PRE", "BLOCKQUOTE", "DIV"]);
289
+ const isElement = (node) => !!node && node.nodeType === Node.ELEMENT_NODE;
290
+ const isText = (node) => !!node && node.nodeType === Node.TEXT_NODE;
291
+ function pointToOffset(root, container, offset) {
292
+ let count = 0, found = -1;
293
+ const visit = (node) => {
294
+ if (found >= 0)
295
+ return;
296
+ if (isText(node)) {
297
+ if (node === container)
298
+ found = count + offset;
299
+ else
300
+ count += node.data.length;
301
+ return;
302
+ }
303
+ if (!isElement(node))
304
+ return;
305
+ if (node !== root && COUNTED.has(node.nodeName))
306
+ count += 1;
307
+ if (node.nodeName === "BR" || node.nodeName === "HR")
308
+ count += 1;
309
+ const children = node.childNodes;
310
+ for (let index = 0; index < children.length; index++) {
311
+ if (node === container && index === offset) {
312
+ found = count;
313
+ return;
314
+ }
315
+ visit(children[index]);
316
+ if (found >= 0)
317
+ return;
318
+ }
319
+ if (node === container)
320
+ found = count;
321
+ };
322
+ visit(root);
323
+ return found < 0 ? count : found;
324
+ }
325
+ function offsetToPoint(root, target) {
326
+ let count = 0;
327
+ let result = null;
328
+ const visit = (node) => {
329
+ if (result)
330
+ return;
331
+ if (isText(node)) {
332
+ if (target <= count + node.data.length) {
333
+ result = [node, Math.max(0, target - count)];
334
+ return;
335
+ }
336
+ count += node.data.length;
337
+ return;
338
+ }
339
+ if (!isElement(node))
340
+ return;
341
+ if (node !== root && COUNTED.has(node.nodeName))
342
+ count += 1;
343
+ if (node.nodeName === "BR" || node.nodeName === "HR") {
344
+ if (count === target && node.parentNode) {
345
+ result = [node.parentNode, Array.prototype.indexOf.call(node.parentNode.childNodes, node)];
346
+ return;
347
+ }
348
+ count += 1;
349
+ return;
350
+ }
351
+ node.childNodes.forEach(visit);
352
+ if (!result && count === target && node !== root && COUNTED.has(node.nodeName))
353
+ result = [node, node.childNodes.length];
354
+ };
355
+ visit(root);
356
+ if (result)
357
+ return result;
358
+ const last = root.lastChild;
359
+ return last && isElement(last) && last.nodeName !== "HR" ? [last, last.childNodes.length] : [root, root.childNodes.length];
360
+ }
361
+ function readOffsets(root) {
362
+ const selection = window.getSelection();
363
+ if (!selection?.rangeCount)
364
+ return null;
365
+ const range = selection.getRangeAt(0);
366
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer))
367
+ return null;
368
+ return { start: pointToOffset(root, range.startContainer, range.startOffset), end: pointToOffset(root, range.endContainer, range.endOffset) };
369
+ }
370
+ function writeOffsets(root, offsets) {
371
+ const selection = window.getSelection();
372
+ if (!selection)
373
+ return;
374
+ const range = document.createRange();
375
+ if (!offsets) {
376
+ range.selectNodeContents(root.lastElementChild ?? root);
377
+ range.collapse(false);
378
+ }
379
+ else {
380
+ const [startNode, startOffset] = offsetToPoint(root, offsets.start);
381
+ const [endNode, endOffset] = offsets.end === offsets.start ? [startNode, startOffset] : offsetToPoint(root, offsets.end);
382
+ range.setStart(startNode, startOffset);
383
+ range.setEnd(endNode, endOffset);
384
+ }
385
+ selection.removeAllRanges();
386
+ selection.addRange(range);
387
+ }
388
+ /** The visible band of the nearest scrolling or clipping ancestor, so floating layers flip before they get cut off. */
389
+ function clipBand(element) {
390
+ let top = 0, bottom = window.innerHeight;
391
+ for (let node = element.parentElement; node; node = node.parentElement) {
392
+ const style = getComputedStyle(node);
393
+ if (style.overflowY !== "visible" || style.overflowX !== "visible") {
394
+ const rect = node.getBoundingClientRect();
395
+ top = Math.max(top, rect.top);
396
+ bottom = Math.min(bottom, rect.bottom);
397
+ }
398
+ }
399
+ return { top, bottom };
400
+ }
401
+ function placeCaret(node, offset) {
402
+ const selection = window.getSelection();
403
+ if (!selection)
404
+ return;
405
+ const range = document.createRange();
406
+ range.setStart(node, offset);
407
+ range.collapse(true);
408
+ selection.removeAllRanges();
409
+ selection.addRange(range);
410
+ }
411
+ function placeAtStart(element) {
412
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
413
+ const first = walker.nextNode();
414
+ if (first)
415
+ placeCaret(first, 0);
416
+ else
417
+ placeCaret(element, 0);
418
+ }
419
+ /** The nearest block around a node: a paragraph, heading, list item, quote, or code block. */
420
+ function blockAt(root, node) {
421
+ let current = node;
422
+ while (current && current !== root) {
423
+ if (isElement(current) && COUNTED.has(current.nodeName))
424
+ return current;
425
+ current = current.parentNode;
426
+ }
427
+ return null;
428
+ }
429
+ const visibleText = (element) => (element.textContent ?? "").replace(ZWSP, "");
430
+ function ensureFilled(element) {
431
+ if (element.nodeName === "HR")
432
+ return;
433
+ if (!visibleText(element) && !element.querySelector("br, hr, ul, ol"))
434
+ element.appendChild(document.createElement("br"));
435
+ }
436
+ function textBefore(block, range) {
437
+ const before = document.createRange();
438
+ before.setStart(block, 0);
439
+ before.setEnd(range.startContainer, range.startOffset);
440
+ return before.toString().replace(ZWSP, "").replace(/\u00a0/g, " ");
441
+ }
442
+ function atBlockStart(block, range) {
443
+ if (!range.collapsed)
444
+ return false;
445
+ const before = document.createRange();
446
+ before.setStart(block, 0);
447
+ before.setEnd(range.startContainer, range.startOffset);
448
+ return !before.toString().replace(ZWSP, "") && !before.cloneContents().querySelector("br, hr");
449
+ }
450
+ function moveChildren(from, to) {
451
+ while (from.firstChild)
452
+ to.appendChild(from.firstChild);
453
+ }
454
+ /** Plain text of a block, with line breaks for `<br>`, for moving content into a code block. */
455
+ function plainText(element) {
456
+ let out = "";
457
+ const walk = (node) => node.childNodes.forEach(child => {
458
+ if (isText(child))
459
+ out += child.data.replace(ZWSP, "");
460
+ else if (child.nodeName === "BR")
461
+ out += "\n";
462
+ else
463
+ walk(child);
464
+ });
465
+ walk(element);
466
+ return out.replace(/\n$/, "");
467
+ }
468
+ function linesToNodes(text) {
469
+ const nodes = [];
470
+ text.replace(/\n$/, "").split("\n").forEach((line, index) => {
471
+ if (index)
472
+ nodes.push(document.createElement("br"));
473
+ if (line)
474
+ nodes.push(document.createTextNode(line));
475
+ });
476
+ return nodes;
477
+ }
478
+ function mergeAdjacentLists(list) {
479
+ const next = list.nextElementSibling;
480
+ if (next && next.nodeName === list.nodeName) {
481
+ moveChildren(next, list);
482
+ next.remove();
483
+ }
484
+ const previous = list.previousElementSibling;
485
+ if (previous && previous.nodeName === list.nodeName) {
486
+ moveChildren(list, previous);
487
+ list.remove();
488
+ return previous;
489
+ }
490
+ return list;
491
+ }
492
+ /** Moves a list item one level out. A top level item leaves the list and becomes a paragraph between its halves. */
493
+ function outdent(item) {
494
+ const list = item.parentElement;
495
+ const parentItem = list.parentElement;
496
+ const following = [];
497
+ for (let node = item.nextSibling; node; node = node.nextSibling)
498
+ following.push(node);
499
+ if (parentItem?.nodeName === "LI") {
500
+ if (following.length) {
501
+ const sub = document.createElement(list.nodeName.toLowerCase());
502
+ following.forEach(node => sub.appendChild(node));
503
+ item.appendChild(sub);
504
+ }
505
+ insertAfter(parentItem, item);
506
+ if (!list.children.length)
507
+ list.remove();
508
+ return item;
509
+ }
510
+ const paragraph = document.createElement("p");
511
+ const nested = [];
512
+ Array.from(item.childNodes).forEach(child => { if (child.nodeName === "UL" || child.nodeName === "OL")
513
+ nested.push(child);
514
+ else
515
+ paragraph.appendChild(child); });
516
+ ensureFilled(paragraph);
517
+ const tail = following.length || nested.length ? document.createElement(list.nodeName.toLowerCase()) : null;
518
+ if (tail) {
519
+ nested.forEach(sub => sub.childNodes.length && Array.from(sub.childNodes).forEach(node => tail.appendChild(node)));
520
+ following.forEach(node => tail.appendChild(node));
521
+ }
522
+ insertAfter(list, paragraph);
523
+ if (tail)
524
+ insertAfter(paragraph, tail);
525
+ item.remove();
526
+ if (!list.children.length)
527
+ list.remove();
528
+ return paragraph;
529
+ }
530
+ function indent(item) {
531
+ const previous = item.previousElementSibling;
532
+ if (!previous || previous.nodeName !== "LI")
533
+ return false;
534
+ const listTag = item.parentElement.nodeName;
535
+ let sub = previous.lastElementChild;
536
+ if (!sub || (sub.nodeName !== "UL" && sub.nodeName !== "OL")) {
537
+ sub = document.createElement(listTag.toLowerCase());
538
+ previous.appendChild(sub);
539
+ }
540
+ sub.appendChild(item);
541
+ return true;
542
+ }
543
+ /** Turns one block into another type, keeping its content. Choosing the type a block already has turns it back into a paragraph. */
544
+ function convertBlock(block, type) {
545
+ if (block.nodeName === "LI") {
546
+ const list = block.parentElement;
547
+ if (type === "ul" || type === "ol") {
548
+ if (list.nodeName.toLowerCase() === type)
549
+ return outdent(block);
550
+ const swapped = document.createElement(type);
551
+ moveChildren(list, swapped);
552
+ replaceNode(list, swapped);
553
+ return block;
554
+ }
555
+ const paragraph = outdent(block);
556
+ return paragraph.nodeName === "P" && type !== "p" ? convertBlock(paragraph, type) : paragraph;
557
+ }
558
+ if (type === "ul" || type === "ol") {
559
+ const item = document.createElement("li");
560
+ if (block.nodeName === "PRE")
561
+ appendNodes(item, ...linesToNodes(block.textContent ?? ""));
562
+ else
563
+ moveChildren(block, item);
564
+ ensureFilled(item);
565
+ const list = document.createElement(type);
566
+ list.appendChild(item);
567
+ replaceNode(block, list);
568
+ mergeAdjacentLists(list);
569
+ return item;
570
+ }
571
+ const tag = block.nodeName.toLowerCase() === type ? "p" : type;
572
+ const next = document.createElement(tag);
573
+ if (tag === "pre")
574
+ next.textContent = plainText(block);
575
+ else if (block.nodeName === "PRE")
576
+ appendNodes(next, ...linesToNodes(block.textContent ?? ""));
577
+ else
578
+ moveChildren(block, next);
579
+ ensureFilled(next);
580
+ replaceNode(block, next);
581
+ return next;
582
+ }
583
+ /** Makes sure a paragraph follows a block the caret cannot type after, such as a rule or a code block at the end. */
584
+ function paragraphAfter(block) {
585
+ const next = block.nextElementSibling;
586
+ if (next && next.nodeName === "P")
587
+ return next;
588
+ const paragraph = document.createElement("p");
589
+ paragraph.appendChild(document.createElement("br"));
590
+ insertAfter(block, paragraph);
591
+ return paragraph;
592
+ }
593
+ /** Repairs what browsers leave behind: styled spans from merges, stray inline content at the top level, and an empty root. */
594
+ function tidy(root) {
595
+ const loose = Array.from(root.childNodes).some(node => (isText(node) && node.data.trim()) || (isElement(node) && !BLOCK_TAGS.has(node.nodeName)));
596
+ const dirty = loose || !root.firstChild || root.querySelector("span, font, [style], div, b, i, strike");
597
+ if (!dirty)
598
+ return;
599
+ const offsets = readOffsets(root);
600
+ root.querySelectorAll("[style]").forEach(element => {
601
+ const style = element.getAttribute("style") ?? "";
602
+ if (element.nodeName === "SPAN" && /font-weight:\s*(bold|[6-9]00)/.test(style))
603
+ replaceNode(element, Object.assign(document.createElement("strong"), { innerHTML: element.innerHTML }));
604
+ else
605
+ element.removeAttribute("style");
606
+ });
607
+ const rename = (selector, tag) => root.querySelectorAll(selector).forEach(element => {
608
+ const next = document.createElement(tag);
609
+ moveChildren(element, next);
610
+ replaceNode(element, next);
611
+ });
612
+ rename("b", "strong");
613
+ rename("i", "em");
614
+ rename("strike", "s");
615
+ rename(":scope > div", "p");
616
+ root.querySelectorAll("span, font, div").forEach(element => replaceNode(element, ...Array.from(element.childNodes)));
617
+ let run = null;
618
+ Array.from(root.childNodes).forEach(node => {
619
+ if (isElement(node) && BLOCK_TAGS.has(node.nodeName)) {
620
+ run = null;
621
+ return;
622
+ }
623
+ if (isText(node) && !node.data.trim() && !run) {
624
+ node.remove();
625
+ return;
626
+ }
627
+ if (!run) {
628
+ run = document.createElement("p");
629
+ insertBefore(node, run);
630
+ }
631
+ run.appendChild(node);
632
+ });
633
+ if (!root.firstChild)
634
+ root.innerHTML = "<p><br></p>";
635
+ if (root.contains(document.activeElement) || document.activeElement === root)
636
+ writeOffsets(root, offsets);
637
+ }
638
+ /** A clean copy for output: canonical tags, no attributes but links, no invisible helpers, no trailing empty lines. */
639
+ function cleanClone(root) {
640
+ const clone = root.cloneNode(true);
641
+ clone.querySelectorAll("*").forEach(element => {
642
+ Array.from(element.attributes).forEach(attribute => { if (!(element.nodeName === "A" && attribute.name === "href"))
643
+ element.removeAttribute(attribute.name); });
644
+ });
645
+ const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
646
+ const empty = [];
647
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
648
+ node.data = node.data.replace(ZWSP, "");
649
+ if (!node.data)
650
+ empty.push(node);
651
+ }
652
+ empty.forEach(node => node.remove());
653
+ clone.querySelectorAll("p, h1, h2, h3, li, blockquote").forEach(block => {
654
+ const last = block.lastChild;
655
+ if (last?.nodeName === "BR" && visibleText(block))
656
+ last.remove();
657
+ });
658
+ clone.querySelectorAll("pre").forEach(pre => { pre.textContent = (pre.textContent ?? "").replace(/\n$/, ""); });
659
+ while (clone.lastElementChild && clone.lastElementChild.nodeName !== "HR" && !visibleText(clone.lastElementChild) && clone.children.length > 1)
660
+ clone.lastElementChild.remove();
661
+ return clone;
662
+ }
663
+ function plainOutput(clone) {
664
+ return Array.from(clone.querySelectorAll("p, h1, h2, h3, li, blockquote, pre")).filter(block => !block.querySelector("p, li")).map(block => (block.textContent ?? "").trim()).filter(Boolean).join("\n");
665
+ }
666
+ const icon = { size: 16, strokeWidth: 1.75, "aria-hidden": true };
667
+ const SLASH_ITEMS = [
668
+ { type: "p", label: "Text", hint: "", keywords: "paragraph plain body", icon: _jsx(Pilcrow, { ...icon }) },
669
+ { type: "h1", label: "Heading 1", hint: "#", keywords: "title h1 large", icon: _jsx(Heading1, { ...icon }) },
670
+ { type: "h2", label: "Heading 2", hint: "##", keywords: "subtitle h2 section", icon: _jsx(Heading2, { ...icon }) },
671
+ { type: "h3", label: "Heading 3", hint: "###", keywords: "h3 small", icon: _jsx(Heading3, { ...icon }) },
672
+ { type: "ul", label: "Bulleted list", hint: "-", keywords: "bullet unordered ul points", icon: _jsx(List, { ...icon }) },
673
+ { type: "ol", label: "Numbered list", hint: "1.", keywords: "ordered ol steps numbers", icon: _jsx(ListOrdered, { ...icon }) },
674
+ { type: "blockquote", label: "Quote", hint: ">", keywords: "blockquote citation callout", icon: _jsx(Quote, { ...icon }) },
675
+ { type: "pre", label: "Code block", hint: "```", keywords: "pre snippet monospace", icon: _jsx(SquareCode, { ...icon }) },
676
+ { type: "hr", label: "Divider", hint: "---", keywords: "rule hr line separator", icon: _jsx(Minus, { ...icon }) },
677
+ ];
678
+ const { spring, duration, ease, blur } = motionTokens;
679
+ const enter = [...ease.enter];
680
+ const standard = [...ease.standard];
681
+ const physical = (visualDuration, bounce) => {
682
+ const root = 2 * Math.PI / (visualDuration * 1.2);
683
+ return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
684
+ };
685
+ const MORPH = physical(spring.morph.visualDuration, .12), GLIDE = physical(.3, .08), GROW = physical(spring.smooth.visualDuration, 0);
686
+ const TRAVEL = 18;
687
+ const faceVariants = {
688
+ hidden: (direction) => ({ opacity: 0, x: direction * TRAVEL, filter: `blur(${blur.subtle}px)` }),
689
+ shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: MORPH, opacity: { duration: .2, ease: enter, delay: .03 }, filter: { duration: .2, ease: enter, delay: .03 } } },
690
+ gone: (direction) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: .12, ease: standard }, filter: { duration: .12, ease: standard } } }),
691
+ };
692
+ const fadeVariants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .14 } }, gone: { opacity: 0, transition: { duration: .1 } } };
693
+ /** A toolbar face reports its natural size while current, so the shared surface can spring to it. */
694
+ function Face({ direction, reduced, onSize, children }) {
695
+ const ref = useRef(null);
696
+ const present = useIsPresent();
697
+ useLayoutEffect(() => {
698
+ const node = ref.current;
699
+ if (!node || !present)
700
+ return;
701
+ const report = () => onSize(node.offsetWidth, node.offsetHeight);
702
+ report();
703
+ if (typeof ResizeObserver === "undefined")
704
+ return;
705
+ const observer = new ResizeObserver(report);
706
+ observer.observe(node);
707
+ return () => observer.disconnect();
708
+ }, [onSize, present]);
709
+ return _jsx(motion.div, { ref: ref, className: styles.face, custom: direction, variants: reduced ? fadeVariants : faceVariants, initial: "hidden", animate: "shown", exit: "gone", inert: !present || undefined, children: children });
710
+ }
711
+ function ToolButton({ label, pressed, shortcut, onPress, children }) {
712
+ return _jsx("button", { type: "button", className: styles.tool, "aria-label": label, "aria-pressed": pressed, title: shortcut ? `${label} (${shortcut})` : label, onMouseDown: event => event.preventDefault(), onClick: onPress, children: children });
713
+ }
714
+ /**
715
+ * A light rich text editor on contentEditable. Markdown shortcuts as you type, a floating selection toolbar that morphs into a link field,
716
+ * a slash menu for blocks, its own undo history, and HTML plus Markdown output. No dependencies beyond Motion and icons.
717
+ */
718
+ export const RichTextEditor = forwardRef(function RichTextEditor({ value, defaultValue, defaultMarkdown, onChange, onHistoryChange, placeholder = "Start writing", blockHint = "Type / for blocks", readOnly = false, autoFocus = false, "aria-label": ariaLabel = "Editor", className, style, }, ref) {
719
+ const reduced = !!useReducedMotion();
720
+ const uid = useId();
721
+ const rootRef = useRef(null);
722
+ const editorRef = useRef(null);
723
+ const busy = useRef(false);
724
+ const dragging = useRef(false);
725
+ const lastInput = useRef("");
726
+ const lastEmitted = useRef(null);
727
+ const history = useRef({ stack: [], index: -1 });
728
+ const callbacks = useRef({ onChange, onHistoryChange });
729
+ useEffect(() => { callbacks.current = { onChange, onHistoryChange }; }, [onChange, onHistoryChange]);
730
+ const [empty, setEmpty] = useState(true);
731
+ const [formats, setFormats] = useState({ bold: false, italic: false, strike: false, code: false, link: null, block: "p" });
732
+ const [bar, setBar] = useState(null);
733
+ const [face, setFace] = useState("format");
734
+ const [linkDraft, setLinkDraft] = useState("");
735
+ const [slash, setSlash] = useState(null);
736
+ const [slashActive, setSlashActive] = useState(0);
737
+ const [announce, setAnnounce] = useState("");
738
+ const faceRef = useRef(face);
739
+ const slashRef = useRef(slash);
740
+ const savedRange = useRef(null);
741
+ const linkInputRef = useRef(null);
742
+ const hinted = useRef(null);
743
+ useEffect(() => { faceRef.current = face; }, [face]);
744
+ useEffect(() => { slashRef.current = slash; }, [slash]);
745
+ /* Output and history. */
746
+ const emit = useCallback(() => {
747
+ const editor = editorRef.current;
748
+ if (!editor)
749
+ return;
750
+ const clone = cleanClone(editor);
751
+ const html = clone.innerHTML;
752
+ const text = plainOutput(clone);
753
+ const isEmpty = !text && !clone.querySelector("hr");
754
+ lastEmitted.current = html;
755
+ setEmpty(isEmpty);
756
+ callbacks.current.onChange?.({ html, markdown: htmlToMarkdown(clone), text, empty: isEmpty });
757
+ }, []);
758
+ const reportHistory = useCallback(() => {
759
+ const { stack, index } = history.current;
760
+ callbacks.current.onHistoryChange?.({ canUndo: index > 0, canRedo: index < stack.length - 1 });
761
+ }, []);
762
+ const record = useCallback((kind, boundary = false) => {
763
+ const editor = editorRef.current;
764
+ if (!editor)
765
+ return;
766
+ const snap = { html: editor.innerHTML.replace(/ data-hint=""/g, ""), selection: readOffsets(editor), kind, at: Date.now() };
767
+ const state = history.current;
768
+ const top = state.stack[state.index];
769
+ if (top && top.html === snap.html) {
770
+ top.selection = snap.selection;
771
+ return;
772
+ }
773
+ if (kind === "type" && top?.kind === "type" && state.index > 0 && snap.at - top.at < 1000 && !boundary) {
774
+ state.stack[state.index] = { ...snap, at: top.at };
775
+ }
776
+ else {
777
+ state.stack = state.stack.slice(0, state.index + 1);
778
+ state.stack.push(snap);
779
+ if (state.stack.length > 300)
780
+ state.stack.shift();
781
+ state.index = state.stack.length - 1;
782
+ }
783
+ reportHistory();
784
+ }, [reportHistory]);
785
+ const restore = useCallback((snap) => {
786
+ const editor = editorRef.current;
787
+ if (!editor)
788
+ return;
789
+ editor.innerHTML = snap.html;
790
+ hinted.current = null;
791
+ editor.focus({ preventScroll: true });
792
+ writeOffsets(editor, snap.selection);
793
+ emit();
794
+ reportHistory();
795
+ }, [emit, reportHistory]);
796
+ const undo = useCallback(() => {
797
+ const state = history.current;
798
+ if (state.index <= 0)
799
+ return;
800
+ state.index -= 1;
801
+ setSlash(null);
802
+ restore(state.stack[state.index]);
803
+ setAnnounce("Undone");
804
+ }, [restore]);
805
+ const redo = useCallback(() => {
806
+ const state = history.current;
807
+ if (state.index >= state.stack.length - 1)
808
+ return;
809
+ state.index += 1;
810
+ setSlash(null);
811
+ restore(state.stack[state.index]);
812
+ setAnnounce("Redone");
813
+ }, [restore]);
814
+ /* Selection driven chrome: block hint, active formats, the slash query, and the toolbar. */
815
+ const sync = useCallback(() => {
816
+ const editor = editorRef.current, root = rootRef.current;
817
+ if (!editor || !root || busy.current)
818
+ return;
819
+ const selection = window.getSelection();
820
+ const inside = !!selection?.rangeCount && editor.contains(selection.anchorNode);
821
+ const range = inside ? selection.getRangeAt(0) : null;
822
+ const block = range ? blockAt(editor, range.startContainer) : null;
823
+ const hint = range?.collapsed && block && !visibleText(block) && block.nodeName !== "PRE" ? block : null;
824
+ if (hinted.current !== hint) {
825
+ hinted.current?.removeAttribute("data-hint");
826
+ hint?.setAttribute("data-hint", "");
827
+ hinted.current = hint;
828
+ }
829
+ if (!range) {
830
+ if (faceRef.current !== "link")
831
+ setBar(null);
832
+ setSlash(null);
833
+ return;
834
+ }
835
+ const start = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
836
+ const within = (selector) => { const found = start?.closest(selector); return !!found && editor.contains(found); };
837
+ const anchor = start?.closest("a");
838
+ const listItem = block?.nodeName === "LI" ? block.parentElement?.nodeName.toLowerCase() ?? "ul" : null;
839
+ const next = {
840
+ bold: within("strong, b"), italic: within("em, i"), strike: within("s, strike, del"),
841
+ code: within("code") && !within("pre"),
842
+ link: anchor && editor.contains(anchor) ? anchor.getAttribute("href") : null,
843
+ block: listItem ?? block?.nodeName.toLowerCase() ?? "p",
844
+ };
845
+ setFormats(current => Object.keys(next).every(key => current[key] === next[key]) ? current : next);
846
+ const open = slashRef.current;
847
+ if (open) {
848
+ const valid = range.collapsed && range.startContainer === open.node && range.startOffset > open.offset && open.node.data[open.offset] === "/";
849
+ const query = valid ? open.node.data.slice(open.offset + 1, range.startOffset) : "";
850
+ if (!valid || query.length > 24 || /^\s/.test(query) || /\s\s/.test(query))
851
+ setSlash(null);
852
+ else if (query !== open.query)
853
+ setSlash({ ...open, query });
854
+ }
855
+ const inCode = !!start?.closest("pre");
856
+ if (range.collapsed || dragging.current || inCode || slashRef.current || readOnly) {
857
+ if (faceRef.current !== "link")
858
+ setBar(null);
859
+ return;
860
+ }
861
+ const rects = range.getClientRects();
862
+ const rect = rects.length ? rects[0] : range.getBoundingClientRect();
863
+ const whole = range.getBoundingClientRect();
864
+ const box = root.getBoundingClientRect();
865
+ const below = rect.top - clipBand(root).top < 60;
866
+ setBar({ x: whole.left + whole.width / 2 - box.left, y: below ? whole.bottom - box.top + 8 : rect.top - box.top - 8, below });
867
+ }, [readOnly]);
868
+ const run = useCallback((change) => {
869
+ const editor = editorRef.current;
870
+ if (!editor)
871
+ return;
872
+ busy.current = true;
873
+ try {
874
+ change();
875
+ tidy(editor);
876
+ }
877
+ finally {
878
+ busy.current = false;
879
+ }
880
+ record("command");
881
+ emit();
882
+ sync();
883
+ }, [emit, record, sync]);
884
+ /* Content in and out. */
885
+ const load = useCallback((html) => {
886
+ const editor = editorRef.current;
887
+ if (!editor)
888
+ return;
889
+ editor.innerHTML = sanitizeHtml(html) || "<p><br></p>";
890
+ editor.querySelectorAll("p, h1, h2, h3, li, blockquote, pre").forEach(block => ensureFilled(block));
891
+ hinted.current = null;
892
+ }, []);
893
+ const initial = useRef({ value, defaultValue, defaultMarkdown, autoFocus });
894
+ useLayoutEffect(() => {
895
+ const editor = editorRef.current;
896
+ if (!editor)
897
+ return;
898
+ const start = initial.current;
899
+ load(start.value ?? start.defaultValue ?? (start.defaultMarkdown !== undefined ? markdownToHtml(start.defaultMarkdown) : "<p><br></p>"));
900
+ history.current = { stack: [{ html: editor.innerHTML, selection: null, kind: "command", at: 0 }], index: 0 };
901
+ emit();
902
+ reportHistory();
903
+ if (start.autoFocus) {
904
+ editor.focus();
905
+ writeOffsets(editor, null);
906
+ }
907
+ }, [emit, load, reportHistory]);
908
+ useEffect(() => {
909
+ if (value === undefined || value === lastEmitted.current)
910
+ return;
911
+ load(value);
912
+ record("command");
913
+ emit();
914
+ }, [emit, load, record, value]);
915
+ /* Markdown shortcuts, applied right after the character that completes them. */
916
+ const shortcut = useCallback((data) => {
917
+ const editor = editorRef.current;
918
+ const selection = window.getSelection();
919
+ if (!editor || !data || !selection?.rangeCount)
920
+ return false;
921
+ const range = selection.getRangeAt(0);
922
+ if (!range.collapsed)
923
+ return false;
924
+ const block = blockAt(editor, range.startContainer);
925
+ if (!block || block.nodeName === "PRE")
926
+ return false;
927
+ const before = textBefore(block, range);
928
+ const plainBlock = block.nodeName === "P" || block.nodeName === "DIV";
929
+ if (data === " " && plainBlock && block.parentElement === editor) {
930
+ const rule = [[/^# $/, "h1"], [/^## $/, "h2"], [/^### $/, "h3"], [/^[-*+] $/, "ul"], [/^\d+[.)] $/, "ol"], [/^> $/, "blockquote"]];
931
+ const match = rule.find(([pattern]) => pattern.test(before));
932
+ if (match) {
933
+ const prefix = document.createRange();
934
+ prefix.setStart(block, 0);
935
+ prefix.setEnd(range.startContainer, range.startOffset);
936
+ prefix.deleteContents();
937
+ const next = convertBlock(block, match[1]);
938
+ placeAtStart(next);
939
+ return true;
940
+ }
941
+ }
942
+ if (data === "-" && plainBlock && block.parentElement === editor && before === "---" && visibleText(block) === "---") {
943
+ const rule = document.createElement("hr");
944
+ replaceNode(block, rule);
945
+ placeAtStart(paragraphAfter(rule));
946
+ return true;
947
+ }
948
+ if (data === "`" && plainBlock && block.parentElement === editor && before === "```" && visibleText(block) === "```") {
949
+ block.textContent = "";
950
+ const next = convertBlock(block, "pre");
951
+ placeAtStart(next);
952
+ return true;
953
+ }
954
+ const node = range.startContainer;
955
+ if (!isText(node) || node.parentElement?.closest("code"))
956
+ return false;
957
+ const text = node.data.slice(0, range.startOffset);
958
+ const inline = [
959
+ [/(\*\*|__)([^*_\s](?:[^*_]*[^*_\s])?)\1$/, "strong"],
960
+ [/~~([^~\s](?:[^~]*[^~\s])?)~~$/, "s"],
961
+ [/`([^`]+)`$/, "code"],
962
+ [/(?:^|[^*\w])(\*([^*\s](?:[^*]*[^*\s])?)\*)$/, "em"],
963
+ [/(?:^|[^_\w])(_([^_\s](?:[^_]*[^_\s])?)_)$/, "em"],
964
+ ];
965
+ for (const [pattern, tag] of inline) {
966
+ const match = pattern.exec(text);
967
+ if (!match)
968
+ continue;
969
+ const whole = tag === "em" ? match[1] : match[0];
970
+ const inner = tag === "em" ? match[2] : tag === "code" ? match[1] : tag === "s" ? match[1] : match[2];
971
+ const from = range.startOffset - whole.length;
972
+ node.splitText(range.startOffset);
973
+ const middle = node.splitText(from);
974
+ const element = document.createElement(tag);
975
+ element.textContent = inner;
976
+ replaceNode(middle, element);
977
+ // A zero width space after the mark keeps the next keystroke outside it; output strips it.
978
+ const exit = document.createTextNode("\u200b");
979
+ insertAfter(element, exit);
980
+ placeCaret(exit, 1);
981
+ return true;
982
+ }
983
+ return false;
984
+ }, []);
985
+ const openSlashIfTyped = useCallback((data) => {
986
+ const editor = editorRef.current, root = rootRef.current;
987
+ const selection = window.getSelection();
988
+ if (data !== "/" || !editor || !root || !selection?.rangeCount)
989
+ return;
990
+ const range = selection.getRangeAt(0);
991
+ const node = range.startContainer;
992
+ if (!range.collapsed || !isText(node) || node.parentElement?.closest("pre, code"))
993
+ return;
994
+ const offset = range.startOffset - 1;
995
+ if (node.data[offset] !== "/" || (offset > 0 && !/[\s\u00a0\u200b]/.test(node.data[offset - 1])))
996
+ return;
997
+ const mark = document.createRange();
998
+ mark.setStart(node, offset);
999
+ mark.setEnd(node, offset + 1);
1000
+ const rect = mark.getBoundingClientRect(), box = root.getBoundingClientRect();
1001
+ const band = clipBand(root);
1002
+ const above = band.bottom - rect.bottom < 330 && rect.top - band.top > band.bottom - rect.bottom;
1003
+ const room = above ? rect.top - band.top - 12 : band.bottom - rect.bottom - 12;
1004
+ setSlash({ node, offset, query: "", x: Math.max(0, Math.min(rect.left - box.left - 8, box.width - 244)), y: above ? rect.top - box.top - 6 : rect.bottom - box.top + 6, above, max: Math.max(150, room) });
1005
+ setSlashActive(0);
1006
+ setBar(null);
1007
+ }, []);
1008
+ const slashResults = useMemo(() => {
1009
+ const query = slash?.query.trim().toLowerCase() ?? "";
1010
+ if (!query)
1011
+ return SLASH_ITEMS;
1012
+ return SLASH_ITEMS.filter(item => `${item.label} ${item.keywords}`.toLowerCase().split(/\s+/).some(word => word.startsWith(query)) || item.label.toLowerCase().includes(query));
1013
+ }, [slash?.query]);
1014
+ const slashIndex = Math.min(slashActive, Math.max(0, slashResults.length - 1));
1015
+ const slashOpen = !!slash && slashResults.length > 0;
1016
+ const applyBlock = useCallback((type, target) => {
1017
+ const editor = editorRef.current;
1018
+ const selection = window.getSelection();
1019
+ if (!editor || !selection?.rangeCount)
1020
+ return;
1021
+ const range = selection.getRangeAt(0);
1022
+ const offsets = readOffsets(editor);
1023
+ const block = target ?? blockAt(editor, range.startContainer);
1024
+ if (!block)
1025
+ return;
1026
+ if (type === "hr") {
1027
+ const top = block.nodeName === "LI" ? block.closest("ul, ol") : block;
1028
+ const rule = document.createElement("hr");
1029
+ if (!visibleText(block) && block.nodeName !== "LI")
1030
+ replaceNode(block, rule);
1031
+ else
1032
+ insertAfter(top, rule);
1033
+ placeAtStart(paragraphAfter(rule));
1034
+ return;
1035
+ }
1036
+ // Several selected blocks turn together; if every one already has the type, they all turn back into paragraphs.
1037
+ const blocks = range.collapsed ? [block] : Array.from(editor.querySelectorAll("p, h1, h2, h3, li, blockquote, pre")).filter(candidate => range.intersectsNode(candidate) && !candidate.querySelector("p, li"));
1038
+ const current = (candidate) => candidate.nodeName === "LI" ? candidate.parentElement.nodeName.toLowerCase() : candidate.nodeName.toLowerCase();
1039
+ const allSame = blocks.every(candidate => current(candidate) === type);
1040
+ blocks.forEach(candidate => {
1041
+ if (allSame)
1042
+ convertBlock(candidate, type);
1043
+ else if (current(candidate) !== type)
1044
+ convertBlock(candidate, type);
1045
+ });
1046
+ writeOffsets(editor, offsets);
1047
+ if (type === "pre") {
1048
+ const pre = blockAt(editor, window.getSelection()?.anchorNode ?? null);
1049
+ if (pre && !pre.nextElementSibling)
1050
+ paragraphAfter(pre);
1051
+ }
1052
+ }, []);
1053
+ const chooseSlash = (item) => {
1054
+ const open = slashRef.current;
1055
+ const editor = editorRef.current;
1056
+ if (!item || !open || !editor)
1057
+ return;
1058
+ const selection = window.getSelection();
1059
+ const caret = selection?.rangeCount && selection.getRangeAt(0).startContainer === open.node ? selection.getRangeAt(0).startOffset : open.offset + 1 + open.query.length;
1060
+ setSlash(null);
1061
+ run(() => {
1062
+ open.node.deleteData(open.offset, Math.max(1, caret - open.offset));
1063
+ const block = blockAt(editor, open.node);
1064
+ placeCaret(open.node, open.offset);
1065
+ if (block) {
1066
+ ensureFilled(block);
1067
+ applyBlock(item.type, block);
1068
+ }
1069
+ });
1070
+ setAnnounce(`${item.label} added`);
1071
+ };
1072
+ /* Inline marks and links. */
1073
+ const toggleMark = (mark) => run(() => {
1074
+ const editor = editorRef.current;
1075
+ const selection = window.getSelection();
1076
+ if (!selection?.rangeCount)
1077
+ return;
1078
+ const range = selection.getRangeAt(0);
1079
+ if (mark !== "code") {
1080
+ document.execCommand("styleWithCSS", false, "false");
1081
+ document.execCommand(mark === "strike" ? "strikeThrough" : mark);
1082
+ return;
1083
+ }
1084
+ const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
1085
+ const code = startElement?.closest("code");
1086
+ if (code && editor.contains(code) && !code.closest("pre")) {
1087
+ const text = document.createTextNode(code.textContent ?? "");
1088
+ replaceNode(code, text);
1089
+ const next = document.createRange();
1090
+ next.selectNodeContents(text);
1091
+ selection.removeAllRanges();
1092
+ selection.addRange(next);
1093
+ return;
1094
+ }
1095
+ if (range.collapsed || blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer))
1096
+ return;
1097
+ const element = document.createElement("code");
1098
+ element.textContent = range.toString();
1099
+ range.deleteContents();
1100
+ range.insertNode(element);
1101
+ const next = document.createRange();
1102
+ next.selectNodeContents(element);
1103
+ selection.removeAllRanges();
1104
+ selection.addRange(next);
1105
+ });
1106
+ const openLink = () => {
1107
+ const editor = editorRef.current;
1108
+ const selection = window.getSelection();
1109
+ if (!editor || !selection?.rangeCount)
1110
+ return;
1111
+ let range = selection.getRangeAt(0);
1112
+ const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
1113
+ const existing = startElement?.closest("a");
1114
+ if (existing && editor.contains(existing)) {
1115
+ range = document.createRange();
1116
+ range.selectNodeContents(existing);
1117
+ selection.removeAllRanges();
1118
+ selection.addRange(range);
1119
+ }
1120
+ if (range.collapsed)
1121
+ return;
1122
+ savedRange.current = range.cloneRange();
1123
+ setLinkDraft(existing?.getAttribute("href") ?? "");
1124
+ setFace("link");
1125
+ faceRef.current = "link";
1126
+ sync();
1127
+ requestAnimationFrame(() => linkInputRef.current?.focus({ preventScroll: true }));
1128
+ };
1129
+ const closeLink = (focusEditor = true) => {
1130
+ setFace("format");
1131
+ faceRef.current = "format";
1132
+ const editor = editorRef.current, range = savedRange.current;
1133
+ savedRange.current = null;
1134
+ if (focusEditor && editor && range) {
1135
+ editor.focus({ preventScroll: true });
1136
+ const selection = window.getSelection();
1137
+ selection?.removeAllRanges();
1138
+ selection?.addRange(range);
1139
+ }
1140
+ sync();
1141
+ };
1142
+ const applyLink = (remove = false) => {
1143
+ const range = savedRange.current;
1144
+ const editor = editorRef.current;
1145
+ const url = normalizeUrl(linkDraft);
1146
+ if (!range || !editor) {
1147
+ closeLink();
1148
+ return;
1149
+ }
1150
+ editor.focus({ preventScroll: true });
1151
+ const selection = window.getSelection();
1152
+ selection.removeAllRanges();
1153
+ selection.addRange(range);
1154
+ run(() => {
1155
+ const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
1156
+ const existing = startElement?.closest("a");
1157
+ if (remove || !url) {
1158
+ if (existing && editor.contains(existing))
1159
+ replaceNode(existing, ...Array.from(existing.childNodes));
1160
+ else
1161
+ document.execCommand("unlink");
1162
+ return;
1163
+ }
1164
+ if (existing && editor.contains(existing) && range.toString() === existing.textContent) {
1165
+ existing.setAttribute("href", url);
1166
+ return;
1167
+ }
1168
+ if (blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) {
1169
+ document.execCommand("createLink", false, url);
1170
+ return;
1171
+ }
1172
+ const contents = range.extractContents();
1173
+ contents.querySelectorAll("a").forEach(anchor => replaceNode(anchor, ...Array.from(anchor.childNodes)));
1174
+ const anchor = document.createElement("a");
1175
+ anchor.setAttribute("href", url);
1176
+ anchor.appendChild(contents);
1177
+ range.insertNode(anchor);
1178
+ const after = document.createRange();
1179
+ after.setStartAfter(anchor);
1180
+ after.collapse(true);
1181
+ selection.removeAllRanges();
1182
+ selection.addRange(after);
1183
+ });
1184
+ savedRange.current = null;
1185
+ setFace("format");
1186
+ faceRef.current = "format";
1187
+ setBar(null);
1188
+ setAnnounce(remove || !url ? "Link removed" : "Link added");
1189
+ };
1190
+ /* Keyboard. Enter, Backspace at a block edge, and history go through beforeinput so mobile keyboards behave the same. */
1191
+ const enterKey = useCallback((soft) => {
1192
+ const editor = editorRef.current;
1193
+ const selection = window.getSelection();
1194
+ if (!selection?.rangeCount)
1195
+ return;
1196
+ let range = selection.getRangeAt(0);
1197
+ if (!range.collapsed) {
1198
+ range.deleteContents();
1199
+ range = selection.getRangeAt(0);
1200
+ }
1201
+ const block = blockAt(editor, range.startContainer);
1202
+ if (!block)
1203
+ return;
1204
+ if (block.nodeName === "PRE") {
1205
+ const full = block.textContent ?? "";
1206
+ const caret = textBefore(block, range).length;
1207
+ const head = full.slice(0, caret), tail = full.slice(caret);
1208
+ if (!tail.replace(/\n$/, "") && head.endsWith("\n") && !soft) {
1209
+ block.textContent = head.slice(0, -1) || "";
1210
+ ensureFilled(block);
1211
+ const paragraph = document.createElement("p");
1212
+ paragraph.appendChild(document.createElement("br"));
1213
+ insertAfter(block, paragraph);
1214
+ placeAtStart(paragraph);
1215
+ return;
1216
+ }
1217
+ block.textContent = head + "\n" + (tail || "\n");
1218
+ placeCaret(block.firstChild, caret + 1);
1219
+ return;
1220
+ }
1221
+ if (soft) {
1222
+ const br = document.createElement("br");
1223
+ range.insertNode(br);
1224
+ if (!br.nextSibling || (isText(br.nextSibling) && !br.nextSibling.data && !br.nextSibling.nextSibling))
1225
+ insertAfter(br, document.createElement("br"));
1226
+ const after = document.createRange();
1227
+ after.setStartAfter(br);
1228
+ after.collapse(true);
1229
+ selection.removeAllRanges();
1230
+ selection.addRange(after);
1231
+ return;
1232
+ }
1233
+ if (block.nodeName === "LI" && !visibleText(block) && !block.querySelector("ul, ol")) {
1234
+ placeAtStart(outdent(block));
1235
+ return;
1236
+ }
1237
+ if (block.nodeName === "BLOCKQUOTE" && !visibleText(block)) {
1238
+ placeAtStart(convertBlock(block, "p"));
1239
+ return;
1240
+ }
1241
+ const tail = document.createRange();
1242
+ tail.setStart(range.startContainer, range.startOffset);
1243
+ tail.setEnd(block, block.childNodes.length);
1244
+ const atEnd = !tail.toString().replace(ZWSP, "") && !tail.cloneContents().querySelector("ul, ol");
1245
+ const heading = /^H[1-3]$/.test(block.nodeName);
1246
+ const fragment = tail.extractContents();
1247
+ const next = document.createElement(heading && atEnd ? "p" : block.nodeName.toLowerCase());
1248
+ if (!(heading && atEnd))
1249
+ next.appendChild(fragment);
1250
+ // Formatting open at the caret carries into the new line only when there is text to carry.
1251
+ if (heading && atEnd)
1252
+ next.appendChild(document.createElement("br"));
1253
+ insertAfter(block, next);
1254
+ block.querySelectorAll(":scope > br:last-child").forEach(br => { if (visibleText(block))
1255
+ br.remove(); });
1256
+ ensureFilled(block);
1257
+ ensureFilled(next);
1258
+ if (!visibleText(next))
1259
+ next.querySelectorAll("strong, em, s, code, a").forEach(element => { if (!visibleText(element))
1260
+ element.remove(); });
1261
+ ensureFilled(next);
1262
+ placeAtStart(next);
1263
+ }, []);
1264
+ const backspaceAtStart = useCallback(() => {
1265
+ const editor = editorRef.current;
1266
+ const selection = window.getSelection();
1267
+ if (!selection?.rangeCount)
1268
+ return false;
1269
+ const range = selection.getRangeAt(0);
1270
+ const block = blockAt(editor, range.startContainer);
1271
+ if (!block || !atBlockStart(block, range))
1272
+ return false;
1273
+ if (/^(H[1-3]|BLOCKQUOTE|PRE)$/.test(block.nodeName)) {
1274
+ placeAtStart(convertBlock(block, "p"));
1275
+ return true;
1276
+ }
1277
+ if (block.nodeName === "LI") {
1278
+ placeAtStart(outdent(block));
1279
+ return true;
1280
+ }
1281
+ const previous = block.previousElementSibling;
1282
+ if (previous?.nodeName === "HR") {
1283
+ previous.remove();
1284
+ return true;
1285
+ }
1286
+ if (!previous && !visibleText(block) && block.nextElementSibling) {
1287
+ const next = block.nextElementSibling;
1288
+ block.remove();
1289
+ placeAtStart(next);
1290
+ return true;
1291
+ }
1292
+ return false;
1293
+ }, []);
1294
+ useEffect(() => {
1295
+ const editor = editorRef.current;
1296
+ if (!editor)
1297
+ return;
1298
+ const onBeforeInput = (event) => {
1299
+ if (readOnly)
1300
+ return;
1301
+ switch (event.inputType) {
1302
+ case "historyUndo":
1303
+ event.preventDefault();
1304
+ undo();
1305
+ return;
1306
+ case "historyRedo":
1307
+ event.preventDefault();
1308
+ redo();
1309
+ return;
1310
+ case "insertParagraph":
1311
+ case "insertLineBreak":
1312
+ event.preventDefault();
1313
+ if (slashRef.current)
1314
+ return;
1315
+ run(() => enterKey(event.inputType === "insertLineBreak"));
1316
+ return;
1317
+ case "deleteContentBackward": {
1318
+ const selection = window.getSelection();
1319
+ if (!selection?.rangeCount || !selection.getRangeAt(0).collapsed)
1320
+ return;
1321
+ busy.current = true;
1322
+ let handled = false;
1323
+ try {
1324
+ handled = backspaceAtStart();
1325
+ }
1326
+ finally {
1327
+ busy.current = false;
1328
+ }
1329
+ if (handled) {
1330
+ event.preventDefault();
1331
+ tidy(editor);
1332
+ record("command");
1333
+ emit();
1334
+ sync();
1335
+ }
1336
+ return;
1337
+ }
1338
+ case "insertText": {
1339
+ // Typing into an emptied editor lands in a paragraph, never loose at the root.
1340
+ const selection = window.getSelection();
1341
+ if (!selection?.rangeCount || blockAt(editor, selection.anchorNode) || editor.querySelector("p, h1, h2, h3, li, pre, blockquote"))
1342
+ return;
1343
+ event.preventDefault();
1344
+ const paragraph = document.createElement("p");
1345
+ const text = document.createTextNode(event.data ?? "");
1346
+ paragraph.appendChild(text);
1347
+ editor.replaceChildren(paragraph);
1348
+ placeCaret(text, text.length);
1349
+ record("type");
1350
+ emit();
1351
+ sync();
1352
+ return;
1353
+ }
1354
+ case "insertFromDrop":
1355
+ event.preventDefault();
1356
+ return;
1357
+ case "formatUnderline":
1358
+ event.preventDefault();
1359
+ return;
1360
+ }
1361
+ };
1362
+ editor.addEventListener("beforeinput", onBeforeInput);
1363
+ return () => editor.removeEventListener("beforeinput", onBeforeInput);
1364
+ }, [backspaceAtStart, emit, enterKey, readOnly, record, redo, run, sync, undo]);
1365
+ const onInput = (event) => {
1366
+ if (busy.current)
1367
+ return;
1368
+ const editor = editorRef.current;
1369
+ const native = event.nativeEvent;
1370
+ if (native.isComposing)
1371
+ return;
1372
+ busy.current = true;
1373
+ let converted = false;
1374
+ try {
1375
+ tidy(editor);
1376
+ if (native.inputType === "insertText")
1377
+ converted = shortcut(native.data);
1378
+ }
1379
+ finally {
1380
+ busy.current = false;
1381
+ }
1382
+ if (converted) {
1383
+ tidy(editor);
1384
+ record("command");
1385
+ setAnnounce("Formatted");
1386
+ }
1387
+ else
1388
+ record("type", native.data === " " || native.inputType !== lastInput.current);
1389
+ lastInput.current = native.inputType;
1390
+ emit();
1391
+ if (!converted && native.inputType === "insertText")
1392
+ openSlashIfTyped(native.data);
1393
+ sync();
1394
+ };
1395
+ const focusToolbar = () => rootRef.current?.querySelector(`[data-toolbar="${uid}"] button`)?.focus();
1396
+ const onKeyDown = (event) => {
1397
+ if (readOnly)
1398
+ return;
1399
+ const mod = event.metaKey || event.ctrlKey;
1400
+ const key = event.key.toLowerCase();
1401
+ if (slashOpen) {
1402
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1403
+ event.preventDefault();
1404
+ setSlashActive((slashIndex + (event.key === "ArrowDown" ? 1 : -1) + slashResults.length) % slashResults.length);
1405
+ return;
1406
+ }
1407
+ if (event.key === "Enter" || event.key === "Tab") {
1408
+ event.preventDefault();
1409
+ chooseSlash(slashResults[slashIndex]);
1410
+ return;
1411
+ }
1412
+ if (event.key === "Escape") {
1413
+ event.preventDefault();
1414
+ event.stopPropagation();
1415
+ setSlash(null);
1416
+ return;
1417
+ }
1418
+ }
1419
+ if (mod && key === "z") {
1420
+ event.preventDefault();
1421
+ if (event.shiftKey)
1422
+ redo();
1423
+ else
1424
+ undo();
1425
+ return;
1426
+ }
1427
+ if (mod && key === "y") {
1428
+ event.preventDefault();
1429
+ redo();
1430
+ return;
1431
+ }
1432
+ if (mod && !event.shiftKey && key === "b") {
1433
+ event.preventDefault();
1434
+ toggleMark("bold");
1435
+ return;
1436
+ }
1437
+ if (mod && !event.shiftKey && key === "i") {
1438
+ event.preventDefault();
1439
+ toggleMark("italic");
1440
+ return;
1441
+ }
1442
+ if (mod && event.shiftKey && key === "x") {
1443
+ event.preventDefault();
1444
+ toggleMark("strike");
1445
+ return;
1446
+ }
1447
+ if (mod && key === "e") {
1448
+ event.preventDefault();
1449
+ toggleMark("code");
1450
+ return;
1451
+ }
1452
+ if (mod && key === "k") {
1453
+ event.preventDefault();
1454
+ openLink();
1455
+ return;
1456
+ }
1457
+ if (mod && key === "u") {
1458
+ event.preventDefault();
1459
+ return;
1460
+ }
1461
+ if (event.altKey && event.key === "F10" && bar) {
1462
+ event.preventDefault();
1463
+ focusToolbar();
1464
+ return;
1465
+ }
1466
+ if (event.key === "Tab") {
1467
+ const editor = editorRef.current;
1468
+ const selection = window.getSelection();
1469
+ const block = selection?.rangeCount ? blockAt(editor, selection.anchorNode) : null;
1470
+ if (block?.nodeName === "LI") {
1471
+ event.preventDefault();
1472
+ run(() => {
1473
+ const offsets = readOffsets(editor);
1474
+ if (event.shiftKey)
1475
+ outdent(block);
1476
+ else
1477
+ indent(block);
1478
+ writeOffsets(editor, offsets);
1479
+ });
1480
+ }
1481
+ else if (block?.nodeName === "PRE" && !event.shiftKey) {
1482
+ event.preventDefault();
1483
+ run(() => {
1484
+ const range = selection.getRangeAt(0);
1485
+ range.deleteContents();
1486
+ const spaces = document.createTextNode(" ");
1487
+ range.insertNode(spaces);
1488
+ placeCaret(spaces, 2);
1489
+ block.normalize();
1490
+ });
1491
+ }
1492
+ }
1493
+ };
1494
+ const onPaste = (event) => {
1495
+ if (readOnly)
1496
+ return;
1497
+ event.preventDefault();
1498
+ const editor = editorRef.current;
1499
+ const html = event.clipboardData.getData("text/html");
1500
+ const text = event.clipboardData.getData("text/plain");
1501
+ run(() => {
1502
+ const selection = window.getSelection();
1503
+ if (!selection?.rangeCount)
1504
+ return;
1505
+ const range = selection.getRangeAt(0);
1506
+ range.deleteContents();
1507
+ const block = blockAt(editor, range.startContainer);
1508
+ const markdownish = /(^|\n)\s*(#{1,3}\s|[-*+]\s|\d+[.)]\s|>|```)|\*\*|`[^`]+`|\[[^\]]+\]\(/.test(text);
1509
+ const source = html ? sanitizeHtml(html) : markdownish || text.includes("\n\n") ? markdownToHtml(text) : "";
1510
+ const fragment = source ? parseHtml(source) : null;
1511
+ const single = fragment && fragment.children.length === 1 && fragment.firstElementChild.nodeName === "P";
1512
+ // Plain text, code blocks, and list items take the text as it is; everything else keeps its structure.
1513
+ if (!fragment || !block || block.nodeName === "PRE" || (block.nodeName === "LI" && !single)) {
1514
+ const plain = block?.nodeName === "PRE" ? text : text.replace(/\s*\n\s*/g, " ");
1515
+ const node = document.createTextNode(plain);
1516
+ range.insertNode(node);
1517
+ placeCaret(node, node.length);
1518
+ return;
1519
+ }
1520
+ if (single) {
1521
+ const nodes = Array.from(fragment.firstElementChild.childNodes);
1522
+ const last = nodes[nodes.length - 1];
1523
+ const holder = document.createDocumentFragment();
1524
+ nodes.forEach(node => holder.appendChild(node));
1525
+ range.insertNode(holder);
1526
+ if (last) {
1527
+ const after = document.createRange();
1528
+ after.setStartAfter(last);
1529
+ after.collapse(true);
1530
+ selection.removeAllRanges();
1531
+ selection.addRange(after);
1532
+ }
1533
+ return;
1534
+ }
1535
+ const tail = document.createRange();
1536
+ tail.setStart(range.startContainer, range.startOffset);
1537
+ tail.setEnd(block, block.childNodes.length);
1538
+ const rest = document.createElement(block.nodeName === "LI" ? "p" : block.nodeName.toLowerCase());
1539
+ rest.appendChild(tail.extractContents());
1540
+ const top = block.nodeName === "LI" ? block.closest("ul, ol") : block;
1541
+ const blocks = Array.from(fragment.childNodes);
1542
+ let anchor = top;
1543
+ blocks.forEach(node => { insertAfter(anchor, node); if (isElement(node))
1544
+ anchor = node; });
1545
+ if (visibleText(rest) || rest.querySelector("br")) {
1546
+ insertAfter(anchor, rest);
1547
+ ensureFilled(rest);
1548
+ }
1549
+ if (!visibleText(block) && block.nodeName === "P")
1550
+ block.remove();
1551
+ const end = document.createRange();
1552
+ end.selectNodeContents(anchor);
1553
+ end.collapse(false);
1554
+ selection.removeAllRanges();
1555
+ selection.addRange(end);
1556
+ });
1557
+ };
1558
+ /* Selection events. */
1559
+ useEffect(() => {
1560
+ let frame = 0;
1561
+ const onChange = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(sync); };
1562
+ const onUp = () => { if (!dragging.current)
1563
+ return; dragging.current = false; onChange(); };
1564
+ document.addEventListener("selectionchange", onChange);
1565
+ document.addEventListener("pointerup", onUp);
1566
+ return () => { cancelAnimationFrame(frame); document.removeEventListener("selectionchange", onChange); document.removeEventListener("pointerup", onUp); };
1567
+ }, [sync]);
1568
+ useEffect(() => {
1569
+ if (face !== "link")
1570
+ return;
1571
+ const down = (event) => { if (!rootRef.current?.querySelector(`[data-toolbar="${uid}"]`)?.contains(event.target)) {
1572
+ savedRange.current = null;
1573
+ setFace("format");
1574
+ faceRef.current = "format";
1575
+ setBar(null);
1576
+ } };
1577
+ document.addEventListener("pointerdown", down);
1578
+ return () => document.removeEventListener("pointerdown", down);
1579
+ }, [face, uid]);
1580
+ useImperativeHandle(ref, () => ({
1581
+ focus: () => editorRef.current?.focus(),
1582
+ getHTML: () => editorRef.current ? cleanClone(editorRef.current).innerHTML : "",
1583
+ getMarkdown: () => editorRef.current ? htmlToMarkdown(cleanClone(editorRef.current)) : "",
1584
+ getText: () => editorRef.current ? plainOutput(cleanClone(editorRef.current)) : "",
1585
+ setHTML: html => { load(html); record("command"); emit(); },
1586
+ setMarkdown: markdown => { load(markdownToHtml(markdown)); record("command"); emit(); },
1587
+ clear: () => { load("<p><br></p>"); record("command"); emit(); },
1588
+ undo,
1589
+ redo,
1590
+ get element() { return editorRef.current; },
1591
+ }), [emit, load, record, redo, undo]);
1592
+ /* Toolbar geometry: one surface whose size springs between faces and whose position glides with the selection. */
1593
+ const tx = useMotionValue(0), ty = useMotionValue(0), tw = useMotionValue(0), th = useMotionValue(0);
1594
+ const shown = useRef(false);
1595
+ /* Until a face has reported its size the surface has nothing to show, so it stays hidden rather than flashing a 1px dot. */
1596
+ const toolbarVisibility = useTransform(tw, width => width > 0 ? "visible" : "hidden");
1597
+ const targetSize = useRef({ width: 0, height: 0 });
1598
+ const onFaceSize = useCallback((width, height) => {
1599
+ targetSize.current = { width, height };
1600
+ if (!shown.current || reduced || tw.get() === 0) {
1601
+ tw.jump(width);
1602
+ th.jump(height);
1603
+ return;
1604
+ }
1605
+ animate(tw, width, MORPH);
1606
+ animate(th, height, MORPH);
1607
+ }, [reduced, th, tw]);
1608
+ const toolbarOpen = !!bar && !readOnly;
1609
+ // A fresh toolbar is placed, not glided, and stays "fresh" until it has painted once, so a selection that settles in the same
1610
+ // frame cannot send it sliding in from its last spot or from the root's corner. Motion resets a remounted element's bound
1611
+ // values to what they held at render (Strict Mode does this on every open in development), so the placement is applied
1612
+ // again once mounting is done. Its size is left alone on close so the exit keeps its shape instead of collapsing.
1613
+ const placedFrame = useRef(0);
1614
+ const placement = useRef(null);
1615
+ const place = useCallback(() => {
1616
+ const at = placement.current;
1617
+ if (!at)
1618
+ return;
1619
+ const { width, height } = targetSize.current;
1620
+ if (tx.get() !== at.x)
1621
+ tx.jump(at.x);
1622
+ if (ty.get() !== at.y)
1623
+ ty.jump(at.y);
1624
+ if (width && tw.get() !== width) {
1625
+ tw.jump(width);
1626
+ th.jump(height);
1627
+ }
1628
+ }, [th, tw, tx, ty]);
1629
+ useLayoutEffect(() => {
1630
+ if (!bar) {
1631
+ shown.current = false;
1632
+ placement.current = null;
1633
+ cancelAnimationFrame(placedFrame.current);
1634
+ return;
1635
+ }
1636
+ const root = rootRef.current;
1637
+ const half = (targetSize.current.width || 280) / 2;
1638
+ const x = root ? Math.max(half + 4, Math.min(bar.x, root.offsetWidth - half - 4)) : bar.x;
1639
+ if (!shown.current || reduced) {
1640
+ placement.current = { x, y: bar.y };
1641
+ place();
1642
+ cancelAnimationFrame(placedFrame.current);
1643
+ placedFrame.current = requestAnimationFrame(() => { place(); shown.current = true; placement.current = null; });
1644
+ return;
1645
+ }
1646
+ animate(tx, x, GLIDE);
1647
+ animate(ty, bar.y, GLIDE);
1648
+ }, [bar, place, reduced, tx, ty]);
1649
+ useEffect(() => { if (bar)
1650
+ place(); }, [bar, place]);
1651
+ useEffect(() => () => cancelAnimationFrame(placedFrame.current), []);
1652
+ const direction = face === "link" ? 1 : -1;
1653
+ /* Slash menu geometry: the highlight glides between rows and the panel springs to its rows. */
1654
+ const listRef = useRef(null);
1655
+ const hy = useMotionValue(0), hh = useMotionValue(0), panelHeight = useMotionValue("auto");
1656
+ const panelBody = useRef(null);
1657
+ const slashPanel = useRef(null);
1658
+ const slashMax = slash?.max ?? 320;
1659
+ const panelSized = useRef(false);
1660
+ const highlightPlaced = useRef(false);
1661
+ useLayoutEffect(() => {
1662
+ const row = listRef.current?.querySelector(`[data-index="${slashIndex}"]`);
1663
+ if (!row)
1664
+ return;
1665
+ if (reduced || !highlightPlaced.current || hh.get() === 0) {
1666
+ hy.jump(row.offsetTop);
1667
+ hh.jump(row.offsetHeight);
1668
+ highlightPlaced.current = true;
1669
+ return;
1670
+ }
1671
+ animate(hy, row.offsetTop, GLIDE);
1672
+ animate(hh, row.offsetHeight, GLIDE);
1673
+ // A short panel scrolls to keep the active row in view.
1674
+ const panel = slashPanel.current;
1675
+ if (panel) {
1676
+ const top = row.offsetTop + 6, bottom = top + row.offsetHeight;
1677
+ if (top - 6 < panel.scrollTop)
1678
+ panel.scrollTop = top - 6;
1679
+ else if (bottom + 6 > panel.scrollTop + panel.clientHeight)
1680
+ panel.scrollTop = bottom + 6 - panel.clientHeight;
1681
+ }
1682
+ }, [hh, hy, reduced, slashIndex, slashResults, slashOpen]);
1683
+ useLayoutEffect(() => {
1684
+ // The highlight keeps its row while the menu fades out; the next open places it fresh.
1685
+ if (!slashOpen) {
1686
+ panelSized.current = false;
1687
+ highlightPlaced.current = false;
1688
+ return;
1689
+ }
1690
+ const body = panelBody.current;
1691
+ if (!body)
1692
+ return;
1693
+ const fit = () => {
1694
+ const target = Math.min(body.offsetHeight, slashMax);
1695
+ if (!panelSized.current || reduced) {
1696
+ panelHeight.jump(target);
1697
+ panelSized.current = true;
1698
+ return;
1699
+ }
1700
+ animate(panelHeight, target, GROW);
1701
+ };
1702
+ fit();
1703
+ if (typeof ResizeObserver === "undefined")
1704
+ return;
1705
+ const observer = new ResizeObserver(fit);
1706
+ observer.observe(body);
1707
+ return () => observer.disconnect();
1708
+ }, [hh, panelHeight, reduced, slashMax, slashOpen]);
1709
+ const onToolbarKey = (event) => {
1710
+ if (face !== "format")
1711
+ return;
1712
+ const buttons = Array.from(event.currentTarget.querySelectorAll("button"));
1713
+ const at = buttons.indexOf(document.activeElement);
1714
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
1715
+ event.preventDefault();
1716
+ buttons[(at + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
1717
+ }
1718
+ else if (event.key === "Escape") {
1719
+ event.preventDefault();
1720
+ editorRef.current?.focus();
1721
+ sync();
1722
+ }
1723
+ };
1724
+ const optionId = (index) => `${uid}-slash-${index}`;
1725
+ const cssVars = { "--placeholder": JSON.stringify(placeholder), "--hint": JSON.stringify(blockHint) };
1726
+ return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), style: style, children: [_jsx("div", { ref: editorRef, className: styles.editor, style: cssVars, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-multiline": "true", "aria-label": ariaLabel, "aria-readonly": readOnly || undefined, "aria-controls": slashOpen ? `${uid}-slash` : undefined, "aria-activedescendant": slashOpen ? optionId(slashIndex) : undefined, "data-empty": empty || undefined, spellCheck: true, onInput: onInput, onKeyDown: onKeyDown, onPaste: onPaste, onDrop: event => event.preventDefault(), onPointerDown: () => { dragging.current = true; }, onCompositionEnd: () => { record("type", true); emit(); } }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: announce }), _jsx(AnimatePresence, { children: toolbarOpen && _jsx(motion.div, { "data-toolbar": uid, role: "toolbar", "aria-label": "Formatting", className: styles.toolbar, "data-below": bar.below || undefined, style: { left: tx, top: ty, width: tw, height: th, visibility: toolbarVisibility, transformOrigin: bar.below ? "50% 0" : "50% 100%" }, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .94, y: bar.below ? -4 : 4 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: reduced ? { opacity: 0 } : { opacity: 0, scale: .96, transition: { duration: duration.exit * .8, ease: standard } }, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }, onKeyDown: onToolbarKey, children: _jsx(AnimatePresence, { initial: false, custom: direction, children: face === "format" ? _jsx(Face, { direction: direction, reduced: reduced, onSize: onFaceSize, children: _jsxs("div", { className: styles.row, children: [_jsx(ToolButton, { label: "Bold", pressed: formats.bold, shortcut: "\u2318B", onPress: () => toggleMark("bold"), children: _jsx(Bold, { ...icon }) }), _jsx(ToolButton, { label: "Italic", pressed: formats.italic, shortcut: "\u2318I", onPress: () => toggleMark("italic"), children: _jsx(Italic, { ...icon }) }), _jsx(ToolButton, { label: "Strikethrough", pressed: formats.strike, shortcut: "\u2318\u21E7X", onPress: () => toggleMark("strike"), children: _jsx(Strikethrough, { ...icon }) }), _jsx(ToolButton, { label: "Inline code", pressed: formats.code, shortcut: "\u2318E", onPress: () => toggleMark("code"), children: _jsx(Code, { ...icon }) }), _jsx(ToolButton, { label: formats.link ? "Edit link" : "Link", pressed: !!formats.link, shortcut: "\u2318K", onPress: openLink, children: _jsx(Link, { ...icon }) }), _jsxs("span", { className: styles.blockTools, children: [_jsx("span", { className: styles.divider, "aria-hidden": "true" }), _jsx(ToolButton, { label: "Heading 1", pressed: formats.block === "h1", onPress: () => run(() => applyBlock("h1")), children: _jsx(Heading1, { ...icon }) }), _jsx(ToolButton, { label: "Heading 2", pressed: formats.block === "h2", onPress: () => run(() => applyBlock("h2")), children: _jsx(Heading2, { ...icon }) }), _jsx(ToolButton, { label: "Quote", pressed: formats.block === "blockquote", onPress: () => run(() => applyBlock("blockquote")), children: _jsx(Quote, { ...icon }) })] })] }) }, "format") : _jsx(Face, { direction: direction, reduced: reduced, onSize: onFaceSize, children: _jsxs("form", { className: styles.linkRow, onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx("button", { type: "button", className: styles.tool, "aria-label": "Back to formatting", onClick: () => closeLink(), children: _jsx(ArrowLeft, { ...icon }) }), _jsx("input", { ref: linkInputRef, className: styles.linkInput, value: linkDraft, onChange: event => setLinkDraft(event.target.value), onKeyDown: event => { if (event.key === "Escape") {
1727
+ event.preventDefault();
1728
+ event.stopPropagation();
1729
+ closeLink();
1730
+ } }, placeholder: "Paste or type a link", "aria-label": "Link address", inputMode: "url", autoComplete: "off", spellCheck: false }), formats.link && _jsx("button", { type: "button", className: styles.tool, "aria-label": "Remove link", onClick: () => applyLink(true), children: _jsx(Unlink, { ...icon }) }), _jsx("button", { type: "submit", className: styles.apply, "aria-label": "Apply link", "data-ready": !!linkDraft.trim() || undefined, children: _jsx(Check, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] }) }, "link") }) }, "toolbar") }), _jsx(AnimatePresence, { children: slashOpen && slash && _jsx(motion.div, { ref: slashPanel, className: styles.slash, "data-above": slash.above || undefined, style: { left: slash.x, top: slash.y, height: panelHeight, maxHeight: slashMax, transformOrigin: slash.above ? "16px 100%" : "16px 0" }, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: slash.above ? 4 : -4 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: reduced ? { opacity: 0 } : { opacity: 0, scale: .97, transition: { duration: duration.exit * .8, ease: standard } }, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }, onMouseDown: event => event.preventDefault(), children: _jsx("div", { ref: panelBody, className: styles.slashBody, children: _jsxs("ul", { ref: listRef, id: `${uid}-slash`, role: "listbox", "aria-label": "Blocks", className: styles.slashList, children: [_jsx(motion.li, { "aria-hidden": "true", className: styles.slashHighlight, style: { y: hy, height: hh } }), slashResults.map((item, index) => _jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === slashIndex, "data-index": index, className: styles.slashOption, onPointerMove: () => { if (index !== slashIndex)
1731
+ setSlashActive(index); }, onClick: () => chooseSlash(item), children: [_jsx("span", { className: styles.slashIcon, children: item.icon }), _jsx("span", { className: styles.slashLabel, children: item.label }), item.hint && _jsx("kbd", { className: styles.slashHint, children: item.hint })] }, item.type))] }) }) }, `${slash.offset}-${slash.y}`) })] });
1732
+ });
1733
+ export default RichTextEditor;