@open-cloud-initiative/editor-x 0.0.1

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 (190) hide show
  1. package/.devcontainer/Dockerfile +13 -0
  2. package/.devcontainer/devcontainer.json +52 -0
  3. package/.github/dependabot.yml +10 -0
  4. package/.github/workflows/pages.yml +42 -0
  5. package/.github/workflows/publish.yml +41 -0
  6. package/.vscode/settings.json +7 -0
  7. package/LICENSE +9 -0
  8. package/README.md +9 -0
  9. package/app/_component/editor.tsx +383 -0
  10. package/app/layout.tsx +46 -0
  11. package/app/page.tsx +11 -0
  12. package/app/r/registry.json/route.ts +22 -0
  13. package/components/editorx/editor.tsx +1794 -0
  14. package/components/editorx/extensions/floating-menu.tsx +376 -0
  15. package/components/editorx/extensions/floating-toolbar.tsx +97 -0
  16. package/components/editorx/extensions/image-placeholder.tsx +316 -0
  17. package/components/editorx/extensions/image.tsx +462 -0
  18. package/components/editorx/extensions/search-and-replace.tsx +438 -0
  19. package/components/editorx/rich-text-editor.tsx +383 -0
  20. package/components/editorx/tiptap.css +421 -0
  21. package/components/editorx/toolbars/alignment.tsx +126 -0
  22. package/components/editorx/toolbars/blockquote.tsx +47 -0
  23. package/components/editorx/toolbars/bold.tsx +48 -0
  24. package/components/editorx/toolbars/bullet-list.tsx +48 -0
  25. package/components/editorx/toolbars/code-block.tsx +47 -0
  26. package/components/editorx/toolbars/code.tsx +43 -0
  27. package/components/editorx/toolbars/color-and-highlight.tsx +215 -0
  28. package/components/editorx/toolbars/editor-toolbar.tsx +77 -0
  29. package/components/editorx/toolbars/hard-break.tsx +46 -0
  30. package/components/editorx/toolbars/headings.tsx +97 -0
  31. package/components/editorx/toolbars/horizontal-rule.tsx +42 -0
  32. package/components/editorx/toolbars/image-placeholder-toolbar.tsx +47 -0
  33. package/components/editorx/toolbars/italic.tsx +48 -0
  34. package/components/editorx/toolbars/link.tsx +130 -0
  35. package/components/editorx/toolbars/mobile-toolbar-group.tsx +76 -0
  36. package/components/editorx/toolbars/ordered-list.tsx +47 -0
  37. package/components/editorx/toolbars/redo.tsx +44 -0
  38. package/components/editorx/toolbars/strikethrough.tsx +48 -0
  39. package/components/editorx/toolbars/toolbar-provider.tsx +29 -0
  40. package/components/editorx/toolbars/underline.tsx +48 -0
  41. package/components/editorx/toolbars/undo.tsx +43 -0
  42. package/components/layout/theme-switcher.tsx +26 -0
  43. package/components/main-nav.tsx +24 -0
  44. package/components/mobile-nav.tsx +46 -0
  45. package/components/open-in-v0-button.tsx +38 -0
  46. package/components/page-header.tsx +30 -0
  47. package/components/site-footer.tsx +41 -0
  48. package/components/site-header.tsx +32 -0
  49. package/components/theme-provider.tsx +8 -0
  50. package/components/ui/button.tsx +57 -0
  51. package/components/ui/checkbox.tsx +30 -0
  52. package/components/ui/collapsible.tsx +11 -0
  53. package/components/ui/command.tsx +148 -0
  54. package/components/ui/dialog.tsx +122 -0
  55. package/components/ui/drawer.tsx +118 -0
  56. package/components/ui/dropdown-menu.tsx +201 -0
  57. package/components/ui/input.tsx +22 -0
  58. package/components/ui/label.tsx +26 -0
  59. package/components/ui/popover.tsx +33 -0
  60. package/components/ui/resizable.tsx +40 -0
  61. package/components/ui/scroll-area.tsx +42 -0
  62. package/components/ui/separator.tsx +31 -0
  63. package/components/ui/sheet.tsx +140 -0
  64. package/components/ui/sidebar.tsx +763 -0
  65. package/components/ui/skeleton.tsx +15 -0
  66. package/components/ui/spinner.tsx +29 -0
  67. package/components/ui/tabs.tsx +55 -0
  68. package/components/ui/toggle-group.tsx +61 -0
  69. package/components/ui/toggle.tsx +45 -0
  70. package/components/ui/tooltip.tsx +32 -0
  71. package/components.json +21 -0
  72. package/config/site.ts +15 -0
  73. package/eslint.config.mjs +20 -0
  74. package/hooks/use-character-limit.ts +28 -0
  75. package/hooks/use-copy-to-clipboard.ts +16 -0
  76. package/hooks/use-debounce.ts +17 -0
  77. package/hooks/use-image-upload.ts +97 -0
  78. package/hooks/use-media-querry.ts +18 -0
  79. package/hooks/use-mobile.tsx +19 -0
  80. package/images/editor.png +0 -0
  81. package/lib/content.ts +39 -0
  82. package/lib/cookie-client.ts +19 -0
  83. package/lib/localstorage-client.ts +19 -0
  84. package/lib/package.ts +144 -0
  85. package/lib/preferences-config.ts +72 -0
  86. package/lib/preferences-storage.ts +20 -0
  87. package/lib/theme-utils.ts +12 -0
  88. package/lib/theme.ts +50 -0
  89. package/lib/tiptap-utils.ts +45 -0
  90. package/lib/utils.ts +11 -0
  91. package/next-env.d.ts +6 -0
  92. package/next.config.mjs +11 -0
  93. package/package.json +92 -0
  94. package/postcss.config.mjs +8 -0
  95. package/prettier.config.mjs +15 -0
  96. package/public/android-chrome-192x192.png +0 -0
  97. package/public/android-chrome-512x512.png +0 -0
  98. package/public/apple-touch-icon.png +0 -0
  99. package/public/favicon-16x16.png +0 -0
  100. package/public/favicon-32x32.png +0 -0
  101. package/public/favicon.ico +0 -0
  102. package/public/file.svg +1 -0
  103. package/public/globe.svg +1 -0
  104. package/public/next.svg +1 -0
  105. package/public/og.webp +0 -0
  106. package/public/r/editor-x.json +85 -0
  107. package/public/r/registry.json +93 -0
  108. package/public/site.webmanifest +19 -0
  109. package/public/vercel.svg +1 -0
  110. package/public/window.svg +1 -0
  111. package/registry/editor/components/editor.tsx +1794 -0
  112. package/registry/editor/components/extensions/floating-menu.tsx +376 -0
  113. package/registry/editor/components/extensions/floating-toolbar.tsx +97 -0
  114. package/registry/editor/components/extensions/image-placeholder.tsx +316 -0
  115. package/registry/editor/components/extensions/image.tsx +462 -0
  116. package/registry/editor/components/extensions/search-and-replace.tsx +438 -0
  117. package/registry/editor/components/rich-text-editor.tsx +383 -0
  118. package/registry/editor/components/tiptap.css +421 -0
  119. package/registry/editor/components/toolbars/alignment.tsx +126 -0
  120. package/registry/editor/components/toolbars/blockquote.tsx +47 -0
  121. package/registry/editor/components/toolbars/bold.tsx +48 -0
  122. package/registry/editor/components/toolbars/bullet-list.tsx +48 -0
  123. package/registry/editor/components/toolbars/code-block.tsx +47 -0
  124. package/registry/editor/components/toolbars/code.tsx +43 -0
  125. package/registry/editor/components/toolbars/color-and-highlight.tsx +215 -0
  126. package/registry/editor/components/toolbars/editor-toolbar.tsx +77 -0
  127. package/registry/editor/components/toolbars/hard-break.tsx +46 -0
  128. package/registry/editor/components/toolbars/headings.tsx +97 -0
  129. package/registry/editor/components/toolbars/horizontal-rule.tsx +42 -0
  130. package/registry/editor/components/toolbars/image-placeholder-toolbar.tsx +47 -0
  131. package/registry/editor/components/toolbars/italic.tsx +48 -0
  132. package/registry/editor/components/toolbars/link.tsx +130 -0
  133. package/registry/editor/components/toolbars/mobile-toolbar-group.tsx +76 -0
  134. package/registry/editor/components/toolbars/ordered-list.tsx +47 -0
  135. package/registry/editor/components/toolbars/redo.tsx +44 -0
  136. package/registry/editor/components/toolbars/strikethrough.tsx +48 -0
  137. package/registry/editor/components/toolbars/toolbar-provider.tsx +29 -0
  138. package/registry/editor/components/toolbars/underline.tsx +48 -0
  139. package/registry/editor/components/toolbars/undo.tsx +43 -0
  140. package/registry/editor/components/ui/button.tsx +57 -0
  141. package/registry/editor/components/ui/checkbox.tsx +30 -0
  142. package/registry/editor/components/ui/collapsible.tsx +11 -0
  143. package/registry/editor/components/ui/command.tsx +148 -0
  144. package/registry/editor/components/ui/dialog.tsx +122 -0
  145. package/registry/editor/components/ui/drawer.tsx +118 -0
  146. package/registry/editor/components/ui/dropdown-menu.tsx +201 -0
  147. package/registry/editor/components/ui/input.tsx +22 -0
  148. package/registry/editor/components/ui/label.tsx +26 -0
  149. package/registry/editor/components/ui/popover.tsx +33 -0
  150. package/registry/editor/components/ui/resizable.tsx +40 -0
  151. package/registry/editor/components/ui/scroll-area.tsx +42 -0
  152. package/registry/editor/components/ui/separator.tsx +31 -0
  153. package/registry/editor/components/ui/sheet.tsx +140 -0
  154. package/registry/editor/components/ui/sidebar.tsx +763 -0
  155. package/registry/editor/components/ui/skeleton.tsx +15 -0
  156. package/registry/editor/components/ui/spinner.tsx +29 -0
  157. package/registry/editor/components/ui/tabs.tsx +55 -0
  158. package/registry/editor/components/ui/toggle-group.tsx +61 -0
  159. package/registry/editor/components/ui/toggle.tsx +45 -0
  160. package/registry/editor/components/ui/tooltip.tsx +32 -0
  161. package/registry/editor/hooks/use-character-limit.ts +28 -0
  162. package/registry/editor/hooks/use-copy-to-clipboard.ts +16 -0
  163. package/registry/editor/hooks/use-debounce.ts +17 -0
  164. package/registry/editor/hooks/use-image-upload.ts +97 -0
  165. package/registry/editor/hooks/use-media-querry.ts +18 -0
  166. package/registry/editor/hooks/use-mobile.tsx +19 -0
  167. package/registry/editor/lib/content.ts +39 -0
  168. package/registry/editor/lib/cookie-client.ts +19 -0
  169. package/registry/editor/lib/localstorage-client.ts +19 -0
  170. package/registry/editor/lib/package.ts +144 -0
  171. package/registry/editor/lib/preferences-config.ts +72 -0
  172. package/registry/editor/lib/preferences-storage.ts +20 -0
  173. package/registry/editor/lib/theme-utils.ts +12 -0
  174. package/registry/editor/lib/theme.ts +50 -0
  175. package/registry/editor/lib/tiptap-utils.ts +45 -0
  176. package/registry/editor/lib/utils.ts +11 -0
  177. package/registry/editor/page.tsx +9 -0
  178. package/registry.json +93 -0
  179. package/reset.d.ts +1 -0
  180. package/scripts/generate-theme-presets.ts +128 -0
  181. package/scripts/postCreateCommand.sh +0 -0
  182. package/scripts/theme-boot.tsx +105 -0
  183. package/server/server-actions.ts +27 -0
  184. package/stores/preferences/preferences-provider.tsx +55 -0
  185. package/stores/preferences/preferences-store.ts +23 -0
  186. package/styles/globals.css +288 -0
  187. package/styles/presets/brutalist.css +89 -0
  188. package/styles/presets/soft-pop.css +89 -0
  189. package/styles/presets/tangerine.css +89 -0
  190. package/tsconfig.json +50 -0
