@multicomma/arkcn 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 (237) hide show
  1. package/README.md +78 -0
  2. package/bin/arkcn.mjs +78 -0
  3. package/cli/commands.mjs +251 -0
  4. package/cli/project.mjs +168 -0
  5. package/cli/registry.mjs +50 -0
  6. package/cli/ui.mjs +46 -0
  7. package/mcp/server.mjs +149 -0
  8. package/package.json +201 -0
  9. package/registry/base.css +680 -0
  10. package/registry/index.json +1833 -0
  11. package/registry/items/accordion.json +18 -0
  12. package/registry/items/activity-feed.json +18 -0
  13. package/registry/items/alert-dialog.json +18 -0
  14. package/registry/items/alert.json +17 -0
  15. package/registry/items/angle-slider.json +17 -0
  16. package/registry/items/app-shell.json +28 -0
  17. package/registry/items/aspect-ratio.json +13 -0
  18. package/registry/items/attachment.json +19 -0
  19. package/registry/items/avatar.json +17 -0
  20. package/registry/items/badge.json +18 -0
  21. package/registry/items/breadcrumb.json +18 -0
  22. package/registry/items/bubble.json +18 -0
  23. package/registry/items/button-group.json +19 -0
  24. package/registry/items/button.json +18 -0
  25. package/registry/items/calendar.json +19 -0
  26. package/registry/items/canvas.json +24 -0
  27. package/registry/items/card.json +15 -0
  28. package/registry/items/carousel.json +19 -0
  29. package/registry/items/cascader.json +23 -0
  30. package/registry/items/chart.json +17 -0
  31. package/registry/items/chat.json +23 -0
  32. package/registry/items/checkbox.json +18 -0
  33. package/registry/items/clipboard.json +19 -0
  34. package/registry/items/collapsible.json +15 -0
  35. package/registry/items/color-picker.json +19 -0
  36. package/registry/items/combobox.json +20 -0
  37. package/registry/items/command.json +21 -0
  38. package/registry/items/comment-thread.json +25 -0
  39. package/registry/items/context-menu.json +18 -0
  40. package/registry/items/controllable.json +14 -0
  41. package/registry/items/data-grid.json +24 -0
  42. package/registry/items/data-table-adapter.json +14 -0
  43. package/registry/items/data-table.json +32 -0
  44. package/registry/items/date-input.json +17 -0
  45. package/registry/items/dialog.json +19 -0
  46. package/registry/items/direction.json +13 -0
  47. package/registry/items/drag-preview.json +16 -0
  48. package/registry/items/drawer.json +18 -0
  49. package/registry/items/dropdown-menu.json +18 -0
  50. package/registry/items/editable.json +17 -0
  51. package/registry/items/empty.json +17 -0
  52. package/registry/items/field.json +19 -0
  53. package/registry/items/file-upload.json +19 -0
  54. package/registry/items/floating-panel.json +19 -0
  55. package/registry/items/floating-toolbar.json +16 -0
  56. package/registry/items/form.json +14 -0
  57. package/registry/items/format.json +15 -0
  58. package/registry/items/gantt.json +22 -0
  59. package/registry/items/history.json +14 -0
  60. package/registry/items/hotkeys.json +18 -0
  61. package/registry/items/hover-card.json +17 -0
  62. package/registry/items/image-cropper.json +17 -0
  63. package/registry/items/input-group.json +20 -0
  64. package/registry/items/input-otp.json +18 -0
  65. package/registry/items/input.json +15 -0
  66. package/registry/items/item.json +19 -0
  67. package/registry/items/json-tree-view.json +17 -0
  68. package/registry/items/kanban.json +26 -0
  69. package/registry/items/kbd.json +15 -0
  70. package/registry/items/label.json +15 -0
  71. package/registry/items/live-region.json +16 -0
  72. package/registry/items/marker.json +18 -0
  73. package/registry/items/marquee.json +17 -0
  74. package/registry/items/menubar.json +19 -0
  75. package/registry/items/message.json +15 -0
  76. package/registry/items/navigation-menu.json +19 -0
  77. package/registry/items/node-graph.json +22 -0
  78. package/registry/items/number-input.json +18 -0
  79. package/registry/items/pagination.json +18 -0
  80. package/registry/items/password-input.json +18 -0
  81. package/registry/items/popconfirm.json +20 -0
  82. package/registry/items/popover.json +18 -0
  83. package/registry/items/progress.json +17 -0
  84. package/registry/items/qr-code.json +17 -0
  85. package/registry/items/query-builder.json +23 -0
  86. package/registry/items/radio-group.json +17 -0
  87. package/registry/items/rating-group.json +18 -0
  88. package/registry/items/resizable.json +17 -0
  89. package/registry/items/rich-text-editor.json +37 -0
  90. package/registry/items/row-layout.json +15 -0
  91. package/registry/items/scheduler.json +23 -0
  92. package/registry/items/scroll-area.json +17 -0
  93. package/registry/items/segment-group.json +17 -0
  94. package/registry/items/select.json +19 -0
  95. package/registry/items/separator.json +15 -0
  96. package/registry/items/sheet.json +19 -0
  97. package/registry/items/sidebar.json +27 -0
  98. package/registry/items/signature-pad.json +19 -0
  99. package/registry/items/skeleton.json +15 -0
  100. package/registry/items/slider.json +17 -0
  101. package/registry/items/sonner.json +17 -0
  102. package/registry/items/spinner.json +17 -0
  103. package/registry/items/steps.json +19 -0
  104. package/registry/items/swap.json +17 -0
  105. package/registry/items/switch.json +17 -0
  106. package/registry/items/table.json +16 -0
  107. package/registry/items/tabs.json +18 -0
  108. package/registry/items/tags-input.json +18 -0
  109. package/registry/items/textarea.json +15 -0
  110. package/registry/items/time.json +14 -0
  111. package/registry/items/timer.json +18 -0
  112. package/registry/items/toc.json +17 -0
  113. package/registry/items/toggle-group.json +19 -0
  114. package/registry/items/toggle.json +18 -0
  115. package/registry/items/tooltip.json +17 -0
  116. package/registry/items/tour.json +19 -0
  117. package/registry/items/transfer-list.json +24 -0
  118. package/registry/items/tree-select.json +24 -0
  119. package/registry/items/tree-view.json +18 -0
  120. package/registry/items/use-mobile.json +13 -0
  121. package/registry/items/utils.json +17 -0
  122. package/registry/items/virtual-list.json +16 -0
  123. package/src/components/ui/accordion.tsx +111 -0
  124. package/src/components/ui/activity-feed.tsx +228 -0
  125. package/src/components/ui/alert-dialog.tsx +204 -0
  126. package/src/components/ui/alert.tsx +55 -0
  127. package/src/components/ui/angle-slider.tsx +117 -0
  128. package/src/components/ui/app-shell.tsx +985 -0
  129. package/src/components/ui/aspect-ratio.tsx +35 -0
  130. package/src/components/ui/attachment.tsx +193 -0
  131. package/src/components/ui/avatar.tsx +94 -0
  132. package/src/components/ui/badge.tsx +45 -0
  133. package/src/components/ui/breadcrumb.tsx +93 -0
  134. package/src/components/ui/bubble.tsx +119 -0
  135. package/src/components/ui/button-group.tsx +79 -0
  136. package/src/components/ui/button.tsx +65 -0
  137. package/src/components/ui/calendar.tsx +172 -0
  138. package/src/components/ui/canvas.tsx +723 -0
  139. package/src/components/ui/card.tsx +69 -0
  140. package/src/components/ui/carousel.tsx +228 -0
  141. package/src/components/ui/cascader.tsx +754 -0
  142. package/src/components/ui/chart.tsx +303 -0
  143. package/src/components/ui/chat.tsx +544 -0
  144. package/src/components/ui/checkbox.tsx +94 -0
  145. package/src/components/ui/clipboard.tsx +94 -0
  146. package/src/components/ui/collapsible.tsx +32 -0
  147. package/src/components/ui/color-picker.tsx +441 -0
  148. package/src/components/ui/combobox.tsx +400 -0
  149. package/src/components/ui/command.tsx +292 -0
  150. package/src/components/ui/comment-thread.tsx +923 -0
  151. package/src/components/ui/context-menu.tsx +331 -0
  152. package/src/components/ui/data-grid.tsx +1227 -0
  153. package/src/components/ui/data-table.tsx +993 -0
  154. package/src/components/ui/date-input.tsx +103 -0
  155. package/src/components/ui/dialog.tsx +159 -0
  156. package/src/components/ui/direction.tsx +32 -0
  157. package/src/components/ui/drawer.tsx +200 -0
  158. package/src/components/ui/dropdown-menu.tsx +329 -0
  159. package/src/components/ui/editable.tsx +90 -0
  160. package/src/components/ui/empty.tsx +86 -0
  161. package/src/components/ui/field.tsx +218 -0
  162. package/src/components/ui/file-upload.tsx +204 -0
  163. package/src/components/ui/floating-panel.tsx +199 -0
  164. package/src/components/ui/floating-toolbar.tsx +79 -0
  165. package/src/components/ui/format.tsx +22 -0
  166. package/src/components/ui/gantt.tsx +950 -0
  167. package/src/components/ui/hotkeys.tsx +340 -0
  168. package/src/components/ui/hover-card.tsx +83 -0
  169. package/src/components/ui/image-cropper.tsx +100 -0
  170. package/src/components/ui/input-group.tsx +133 -0
  171. package/src/components/ui/input-otp.tsx +82 -0
  172. package/src/components/ui/input.tsx +18 -0
  173. package/src/components/ui/item.tsx +174 -0
  174. package/src/components/ui/json-tree-view.tsx +37 -0
  175. package/src/components/ui/kanban.tsx +884 -0
  176. package/src/components/ui/kbd.tsx +20 -0
  177. package/src/components/ui/label.tsx +30 -0
  178. package/src/components/ui/live-region.tsx +54 -0
  179. package/src/components/ui/marker.tsx +66 -0
  180. package/src/components/ui/marquee.tsx +61 -0
  181. package/src/components/ui/menubar.tsx +456 -0
  182. package/src/components/ui/message.tsx +74 -0
  183. package/src/components/ui/navigation-menu.tsx +202 -0
  184. package/src/components/ui/node-graph.tsx +1723 -0
  185. package/src/components/ui/number-input.tsx +148 -0
  186. package/src/components/ui/pagination.tsx +88 -0
  187. package/src/components/ui/password-input.tsx +104 -0
  188. package/src/components/ui/popconfirm.tsx +149 -0
  189. package/src/components/ui/popover.tsx +126 -0
  190. package/src/components/ui/progress.tsx +114 -0
  191. package/src/components/ui/qr-code.tsx +51 -0
  192. package/src/components/ui/query-builder.tsx +938 -0
  193. package/src/components/ui/radio-group.tsx +108 -0
  194. package/src/components/ui/rating-group.tsx +91 -0
  195. package/src/components/ui/resizable.tsx +41 -0
  196. package/src/components/ui/rich-text-editor.tsx +845 -0
  197. package/src/components/ui/scheduler.tsx +1164 -0
  198. package/src/components/ui/scroll-area.tsx +82 -0
  199. package/src/components/ui/segment-group.tsx +95 -0
  200. package/src/components/ui/select.tsx +354 -0
  201. package/src/components/ui/separator.tsx +32 -0
  202. package/src/components/ui/sheet.tsx +130 -0
  203. package/src/components/ui/sidebar.tsx +665 -0
  204. package/src/components/ui/signature-pad.tsx +112 -0
  205. package/src/components/ui/skeleton.tsx +7 -0
  206. package/src/components/ui/slider.tsx +183 -0
  207. package/src/components/ui/sonner.tsx +37 -0
  208. package/src/components/ui/spinner.tsx +16 -0
  209. package/src/components/ui/steps.tsx +152 -0
  210. package/src/components/ui/swap.tsx +33 -0
  211. package/src/components/ui/switch.tsx +83 -0
  212. package/src/components/ui/table.tsx +72 -0
  213. package/src/components/ui/tabs.tsx +100 -0
  214. package/src/components/ui/tags-input.tsx +153 -0
  215. package/src/components/ui/textarea.tsx +17 -0
  216. package/src/components/ui/timer.tsx +67 -0
  217. package/src/components/ui/toc.tsx +64 -0
  218. package/src/components/ui/toggle-group.tsx +89 -0
  219. package/src/components/ui/toggle.tsx +46 -0
  220. package/src/components/ui/tooltip.tsx +113 -0
  221. package/src/components/ui/tour.tsx +168 -0
  222. package/src/components/ui/transfer-list.tsx +700 -0
  223. package/src/components/ui/tree-select.tsx +638 -0
  224. package/src/components/ui/tree-view.tsx +217 -0
  225. package/src/components/ui/virtual-list.tsx +343 -0
  226. package/src/hooks/use-mobile.ts +19 -0
  227. package/src/lib/controllable.ts +31 -0
  228. package/src/lib/data-table-adapter.ts +67 -0
  229. package/src/lib/drag-preview.ts +39 -0
  230. package/src/lib/form.ts +39 -0
  231. package/src/lib/history.ts +55 -0
  232. package/src/lib/row-layout.ts +81 -0
  233. package/src/lib/table.ts +483 -0
  234. package/src/lib/time.ts +37 -0
  235. package/src/lib/utils.ts +6 -0
  236. package/src/styles/arkcn.css +764 -0
  237. package/src/styles/index.css +3 -0
