@questpie/admin 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 (203) hide show
  1. package/.turbo/turbo-build.log +108 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +556 -0
  4. package/STATUS.md +917 -0
  5. package/VALIDATION.md +602 -0
  6. package/components.json +24 -0
  7. package/dist/__tests__/setup.mjs +38 -0
  8. package/dist/__tests__/test-utils.mjs +45 -0
  9. package/dist/__tests__/vitest.d.mjs +3 -0
  10. package/dist/components/admin-app.mjs +69 -0
  11. package/dist/components/fields/array-field.mjs +190 -0
  12. package/dist/components/fields/checkbox-field.mjs +34 -0
  13. package/dist/components/fields/custom-field.mjs +32 -0
  14. package/dist/components/fields/date-field.mjs +41 -0
  15. package/dist/components/fields/datetime-field.mjs +42 -0
  16. package/dist/components/fields/email-field.mjs +37 -0
  17. package/dist/components/fields/embedded-collection.mjs +253 -0
  18. package/dist/components/fields/field-types.mjs +1 -0
  19. package/dist/components/fields/field-utils.mjs +10 -0
  20. package/dist/components/fields/field-wrapper.mjs +34 -0
  21. package/dist/components/fields/index.mjs +23 -0
  22. package/dist/components/fields/json-field.mjs +243 -0
  23. package/dist/components/fields/locale-badge.mjs +16 -0
  24. package/dist/components/fields/number-field.mjs +39 -0
  25. package/dist/components/fields/password-field.mjs +37 -0
  26. package/dist/components/fields/relation-field.mjs +104 -0
  27. package/dist/components/fields/relation-picker.mjs +229 -0
  28. package/dist/components/fields/relation-select.mjs +188 -0
  29. package/dist/components/fields/rich-text-editor/index.mjs +897 -0
  30. package/dist/components/fields/select-field.mjs +41 -0
  31. package/dist/components/fields/switch-field.mjs +34 -0
  32. package/dist/components/fields/text-field.mjs +38 -0
  33. package/dist/components/fields/textarea-field.mjs +38 -0
  34. package/dist/components/index.mjs +59 -0
  35. package/dist/components/primitives/checkbox-input.mjs +127 -0
  36. package/dist/components/primitives/date-input.mjs +303 -0
  37. package/dist/components/primitives/index.mjs +12 -0
  38. package/dist/components/primitives/number-input.mjs +104 -0
  39. package/dist/components/primitives/select-input.mjs +177 -0
  40. package/dist/components/primitives/tag-input.mjs +135 -0
  41. package/dist/components/primitives/text-input.mjs +39 -0
  42. package/dist/components/primitives/textarea-input.mjs +37 -0
  43. package/dist/components/primitives/toggle-input.mjs +31 -0
  44. package/dist/components/primitives/types.mjs +12 -0
  45. package/dist/components/ui/accordion.mjs +55 -0
  46. package/dist/components/ui/avatar.mjs +54 -0
  47. package/dist/components/ui/badge.mjs +34 -0
  48. package/dist/components/ui/button.mjs +48 -0
  49. package/dist/components/ui/card.mjs +58 -0
  50. package/dist/components/ui/checkbox.mjs +21 -0
  51. package/dist/components/ui/combobox.mjs +163 -0
  52. package/dist/components/ui/dialog.mjs +95 -0
  53. package/dist/components/ui/dropdown-menu.mjs +138 -0
  54. package/dist/components/ui/field.mjs +113 -0
  55. package/dist/components/ui/input-group.mjs +82 -0
  56. package/dist/components/ui/input.mjs +17 -0
  57. package/dist/components/ui/label.mjs +15 -0
  58. package/dist/components/ui/popover.mjs +56 -0
  59. package/dist/components/ui/scroll-area.mjs +38 -0
  60. package/dist/components/ui/select.mjs +100 -0
  61. package/dist/components/ui/separator.mjs +16 -0
  62. package/dist/components/ui/sheet.mjs +90 -0
  63. package/dist/components/ui/sidebar.mjs +387 -0
  64. package/dist/components/ui/skeleton.mjs +14 -0
  65. package/dist/components/ui/spinner.mjs +16 -0
  66. package/dist/components/ui/switch.mjs +22 -0
  67. package/dist/components/ui/table.mjs +68 -0
  68. package/dist/components/ui/tabs.mjs +48 -0
  69. package/dist/components/ui/textarea.mjs +15 -0
  70. package/dist/components/ui/tooltip.mjs +44 -0
  71. package/dist/config/component-registry.mjs +38 -0
  72. package/dist/config/index.mjs +129 -0
  73. package/dist/hooks/admin-provider.mjs +70 -0
  74. package/dist/hooks/index.mjs +7 -0
  75. package/dist/hooks/store.mjs +178 -0
  76. package/dist/hooks/use-auth.mjs +76 -0
  77. package/dist/hooks/use-collection-db.mjs +146 -0
  78. package/dist/hooks/use-collection.mjs +112 -0
  79. package/dist/hooks/use-global.mjs +46 -0
  80. package/dist/hooks/use-mobile.mjs +20 -0
  81. package/dist/lib/utils.mjs +10 -0
  82. package/dist/styles/index.css +336 -0
  83. package/dist/styles/index.mjs +1 -0
  84. package/dist/utils/index.mjs +9 -0
  85. package/dist/views/auth/auth-layout.mjs +52 -0
  86. package/dist/views/auth/forgot-password-form.mjs +148 -0
  87. package/dist/views/auth/index.mjs +6 -0
  88. package/dist/views/auth/login-form.mjs +156 -0
  89. package/dist/views/auth/reset-password-form.mjs +184 -0
  90. package/dist/views/collection/auto-form-fields.mjs +525 -0
  91. package/dist/views/collection/collection-form.mjs +91 -0
  92. package/dist/views/collection/collection-list.mjs +76 -0
  93. package/dist/views/collection/form-field.mjs +42 -0
  94. package/dist/views/collection/index.mjs +6 -0
  95. package/dist/views/common/index.mjs +4 -0
  96. package/dist/views/common/locale-switcher.mjs +39 -0
  97. package/dist/views/common/version-history.mjs +272 -0
  98. package/dist/views/index.mjs +9 -0
  99. package/dist/views/layout/admin-layout.mjs +40 -0
  100. package/dist/views/layout/admin-router.mjs +95 -0
  101. package/dist/views/layout/admin-sidebar.mjs +63 -0
  102. package/dist/views/layout/index.mjs +5 -0
  103. package/package.json +276 -0
  104. package/src/__tests__/setup.ts +44 -0
  105. package/src/__tests__/test-utils.tsx +49 -0
  106. package/src/__tests__/vitest.d.ts +9 -0
  107. package/src/components/admin-app.tsx +221 -0
  108. package/src/components/fields/array-field.tsx +237 -0
  109. package/src/components/fields/checkbox-field.tsx +47 -0
  110. package/src/components/fields/custom-field.tsx +50 -0
  111. package/src/components/fields/date-field.tsx +65 -0
  112. package/src/components/fields/datetime-field.tsx +67 -0
  113. package/src/components/fields/email-field.tsx +51 -0
  114. package/src/components/fields/embedded-collection.tsx +315 -0
  115. package/src/components/fields/field-types.ts +162 -0
  116. package/src/components/fields/field-utils.ts +6 -0
  117. package/src/components/fields/field-wrapper.tsx +52 -0
  118. package/src/components/fields/index.ts +66 -0
  119. package/src/components/fields/json-field.tsx +440 -0
  120. package/src/components/fields/locale-badge.tsx +15 -0
  121. package/src/components/fields/number-field.tsx +57 -0
  122. package/src/components/fields/password-field.tsx +51 -0
  123. package/src/components/fields/relation-field.tsx +243 -0
  124. package/src/components/fields/relation-picker.tsx +402 -0
  125. package/src/components/fields/relation-select.tsx +327 -0
  126. package/src/components/fields/rich-text-editor/index.tsx +1337 -0
  127. package/src/components/fields/select-field.tsx +61 -0
  128. package/src/components/fields/switch-field.tsx +47 -0
  129. package/src/components/fields/text-field.tsx +55 -0
  130. package/src/components/fields/textarea-field.tsx +55 -0
  131. package/src/components/index.ts +40 -0
  132. package/src/components/primitives/checkbox-input.tsx +193 -0
  133. package/src/components/primitives/date-input.tsx +401 -0
  134. package/src/components/primitives/index.ts +24 -0
  135. package/src/components/primitives/number-input.tsx +132 -0
  136. package/src/components/primitives/select-input.tsx +296 -0
  137. package/src/components/primitives/tag-input.tsx +200 -0
  138. package/src/components/primitives/text-input.tsx +49 -0
  139. package/src/components/primitives/textarea-input.tsx +46 -0
  140. package/src/components/primitives/toggle-input.tsx +36 -0
  141. package/src/components/primitives/types.ts +235 -0
  142. package/src/components/ui/accordion.tsx +72 -0
  143. package/src/components/ui/avatar.tsx +106 -0
  144. package/src/components/ui/badge.tsx +48 -0
  145. package/src/components/ui/button.tsx +53 -0
  146. package/src/components/ui/card.tsx +94 -0
  147. package/src/components/ui/checkbox.tsx +27 -0
  148. package/src/components/ui/combobox.tsx +290 -0
  149. package/src/components/ui/dialog.tsx +151 -0
  150. package/src/components/ui/dropdown-menu.tsx +254 -0
  151. package/src/components/ui/field.tsx +227 -0
  152. package/src/components/ui/input-group.tsx +149 -0
  153. package/src/components/ui/input.tsx +20 -0
  154. package/src/components/ui/label.tsx +18 -0
  155. package/src/components/ui/popover.tsx +88 -0
  156. package/src/components/ui/scroll-area.tsx +53 -0
  157. package/src/components/ui/select.tsx +192 -0
  158. package/src/components/ui/separator.tsx +23 -0
  159. package/src/components/ui/sheet.tsx +127 -0
  160. package/src/components/ui/sidebar.tsx +723 -0
  161. package/src/components/ui/skeleton.tsx +13 -0
  162. package/src/components/ui/spinner.tsx +10 -0
  163. package/src/components/ui/switch.tsx +32 -0
  164. package/src/components/ui/table.tsx +99 -0
  165. package/src/components/ui/tabs.tsx +82 -0
  166. package/src/components/ui/textarea.tsx +18 -0
  167. package/src/components/ui/tooltip.tsx +70 -0
  168. package/src/config/component-registry.ts +190 -0
  169. package/src/config/index.ts +1099 -0
  170. package/src/hooks/README.md +269 -0
  171. package/src/hooks/admin-provider.tsx +110 -0
  172. package/src/hooks/index.ts +41 -0
  173. package/src/hooks/store.ts +248 -0
  174. package/src/hooks/use-auth.ts +168 -0
  175. package/src/hooks/use-collection-db.ts +209 -0
  176. package/src/hooks/use-collection.ts +156 -0
  177. package/src/hooks/use-global.ts +69 -0
  178. package/src/hooks/use-mobile.ts +21 -0
  179. package/src/lib/utils.ts +6 -0
  180. package/src/styles/index.css +340 -0
  181. package/src/utils/index.ts +6 -0
  182. package/src/views/auth/auth-layout.tsx +77 -0
  183. package/src/views/auth/forgot-password-form.tsx +192 -0
  184. package/src/views/auth/index.ts +21 -0
  185. package/src/views/auth/login-form.tsx +229 -0
  186. package/src/views/auth/reset-password-form.tsx +232 -0
  187. package/src/views/collection/auto-form-fields.tsx +982 -0
  188. package/src/views/collection/collection-form.tsx +186 -0
  189. package/src/views/collection/collection-list.tsx +223 -0
  190. package/src/views/collection/form-field.tsx +52 -0
  191. package/src/views/collection/index.ts +15 -0
  192. package/src/views/common/index.ts +8 -0
  193. package/src/views/common/locale-switcher.tsx +45 -0
  194. package/src/views/common/version-history.tsx +406 -0
  195. package/src/views/index.ts +25 -0
  196. package/src/views/layout/admin-layout.tsx +117 -0
  197. package/src/views/layout/admin-router.tsx +206 -0
  198. package/src/views/layout/admin-sidebar.tsx +185 -0
  199. package/src/views/layout/index.ts +12 -0
  200. package/tsconfig.json +13 -0
  201. package/tsconfig.tsbuildinfo +1 -0
  202. package/tsdown.config.ts +13 -0
  203. package/vitest.config.ts +29 -0
