@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.
- package/.devcontainer/Dockerfile +13 -0
- package/.devcontainer/devcontainer.json +52 -0
- package/.github/dependabot.yml +10 -0
- package/.github/workflows/pages.yml +42 -0
- package/.github/workflows/publish.yml +41 -0
- package/.vscode/settings.json +7 -0
- package/LICENSE +9 -0
- package/README.md +9 -0
- package/app/_component/editor.tsx +383 -0
- package/app/layout.tsx +46 -0
- package/app/page.tsx +11 -0
- package/app/r/registry.json/route.ts +22 -0
- package/components/editorx/editor.tsx +1794 -0
- package/components/editorx/extensions/floating-menu.tsx +376 -0
- package/components/editorx/extensions/floating-toolbar.tsx +97 -0
- package/components/editorx/extensions/image-placeholder.tsx +316 -0
- package/components/editorx/extensions/image.tsx +462 -0
- package/components/editorx/extensions/search-and-replace.tsx +438 -0
- package/components/editorx/rich-text-editor.tsx +383 -0
- package/components/editorx/tiptap.css +421 -0
- package/components/editorx/toolbars/alignment.tsx +126 -0
- package/components/editorx/toolbars/blockquote.tsx +47 -0
- package/components/editorx/toolbars/bold.tsx +48 -0
- package/components/editorx/toolbars/bullet-list.tsx +48 -0
- package/components/editorx/toolbars/code-block.tsx +47 -0
- package/components/editorx/toolbars/code.tsx +43 -0
- package/components/editorx/toolbars/color-and-highlight.tsx +215 -0
- package/components/editorx/toolbars/editor-toolbar.tsx +77 -0
- package/components/editorx/toolbars/hard-break.tsx +46 -0
- package/components/editorx/toolbars/headings.tsx +97 -0
- package/components/editorx/toolbars/horizontal-rule.tsx +42 -0
- package/components/editorx/toolbars/image-placeholder-toolbar.tsx +47 -0
- package/components/editorx/toolbars/italic.tsx +48 -0
- package/components/editorx/toolbars/link.tsx +130 -0
- package/components/editorx/toolbars/mobile-toolbar-group.tsx +76 -0
- package/components/editorx/toolbars/ordered-list.tsx +47 -0
- package/components/editorx/toolbars/redo.tsx +44 -0
- package/components/editorx/toolbars/strikethrough.tsx +48 -0
- package/components/editorx/toolbars/toolbar-provider.tsx +29 -0
- package/components/editorx/toolbars/underline.tsx +48 -0
- package/components/editorx/toolbars/undo.tsx +43 -0
- package/components/layout/theme-switcher.tsx +26 -0
- package/components/main-nav.tsx +24 -0
- package/components/mobile-nav.tsx +46 -0
- package/components/open-in-v0-button.tsx +38 -0
- package/components/page-header.tsx +30 -0
- package/components/site-footer.tsx +41 -0
- package/components/site-header.tsx +32 -0
- package/components/theme-provider.tsx +8 -0
- package/components/ui/button.tsx +57 -0
- package/components/ui/checkbox.tsx +30 -0
- package/components/ui/collapsible.tsx +11 -0
- package/components/ui/command.tsx +148 -0
- package/components/ui/dialog.tsx +122 -0
- package/components/ui/drawer.tsx +118 -0
- package/components/ui/dropdown-menu.tsx +201 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/popover.tsx +33 -0
- package/components/ui/resizable.tsx +40 -0
- package/components/ui/scroll-area.tsx +42 -0
- package/components/ui/separator.tsx +31 -0
- package/components/ui/sheet.tsx +140 -0
- package/components/ui/sidebar.tsx +763 -0
- package/components/ui/skeleton.tsx +15 -0
- package/components/ui/spinner.tsx +29 -0
- package/components/ui/tabs.tsx +55 -0
- package/components/ui/toggle-group.tsx +61 -0
- package/components/ui/toggle.tsx +45 -0
- package/components/ui/tooltip.tsx +32 -0
- package/components.json +21 -0
- package/config/site.ts +15 -0
- package/eslint.config.mjs +20 -0
- package/hooks/use-character-limit.ts +28 -0
- package/hooks/use-copy-to-clipboard.ts +16 -0
- package/hooks/use-debounce.ts +17 -0
- package/hooks/use-image-upload.ts +97 -0
- package/hooks/use-media-querry.ts +18 -0
- package/hooks/use-mobile.tsx +19 -0
- package/images/editor.png +0 -0
- package/lib/content.ts +39 -0
- package/lib/cookie-client.ts +19 -0
- package/lib/localstorage-client.ts +19 -0
- package/lib/package.ts +144 -0
- package/lib/preferences-config.ts +72 -0
- package/lib/preferences-storage.ts +20 -0
- package/lib/theme-utils.ts +12 -0
- package/lib/theme.ts +50 -0
- package/lib/tiptap-utils.ts +45 -0
- package/lib/utils.ts +11 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +11 -0
- package/package.json +92 -0
- package/postcss.config.mjs +8 -0
- package/prettier.config.mjs +15 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/og.webp +0 -0
- package/public/r/editor-x.json +85 -0
- package/public/r/registry.json +93 -0
- package/public/site.webmanifest +19 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/registry/editor/components/editor.tsx +1794 -0
- package/registry/editor/components/extensions/floating-menu.tsx +376 -0
- package/registry/editor/components/extensions/floating-toolbar.tsx +97 -0
- package/registry/editor/components/extensions/image-placeholder.tsx +316 -0
- package/registry/editor/components/extensions/image.tsx +462 -0
- package/registry/editor/components/extensions/search-and-replace.tsx +438 -0
- package/registry/editor/components/rich-text-editor.tsx +383 -0
- package/registry/editor/components/tiptap.css +421 -0
- package/registry/editor/components/toolbars/alignment.tsx +126 -0
- package/registry/editor/components/toolbars/blockquote.tsx +47 -0
- package/registry/editor/components/toolbars/bold.tsx +48 -0
- package/registry/editor/components/toolbars/bullet-list.tsx +48 -0
- package/registry/editor/components/toolbars/code-block.tsx +47 -0
- package/registry/editor/components/toolbars/code.tsx +43 -0
- package/registry/editor/components/toolbars/color-and-highlight.tsx +215 -0
- package/registry/editor/components/toolbars/editor-toolbar.tsx +77 -0
- package/registry/editor/components/toolbars/hard-break.tsx +46 -0
- package/registry/editor/components/toolbars/headings.tsx +97 -0
- package/registry/editor/components/toolbars/horizontal-rule.tsx +42 -0
- package/registry/editor/components/toolbars/image-placeholder-toolbar.tsx +47 -0
- package/registry/editor/components/toolbars/italic.tsx +48 -0
- package/registry/editor/components/toolbars/link.tsx +130 -0
- package/registry/editor/components/toolbars/mobile-toolbar-group.tsx +76 -0
- package/registry/editor/components/toolbars/ordered-list.tsx +47 -0
- package/registry/editor/components/toolbars/redo.tsx +44 -0
- package/registry/editor/components/toolbars/strikethrough.tsx +48 -0
- package/registry/editor/components/toolbars/toolbar-provider.tsx +29 -0
- package/registry/editor/components/toolbars/underline.tsx +48 -0
- package/registry/editor/components/toolbars/undo.tsx +43 -0
- package/registry/editor/components/ui/button.tsx +57 -0
- package/registry/editor/components/ui/checkbox.tsx +30 -0
- package/registry/editor/components/ui/collapsible.tsx +11 -0
- package/registry/editor/components/ui/command.tsx +148 -0
- package/registry/editor/components/ui/dialog.tsx +122 -0
- package/registry/editor/components/ui/drawer.tsx +118 -0
- package/registry/editor/components/ui/dropdown-menu.tsx +201 -0
- package/registry/editor/components/ui/input.tsx +22 -0
- package/registry/editor/components/ui/label.tsx +26 -0
- package/registry/editor/components/ui/popover.tsx +33 -0
- package/registry/editor/components/ui/resizable.tsx +40 -0
- package/registry/editor/components/ui/scroll-area.tsx +42 -0
- package/registry/editor/components/ui/separator.tsx +31 -0
- package/registry/editor/components/ui/sheet.tsx +140 -0
- package/registry/editor/components/ui/sidebar.tsx +763 -0
- package/registry/editor/components/ui/skeleton.tsx +15 -0
- package/registry/editor/components/ui/spinner.tsx +29 -0
- package/registry/editor/components/ui/tabs.tsx +55 -0
- package/registry/editor/components/ui/toggle-group.tsx +61 -0
- package/registry/editor/components/ui/toggle.tsx +45 -0
- package/registry/editor/components/ui/tooltip.tsx +32 -0
- package/registry/editor/hooks/use-character-limit.ts +28 -0
- package/registry/editor/hooks/use-copy-to-clipboard.ts +16 -0
- package/registry/editor/hooks/use-debounce.ts +17 -0
- package/registry/editor/hooks/use-image-upload.ts +97 -0
- package/registry/editor/hooks/use-media-querry.ts +18 -0
- package/registry/editor/hooks/use-mobile.tsx +19 -0
- package/registry/editor/lib/content.ts +39 -0
- package/registry/editor/lib/cookie-client.ts +19 -0
- package/registry/editor/lib/localstorage-client.ts +19 -0
- package/registry/editor/lib/package.ts +144 -0
- package/registry/editor/lib/preferences-config.ts +72 -0
- package/registry/editor/lib/preferences-storage.ts +20 -0
- package/registry/editor/lib/theme-utils.ts +12 -0
- package/registry/editor/lib/theme.ts +50 -0
- package/registry/editor/lib/tiptap-utils.ts +45 -0
- package/registry/editor/lib/utils.ts +11 -0
- package/registry/editor/page.tsx +9 -0
- package/registry.json +93 -0
- package/reset.d.ts +1 -0
- package/scripts/generate-theme-presets.ts +128 -0
- package/scripts/postCreateCommand.sh +0 -0
- package/scripts/theme-boot.tsx +105 -0
- package/server/server-actions.ts +27 -0
- package/stores/preferences/preferences-provider.tsx +55 -0
- package/stores/preferences/preferences-store.ts +23 -0
- package/styles/globals.css +288 -0
- package/styles/presets/brutalist.css +89 -0
- package/styles/presets/soft-pop.css +89 -0
- package/styles/presets/tangerine.css +89 -0
- 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
|
+
}
|