@@ -0,0 +1,845 @@
1
+ import * as React from "react"
2
+ import { ark } from "@ark-ui/react"
3
+ import { createListCollection } from "@ark-ui/react/collection"
4
+ import { Highlight } from "@tiptap/extension-highlight"
5
+ import { Image } from "@tiptap/extension-image"
6
+ import { TableKit } from "@tiptap/extension-table"
7
+ import { TaskItem } from "@tiptap/extension-task-item"
8
+ import { TaskList } from "@tiptap/extension-task-list"
9
+ import { TextAlign } from "@tiptap/extension-text-align"
10
+ import { CharacterCount, Placeholder } from "@tiptap/extensions"
11
+ import {
12
+ EditorContent,
13
+ useEditor,
14
+ useEditorState,
15
+ type Editor,
16
+ type Extensions,
17
+ type JSONContent,
18
+ type UseEditorOptions,
19
+ } from "@tiptap/react"
20
+ import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus"
21
+ import { StarterKit } from "@tiptap/starter-kit"
22
+ import {
23
+ AlignCenterIcon,
24
+ AlignJustifyIcon,
25
+ AlignLeftIcon,
26
+ AlignRightIcon,
27
+ BoldIcon,
28
+ CodeIcon,
29
+ Heading1Icon,
30
+ Heading2Icon,
31
+ Heading3Icon,
32
+ HighlighterIcon,
33
+ ImageIcon,
34
+ ItalicIcon,
35
+ LinkIcon,
36
+ ListChecksIcon,
37
+ ListIcon,
38
+ ListOrderedIcon,
39
+ MinusIcon,
40
+ PilcrowIcon,
41
+ QuoteIcon,
42
+ Redo2Icon,
43
+ RemoveFormattingIcon,
44
+ SquareCodeIcon,
45
+ StrikethroughIcon,
46
+ TableIcon,
47
+ UnderlineIcon,
48
+ Undo2Icon,
49
+ UnlinkIcon,
50
+ } from "lucide-react"
51
+ import { Button } from "@/components/ui/button"
52
+ import { Input } from "@/components/ui/input"
53
+ import { Kbd } from "@/components/ui/kbd"
54
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
55
+ import {
56
+ Select,
57
+ SelectContent,
58
+ SelectControl,
59
+ SelectItem,
60
+ SelectItemIndicator,
61
+ SelectItemText,
62
+ SelectTrigger,
63
+ SelectValue,
64
+ } from "@/components/ui/select"
65
+ import { Separator } from "@/components/ui/separator"
66
+ import { Toggle } from "@/components/ui/toggle"
67
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
68
+ import { cn } from "@/lib/utils"
69
+
70
+ /**
71
+ * RichTextEditor — compositional editor parts on tiptap. The root owns the
72
+ * tiptap `Editor` (content is controllable as HTML) and reports changes as
73
+ * `onChange({ html, json, text })`. Every toolbar control is its own part and
74
+ * reads the editor from context, so a consumer composes exactly the toolbar
75
+ * it wants; `RichTextEditorToggle name="bold"` covers the common actions and
76
+ * `isActive`/`onToggle` take over for custom ones.
77
+ *
78
+ * Anatomy:
79
+ * RichTextEditor { content, onChange, placeholder, editable }
80
+ * RichTextEditorToolbar > ToolbarGroup > Toggle name=… / Trigger name=… / BlockSelect / LinkTrigger / ImageTrigger
81
+ * RichTextEditorContent the prose area
82
+ * RichTextEditorBubbleMenu selection toolbar (same toggles)
83
+ * RichTextEditorFooter > RichTextEditorCharacterCount
84
+ */
85
+
86
+ type RichTextChange = { html: string; json: JSONContent; text: string }
87
+
88
+ type RichTextEditorContextValue = { editor: Editor | null; editable: boolean }
89
+ const RichTextEditorContext = React.createContext<RichTextEditorContextValue | null>(null)
90
+
91
+ function useRichTextEditor() {
92
+ const ctx = React.useContext(RichTextEditorContext)
93
+ if (!ctx) throw new Error("RichTextEditor parts must be used within <RichTextEditor>")
94
+ return ctx
95
+ }
96
+
97
+ /* ---------------------------------------------------------------------------
98
+ * Root
99
+ * ------------------------------------------------------------------------- */
100
+
101
+ function RichTextEditor({
102
+ content,
103
+ defaultContent = "",
104
+ onChange,
105
+ placeholder = "Write something…",
106
+ editable = true,
107
+ autofocus = false,
108
+ extensions = [],
109
+ characterLimit,
110
+ editorOptions,
111
+ className,
112
+ children,
113
+ ...props
114
+ }: Omit<React.ComponentProps<"div">, "content" | "onChange"> & {
115
+ /** Controlled HTML. Prefer `defaultContent` + `onChange` unless you need to replace the document. */
116
+ content?: string
117
+ defaultContent?: string
118
+ onChange?: (change: RichTextChange) => void
119
+ placeholder?: string
120
+ editable?: boolean
121
+ autofocus?: boolean
122
+ /** Extra tiptap extensions appended to the built-in set. */
123
+ extensions?: Extensions
124
+ characterLimit?: number
125
+ /** Escape hatch for any other `useEditor` option. */
126
+ editorOptions?: Partial<UseEditorOptions>
127
+ }) {
128
+ const onChangeRef = React.useRef(onChange)
129
+ React.useEffect(() => {
130
+ onChangeRef.current = onChange
131
+ }, [onChange])
132
+
133
+ const editor = useEditor({
134
+ extensions: [
135
+ StarterKit.configure({
136
+ heading: { levels: [1, 2, 3] },
137
+ link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
138
+ }),
139
+ Placeholder.configure({ placeholder }),
140
+ CharacterCount.configure({ limit: characterLimit }),
141
+ TaskList,
142
+ TaskItem.configure({ nested: true }),
143
+ Highlight,
144
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
145
+ Image.configure({ inline: false }),
146
+ TableKit.configure({ table: { resizable: false } }),
147
+ ...extensions,
148
+ ],
149
+ content: content ?? defaultContent,
150
+ editable,
151
+ autofocus,
152
+ editorProps: {
153
+ attributes: { class: "outline-none" },
154
+ ...editorOptions?.editorProps,
155
+ },
156
+ onUpdate: ({ editor }) => {
157
+ onChangeRef.current?.({ html: editor.getHTML(), json: editor.getJSON(), text: editor.getText() })
158
+ editorOptions?.onUpdate?.({ editor, transaction: editor.state.tr, appendedTransactions: [] })
159
+ },
160
+ ...editorOptions,
161
+ })
162
+
163
+ // Controlled content: replace the document only when the outside value actually differs.
164
+ React.useEffect(() => {
165
+ if (!editor || content === undefined) return
166
+ if (content !== editor.getHTML()) editor.commands.setContent(content, { emitUpdate: false })
167
+ }, [editor, content])
168
+
169
+ React.useEffect(() => {
170
+ if (editor && editor.isEditable !== editable) editor.setEditable(editable)
171
+ }, [editor, editable])
172
+
173
+ const ctx = React.useMemo(() => ({ editor, editable }), [editor, editable])
174
+
175
+ return (
176
+ <RichTextEditorContext.Provider value={ctx}>
177
+ <div
178
+ data-slot="rich-text-editor"
179
+ data-editable={editable ? "" : undefined}
180
+ className={cn(
181
+ "flex min-h-0 flex-col rounded-xl border bg-background text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-focus-visible:border-ring",
182
+ className
183
+ )}
184
+ {...props}
185
+ >
186
+ {children}
187
+ </div>
188
+ </RichTextEditorContext.Provider>
189
+ )
190
+ }
191
+
192
+ /* ---------------------------------------------------------------------------
193
+ * Actions registry
194
+ * ------------------------------------------------------------------------- */
195
+
196
+ type ActionDef = {
197
+ label: string
198
+ icon: React.ComponentType<{ className?: string }>
199
+ shortcut?: string
200
+ isActive?: (editor: Editor) => boolean
201
+ canRun?: (editor: Editor) => boolean
202
+ run: (editor: Editor) => void
203
+ }
204
+
205
+ const chain = (editor: Editor) => editor.chain().focus()
206
+
207
+ const actions = {
208
+ bold: {
209
+ label: "Bold",
210
+ icon: BoldIcon,
211
+ shortcut: "⌘B",
212
+ isActive: (e) => e.isActive("bold"),
213
+ run: (e) => chain(e).toggleBold().run(),
214
+ },
215
+ italic: {
216
+ label: "Italic",
217
+ icon: ItalicIcon,
218
+ shortcut: "⌘I",
219
+ isActive: (e) => e.isActive("italic"),
220
+ run: (e) => chain(e).toggleItalic().run(),
221
+ },
222
+ underline: {
223
+ label: "Underline",
224
+ icon: UnderlineIcon,
225
+ shortcut: "⌘U",
226
+ isActive: (e) => e.isActive("underline"),
227
+ run: (e) => chain(e).toggleUnderline().run(),
228
+ },
229
+ strike: {
230
+ label: "Strikethrough",
231
+ icon: StrikethroughIcon,
232
+ shortcut: "⌘⇧S",
233
+ isActive: (e) => e.isActive("strike"),
234
+ run: (e) => chain(e).toggleStrike().run(),
235
+ },
236
+ code: {
237
+ label: "Inline code",
238
+ icon: CodeIcon,
239
+ shortcut: "⌘E",
240
+ isActive: (e) => e.isActive("code"),
241
+ run: (e) => chain(e).toggleCode().run(),
242
+ },
243
+ highlight: {
244
+ label: "Highlight",
245
+ icon: HighlighterIcon,
246
+ shortcut: "⌘⇧H",
247
+ isActive: (e) => e.isActive("highlight"),
248
+ run: (e) => chain(e).toggleHighlight().run(),
249
+ },
250
+ paragraph: {
251
+ label: "Paragraph",
252
+ icon: PilcrowIcon,
253
+ shortcut: "⌘⌥0",
254
+ isActive: (e) => e.isActive("paragraph"),
255
+ run: (e) => chain(e).setParagraph().run(),
256
+ },
257
+ heading1: {
258
+ label: "Heading 1",
259
+ icon: Heading1Icon,
260
+ shortcut: "⌘⌥1",
261
+ isActive: (e) => e.isActive("heading", { level: 1 }),
262
+ run: (e) => chain(e).toggleHeading({ level: 1 }).run(),
263
+ },
264
+ heading2: {
265
+ label: "Heading 2",
266
+ icon: Heading2Icon,
267
+ shortcut: "⌘⌥2",
268
+ isActive: (e) => e.isActive("heading", { level: 2 }),
269
+ run: (e) => chain(e).toggleHeading({ level: 2 }).run(),
270
+ },
271
+ heading3: {
272
+ label: "Heading 3",
273
+ icon: Heading3Icon,
274
+ shortcut: "⌘⌥3",
275
+ isActive: (e) => e.isActive("heading", { level: 3 }),
276
+ run: (e) => chain(e).toggleHeading({ level: 3 }).run(),
277
+ },
278
+ bulletList: {
279
+ label: "Bullet list",
280
+ icon: ListIcon,
281
+ shortcut: "⌘⇧8",
282
+ isActive: (e) => e.isActive("bulletList"),
283
+ run: (e) => chain(e).toggleBulletList().run(),
284
+ },
285
+ orderedList: {
286
+ label: "Numbered list",
287
+ icon: ListOrderedIcon,
288
+ shortcut: "⌘⇧7",
289
+ isActive: (e) => e.isActive("orderedList"),
290
+ run: (e) => chain(e).toggleOrderedList().run(),
291
+ },
292
+ taskList: {
293
+ label: "Task list",
294
+ icon: ListChecksIcon,
295
+ shortcut: "⌘⇧9",
296
+ isActive: (e) => e.isActive("taskList"),
297
+ run: (e) => chain(e).toggleTaskList().run(),
298
+ },
299
+ blockquote: {
300
+ label: "Quote",
301
+ icon: QuoteIcon,
302
+ shortcut: "⌘⇧B",
303
+ isActive: (e) => e.isActive("blockquote"),
304
+ run: (e) => chain(e).toggleBlockquote().run(),
305
+ },
306
+ codeBlock: {
307
+ label: "Code block",
308
+ icon: SquareCodeIcon,
309
+ shortcut: "⌘⌥C",
310
+ isActive: (e) => e.isActive("codeBlock"),
311
+ run: (e) => chain(e).toggleCodeBlock().run(),
312
+ },
313
+ alignLeft: {
314
+ label: "Align left",
315
+ icon: AlignLeftIcon,
316
+ shortcut: "⌘⇧L",
317
+ isActive: (e) => e.isActive({ textAlign: "left" }),
318
+ run: (e) => chain(e).setTextAlign("left").run(),
319
+ },
320
+ alignCenter: {
321
+ label: "Align center",
322
+ icon: AlignCenterIcon,
323
+ shortcut: "⌘⇧E",
324
+ isActive: (e) => e.isActive({ textAlign: "center" }),
325
+ run: (e) => chain(e).setTextAlign("center").run(),
326
+ },
327
+ alignRight: {
328
+ label: "Align right",
329
+ icon: AlignRightIcon,
330
+ shortcut: "⌘⇧R",
331
+ isActive: (e) => e.isActive({ textAlign: "right" }),
332
+ run: (e) => chain(e).setTextAlign("right").run(),
333
+ },
334
+ alignJustify: {
335
+ label: "Justify",
336
+ icon: AlignJustifyIcon,
337
+ shortcut: "⌘⇧J",
338
+ isActive: (e) => e.isActive({ textAlign: "justify" }),
339
+ run: (e) => chain(e).setTextAlign("justify").run(),
340
+ },
341
+ horizontalRule: { label: "Divider", icon: MinusIcon, run: (e) => chain(e).setHorizontalRule().run() },
342
+ clearFormatting: {
343
+ label: "Clear formatting",
344
+ icon: RemoveFormattingIcon,
345
+ run: (e) => chain(e).unsetAllMarks().clearNodes().run(),
346
+ },
347
+ undo: {
348
+ label: "Undo",
349
+ icon: Undo2Icon,
350
+ shortcut: "⌘Z",
351
+ canRun: (e) => e.can().undo(),
352
+ run: (e) => chain(e).undo().run(),
353
+ },
354
+ redo: {
355
+ label: "Redo",
356
+ icon: Redo2Icon,
357
+ shortcut: "⌘⇧Z",
358
+ canRun: (e) => e.can().redo(),
359
+ run: (e) => chain(e).redo().run(),
360
+ },
361
+ unsetLink: {
362
+ label: "Remove link",
363
+ icon: UnlinkIcon,
364
+ isActive: (e) => e.isActive("link"),
365
+ canRun: (e) => e.isActive("link"),
366
+ run: (e) => chain(e).unsetLink().run(),
367
+ },
368
+ insertTable: {
369
+ label: "Insert table",
370
+ icon: TableIcon,
371
+ run: (e) => chain(e).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
372
+ },
373
+ } satisfies Record<string, ActionDef>
374
+
375
+ type ActionName = keyof typeof actions
376
+
377
+ /** Subscribe to the bits of editor state a control needs; re-renders on selection and doc changes. */
378
+ function useActionState(isActive?: (e: Editor) => boolean, canRun?: (e: Editor) => boolean) {
379
+ const { editor, editable } = useRichTextEditor()
380
+ const state = useEditorState({
381
+ editor,
382
+ selector: ({ editor }) => ({
383
+ active: !!editor && !!isActive?.(editor),
384
+ enabled: !!editor && editable && (canRun ? canRun(editor) : true),
385
+ }),
386
+ })
387
+ return state ?? { active: false, enabled: false }
388
+ }
389
+
390
+ /* ---------------------------------------------------------------------------
391
+ * Toolbar
392
+ * ------------------------------------------------------------------------- */
393
+
394
+ function RichTextEditorToolbar({ className, onKeyDown, ...props }: React.ComponentProps<"div">) {
395
+ const ref = React.useRef<HTMLDivElement>(null)
396
+ return (
397
+ <div
398
+ ref={ref}
399
+ data-slot="rich-text-editor-toolbar"
400
+ role="toolbar"
401
+ aria-label="Formatting"
402
+ className={cn("flex flex-wrap items-center gap-1 border-b p-1.5", className)}
403
+ onKeyDown={(event) => {
404
+ onKeyDown?.(event)
405
+ if (event.defaultPrevented) return
406
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return
407
+ const buttons = Array.from(ref.current?.querySelectorAll<HTMLElement>("button:not([disabled])") ?? [])
408
+ const index = buttons.indexOf(document.activeElement as HTMLElement)
409
+ if (index === -1) return
410
+ event.preventDefault()
411
+ const next =
412
+ event.key === "ArrowRight" ? (index + 1) % buttons.length : (index - 1 + buttons.length) % buttons.length
413
+ buttons[next]?.focus()
414
+ }}
415
+ {...props}
416
+ />
417
+ )
418
+ }
419
+
420
+ function RichTextEditorToolbarGroup({ className, ...props }: React.ComponentProps<"div">) {
421
+ return (
422
+ <div data-slot="rich-text-editor-toolbar-group" className={cn("flex items-center gap-0.5", className)} {...props} />
423
+ )
424
+ }
425
+
426
+ function RichTextEditorToolbarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
427
+ return (
428
+ <Separator
429
+ data-slot="rich-text-editor-toolbar-separator"
430
+ orientation="vertical"
431
+ className={cn("mx-1 h-5!", className)}
432
+ {...props}
433
+ />
434
+ )
435
+ }
436
+
437
+ function ControlTooltip({
438
+ label,
439
+ shortcut,
440
+ children,
441
+ }: {
442
+ label: React.ReactNode
443
+ shortcut?: string
444
+ children: React.ReactElement
445
+ }) {
446
+ return (
447
+ <Tooltip>
448
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
449
+ <TooltipContent>
450
+ {label}
451
+ {shortcut && <Kbd className="ms-1.5">{shortcut}</Kbd>}
452
+ </TooltipContent>
453
+ </Tooltip>
454
+ )
455
+ }
456
+
457
+ type ControlProps = {
458
+ /** A built-in action; supplies icon, label, shortcut and behaviour. */
459
+ name?: ActionName
460
+ label?: string
461
+ shortcut?: string
462
+ isActive?: (editor: Editor) => boolean
463
+ canRun?: (editor: Editor) => boolean
464
+ onToggle?: (editor: Editor) => void
465
+ tooltip?: boolean
466
+ }
467
+
468
+ /** A pressed/unpressed formatting control (marks, lists, alignment, block types). */
469
+ function RichTextEditorToggle({
470
+ name,
471
+ label,
472
+ shortcut,
473
+ isActive,
474
+ canRun,
475
+ onToggle,
476
+ tooltip = true,
477
+ className,
478
+ children,
479
+ asChild,
480
+ ...props
481
+ }: Omit<React.ComponentProps<typeof Toggle>, "pressed" | "onPressedChange"> & ControlProps) {
482
+ const { editor } = useRichTextEditor()
483
+ const def: ActionDef | undefined = name ? actions[name] : undefined
484
+ const activeFn = isActive ?? def?.isActive
485
+ const canFn = canRun ?? def?.canRun
486
+ const { active, enabled } = useActionState(activeFn, canFn)
487
+ const text = label ?? def?.label ?? name ?? ""
488
+ const Icon = def?.icon
489
+ const control = (
490
+ <Toggle
491
+ data-slot="rich-text-editor-toggle"
492
+ data-action={name}
493
+ size="sm"
494
+ aria-label={text}
495
+ pressed={active}
496
+ disabled={!enabled}
497
+ asChild={asChild}
498
+ className={cn("size-7 px-0", className)}
499
+ onPressedChange={() => {
500
+ if (!editor) return
501
+ if (onToggle) onToggle(editor)
502
+ else def?.run(editor)
503
+ }}
504
+ {...props}
505
+ >
506
+ {asChild ? children : (children ?? (Icon ? <Icon /> : text))}
507
+ </Toggle>
508
+ )
509
+ return tooltip ? (
510
+ <ControlTooltip label={text} shortcut={shortcut ?? def?.shortcut}>
511
+ {control}
512
+ </ControlTooltip>
513
+ ) : (
514
+ control
515
+ )
516
+ }
517
+
518
+ /** A one-shot action (undo, redo, divider, clear formatting, insert table). */
519
+ function RichTextEditorTrigger({
520
+ name,
521
+ label,
522
+ shortcut,
523
+ canRun,
524
+ onToggle,
525
+ tooltip = true,
526
+ className,
527
+ children,
528
+ asChild,
529
+ onClick,
530
+ ...props
531
+ }: React.ComponentProps<typeof Button> & Omit<ControlProps, "isActive">) {
532
+ const { editor } = useRichTextEditor()
533
+ const def: ActionDef | undefined = name ? actions[name] : undefined
534
+ const { enabled } = useActionState(undefined, canRun ?? def?.canRun)
535
+ const text = label ?? def?.label ?? name ?? ""
536
+ const Icon = def?.icon
537
+ const control = (
538
+ <Button
539
+ data-slot="rich-text-editor-trigger"
540
+ data-action={name}
541
+ variant="ghost"
542
+ size="icon-sm"
543
+ aria-label={text}
544
+ disabled={!enabled}
545
+ asChild={asChild}
546
+ className={className}
547
+ onClick={(event) => {
548
+ onClick?.(event)
549
+ if (event.defaultPrevented || !editor) return
550
+ if (onToggle) onToggle(editor)
551
+ else def?.run(editor)
552
+ }}
553
+ {...props}
554
+ >
555
+ {asChild ? children : (children ?? (Icon ? <Icon /> : text))}
556
+ </Button>
557
+ )
558
+ return tooltip ? (
559
+ <ControlTooltip label={text} shortcut={shortcut ?? def?.shortcut}>
560
+ {control}
561
+ </ControlTooltip>
562
+ ) : (
563
+ control
564
+ )
565
+ }
566
+
567
+ /* ---------------------------------------------------------------------------
568
+ * Block type select, link, image
569
+ * ------------------------------------------------------------------------- */
570
+
571
+ const blockOptions: { value: ActionName; label: string }[] = [
572
+ { value: "paragraph", label: "Text" },
573
+ { value: "heading1", label: "Heading 1" },
574
+ { value: "heading2", label: "Heading 2" },
575
+ { value: "heading3", label: "Heading 3" },
576
+ { value: "blockquote", label: "Quote" },
577
+ { value: "codeBlock", label: "Code block" },
578
+ ]
579
+ const blockCollection = createListCollection({ items: blockOptions, itemToValue: (o) => o.value })
580
+
581
+ function RichTextEditorBlockSelect({
582
+ className,
583
+ ...props
584
+ }: Omit<React.ComponentProps<typeof Select>, "collection" | "value" | "onValueChange">) {
585
+ const { editor, editable } = useRichTextEditor()
586
+ const current = useEditorState({
587
+ editor,
588
+ selector: ({ editor }) =>
589
+ editor
590
+ ? (blockOptions.find((o) => (actions[o.value] as ActionDef).isActive?.(editor))?.value ?? "paragraph")
591
+ : "paragraph",
592
+ })
593
+ const label = blockOptions.find((o) => o.value === current)?.label
594
+ return (
595
+ <Select
596
+ collection={blockCollection}
597
+ value={[current ?? "paragraph"]}
598
+ onValueChange={({ value }) => {
599
+ const name = value[0] as ActionName | undefined
600
+ if (editor && name) actions[name].run(editor)
601
+ }}
602
+ disabled={!editable}
603
+ positioning={{ sameWidth: false }}
604
+ {...props}
605
+ >
606
+ <SelectControl>
607
+ <SelectTrigger
608
+ size="sm"
609
+ className={cn("w-32", className)}
610
+ data-slot="rich-text-editor-block-select"
611
+ aria-label="Block type"
612
+ >
613
+ <SelectValue>{label}</SelectValue>
614
+ </SelectTrigger>
615
+ </SelectControl>
616
+ <SelectContent>
617
+ {blockOptions.map((o) => {
618
+ const Icon = actions[o.value].icon
619
+ return (
620
+ <SelectItem key={o.value} item={o}>
621
+ <Icon className="size-4 text-muted-foreground" />
622
+ <SelectItemText>{o.label}</SelectItemText>
623
+ <SelectItemIndicator />
624
+ </SelectItem>
625
+ )
626
+ })}
627
+ </SelectContent>
628
+ </Select>
629
+ )
630
+ }
631
+
632
+ /** Set or edit a link on the selection through a small popover form. */
633
+ function RichTextEditorLinkTrigger({ className, ...props }: React.ComponentProps<typeof Toggle>) {
634
+ const { editor, editable } = useRichTextEditor()
635
+ const { active } = useActionState((e) => e.isActive("link"))
636
+ const [open, setOpen] = React.useState(false)
637
+ const [href, setHref] = React.useState("")
638
+ const apply = () => {
639
+ if (!editor) return
640
+ const url = href.trim()
641
+ if (!url) editor.chain().focus().extendMarkRange("link").unsetLink().run()
642
+ else editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
643
+ setOpen(false)
644
+ }
645
+ return (
646
+ <Popover
647
+ open={open}
648
+ onOpenChange={({ open }) => {
649
+ if (open) setHref((editor?.getAttributes("link").href as string | undefined) ?? "")
650
+ setOpen(open)
651
+ }}
652
+ positioning={{ placement: "bottom-start" }}
653
+ >
654
+ <ControlTooltip label="Link" shortcut="⌘K">
655
+ <PopoverTrigger asChild>
656
+ <Toggle
657
+ data-slot="rich-text-editor-link-trigger"
658
+ size="sm"
659
+ aria-label="Link"
660
+ pressed={active}
661
+ disabled={!editable}
662
+ className={cn("size-7 px-0", className)}
663
+ {...props}
664
+ >
665
+ <LinkIcon />
666
+ </Toggle>
667
+ </PopoverTrigger>
668
+ </ControlTooltip>
669
+ <PopoverContent className="w-72">
670
+ <form
671
+ className="flex items-center gap-2"
672
+ onSubmit={(event) => {
673
+ event.preventDefault()
674
+ apply()
675
+ }}
676
+ >
677
+ <Input
678
+ value={href}
679
+ onChange={(e) => setHref(e.target.value)}
680
+ placeholder="https://example.com"
681
+ aria-label="Link URL"
682
+ autoFocus
683
+ className="h-8"
684
+ />
685
+ <Button type="submit" size="sm">
686
+ {href.trim() ? "Apply" : "Remove"}
687
+ </Button>
688
+ </form>
689
+ </PopoverContent>
690
+ </Popover>
691
+ )
692
+ }
693
+
694
+ /** Insert an image by URL. */
695
+ function RichTextEditorImageTrigger({ className, ...props }: React.ComponentProps<typeof Button>) {
696
+ const { editor, editable } = useRichTextEditor()
697
+ const [open, setOpen] = React.useState(false)
698
+ const [src, setSrc] = React.useState("")
699
+ return (
700
+ <Popover open={open} onOpenChange={({ open }) => setOpen(open)} positioning={{ placement: "bottom-start" }}>
701
+ <ControlTooltip label="Image">
702
+ <PopoverTrigger asChild>
703
+ <Button
704
+ data-slot="rich-text-editor-image-trigger"
705
+ variant="ghost"
706
+ size="icon-sm"
707
+ aria-label="Image"
708
+ disabled={!editable}
709
+ className={className}
710
+ {...props}
711
+ >
712
+ <ImageIcon />
713
+ </Button>
714
+ </PopoverTrigger>
715
+ </ControlTooltip>
716
+ <PopoverContent className="w-72">
717
+ <form
718
+ className="flex items-center gap-2"
719
+ onSubmit={(event) => {
720
+ event.preventDefault()
721
+ if (editor && src.trim()) editor.chain().focus().setImage({ src: src.trim() }).run()
722
+ setSrc("")
723
+ setOpen(false)
724
+ }}
725
+ >
726
+ <Input
727
+ value={src}
728
+ onChange={(e) => setSrc(e.target.value)}
729
+ placeholder="https://…/image.png"
730
+ aria-label="Image URL"
731
+ autoFocus
732
+ className="h-8"
733
+ />
734
+ <Button type="submit" size="sm" disabled={!src.trim()}>
735
+ Insert
736
+ </Button>
737
+ </form>
738
+ </PopoverContent>
739
+ </Popover>
740
+ )
741
+ }
742
+
743
+ /* ---------------------------------------------------------------------------
744
+ * Content, bubble menu, footer
745
+ * ------------------------------------------------------------------------- */
746
+
747
+ function RichTextEditorContent({ className, ...props }: Omit<React.ComponentProps<typeof EditorContent>, "editor">) {
748
+ const { editor } = useRichTextEditor()
749
+ return (
750
+ <EditorContent
751
+ editor={editor}
752
+ data-slot="rich-text-editor-content"
753
+ className={cn(
754
+ "prose prose-sm min-h-0 max-w-none flex-1 overflow-y-auto px-4 py-3 prose-neutral dark:prose-invert",
755
+ "[&_.tiptap]:min-h-40 [&_.tiptap]:outline-none",
756
+ className
757
+ )}
758
+ {...props}
759
+ />
760
+ )
761
+ }
762
+
763
+ /** A floating toolbar over the current text selection. Compose it from the same toggles. */
764
+ function RichTextEditorBubbleMenu({ className, children, ...props }: Omit<BubbleMenuProps, "editor">) {
765
+ const { editor, editable } = useRichTextEditor()
766
+ if (!editor || !editable) return null
767
+ return (
768
+ <BubbleMenu
769
+ editor={editor}
770
+ options={{ placement: "top", offset: 8 }}
771
+ shouldShow={({ editor, from, to }) =>
772
+ editable && from !== to && !editor.isActive("image") && !editor.isActive("codeBlock")
773
+ }
774
+ className={cn(
775
+ "z-50 flex items-center gap-0.5 rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
776
+ className
777
+ )}
778
+ {...props}
779
+ >
780
+ <div
781
+ data-slot="rich-text-editor-bubble-menu"
782
+ role="toolbar"
783
+ aria-label="Selection formatting"
784
+ className="contents"
785
+ >
786
+ {children}
787
+ </div>
788
+ </BubbleMenu>
789
+ )
790
+ }
791
+
792
+ function RichTextEditorFooter({ className, ...props }: React.ComponentProps<"div">) {
793
+ return (
794
+ <div
795
+ data-slot="rich-text-editor-footer"
796
+ className={cn("flex items-center gap-3 border-t px-3 py-1.5 text-xs text-muted-foreground", className)}
797
+ {...props}
798
+ />
799
+ )
800
+ }
801
+
802
+ function RichTextEditorCharacterCount({ className, ...props }: React.ComponentProps<typeof ark.span>) {
803
+ const { editor } = useRichTextEditor()
804
+ const stats = useEditorState({
805
+ editor,
806
+ selector: ({ editor }) => {
807
+ const storage = editor?.storage.characterCount as { characters?: () => number; words?: () => number } | undefined
808
+ return { characters: storage?.characters?.() ?? 0, words: storage?.words?.() ?? 0 }
809
+ },
810
+ })
811
+ const limit = (
812
+ editor?.extensionManager.extensions.find((e) => e.name === "characterCount")?.options as { limit?: number }
813
+ )?.limit
814
+ return (
815
+ <ark.span
816
+ data-slot="rich-text-editor-character-count"
817
+ data-over={limit && stats && stats.characters >= limit ? "" : undefined}
818
+ className={cn("tabular-nums data-over:text-destructive", className)}
819
+ {...props}
820
+ >
821
+ {stats?.words ?? 0} words · {stats?.characters ?? 0}
822
+ {limit ? ` / ${limit}` : ""} characters
823
+ </ark.span>
824
+ )
825
+ }
826
+
827
+ export {
828
+ RichTextEditor,
829
+ RichTextEditorToolbar,
830
+ RichTextEditorToolbarGroup,
831
+ RichTextEditorToolbarSeparator,
832
+ RichTextEditorToggle,
833
+ RichTextEditorTrigger,
834
+ RichTextEditorBlockSelect,
835
+ RichTextEditorLinkTrigger,
836
+ RichTextEditorImageTrigger,
837
+ RichTextEditorContent,
838
+ RichTextEditorBubbleMenu,
839
+ RichTextEditorFooter,
840
+ RichTextEditorCharacterCount,
841
+ useRichTextEditor,
842
+ actions as richTextActions,
843
+ type RichTextChange,
844
+ type ActionName as RichTextActionName,
845
+ }