@refraction-ui/astro 0.4.2 → 0.4.4

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 (334) hide show
  1. package/dist/accordion/index.ts +1 -0
  2. package/dist/ai/ai-manager.ts +121 -0
  3. package/dist/ai/index.ts +23 -0
  4. package/dist/ai/mock-providers.ts +68 -0
  5. package/dist/ai/sanitizer.ts +174 -0
  6. package/dist/ai/tts-manager.ts +80 -0
  7. package/dist/ai/types.ts +72 -0
  8. package/dist/animated-text/animated-text.styles.ts +28 -0
  9. package/dist/animated-text/animated-text.ts +83 -0
  10. package/dist/animated-text/index.ts +15 -0
  11. package/dist/app-shell/app-shell.ts +252 -0
  12. package/dist/app-shell/auth-shell.ts +84 -0
  13. package/dist/app-shell/index.ts +22 -0
  14. package/dist/app-shell/page-shell.ts +125 -0
  15. package/dist/astro-accordion/Button.astro +1 -1
  16. package/dist/astro-ai/index.ts +1 -1
  17. package/dist/astro-animated-text/AnimatedText.astro +1 -1
  18. package/dist/astro-animated-text/TypewriterText.astro +1 -1
  19. package/dist/astro-app-shell/AppShell.astro +1 -1
  20. package/dist/astro-app-shell/AuthShell.astro +1 -1
  21. package/dist/astro-app-shell/AuthShellCard.astro +1 -1
  22. package/dist/astro-app-shell/PageShell.astro +1 -1
  23. package/dist/astro-app-shell/PageShellSection.astro +1 -1
  24. package/dist/astro-app-shell/index.ts +1 -1
  25. package/dist/astro-auth/index.ts +2 -2
  26. package/dist/astro-avatar/Avatar.astro +1 -1
  27. package/dist/astro-avatar/AvatarFallback.astro +1 -1
  28. package/dist/astro-avatar/AvatarImage.astro +1 -1
  29. package/dist/astro-avatar-group/AvatarGroup.astro +1 -1
  30. package/dist/astro-badge/Badge.astro +1 -1
  31. package/dist/astro-bottom-nav/BottomNav.astro +1 -1
  32. package/dist/astro-breadcrumbs/Breadcrumbs.astro +1 -1
  33. package/dist/astro-button/Button.astro +1 -1
  34. package/dist/astro-calendar/Calendar.astro +1 -1
  35. package/dist/astro-card/Card.astro +1 -1
  36. package/dist/astro-card/CardContent.astro +1 -1
  37. package/dist/astro-card/CardDescription.astro +1 -1
  38. package/dist/astro-card/CardFooter.astro +1 -1
  39. package/dist/astro-card/CardHeader.astro +1 -1
  40. package/dist/astro-card/CardTitle.astro +1 -1
  41. package/dist/astro-card-grid/CardGrid.astro +1 -1
  42. package/dist/astro-carousel/Button.astro +1 -1
  43. package/dist/astro-charts/Bars.astro +1 -1
  44. package/dist/astro-charts/Chart.astro +1 -1
  45. package/dist/astro-charts/Circles.astro +1 -1
  46. package/dist/astro-charts/Histogram.astro +1 -1
  47. package/dist/astro-charts/Line.astro +1 -1
  48. package/dist/astro-charts/PieChart.astro +1 -1
  49. package/dist/astro-charts/ScatterPlot.astro +1 -1
  50. package/dist/astro-charts/index.ts +1 -1
  51. package/dist/astro-checkbox/Checkbox.astro +1 -1
  52. package/dist/astro-code-editor/CodeEditor.astro +1 -1
  53. package/dist/astro-collapsible/Collapsible.astro +1 -1
  54. package/dist/astro-collapsible/CollapsibleContent.astro +1 -1
  55. package/dist/astro-command/Command.astro +1 -1
  56. package/dist/astro-command/CommandGroup.astro +1 -1
  57. package/dist/astro-command/CommandInput.astro +1 -1
  58. package/dist/astro-command/CommandItem.astro +1 -1
  59. package/dist/astro-command-input/CommandInput.astro +1 -1
  60. package/dist/astro-content-protection/ContentProtection.astro +1 -1
  61. package/dist/astro-data-table/DataTable.astro +1 -1
  62. package/dist/astro-date-picker/DatePicker.astro +1 -1
  63. package/dist/astro-device-frame/DeviceFrame.astro +1 -1
  64. package/dist/astro-dialog/DialogContent.astro +1 -1
  65. package/dist/astro-dialog/DialogOverlay.astro +1 -1
  66. package/dist/astro-diff-viewer/DiffViewer.astro +1 -1
  67. package/dist/astro-dropdown-menu/DropdownMenuContent.astro +1 -1
  68. package/dist/astro-dropdown-menu/DropdownMenuItem.astro +1 -1
  69. package/dist/astro-emoji-picker/EmojiPicker.astro +1 -1
  70. package/dist/astro-feedback-dialog/FeedbackDialog.astro +1 -1
  71. package/dist/astro-file-upload/FileUpload.astro +1 -1
  72. package/dist/astro-footer/Footer.astro +1 -1
  73. package/dist/astro-inline-editor/InlineEditor.astro +1 -1
  74. package/dist/astro-input/Input.astro +1 -1
  75. package/dist/astro-input-group/InputGroup.astro +1 -1
  76. package/dist/astro-input-group/InputGroupAddon.astro +1 -1
  77. package/dist/astro-input-group/InputGroupButton.astro +1 -1
  78. package/dist/astro-install-prompt/InstallPrompt.astro +1 -1
  79. package/dist/astro-keyboard-shortcut/ShortcutBadge.astro +1 -1
  80. package/dist/astro-language-selector/LanguageSelector.astro +1 -1
  81. package/dist/astro-link-card/LinkCard.astro +1 -1
  82. package/dist/astro-markdown-renderer/MarkdownRenderer.astro +1 -1
  83. package/dist/astro-mobile-nav/MobileNav.astro +1 -1
  84. package/dist/astro-mobile-nav/MobileNavLink.astro +1 -1
  85. package/dist/astro-mobile-nav/MobileNavTrigger.astro +1 -1
  86. package/dist/astro-navbar/Navbar.astro +1 -1
  87. package/dist/astro-otp-input/OtpInput.astro +1 -1
  88. package/dist/astro-payment/Payment.astro +1 -1
  89. package/dist/astro-popover/PopoverContent.astro +1 -1
  90. package/dist/astro-presence-indicator/PresenceIndicator.astro +1 -1
  91. package/dist/astro-progress-display/BadgeDisplay.astro +1 -1
  92. package/dist/astro-progress-display/ProgressBar.astro +1 -1
  93. package/dist/astro-progress-display/StatsGrid.astro +1 -1
  94. package/dist/astro-radio/RadioGroup.astro +1 -1
  95. package/dist/astro-radio/RadioItem.astro +1 -1
  96. package/dist/astro-reaction-bar/ReactionBar.astro +1 -1
  97. package/dist/astro-resizable-layout/ResizableDivider.astro +1 -1
  98. package/dist/astro-resizable-layout/ResizableLayout.astro +1 -1
  99. package/dist/astro-resizable-layout/ResizablePane.astro +1 -1
  100. package/dist/astro-resizable-layout/index.ts +1 -1
  101. package/dist/astro-rich-editor/index.ts +2 -2
  102. package/dist/astro-search-bar/SearchBar.astro +1 -1
  103. package/dist/astro-search-bar/SearchResults.astro +1 -1
  104. package/dist/astro-select/Select.astro +1 -1
  105. package/dist/astro-select/SelectContent.astro +1 -1
  106. package/dist/astro-select/SelectItem.astro +1 -1
  107. package/dist/astro-select/SelectTrigger.astro +1 -1
  108. package/dist/astro-sidebar/Sidebar.astro +1 -1
  109. package/dist/astro-skeleton/Skeleton.astro +1 -1
  110. package/dist/astro-slide-viewer/SlideViewer.astro +1 -1
  111. package/dist/astro-status-indicator/StatusIndicator.astro +1 -1
  112. package/dist/astro-switch/Switch.astro +1 -1
  113. package/dist/astro-table-of-contents/TableOfContents.astro +1 -1
  114. package/dist/astro-tabs/Tabs.astro +1 -1
  115. package/dist/astro-tabs/TabsList.astro +1 -1
  116. package/dist/astro-tabs/TabsTrigger.astro +1 -1
  117. package/dist/astro-textarea/Textarea.astro +1 -1
  118. package/dist/astro-theme/ThemeScript.astro +1 -1
  119. package/dist/astro-thread-view/ThreadView.astro +1 -1
  120. package/dist/astro-toast/Toast.astro +1 -1
  121. package/dist/astro-tooltip/TooltipContent.astro +1 -1
  122. package/dist/astro-version-selector/VersionSelector.astro +1 -1
  123. package/dist/astro-video-player/VideoPlayer.astro +1 -1
  124. package/dist/auth/adapters/mock.ts +74 -0
  125. package/dist/auth/auth-machine.ts +120 -0
  126. package/dist/auth/index.ts +22 -0
  127. package/dist/auth/rbac.ts +44 -0
  128. package/dist/auth/types.ts +44 -0
  129. package/dist/avatar/avatar.styles.ts +47 -0
  130. package/dist/avatar/avatar.ts +73 -0
  131. package/dist/avatar/index.ts +15 -0
  132. package/dist/avatar-group/avatar-group.styles.ts +63 -0
  133. package/dist/avatar-group/avatar-group.ts +106 -0
  134. package/dist/avatar-group/index.ts +18 -0
  135. package/dist/badge/badge.styles.ts +24 -0
  136. package/dist/badge/badge.ts +48 -0
  137. package/dist/badge/index.ts +10 -0
  138. package/dist/bottom-nav/bottom-nav.styles.ts +18 -0
  139. package/dist/bottom-nav/bottom-nav.ts +33 -0
  140. package/dist/bottom-nav/index.ts +2 -0
  141. package/dist/breadcrumbs/breadcrumbs.styles.ts +20 -0
  142. package/dist/breadcrumbs/breadcrumbs.ts +72 -0
  143. package/dist/breadcrumbs/index.ts +8 -0
  144. package/dist/button/button.styles.ts +38 -0
  145. package/dist/button/button.ts +64 -0
  146. package/dist/button/index.ts +10 -0
  147. package/dist/calendar/calendar.styles.ts +21 -0
  148. package/dist/calendar/calendar.ts +202 -0
  149. package/dist/calendar/index.ts +9 -0
  150. package/dist/callout/callout.ts +1 -0
  151. package/dist/callout/index.ts +1 -0
  152. package/dist/card/card.styles.ts +55 -0
  153. package/dist/card/card.ts +45 -0
  154. package/dist/card/index.ts +20 -0
  155. package/dist/card-grid/card-grid.ts +9 -0
  156. package/dist/card-grid/index.ts +1 -0
  157. package/dist/carousel/index.ts +1 -0
  158. package/dist/charts/axis.ts +50 -0
  159. package/dist/charts/chart.ts +73 -0
  160. package/dist/charts/colorblind.ts +100 -0
  161. package/dist/charts/data.ts +72 -0
  162. package/dist/charts/geometry.ts +54 -0
  163. package/dist/charts/index.ts +61 -0
  164. package/dist/charts/scales.ts +151 -0
  165. package/dist/checkbox/checkbox.styles.ts +33 -0
  166. package/dist/checkbox/checkbox.ts +73 -0
  167. package/dist/checkbox/index.ts +10 -0
  168. package/dist/code-block/code-block.ts +1 -0
  169. package/dist/code-block/index.ts +1 -0
  170. package/dist/code-editor/code-editor.styles.ts +32 -0
  171. package/dist/code-editor/code-editor.ts +140 -0
  172. package/dist/code-editor/index.ts +9 -0
  173. package/dist/collapsible/collapsible.styles.ts +14 -0
  174. package/dist/collapsible/collapsible.ts +93 -0
  175. package/dist/collapsible/index.ts +7 -0
  176. package/dist/command/command.styles.ts +27 -0
  177. package/dist/command/command.ts +179 -0
  178. package/dist/command/index.ts +14 -0
  179. package/dist/command-input/command-input.ts +176 -0
  180. package/dist/command-input/index.ts +1 -0
  181. package/dist/content-protection/content-protection.styles.ts +9 -0
  182. package/dist/content-protection/content-protection.ts +68 -0
  183. package/dist/content-protection/index.ts +8 -0
  184. package/dist/data-table/data-table.styles.ts +55 -0
  185. package/dist/data-table/data-table.ts +183 -0
  186. package/dist/data-table/index.ts +15 -0
  187. package/dist/date-picker/date-picker.styles.ts +31 -0
  188. package/dist/date-picker/date-picker.ts +333 -0
  189. package/dist/date-picker/index.ts +18 -0
  190. package/dist/device-frame/device-frame.styles.ts +21 -0
  191. package/dist/device-frame/device-frame.ts +112 -0
  192. package/dist/device-frame/index.ts +10 -0
  193. package/dist/dialog/dialog.styles.ts +8 -0
  194. package/dist/dialog/dialog.ts +133 -0
  195. package/dist/dialog/index.ts +8 -0
  196. package/dist/diff-viewer/diff-viewer.styles.ts +124 -0
  197. package/dist/diff-viewer/diff-viewer.ts +255 -0
  198. package/dist/diff-viewer/index.ts +20 -0
  199. package/dist/dropdown-menu/dropdown-menu.styles.ts +9 -0
  200. package/dist/dropdown-menu/dropdown-menu.ts +139 -0
  201. package/dist/dropdown-menu/index.ts +9 -0
  202. package/dist/emoji-picker/emoji-data.ts +282 -0
  203. package/dist/emoji-picker/emoji-picker.styles.ts +32 -0
  204. package/dist/emoji-picker/emoji-picker.ts +165 -0
  205. package/dist/emoji-picker/index.ts +25 -0
  206. package/dist/feedback-dialog/feedback-dialog.styles.ts +15 -0
  207. package/dist/feedback-dialog/feedback-dialog.ts +147 -0
  208. package/dist/feedback-dialog/index.ts +11 -0
  209. package/dist/file-upload/file-upload.styles.ts +30 -0
  210. package/dist/file-upload/file-upload.ts +260 -0
  211. package/dist/file-upload/index.ts +19 -0
  212. package/dist/footer/footer.styles.ts +5 -0
  213. package/dist/footer/footer.ts +32 -0
  214. package/dist/footer/index.ts +9 -0
  215. package/dist/index.ts +1 -1
  216. package/dist/inline-editor/index.ts +13 -0
  217. package/dist/inline-editor/inline-editor.styles.ts +42 -0
  218. package/dist/inline-editor/inline-editor.ts +96 -0
  219. package/dist/input/index.ts +9 -0
  220. package/dist/input/input.styles.ts +15 -0
  221. package/dist/input/input.ts +84 -0
  222. package/dist/input-group/index.ts +13 -0
  223. package/dist/input-group/input-group.styles.ts +50 -0
  224. package/dist/input-group/input-group.ts +44 -0
  225. package/dist/install-prompt/index.ts +9 -0
  226. package/dist/install-prompt/install-prompt.styles.ts +5 -0
  227. package/dist/install-prompt/install-prompt.ts +68 -0
  228. package/dist/keyboard-shortcut/index.ts +12 -0
  229. package/dist/keyboard-shortcut/keyboard-shortcut.styles.ts +8 -0
  230. package/dist/keyboard-shortcut/keyboard-shortcut.ts +153 -0
  231. package/dist/language-selector/index.ts +8 -0
  232. package/dist/language-selector/language-selector.styles.ts +28 -0
  233. package/dist/language-selector/language-selector.ts +149 -0
  234. package/dist/link-card/index.ts +1 -0
  235. package/dist/link-card/link-card.ts +9 -0
  236. package/dist/markdown-renderer/index.ts +8 -0
  237. package/dist/markdown-renderer/markdown-renderer.styles.ts +31 -0
  238. package/dist/markdown-renderer/markdown-renderer.ts +251 -0
  239. package/dist/mobile-nav/index.ts +14 -0
  240. package/dist/mobile-nav/mobile-nav.styles.ts +39 -0
  241. package/dist/mobile-nav/mobile-nav.ts +74 -0
  242. package/dist/navbar/index.ts +9 -0
  243. package/dist/navbar/navbar.styles.ts +29 -0
  244. package/dist/navbar/navbar.ts +39 -0
  245. package/dist/otp-input/index.ts +13 -0
  246. package/dist/otp-input/otp-input.styles.ts +51 -0
  247. package/dist/otp-input/otp-input.ts +198 -0
  248. package/dist/pagination/index.ts +1 -0
  249. package/dist/payment/index.ts +1 -0
  250. package/dist/payment/payment.ts +16 -0
  251. package/dist/popover/index.ts +7 -0
  252. package/dist/popover/popover.styles.ts +16 -0
  253. package/dist/popover/popover.ts +89 -0
  254. package/dist/presence-indicator/index.ts +14 -0
  255. package/dist/presence-indicator/presence-indicator.styles.ts +29 -0
  256. package/dist/presence-indicator/presence-indicator.ts +63 -0
  257. package/dist/progress-display/index.ts +15 -0
  258. package/dist/progress-display/progress-display.styles.ts +72 -0
  259. package/dist/progress-display/progress-display.ts +60 -0
  260. package/dist/radio/index.ts +8 -0
  261. package/dist/radio/radio.styles.ts +34 -0
  262. package/dist/radio/radio.ts +70 -0
  263. package/dist/reaction-bar/index.ts +14 -0
  264. package/dist/reaction-bar/reaction-bar.styles.ts +23 -0
  265. package/dist/reaction-bar/reaction-bar.ts +87 -0
  266. package/dist/resizable-layout/index.ts +12 -0
  267. package/dist/resizable-layout/resizable-layout.styles.ts +50 -0
  268. package/dist/resizable-layout/resizable-layout.ts +139 -0
  269. package/dist/rich-editor/editor.ts +342 -0
  270. package/dist/rich-editor/emoji.ts +175 -0
  271. package/dist/rich-editor/history.ts +79 -0
  272. package/dist/rich-editor/index.ts +106 -0
  273. package/dist/rich-editor/markdown-shortcuts.ts +248 -0
  274. package/dist/rich-editor/mentions.ts +162 -0
  275. package/dist/rich-editor/model.ts +206 -0
  276. package/dist/rich-editor/operations.ts +772 -0
  277. package/dist/rich-editor/selection.ts +157 -0
  278. package/dist/rich-editor/serialization.ts +711 -0
  279. package/dist/rich-editor/slash-commands.ts +212 -0
  280. package/dist/search-bar/index.ts +7 -0
  281. package/dist/search-bar/search-bar.styles.ts +29 -0
  282. package/dist/search-bar/search-bar.ts +130 -0
  283. package/dist/select/index.ts +13 -0
  284. package/dist/select/select.styles.ts +52 -0
  285. package/dist/select/select.ts +152 -0
  286. package/dist/sidebar/index.ts +9 -0
  287. package/dist/sidebar/sidebar.styles.ts +27 -0
  288. package/dist/sidebar/sidebar.ts +59 -0
  289. package/dist/skeleton/index.ts +8 -0
  290. package/dist/skeleton/skeleton.styles.ts +16 -0
  291. package/dist/skeleton/skeleton.ts +39 -0
  292. package/dist/skip-to-content/index.ts +1 -0
  293. package/dist/skip-to-content/skip-to-content.ts +1 -0
  294. package/dist/slide-viewer/index.ts +16 -0
  295. package/dist/slide-viewer/slide-viewer.styles.ts +57 -0
  296. package/dist/slide-viewer/slide-viewer.ts +152 -0
  297. package/dist/slider/index.ts +1 -0
  298. package/dist/slider/slider.ts +2 -0
  299. package/dist/status-indicator/index.ts +15 -0
  300. package/dist/status-indicator/status-indicator.styles.ts +41 -0
  301. package/dist/status-indicator/status-indicator.ts +66 -0
  302. package/dist/steps/index.ts +1 -0
  303. package/dist/steps/steps.ts +1 -0
  304. package/dist/switch/index.ts +7 -0
  305. package/dist/switch/switch.styles.ts +55 -0
  306. package/dist/switch/switch.ts +63 -0
  307. package/dist/table-of-contents/index.ts +1 -0
  308. package/dist/table-of-contents/table-of-contents.ts +37 -0
  309. package/dist/tabs/index.ts +8 -0
  310. package/dist/tabs/tabs.styles.ts +9 -0
  311. package/dist/tabs/tabs.ts +133 -0
  312. package/dist/textarea/index.ts +7 -0
  313. package/dist/textarea/textarea.styles.ts +15 -0
  314. package/dist/textarea/textarea.ts +59 -0
  315. package/dist/theme/dom-adapters.ts +60 -0
  316. package/dist/theme/index.ts +18 -0
  317. package/dist/theme/theme-machine.ts +130 -0
  318. package/dist/theme/theme-script.ts +18 -0
  319. package/dist/thread-view/index.ts +26 -0
  320. package/dist/thread-view/thread-view.styles.ts +35 -0
  321. package/dist/thread-view/thread-view.ts +176 -0
  322. package/dist/toast/index.ts +13 -0
  323. package/dist/toast/toast.styles.ts +19 -0
  324. package/dist/toast/toast.ts +203 -0
  325. package/dist/tooltip/index.ts +7 -0
  326. package/dist/tooltip/tooltip.styles.ts +16 -0
  327. package/dist/tooltip/tooltip.ts +109 -0
  328. package/dist/version-selector/index.ts +12 -0
  329. package/dist/version-selector/version-selector.styles.ts +41 -0
  330. package/dist/version-selector/version-selector.ts +136 -0
  331. package/dist/video-player/index.ts +12 -0
  332. package/dist/video-player/video-player.styles.ts +42 -0
  333. package/dist/video-player/video-player.ts +100 -0
  334. package/package.json +1 -1
