@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,248 @@
1
+ /**
2
+ * Markdown shortcuts — auto-convert markdown syntax as the user types.
3
+ *
4
+ * Block shortcuts trigger at the start of a block (e.g. "# " -> heading).
5
+ * Inline shortcuts trigger when a closing marker completes a pair (e.g. "**text**" -> bold).
6
+ */
7
+
8
+ import type { Document, Block, Mark, MarkType, BlockType } from './model.js'
9
+ import type { Selection } from './selection.js'
10
+ import type { EditorState } from './operations.js'
11
+ import {
12
+ findBlockById,
13
+ getBlockText,
14
+ createTextSegment,
15
+ normalizeContent,
16
+ cloneDocument,
17
+ } from './model.js'
18
+ import { createCollapsedSelection, isCollapsed } from './selection.js'
19
+ import { changeBlockType } from './operations.js'
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Block shortcuts
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface BlockShortcut {
26
+ pattern: RegExp
27
+ type: BlockType
28
+ meta?: Record<string, unknown>
29
+ }
30
+
31
+ const BLOCK_SHORTCUTS: BlockShortcut[] = [
32
+ { pattern: /^### $/, type: 'heading', meta: { level: 3 } },
33
+ { pattern: /^## $/, type: 'heading', meta: { level: 2 } },
34
+ { pattern: /^# $/, type: 'heading', meta: { level: 1 } },
35
+ { pattern: /^- $/, type: 'list-item', meta: { listType: 'bullet' } },
36
+ { pattern: /^\* $/, type: 'list-item', meta: { listType: 'bullet' } },
37
+ { pattern: /^1\. $/, type: 'list-item', meta: { listType: 'ordered' } },
38
+ { pattern: /^> $/, type: 'blockquote' },
39
+ { pattern: /^```$/, type: 'code-block' },
40
+ { pattern: /^---$/, type: 'divider' },
41
+ ]
42
+
43
+ function tryBlockShortcut(
44
+ doc: Document,
45
+ sel: Selection,
46
+ char: string,
47
+ ): { doc: Document; sel: Selection; consumed: boolean } | null {
48
+ if (!isCollapsed(sel)) return null
49
+
50
+ const block = findBlockById(doc, sel.anchor.blockId)
51
+ if (!block || block.type !== 'paragraph') return null
52
+
53
+ const text = getBlockText(block)
54
+ const textBeforeCursor = text.slice(0, sel.anchor.offset) + char
55
+
56
+ for (const shortcut of BLOCK_SHORTCUTS) {
57
+ if (shortcut.pattern.test(textBeforeCursor)) {
58
+ const d = cloneDocument(doc)
59
+ const b = d.blocks.find((bl) => bl.id === block.id)!
60
+
61
+ // Clear the shortcut text
62
+ b.content = [createTextSegment('')]
63
+
64
+ // Special case for code-block — the ``` doesn't need the trailing space
65
+ // and divider --- is also self-contained
66
+ b.type = shortcut.type
67
+ if (shortcut.meta) {
68
+ b.meta = { ...shortcut.meta }
69
+ }
70
+
71
+ if (shortcut.type === 'divider') {
72
+ b.content = [createTextSegment('')]
73
+ }
74
+
75
+ return {
76
+ doc: d,
77
+ sel: createCollapsedSelection(b.id, 0),
78
+ consumed: true,
79
+ }
80
+ }
81
+ }
82
+
83
+ return null
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Inline shortcuts
88
+ // ---------------------------------------------------------------------------
89
+
90
+ interface InlineShortcut {
91
+ openMarker: string
92
+ closeMarker: string
93
+ markType: MarkType
94
+ }
95
+
96
+ const INLINE_SHORTCUTS: InlineShortcut[] = [
97
+ { openMarker: '**', closeMarker: '**', markType: 'bold' },
98
+ { openMarker: '__', closeMarker: '__', markType: 'bold' },
99
+ { openMarker: '*', closeMarker: '*', markType: 'italic' },
100
+ { openMarker: '_', closeMarker: '_', markType: 'italic' },
101
+ { openMarker: '~~', closeMarker: '~~', markType: 'strikethrough' },
102
+ { openMarker: '`', closeMarker: '`', markType: 'code' },
103
+ ]
104
+
105
+ /**
106
+ * Try to match an inline shortcut like **bold** or *italic*.
107
+ * We look at the text before the cursor + the new char, and see if it
108
+ * completes a matched pair of markers with content between them.
109
+ */
110
+ function tryInlineShortcut(
111
+ doc: Document,
112
+ sel: Selection,
113
+ char: string,
114
+ ): { doc: Document; sel: Selection; consumed: boolean } | null {
115
+ if (!isCollapsed(sel)) return null
116
+
117
+ const block = findBlockById(doc, sel.anchor.blockId)
118
+ if (!block) return null
119
+
120
+ const text = getBlockText(block)
121
+ const textWithChar = text.slice(0, sel.anchor.offset) + char
122
+
123
+ // Check each inline shortcut — longer markers first (already sorted)
124
+ for (const shortcut of INLINE_SHORTCUTS) {
125
+ const { openMarker, closeMarker, markType } = shortcut
126
+
127
+ // Check if textWithChar ends with the close marker
128
+ if (!textWithChar.endsWith(closeMarker)) continue
129
+
130
+ // Find the matching open marker before the close marker
131
+ const beforeClose = textWithChar.slice(0, textWithChar.length - closeMarker.length)
132
+
133
+ // For single-char markers like * and _, we need to be careful not to
134
+ // confuse with the double-char ** and __ markers.
135
+ // The open marker should not be preceded by the same character (to avoid ** matching as two *).
136
+ const openIdx = beforeClose.lastIndexOf(openMarker)
137
+ if (openIdx === -1) continue
138
+
139
+ // Make sure there's content between the markers
140
+ const content = beforeClose.slice(openIdx + openMarker.length)
141
+ if (content.length === 0) continue
142
+
143
+ // For single-char markers, check that the char before the open marker isn't the same
144
+ // (to avoid **text** being matched by the * shortcut on the inner *)
145
+ if (openMarker.length === 1 && openIdx > 0 && beforeClose[openIdx - 1] === openMarker) continue
146
+
147
+ // We have a valid match! Replace the markers with marked content.
148
+ const d = cloneDocument(doc)
149
+ const b = d.blocks.find((bl) => bl.id === block.id)!
150
+
151
+ const beforeMarkerText = text.slice(0, openIdx)
152
+ const afterCursorText = text.slice(sel.anchor.offset)
153
+
154
+ const mark: Mark = { type: markType }
155
+ const segments = [
156
+ ...(beforeMarkerText ? [createTextSegment(beforeMarkerText)] : []),
157
+ createTextSegment(content, [mark]),
158
+ ...(afterCursorText ? [createTextSegment(afterCursorText)] : []),
159
+ ]
160
+
161
+ b.content = normalizeContent(segments)
162
+
163
+ const newOffset = openIdx + content.length
164
+ return {
165
+ doc: d,
166
+ sel: createCollapsedSelection(b.id, newOffset),
167
+ consumed: true,
168
+ }
169
+ }
170
+
171
+ return null
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Link shortcut: [text](url)
176
+ // ---------------------------------------------------------------------------
177
+ function tryLinkShortcut(
178
+ doc: Document,
179
+ sel: Selection,
180
+ char: string,
181
+ ): { doc: Document; sel: Selection; consumed: boolean } | null {
182
+ if (!isCollapsed(sel) || char !== ')') return null
183
+
184
+ const block = findBlockById(doc, sel.anchor.blockId)
185
+ if (!block) return null
186
+
187
+ const text = getBlockText(block)
188
+ const textWithChar = text.slice(0, sel.anchor.offset) + char
189
+
190
+ // Look for [text](url) pattern ending at cursor
191
+ const match = textWithChar.match(/\[([^\]]+)\]\(([^)]+)\)$/)
192
+ if (!match) return null
193
+
194
+ const fullMatch = match[0]
195
+ const linkText = match[1]
196
+ const linkUrl = match[2]
197
+
198
+ const d = cloneDocument(doc)
199
+ const b = d.blocks.find((bl) => bl.id === block.id)!
200
+
201
+ const matchStart = textWithChar.length - fullMatch.length
202
+ const beforeText = text.slice(0, matchStart)
203
+ const afterText = text.slice(sel.anchor.offset)
204
+
205
+ const mark: Mark = { type: 'link', attrs: { href: linkUrl } }
206
+ const segments = [
207
+ ...(beforeText ? [createTextSegment(beforeText)] : []),
208
+ createTextSegment(linkText, [mark]),
209
+ ...(afterText ? [createTextSegment(afterText)] : []),
210
+ ]
211
+
212
+ b.content = normalizeContent(segments)
213
+
214
+ const newOffset = matchStart + linkText.length
215
+ return {
216
+ doc: d,
217
+ sel: createCollapsedSelection(b.id, newOffset),
218
+ consumed: true,
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Main entry point
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Process a character input and check if it triggers a markdown shortcut.
228
+ * Returns { consumed: true } if a shortcut was applied, { consumed: false } otherwise.
229
+ */
230
+ export function processMarkdownShortcut(
231
+ doc: Document,
232
+ sel: Selection,
233
+ char: string,
234
+ ): { doc: Document; sel: Selection; consumed: boolean } {
235
+ // Try block shortcuts first (they require a space or specific ending)
236
+ const blockResult = tryBlockShortcut(doc, sel, char)
237
+ if (blockResult) return blockResult
238
+
239
+ // Try link shortcut
240
+ const linkResult = tryLinkShortcut(doc, sel, char)
241
+ if (linkResult) return linkResult
242
+
243
+ // Try inline shortcuts
244
+ const inlineResult = tryInlineShortcut(doc, sel, char)
245
+ if (inlineResult) return inlineResult
246
+
247
+ return { doc, sel, consumed: false }
248
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Mention system — trigger on "@" to show a searchable menu of users/channels/teams.
3
+ */
4
+
5
+ import type { Document } from './model.js'
6
+ import type { Selection } from './selection.js'
7
+ import type { MentionSegment } from './model.js'
8
+ import { findBlockById, getBlockText, createMentionSegment } from './model.js'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface MentionOption {
15
+ id: string
16
+ label: string
17
+ avatar?: string
18
+ type: 'user' | 'channel' | 'team'
19
+ }
20
+
21
+ export interface MentionMenu {
22
+ isOpen: boolean
23
+ query: string
24
+ filteredOptions: MentionOption[]
25
+ selectedIndex: number
26
+ open(): void
27
+ close(): void
28
+ search(query: string): void
29
+ selectNext(): void
30
+ selectPrevious(): void
31
+ select(option: MentionOption): { mention: MentionSegment }
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Fuzzy matching
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function fuzzyMatch(text: string, query: string): boolean {
39
+ const t = text.toLowerCase()
40
+ const q = query.toLowerCase()
41
+ if (q === '') return true
42
+ if (t.includes(q)) return true
43
+
44
+ // Simple fuzzy: all characters of query appear in order in text
45
+ let qi = 0
46
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
47
+ if (t[ti] === q[qi]) qi++
48
+ }
49
+ return qi === q.length
50
+ }
51
+
52
+ function matchScore(option: MentionOption, query: string): number {
53
+ const q = query.toLowerCase()
54
+ const label = option.label.toLowerCase()
55
+
56
+ if (label === q) return 100
57
+ if (label.startsWith(q)) return 90
58
+ if (label.includes(q)) return 80
59
+ // Fuzzy match gets lower score
60
+ return 50
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Menu factory
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export function createMentionMenu(options: MentionOption[]): MentionMenu {
68
+ const state = {
69
+ isOpen: false,
70
+ query: '',
71
+ filteredOptions: [] as MentionOption[],
72
+ selectedIndex: 0,
73
+ }
74
+
75
+ function updateFiltered() {
76
+ if (state.query === '') {
77
+ state.filteredOptions = [...options]
78
+ } else {
79
+ state.filteredOptions = options
80
+ .filter((opt) => fuzzyMatch(opt.label, state.query) || fuzzyMatch(opt.id, state.query))
81
+ .sort((a, b) => matchScore(b, state.query) - matchScore(a, state.query))
82
+ }
83
+ if (state.selectedIndex >= state.filteredOptions.length) {
84
+ state.selectedIndex = Math.max(0, state.filteredOptions.length - 1)
85
+ }
86
+ }
87
+
88
+ const menu: MentionMenu = {
89
+ get isOpen() { return state.isOpen },
90
+ get query() { return state.query },
91
+ get filteredOptions() { return state.filteredOptions },
92
+ get selectedIndex() { return state.selectedIndex },
93
+
94
+ open() {
95
+ state.isOpen = true
96
+ state.query = ''
97
+ state.selectedIndex = 0
98
+ updateFiltered()
99
+ },
100
+
101
+ close() {
102
+ state.isOpen = false
103
+ state.query = ''
104
+ state.filteredOptions = []
105
+ state.selectedIndex = 0
106
+ },
107
+
108
+ search(query: string) {
109
+ state.query = query
110
+ updateFiltered()
111
+ },
112
+
113
+ selectNext() {
114
+ if (state.filteredOptions.length === 0) return
115
+ state.selectedIndex = (state.selectedIndex + 1) % state.filteredOptions.length
116
+ },
117
+
118
+ selectPrevious() {
119
+ if (state.filteredOptions.length === 0) return
120
+ state.selectedIndex =
121
+ (state.selectedIndex - 1 + state.filteredOptions.length) % state.filteredOptions.length
122
+ },
123
+
124
+ select(option: MentionOption): { mention: MentionSegment } {
125
+ menu.close()
126
+ return {
127
+ mention: createMentionSegment(option.id, `@${option.label}`),
128
+ }
129
+ },
130
+ }
131
+
132
+ return menu
133
+ }
134
+
135
+ /**
136
+ * Detect whether the user has typed "@" that should trigger the mention menu.
137
+ */
138
+ export function detectMentionTrigger(
139
+ doc: Document,
140
+ sel: Selection,
141
+ ): { triggered: boolean; query: string } {
142
+ const block = findBlockById(doc, sel.anchor.blockId)
143
+ if (!block) return { triggered: false, query: '' }
144
+
145
+ const text = getBlockText(block)
146
+ const textBeforeCursor = text.slice(0, sel.anchor.offset)
147
+
148
+ // Look for "@" preceded by start-of-text or a space
149
+ const atIdx = textBeforeCursor.lastIndexOf('@')
150
+ if (atIdx === -1) return { triggered: false, query: '' }
151
+
152
+ // The @ must be at position 0 or preceded by a space
153
+ if (atIdx > 0 && textBeforeCursor[atIdx - 1] !== ' ') {
154
+ return { triggered: false, query: '' }
155
+ }
156
+
157
+ const query = textBeforeCursor.slice(atIdx + 1)
158
+ // If query contains spaces, the mention trigger is no longer active
159
+ if (query.includes(' ')) return { triggered: false, query: '' }
160
+
161
+ return { triggered: true, query }
162
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Core data model for the rich text editor.
3
+ * Document is a tree of blocks, each containing inline content segments.
4
+ */
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Block types
8
+ // ---------------------------------------------------------------------------
9
+ export type BlockType =
10
+ | 'paragraph'
11
+ | 'heading'
12
+ | 'blockquote'
13
+ | 'code-block'
14
+ | 'list-item'
15
+ | 'divider'
16
+ | 'image'
17
+
18
+ export type HeadingLevel = 1 | 2 | 3
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Inline content
22
+ // ---------------------------------------------------------------------------
23
+ export type MarkType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'underline' | 'link'
24
+
25
+ export interface Mark {
26
+ type: MarkType
27
+ attrs?: Record<string, string>
28
+ }
29
+
30
+ export interface TextSegment {
31
+ type: 'text'
32
+ text: string
33
+ marks: Mark[]
34
+ }
35
+
36
+ export interface MentionSegment {
37
+ type: 'mention'
38
+ id: string
39
+ label: string
40
+ }
41
+
42
+ export interface EmojiSegment {
43
+ type: 'emoji'
44
+ shortcode: string
45
+ unicode: string
46
+ }
47
+
48
+ export type InlineContent = TextSegment | MentionSegment | EmojiSegment
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Block
52
+ // ---------------------------------------------------------------------------
53
+ export interface Block {
54
+ id: string
55
+ type: BlockType
56
+ content: InlineContent[]
57
+ meta?: Record<string, unknown>
58
+ indent?: number
59
+ children?: Block[]
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Document
64
+ // ---------------------------------------------------------------------------
65
+ export interface Document {
66
+ blocks: Block[]
67
+ version: number
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Helpers
72
+ // ---------------------------------------------------------------------------
73
+ let _idCounter = 0
74
+
75
+ export function generateBlockId(): string {
76
+ _idCounter++
77
+ return `block-${_idCounter}`
78
+ }
79
+
80
+ export function resetBlockIdCounter(): void {
81
+ _idCounter = 0
82
+ }
83
+
84
+ export function createTextSegment(text: string, marks: Mark[] = []): TextSegment {
85
+ return { type: 'text', text, marks }
86
+ }
87
+
88
+ export function createMentionSegment(id: string, label: string): MentionSegment {
89
+ return { type: 'mention', id, label }
90
+ }
91
+
92
+ export function createEmojiSegment(shortcode: string, unicode: string): EmojiSegment {
93
+ return { type: 'emoji', shortcode, unicode }
94
+ }
95
+
96
+ export function createBlock(
97
+ type: BlockType = 'paragraph',
98
+ content: InlineContent[] = [],
99
+ meta?: Record<string, unknown>,
100
+ ): Block {
101
+ return {
102
+ id: generateBlockId(),
103
+ type,
104
+ content: content.length === 0 ? [createTextSegment('')] : content,
105
+ ...(meta ? { meta } : {}),
106
+ }
107
+ }
108
+
109
+ export function createDocument(blocks?: Block[]): Document {
110
+ return {
111
+ blocks: blocks && blocks.length > 0 ? blocks : [createBlock('paragraph')],
112
+ version: 1,
113
+ }
114
+ }
115
+
116
+ /** Get the full plain text length of a block's inline content. */
117
+ export function getBlockTextLength(block: Block): number {
118
+ let len = 0
119
+ for (const seg of block.content) {
120
+ if (seg.type === 'text') {
121
+ len += seg.text.length
122
+ } else if (seg.type === 'mention') {
123
+ len += seg.label.length
124
+ } else if (seg.type === 'emoji') {
125
+ len += seg.unicode.length
126
+ }
127
+ }
128
+ return len
129
+ }
130
+
131
+ /** Get the full plain text of a block's inline content. */
132
+ export function getBlockText(block: Block): string {
133
+ let text = ''
134
+ for (const seg of block.content) {
135
+ if (seg.type === 'text') {
136
+ text += seg.text
137
+ } else if (seg.type === 'mention') {
138
+ text += seg.label
139
+ } else if (seg.type === 'emoji') {
140
+ text += seg.unicode
141
+ }
142
+ }
143
+ return text
144
+ }
145
+
146
+ /** Find a block by its id. */
147
+ export function findBlockById(doc: Document, blockId: string): Block | undefined {
148
+ for (const block of doc.blocks) {
149
+ if (block.id === blockId) return block
150
+ if (block.children) {
151
+ for (const child of block.children) {
152
+ if (child.id === blockId) return child
153
+ }
154
+ }
155
+ }
156
+ return undefined
157
+ }
158
+
159
+ /** Find the index of a block by its id. Returns -1 if not found at top level. */
160
+ export function findBlockIndex(doc: Document, blockId: string): number {
161
+ return doc.blocks.findIndex((b) => b.id === blockId)
162
+ }
163
+
164
+ /** Deep clone a document. */
165
+ export function cloneDocument(doc: Document): Document {
166
+ return JSON.parse(JSON.stringify(doc)) as Document
167
+ }
168
+
169
+ /** Deep clone a block. */
170
+ export function cloneBlock(block: Block): Block {
171
+ return JSON.parse(JSON.stringify(block)) as Block
172
+ }
173
+
174
+ /** Check that marks are equal (order-insensitive). */
175
+ export function marksEqual(a: Mark[], b: Mark[]): boolean {
176
+ if (a.length !== b.length) return false
177
+ for (const markA of a) {
178
+ const found = b.find(
179
+ (markB) =>
180
+ markB.type === markA.type &&
181
+ JSON.stringify(markB.attrs ?? {}) === JSON.stringify(markA.attrs ?? {}),
182
+ )
183
+ if (!found) return false
184
+ }
185
+ return true
186
+ }
187
+
188
+ /** Normalize a block's inline content — merge adjacent text segments with identical marks. */
189
+ export function normalizeContent(content: InlineContent[]): InlineContent[] {
190
+ const result: InlineContent[] = []
191
+ for (const seg of content) {
192
+ if (seg.type === 'text' && seg.text === '' && content.length > 1) {
193
+ continue // skip empty text segments unless it's the only one
194
+ }
195
+ const last = result[result.length - 1]
196
+ if (seg.type === 'text' && last && last.type === 'text' && marksEqual(last.marks, seg.marks)) {
197
+ last.text += seg.text
198
+ } else {
199
+ result.push({ ...seg, ...(seg.type === 'text' ? { marks: [...seg.marks] } : {}) })
200
+ }
201
+ }
202
+ if (result.length === 0) {
203
+ return [createTextSegment('')]
204
+ }
205
+ return result
206
+ }