@@ -0,0 +1,1337 @@
1
+ /**
2
+ * RichTextEditor Component
3
+ *
4
+ * Tiptap-based rich text editor with toolbar controls.
5
+ */
6
+
7
+ import * as React from "react";
8
+ import { Extension, type Editor, type Range } from "@tiptap/core";
9
+ import type { Extension as TiptapExtension } from "@tiptap/core";
10
+ import {
11
+ BubbleMenu,
12
+ EditorContent,
13
+ ReactRenderer,
14
+ useEditor,
15
+ } from "@tiptap/react";
16
+ import StarterKit from "@tiptap/starter-kit";
17
+ import Underline from "@tiptap/extension-underline";
18
+ import Link from "@tiptap/extension-link";
19
+ import Image from "@tiptap/extension-image";
20
+ import Placeholder from "@tiptap/extension-placeholder";
21
+ import TextAlign from "@tiptap/extension-text-align";
22
+ import Table from "@tiptap/extension-table";
23
+ import TableRow from "@tiptap/extension-table-row";
24
+ import TableHeader from "@tiptap/extension-table-header";
25
+ import TableCell from "@tiptap/extension-table-cell";
26
+ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
27
+ import CharacterCount from "@tiptap/extension-character-count";
28
+ import Suggestion from "@tiptap/suggestion";
29
+ import tippy, { type Instance } from "tippy.js";
30
+ import { common, createLowlight } from "lowlight";
31
+
32
+ const lowlight = createLowlight(common);
33
+ import { Button } from "../../ui/button";
34
+ import { Input } from "../../ui/input";
35
+ import { Label } from "../../ui/label";
36
+ import { LocaleBadge } from "../locale-badge";
37
+ import {
38
+ Popover,
39
+ PopoverContent,
40
+ PopoverHeader,
41
+ PopoverTitle,
42
+ PopoverTrigger,
43
+ } from "../../ui/popover";
44
+ import type { FieldComponentProps } from "../../../config/component-registry";
45
+ import { cn } from "../../../utils";
46
+
47
+ export type RichTextFeatures = {
48
+ toolbar?: boolean;
49
+ bubbleMenu?: boolean;
50
+ slashCommands?: boolean;
51
+ history?: boolean;
52
+ heading?: boolean;
53
+ bold?: boolean;
54
+ italic?: boolean;
55
+ underline?: boolean;
56
+ strike?: boolean;
57
+ code?: boolean;
58
+ codeBlock?: boolean;
59
+ blockquote?: boolean;
60
+ bulletList?: boolean;
61
+ orderedList?: boolean;
62
+ horizontalRule?: boolean;
63
+ align?: boolean;
64
+ link?: boolean;
65
+ image?: boolean;
66
+ table?: boolean;
67
+ tableControls?: boolean;
68
+ characterCount?: boolean;
69
+ };
70
+
71
+ export interface RichTextEditorProps extends FieldComponentProps<any> {
72
+ /**
73
+ * Output format
74
+ */
75
+ outputFormat?: "json" | "html" | "markdown";
76
+
77
+ /**
78
+ * Custom Tiptap extensions
79
+ */
80
+ extensions?: TiptapExtension[];
81
+
82
+ /**
83
+ * Feature toggles
84
+ */
85
+ features?: RichTextFeatures;
86
+
87
+ /**
88
+ * Show character count
89
+ */
90
+ showCharacterCount?: boolean;
91
+
92
+ /**
93
+ * Max character limit
94
+ */
95
+ maxCharacters?: number;
96
+
97
+ /**
98
+ * Enable image uploads
99
+ */
100
+ enableImages?: boolean;
101
+
102
+ /**
103
+ * Image upload handler
104
+ */
105
+ onImageUpload?: (file: File) => Promise<string>;
106
+ }
107
+
108
+ type OutputValue = Record<string, any> | string;
109
+
110
+ type ToolbarButtonProps = {
111
+ active?: boolean;
112
+ disabled?: boolean;
113
+ title?: string;
114
+ onClick: () => void;
115
+ children: React.ReactNode;
116
+ };
117
+
118
+ type SlashCommandItem = {
119
+ title: string;
120
+ description?: string;
121
+ keywords?: string[];
122
+ command: (editor: any) => void;
123
+ };
124
+
125
+ type SlashCommandListProps = {
126
+ items: SlashCommandItem[];
127
+ command: (item: SlashCommandItem) => void;
128
+ };
129
+
130
+ type SlashCommandListHandle = {
131
+ onKeyDown: (props: { event: KeyboardEvent }) => boolean;
132
+ };
133
+
134
+ const defaultFeatures: Required<RichTextFeatures> = {
135
+ toolbar: true,
136
+ bubbleMenu: true,
137
+ slashCommands: true,
138
+ history: true,
139
+ heading: true,
140
+ bold: true,
141
+ italic: true,
142
+ underline: true,
143
+ strike: true,
144
+ code: true,
145
+ codeBlock: true,
146
+ blockquote: true,
147
+ bulletList: true,
148
+ orderedList: true,
149
+ horizontalRule: true,
150
+ align: true,
151
+ link: true,
152
+ image: true,
153
+ table: true,
154
+ tableControls: true,
155
+ characterCount: true,
156
+ };
157
+
158
+ function ToolbarButton({
159
+ active,
160
+ disabled,
161
+ title,
162
+ onClick,
163
+ children,
164
+ }: ToolbarButtonProps) {
165
+ return (
166
+ <Button
167
+ type="button"
168
+ variant="ghost"
169
+ size="xs"
170
+ data-active={active}
171
+ title={title}
172
+ disabled={disabled}
173
+ onClick={onClick}
174
+ className="data-[active=true]:bg-muted data-[active=true]:text-foreground"
175
+ >
176
+ {children}
177
+ </Button>
178
+ );
179
+ }
180
+
181
+ function ToolbarGroup({ children }: { children: React.ReactNode }) {
182
+ return (
183
+ <div className="flex items-center gap-1 border-r border-border pr-2 last:border-r-0 last:pr-0">
184
+ {children}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ const SlashCommandList = React.forwardRef<
190
+ SlashCommandListHandle,
191
+ SlashCommandListProps
192
+ >(function SlashCommandList({ items, command }, ref) {
193
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
194
+
195
+ React.useEffect(() => {
196
+ setSelectedIndex(0);
197
+ }, [items]);
198
+
199
+ const selectItem = React.useCallback(
200
+ (index: number) => {
201
+ const item = items[index];
202
+ if (item) {
203
+ command(item);
204
+ }
205
+ },
206
+ [command, items],
207
+ );
208
+
209
+ React.useImperativeHandle(ref, () => ({
210
+ onKeyDown: ({ event }) => {
211
+ if (items.length === 0) return false;
212
+ if (event.key === "ArrowDown") {
213
+ setSelectedIndex((prev) => (prev + 1) % items.length);
214
+ return true;
215
+ }
216
+
217
+ if (event.key === "ArrowUp") {
218
+ setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
219
+ return true;
220
+ }
221
+
222
+ if (event.key === "Enter") {
223
+ selectItem(selectedIndex);
224
+ return true;
225
+ }
226
+
227
+ return false;
228
+ },
229
+ }));
230
+
231
+ return (
232
+ <div className="qp-rich-text-editor__slash">
233
+ {items.length === 0 && (
234
+ <div className="qp-rich-text-editor__slash-empty">No results</div>
235
+ )}
236
+ {items.map((item, index) => (
237
+ <button
238
+ key={item.title}
239
+ type="button"
240
+ className={cn(
241
+ "qp-rich-text-editor__slash-item",
242
+ index === selectedIndex
243
+ ? "qp-rich-text-editor__slash-item--active"
244
+ : "",
245
+ )}
246
+ onClick={() => selectItem(index)}
247
+ >
248
+ <span className="qp-rich-text-editor__slash-title">{item.title}</span>
249
+ {item.description && (
250
+ <span className="qp-rich-text-editor__slash-description">
251
+ {item.description}
252
+ </span>
253
+ )}
254
+ </button>
255
+ ))}
256
+ </div>
257
+ );
258
+ });
259
+
260
+ function getHeadingLevel(editor: ReturnType<typeof useEditor>) {
261
+ if (!editor) return "paragraph";
262
+ for (let level = 1; level <= 6; level += 1) {
263
+ if (editor.isActive("heading", { level })) {
264
+ return String(level);
265
+ }
266
+ }
267
+ return "paragraph";
268
+ }
269
+
270
+ function getOutput(
271
+ editor: NonNullable<ReturnType<typeof useEditor>>,
272
+ outputFormat: "json" | "html" | "markdown",
273
+ ) {
274
+ if (outputFormat === "html") {
275
+ return editor.getHTML();
276
+ }
277
+
278
+ if (outputFormat === "markdown") {
279
+ const markdown = (editor.storage as any)?.markdown?.getMarkdown?.();
280
+ if (typeof markdown === "string") {
281
+ return markdown;
282
+ }
283
+ return editor.getHTML();
284
+ }
285
+
286
+ return editor.getJSON();
287
+ }
288
+
289
+ function isSameValue(a: OutputValue | undefined, b: OutputValue | undefined) {
290
+ if (a === b) return true;
291
+ if (!a || !b) return false;
292
+ if (typeof a === "string" && typeof b === "string") return a === b;
293
+ try {
294
+ return JSON.stringify(a) === JSON.stringify(b);
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ function createSlashCommandExtension(
301
+ getItems: (editor: Editor) => SlashCommandItem[],
302
+ ) {
303
+ return Extension.create({
304
+ name: "slashCommand",
305
+ addOptions() {
306
+ return {
307
+ suggestion: {
308
+ char: "/",
309
+ startOfLine: true,
310
+ command: ({
311
+ editor,
312
+ range,
313
+ props,
314
+ }: {
315
+ editor: Editor;
316
+ range: Range;
317
+ props: SlashCommandItem;
318
+ }) => {
319
+ editor.chain().focus().deleteRange(range).run();
320
+ props.command(editor);
321
+ },
322
+ items: ({ query, editor }: { query: string; editor: Editor }) => {
323
+ const items = getItems(editor);
324
+ if (!query) return items;
325
+ const search = query.toLowerCase();
326
+ return items.filter((item) => {
327
+ return (
328
+ item.title.toLowerCase().includes(search) ||
329
+ item.description?.toLowerCase().includes(search) ||
330
+ item.keywords?.some((keyword) =>
331
+ keyword.toLowerCase().includes(search),
332
+ )
333
+ );
334
+ });
335
+ },
336
+ render: () => {
337
+ let component: ReactRenderer<SlashCommandListHandle> | null = null;
338
+ let popup: Instance[] | null = null;
339
+
340
+ return {
341
+ onStart: (props: any) => {
342
+ component = new ReactRenderer(SlashCommandList, {
343
+ props,
344
+ editor: props.editor,
345
+ });
346
+
347
+ if (!props.clientRect) {
348
+ return;
349
+ }
350
+
351
+ popup = tippy("body", {
352
+ getReferenceClientRect: props.clientRect,
353
+ appendTo: () => document.body,
354
+ content: component.element,
355
+ showOnCreate: true,
356
+ interactive: true,
357
+ trigger: "manual",
358
+ placement: "bottom-start",
359
+ theme: "qp-rich-text-editor",
360
+ });
361
+ },
362
+
363
+ onUpdate: (props: any) => {
364
+ component?.updateProps(props);
365
+
366
+ if (!props.clientRect) {
367
+ return;
368
+ }
369
+
370
+ popup?.[0].setProps({
371
+ getReferenceClientRect: props.clientRect,
372
+ });
373
+ },
374
+
375
+ onKeyDown: (props: any) => {
376
+ if (props.event.key === "Escape") {
377
+ popup?.[0].hide();
378
+ return true;
379
+ }
380
+
381
+ return component?.ref?.onKeyDown(props) ?? false;
382
+ },
383
+
384
+ onExit: () => {
385
+ popup?.[0].destroy();
386
+ component?.destroy();
387
+ },
388
+ };
389
+ },
390
+ },
391
+ };
392
+ },
393
+ addProseMirrorPlugins() {
394
+ return [Suggestion(this.options.suggestion)];
395
+ },
396
+ });
397
+ }
398
+
399
+ export function RichTextEditor({
400
+ name,
401
+ value,
402
+ onChange,
403
+ disabled,
404
+ readOnly,
405
+ label,
406
+ description,
407
+ placeholder,
408
+ required,
409
+ error,
410
+ localized,
411
+ locale,
412
+ outputFormat = "json",
413
+ extensions,
414
+ features,
415
+ showCharacterCount,
416
+ maxCharacters,
417
+ enableImages,
418
+ onImageUpload,
419
+ }: RichTextEditorProps) {
420
+ const [linkOpen, setLinkOpen] = React.useState(false);
421
+ const [linkUrl, setLinkUrl] = React.useState("");
422
+ const [imageOpen, setImageOpen] = React.useState(false);
423
+ const [imageUrl, setImageUrl] = React.useState("");
424
+ const [imageAlt, setImageAlt] = React.useState("");
425
+ const [uploadingImage, setUploadingImage] = React.useState(false);
426
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null);
427
+
428
+ const resolvedFeatures = React.useMemo(
429
+ () => ({
430
+ ...defaultFeatures,
431
+ ...features,
432
+ }),
433
+ [features],
434
+ );
435
+
436
+ const allowImages = resolvedFeatures.image && (enableImages ?? true);
437
+ const allowLinks = resolvedFeatures.link;
438
+ const allowTables = resolvedFeatures.table;
439
+ const allowTableControls = resolvedFeatures.tableControls && allowTables;
440
+ const allowSlashCommands = resolvedFeatures.slashCommands;
441
+ const allowBubbleMenu = resolvedFeatures.bubbleMenu;
442
+ const allowToolbar = resolvedFeatures.toolbar;
443
+ const allowCharacterCount =
444
+ resolvedFeatures.characterCount && (showCharacterCount || maxCharacters);
445
+
446
+ const resolvedExtensions = React.useMemo(() => {
447
+ const starterKitConfig: Record<string, any> = {
448
+ codeBlock: false,
449
+ };
450
+
451
+ if (!resolvedFeatures.bold) starterKitConfig.bold = false;
452
+ if (!resolvedFeatures.italic) starterKitConfig.italic = false;
453
+ if (!resolvedFeatures.strike) starterKitConfig.strike = false;
454
+ if (!resolvedFeatures.code) starterKitConfig.code = false;
455
+ if (!resolvedFeatures.blockquote) starterKitConfig.blockquote = false;
456
+ if (!resolvedFeatures.heading) starterKitConfig.heading = false;
457
+ if (!resolvedFeatures.bulletList) starterKitConfig.bulletList = false;
458
+ if (!resolvedFeatures.orderedList) starterKitConfig.orderedList = false;
459
+ if (!resolvedFeatures.bulletList && !resolvedFeatures.orderedList) {
460
+ starterKitConfig.listItem = false;
461
+ }
462
+ if (!resolvedFeatures.horizontalRule)
463
+ starterKitConfig.horizontalRule = false;
464
+ if (!resolvedFeatures.history) starterKitConfig.history = false;
465
+
466
+ const items: any[] = [
467
+ StarterKit.configure(starterKitConfig),
468
+ Placeholder.configure({
469
+ placeholder: placeholder || "Start writing...",
470
+ }),
471
+ ];
472
+
473
+ if (resolvedFeatures.underline) {
474
+ items.push(Underline);
475
+ }
476
+
477
+ if (allowLinks) {
478
+ items.push(
479
+ Link.configure({
480
+ openOnClick: false,
481
+ autolink: true,
482
+ linkOnPaste: true,
483
+ }),
484
+ );
485
+ }
486
+
487
+ if (resolvedFeatures.align) {
488
+ items.push(TextAlign.configure({ types: ["heading", "paragraph"] }));
489
+ }
490
+
491
+ if (allowImages) {
492
+ items.push(Image);
493
+ }
494
+
495
+ if (allowTables) {
496
+ items.push(
497
+ Table.configure({ resizable: true }),
498
+ TableRow,
499
+ TableHeader,
500
+ TableCell,
501
+ );
502
+ }
503
+
504
+ if (resolvedFeatures.codeBlock) {
505
+ items.push(CodeBlockLowlight.configure({ lowlight }));
506
+ }
507
+
508
+ if (allowCharacterCount) {
509
+ items.push(
510
+ CharacterCount.configure({
511
+ limit: maxCharacters,
512
+ }),
513
+ );
514
+ }
515
+
516
+ if (allowSlashCommands) {
517
+ items.push(
518
+ createSlashCommandExtension((editor) => {
519
+ const commands: SlashCommandItem[] = [];
520
+
521
+ if (resolvedFeatures.heading) {
522
+ commands.push(
523
+ {
524
+ title: "Heading 1",
525
+ description: "Large section heading",
526
+ keywords: ["h1"],
527
+ command: (cmdEditor) =>
528
+ cmdEditor.chain().focus().toggleHeading({ level: 1 }).run(),
529
+ },
530
+ {
531
+ title: "Heading 2",
532
+ description: "Medium section heading",
533
+ keywords: ["h2"],
534
+ command: (cmdEditor) =>
535
+ cmdEditor.chain().focus().toggleHeading({ level: 2 }).run(),
536
+ },
537
+ {
538
+ title: "Heading 3",
539
+ description: "Small section heading",
540
+ keywords: ["h3"],
541
+ command: (cmdEditor) =>
542
+ cmdEditor.chain().focus().toggleHeading({ level: 3 }).run(),
543
+ },
544
+ );
545
+ }
546
+
547
+ commands.push({
548
+ title: "Paragraph",
549
+ description: "Start with plain text",
550
+ keywords: ["text"],
551
+ command: (cmdEditor) =>
552
+ cmdEditor.chain().focus().setParagraph().run(),
553
+ });
554
+
555
+ if (resolvedFeatures.bulletList) {
556
+ commands.push({
557
+ title: "Bullet list",
558
+ description: "Create a bulleted list",
559
+ keywords: ["list", "ul"],
560
+ command: (cmdEditor) =>
561
+ cmdEditor.chain().focus().toggleBulletList().run(),
562
+ });
563
+ }
564
+
565
+ if (resolvedFeatures.orderedList) {
566
+ commands.push({
567
+ title: "Numbered list",
568
+ description: "Create an ordered list",
569
+ keywords: ["list", "ol"],
570
+ command: (cmdEditor) =>
571
+ cmdEditor.chain().focus().toggleOrderedList().run(),
572
+ });
573
+ }
574
+
575
+ if (resolvedFeatures.blockquote) {
576
+ commands.push({
577
+ title: "Quote",
578
+ description: "Capture a quote",
579
+ keywords: ["blockquote"],
580
+ command: (cmdEditor) =>
581
+ cmdEditor.chain().focus().toggleBlockquote().run(),
582
+ });
583
+ }
584
+
585
+ if (resolvedFeatures.codeBlock) {
586
+ commands.push({
587
+ title: "Code block",
588
+ description: "Insert code snippet",
589
+ keywords: ["code"],
590
+ command: (cmdEditor) =>
591
+ cmdEditor.chain().focus().toggleCodeBlock().run(),
592
+ });
593
+ }
594
+
595
+ if (resolvedFeatures.horizontalRule) {
596
+ commands.push({
597
+ title: "Divider",
598
+ description: "Insert a horizontal rule",
599
+ keywords: ["hr"],
600
+ command: (cmdEditor) =>
601
+ cmdEditor.chain().focus().setHorizontalRule().run(),
602
+ });
603
+ }
604
+
605
+ if (allowTables) {
606
+ commands.push({
607
+ title: "Table",
608
+ description: "Insert a 3x3 table",
609
+ keywords: ["grid"],
610
+ command: (cmdEditor) =>
611
+ cmdEditor
612
+ .chain()
613
+ .focus()
614
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
615
+ .run(),
616
+ });
617
+ }
618
+
619
+ return commands;
620
+ }),
621
+ );
622
+ }
623
+
624
+ if (extensions?.length) {
625
+ items.push(...extensions);
626
+ }
627
+
628
+ return items;
629
+ }, [
630
+ allowCharacterCount,
631
+ allowImages,
632
+ allowLinks,
633
+ allowSlashCommands,
634
+ allowTables,
635
+ extensions,
636
+ maxCharacters,
637
+ placeholder,
638
+ resolvedFeatures,
639
+ ]);
640
+
641
+ const editor = useEditor({
642
+ extensions: resolvedExtensions,
643
+ content: value ?? "",
644
+ editorProps: {
645
+ attributes: {
646
+ class: "qp-rich-text-editor__content",
647
+ },
648
+ },
649
+ editable: !disabled && !readOnly,
650
+ onUpdate: ({ editor: currentEditor }) => {
651
+ if (disabled || readOnly) return;
652
+ const nextValue = getOutput(currentEditor, outputFormat);
653
+ onChange(nextValue as OutputValue);
654
+ },
655
+ });
656
+
657
+ const isEditable = !disabled && !readOnly;
658
+ const headingValue = getHeadingLevel(editor);
659
+ const inTable = editor?.isActive("table") ?? false;
660
+
661
+ React.useEffect(() => {
662
+ if (!editor) return;
663
+ editor.setEditable(isEditable);
664
+ }, [editor, isEditable]);
665
+
666
+ React.useEffect(() => {
667
+ if (!editor) return;
668
+ if (value === undefined) return;
669
+
670
+ const currentValue = getOutput(editor, outputFormat);
671
+ if (isSameValue(value as OutputValue, currentValue as OutputValue)) {
672
+ return;
673
+ }
674
+
675
+ editor.commands.setContent(value ?? "", false);
676
+ }, [editor, outputFormat, value]);
677
+
678
+ React.useEffect(() => {
679
+ if (!linkOpen || !editor) return;
680
+ const currentLink = editor.getAttributes("link").href as string | undefined;
681
+ setLinkUrl(currentLink || "");
682
+ }, [editor, linkOpen]);
683
+
684
+ const handleApplyLink = React.useCallback(() => {
685
+ if (!editor) return;
686
+ if (!linkUrl) {
687
+ editor.chain().focus().unsetLink().run();
688
+ setLinkOpen(false);
689
+ return;
690
+ }
691
+
692
+ editor
693
+ .chain()
694
+ .focus()
695
+ .setLink({
696
+ href: linkUrl,
697
+ target: "_blank",
698
+ rel: "noopener noreferrer",
699
+ })
700
+ .run();
701
+ setLinkOpen(false);
702
+ }, [editor, linkUrl]);
703
+
704
+ const handleRemoveLink = React.useCallback(() => {
705
+ if (!editor) return;
706
+ editor.chain().focus().unsetLink().run();
707
+ setLinkOpen(false);
708
+ }, [editor]);
709
+
710
+ const handleInsertImageUrl = React.useCallback(() => {
711
+ if (!editor || !imageUrl) return;
712
+ editor
713
+ .chain()
714
+ .focus()
715
+ .setImage({ src: imageUrl, alt: imageAlt || undefined })
716
+ .run();
717
+ setImageUrl("");
718
+ setImageAlt("");
719
+ setImageOpen(false);
720
+ }, [editor, imageAlt, imageUrl]);
721
+
722
+ const handleImageUpload = React.useCallback(
723
+ async (event: React.ChangeEvent<HTMLInputElement>) => {
724
+ const file = event.target.files?.[0];
725
+ if (!file || !editor || !onImageUpload) return;
726
+
727
+ try {
728
+ setUploadingImage(true);
729
+ const url = await onImageUpload(file);
730
+ if (url) {
731
+ editor
732
+ .chain()
733
+ .focus()
734
+ .setImage({ src: url, alt: imageAlt || undefined })
735
+ .run();
736
+ setImageUrl("");
737
+ setImageAlt("");
738
+ setImageOpen(false);
739
+ }
740
+ } finally {
741
+ setUploadingImage(false);
742
+ event.target.value = "";
743
+ }
744
+ },
745
+ [editor, imageAlt, onImageUpload],
746
+ );
747
+
748
+ const characterCount = React.useMemo(() => {
749
+ if (!editor) return { characters: 0, words: 0 };
750
+ const storage = editor.storage as any;
751
+ if (storage?.characterCount) {
752
+ return {
753
+ characters: storage.characterCount.characters(),
754
+ words: storage.characterCount.words(),
755
+ };
756
+ }
757
+ const text = editor.getText();
758
+ const words = text.trim().length ? text.trim().split(/\s+/).length : 0;
759
+ return { characters: text.length, words };
760
+ }, [editor, value]);
761
+
762
+ return (
763
+ <div className="space-y-2" data-disabled={disabled || readOnly}>
764
+ {label && (
765
+ <div className="flex items-center gap-2">
766
+ <Label htmlFor={name}>
767
+ {label}
768
+ {required && <span className="text-destructive ml-1">*</span>}
769
+ </Label>
770
+ {localized && <LocaleBadge locale={locale || "i18n"} />}
771
+ </div>
772
+ )}
773
+
774
+ <div
775
+ className={cn(
776
+ "qp-rich-text-editor rounded-md border bg-background",
777
+ disabled || readOnly ? "opacity-60" : "",
778
+ error ? "border-destructive" : "border-input",
779
+ )}
780
+ >
781
+ {editor && allowToolbar && (
782
+ <div className="flex flex-wrap items-center gap-2 border-b bg-muted/40 p-2">
783
+ {resolvedFeatures.history && (
784
+ <ToolbarGroup>
785
+ <ToolbarButton
786
+ disabled={!isEditable || !editor.can().undo()}
787
+ title="Undo"
788
+ onClick={() => editor.chain().focus().undo().run()}
789
+ >
790
+ Undo
791
+ </ToolbarButton>
792
+ <ToolbarButton
793
+ disabled={!isEditable || !editor.can().redo()}
794
+ title="Redo"
795
+ onClick={() => editor.chain().focus().redo().run()}
796
+ >
797
+ Redo
798
+ </ToolbarButton>
799
+ </ToolbarGroup>
800
+ )}
801
+
802
+ {resolvedFeatures.heading && (
803
+ <ToolbarGroup>
804
+ <select
805
+ className="h-6 rounded-sm border bg-background px-2 text-xs"
806
+ value={headingValue}
807
+ onChange={(event) => {
808
+ if (!editor) return;
809
+ const nextValue = event.target.value;
810
+ if (nextValue === "paragraph") {
811
+ editor.chain().focus().setParagraph().run();
812
+ return;
813
+ }
814
+ editor
815
+ .chain()
816
+ .focus()
817
+ .toggleHeading({
818
+ level: Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6,
819
+ })
820
+ .run();
821
+ }}
822
+ disabled={!isEditable}
823
+ >
824
+ <option value="paragraph">Paragraph</option>
825
+ <option value="1">Heading 1</option>
826
+ <option value="2">Heading 2</option>
827
+ <option value="3">Heading 3</option>
828
+ <option value="4">Heading 4</option>
829
+ <option value="5">Heading 5</option>
830
+ <option value="6">Heading 6</option>
831
+ </select>
832
+ </ToolbarGroup>
833
+ )}
834
+
835
+ <ToolbarGroup>
836
+ {resolvedFeatures.bold && (
837
+ <ToolbarButton
838
+ active={editor.isActive("bold")}
839
+ disabled={!isEditable}
840
+ title="Bold"
841
+ onClick={() => editor.chain().focus().toggleBold().run()}
842
+ >
843
+ Bold
844
+ </ToolbarButton>
845
+ )}
846
+ {resolvedFeatures.italic && (
847
+ <ToolbarButton
848
+ active={editor.isActive("italic")}
849
+ disabled={!isEditable}
850
+ title="Italic"
851
+ onClick={() => editor.chain().focus().toggleItalic().run()}
852
+ >
853
+ Italic
854
+ </ToolbarButton>
855
+ )}
856
+ {resolvedFeatures.underline && (
857
+ <ToolbarButton
858
+ active={editor.isActive("underline")}
859
+ disabled={!isEditable}
860
+ title="Underline"
861
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
862
+ >
863
+ Underline
864
+ </ToolbarButton>
865
+ )}
866
+ {resolvedFeatures.strike && (
867
+ <ToolbarButton
868
+ active={editor.isActive("strike")}
869
+ disabled={!isEditable}
870
+ title="Strikethrough"
871
+ onClick={() => editor.chain().focus().toggleStrike().run()}
872
+ >
873
+ Strike
874
+ </ToolbarButton>
875
+ )}
876
+ {resolvedFeatures.code && (
877
+ <ToolbarButton
878
+ active={editor.isActive("code")}
879
+ disabled={!isEditable}
880
+ title="Inline code"
881
+ onClick={() => editor.chain().focus().toggleCode().run()}
882
+ >
883
+ Code
884
+ </ToolbarButton>
885
+ )}
886
+ {resolvedFeatures.codeBlock && (
887
+ <ToolbarButton
888
+ active={editor.isActive("codeBlock")}
889
+ disabled={!isEditable}
890
+ title="Code block"
891
+ onClick={() => editor.chain().focus().toggleCodeBlock().run()}
892
+ >
893
+ Code Block
894
+ </ToolbarButton>
895
+ )}
896
+ </ToolbarGroup>
897
+
898
+ <ToolbarGroup>
899
+ {resolvedFeatures.bulletList && (
900
+ <ToolbarButton
901
+ active={editor.isActive("bulletList")}
902
+ disabled={!isEditable}
903
+ title="Bullet list"
904
+ onClick={() =>
905
+ editor.chain().focus().toggleBulletList().run()
906
+ }
907
+ >
908
+ Bullet List
909
+ </ToolbarButton>
910
+ )}
911
+ {resolvedFeatures.orderedList && (
912
+ <ToolbarButton
913
+ active={editor.isActive("orderedList")}
914
+ disabled={!isEditable}
915
+ title="Numbered list"
916
+ onClick={() =>
917
+ editor.chain().focus().toggleOrderedList().run()
918
+ }
919
+ >
920
+ Numbered List
921
+ </ToolbarButton>
922
+ )}
923
+ {resolvedFeatures.blockquote && (
924
+ <ToolbarButton
925
+ active={editor.isActive("blockquote")}
926
+ disabled={!isEditable}
927
+ title="Blockquote"
928
+ onClick={() =>
929
+ editor.chain().focus().toggleBlockquote().run()
930
+ }
931
+ >
932
+ Quote
933
+ </ToolbarButton>
934
+ )}
935
+ {resolvedFeatures.horizontalRule && (
936
+ <ToolbarButton
937
+ disabled={!isEditable}
938
+ title="Horizontal rule"
939
+ onClick={() =>
940
+ editor.chain().focus().setHorizontalRule().run()
941
+ }
942
+ >
943
+ Divider
944
+ </ToolbarButton>
945
+ )}
946
+ </ToolbarGroup>
947
+
948
+ {resolvedFeatures.align && (
949
+ <ToolbarGroup>
950
+ <ToolbarButton
951
+ active={editor.isActive({ textAlign: "left" })}
952
+ disabled={!isEditable}
953
+ title="Align left"
954
+ onClick={() =>
955
+ editor.chain().focus().setTextAlign("left").run()
956
+ }
957
+ >
958
+ Align Left
959
+ </ToolbarButton>
960
+ <ToolbarButton
961
+ active={editor.isActive({ textAlign: "center" })}
962
+ disabled={!isEditable}
963
+ title="Align center"
964
+ onClick={() =>
965
+ editor.chain().focus().setTextAlign("center").run()
966
+ }
967
+ >
968
+ Align Center
969
+ </ToolbarButton>
970
+ <ToolbarButton
971
+ active={editor.isActive({ textAlign: "right" })}
972
+ disabled={!isEditable}
973
+ title="Align right"
974
+ onClick={() =>
975
+ editor.chain().focus().setTextAlign("right").run()
976
+ }
977
+ >
978
+ Align Right
979
+ </ToolbarButton>
980
+ <ToolbarButton
981
+ active={editor.isActive({ textAlign: "justify" })}
982
+ disabled={!isEditable}
983
+ title="Align justify"
984
+ onClick={() =>
985
+ editor.chain().focus().setTextAlign("justify").run()
986
+ }
987
+ >
988
+ Justify
989
+ </ToolbarButton>
990
+ </ToolbarGroup>
991
+ )}
992
+
993
+ <ToolbarGroup>
994
+ {allowLinks && (
995
+ <Popover open={linkOpen} onOpenChange={setLinkOpen}>
996
+ <PopoverTrigger
997
+ render={
998
+ <Button
999
+ type="button"
1000
+ variant="ghost"
1001
+ size="xs"
1002
+ disabled={!isEditable}
1003
+ data-active={editor.isActive("link")}
1004
+ >
1005
+ Link
1006
+ </Button>
1007
+ }
1008
+ />
1009
+ <PopoverContent className="w-72">
1010
+ <PopoverHeader>
1011
+ <PopoverTitle>Insert link</PopoverTitle>
1012
+ </PopoverHeader>
1013
+ <div className="space-y-2">
1014
+ <Input
1015
+ value={linkUrl}
1016
+ placeholder="https://example.com"
1017
+ onChange={(event) => setLinkUrl(event.target.value)}
1018
+ disabled={!isEditable}
1019
+ />
1020
+ <div className="flex justify-end gap-2">
1021
+ <Button
1022
+ type="button"
1023
+ size="xs"
1024
+ variant="outline"
1025
+ onClick={handleRemoveLink}
1026
+ disabled={!isEditable || !editor.isActive("link")}
1027
+ >
1028
+ Remove
1029
+ </Button>
1030
+ <Button
1031
+ type="button"
1032
+ size="xs"
1033
+ onClick={handleApplyLink}
1034
+ disabled={!isEditable}
1035
+ >
1036
+ Apply
1037
+ </Button>
1038
+ </div>
1039
+ </div>
1040
+ </PopoverContent>
1041
+ </Popover>
1042
+ )}
1043
+
1044
+ {allowImages && (
1045
+ <Popover open={imageOpen} onOpenChange={setImageOpen}>
1046
+ <PopoverTrigger
1047
+ render={
1048
+ <Button
1049
+ type="button"
1050
+ variant="ghost"
1051
+ size="xs"
1052
+ disabled={!isEditable}
1053
+ >
1054
+ Image
1055
+ </Button>
1056
+ }
1057
+ />
1058
+ <PopoverContent className="w-80">
1059
+ <PopoverHeader>
1060
+ <PopoverTitle>Insert image</PopoverTitle>
1061
+ </PopoverHeader>
1062
+ <div className="space-y-3">
1063
+ <div className="space-y-2">
1064
+ <Input
1065
+ value={imageUrl}
1066
+ placeholder="https://example.com/image.jpg"
1067
+ onChange={(event) => setImageUrl(event.target.value)}
1068
+ disabled={!isEditable}
1069
+ />
1070
+ <Input
1071
+ value={imageAlt}
1072
+ placeholder="Alt text (optional)"
1073
+ onChange={(event) => setImageAlt(event.target.value)}
1074
+ disabled={!isEditable}
1075
+ />
1076
+ <div className="flex justify-end gap-2">
1077
+ <Button
1078
+ type="button"
1079
+ size="xs"
1080
+ onClick={handleInsertImageUrl}
1081
+ disabled={!isEditable || !imageUrl}
1082
+ >
1083
+ Insert URL
1084
+ </Button>
1085
+ </div>
1086
+ </div>
1087
+
1088
+ {onImageUpload && (
1089
+ <div className="space-y-2">
1090
+ <div className="text-xs font-medium">Upload file</div>
1091
+ <input
1092
+ ref={fileInputRef}
1093
+ type="file"
1094
+ accept="image/*"
1095
+ onChange={handleImageUpload}
1096
+ className="sr-only"
1097
+ disabled={!isEditable || uploadingImage}
1098
+ />
1099
+ <Button
1100
+ type="button"
1101
+ size="xs"
1102
+ variant="outline"
1103
+ onClick={() => fileInputRef.current?.click()}
1104
+ disabled={!isEditable || uploadingImage}
1105
+ >
1106
+ {uploadingImage ? "Uploading..." : "Choose file"}
1107
+ </Button>
1108
+ </div>
1109
+ )}
1110
+ </div>
1111
+ </PopoverContent>
1112
+ </Popover>
1113
+ )}
1114
+
1115
+ {allowTableControls && (
1116
+ <Popover>
1117
+ <PopoverTrigger
1118
+ render={
1119
+ <Button
1120
+ type="button"
1121
+ variant="ghost"
1122
+ size="xs"
1123
+ disabled={!isEditable}
1124
+ >
1125
+ Table
1126
+ </Button>
1127
+ }
1128
+ />
1129
+ <PopoverContent className="w-80">
1130
+ <PopoverHeader>
1131
+ <PopoverTitle>Table tools</PopoverTitle>
1132
+ </PopoverHeader>
1133
+ <div className="grid grid-cols-2 gap-2">
1134
+ <Button
1135
+ type="button"
1136
+ size="xs"
1137
+ onClick={() =>
1138
+ editor
1139
+ .chain()
1140
+ .focus()
1141
+ .insertTable({
1142
+ rows: 3,
1143
+ cols: 3,
1144
+ withHeaderRow: true,
1145
+ })
1146
+ .run()
1147
+ }
1148
+ disabled={!isEditable}
1149
+ >
1150
+ Insert table
1151
+ </Button>
1152
+ <Button
1153
+ type="button"
1154
+ size="xs"
1155
+ variant="outline"
1156
+ onClick={() =>
1157
+ editor.chain().focus().addRowBefore().run()
1158
+ }
1159
+ disabled={!isEditable || !inTable}
1160
+ >
1161
+ Add row before
1162
+ </Button>
1163
+ <Button
1164
+ type="button"
1165
+ size="xs"
1166
+ variant="outline"
1167
+ onClick={() =>
1168
+ editor.chain().focus().addRowAfter().run()
1169
+ }
1170
+ disabled={!isEditable || !inTable}
1171
+ >
1172
+ Add row after
1173
+ </Button>
1174
+ <Button
1175
+ type="button"
1176
+ size="xs"
1177
+ variant="outline"
1178
+ onClick={() =>
1179
+ editor.chain().focus().addColumnBefore().run()
1180
+ }
1181
+ disabled={!isEditable || !inTable}
1182
+ >
1183
+ Add column before
1184
+ </Button>
1185
+ <Button
1186
+ type="button"
1187
+ size="xs"
1188
+ variant="outline"
1189
+ onClick={() =>
1190
+ editor.chain().focus().addColumnAfter().run()
1191
+ }
1192
+ disabled={!isEditable || !inTable}
1193
+ >
1194
+ Add column after
1195
+ </Button>
1196
+ <Button
1197
+ type="button"
1198
+ size="xs"
1199
+ variant="outline"
1200
+ onClick={() => editor.chain().focus().deleteRow().run()}
1201
+ disabled={!isEditable || !inTable}
1202
+ >
1203
+ Delete row
1204
+ </Button>
1205
+ <Button
1206
+ type="button"
1207
+ size="xs"
1208
+ variant="outline"
1209
+ onClick={() =>
1210
+ editor.chain().focus().deleteColumn().run()
1211
+ }
1212
+ disabled={!isEditable || !inTable}
1213
+ >
1214
+ Delete column
1215
+ </Button>
1216
+ <Button
1217
+ type="button"
1218
+ size="xs"
1219
+ variant="outline"
1220
+ onClick={() =>
1221
+ editor.chain().focus().toggleHeaderRow().run()
1222
+ }
1223
+ disabled={!isEditable || !inTable}
1224
+ >
1225
+ Toggle header row
1226
+ </Button>
1227
+ <Button
1228
+ type="button"
1229
+ size="xs"
1230
+ variant="outline"
1231
+ onClick={() =>
1232
+ editor.chain().focus().toggleHeaderColumn().run()
1233
+ }
1234
+ disabled={!isEditable || !inTable}
1235
+ >
1236
+ Toggle header column
1237
+ </Button>
1238
+ <Button
1239
+ type="button"
1240
+ size="xs"
1241
+ variant="outline"
1242
+ onClick={() =>
1243
+ editor.chain().focus().mergeCells().run()
1244
+ }
1245
+ disabled={!isEditable || !inTable}
1246
+ >
1247
+ Merge cells
1248
+ </Button>
1249
+ <Button
1250
+ type="button"
1251
+ size="xs"
1252
+ variant="outline"
1253
+ onClick={() => editor.chain().focus().splitCell().run()}
1254
+ disabled={!isEditable || !inTable}
1255
+ >
1256
+ Split cell
1257
+ </Button>
1258
+ <Button
1259
+ type="button"
1260
+ size="xs"
1261
+ variant="outline"
1262
+ onClick={() =>
1263
+ editor.chain().focus().deleteTable().run()
1264
+ }
1265
+ disabled={!isEditable || !inTable}
1266
+ >
1267
+ Delete table
1268
+ </Button>
1269
+ </div>
1270
+ </PopoverContent>
1271
+ </Popover>
1272
+ )}
1273
+ </ToolbarGroup>
1274
+ </div>
1275
+ )}
1276
+
1277
+ {editor && allowBubbleMenu && (
1278
+ <BubbleMenu
1279
+ editor={editor}
1280
+ className="flex items-center gap-1 rounded-md border bg-background p-1 shadow"
1281
+ >
1282
+ {resolvedFeatures.bold && (
1283
+ <ToolbarButton
1284
+ active={editor.isActive("bold")}
1285
+ disabled={!isEditable}
1286
+ title="Bold"
1287
+ onClick={() => editor.chain().focus().toggleBold().run()}
1288
+ >
1289
+ Bold
1290
+ </ToolbarButton>
1291
+ )}
1292
+ {resolvedFeatures.italic && (
1293
+ <ToolbarButton
1294
+ active={editor.isActive("italic")}
1295
+ disabled={!isEditable}
1296
+ title="Italic"
1297
+ onClick={() => editor.chain().focus().toggleItalic().run()}
1298
+ >
1299
+ Italic
1300
+ </ToolbarButton>
1301
+ )}
1302
+ {resolvedFeatures.underline && (
1303
+ <ToolbarButton
1304
+ active={editor.isActive("underline")}
1305
+ disabled={!isEditable}
1306
+ title="Underline"
1307
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
1308
+ >
1309
+ Underline
1310
+ </ToolbarButton>
1311
+ )}
1312
+ </BubbleMenu>
1313
+ )}
1314
+
1315
+ <EditorContent editor={editor} id={name} />
1316
+
1317
+ {allowCharacterCount && showCharacterCount && (
1318
+ <div className="flex items-center justify-between border-t bg-muted/30 px-2 py-1 text-xs text-muted-foreground">
1319
+ <span>
1320
+ {characterCount.words} word{characterCount.words === 1 ? "" : "s"}
1321
+ </span>
1322
+ <span>
1323
+ {characterCount.characters}
1324
+ {typeof maxCharacters === "number" ? ` / ${maxCharacters}` : ""}
1325
+ characters
1326
+ </span>
1327
+ </div>
1328
+ )}
1329
+ </div>
1330
+
1331
+ {description && (
1332
+ <p className="text-muted-foreground text-xs">{description}</p>
1333
+ )}
1334
+ {error && <p className="text-destructive text-xs">{error}</p>}
1335
+ </div>
1336
+ );
1337
+ }