@@ -0,0 +1,772 @@
1
+ /**
2
+ * Editor operations — pure functions that take (doc, selection) and return new state.
3
+ * Every operation is immutable: documents and selections are never mutated in-place.
4
+ */
5
+
6
+ import type {
7
+ Document,
8
+ Block,
9
+ InlineContent,
10
+ TextSegment,
11
+ Mark,
12
+ MarkType,
13
+ BlockType,
14
+ } from './model.js'
15
+ import {
16
+ cloneDocument,
17
+ createBlock,
18
+ createTextSegment,
19
+ findBlockById,
20
+ findBlockIndex,
21
+ getBlockText,
22
+ getBlockTextLength,
23
+ normalizeContent,
24
+ } from './model.js'
25
+ import type { Selection, Position } from './selection.js'
26
+ import {
27
+ createCollapsedSelection,
28
+ createPosition,
29
+ createSelection,
30
+ getSelectionEnd,
31
+ getSelectionStart,
32
+ isCollapsed,
33
+ isForward,
34
+ } from './selection.js'
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Editor state
38
+ // ---------------------------------------------------------------------------
39
+ export interface EditorState {
40
+ doc: Document
41
+ selection: Selection
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Internal helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Split a block's inline content at a character offset, returning [before, after].
50
+ */
51
+ function splitContentAt(
52
+ content: InlineContent[],
53
+ offset: number,
54
+ ): [InlineContent[], InlineContent[]] {
55
+ const before: InlineContent[] = []
56
+ const after: InlineContent[] = []
57
+ let pos = 0
58
+
59
+ for (const seg of content) {
60
+ const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
61
+
62
+ if (pos + segLen <= offset) {
63
+ before.push(structuredClone(seg))
64
+ pos += segLen
65
+ } else if (pos >= offset) {
66
+ after.push(structuredClone(seg))
67
+ pos += segLen
68
+ } else {
69
+ // Split within this segment
70
+ const splitAt = offset - pos
71
+ if (seg.type === 'text') {
72
+ before.push(createTextSegment(seg.text.slice(0, splitAt), [...seg.marks]))
73
+ after.push(createTextSegment(seg.text.slice(splitAt), [...seg.marks]))
74
+ } else {
75
+ // Mention/emoji can't really be split; put whole segment in before
76
+ before.push(structuredClone(seg))
77
+ }
78
+ pos += segLen
79
+ }
80
+ }
81
+
82
+ return [
83
+ before.length > 0 ? normalizeContent(before) : [createTextSegment('')],
84
+ after.length > 0 ? normalizeContent(after) : [createTextSegment('')],
85
+ ]
86
+ }
87
+
88
+ /**
89
+ * Delete a range of characters from content, returning new content.
90
+ */
91
+ function deleteContentRange(
92
+ content: InlineContent[],
93
+ start: number,
94
+ end: number,
95
+ ): InlineContent[] {
96
+ const [beforeAll] = splitContentAt(content, start)
97
+ const [, afterAll] = splitContentAt(content, end)
98
+ return normalizeContent([...beforeAll, ...afterAll])
99
+ }
100
+
101
+ /**
102
+ * Insert text into content at a given offset, with given marks.
103
+ */
104
+ function insertTextIntoContent(
105
+ content: InlineContent[],
106
+ offset: number,
107
+ text: string,
108
+ marks: Mark[] = [],
109
+ ): InlineContent[] {
110
+ const [before, after] = splitContentAt(content, offset)
111
+ const newSeg = createTextSegment(text, marks)
112
+ return normalizeContent([...before, newSeg, ...after])
113
+ }
114
+
115
+ /**
116
+ * Insert an inline content segment at a given offset.
117
+ */
118
+ function insertSegmentIntoContent(
119
+ content: InlineContent[],
120
+ offset: number,
121
+ segment: InlineContent,
122
+ ): InlineContent[] {
123
+ const [before, after] = splitContentAt(content, offset)
124
+ return normalizeContent([...before, segment, ...after])
125
+ }
126
+
127
+ /**
128
+ * Get the marks at a specific offset within content.
129
+ */
130
+ function getMarksAtOffset(content: InlineContent[], offset: number): Mark[] {
131
+ let pos = 0
132
+ for (const seg of content) {
133
+ const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
134
+ if (seg.type === 'text' && pos <= offset && offset <= pos + segLen) {
135
+ return [...seg.marks]
136
+ }
137
+ pos += segLen
138
+ }
139
+ return []
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Text operations
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Insert text at the current selection. If selection is expanded, delete first. */
147
+ export function insertText(doc: Document, sel: Selection, text: string): EditorState {
148
+ let d = cloneDocument(doc)
149
+ let s = sel
150
+
151
+ // If there's a selection range, delete it first
152
+ if (!isCollapsed(s)) {
153
+ const result = deleteSelection(d, s)
154
+ d = result.doc
155
+ s = result.selection
156
+ }
157
+
158
+ const block = findBlockById(d, s.anchor.blockId)
159
+ if (!block) return { doc: d, selection: s }
160
+
161
+ const marks = getMarksAtOffset(block.content, s.anchor.offset)
162
+ block.content = insertTextIntoContent(block.content, s.anchor.offset, text, marks)
163
+ const newOffset = s.anchor.offset + text.length
164
+ const newSel = createCollapsedSelection(block.id, newOffset)
165
+
166
+ return { doc: d, selection: newSel }
167
+ }
168
+
169
+ /** Delete one character backward from the cursor (Backspace). */
170
+ export function deleteBackward(doc: Document, sel: Selection): EditorState {
171
+ if (!isCollapsed(sel)) {
172
+ return deleteSelection(doc, sel)
173
+ }
174
+
175
+ const d = cloneDocument(doc)
176
+ const block = findBlockById(d, sel.anchor.blockId)
177
+ if (!block) return { doc: d, selection: sel }
178
+
179
+ if (sel.anchor.offset > 0) {
180
+ // Delete one character within the block
181
+ block.content = deleteContentRange(block.content, sel.anchor.offset - 1, sel.anchor.offset)
182
+ return {
183
+ doc: d,
184
+ selection: createCollapsedSelection(block.id, sel.anchor.offset - 1),
185
+ }
186
+ }
187
+
188
+ // At start of block — merge with previous block
189
+ return mergeBlockBackward(d, sel)
190
+ }
191
+
192
+ /** Delete one character forward from the cursor (Delete key). */
193
+ export function deleteForward(doc: Document, sel: Selection): EditorState {
194
+ if (!isCollapsed(sel)) {
195
+ return deleteSelection(doc, sel)
196
+ }
197
+
198
+ const d = cloneDocument(doc)
199
+ const block = findBlockById(d, sel.anchor.blockId)
200
+ if (!block) return { doc: d, selection: sel }
201
+
202
+ const textLen = getBlockTextLength(block)
203
+ if (sel.anchor.offset < textLen) {
204
+ // Delete one character forward within the block
205
+ block.content = deleteContentRange(block.content, sel.anchor.offset, sel.anchor.offset + 1)
206
+ return { doc: d, selection: createCollapsedSelection(block.id, sel.anchor.offset) }
207
+ }
208
+
209
+ // At end of block — merge next block into this one
210
+ return mergeBlockForward(d, sel)
211
+ }
212
+
213
+ /** Delete the currently selected content. */
214
+ export function deleteSelection(doc: Document, sel: Selection): EditorState {
215
+ if (isCollapsed(sel)) return { doc: cloneDocument(doc), selection: sel }
216
+
217
+ const d = cloneDocument(doc)
218
+ const start = getSelectionStart(d, sel)
219
+ const end = getSelectionEnd(d, sel)
220
+
221
+ if (start.blockId === end.blockId) {
222
+ // Single block deletion
223
+ const block = findBlockById(d, start.blockId)!
224
+ block.content = deleteContentRange(block.content, start.offset, end.offset)
225
+ return { doc: d, selection: createCollapsedSelection(block.id, start.offset) }
226
+ }
227
+
228
+ // Multi-block deletion
229
+ const startIdx = findBlockIndex(d, start.blockId)
230
+ const endIdx = findBlockIndex(d, end.blockId)
231
+
232
+ const startBlock = d.blocks[startIdx]
233
+ const endBlock = d.blocks[endIdx]
234
+
235
+ // Keep content before start and after end, merge into start block
236
+ const [beforeContent] = splitContentAt(startBlock.content, start.offset)
237
+ const [, afterContent] = splitContentAt(endBlock.content, end.offset)
238
+
239
+ startBlock.content = normalizeContent([...beforeContent, ...afterContent])
240
+
241
+ // Remove all blocks between start and end (inclusive of end)
242
+ d.blocks.splice(startIdx + 1, endIdx - startIdx)
243
+
244
+ return { doc: d, selection: createCollapsedSelection(startBlock.id, start.offset) }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Block operations
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /** Insert a new block of the given type after the current block. */
252
+ export function insertBlock(doc: Document, sel: Selection, type: BlockType, meta?: Record<string, unknown>): EditorState {
253
+ const d = cloneDocument(doc)
254
+ const idx = findBlockIndex(d, sel.anchor.blockId)
255
+ if (idx === -1) return { doc: d, selection: sel }
256
+
257
+ const newBlock = createBlock(type, [], meta)
258
+ d.blocks.splice(idx + 1, 0, newBlock)
259
+
260
+ return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
261
+ }
262
+
263
+ /** Split the current block at the cursor position (Enter key). */
264
+ export function splitBlock(doc: Document, sel: Selection): EditorState {
265
+ let d = cloneDocument(doc)
266
+ let s = sel
267
+
268
+ if (!isCollapsed(s)) {
269
+ const result = deleteSelection(d, s)
270
+ d = result.doc
271
+ s = result.selection
272
+ }
273
+
274
+ const idx = findBlockIndex(d, s.anchor.blockId)
275
+ if (idx === -1) return { doc: d, selection: s }
276
+
277
+ const block = d.blocks[idx]
278
+
279
+ // For dividers, just insert a new paragraph after
280
+ if (block.type === 'divider') {
281
+ const newBlock = createBlock('paragraph')
282
+ d.blocks.splice(idx + 1, 0, newBlock)
283
+ return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
284
+ }
285
+
286
+ const [beforeContent, afterContent] = splitContentAt(block.content, s.anchor.offset)
287
+
288
+ block.content = beforeContent
289
+
290
+ // New block inherits type for paragraphs and list items, otherwise becomes paragraph
291
+ const newType: BlockType = block.type === 'list-item' ? 'list-item' : 'paragraph'
292
+ const newBlock = createBlock(newType, afterContent)
293
+
294
+ d.blocks.splice(idx + 1, 0, newBlock)
295
+
296
+ return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
297
+ }
298
+
299
+ /** Merge the current block backward into the previous block (Backspace at start). */
300
+ export function mergeBlockBackward(doc: Document, sel: Selection): EditorState {
301
+ const d = cloneDocument(doc)
302
+ const idx = findBlockIndex(d, sel.anchor.blockId)
303
+
304
+ // If it's a non-paragraph block at start, convert to paragraph first
305
+ if (idx <= 0) {
306
+ const block = d.blocks[idx]
307
+ if (block && block.type !== 'paragraph') {
308
+ block.type = 'paragraph'
309
+ delete block.meta
310
+ return { doc: d, selection: createCollapsedSelection(block.id, 0) }
311
+ }
312
+ return { doc: d, selection: sel }
313
+ }
314
+
315
+ const prevBlock = d.blocks[idx - 1]
316
+ const currBlock = d.blocks[idx]
317
+
318
+ // If previous block is a divider, just remove it
319
+ if (prevBlock.type === 'divider') {
320
+ d.blocks.splice(idx - 1, 1)
321
+ return { doc: d, selection: createCollapsedSelection(currBlock.id, 0) }
322
+ }
323
+
324
+ const prevLen = getBlockTextLength(prevBlock)
325
+ prevBlock.content = normalizeContent([...prevBlock.content, ...currBlock.content])
326
+ d.blocks.splice(idx, 1)
327
+
328
+ return { doc: d, selection: createCollapsedSelection(prevBlock.id, prevLen) }
329
+ }
330
+
331
+ /** Merge the next block forward into the current block (Delete at end). */
332
+ export function mergeBlockForward(doc: Document, sel: Selection): EditorState {
333
+ const d = cloneDocument(doc)
334
+ const idx = findBlockIndex(d, sel.anchor.blockId)
335
+ if (idx === -1 || idx >= d.blocks.length - 1) return { doc: d, selection: sel }
336
+
337
+ const currBlock = d.blocks[idx]
338
+ const nextBlock = d.blocks[idx + 1]
339
+
340
+ // If next block is a divider, just remove it
341
+ if (nextBlock.type === 'divider') {
342
+ d.blocks.splice(idx + 1, 1)
343
+ return { doc: d, selection: sel }
344
+ }
345
+
346
+ const currLen = getBlockTextLength(currBlock)
347
+ currBlock.content = normalizeContent([...currBlock.content, ...nextBlock.content])
348
+ d.blocks.splice(idx + 1, 1)
349
+
350
+ return { doc: d, selection: createCollapsedSelection(currBlock.id, currLen) }
351
+ }
352
+
353
+ /** Change the type of the block at the cursor. */
354
+ export function changeBlockType(
355
+ doc: Document,
356
+ sel: Selection,
357
+ type: BlockType,
358
+ meta?: Record<string, unknown>,
359
+ ): EditorState {
360
+ const d = cloneDocument(doc)
361
+ const block = findBlockById(d, sel.anchor.blockId)
362
+ if (!block) return { doc: d, selection: sel }
363
+
364
+ block.type = type
365
+ if (meta) {
366
+ block.meta = { ...block.meta, ...meta }
367
+ } else if (type !== block.type) {
368
+ // Clear meta when changing type without new meta, unless it's same type
369
+ }
370
+
371
+ // Dividers have no content
372
+ if (type === 'divider') {
373
+ block.content = [createTextSegment('')]
374
+ }
375
+
376
+ return { doc: d, selection: sel }
377
+ }
378
+
379
+ /** Indent a block (increase indent level). */
380
+ export function indentBlock(doc: Document, sel: Selection): EditorState {
381
+ const d = cloneDocument(doc)
382
+ const block = findBlockById(d, sel.anchor.blockId)
383
+ if (!block) return { doc: d, selection: sel }
384
+
385
+ const currentIndent = block.indent ?? 0
386
+ if (currentIndent < 5) {
387
+ block.indent = currentIndent + 1
388
+ }
389
+
390
+ return { doc: d, selection: sel }
391
+ }
392
+
393
+ /** Outdent a block (decrease indent level). */
394
+ export function outdentBlock(doc: Document, sel: Selection): EditorState {
395
+ const d = cloneDocument(doc)
396
+ const block = findBlockById(d, sel.anchor.blockId)
397
+ if (!block) return { doc: d, selection: sel }
398
+
399
+ const currentIndent = block.indent ?? 0
400
+ if (currentIndent > 0) {
401
+ block.indent = currentIndent - 1
402
+ }
403
+
404
+ return { doc: d, selection: sel }
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Mark operations
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /** Check if a mark of a given type is in the marks array. */
412
+ function hasMarkType(marks: Mark[], type: MarkType): boolean {
413
+ return marks.some((m) => m.type === type)
414
+ }
415
+
416
+ /** Get the active marks at the current selection. */
417
+ export function getActiveMarks(doc: Document, sel: Selection): Mark[] {
418
+ const block = findBlockById(doc, sel.anchor.blockId)
419
+ if (!block) return []
420
+
421
+ if (isCollapsed(sel)) {
422
+ // Return marks at the cursor position
423
+ return getMarksAtOffset(block.content, sel.anchor.offset > 0 ? sel.anchor.offset - 1 : 0)
424
+ }
425
+
426
+ // For a range selection, return marks common to all characters in the range
427
+ const start = getSelectionStart(doc, sel)
428
+ const end = getSelectionEnd(doc, sel)
429
+
430
+ if (start.blockId !== end.blockId) {
431
+ // For simplicity across blocks, get marks from the start position
432
+ return getMarksAtOffset(block.content, start.offset)
433
+ }
434
+
435
+ // Get all marks that apply to every character in the range
436
+ const allMarks: Mark[][] = []
437
+ let pos = 0
438
+ for (const seg of block.content) {
439
+ if (seg.type !== 'text') {
440
+ pos += seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
441
+ continue
442
+ }
443
+ const segStart = pos
444
+ const segEnd = pos + seg.text.length
445
+ pos = segEnd
446
+
447
+ // Check if this segment overlaps with the selection
448
+ if (segEnd <= start.offset || segStart >= end.offset) continue
449
+ allMarks.push([...seg.marks])
450
+ }
451
+
452
+ if (allMarks.length === 0) return []
453
+
454
+ // Intersection of all mark arrays
455
+ return allMarks[0].filter((mark) => allMarks.every((marks) => hasMarkType(marks, mark.type)))
456
+ }
457
+
458
+ /** Toggle a mark type on the selection. If fully applied, remove; otherwise add. */
459
+ export function toggleMark(doc: Document, sel: Selection, markType: MarkType, attrs?: Record<string, string>): EditorState {
460
+ const active = getActiveMarks(doc, sel)
461
+ const mark: Mark = { type: markType, ...(attrs ? { attrs } : {}) }
462
+ if (hasMarkType(active, markType)) {
463
+ return removeMark(doc, sel, markType)
464
+ }
465
+ return addMark(doc, sel, mark)
466
+ }
467
+
468
+ /** Add a mark to the selected range. */
469
+ export function addMark(doc: Document, sel: Selection, mark: Mark): EditorState {
470
+ if (isCollapsed(sel)) {
471
+ // For collapsed selections, we can't apply marks to nothing
472
+ return { doc: cloneDocument(doc), selection: sel }
473
+ }
474
+
475
+ const d = cloneDocument(doc)
476
+ const start = getSelectionStart(d, sel)
477
+ const end = getSelectionEnd(d, sel)
478
+
479
+ // Apply to each block in the selection
480
+ const startIdx = findBlockIndex(d, start.blockId)
481
+ const endIdx = findBlockIndex(d, end.blockId)
482
+
483
+ for (let i = startIdx; i <= endIdx; i++) {
484
+ const block = d.blocks[i]
485
+ const blockStart = i === startIdx ? start.offset : 0
486
+ const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)
487
+
488
+ block.content = applyMarkToContent(block.content, blockStart, blockEnd, mark)
489
+ }
490
+
491
+ return { doc: d, selection: sel }
492
+ }
493
+
494
+ /** Remove a mark type from the selected range. */
495
+ export function removeMark(doc: Document, sel: Selection, markType: MarkType): EditorState {
496
+ if (isCollapsed(sel)) {
497
+ return { doc: cloneDocument(doc), selection: sel }
498
+ }
499
+
500
+ const d = cloneDocument(doc)
501
+ const start = getSelectionStart(d, sel)
502
+ const end = getSelectionEnd(d, sel)
503
+
504
+ const startIdx = findBlockIndex(d, start.blockId)
505
+ const endIdx = findBlockIndex(d, end.blockId)
506
+
507
+ for (let i = startIdx; i <= endIdx; i++) {
508
+ const block = d.blocks[i]
509
+ const blockStart = i === startIdx ? start.offset : 0
510
+ const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)
511
+
512
+ block.content = removeMarkFromContent(block.content, blockStart, blockEnd, markType)
513
+ }
514
+
515
+ return { doc: d, selection: sel }
516
+ }
517
+
518
+ /**
519
+ * Apply a mark to a range within inline content.
520
+ * Splits segments as needed so the mark only applies to the specified range.
521
+ */
522
+ function applyMarkToContent(
523
+ content: InlineContent[],
524
+ start: number,
525
+ end: number,
526
+ mark: Mark,
527
+ ): InlineContent[] {
528
+ const result: InlineContent[] = []
529
+ let pos = 0
530
+
531
+ for (const seg of content) {
532
+ const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
533
+ const segStart = pos
534
+ const segEnd = pos + segLen
535
+ pos = segEnd
536
+
537
+ if (seg.type !== 'text') {
538
+ result.push(structuredClone(seg))
539
+ continue
540
+ }
541
+
542
+ if (segEnd <= start || segStart >= end) {
543
+ // Outside the range
544
+ result.push(createTextSegment(seg.text, [...seg.marks]))
545
+ } else if (segStart >= start && segEnd <= end) {
546
+ // Fully inside the range
547
+ const newMarks = hasMarkType(seg.marks, mark.type)
548
+ ? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))
549
+ : [...seg.marks, { ...mark }]
550
+ result.push(createTextSegment(seg.text, newMarks))
551
+ } else {
552
+ // Partially overlapping — split
553
+ if (segStart < start) {
554
+ result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))
555
+ }
556
+ const overlapStart = Math.max(0, start - segStart)
557
+ const overlapEnd = Math.min(segLen, end - segStart)
558
+ const newMarks = hasMarkType(seg.marks, mark.type)
559
+ ? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))
560
+ : [...seg.marks, { ...mark }]
561
+ result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))
562
+ if (segEnd > end) {
563
+ result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))
564
+ }
565
+ }
566
+ }
567
+
568
+ return normalizeContent(result)
569
+ }
570
+
571
+ /**
572
+ * Remove a mark type from a range within inline content.
573
+ */
574
+ function removeMarkFromContent(
575
+ content: InlineContent[],
576
+ start: number,
577
+ end: number,
578
+ markType: MarkType,
579
+ ): InlineContent[] {
580
+ const result: InlineContent[] = []
581
+ let pos = 0
582
+
583
+ for (const seg of content) {
584
+ const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
585
+ const segStart = pos
586
+ const segEnd = pos + segLen
587
+ pos = segEnd
588
+
589
+ if (seg.type !== 'text') {
590
+ result.push(structuredClone(seg))
591
+ continue
592
+ }
593
+
594
+ if (segEnd <= start || segStart >= end) {
595
+ result.push(createTextSegment(seg.text, [...seg.marks]))
596
+ } else if (segStart >= start && segEnd <= end) {
597
+ const newMarks = seg.marks.filter((m) => m.type !== markType)
598
+ result.push(createTextSegment(seg.text, newMarks))
599
+ } else {
600
+ if (segStart < start) {
601
+ result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))
602
+ }
603
+ const overlapStart = Math.max(0, start - segStart)
604
+ const overlapEnd = Math.min(segLen, end - segStart)
605
+ const newMarks = seg.marks.filter((m) => m.type !== markType)
606
+ result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))
607
+ if (segEnd > end) {
608
+ result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))
609
+ }
610
+ }
611
+ }
612
+
613
+ return normalizeContent(result)
614
+ }
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // Selection movement operations
618
+ // ---------------------------------------------------------------------------
619
+
620
+ /** Move cursor one position to the left. */
621
+ export function moveLeft(doc: Document, sel: Selection): Selection {
622
+ if (!isCollapsed(sel)) {
623
+ const start = getSelectionStart(doc, sel)
624
+ return createCollapsedSelection(start.blockId, start.offset)
625
+ }
626
+
627
+ const block = findBlockById(doc, sel.anchor.blockId)
628
+ if (!block) return sel
629
+
630
+ if (sel.anchor.offset > 0) {
631
+ return createCollapsedSelection(block.id, sel.anchor.offset - 1)
632
+ }
633
+
634
+ // Move to end of previous block
635
+ const idx = findBlockIndex(doc, block.id)
636
+ if (idx > 0) {
637
+ const prevBlock = doc.blocks[idx - 1]
638
+ return createCollapsedSelection(prevBlock.id, getBlockTextLength(prevBlock))
639
+ }
640
+
641
+ return sel
642
+ }
643
+
644
+ /** Move cursor one position to the right. */
645
+ export function moveRight(doc: Document, sel: Selection): Selection {
646
+ if (!isCollapsed(sel)) {
647
+ const end = getSelectionEnd(doc, sel)
648
+ return createCollapsedSelection(end.blockId, end.offset)
649
+ }
650
+
651
+ const block = findBlockById(doc, sel.anchor.blockId)
652
+ if (!block) return sel
653
+
654
+ const textLen = getBlockTextLength(block)
655
+ if (sel.anchor.offset < textLen) {
656
+ return createCollapsedSelection(block.id, sel.anchor.offset + 1)
657
+ }
658
+
659
+ // Move to start of next block
660
+ const idx = findBlockIndex(doc, block.id)
661
+ if (idx < doc.blocks.length - 1) {
662
+ const nextBlock = doc.blocks[idx + 1]
663
+ return createCollapsedSelection(nextBlock.id, 0)
664
+ }
665
+
666
+ return sel
667
+ }
668
+
669
+ /** Move cursor up one block, trying to preserve offset. */
670
+ export function moveUp(doc: Document, sel: Selection): Selection {
671
+ const pos = isCollapsed(sel) ? sel.anchor : getSelectionStart(doc, sel)
672
+ const idx = findBlockIndex(doc, pos.blockId)
673
+ if (idx <= 0) {
674
+ return createCollapsedSelection(doc.blocks[0].id, 0)
675
+ }
676
+ const prevBlock = doc.blocks[idx - 1]
677
+ const maxOffset = getBlockTextLength(prevBlock)
678
+ return createCollapsedSelection(prevBlock.id, Math.min(pos.offset, maxOffset))
679
+ }
680
+
681
+ /** Move cursor down one block, trying to preserve offset. */
682
+ export function moveDown(doc: Document, sel: Selection): Selection {
683
+ const pos = isCollapsed(sel) ? sel.anchor : getSelectionEnd(doc, sel)
684
+ const idx = findBlockIndex(doc, pos.blockId)
685
+ if (idx >= doc.blocks.length - 1) {
686
+ const lastBlock = doc.blocks[doc.blocks.length - 1]
687
+ return createCollapsedSelection(lastBlock.id, getBlockTextLength(lastBlock))
688
+ }
689
+ const nextBlock = doc.blocks[idx + 1]
690
+ const maxOffset = getBlockTextLength(nextBlock)
691
+ return createCollapsedSelection(nextBlock.id, Math.min(pos.offset, maxOffset))
692
+ }
693
+
694
+ /** Move cursor to the start of the current block. */
695
+ export function moveToStartOfBlock(doc: Document, sel: Selection): Selection {
696
+ return createCollapsedSelection(sel.anchor.blockId, 0)
697
+ }
698
+
699
+ /** Move cursor to the end of the current block. */
700
+ export function moveToEndOfBlock(doc: Document, sel: Selection): Selection {
701
+ const block = findBlockById(doc, sel.anchor.blockId)
702
+ if (!block) return sel
703
+ return createCollapsedSelection(block.id, getBlockTextLength(block))
704
+ }
705
+
706
+ /** Move cursor to the start of the document. */
707
+ export function moveToStartOfDocument(doc: Document): Selection {
708
+ return createCollapsedSelection(doc.blocks[0].id, 0)
709
+ }
710
+
711
+ /** Move cursor to the end of the document. */
712
+ export function moveToEndOfDocument(doc: Document): Selection {
713
+ const last = doc.blocks[doc.blocks.length - 1]
714
+ return createCollapsedSelection(last.id, getBlockTextLength(last))
715
+ }
716
+
717
+ /** Expand selection to the full word surrounding the cursor. */
718
+ export function expandSelectionWord(doc: Document, sel: Selection): Selection {
719
+ const block = findBlockById(doc, sel.anchor.blockId)
720
+ if (!block) return sel
721
+
722
+ const text = getBlockText(block)
723
+ const offset = sel.anchor.offset
724
+
725
+ // Find word boundaries
726
+ let start = offset
727
+ let end = offset
728
+
729
+ const isWordChar = (ch: string) => /\w/.test(ch)
730
+
731
+ while (start > 0 && isWordChar(text[start - 1])) start--
732
+ while (end < text.length && isWordChar(text[end])) end++
733
+
734
+ return createSelection(
735
+ createPosition(block.id, start),
736
+ createPosition(block.id, end),
737
+ )
738
+ }
739
+
740
+ /** Insert an inline content segment (mention, emoji) at the current selection. */
741
+ export function insertInlineContent(
742
+ doc: Document,
743
+ sel: Selection,
744
+ segment: InlineContent,
745
+ ): EditorState {
746
+ let d = cloneDocument(doc)
747
+ let s = sel
748
+
749
+ if (!isCollapsed(s)) {
750
+ const result = deleteSelection(d, s)
751
+ d = result.doc
752
+ s = result.selection
753
+ }
754
+
755
+ const block = findBlockById(d, s.anchor.blockId)
756
+ if (!block) return { doc: d, selection: s }
757
+
758
+ block.content = insertSegmentIntoContent(block.content, s.anchor.offset, segment)
759
+
760
+ const segLen = segment.type === 'text'
761
+ ? segment.text.length
762
+ : segment.type === 'mention'
763
+ ? segment.label.length
764
+ : segment.type === 'emoji'
765
+ ? segment.unicode.length
766
+ : 0
767
+
768
+ return {
769
+ doc: d,
770
+ selection: createCollapsedSelection(block.id, s.anchor.offset + segLen),
771
+ }
772
+ }