@@ -0,0 +1,1794 @@
1
+ 'use client'
2
+
3
+ import './tiptap.css'
4
+
5
+ import { Button } from '@/components/ui/button'
6
+ import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
8
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
9
+ import { Separator } from '@/components/ui/separator'
10
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
11
+ import { cn } from '@/lib/utils'
12
+ import type { Editor, Range } from '@tiptap/core'
13
+ import { mergeAttributes, Node } from '@tiptap/core'
14
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
15
+ import { TaskItem, TaskList } from '@tiptap/extension-list'
16
+ import Subscript from '@tiptap/extension-subscript'
17
+ import Superscript from '@tiptap/extension-superscript'
18
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
19
+ import TextAlign from '@tiptap/extension-text-align'
20
+ import { TextStyle, TextStyleKit } from '@tiptap/extension-text-style'
21
+ import Typography from '@tiptap/extension-typography'
22
+ import { CharacterCount, Placeholder } from '@tiptap/extensions'
23
+ import { Markdown } from '@tiptap/markdown'
24
+ import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
25
+ import { PluginKey } from '@tiptap/pm/state'
26
+ import {
27
+ ReactRenderer,
28
+ EditorProvider as TiptapEditorProvider,
29
+ type EditorProviderProps as TiptapEditorProviderProps,
30
+ useCurrentEditor,
31
+ } from '@tiptap/react'
32
+ import { BubbleMenu, type BubbleMenuProps, type FloatingMenuProps } from '@tiptap/react/menus'
33
+ import { Children } from 'react'
34
+
35
+ export type { Editor, JSONContent } from '@tiptap/react'
36
+
37
+ import StarterKit from '@tiptap/starter-kit'
38
+ import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
39
+ import Fuse from 'fuse.js'
40
+ import { all, createLowlight } from 'lowlight'
41
+ import {
42
+ ArrowDownIcon,
43
+ ArrowLeftIcon,
44
+ ArrowRightIcon,
45
+ ArrowUpIcon,
46
+ BoldIcon,
47
+ BoltIcon,
48
+ CheckIcon,
49
+ CheckSquareIcon,
50
+ ChevronDownIcon,
51
+ CodeIcon,
52
+ ColumnsIcon,
53
+ EllipsisIcon,
54
+ EllipsisVerticalIcon,
55
+ ExternalLinkIcon,
56
+ Heading1Icon,
57
+ Heading2Icon,
58
+ Heading3Icon,
59
+ ItalicIcon,
60
+ ListIcon,
61
+ ListOrderedIcon,
62
+ type LucideIcon,
63
+ type LucideProps,
64
+ RemoveFormattingIcon,
65
+ RowsIcon,
66
+ StrikethroughIcon,
67
+ SubscriptIcon,
68
+ SuperscriptIcon,
69
+ TableCellsMergeIcon,
70
+ TableColumnsSplitIcon,
71
+ TableIcon,
72
+ TextIcon,
73
+ TextQuoteIcon,
74
+ TrashIcon,
75
+ UnderlineIcon,
76
+ } from 'lucide-react'
77
+ import type { FormEventHandler, HTMLAttributes, ReactNode } from 'react'
78
+ import { useCallback, useEffect, useRef, useState } from 'react'
79
+ import tippy, { type Instance as TippyInstance } from 'tippy.js'
80
+
81
+ type SlashNodeAttrs = {
82
+ id: string | null
83
+ label?: string | null
84
+ }
85
+
86
+ type SlashOptions<SlashOptionSuggestionItem = unknown, Attrs = SlashNodeAttrs> = {
87
+ HTMLAttributes: Record<string, unknown>
88
+ renderText: (props: { options: SlashOptions<SlashOptionSuggestionItem, Attrs>; node: ProseMirrorNode }) => string
89
+ renderHTML: (props: {
90
+ options: SlashOptions<SlashOptionSuggestionItem, Attrs>
91
+ node: ProseMirrorNode
92
+ }) => DOMOutputSpec
93
+ deleteTriggerWithBackspace: boolean
94
+ suggestion: Omit<SuggestionOptions<SlashOptionSuggestionItem, Attrs>, 'editor'>
95
+ }
96
+
97
+ const SlashPluginKey = new PluginKey('slash')
98
+
99
+ export type SuggestionItem = {
100
+ title: string
101
+ description: string
102
+ icon: LucideIcon
103
+ searchTerms: string[]
104
+ command: (props: { editor: Editor; range: Range }) => void
105
+ }
106
+
107
+ export const defaultSlashSuggestions: SuggestionOptions<SuggestionItem>['items'] = () => [
108
+ {
109
+ title: 'Text',
110
+ description: 'Just start typing with plain text.',
111
+ searchTerms: ['p', 'paragraph'],
112
+ icon: TextIcon,
113
+ command: ({ editor, range }) => {
114
+ editor.chain().focus().deleteRange(range).toggleNode('paragraph', 'paragraph').run()
115
+ },
116
+ },
117
+ {
118
+ title: 'To-do List',
119
+ description: 'Track tasks with a to-do list.',
120
+ searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'],
121
+ icon: CheckSquareIcon,
122
+ command: ({ editor, range }) => {
123
+ editor.chain().focus().deleteRange(range).toggleList('taskList', 'taskItem').run()
124
+ },
125
+ },
126
+ {
127
+ title: 'Heading 1',
128
+ description: 'Big section heading.',
129
+ searchTerms: ['title', 'big', 'large'],
130
+ icon: Heading1Icon,
131
+ command: ({ editor, range }) => {
132
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
133
+ },
134
+ },
135
+ {
136
+ title: 'Heading 2',
137
+ description: 'Medium section heading.',
138
+ searchTerms: ['subtitle', 'medium'],
139
+ icon: Heading2Icon,
140
+ command: ({ editor, range }) => {
141
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
142
+ },
143
+ },
144
+ {
145
+ title: 'Heading 3',
146
+ description: 'Small section heading.',
147
+ searchTerms: ['subtitle', 'small'],
148
+ icon: Heading3Icon,
149
+ command: ({ editor, range }) => {
150
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()
151
+ },
152
+ },
153
+ {
154
+ title: 'Bullet List',
155
+ description: 'Create a simple bullet list.',
156
+ searchTerms: ['unordered', 'point'],
157
+ icon: ListIcon,
158
+ command: ({ editor, range }) => {
159
+ editor.chain().focus().deleteRange(range).toggleBulletList().run()
160
+ },
161
+ },
162
+ {
163
+ title: 'Numbered List',
164
+ description: 'Create a list with numbering.',
165
+ searchTerms: ['ordered'],
166
+ icon: ListOrderedIcon,
167
+ command: ({ editor, range }) => {
168
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run()
169
+ },
170
+ },
171
+ {
172
+ title: 'Quote',
173
+ description: 'Capture a quote.',
174
+ searchTerms: ['blockquote'],
175
+ icon: TextQuoteIcon,
176
+ command: ({ editor, range }) =>
177
+ editor.chain().focus().deleteRange(range).toggleNode('paragraph', 'paragraph').toggleBlockquote().run(),
178
+ },
179
+ {
180
+ title: 'Code',
181
+ description: 'Capture a code snippet.',
182
+ searchTerms: ['codeblock'],
183
+ icon: CodeIcon,
184
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
185
+ },
186
+ {
187
+ title: 'Table',
188
+ description: 'Add a table view to organize data.',
189
+ searchTerms: ['table'],
190
+ icon: TableIcon,
191
+ command: ({ editor, range }) =>
192
+ editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
193
+ },
194
+ ]
195
+
196
+ const Slash = Node.create<SlashOptions>({
197
+ name: 'slash',
198
+ priority: 101,
199
+ addOptions() {
200
+ return {
201
+ HTMLAttributes: {},
202
+ renderText({ options, node }) {
203
+ return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
204
+ },
205
+ deleteTriggerWithBackspace: false,
206
+ renderHTML({ options, node }) {
207
+ return [
208
+ 'span',
209
+ mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
210
+ `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
211
+ ]
212
+ },
213
+ suggestion: {
214
+ char: '/',
215
+ pluginKey: SlashPluginKey,
216
+ command: ({ editor, range, props }) => {
217
+ // increase range.to by one when the next node is of type "text"
218
+ // and starts with a space character
219
+ const nodeAfter = editor.view.state.selection.$to.nodeAfter
220
+ const overrideSpace = nodeAfter?.text?.startsWith(' ')
221
+
222
+ if (overrideSpace) {
223
+ range.to += 1
224
+ }
225
+
226
+ editor
227
+ .chain()
228
+ .focus()
229
+ .insertContentAt(range, [
230
+ {
231
+ type: this.name,
232
+ attrs: props,
233
+ },
234
+ {
235
+ type: 'text',
236
+ text: ' ',
237
+ },
238
+ ])
239
+ .run()
240
+
241
+ // get reference to `window` object from editor element, to support cross-frame JS usage
242
+ editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd()
243
+ },
244
+ allow: ({ state, range }) => {
245
+ const $from = state.doc.resolve(range.from)
246
+ const type = state.schema.nodes[this.name]
247
+ const allow = !!$from.parent.type.contentMatch.matchType(type)
248
+
249
+ return allow
250
+ },
251
+ },
252
+ }
253
+ },
254
+
255
+ group: 'inline',
256
+
257
+ inline: true,
258
+
259
+ selectable: false,
260
+
261
+ atom: true,
262
+
263
+ addAttributes() {
264
+ return {
265
+ id: {
266
+ default: null,
267
+ parseHTML: (element) => element.getAttribute('data-id'),
268
+ renderHTML: (attributes) => {
269
+ if (!attributes.id) {
270
+ return {}
271
+ }
272
+
273
+ return {
274
+ 'data-id': attributes.id,
275
+ }
276
+ },
277
+ },
278
+
279
+ label: {
280
+ default: null,
281
+ parseHTML: (element) => element.getAttribute('data-label'),
282
+ renderHTML: (attributes) => {
283
+ if (!attributes.label) {
284
+ return {}
285
+ }
286
+
287
+ return {
288
+ 'data-label': attributes.label,
289
+ }
290
+ },
291
+ },
292
+ }
293
+ },
294
+
295
+ parseHTML() {
296
+ return [
297
+ {
298
+ tag: `span[data-type="${this.name}"]`,
299
+ },
300
+ ]
301
+ },
302
+
303
+ // biome-ignore lint/nursery/noShadow: TipTap convention uses HTMLAttributes parameter
304
+ renderHTML({ node, HTMLAttributes }) {
305
+ const mergedOptions = { ...this.options }
306
+
307
+ mergedOptions.HTMLAttributes = mergeAttributes(
308
+ { 'data-type': this.name },
309
+ this.options.HTMLAttributes,
310
+ HTMLAttributes,
311
+ )
312
+ const html = this.options.renderHTML({
313
+ options: mergedOptions,
314
+ node,
315
+ })
316
+
317
+ if (typeof html === 'string') {
318
+ return [
319
+ 'span',
320
+ mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes),
321
+ html,
322
+ ]
323
+ }
324
+ return html
325
+ },
326
+
327
+ renderText({ node }) {
328
+ return this.options.renderText({
329
+ options: this.options,
330
+ node,
331
+ })
332
+ },
333
+
334
+ addKeyboardShortcuts() {
335
+ return {
336
+ Backspace: () =>
337
+ this.editor.commands.command(({ tr, state }) => {
338
+ let isMention = false
339
+ const { selection } = state
340
+ const { empty, anchor } = selection
341
+
342
+ if (!empty) {
343
+ return false
344
+ }
345
+
346
+ state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
347
+ if (node.type.name === this.name) {
348
+ isMention = true
349
+ tr.insertText(
350
+ this.options.deleteTriggerWithBackspace ? '' : this.options.suggestion.char || '',
351
+ pos,
352
+ pos + node.nodeSize,
353
+ )
354
+
355
+ return false
356
+ }
357
+ })
358
+
359
+ return isMention
360
+ }),
361
+ }
362
+ },
363
+
364
+ addProseMirrorPlugins() {
365
+ return [
366
+ Suggestion({
367
+ editor: this.editor,
368
+ ...this.options.suggestion,
369
+ }),
370
+ ]
371
+ },
372
+ })
373
+
374
+ // Create a lowlight instance with all languages loaded
375
+ const lowlight = createLowlight(all)
376
+
377
+ type EditorSlashMenuProps = {
378
+ items: SuggestionItem[]
379
+ command: (item: SuggestionItem) => void
380
+ editor: Editor
381
+ range: Range
382
+ }
383
+
384
+ const EditorSlashMenu = ({ items, editor, range }: EditorSlashMenuProps) => (
385
+ <Command
386
+ className="border shadow"
387
+ id="slash-command"
388
+ onKeyDown={(e) => {
389
+ e.stopPropagation()
390
+ }}
391
+ >
392
+ <CommandEmpty className="flex w-full items-center justify-center p-4 text-muted-foreground text-sm">
393
+ <p>No results</p>
394
+ </CommandEmpty>
395
+ <CommandList>
396
+ {items.map((item) => (
397
+ <CommandItem
398
+ className="flex items-center gap-3 pr-3"
399
+ key={item.title}
400
+ onSelect={() => item.command({ editor, range })}
401
+ >
402
+ <div className="flex size-9 shrink-0 items-center justify-center rounded border bg-secondary">
403
+ <item.icon className="text-muted-foreground" size={16} />
404
+ </div>
405
+ <div className="flex flex-col">
406
+ <span className="font-medium text-sm">{item.title}</span>
407
+ <span className="text-muted-foreground text-xs">{item.description}</span>
408
+ </div>
409
+ </CommandItem>
410
+ ))}
411
+ </CommandList>
412
+ </Command>
413
+ )
414
+
415
+ const handleCommandNavigation = (event: KeyboardEvent) => {
416
+ if (['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
417
+ const slashCommand = document.querySelector('#slash-command')
418
+
419
+ if (slashCommand) {
420
+ event.preventDefault()
421
+
422
+ slashCommand.dispatchEvent(
423
+ new KeyboardEvent('keydown', {
424
+ key: event.key,
425
+ cancelable: true,
426
+ bubbles: true,
427
+ }),
428
+ )
429
+
430
+ return true
431
+ }
432
+ }
433
+ }
434
+
435
+ export type EditorProviderProps = TiptapEditorProviderProps & {
436
+ className?: string
437
+ limit?: number
438
+ placeholder?: string
439
+ }
440
+
441
+ export const EditorProvider = ({ className, extensions, limit, placeholder, ...props }: EditorProviderProps) => {
442
+ const defaultExtensions = [
443
+ StarterKit.configure({
444
+ codeBlock: false,
445
+ orderedList: {
446
+ HTMLAttributes: {
447
+ class: 'list-decimal',
448
+ },
449
+ },
450
+ bulletList: {
451
+ HTMLAttributes: {
452
+ class: 'list-disc',
453
+ },
454
+ },
455
+ heading: {
456
+ levels: [1, 2, 3, 4],
457
+ },
458
+ listItem: {
459
+ HTMLAttributes: {
460
+ class: cn('leading-normal'),
461
+ },
462
+ },
463
+ blockquote: {
464
+ HTMLAttributes: {
465
+ class: cn('border-l border-l-2 pl-2'),
466
+ },
467
+ },
468
+ code: {
469
+ HTMLAttributes: {
470
+ class: cn('rounded-md bg-muted px-1.5 py-1 font-medium font-mono'),
471
+ spellcheck: 'false',
472
+ },
473
+ },
474
+ horizontalRule: {
475
+ HTMLAttributes: {
476
+ class: cn('mt-4 mb-6 border-muted-foreground border-t'),
477
+ },
478
+ },
479
+ dropcursor: {
480
+ color: 'var(--border)',
481
+ width: 4,
482
+ },
483
+ }),
484
+ Typography,
485
+ Placeholder.configure({
486
+ placeholder,
487
+ emptyEditorClass:
488
+ 'before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none',
489
+ }),
490
+ Markdown.configure({
491
+ markedOptions: { gfm: true },
492
+ }),
493
+ CharacterCount.configure({
494
+ limit,
495
+ }),
496
+ CodeBlockLowlight.configure({
497
+ lowlight,
498
+ HTMLAttributes: {
499
+ class: cn(
500
+ 'rounded-md border p-4 text-sm',
501
+ 'bg-background text-foreground',
502
+ '[&_.hljs-doctag]:text-[#d73a49] [&_.hljs-keyword]:text-[#d73a49] [&_.hljs-meta_.hljs-keyword]:text-[#d73a49] [&_.hljs-template-tag]:text-[#d73a49] [&_.hljs-template-variable]:text-[#d73a49] [&_.hljs-type]:text-[#d73a49] [&_.hljs-variable.language_]:text-[#d73a49]',
503
+ '[&_.hljs-title.class_.inherited__]:text-[#6f42c1] [&_.hljs-title.class_]:text-[#6f42c1] [&_.hljs-title.function_]:text-[#6f42c1] [&_.hljs-title]:text-[#6f42c1]',
504
+ '[&_.hljs-attr]:text-[#005cc5] [&_.hljs-attribute]:text-[#005cc5] [&_.hljs-literal]:text-[#005cc5] [&_.hljs-meta]:text-[#005cc5] [&_.hljs-number]:text-[#005cc5] [&_.hljs-operator]:text-[#005cc5] [&_.hljs-selector-attr]:text-[#005cc5] [&_.hljs-selector-class]:text-[#005cc5] [&_.hljs-selector-id]:text-[#005cc5] [&_.hljs-variable]:text-[#005cc5]',
505
+ '[&_.hljs-meta_.hljs-string]:text-[#032f62] [&_.hljs-regexp]:text-[#032f62] [&_.hljs-string]:text-[#032f62]',
506
+ '[&_.hljs-built_in]:text-[#e36209] [&_.hljs-symbol]:text-[#e36209]',
507
+ '[&_.hljs-code]:text-[#6a737d] [&_.hljs-comment]:text-[#6a737d] [&_.hljs-formula]:text-[#6a737d]',
508
+ '[&_.hljs-name]:text-[#22863a] [&_.hljs-quote]:text-[#22863a] [&_.hljs-selector-pseudo]:text-[#22863a] [&_.hljs-selector-tag]:text-[#22863a]',
509
+ '[&_.hljs-subst]:text-[#24292e]',
510
+ '[&_.hljs-section]:font-bold [&_.hljs-section]:text-[#005cc5]',
511
+ '[&_.hljs-bullet]:text-[#735c0f]',
512
+ '[&_.hljs-emphasis]:text-[#24292e] [&_.hljs-emphasis]:italic',
513
+ '[&_.hljs-strong]:font-bold [&_.hljs-strong]:text-[#24292e]',
514
+ '[&_.hljs-addition]:bg-[#f0fff4] [&_.hljs-addition]:text-[#22863a]',
515
+ '[&_.hljs-deletion]:bg-[#ffeef0] [&_.hljs-deletion]:text-[#b31d28]',
516
+ ),
517
+ },
518
+ }),
519
+ TextStyle,
520
+ Superscript,
521
+ Subscript,
522
+ TextAlign.configure({
523
+ types: ['heading', 'paragraph'],
524
+ }),
525
+ Placeholder.configure({
526
+ emptyNodeClass: 'is-editor-empty',
527
+ placeholder: ({ node }) => {
528
+ switch (node.type.name) {
529
+ case 'heading':
530
+ return `Heading ${node.attrs.level}`
531
+ case 'detailsSummary':
532
+ return 'Section title'
533
+ case 'codeBlock':
534
+ // never show the placeholder when editing code
535
+ return ''
536
+ default:
537
+ return "Write, type '/' for commands"
538
+ }
539
+ },
540
+ includeChildren: false,
541
+ }),
542
+ Slash.configure({
543
+ suggestion: {
544
+ items: async ({ editor, query }) => {
545
+ const items = await defaultSlashSuggestions({ editor, query })
546
+
547
+ if (!query) {
548
+ return items
549
+ }
550
+
551
+ const slashFuse = new Fuse(items, {
552
+ keys: ['title', 'description', 'searchTerms'],
553
+ threshold: 0.2,
554
+ minMatchCharLength: 1,
555
+ })
556
+
557
+ const results = slashFuse.search(query)
558
+
559
+ return results.map((result) => result.item)
560
+ },
561
+ char: '/',
562
+ render: () => {
563
+ let component: ReactRenderer<EditorSlashMenuProps>
564
+ let popup: TippyInstance
565
+
566
+ return {
567
+ onStart: (onStartProps) => {
568
+ component = new ReactRenderer(EditorSlashMenu, {
569
+ props: onStartProps,
570
+ editor: onStartProps.editor,
571
+ })
572
+
573
+ popup = tippy(document.body, {
574
+ getReferenceClientRect: () => onStartProps.clientRect?.() || new DOMRect(),
575
+ appendTo: () => document.body,
576
+ content: component.element,
577
+ showOnCreate: true,
578
+ interactive: true,
579
+ trigger: 'manual',
580
+ placement: 'bottom-start',
581
+ })
582
+ },
583
+
584
+ onUpdate(onUpdateProps) {
585
+ component.updateProps(onUpdateProps)
586
+
587
+ popup.setProps({
588
+ getReferenceClientRect: () => onUpdateProps.clientRect?.() || new DOMRect(),
589
+ })
590
+ },
591
+
592
+ onKeyDown(onKeyDownProps) {
593
+ if (onKeyDownProps.event.key === 'Escape') {
594
+ popup.hide()
595
+ component.destroy()
596
+
597
+ return true
598
+ }
599
+
600
+ return handleCommandNavigation(onKeyDownProps.event) ?? false
601
+ },
602
+
603
+ onExit() {
604
+ popup.destroy()
605
+ component.destroy()
606
+ },
607
+ }
608
+ },
609
+ },
610
+ }),
611
+ Table.configure({
612
+ HTMLAttributes: {
613
+ class: cn(
614
+ 'relative m-0 mx-auto my-3 w-full table-fixed border-collapse overflow-hidden rounded-none text-sm',
615
+ ),
616
+ },
617
+ allowTableNodeSelection: true,
618
+ }),
619
+
620
+ TableRow.configure({
621
+ HTMLAttributes: {
622
+ class: cn('relative box-border min-w-[1em] border p-1 text-start align-top'),
623
+ },
624
+ }),
625
+ TableCell.configure({
626
+ HTMLAttributes: {
627
+ class: cn('relative box-border min-w-[1em] border p-1 text-start align-top'),
628
+ },
629
+ }),
630
+ TableHeader.configure({
631
+ HTMLAttributes: {
632
+ class: cn(
633
+ 'relative box-border min-w-[1em] border bg-secondary p-1 text-start align-top font-medium font-semibold text-muted-foreground',
634
+ ),
635
+ },
636
+ }),
637
+ TaskList.configure({
638
+ HTMLAttributes: {
639
+ class: 'list-none',
640
+ },
641
+ }),
642
+ TaskItem.configure({}),
643
+ ]
644
+
645
+ return (
646
+ <TooltipProvider>
647
+ <div className={cn(className, '[&_.ProseMirror-focused]:outline-none')}>
648
+ <TiptapEditorProvider
649
+ editorProps={{
650
+ handleKeyDown: (_view, event) => {
651
+ handleCommandNavigation(event)
652
+ },
653
+ }}
654
+ extensions={[...defaultExtensions, TextStyleKit, ...(extensions ?? [])]}
655
+ immediatelyRender={false}
656
+ {...props}
657
+ />
658
+ </div>
659
+ </TooltipProvider>
660
+ )
661
+ }
662
+
663
+ export type EditorFloatingMenuProps = Omit<FloatingMenuProps, 'editor'>
664
+
665
+ export type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'editor'>
666
+
667
+ export const EditorBubbleMenu = ({ className, children, ...props }: EditorBubbleMenuProps) => {
668
+ const { editor } = useCurrentEditor()
669
+ const result = Children.toArray(children)
670
+
671
+ return (
672
+ <BubbleMenu
673
+ className={cn(
674
+ 'flex rounded-xl border bg-background p-0.5 shadow',
675
+ '[&>*:first-child]:rounded-l-[9px]',
676
+ '[&>*:last-child]:rounded-r-[9px]',
677
+ className,
678
+ )}
679
+ editor={editor ?? undefined}
680
+ {...props}
681
+ >
682
+ {(() => {
683
+ return result.reduce((acc: ReactNode[], child, index) => {
684
+ if (index === 0) {
685
+ return [child]
686
+ }
687
+ acc.push(<Separator key={index} orientation="vertical" />)
688
+ acc.push(child)
689
+ return acc
690
+ }, [])
691
+ })()}
692
+ </BubbleMenu>
693
+ )
694
+ }
695
+
696
+ type EditorButtonProps = {
697
+ name: string
698
+ isActive: () => boolean
699
+ command: () => void
700
+ icon: LucideIcon | ((props: LucideProps) => ReactNode)
701
+ hideName?: boolean
702
+ }
703
+
704
+ const BubbleMenuButton = ({ name, isActive, command, icon: Icon, hideName }: EditorButtonProps) => (
705
+ <Button className={`flex gap-4 ${hideName ? '' : 'w-full'}`} onClick={() => command()} size="sm" variant="ghost">
706
+ <Icon className="shrink-0 text-muted-foreground" size={12} />
707
+ {!hideName && <span className="flex-1 text-left">{name}</span>}
708
+ {isActive() ? <CheckIcon className="shrink-0 text-muted-foreground" size={12} /> : null}
709
+ </Button>
710
+ )
711
+
712
+ export type EditorClearFormattingProps = Pick<EditorButtonProps, 'hideName'>
713
+
714
+ export const EditorClearFormatting = ({ hideName = true }: EditorClearFormattingProps) => {
715
+ const { editor } = useCurrentEditor()
716
+
717
+ if (!editor) {
718
+ return null
719
+ }
720
+
721
+ return (
722
+ <BubbleMenuButton
723
+ command={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
724
+ hideName={hideName}
725
+ icon={RemoveFormattingIcon}
726
+ isActive={() => false}
727
+ name="Clear Formatting"
728
+ />
729
+ )
730
+ }
731
+
732
+ export type EditorNodeTextProps = Pick<EditorButtonProps, 'hideName'>
733
+
734
+ export const EditorNodeText = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
735
+ const { editor } = useCurrentEditor()
736
+
737
+ if (!editor) {
738
+ return null
739
+ }
740
+
741
+ return (
742
+ <BubbleMenuButton
743
+ command={() => editor.chain().focus().toggleNode('paragraph', 'paragraph').run()}
744
+ hideName={hideName}
745
+ // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
746
+ icon={TextIcon}
747
+ isActive={() =>
748
+ Boolean(
749
+ editor &&
750
+ !editor.isActive('paragraph') &&
751
+ !editor.isActive('bulletList') &&
752
+ !editor.isActive('orderedList'),
753
+ )
754
+ }
755
+ name="Text"
756
+ />
757
+ )
758
+ }
759
+
760
+ export type EditorNodeHeading1Props = Pick<EditorButtonProps, 'hideName'>
761
+
762
+ export const EditorNodeHeading1 = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
763
+ const { editor } = useCurrentEditor()
764
+
765
+ if (!editor) {
766
+ return null
767
+ }
768
+
769
+ return (
770
+ <BubbleMenuButton
771
+ command={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
772
+ hideName={hideName}
773
+ icon={Heading1Icon}
774
+ isActive={() => editor.isActive('heading', { level: 1 }) ?? false}
775
+ name="Heading 1"
776
+ />
777
+ )
778
+ }
779
+
780
+ export type EditorNodeHeading2Props = Pick<EditorButtonProps, 'hideName'>
781
+
782
+ export const EditorNodeHeading2 = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
783
+ const { editor } = useCurrentEditor()
784
+
785
+ if (!editor) {
786
+ return null
787
+ }
788
+
789
+ return (
790
+ <BubbleMenuButton
791
+ command={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
792
+ hideName={hideName}
793
+ icon={Heading2Icon}
794
+ isActive={() => editor.isActive('heading', { level: 2 }) ?? false}
795
+ name="Heading 2"
796
+ />
797
+ )
798
+ }
799
+
800
+ export type EditorNodeHeading3Props = Pick<EditorButtonProps, 'hideName'>
801
+
802
+ export const EditorNodeHeading3 = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
803
+ const { editor } = useCurrentEditor()
804
+
805
+ if (!editor) {
806
+ return null
807
+ }
808
+
809
+ return (
810
+ <BubbleMenuButton
811
+ command={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
812
+ hideName={hideName}
813
+ icon={Heading3Icon}
814
+ isActive={() => editor.isActive('heading', { level: 3 }) ?? false}
815
+ name="Heading 3"
816
+ />
817
+ )
818
+ }
819
+
820
+ export type EditorNodeBulletListProps = Pick<EditorButtonProps, 'hideName'>
821
+
822
+ export const EditorNodeBulletList = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
823
+ const { editor } = useCurrentEditor()
824
+
825
+ if (!editor) {
826
+ return null
827
+ }
828
+
829
+ return (
830
+ <BubbleMenuButton
831
+ command={() => editor.chain().focus().toggleBulletList().run()}
832
+ hideName={hideName}
833
+ icon={ListIcon}
834
+ isActive={() => editor.isActive('bulletList') ?? false}
835
+ name="Bullet List"
836
+ />
837
+ )
838
+ }
839
+
840
+ export type EditorNodeOrderedListProps = Pick<EditorButtonProps, 'hideName'>
841
+
842
+ export const EditorNodeOrderedList = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
843
+ const { editor } = useCurrentEditor()
844
+
845
+ if (!editor) {
846
+ return null
847
+ }
848
+
849
+ return (
850
+ <BubbleMenuButton
851
+ command={() => editor.chain().focus().toggleOrderedList().run()}
852
+ hideName={hideName}
853
+ icon={ListOrderedIcon}
854
+ isActive={() => editor.isActive('orderedList') ?? false}
855
+ name="Numbered List"
856
+ />
857
+ )
858
+ }
859
+
860
+ export type EditorNodeTaskListProps = Pick<EditorButtonProps, 'hideName'>
861
+
862
+ export const EditorNodeTaskList = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
863
+ const { editor } = useCurrentEditor()
864
+
865
+ if (!editor) {
866
+ return null
867
+ }
868
+
869
+ return (
870
+ <BubbleMenuButton
871
+ command={() => editor.chain().focus().toggleList('taskList', 'taskItem').run()}
872
+ hideName={hideName}
873
+ icon={CheckSquareIcon}
874
+ isActive={() => editor.isActive('taskItem') ?? false}
875
+ name="To-do List"
876
+ />
877
+ )
878
+ }
879
+
880
+ export type EditorNodeQuoteProps = Pick<EditorButtonProps, 'hideName'>
881
+
882
+ export const EditorNodeQuote = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
883
+ const { editor } = useCurrentEditor()
884
+
885
+ if (!editor) {
886
+ return null
887
+ }
888
+
889
+ return (
890
+ <BubbleMenuButton
891
+ command={() => editor.chain().focus().toggleNode('paragraph', 'paragraph').toggleBlockquote().run()}
892
+ hideName={hideName}
893
+ icon={TextQuoteIcon}
894
+ isActive={() => editor.isActive('blockquote') ?? false}
895
+ name="Quote"
896
+ />
897
+ )
898
+ }
899
+
900
+ export type EditorNodeCodeProps = Pick<EditorButtonProps, 'hideName'>
901
+
902
+ export const EditorNodeCode = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
903
+ const { editor } = useCurrentEditor()
904
+
905
+ if (!editor) {
906
+ return null
907
+ }
908
+
909
+ return (
910
+ <BubbleMenuButton
911
+ command={() => editor.chain().focus().toggleCodeBlock().run()}
912
+ hideName={hideName}
913
+ icon={CodeIcon}
914
+ isActive={() => editor.isActive('codeBlock') ?? false}
915
+ name="Code"
916
+ />
917
+ )
918
+ }
919
+
920
+ export type EditorNodeTableProps = Pick<EditorButtonProps, 'hideName'>
921
+
922
+ export const EditorNodeTable = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
923
+ const { editor } = useCurrentEditor()
924
+
925
+ if (!editor) {
926
+ return null
927
+ }
928
+
929
+ return (
930
+ <BubbleMenuButton
931
+ command={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
932
+ hideName={hideName}
933
+ icon={TableIcon}
934
+ isActive={() => editor.isActive('table') ?? false}
935
+ name="Table"
936
+ />
937
+ )
938
+ }
939
+
940
+ export type EditorSelectorProps = HTMLAttributes<HTMLDivElement> & {
941
+ open?: boolean
942
+ onOpenChange?: (open: boolean) => void
943
+ title: string
944
+ }
945
+
946
+ export const EditorSelector = ({ open, onOpenChange, title, className, children, ...props }: EditorSelectorProps) => {
947
+ const { editor } = useCurrentEditor()
948
+
949
+ if (!editor) {
950
+ return null
951
+ }
952
+
953
+ return (
954
+ <Popover onOpenChange={onOpenChange} open={open}>
955
+ <PopoverTrigger asChild>
956
+ <Button className="gap-2 rounded-none border-none" size="sm" variant="ghost">
957
+ <span className="whitespace-nowrap text-xs">{title}</span>
958
+ <ChevronDownIcon size={12} />
959
+ </Button>
960
+ </PopoverTrigger>
961
+ <PopoverContent align="start" className={cn('w-48 p-1', className)} sideOffset={5} {...props}>
962
+ {children}
963
+ </PopoverContent>
964
+ </Popover>
965
+ )
966
+ }
967
+
968
+ export type EditorFormatBoldProps = Pick<EditorButtonProps, 'hideName'>
969
+
970
+ export const EditorFormatBold = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
971
+ const { editor } = useCurrentEditor()
972
+
973
+ if (!editor) {
974
+ return null
975
+ }
976
+
977
+ return (
978
+ <BubbleMenuButton
979
+ command={() => editor.chain().focus().toggleBold().run()}
980
+ hideName={hideName}
981
+ icon={BoldIcon}
982
+ isActive={() => editor.isActive('bold') ?? false}
983
+ name="Bold"
984
+ />
985
+ )
986
+ }
987
+
988
+ export type EditorFormatItalicProps = Pick<EditorButtonProps, 'hideName'>
989
+
990
+ export const EditorFormatItalic = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
991
+ const { editor } = useCurrentEditor()
992
+
993
+ if (!editor) {
994
+ return null
995
+ }
996
+
997
+ return (
998
+ <BubbleMenuButton
999
+ command={() => editor.chain().focus().toggleItalic().run()}
1000
+ hideName={hideName}
1001
+ icon={ItalicIcon}
1002
+ isActive={() => editor.isActive('italic') ?? false}
1003
+ name="Italic"
1004
+ />
1005
+ )
1006
+ }
1007
+
1008
+ export type EditorFormatStrikeProps = Pick<EditorButtonProps, 'hideName'>
1009
+
1010
+ export const EditorFormatStrike = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
1011
+ const { editor } = useCurrentEditor()
1012
+
1013
+ if (!editor) {
1014
+ return null
1015
+ }
1016
+
1017
+ return (
1018
+ <BubbleMenuButton
1019
+ command={() => editor.chain().focus().toggleStrike().run()}
1020
+ hideName={hideName}
1021
+ icon={StrikethroughIcon}
1022
+ isActive={() => editor.isActive('strike') ?? false}
1023
+ name="Strikethrough"
1024
+ />
1025
+ )
1026
+ }
1027
+
1028
+ export type EditorFormatCodeProps = Pick<EditorButtonProps, 'hideName'>
1029
+
1030
+ export const EditorFormatCode = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
1031
+ const { editor } = useCurrentEditor()
1032
+
1033
+ if (!editor) {
1034
+ return null
1035
+ }
1036
+
1037
+ return (
1038
+ <BubbleMenuButton
1039
+ command={() => editor.chain().focus().toggleCode().run()}
1040
+ hideName={hideName}
1041
+ icon={CodeIcon}
1042
+ isActive={() => editor.isActive('code') ?? false}
1043
+ name="Code"
1044
+ />
1045
+ )
1046
+ }
1047
+
1048
+ export type EditorFormatSubscriptProps = Pick<EditorButtonProps, 'hideName'>
1049
+
1050
+ export const EditorFormatSubscript = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
1051
+ const { editor } = useCurrentEditor()
1052
+
1053
+ if (!editor) {
1054
+ return null
1055
+ }
1056
+
1057
+ return (
1058
+ <BubbleMenuButton
1059
+ command={() => editor.chain().focus().toggleSubscript().run()}
1060
+ hideName={hideName}
1061
+ icon={SubscriptIcon}
1062
+ isActive={() => editor.isActive('subscript') ?? false}
1063
+ name="Subscript"
1064
+ />
1065
+ )
1066
+ }
1067
+
1068
+ export type EditorFormatSuperscriptProps = Pick<EditorButtonProps, 'hideName'>
1069
+
1070
+ export const EditorFormatSuperscript = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
1071
+ const { editor } = useCurrentEditor()
1072
+
1073
+ if (!editor) {
1074
+ return null
1075
+ }
1076
+
1077
+ return (
1078
+ <BubbleMenuButton
1079
+ command={() => editor.chain().focus().toggleSuperscript().run()}
1080
+ hideName={hideName}
1081
+ icon={SuperscriptIcon}
1082
+ isActive={() => editor.isActive('superscript') ?? false}
1083
+ name="Superscript"
1084
+ />
1085
+ )
1086
+ }
1087
+
1088
+ export type EditorFormatUnderlineProps = Pick<EditorButtonProps, 'hideName'>
1089
+
1090
+ export const EditorFormatUnderline = ({ hideName = false }: Pick<EditorButtonProps, 'hideName'>) => {
1091
+ const { editor } = useCurrentEditor()
1092
+
1093
+ if (!editor) {
1094
+ return null
1095
+ }
1096
+
1097
+ return (
1098
+ <BubbleMenuButton
1099
+ command={() => editor.chain().focus().toggleUnderline().run()}
1100
+ hideName={hideName}
1101
+ icon={UnderlineIcon}
1102
+ isActive={() => editor.isActive('underline') ?? false}
1103
+ name="Underline"
1104
+ />
1105
+ )
1106
+ }
1107
+
1108
+ export type EditorLinkSelectorProps = {
1109
+ open?: boolean
1110
+ onOpenChange?: (open: boolean) => void
1111
+ }
1112
+
1113
+ export const EditorLinkSelector = ({ open, onOpenChange }: EditorLinkSelectorProps) => {
1114
+ const [url, setUrl] = useState<string>('')
1115
+ const inputReference = useRef<HTMLInputElement>(null)
1116
+ const { editor } = useCurrentEditor()
1117
+
1118
+ const isValidUrl = (text: string): boolean => {
1119
+ try {
1120
+ new URL(text)
1121
+ return true
1122
+ } catch {
1123
+ return false
1124
+ }
1125
+ }
1126
+
1127
+ const getUrlFromString = (text: string): string | null => {
1128
+ if (isValidUrl(text)) {
1129
+ return text
1130
+ }
1131
+ try {
1132
+ if (text.includes('.') && !text.includes(' ')) {
1133
+ return new URL(`https://${text}`).toString()
1134
+ }
1135
+
1136
+ return null
1137
+ } catch {
1138
+ return null
1139
+ }
1140
+ }
1141
+
1142
+ useEffect(() => {
1143
+ inputReference.current?.focus()
1144
+ }, [])
1145
+
1146
+ if (!editor) {
1147
+ return null
1148
+ }
1149
+
1150
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
1151
+ event.preventDefault()
1152
+
1153
+ const href = getUrlFromString(url)
1154
+
1155
+ if (href) {
1156
+ editor.chain().focus().setLink({ href }).run()
1157
+ onOpenChange?.(false)
1158
+ }
1159
+ }
1160
+
1161
+ const defaultValue = (editor.getAttributes('link') as { href?: string }).href
1162
+
1163
+ return (
1164
+ <Popover modal onOpenChange={onOpenChange} open={open}>
1165
+ <PopoverTrigger asChild>
1166
+ <Button className="gap-2 rounded-none border-none" size="sm" variant="ghost">
1167
+ <ExternalLinkIcon size={12} />
1168
+ <p
1169
+ className={cn('text-xs underline decoration-text-muted underline-offset-4', {
1170
+ 'text-primary': editor.isActive('link'),
1171
+ })}
1172
+ >
1173
+ Link
1174
+ </p>
1175
+ </Button>
1176
+ </PopoverTrigger>
1177
+ <PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
1178
+ <form className="flex p-1" onSubmit={handleSubmit}>
1179
+ <input
1180
+ aria-label="Link URL"
1181
+ className="flex-1 bg-background p-1 text-sm outline-none"
1182
+ defaultValue={defaultValue ?? ''}
1183
+ onChange={(event) => setUrl(event.target.value)}
1184
+ placeholder="Paste a link"
1185
+ ref={inputReference}
1186
+ type="text"
1187
+ value={url}
1188
+ />
1189
+ {editor.getAttributes('link').href ? (
1190
+ <Button
1191
+ className="flex h-8 items-center rounded-sm p-1 text-destructive transition-all hover:bg-destructive-foreground dark:hover:bg-destructive"
1192
+ onClick={() => {
1193
+ editor.chain().focus().unsetLink().run()
1194
+ onOpenChange?.(false)
1195
+ }}
1196
+ size="icon"
1197
+ type="button"
1198
+ variant="outline"
1199
+ >
1200
+ <TrashIcon size={12} />
1201
+ </Button>
1202
+ ) : (
1203
+ <Button className="h-8" size="icon" variant="secondary">
1204
+ <CheckIcon size={12} />
1205
+ </Button>
1206
+ )}
1207
+ </form>
1208
+ </PopoverContent>
1209
+ </Popover>
1210
+ )
1211
+ }
1212
+
1213
+ export type EditorTableMenuProps = {
1214
+ children: ReactNode
1215
+ }
1216
+
1217
+ export const EditorTableMenu = ({ children }: EditorTableMenuProps) => {
1218
+ const { editor } = useCurrentEditor()
1219
+
1220
+ if (!editor) {
1221
+ return null
1222
+ }
1223
+
1224
+ const isActive = editor.isActive('table')
1225
+
1226
+ return (
1227
+ <div
1228
+ className={cn({
1229
+ hidden: !isActive,
1230
+ })}
1231
+ >
1232
+ {children}
1233
+ </div>
1234
+ )
1235
+ }
1236
+
1237
+ export type EditorTableGlobalMenuProps = {
1238
+ children: ReactNode
1239
+ }
1240
+
1241
+ export const EditorTableGlobalMenu = ({ children }: EditorTableGlobalMenuProps) => {
1242
+ const { editor } = useCurrentEditor()
1243
+ const [top, setTop] = useState(0)
1244
+ const [left, setLeft] = useState(0)
1245
+
1246
+ useEffect(() => {
1247
+ if (!editor) {
1248
+ return
1249
+ }
1250
+
1251
+ editor.on('selectionUpdate', () => {
1252
+ const selection = window.getSelection()
1253
+
1254
+ if (!selection) {
1255
+ return
1256
+ }
1257
+
1258
+ const range = selection.getRangeAt(0)
1259
+ let startContainer = range.startContainer as HTMLElement | string
1260
+
1261
+ if (!(startContainer instanceof HTMLElement)) {
1262
+ startContainer = range.startContainer.parentElement as HTMLElement
1263
+ }
1264
+
1265
+ const tableNode = startContainer.closest('table')
1266
+
1267
+ if (!tableNode) {
1268
+ return
1269
+ }
1270
+
1271
+ const tableRect = tableNode.getBoundingClientRect()
1272
+
1273
+ setTop(tableRect.top + tableRect.height)
1274
+ setLeft(tableRect.left + tableRect.width / 2)
1275
+ })
1276
+
1277
+ return () => {
1278
+ editor.off('selectionUpdate')
1279
+ }
1280
+ }, [editor])
1281
+
1282
+ return (
1283
+ <div
1284
+ className={cn(
1285
+ '-translate-x-1/2 absolute flex translate-y-1/2 items-center rounded-full border bg-background shadow-xl',
1286
+ {
1287
+ hidden: !(left || top),
1288
+ },
1289
+ )}
1290
+ style={{ top, left }}
1291
+ >
1292
+ {children}
1293
+ </div>
1294
+ )
1295
+ }
1296
+
1297
+ export type EditorTableColumnMenuProps = {
1298
+ children: ReactNode
1299
+ }
1300
+
1301
+ export const EditorTableColumnMenu = ({ children }: EditorTableColumnMenuProps) => {
1302
+ const { editor } = useCurrentEditor()
1303
+ const [top, setTop] = useState(0)
1304
+ const [left, setLeft] = useState(0)
1305
+
1306
+ useEffect(() => {
1307
+ if (!editor) {
1308
+ return
1309
+ }
1310
+
1311
+ editor.on('selectionUpdate', () => {
1312
+ const selection = window.getSelection()
1313
+
1314
+ if (!selection) {
1315
+ return
1316
+ }
1317
+
1318
+ const range = selection.getRangeAt(0)
1319
+ let startContainer = range.startContainer as HTMLElement | string
1320
+
1321
+ if (!(startContainer instanceof HTMLElement)) {
1322
+ startContainer = range.startContainer.parentElement as HTMLElement
1323
+ }
1324
+
1325
+ // Get the closest table cell (td or th)
1326
+ const tableCell = startContainer.closest('td, th')
1327
+
1328
+ if (!tableCell) {
1329
+ return
1330
+ }
1331
+
1332
+ const cellRect = tableCell.getBoundingClientRect()
1333
+
1334
+ setTop(cellRect.top)
1335
+ setLeft(cellRect.left + cellRect.width / 2)
1336
+ })
1337
+
1338
+ return () => {
1339
+ editor.off('selectionUpdate')
1340
+ }
1341
+ }, [editor])
1342
+
1343
+ return (
1344
+ <DropdownMenu>
1345
+ <DropdownMenuTrigger
1346
+ asChild
1347
+ className={cn(
1348
+ '-translate-x-1/2 -translate-y-1/2 absolute flex h-4 w-7 overflow-hidden rounded-md border bg-background shadow-xl',
1349
+ {
1350
+ hidden: !(left || top),
1351
+ },
1352
+ )}
1353
+ style={{ top, left }}
1354
+ >
1355
+ <Button size="icon" variant="ghost">
1356
+ <EllipsisIcon className="text-muted-foreground" size={16} />
1357
+ </Button>
1358
+ </DropdownMenuTrigger>
1359
+ <DropdownMenuContent>{children}</DropdownMenuContent>
1360
+ </DropdownMenu>
1361
+ )
1362
+ }
1363
+
1364
+ export type EditorTableRowMenuProps = {
1365
+ children: ReactNode
1366
+ }
1367
+
1368
+ export const EditorTableRowMenu = ({ children }: EditorTableRowMenuProps) => {
1369
+ const { editor } = useCurrentEditor()
1370
+ const [top, setTop] = useState(0)
1371
+ const [left, setLeft] = useState(0)
1372
+
1373
+ useEffect(() => {
1374
+ if (!editor) {
1375
+ return
1376
+ }
1377
+
1378
+ editor.on('selectionUpdate', () => {
1379
+ const selection = window.getSelection()
1380
+
1381
+ if (!selection) {
1382
+ return
1383
+ }
1384
+
1385
+ const range = selection.getRangeAt(0)
1386
+ let startContainer = range.startContainer as HTMLElement | string
1387
+
1388
+ if (!(startContainer instanceof HTMLElement)) {
1389
+ startContainer = range.startContainer.parentElement as HTMLElement
1390
+ }
1391
+
1392
+ const tableRow = startContainer.closest('tr')
1393
+
1394
+ if (!tableRow) {
1395
+ return
1396
+ }
1397
+
1398
+ const rowRect = tableRow.getBoundingClientRect()
1399
+
1400
+ setTop(rowRect.top + rowRect.height / 2)
1401
+ setLeft(rowRect.left)
1402
+ })
1403
+
1404
+ return () => {
1405
+ editor.off('selectionUpdate')
1406
+ }
1407
+ }, [editor])
1408
+
1409
+ return (
1410
+ <DropdownMenu>
1411
+ <DropdownMenuTrigger asChild>
1412
+ <Button
1413
+ className={cn(
1414
+ '-translate-x-1/2 -translate-y-1/2 absolute flex h-7 w-4 overflow-hidden rounded-md border bg-background shadow-xl',
1415
+ {
1416
+ hidden: !(left || top),
1417
+ },
1418
+ )}
1419
+ size="icon"
1420
+ style={{ top, left }}
1421
+ variant="ghost"
1422
+ >
1423
+ <EllipsisVerticalIcon className="text-muted-foreground" size={12} />
1424
+ </Button>
1425
+ </DropdownMenuTrigger>
1426
+ <DropdownMenuContent>{children}</DropdownMenuContent>
1427
+ </DropdownMenu>
1428
+ )
1429
+ }
1430
+
1431
+ export const EditorTableColumnBefore = () => {
1432
+ const { editor } = useCurrentEditor()
1433
+
1434
+ const handleClick = useCallback(() => {
1435
+ if (editor) {
1436
+ editor.chain().focus().addColumnBefore().run()
1437
+ }
1438
+ }, [editor])
1439
+
1440
+ if (!editor) {
1441
+ return null
1442
+ }
1443
+
1444
+ return (
1445
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1446
+ <ArrowLeftIcon className="text-muted-foreground" size={16} />
1447
+ <span>Add column before</span>
1448
+ </DropdownMenuItem>
1449
+ )
1450
+ }
1451
+
1452
+ export const EditorTableColumnAfter = () => {
1453
+ const { editor } = useCurrentEditor()
1454
+
1455
+ const handleClick = useCallback(() => {
1456
+ if (editor) {
1457
+ editor.chain().focus().addColumnAfter().run()
1458
+ }
1459
+ }, [editor])
1460
+
1461
+ if (!editor) {
1462
+ return null
1463
+ }
1464
+
1465
+ return (
1466
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1467
+ <ArrowRightIcon className="text-muted-foreground" size={16} />
1468
+ <span>Add column after</span>
1469
+ </DropdownMenuItem>
1470
+ )
1471
+ }
1472
+
1473
+ export const EditorTableRowBefore = () => {
1474
+ const { editor } = useCurrentEditor()
1475
+
1476
+ const handleClick = useCallback(() => {
1477
+ if (editor) {
1478
+ editor.chain().focus().addRowBefore().run()
1479
+ }
1480
+ }, [editor])
1481
+
1482
+ if (!editor) {
1483
+ return null
1484
+ }
1485
+
1486
+ return (
1487
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1488
+ <ArrowUpIcon className="text-muted-foreground" size={16} />
1489
+ <span>Add row before</span>
1490
+ </DropdownMenuItem>
1491
+ )
1492
+ }
1493
+
1494
+ export const EditorTableRowAfter = () => {
1495
+ const { editor } = useCurrentEditor()
1496
+
1497
+ const handleClick = useCallback(() => {
1498
+ if (editor) {
1499
+ editor.chain().focus().addRowAfter().run()
1500
+ }
1501
+ }, [editor])
1502
+
1503
+ if (!editor) {
1504
+ return null
1505
+ }
1506
+
1507
+ return (
1508
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1509
+ <ArrowDownIcon className="text-muted-foreground" size={16} />
1510
+ <span>Add row after</span>
1511
+ </DropdownMenuItem>
1512
+ )
1513
+ }
1514
+
1515
+ export const EditorTableColumnDelete = () => {
1516
+ const { editor } = useCurrentEditor()
1517
+
1518
+ const handleClick = useCallback(() => {
1519
+ if (editor) {
1520
+ editor.chain().focus().deleteColumn().run()
1521
+ }
1522
+ }, [editor])
1523
+
1524
+ if (!editor) {
1525
+ return null
1526
+ }
1527
+
1528
+ return (
1529
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1530
+ <TrashIcon className="text-destructive" size={16} />
1531
+ <span>Delete column</span>
1532
+ </DropdownMenuItem>
1533
+ )
1534
+ }
1535
+
1536
+ export const EditorTableRowDelete = () => {
1537
+ const { editor } = useCurrentEditor()
1538
+
1539
+ const handleClick = useCallback(() => {
1540
+ if (editor) {
1541
+ editor.chain().focus().deleteRow().run()
1542
+ }
1543
+ }, [editor])
1544
+
1545
+ if (!editor) {
1546
+ return null
1547
+ }
1548
+
1549
+ return (
1550
+ <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
1551
+ <TrashIcon className="text-destructive" size={16} />
1552
+ <span>Delete row</span>
1553
+ </DropdownMenuItem>
1554
+ )
1555
+ }
1556
+
1557
+ export const EditorTableHeaderColumnToggle = () => {
1558
+ const { editor } = useCurrentEditor()
1559
+
1560
+ const handleClick = useCallback(() => {
1561
+ if (editor) {
1562
+ editor.chain().focus().toggleHeaderColumn().run()
1563
+ }
1564
+ }, [editor])
1565
+
1566
+ if (!editor) {
1567
+ return null
1568
+ }
1569
+
1570
+ return (
1571
+ <Tooltip>
1572
+ <TooltipTrigger asChild>
1573
+ <Button
1574
+ className="flex items-center gap-2 rounded-full"
1575
+ onClick={handleClick}
1576
+ size="icon"
1577
+ variant="ghost"
1578
+ >
1579
+ <ColumnsIcon className="text-muted-foreground" size={16} />
1580
+ </Button>
1581
+ </TooltipTrigger>
1582
+ <TooltipContent>
1583
+ <span>Toggle header column</span>
1584
+ </TooltipContent>
1585
+ </Tooltip>
1586
+ )
1587
+ }
1588
+
1589
+ export const EditorTableHeaderRowToggle = () => {
1590
+ const { editor } = useCurrentEditor()
1591
+
1592
+ const handleClick = useCallback(() => {
1593
+ if (editor) {
1594
+ editor.chain().focus().toggleHeaderRow().run()
1595
+ }
1596
+ }, [editor])
1597
+
1598
+ if (!editor) {
1599
+ return null
1600
+ }
1601
+
1602
+ return (
1603
+ <Tooltip>
1604
+ <TooltipTrigger asChild>
1605
+ <Button
1606
+ className="flex items-center gap-2 rounded-full"
1607
+ onClick={handleClick}
1608
+ size="icon"
1609
+ variant="ghost"
1610
+ >
1611
+ <RowsIcon className="text-muted-foreground" size={16} />
1612
+ </Button>
1613
+ </TooltipTrigger>
1614
+ <TooltipContent>
1615
+ <span>Toggle header row</span>
1616
+ </TooltipContent>
1617
+ </Tooltip>
1618
+ )
1619
+ }
1620
+
1621
+ export const EditorTableDelete = () => {
1622
+ const { editor } = useCurrentEditor()
1623
+
1624
+ const handleClick = useCallback(() => {
1625
+ if (editor) {
1626
+ editor.chain().focus().deleteTable().run()
1627
+ }
1628
+ }, [editor])
1629
+
1630
+ if (!editor) {
1631
+ return null
1632
+ }
1633
+
1634
+ return (
1635
+ <Tooltip>
1636
+ <TooltipTrigger asChild>
1637
+ <Button
1638
+ className="flex items-center gap-2 rounded-full"
1639
+ onClick={handleClick}
1640
+ size="icon"
1641
+ variant="ghost"
1642
+ >
1643
+ <TrashIcon className="text-destructive" size={16} />
1644
+ </Button>
1645
+ </TooltipTrigger>
1646
+ <TooltipContent>
1647
+ <span>Delete table</span>
1648
+ </TooltipContent>
1649
+ </Tooltip>
1650
+ )
1651
+ }
1652
+
1653
+ export const EditorTableMergeCells = () => {
1654
+ const { editor } = useCurrentEditor()
1655
+
1656
+ const handleClick = useCallback(() => {
1657
+ if (editor) {
1658
+ editor.chain().focus().mergeCells().run()
1659
+ }
1660
+ }, [editor])
1661
+
1662
+ if (!editor) {
1663
+ return null
1664
+ }
1665
+
1666
+ return (
1667
+ <Tooltip>
1668
+ <TooltipTrigger asChild>
1669
+ <Button
1670
+ className="flex items-center gap-2 rounded-full"
1671
+ onClick={handleClick}
1672
+ size="icon"
1673
+ variant="ghost"
1674
+ >
1675
+ <TableCellsMergeIcon className="text-muted-foreground" size={16} />
1676
+ </Button>
1677
+ </TooltipTrigger>
1678
+ <TooltipContent>
1679
+ <span>Merge cells</span>
1680
+ </TooltipContent>
1681
+ </Tooltip>
1682
+ )
1683
+ }
1684
+
1685
+ export const EditorTableSplitCell = () => {
1686
+ const { editor } = useCurrentEditor()
1687
+
1688
+ const handleClick = useCallback(() => {
1689
+ if (editor) {
1690
+ editor.chain().focus().splitCell().run()
1691
+ }
1692
+ }, [editor])
1693
+
1694
+ if (!editor) {
1695
+ return null
1696
+ }
1697
+
1698
+ return (
1699
+ <Tooltip>
1700
+ <TooltipTrigger asChild>
1701
+ <Button
1702
+ className="flex items-center gap-2 rounded-full"
1703
+ onClick={handleClick}
1704
+ size="icon"
1705
+ variant="ghost"
1706
+ >
1707
+ <TableColumnsSplitIcon className="text-muted-foreground" size={16} />
1708
+ </Button>
1709
+ </TooltipTrigger>
1710
+ <TooltipContent>
1711
+ <span>Split cell</span>
1712
+ </TooltipContent>
1713
+ </Tooltip>
1714
+ )
1715
+ }
1716
+
1717
+ export const EditorTableFix = () => {
1718
+ const { editor } = useCurrentEditor()
1719
+
1720
+ const handleClick = useCallback(() => {
1721
+ if (editor) {
1722
+ editor.chain().focus().fixTables().run()
1723
+ }
1724
+ }, [editor])
1725
+
1726
+ if (!editor) {
1727
+ return null
1728
+ }
1729
+
1730
+ return (
1731
+ <Tooltip>
1732
+ <TooltipTrigger asChild>
1733
+ <Button
1734
+ className="flex items-center gap-2 rounded-full"
1735
+ onClick={handleClick}
1736
+ size="icon"
1737
+ variant="ghost"
1738
+ >
1739
+ <BoltIcon className="text-muted-foreground" size={16} />
1740
+ </Button>
1741
+ </TooltipTrigger>
1742
+ <TooltipContent>
1743
+ <span>Fix table</span>
1744
+ </TooltipContent>
1745
+ </Tooltip>
1746
+ )
1747
+ }
1748
+
1749
+ export type EditorCharacterCountProps = {
1750
+ children: ReactNode
1751
+ className?: string
1752
+ }
1753
+
1754
+ export const EditorCharacterCount = {
1755
+ Characters({ children, className }: EditorCharacterCountProps) {
1756
+ const { editor } = useCurrentEditor()
1757
+
1758
+ if (!editor) {
1759
+ return null
1760
+ }
1761
+
1762
+ return (
1763
+ <div
1764
+ className={cn(
1765
+ 'absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow',
1766
+ className,
1767
+ )}
1768
+ >
1769
+ {children}
1770
+ {editor.storage.characterCount.characters()}
1771
+ </div>
1772
+ )
1773
+ },
1774
+
1775
+ Words({ children, className }: EditorCharacterCountProps) {
1776
+ const { editor } = useCurrentEditor()
1777
+
1778
+ if (!editor) {
1779
+ return null
1780
+ }
1781
+
1782
+ return (
1783
+ <div
1784
+ className={cn(
1785
+ 'absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow',
1786
+ className,
1787
+ )}
1788
+ >
1789
+ {children}
1790
+ {editor.storage.characterCount.words()}
1791
+ </div>
1792
+ )
1793
+ },
1794
+ }