@justin_evo/evo-ui 1.1.0 → 1.2.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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. package/src/index.ts +60 -60
@@ -1,869 +1,886 @@
1
- import React, {
2
- forwardRef,
3
- useCallback,
4
- useEffect,
5
- useImperativeHandle,
6
- useMemo,
7
- useRef,
8
- useState,
9
- } from 'react';
10
- import styles from '../css/richtextarea.module.scss';
11
-
12
- // ----------------------------------------------------------------------------
13
- // Tool keys & types
14
- // ----------------------------------------------------------------------------
15
-
16
- export type EvoRichTextBuiltInTool =
17
- | 'bold'
18
- | 'italic'
19
- | 'underline'
20
- | 'strike'
21
- | 'h1'
22
- | 'h2'
23
- | 'h3'
24
- | 'paragraph'
25
- | 'ul'
26
- | 'ol'
27
- | 'quote'
28
- | 'code'
29
- | 'link'
30
- | 'image'
31
- | 'align-left'
32
- | 'align-center'
33
- | 'align-right'
34
- | 'clear'
35
- | 'undo'
36
- | 'redo'
37
- | 'divider';
38
-
39
- export interface EvoRichTextCustomTool {
40
- key: string;
41
- label: string;
42
- icon: React.ReactNode;
43
- onAction: (api: EvoRichTextHandle) => void;
44
- isActive?: () => boolean;
45
- }
46
-
47
- export type EvoRichTextTool = EvoRichTextBuiltInTool | EvoRichTextCustomTool;
48
-
49
- export interface EvoRichTextHandle {
50
- /** Returns the current HTML content. */
51
- getHTML: () => string;
52
- /** Replaces the editor content with the given HTML. */
53
- setHTML: (html: string) => void;
54
- /** Returns the current plain text content. */
55
- getText: () => string;
56
- /** Focuses the editor. */
57
- focus: () => void;
58
- /** Inserts an image at the caret. */
59
- insertImage: (src: string, alt?: string) => void;
60
- /** Inserts arbitrary HTML at the caret. */
61
- insertHTML: (html: string) => void;
62
- /** Clears all content. */
63
- clear: () => void;
64
- }
65
-
66
- export interface EvoRichTextAreaProps {
67
- /** Controlled HTML value. */
68
- value?: string;
69
- /** Initial HTML for uncontrolled use. */
70
- defaultValue?: string;
71
- /** Fires whenever the content changes. Receives sanitized HTML. */
72
- onChange?: (html: string) => void;
73
- /** Which tools to render in the toolbar. Pass [] for no toolbar. */
74
- tools?: EvoRichTextTool[];
75
- /** Placeholder shown when empty. */
76
- placeholder?: string;
77
- /** Minimum editor height (CSS). */
78
- minHeight?: number | string;
79
- /** Maximum editor height before it scrolls (CSS). */
80
- maxHeight?: number | string;
81
- /** Disables editing. */
82
- disabled?: boolean;
83
- /** Read-only — same look, no editing. */
84
- readOnly?: boolean;
85
- /** Optional label rendered above. */
86
- label?: string;
87
- /** Helper text rendered below (hidden if `error` set). */
88
- helperText?: string;
89
- /** Error message — also marks the field invalid. */
90
- error?: string;
91
- /** Stretch to container width. */
92
- fullWidth?: boolean;
93
- /** Custom upload handler. Resolve with the URL to embed. */
94
- onImageUpload?: (file: File) => Promise<string>;
95
- /** Accepted image MIME types for the file picker. */
96
- acceptedImageTypes?: string[];
97
- /** Maximum image size in bytes. Larger images are rejected. */
98
- maxImageSize?: number;
99
- /** Fires when an image fails validation or upload. */
100
- onImageError?: (error: { code: 'too-large' | 'wrong-type' | 'upload-failed'; message: string }) => void;
101
- /** Optional class name for the root. */
102
- className?: string;
103
- /** Optional id (forwarded to the editable surface). */
104
- id?: string;
105
- }
106
-
107
- // ----------------------------------------------------------------------------
108
- // Icons (inline, no deps — kept lightweight)
109
- // ----------------------------------------------------------------------------
110
-
111
- const Icon = ({ children }: { children: React.ReactNode }) => (
112
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
113
- {children}
114
- </svg>
115
- );
116
-
117
- const ICONS = {
118
- bold: <Icon><path d="M6 4h8a4 4 0 0 1 0 8H6z" /><path d="M6 12h9a4 4 0 0 1 0 8H6z" /></Icon>,
119
- italic: <Icon><line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" /></Icon>,
120
- underline: <Icon><path d="M6 3v7a6 6 0 0 0 12 0V3" /><line x1="4" y1="21" x2="20" y2="21" /></Icon>,
121
- strike: <Icon><path d="M16 4H9a3 3 0 0 0-2.83 4" /><path d="M14 12a4 4 0 0 1 0 8H6" /><line x1="4" y1="12" x2="20" y2="12" /></Icon>,
122
- h1: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M17 10l3-2v10" /></Icon>,
123
- h2: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" /></Icon>,
124
- h3: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" /><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" /></Icon>,
125
- paragraph: <Icon><path d="M13 4v16" /><path d="M17 4v16" /><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" /></Icon>,
126
- ul: <Icon><line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" /></Icon>,
127
- ol: <Icon><line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" /><path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></Icon>,
128
- quote: <Icon><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" /><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" /></Icon>,
129
- code: <Icon><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></Icon>,
130
- link: <Icon><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></Icon>,
131
- image: <Icon><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></Icon>,
132
- alignLeft: <Icon><line x1="17" y1="10" x2="3" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="17" y1="18" x2="3" y2="18" /></Icon>,
133
- alignCenter: <Icon><line x1="18" y1="10" x2="6" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="18" y1="18" x2="6" y2="18" /></Icon>,
134
- alignRight: <Icon><line x1="21" y1="10" x2="7" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="21" y1="18" x2="7" y2="18" /></Icon>,
135
- clear: <Icon><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></Icon>,
136
- undo: <Icon><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></Icon>,
137
- redo: <Icon><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></Icon>,
138
- };
139
-
140
- // ----------------------------------------------------------------------------
141
- // Built-in tool descriptors
142
- // ----------------------------------------------------------------------------
143
-
144
- interface BuiltInDescriptor {
145
- label: string;
146
- icon: React.ReactNode;
147
- command: string;
148
- arg?: string;
149
- shortcut?: string;
150
- query?: string; // queryCommandState key (defaults to command)
151
- }
152
-
153
- const BUILTINS: Record<Exclude<EvoRichTextBuiltInTool, 'image' | 'link' | 'divider'>, BuiltInDescriptor> = {
154
- bold: { label: 'Bold', icon: ICONS.bold, command: 'bold', shortcut: '⌘B' },
155
- italic: { label: 'Italic', icon: ICONS.italic, command: 'italic', shortcut: '⌘I' },
156
- underline: { label: 'Underline', icon: ICONS.underline, command: 'underline', shortcut: '⌘U' },
157
- strike: { label: 'Strikethrough', icon: ICONS.strike, command: 'strikeThrough', query: 'strikeThrough' },
158
- h1: { label: 'Heading 1', icon: ICONS.h1, command: 'formatBlock', arg: 'H1' },
159
- h2: { label: 'Heading 2', icon: ICONS.h2, command: 'formatBlock', arg: 'H2' },
160
- h3: { label: 'Heading 3', icon: ICONS.h3, command: 'formatBlock', arg: 'H3' },
161
- paragraph: { label: 'Paragraph', icon: ICONS.paragraph, command: 'formatBlock', arg: 'P' },
162
- ul: { label: 'Bulleted list', icon: ICONS.ul, command: 'insertUnorderedList' },
163
- ol: { label: 'Numbered list', icon: ICONS.ol, command: 'insertOrderedList' },
164
- quote: { label: 'Blockquote', icon: ICONS.quote, command: 'formatBlock', arg: 'BLOCKQUOTE' },
165
- code: { label: 'Inline code', icon: ICONS.code, command: 'formatBlock', arg: 'PRE' },
166
- 'align-left': { label: 'Align left', icon: ICONS.alignLeft, command: 'justifyLeft' },
167
- 'align-center': { label: 'Align center', icon: ICONS.alignCenter, command: 'justifyCenter' },
168
- 'align-right': { label: 'Align right', icon: ICONS.alignRight, command: 'justifyRight' },
169
- clear: { label: 'Clear formatting', icon: ICONS.clear, command: 'removeFormat' },
170
- undo: { label: 'Undo', icon: ICONS.undo, command: 'undo', shortcut: '⌘Z' },
171
- redo: { label: 'Redo', icon: ICONS.redo, command: 'redo', shortcut: '⇧⌘Z' },
172
- };
173
-
174
- // ----------------------------------------------------------------------------
175
- // Helpers
176
- // ----------------------------------------------------------------------------
177
-
178
- const DEFAULT_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
179
-
180
- function isCustomTool(t: EvoRichTextTool): t is EvoRichTextCustomTool {
181
- return typeof t === 'object' && t !== null;
182
- }
183
-
184
- function readFileAsDataURL(file: File): Promise<string> {
185
- return new Promise((resolve, reject) => {
186
- const reader = new FileReader();
187
- reader.onload = () => resolve(String(reader.result));
188
- reader.onerror = () => reject(reader.error);
189
- reader.readAsDataURL(file);
190
- });
191
- }
192
-
193
- function execCommand(command: string, arg?: string) {
194
- // execCommand is officially deprecated but remains the most pragmatic
195
- // way to do rich-text editing without a full editor framework.
196
- // All major browsers still support it.
197
- try {
198
- document.execCommand(command, false, arg);
199
- } catch {
200
- /* no-op */
201
- }
202
- }
203
-
204
- // Tags that count as a block-level child — used to decide whether unwrapping a
205
- // block should lift its children out or wrap loose inline content in a <p>.
206
- const BLOCK_CHILD_TAGS = /^(?:P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL|LI|TABLE)$/;
207
-
208
- /**
209
- * Nearest ancestor of the caret matching `selector` (a tag or comma-separated
210
- * tag list), scoped to the editor. Null if the caret sits outside the editor.
211
- */
212
- function blockAncestor(editor: HTMLElement, selector: string): HTMLElement | null {
213
- const sel = window.getSelection();
214
- if (!sel || sel.rangeCount === 0) return null;
215
- const node = sel.getRangeAt(0).startContainer;
216
- const start = node.nodeType === 1 ? (node as Element) : node.parentElement;
217
- let match: Element | null = null;
218
- try {
219
- match = start?.closest(selector) ?? null;
220
- } catch {
221
- return null;
222
- }
223
- return match && editor.contains(match) && match !== editor ? (match as HTMLElement) : null;
224
- }
225
-
226
- /**
227
- * Removes every block wrapper matching `selector` around the caret, replacing
228
- * each with a plain <p> (or unwrapping it when it already holds block-level
229
- * children, to avoid invalid <p> nesting).
230
- *
231
- * This exists because `execCommand('formatBlock', 'P')` cannot reliably *remove*
232
- * a <blockquote>/<pre> — Blink nests a <p> inside it instead — so block tools
233
- * could be switched on but never off, and the wrapper's CSS kept styling the
234
- * content. A temporary marker node preserves the caret across the DOM moves.
235
- */
236
- function unwrapBlocks(editor: HTMLElement, selector: string): boolean {
237
- const sel = window.getSelection();
238
- if (!sel || sel.rangeCount === 0) return false;
239
- const range = sel.getRangeAt(0);
240
- const startNode = range.startContainer;
241
- const startEl = startNode.nodeType === 1 ? (startNode as Element) : startNode.parentElement;
242
- if (!startEl) return false;
243
-
244
- // Collect every matching wrapper between the caret and the editor root.
245
- const targets: HTMLElement[] = [];
246
- let cur: HTMLElement | null = null;
247
- try {
248
- cur = startEl.closest(selector) as HTMLElement | null;
249
- } catch {
250
- return false;
251
- }
252
- while (cur && editor.contains(cur) && cur !== editor) {
253
- targets.push(cur);
254
- cur = (cur.parentElement?.closest(selector) ?? null) as HTMLElement | null;
255
- }
256
- if (targets.length === 0) return false;
257
-
258
- // Drop a marker at the caret so it survives the restructuring below.
259
- const marker = document.createElement('span');
260
- marker.setAttribute('data-evo-caret', '');
261
- range.insertNode(marker);
262
-
263
- targets.forEach((el) => {
264
- const hasBlockChild = Array.from(el.children).some((c) => BLOCK_CHILD_TAGS.test(c.tagName));
265
- if (hasBlockChild) {
266
- el.replaceWith(...Array.from(el.childNodes));
267
- } else {
268
- const p = document.createElement('p');
269
- while (el.firstChild) p.appendChild(el.firstChild);
270
- if (!p.childNodes.length) p.appendChild(document.createElement('br'));
271
- el.replaceWith(p);
272
- }
273
- });
274
-
275
- // Collapse the caret back onto the marker, then remove it.
276
- const placed = editor.querySelector('span[data-evo-caret]');
277
- if (placed) {
278
- const host = placed.parentElement;
279
- const restored = document.createRange();
280
- restored.setStartBefore(placed);
281
- restored.collapse(true);
282
- sel.removeAllRanges();
283
- sel.addRange(restored);
284
- placed.remove();
285
- // A now-empty paragraph collapses in contentEditable — keep it caret-able.
286
- if (host && !host.childNodes.length) host.appendChild(document.createElement('br'));
287
- }
288
- return true;
289
- }
290
-
291
- /**
292
- * Splits a fragment into top-level block elements: loose inline runs are wrapped
293
- * in <p> and <div> is normalised to <p>. Used to re-home content pulled out of a
294
- * code block / blockquote when Enter exits it.
295
- */
296
- function fragmentToBlocks(fragment: DocumentFragment): HTMLElement[] {
297
- const BLOCK = /^(?:P|DIV|H[1-6]|UL|OL|PRE|BLOCKQUOTE|TABLE)$/;
298
- const blocks: HTMLElement[] = [];
299
- let run: Node[] = [];
300
- const flush = () => {
301
- if (!run.length) return;
302
- const p = document.createElement('p');
303
- run.forEach((n) => p.appendChild(n));
304
- run = [];
305
- blocks.push(p);
306
- };
307
- Array.from(fragment.childNodes).forEach((node) => {
308
- if (node.nodeType === 1 && BLOCK.test((node as Element).tagName)) {
309
- flush();
310
- let el = node as HTMLElement;
311
- if (el.tagName === 'DIV') {
312
- const p = document.createElement('p');
313
- while (el.firstChild) p.appendChild(el.firstChild);
314
- el = p;
315
- }
316
- blocks.push(el);
317
- } else {
318
- run.push(node);
319
- }
320
- });
321
- flush();
322
- // Empty paragraphs collapse in contentEditable — keep each one caret-able.
323
- blocks.forEach((b) => {
324
- if (!b.textContent && !b.querySelector('br,img')) b.appendChild(document.createElement('br'));
325
- });
326
- return blocks;
327
- }
328
-
329
- /**
330
- * If the caret sits inside a code block (<pre>) or blockquote, splits it at the
331
- * caret: content from the caret onward moves into fresh paragraph(s) placed
332
- * right after the block, and the caret follows. Returns true if it acted (the
333
- * caller then preventDefaults).
334
- *
335
- * Both are single-line on plain Enter by design — Enter leaves the block instead
336
- * of extending it, so the next line never inherits the block style. Shift+Enter
337
- * is left to the browser as a soft line break for intentional multi-line code /
338
- * multi-paragraph quotes.
339
- */
340
- function exitBlockOnEnter(editor: HTMLElement): boolean {
341
- const sel = window.getSelection();
342
- if (!sel || sel.rangeCount === 0) return false;
343
- const range = sel.getRangeAt(0);
344
- if (!range.collapsed) return false; // leave range-Enter to the browser
345
-
346
- const startNode = range.startContainer;
347
- const startEl = startNode.nodeType === 1 ? (startNode as Element) : startNode.parentElement;
348
- const block = (startEl?.closest('pre,blockquote') ?? null) as HTMLElement | null;
349
- if (!block || !editor.contains(block) || block === editor) return false;
350
-
351
- // Pull everything from the caret to the end of the block into sibling blocks.
352
- const tail = document.createRange();
353
- tail.setStart(range.startContainer, range.startOffset);
354
- tail.setEnd(block, block.childNodes.length);
355
- const blocks = fragmentToBlocks(tail.extractContents());
356
- if (blocks.length === 0) {
357
- const empty = document.createElement('p');
358
- empty.appendChild(document.createElement('br'));
359
- blocks.push(empty);
360
- }
361
-
362
- let anchor: Element = block;
363
- blocks.forEach((b) => {
364
- anchor.after(b);
365
- anchor = b;
366
- });
367
- if (!block.firstChild) block.remove(); // caret was at the very start — no empty block
368
-
369
- const caret = document.createRange();
370
- caret.setStart(blocks[0], 0);
371
- caret.collapse(true);
372
- sel.removeAllRanges();
373
- sel.addRange(caret);
374
- return true;
375
- }
376
-
377
- // ----------------------------------------------------------------------------
378
- // Component
379
- // ----------------------------------------------------------------------------
380
-
381
- const DEFAULT_TOOLS: EvoRichTextTool[] = ['bold', 'italic', 'underline', 'divider', 'h1', 'h2', 'divider', 'ul', 'ol', 'divider', 'link', 'image'];
382
-
383
- export const EvoRichTextArea = forwardRef<EvoRichTextHandle, EvoRichTextAreaProps>(function EvoRichTextArea(
384
- {
385
- value,
386
- defaultValue,
387
- onChange,
388
- tools = DEFAULT_TOOLS,
389
- placeholder = 'Start writing…',
390
- minHeight = 160,
391
- maxHeight,
392
- disabled = false,
393
- readOnly = false,
394
- label,
395
- helperText,
396
- error,
397
- fullWidth = false,
398
- onImageUpload,
399
- acceptedImageTypes = DEFAULT_IMAGE_TYPES,
400
- maxImageSize,
401
- onImageError,
402
- className = '',
403
- id,
404
- },
405
- forwardedRef,
406
- ) {
407
- const editorRef = useRef<HTMLDivElement | null>(null);
408
- const fileInputRef = useRef<HTMLInputElement | null>(null);
409
- const lastEmittedHTML = useRef<string>('');
410
- const isControlled = value !== undefined;
411
-
412
- const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
413
- const [isEmpty, setIsEmpty] = useState(true);
414
- const [isDragOver, setIsDragOver] = useState(false);
415
- const [linkPrompt, setLinkPrompt] = useState<{ url: string; range: Range | null } | null>(null);
416
-
417
- const editorId = id ?? label?.toLowerCase().replace(/\s+/g, '-') ?? undefined;
418
-
419
- // ---- Sync controlled value into the DOM (only when external) ----
420
- useEffect(() => {
421
- const el = editorRef.current;
422
- if (!el) return;
423
-
424
- if (isControlled) {
425
- if (value !== lastEmittedHTML.current) {
426
- el.innerHTML = value ?? '';
427
- lastEmittedHTML.current = value ?? '';
428
- setIsEmpty(!el.textContent?.trim());
429
- }
430
- } else if (defaultValue && !lastEmittedHTML.current) {
431
- el.innerHTML = defaultValue;
432
- lastEmittedHTML.current = defaultValue;
433
- setIsEmpty(!el.textContent?.trim());
434
- }
435
- }, [value, defaultValue, isControlled]);
436
-
437
- // ---- Refresh active states for toolbar ----
438
- const refreshActiveStates = useCallback(() => {
439
- const editor = editorRef.current;
440
- const next: Record<string, boolean> = {};
441
- Object.entries(BUILTINS).forEach(([key, desc]) => {
442
- try {
443
- if (desc.command === 'formatBlock') {
444
- // DOM-based detection: `formatBlock` can leave a <blockquote>/<pre>
445
- // wrapper that `queryCommandValue` doesn't report, which would desync
446
- // the toolbar highlight from what a click actually does.
447
- if (!editor) {
448
- next[key] = false;
449
- } else if ((desc.arg ?? '').toUpperCase() === 'P') {
450
- // "Paragraph" is active only when no stronger block wraps the caret.
451
- next[key] =
452
- !!blockAncestor(editor, 'P,DIV') &&
453
- !blockAncestor(editor, 'BLOCKQUOTE,PRE,H1,H2,H3,H4,H5,H6');
454
- } else {
455
- next[key] = !!blockAncestor(editor, desc.arg ?? '');
456
- }
457
- } else {
458
- next[key] = document.queryCommandState(desc.query ?? desc.command);
459
- }
460
- } catch {
461
- next[key] = false;
462
- }
463
- });
464
- setActiveStates(next);
465
- }, []);
466
-
467
- // ---- Emit changes ----
468
- const emitChange = useCallback(() => {
469
- const el = editorRef.current;
470
- if (!el) return;
471
- const html = el.innerHTML;
472
- lastEmittedHTML.current = html;
473
- setIsEmpty(!el.textContent?.trim() && !el.querySelector('img'));
474
- onChange?.(html);
475
- refreshActiveStates();
476
- }, [onChange, refreshActiveStates]);
477
-
478
- // ---- Imperative handle ----
479
- useImperativeHandle(forwardedRef, () => ({
480
- getHTML: () => editorRef.current?.innerHTML ?? '',
481
- setHTML: (html: string) => {
482
- const el = editorRef.current;
483
- if (!el) return;
484
- el.innerHTML = html;
485
- emitChange();
486
- },
487
- getText: () => editorRef.current?.textContent ?? '',
488
- focus: () => editorRef.current?.focus(),
489
- insertImage: (src: string, alt = '') => insertImageAtCaret(src, alt),
490
- insertHTML: (html: string) => {
491
- editorRef.current?.focus();
492
- execCommand('insertHTML', html);
493
- emitChange();
494
- },
495
- clear: () => {
496
- const el = editorRef.current;
497
- if (!el) return;
498
- el.innerHTML = '';
499
- emitChange();
500
- },
501
- }), [emitChange]);
502
-
503
- // ---- Insert image (used by paste, drop, button) ----
504
- const insertImageAtCaret = useCallback((src: string, alt = '') => {
505
- editorRef.current?.focus();
506
- const html = `<img src="${src.replace(/"/g, '&quot;')}" alt="${alt.replace(/"/g, '&quot;')}" />`;
507
- execCommand('insertHTML', html);
508
- emitChange();
509
- }, [emitChange]);
510
-
511
- // ---- Image upload pipeline (file -> URL) ----
512
- const handleImageFile = useCallback(async (file: File) => {
513
- if (!acceptedImageTypes.includes(file.type)) {
514
- onImageError?.({ code: 'wrong-type', message: `Unsupported image type: ${file.type}` });
515
- return;
516
- }
517
- if (maxImageSize && file.size > maxImageSize) {
518
- onImageError?.({ code: 'too-large', message: `Image exceeds ${(maxImageSize / 1024 / 1024).toFixed(1)} MB` });
519
- return;
520
- }
521
- try {
522
- const url = onImageUpload ? await onImageUpload(file) : await readFileAsDataURL(file);
523
- insertImageAtCaret(url, file.name);
524
- } catch (err) {
525
- onImageError?.({ code: 'upload-failed', message: err instanceof Error ? err.message : 'Upload failed' });
526
- }
527
- }, [acceptedImageTypes, maxImageSize, onImageUpload, onImageError, insertImageAtCaret]);
528
-
529
- // ---- Toolbar actions ----
530
- const runBuiltin = useCallback((key: Exclude<EvoRichTextBuiltInTool, 'divider' | 'image' | 'link'>) => {
531
- const editor = editorRef.current;
532
- editor?.focus();
533
- const desc = BUILTINS[key];
534
- if (!desc) return;
535
- if (desc.command === 'formatBlock' && editor) {
536
- // `formatBlock` can *apply* a block but cannot *remove* one — re-clicking
537
- // an active block tool must toggle it off, and `formatBlock('P')` would
538
- // only nest a <p> inside the surviving <blockquote>/<pre>. So toggle off
539
- // by unwrapping the block in the DOM; when switching to a different block
540
- // first strip whatever wrapper is there, since a stale one would survive.
541
- const target = desc.arg ?? '';
542
- if (target.toUpperCase() !== 'P' && blockAncestor(editor, target)) {
543
- unwrapBlocks(editor, target);
544
- } else {
545
- unwrapBlocks(editor, 'BLOCKQUOTE,PRE,H1,H2,H3,H4,H5,H6');
546
- execCommand('formatBlock', target);
547
- }
548
- } else {
549
- execCommand(desc.command, desc.arg);
550
- }
551
- emitChange();
552
- }, [emitChange]);
553
-
554
- const openImagePicker = useCallback(() => {
555
- fileInputRef.current?.click();
556
- }, []);
557
-
558
- const startLinkPrompt = useCallback(() => {
559
- const sel = window.getSelection();
560
- const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
561
- setLinkPrompt({ url: 'https://', range });
562
- }, []);
563
-
564
- const applyLink = useCallback(() => {
565
- if (!linkPrompt) return;
566
- const { url, range } = linkPrompt;
567
- editorRef.current?.focus();
568
- if (range) {
569
- const sel = window.getSelection();
570
- sel?.removeAllRanges();
571
- sel?.addRange(range);
572
- }
573
- if (url && url !== 'https://') {
574
- execCommand('createLink', url);
575
- // Make new links open in a new tab by default
576
- const links = editorRef.current?.querySelectorAll<HTMLAnchorElement>('a[href="' + url + '"]');
577
- links?.forEach((a) => a.setAttribute('target', '_blank'));
578
- }
579
- setLinkPrompt(null);
580
- emitChange();
581
- }, [linkPrompt, emitChange]);
582
-
583
- // ---- Event handlers on the editable surface ----
584
- const handleInput = useCallback(() => {
585
- emitChange();
586
- }, [emitChange]);
587
-
588
- const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
589
- // Plain Enter inside a code block or blockquote exits to a fresh paragraph —
590
- // both are single-line on Enter by design, so the next line never inherits
591
- // the block style. Shift+Enter is left to the browser as a soft line break.
592
- if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
593
- const editor = editorRef.current;
594
- if (editor && exitBlockOnEnter(editor)) {
595
- e.preventDefault();
596
- emitChange();
597
- return;
598
- }
599
- }
600
-
601
- const meta = e.metaKey || e.ctrlKey;
602
- if (!meta) return;
603
- const k = e.key.toLowerCase();
604
- if (k === 'b') { e.preventDefault(); runBuiltin('bold'); }
605
- else if (k === 'i') { e.preventDefault(); runBuiltin('italic'); }
606
- else if (k === 'u') { e.preventDefault(); runBuiltin('underline'); }
607
- }, [runBuiltin, emitChange]);
608
-
609
- const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLDivElement>) => {
610
- const items = e.clipboardData?.items;
611
- if (!items) return;
612
-
613
- // 1. Image paste — the headline feature TipTap omits.
614
- for (let i = 0; i < items.length; i++) {
615
- const item = items[i];
616
- if (item.type.startsWith('image/')) {
617
- e.preventDefault();
618
- const file = item.getAsFile();
619
- if (file) await handleImageFile(file);
620
- return;
621
- }
622
- }
623
-
624
- // 2. Plain text fallback — keep paste clean, strip foreign styles.
625
- const text = e.clipboardData?.getData('text/plain');
626
- if (text !== undefined && text !== '') {
627
- e.preventDefault();
628
- execCommand('insertText', text);
629
- }
630
- }, [handleImageFile]);
631
-
632
- const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
633
- if (e.dataTransfer.types.includes('Files')) {
634
- e.preventDefault();
635
- setIsDragOver(true);
636
- }
637
- }, []);
638
-
639
- const handleDragLeave = useCallback(() => setIsDragOver(false), []);
640
-
641
- const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
642
- setIsDragOver(false);
643
- const files = Array.from(e.dataTransfer.files ?? []).filter((f) => f.type.startsWith('image/'));
644
- if (files.length === 0) return;
645
- e.preventDefault();
646
- for (const file of files) {
647
- // eslint-disable-next-line no-await-in-loop
648
- await handleImageFile(file);
649
- }
650
- }, [handleImageFile]);
651
-
652
- const handleFilesPicked = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
653
- const files = Array.from(e.target.files ?? []);
654
- e.target.value = '';
655
- for (const file of files) {
656
- // eslint-disable-next-line no-await-in-loop
657
- await handleImageFile(file);
658
- }
659
- }, [handleImageFile]);
660
-
661
- // Refresh active states on selection change while focused
662
- useEffect(() => {
663
- const handler = () => {
664
- const el = editorRef.current;
665
- if (!el) return;
666
- if (document.activeElement === el || el.contains(document.activeElement)) {
667
- refreshActiveStates();
668
- }
669
- };
670
- document.addEventListener('selectionchange', handler);
671
- return () => document.removeEventListener('selectionchange', handler);
672
- }, [refreshActiveStates]);
673
-
674
- // ---- Render the toolbar ----
675
- const renderedTools = useMemo(() => {
676
- const showToolbar = tools.length > 0;
677
- if (!showToolbar) return null;
678
-
679
- return (
680
- <div className={styles.toolbar} role="toolbar" aria-label="Formatting toolbar">
681
- {tools.map((tool, idx) => {
682
- if (tool === 'divider') {
683
- return <span key={`d-${idx}`} className={styles.divider} aria-hidden="true" />;
684
- }
685
- if (isCustomTool(tool)) {
686
- const active = tool.isActive?.() ?? false;
687
- return (
688
- <button
689
- key={tool.key}
690
- type="button"
691
- className={[styles.toolBtn, active ? styles.toolBtnActive : ''].filter(Boolean).join(' ')}
692
- title={tool.label}
693
- aria-label={tool.label}
694
- aria-pressed={active}
695
- disabled={disabled || readOnly}
696
- onMouseDown={(e) => e.preventDefault()}
697
- onClick={() => tool.onAction(getHandle())}
698
- >
699
- {tool.icon}
700
- </button>
701
- );
702
- }
703
- if (tool === 'image') {
704
- return (
705
- <button
706
- key="image"
707
- type="button"
708
- className={styles.toolBtn}
709
- title="Insert image"
710
- aria-label="Insert image"
711
- disabled={disabled || readOnly}
712
- onMouseDown={(e) => e.preventDefault()}
713
- onClick={openImagePicker}
714
- >
715
- {ICONS.image}
716
- </button>
717
- );
718
- }
719
- if (tool === 'link') {
720
- return (
721
- <button
722
- key="link"
723
- type="button"
724
- className={styles.toolBtn}
725
- title="Insert link"
726
- aria-label="Insert link"
727
- disabled={disabled || readOnly}
728
- onMouseDown={(e) => e.preventDefault()}
729
- onClick={startLinkPrompt}
730
- >
731
- {ICONS.link}
732
- </button>
733
- );
734
- }
735
- const desc = BUILTINS[tool];
736
- if (!desc) return null;
737
- const active = !!activeStates[tool];
738
- return (
739
- <button
740
- key={tool}
741
- type="button"
742
- className={[styles.toolBtn, active ? styles.toolBtnActive : ''].filter(Boolean).join(' ')}
743
- title={`${desc.label}${desc.shortcut ? ` (${desc.shortcut})` : ''}`}
744
- aria-label={desc.label}
745
- aria-pressed={active}
746
- disabled={disabled || readOnly}
747
- onMouseDown={(e) => e.preventDefault()}
748
- onClick={() => runBuiltin(tool)}
749
- >
750
- {desc.icon}
751
- </button>
752
- );
753
- })}
754
- </div>
755
- );
756
- // eslint-disable-next-line react-hooks/exhaustive-deps
757
- }, [tools, activeStates, disabled, readOnly, openImagePicker, startLinkPrompt, runBuiltin]);
758
-
759
- // Get a fresh handle for custom tool callbacks
760
- const getHandle = useCallback((): EvoRichTextHandle => ({
761
- getHTML: () => editorRef.current?.innerHTML ?? '',
762
- setHTML: (html: string) => {
763
- const el = editorRef.current;
764
- if (!el) return;
765
- el.innerHTML = html;
766
- emitChange();
767
- },
768
- getText: () => editorRef.current?.textContent ?? '',
769
- focus: () => editorRef.current?.focus(),
770
- insertImage: (src: string, alt = '') => insertImageAtCaret(src, alt),
771
- insertHTML: (html: string) => {
772
- editorRef.current?.focus();
773
- execCommand('insertHTML', html);
774
- emitChange();
775
- },
776
- clear: () => {
777
- const el = editorRef.current;
778
- if (!el) return;
779
- el.innerHTML = '';
780
- emitChange();
781
- },
782
- }), [emitChange, insertImageAtCaret]);
783
-
784
- const heightStyle: React.CSSProperties = {
785
- minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight,
786
- maxHeight: maxHeight !== undefined ? (typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight) : undefined,
787
- };
788
-
789
- return (
790
- <div className={[styles.field, fullWidth ? styles.fullWidth : '', className].filter(Boolean).join(' ')}>
791
- {label && (
792
- <label htmlFor={editorId} className={styles.label}>
793
- {label}
794
- </label>
795
- )}
796
-
797
- <div
798
- className={[
799
- styles.shell,
800
- error ? styles.hasError : '',
801
- disabled ? styles.disabled : '',
802
- isDragOver ? styles.dragOver : '',
803
- ].filter(Boolean).join(' ')}
804
- >
805
- {renderedTools}
806
-
807
- <div
808
- ref={editorRef}
809
- id={editorId}
810
- className={[styles.editor, isEmpty ? styles.editorEmpty : ''].filter(Boolean).join(' ')}
811
- contentEditable={!disabled && !readOnly}
812
- suppressContentEditableWarning
813
- role="textbox"
814
- aria-multiline="true"
815
- aria-label={label ?? 'Rich text editor'}
816
- aria-invalid={!!error}
817
- aria-readonly={readOnly}
818
- aria-disabled={disabled}
819
- data-placeholder={placeholder}
820
- style={heightStyle}
821
- onInput={handleInput}
822
- onKeyDown={handleKeyDown}
823
- onPaste={handlePaste}
824
- onDragOver={handleDragOver}
825
- onDragLeave={handleDragLeave}
826
- onDrop={handleDrop}
827
- onBlur={refreshActiveStates}
828
- />
829
-
830
- {isDragOver && (
831
- <div className={styles.dropOverlay} aria-hidden="true">
832
- <span>Drop image to upload</span>
833
- </div>
834
- )}
835
-
836
- {linkPrompt && (
837
- <div className={styles.linkPrompt} role="dialog" aria-label="Insert link">
838
- <input
839
- type="url"
840
- className={styles.linkInput}
841
- value={linkPrompt.url}
842
- autoFocus
843
- onChange={(e) => setLinkPrompt({ ...linkPrompt, url: e.target.value })}
844
- onKeyDown={(e) => {
845
- if (e.key === 'Enter') { e.preventDefault(); applyLink(); }
846
- else if (e.key === 'Escape') { e.preventDefault(); setLinkPrompt(null); }
847
- }}
848
- placeholder="https://example.com"
849
- />
850
- <button type="button" className={styles.linkBtn} onClick={applyLink}>Apply</button>
851
- <button type="button" className={styles.linkBtnGhost} onClick={() => setLinkPrompt(null)}>Cancel</button>
852
- </div>
853
- )}
854
-
855
- <input
856
- ref={fileInputRef}
857
- type="file"
858
- accept={acceptedImageTypes.join(',')}
859
- multiple
860
- hidden
861
- onChange={handleFilesPicked}
862
- />
863
- </div>
864
-
865
- {error && <p className={styles.errorText}>{error}</p>}
866
- {!error && helperText && <p className={styles.helperText}>{helperText}</p>}
867
- </div>
868
- );
869
- });
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import styles from '../css/richtextarea.module.scss';
11
+
12
+ // ----------------------------------------------------------------------------
13
+ // Tool keys & types
14
+ // ----------------------------------------------------------------------------
15
+
16
+ export type EvoRichTextBuiltInTool =
17
+ | 'bold'
18
+ | 'italic'
19
+ | 'underline'
20
+ | 'strike'
21
+ | 'h1'
22
+ | 'h2'
23
+ | 'h3'
24
+ | 'paragraph'
25
+ | 'ul'
26
+ | 'ol'
27
+ | 'quote'
28
+ | 'code'
29
+ | 'link'
30
+ | 'image'
31
+ | 'align-left'
32
+ | 'align-center'
33
+ | 'align-right'
34
+ | 'clear'
35
+ | 'undo'
36
+ | 'redo'
37
+ | 'divider';
38
+
39
+ export interface EvoRichTextCustomTool {
40
+ key: string;
41
+ label: string;
42
+ icon: React.ReactNode;
43
+ onAction: (api: EvoRichTextHandle) => void;
44
+ isActive?: () => boolean;
45
+ }
46
+
47
+ export type EvoRichTextTool = EvoRichTextBuiltInTool | EvoRichTextCustomTool;
48
+
49
+ export interface EvoRichTextHandle {
50
+ /** Returns the current HTML content. */
51
+ getHTML: () => string;
52
+ /** Replaces the editor content with the given HTML. */
53
+ setHTML: (html: string) => void;
54
+ /** Returns the current plain text content. */
55
+ getText: () => string;
56
+ /** Focuses the editor. */
57
+ focus: () => void;
58
+ /** Inserts an image at the caret. */
59
+ insertImage: (src: string, alt?: string) => void;
60
+ /** Inserts arbitrary HTML at the caret. */
61
+ insertHTML: (html: string) => void;
62
+ /** Clears all content. */
63
+ clear: () => void;
64
+ }
65
+
66
+ export interface EvoRichTextAreaProps {
67
+ /** Controlled HTML value. */
68
+ value?: string;
69
+ /** Initial HTML for uncontrolled use. */
70
+ defaultValue?: string;
71
+ /** Fires whenever the content changes. Receives sanitized HTML. */
72
+ onChange?: (html: string) => void;
73
+ /** Which tools to render in the toolbar. Pass [] for no toolbar. */
74
+ tools?: EvoRichTextTool[];
75
+ /** Placeholder shown when empty. */
76
+ placeholder?: string;
77
+ /** Minimum editor height (CSS). */
78
+ minHeight?: number | string;
79
+ /** Maximum editor height before it scrolls (CSS). */
80
+ maxHeight?: number | string;
81
+ /** Disables editing. */
82
+ disabled?: boolean;
83
+ /** Read-only — same look, no editing. */
84
+ readOnly?: boolean;
85
+ /** Optional label rendered above. */
86
+ label?: string;
87
+ /** Helper text rendered below (hidden if `error` set). */
88
+ helperText?: string;
89
+ /** Error message — also marks the field invalid. */
90
+ error?: string;
91
+ /** Stretch to container width. */
92
+ fullWidth?: boolean;
93
+ /** Custom upload handler. Resolve with the URL to embed. */
94
+ onImageUpload?: (file: File) => Promise<string>;
95
+ /** Accepted image MIME types for the file picker. */
96
+ acceptedImageTypes?: string[];
97
+ /** Maximum image size in bytes. Larger images are rejected. */
98
+ maxImageSize?: number;
99
+ /** Fires when an image fails validation or upload. */
100
+ onImageError?: (error: { code: 'too-large' | 'wrong-type' | 'upload-failed'; message: string }) => void;
101
+ /** Optional class name for the root. */
102
+ className?: string;
103
+ /** Optional id (forwarded to the editable surface). */
104
+ id?: string;
105
+ }
106
+
107
+ // ----------------------------------------------------------------------------
108
+ // Icons (inline, no deps — kept lightweight)
109
+ // ----------------------------------------------------------------------------
110
+
111
+ const Icon = ({ children }: { children: React.ReactNode }) => (
112
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
113
+ {children}
114
+ </svg>
115
+ );
116
+
117
+ const ICONS = {
118
+ bold: <Icon><path d="M6 4h8a4 4 0 0 1 0 8H6z" /><path d="M6 12h9a4 4 0 0 1 0 8H6z" /></Icon>,
119
+ italic: <Icon><line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" /></Icon>,
120
+ underline: <Icon><path d="M6 3v7a6 6 0 0 0 12 0V3" /><line x1="4" y1="21" x2="20" y2="21" /></Icon>,
121
+ strike: <Icon><path d="M16 4H9a3 3 0 0 0-2.83 4" /><path d="M14 12a4 4 0 0 1 0 8H6" /><line x1="4" y1="12" x2="20" y2="12" /></Icon>,
122
+ h1: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M17 10l3-2v10" /></Icon>,
123
+ h2: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1" /></Icon>,
124
+ h3: <Icon><path d="M4 12h8" /><path d="M4 18V6" /><path d="M12 18V6" /><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2" /><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2" /></Icon>,
125
+ paragraph: <Icon><path d="M13 4v16" /><path d="M17 4v16" /><path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" /></Icon>,
126
+ ul: <Icon><line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" /></Icon>,
127
+ ol: <Icon><line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" /><path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></Icon>,
128
+ quote: <Icon><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" /><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" /></Icon>,
129
+ code: <Icon><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></Icon>,
130
+ link: <Icon><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></Icon>,
131
+ image: <Icon><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></Icon>,
132
+ alignLeft: <Icon><line x1="17" y1="10" x2="3" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="17" y1="18" x2="3" y2="18" /></Icon>,
133
+ alignCenter: <Icon><line x1="18" y1="10" x2="6" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="18" y1="18" x2="6" y2="18" /></Icon>,
134
+ alignRight: <Icon><line x1="21" y1="10" x2="7" y2="10" /><line x1="21" y1="6" x2="3" y2="6" /><line x1="21" y1="14" x2="3" y2="14" /><line x1="21" y1="18" x2="7" y2="18" /></Icon>,
135
+ clear: <Icon><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></Icon>,
136
+ undo: <Icon><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></Icon>,
137
+ redo: <Icon><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></Icon>,
138
+ };
139
+
140
+ // ----------------------------------------------------------------------------
141
+ // Built-in tool descriptors
142
+ // ----------------------------------------------------------------------------
143
+
144
+ interface BuiltInDescriptor {
145
+ label: string;
146
+ icon: React.ReactNode;
147
+ command: string;
148
+ arg?: string;
149
+ shortcut?: string;
150
+ query?: string; // queryCommandState key (defaults to command)
151
+ }
152
+
153
+ const BUILTINS: Record<Exclude<EvoRichTextBuiltInTool, 'image' | 'link' | 'divider'>, BuiltInDescriptor> = {
154
+ bold: { label: 'Bold', icon: ICONS.bold, command: 'bold', shortcut: '⌘B' },
155
+ italic: { label: 'Italic', icon: ICONS.italic, command: 'italic', shortcut: '⌘I' },
156
+ underline: { label: 'Underline', icon: ICONS.underline, command: 'underline', shortcut: '⌘U' },
157
+ strike: { label: 'Strikethrough', icon: ICONS.strike, command: 'strikeThrough', query: 'strikeThrough' },
158
+ h1: { label: 'Heading 1', icon: ICONS.h1, command: 'formatBlock', arg: 'H1' },
159
+ h2: { label: 'Heading 2', icon: ICONS.h2, command: 'formatBlock', arg: 'H2' },
160
+ h3: { label: 'Heading 3', icon: ICONS.h3, command: 'formatBlock', arg: 'H3' },
161
+ paragraph: { label: 'Paragraph', icon: ICONS.paragraph, command: 'formatBlock', arg: 'P' },
162
+ ul: { label: 'Bulleted list', icon: ICONS.ul, command: 'insertUnorderedList' },
163
+ ol: { label: 'Numbered list', icon: ICONS.ol, command: 'insertOrderedList' },
164
+ quote: { label: 'Blockquote', icon: ICONS.quote, command: 'formatBlock', arg: 'BLOCKQUOTE' },
165
+ code: { label: 'Inline code', icon: ICONS.code, command: 'formatBlock', arg: 'PRE' },
166
+ 'align-left': { label: 'Align left', icon: ICONS.alignLeft, command: 'justifyLeft' },
167
+ 'align-center': { label: 'Align center', icon: ICONS.alignCenter, command: 'justifyCenter' },
168
+ 'align-right': { label: 'Align right', icon: ICONS.alignRight, command: 'justifyRight' },
169
+ clear: { label: 'Clear formatting', icon: ICONS.clear, command: 'removeFormat' },
170
+ undo: { label: 'Undo', icon: ICONS.undo, command: 'undo', shortcut: '⌘Z' },
171
+ redo: { label: 'Redo', icon: ICONS.redo, command: 'redo', shortcut: '⇧⌘Z' },
172
+ };
173
+
174
+ // ----------------------------------------------------------------------------
175
+ // Helpers
176
+ // ----------------------------------------------------------------------------
177
+
178
+ const DEFAULT_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
179
+
180
+ function isCustomTool(t: EvoRichTextTool): t is EvoRichTextCustomTool {
181
+ return typeof t === 'object' && t !== null;
182
+ }
183
+
184
+ function readFileAsDataURL(file: File): Promise<string> {
185
+ return new Promise((resolve, reject) => {
186
+ const reader = new FileReader();
187
+ reader.onload = () => resolve(String(reader.result));
188
+ reader.onerror = () => reject(reader.error);
189
+ reader.readAsDataURL(file);
190
+ });
191
+ }
192
+
193
+ function execCommand(command: string, arg?: string) {
194
+ // execCommand is officially deprecated but remains the most pragmatic
195
+ // way to do rich-text editing without a full editor framework.
196
+ // All major browsers still support it.
197
+ try {
198
+ document.execCommand(command, false, arg);
199
+ } catch {
200
+ /* no-op */
201
+ }
202
+ }
203
+
204
+ // Tags that count as a block-level child — used to decide whether unwrapping a
205
+ // block should lift its children out or wrap loose inline content in a <p>.
206
+ const BLOCK_CHILD_TAGS = /^(?:P|DIV|H[1-6]|BLOCKQUOTE|PRE|UL|OL|LI|TABLE)$/;
207
+
208
+ /**
209
+ * Nearest ancestor of the caret matching `selector` (a tag or comma-separated
210
+ * tag list), scoped to the editor. Null if the caret sits outside the editor.
211
+ */
212
+ function blockAncestor(editor: HTMLElement, selector: string): HTMLElement | null {
213
+ const sel = window.getSelection();
214
+ if (!sel || sel.rangeCount === 0) return null;
215
+ const node = sel.getRangeAt(0).startContainer;
216
+ const start = node.nodeType === 1 ? (node as Element) : node.parentElement;
217
+ let match: Element | null = null;
218
+ try {
219
+ match = start?.closest(selector) ?? null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ return match && editor.contains(match) && match !== editor ? (match as HTMLElement) : null;
224
+ }
225
+
226
+ /**
227
+ * Removes every block wrapper matching `selector` around the caret, replacing
228
+ * each with a plain <p> (or unwrapping it when it already holds block-level
229
+ * children, to avoid invalid <p> nesting).
230
+ *
231
+ * This exists because `execCommand('formatBlock', 'P')` cannot reliably *remove*
232
+ * a <blockquote>/<pre> — Blink nests a <p> inside it instead — so block tools
233
+ * could be switched on but never off, and the wrapper's CSS kept styling the
234
+ * content. A temporary marker node preserves the caret across the DOM moves.
235
+ */
236
+ function unwrapBlocks(editor: HTMLElement, selector: string): boolean {
237
+ const sel = window.getSelection();
238
+ if (!sel || sel.rangeCount === 0) return false;
239
+ const range = sel.getRangeAt(0);
240
+ const startNode = range.startContainer;
241
+ const startEl = startNode.nodeType === 1 ? (startNode as Element) : startNode.parentElement;
242
+ if (!startEl) return false;
243
+
244
+ // Collect every matching wrapper between the caret and the editor root.
245
+ const targets: HTMLElement[] = [];
246
+ let cur: HTMLElement | null = null;
247
+ try {
248
+ cur = startEl.closest(selector) as HTMLElement | null;
249
+ } catch {
250
+ return false;
251
+ }
252
+ while (cur && editor.contains(cur) && cur !== editor) {
253
+ targets.push(cur);
254
+ cur = (cur.parentElement?.closest(selector) ?? null) as HTMLElement | null;
255
+ }
256
+ if (targets.length === 0) return false;
257
+
258
+ // Drop a marker at the caret so it survives the restructuring below.
259
+ const marker = document.createElement('span');
260
+ marker.setAttribute('data-evo-caret', '');
261
+ range.insertNode(marker);
262
+
263
+ targets.forEach((el) => {
264
+ const hasBlockChild = Array.from(el.children).some((c) => BLOCK_CHILD_TAGS.test(c.tagName));
265
+ if (hasBlockChild) {
266
+ el.replaceWith(...Array.from(el.childNodes));
267
+ } else {
268
+ const p = document.createElement('p');
269
+ while (el.firstChild) p.appendChild(el.firstChild);
270
+ if (!p.childNodes.length) p.appendChild(document.createElement('br'));
271
+ el.replaceWith(p);
272
+ }
273
+ });
274
+
275
+ // Collapse the caret back onto the marker, then remove it.
276
+ const placed = editor.querySelector('span[data-evo-caret]');
277
+ if (placed) {
278
+ const host = placed.parentElement;
279
+ const restored = document.createRange();
280
+ restored.setStartBefore(placed);
281
+ restored.collapse(true);
282
+ sel.removeAllRanges();
283
+ sel.addRange(restored);
284
+ placed.remove();
285
+ // A now-empty paragraph collapses in contentEditable — keep it caret-able.
286
+ if (host && !host.childNodes.length) host.appendChild(document.createElement('br'));
287
+ }
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * Splits a fragment into top-level block elements: loose inline runs are wrapped
293
+ * in <p> and <div> is normalised to <p>. Used to re-home content pulled out of a
294
+ * code block / blockquote when Enter exits it.
295
+ */
296
+ function fragmentToBlocks(fragment: DocumentFragment): HTMLElement[] {
297
+ const BLOCK = /^(?:P|DIV|H[1-6]|UL|OL|PRE|BLOCKQUOTE|TABLE)$/;
298
+ const blocks: HTMLElement[] = [];
299
+ let run: Node[] = [];
300
+ const flush = () => {
301
+ if (!run.length) return;
302
+ const p = document.createElement('p');
303
+ run.forEach((n) => p.appendChild(n));
304
+ run = [];
305
+ blocks.push(p);
306
+ };
307
+ Array.from(fragment.childNodes).forEach((node) => {
308
+ if (node.nodeType === 1 && BLOCK.test((node as Element).tagName)) {
309
+ flush();
310
+ let el = node as HTMLElement;
311
+ if (el.tagName === 'DIV') {
312
+ const p = document.createElement('p');
313
+ while (el.firstChild) p.appendChild(el.firstChild);
314
+ el = p;
315
+ }
316
+ blocks.push(el);
317
+ } else {
318
+ run.push(node);
319
+ }
320
+ });
321
+ flush();
322
+ // Empty paragraphs collapse in contentEditable — keep each one caret-able.
323
+ blocks.forEach((b) => {
324
+ if (!b.textContent && !b.querySelector('br,img')) b.appendChild(document.createElement('br'));
325
+ });
326
+ return blocks;
327
+ }
328
+
329
+ /**
330
+ * If the caret sits inside a code block (<pre>) or blockquote, splits it at the
331
+ * caret: content from the caret onward moves into fresh paragraph(s) placed
332
+ * right after the block, and the caret follows. Returns true if it acted (the
333
+ * caller then preventDefaults).
334
+ *
335
+ * Both are single-line on plain Enter by design — Enter leaves the block instead
336
+ * of extending it, so the next line never inherits the block style. Shift+Enter
337
+ * is left to the browser as a soft line break for intentional multi-line code /
338
+ * multi-paragraph quotes.
339
+ */
340
+ function exitBlockOnEnter(editor: HTMLElement): boolean {
341
+ const sel = window.getSelection();
342
+ if (!sel || sel.rangeCount === 0) return false;
343
+ const range = sel.getRangeAt(0);
344
+ if (!range.collapsed) return false; // leave range-Enter to the browser
345
+
346
+ const startNode = range.startContainer;
347
+ const startEl = startNode.nodeType === 1 ? (startNode as Element) : startNode.parentElement;
348
+ const block = (startEl?.closest('pre,blockquote') ?? null) as HTMLElement | null;
349
+ if (!block || !editor.contains(block) || block === editor) return false;
350
+
351
+ // Pull everything from the caret to the end of the block into sibling blocks.
352
+ const tail = document.createRange();
353
+ tail.setStart(range.startContainer, range.startOffset);
354
+ tail.setEnd(block, block.childNodes.length);
355
+ const blocks = fragmentToBlocks(tail.extractContents());
356
+ if (blocks.length === 0) {
357
+ const empty = document.createElement('p');
358
+ empty.appendChild(document.createElement('br'));
359
+ blocks.push(empty);
360
+ }
361
+
362
+ let anchor: Element = block;
363
+ blocks.forEach((b) => {
364
+ anchor.after(b);
365
+ anchor = b;
366
+ });
367
+ if (!block.firstChild) block.remove(); // caret was at the very start — no empty block
368
+
369
+ const caret = document.createRange();
370
+ caret.setStart(blocks[0], 0);
371
+ caret.collapse(true);
372
+ sel.removeAllRanges();
373
+ sel.addRange(caret);
374
+ return true;
375
+ }
376
+
377
+ // ----------------------------------------------------------------------------
378
+ // Component
379
+ // ----------------------------------------------------------------------------
380
+
381
+ const DEFAULT_TOOLS: EvoRichTextTool[] = ['bold', 'italic', 'underline', 'divider', 'h1', 'h2', 'divider', 'ul', 'ol', 'divider', 'link', 'image'];
382
+
383
+ export const EvoRichTextArea = forwardRef<EvoRichTextHandle, EvoRichTextAreaProps>(function EvoRichTextArea(
384
+ {
385
+ value,
386
+ defaultValue,
387
+ onChange,
388
+ tools = DEFAULT_TOOLS,
389
+ placeholder = 'Start writing…',
390
+ minHeight = 160,
391
+ maxHeight,
392
+ disabled = false,
393
+ readOnly = false,
394
+ label,
395
+ helperText,
396
+ error,
397
+ fullWidth = false,
398
+ onImageUpload,
399
+ acceptedImageTypes = DEFAULT_IMAGE_TYPES,
400
+ maxImageSize,
401
+ onImageError,
402
+ className = '',
403
+ id,
404
+ },
405
+ forwardedRef,
406
+ ) {
407
+ const editorRef = useRef<HTMLDivElement | null>(null);
408
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
409
+ const lastEmittedHTML = useRef<string>('');
410
+ const isControlled = value !== undefined;
411
+
412
+ const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
413
+ const [isEmpty, setIsEmpty] = useState(true);
414
+ const [isDragOver, setIsDragOver] = useState(false);
415
+ const [linkPrompt, setLinkPrompt] = useState<{ url: string; range: Range | null } | null>(null);
416
+
417
+ const editorId = id ?? label?.toLowerCase().replace(/\s+/g, '-') ?? undefined;
418
+
419
+ // ---- Sync controlled value into the DOM (only when external) ----
420
+ useEffect(() => {
421
+ const el = editorRef.current;
422
+ if (!el) return;
423
+
424
+ if (isControlled) {
425
+ if (value !== lastEmittedHTML.current) {
426
+ el.innerHTML = value ?? '';
427
+ lastEmittedHTML.current = value ?? '';
428
+ setIsEmpty(!el.textContent?.trim());
429
+ }
430
+ } else if (defaultValue && !lastEmittedHTML.current) {
431
+ el.innerHTML = defaultValue;
432
+ lastEmittedHTML.current = defaultValue;
433
+ setIsEmpty(!el.textContent?.trim());
434
+ }
435
+ }, [value, defaultValue, isControlled]);
436
+
437
+ // ---- Refresh active states for toolbar ----
438
+ const refreshActiveStates = useCallback(() => {
439
+ const editor = editorRef.current;
440
+ const next: Record<string, boolean> = {};
441
+ Object.entries(BUILTINS).forEach(([key, desc]) => {
442
+ try {
443
+ if (desc.command === 'formatBlock') {
444
+ // DOM-based detection: `formatBlock` can leave a <blockquote>/<pre>
445
+ // wrapper that `queryCommandValue` doesn't report, which would desync
446
+ // the toolbar highlight from what a click actually does.
447
+ if (!editor) {
448
+ next[key] = false;
449
+ } else if ((desc.arg ?? '').toUpperCase() === 'P') {
450
+ // "Paragraph" is active only when no stronger block wraps the caret.
451
+ next[key] =
452
+ !!blockAncestor(editor, 'P,DIV') &&
453
+ !blockAncestor(editor, 'BLOCKQUOTE,PRE,H1,H2,H3,H4,H5,H6');
454
+ } else {
455
+ next[key] = !!blockAncestor(editor, desc.arg ?? '');
456
+ }
457
+ } else {
458
+ next[key] = document.queryCommandState(desc.query ?? desc.command);
459
+ }
460
+ } catch {
461
+ next[key] = false;
462
+ }
463
+ });
464
+ setActiveStates(next);
465
+ }, []);
466
+
467
+ // ---- Emit changes ----
468
+ const emitChange = useCallback(() => {
469
+ const el = editorRef.current;
470
+ if (!el) return;
471
+ const html = el.innerHTML;
472
+ lastEmittedHTML.current = html;
473
+ setIsEmpty(!el.textContent?.trim() && !el.querySelector('img'));
474
+ onChange?.(html);
475
+ refreshActiveStates();
476
+ }, [onChange, refreshActiveStates]);
477
+
478
+ // ---- Imperative handle ----
479
+ useImperativeHandle(forwardedRef, () => ({
480
+ getHTML: () => editorRef.current?.innerHTML ?? '',
481
+ setHTML: (html: string) => {
482
+ const el = editorRef.current;
483
+ if (!el) return;
484
+ el.innerHTML = html;
485
+ emitChange();
486
+ },
487
+ getText: () => editorRef.current?.textContent ?? '',
488
+ focus: () => editorRef.current?.focus(),
489
+ insertImage: (src: string, alt = '') => insertImageAtCaret(src, alt),
490
+ insertHTML: (html: string) => {
491
+ editorRef.current?.focus();
492
+ execCommand('insertHTML', html);
493
+ emitChange();
494
+ },
495
+ clear: () => {
496
+ const el = editorRef.current;
497
+ if (!el) return;
498
+ el.innerHTML = '';
499
+ emitChange();
500
+ },
501
+ }), [emitChange]);
502
+
503
+ // ---- Insert image (used by paste, drop, button) ----
504
+ const insertImageAtCaret = useCallback((src: string, alt = '') => {
505
+ const el = editorRef.current;
506
+ if (!el) return;
507
+ el.focus();
508
+ const safeSrc = src.replace(/"/g, '&quot;');
509
+ const safeAlt = alt.replace(/"/g, '&quot;');
510
+ // Images render display:block; inserting a bare <img> leaves the caret
511
+ // beside it, which paints at the image's top-right edge. Drop a trailing
512
+ // empty paragraph and move the caret into it, so the user lands on a clean
513
+ // new line *below* the image. (Marker idiom matches unwrapBlocks above.)
514
+ execCommand('insertHTML', `<img src="${safeSrc}" alt="${safeAlt}" /><p data-evo-caret><br></p>`);
515
+ const landing = el.querySelector<HTMLParagraphElement>('p[data-evo-caret]');
516
+ if (landing) {
517
+ landing.removeAttribute('data-evo-caret');
518
+ const sel = window.getSelection();
519
+ const r = document.createRange();
520
+ r.setStart(landing, 0);
521
+ r.collapse(true);
522
+ sel?.removeAllRanges();
523
+ sel?.addRange(r);
524
+ }
525
+ emitChange();
526
+ }, [emitChange]);
527
+
528
+ // ---- Image upload pipeline (file -> URL) ----
529
+ const handleImageFile = useCallback(async (file: File) => {
530
+ if (!acceptedImageTypes.includes(file.type)) {
531
+ onImageError?.({ code: 'wrong-type', message: `Unsupported image type: ${file.type}` });
532
+ return;
533
+ }
534
+ if (maxImageSize && file.size > maxImageSize) {
535
+ onImageError?.({ code: 'too-large', message: `Image exceeds ${(maxImageSize / 1024 / 1024).toFixed(1)} MB` });
536
+ return;
537
+ }
538
+ try {
539
+ const url = onImageUpload ? await onImageUpload(file) : await readFileAsDataURL(file);
540
+ insertImageAtCaret(url, file.name);
541
+ } catch (err) {
542
+ onImageError?.({ code: 'upload-failed', message: err instanceof Error ? err.message : 'Upload failed' });
543
+ }
544
+ }, [acceptedImageTypes, maxImageSize, onImageUpload, onImageError, insertImageAtCaret]);
545
+
546
+ // ---- Toolbar actions ----
547
+ const runBuiltin = useCallback((key: Exclude<EvoRichTextBuiltInTool, 'divider' | 'image' | 'link'>) => {
548
+ const editor = editorRef.current;
549
+ editor?.focus();
550
+ const desc = BUILTINS[key];
551
+ if (!desc) return;
552
+ if (desc.command === 'formatBlock' && editor) {
553
+ // `formatBlock` can *apply* a block but cannot *remove* one — re-clicking
554
+ // an active block tool must toggle it off, and `formatBlock('P')` would
555
+ // only nest a <p> inside the surviving <blockquote>/<pre>. So toggle off
556
+ // by unwrapping the block in the DOM; when switching to a different block
557
+ // first strip whatever wrapper is there, since a stale one would survive.
558
+ const target = desc.arg ?? '';
559
+ if (target.toUpperCase() !== 'P' && blockAncestor(editor, target)) {
560
+ unwrapBlocks(editor, target);
561
+ } else {
562
+ unwrapBlocks(editor, 'BLOCKQUOTE,PRE,H1,H2,H3,H4,H5,H6');
563
+ execCommand('formatBlock', target);
564
+ }
565
+ } else {
566
+ execCommand(desc.command, desc.arg);
567
+ }
568
+ emitChange();
569
+ }, [emitChange]);
570
+
571
+ const openImagePicker = useCallback(() => {
572
+ fileInputRef.current?.click();
573
+ }, []);
574
+
575
+ const startLinkPrompt = useCallback(() => {
576
+ const sel = window.getSelection();
577
+ const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
578
+ setLinkPrompt({ url: 'https://', range });
579
+ }, []);
580
+
581
+ const applyLink = useCallback(() => {
582
+ if (!linkPrompt) return;
583
+ const { url, range } = linkPrompt;
584
+ editorRef.current?.focus();
585
+ if (range) {
586
+ const sel = window.getSelection();
587
+ sel?.removeAllRanges();
588
+ sel?.addRange(range);
589
+ }
590
+ if (url && url !== 'https://') {
591
+ execCommand('createLink', url);
592
+ // Make new links open in a new tab by default
593
+ const links = editorRef.current?.querySelectorAll<HTMLAnchorElement>('a[href="' + url + '"]');
594
+ links?.forEach((a) => a.setAttribute('target', '_blank'));
595
+ }
596
+ setLinkPrompt(null);
597
+ emitChange();
598
+ }, [linkPrompt, emitChange]);
599
+
600
+ // ---- Event handlers on the editable surface ----
601
+ const handleInput = useCallback(() => {
602
+ emitChange();
603
+ }, [emitChange]);
604
+
605
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
606
+ // Plain Enter inside a code block or blockquote exits to a fresh paragraph —
607
+ // both are single-line on Enter by design, so the next line never inherits
608
+ // the block style. Shift+Enter is left to the browser as a soft line break.
609
+ if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
610
+ const editor = editorRef.current;
611
+ if (editor && exitBlockOnEnter(editor)) {
612
+ e.preventDefault();
613
+ emitChange();
614
+ return;
615
+ }
616
+ }
617
+
618
+ const meta = e.metaKey || e.ctrlKey;
619
+ if (!meta) return;
620
+ const k = e.key.toLowerCase();
621
+ if (k === 'b') { e.preventDefault(); runBuiltin('bold'); }
622
+ else if (k === 'i') { e.preventDefault(); runBuiltin('italic'); }
623
+ else if (k === 'u') { e.preventDefault(); runBuiltin('underline'); }
624
+ }, [runBuiltin, emitChange]);
625
+
626
+ const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLDivElement>) => {
627
+ const items = e.clipboardData?.items;
628
+ if (!items) return;
629
+
630
+ // 1. Image paste — the headline feature TipTap omits.
631
+ for (let i = 0; i < items.length; i++) {
632
+ const item = items[i];
633
+ if (item.type.startsWith('image/')) {
634
+ e.preventDefault();
635
+ const file = item.getAsFile();
636
+ if (file) await handleImageFile(file);
637
+ return;
638
+ }
639
+ }
640
+
641
+ // 2. Plain text fallback keep paste clean, strip foreign styles.
642
+ const text = e.clipboardData?.getData('text/plain');
643
+ if (text !== undefined && text !== '') {
644
+ e.preventDefault();
645
+ execCommand('insertText', text);
646
+ }
647
+ }, [handleImageFile]);
648
+
649
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
650
+ if (e.dataTransfer.types.includes('Files')) {
651
+ e.preventDefault();
652
+ setIsDragOver(true);
653
+ }
654
+ }, []);
655
+
656
+ const handleDragLeave = useCallback(() => setIsDragOver(false), []);
657
+
658
+ const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
659
+ setIsDragOver(false);
660
+ const files = Array.from(e.dataTransfer.files ?? []).filter((f) => f.type.startsWith('image/'));
661
+ if (files.length === 0) return;
662
+ e.preventDefault();
663
+ for (const file of files) {
664
+ // eslint-disable-next-line no-await-in-loop
665
+ await handleImageFile(file);
666
+ }
667
+ }, [handleImageFile]);
668
+
669
+ const handleFilesPicked = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
670
+ const files = Array.from(e.target.files ?? []);
671
+ e.target.value = '';
672
+ for (const file of files) {
673
+ // eslint-disable-next-line no-await-in-loop
674
+ await handleImageFile(file);
675
+ }
676
+ }, [handleImageFile]);
677
+
678
+ // Refresh active states on selection change while focused
679
+ useEffect(() => {
680
+ const handler = () => {
681
+ const el = editorRef.current;
682
+ if (!el) return;
683
+ if (document.activeElement === el || el.contains(document.activeElement)) {
684
+ refreshActiveStates();
685
+ }
686
+ };
687
+ document.addEventListener('selectionchange', handler);
688
+ return () => document.removeEventListener('selectionchange', handler);
689
+ }, [refreshActiveStates]);
690
+
691
+ // ---- Render the toolbar ----
692
+ const renderedTools = useMemo(() => {
693
+ const showToolbar = tools.length > 0;
694
+ if (!showToolbar) return null;
695
+
696
+ return (
697
+ <div className={styles.toolbar} role="toolbar" aria-label="Formatting toolbar">
698
+ {tools.map((tool, idx) => {
699
+ if (tool === 'divider') {
700
+ return <span key={`d-${idx}`} className={styles.divider} aria-hidden="true" />;
701
+ }
702
+ if (isCustomTool(tool)) {
703
+ const active = tool.isActive?.() ?? false;
704
+ return (
705
+ <button
706
+ key={tool.key}
707
+ type="button"
708
+ className={[styles.toolBtn, active ? styles.toolBtnActive : ''].filter(Boolean).join(' ')}
709
+ title={tool.label}
710
+ aria-label={tool.label}
711
+ aria-pressed={active}
712
+ disabled={disabled || readOnly}
713
+ onMouseDown={(e) => e.preventDefault()}
714
+ onClick={() => tool.onAction(getHandle())}
715
+ >
716
+ {tool.icon}
717
+ </button>
718
+ );
719
+ }
720
+ if (tool === 'image') {
721
+ return (
722
+ <button
723
+ key="image"
724
+ type="button"
725
+ className={styles.toolBtn}
726
+ title="Insert image"
727
+ aria-label="Insert image"
728
+ disabled={disabled || readOnly}
729
+ onMouseDown={(e) => e.preventDefault()}
730
+ onClick={openImagePicker}
731
+ >
732
+ {ICONS.image}
733
+ </button>
734
+ );
735
+ }
736
+ if (tool === 'link') {
737
+ return (
738
+ <button
739
+ key="link"
740
+ type="button"
741
+ className={styles.toolBtn}
742
+ title="Insert link"
743
+ aria-label="Insert link"
744
+ disabled={disabled || readOnly}
745
+ onMouseDown={(e) => e.preventDefault()}
746
+ onClick={startLinkPrompt}
747
+ >
748
+ {ICONS.link}
749
+ </button>
750
+ );
751
+ }
752
+ const desc = BUILTINS[tool];
753
+ if (!desc) return null;
754
+ const active = !!activeStates[tool];
755
+ return (
756
+ <button
757
+ key={tool}
758
+ type="button"
759
+ className={[styles.toolBtn, active ? styles.toolBtnActive : ''].filter(Boolean).join(' ')}
760
+ title={`${desc.label}${desc.shortcut ? ` (${desc.shortcut})` : ''}`}
761
+ aria-label={desc.label}
762
+ aria-pressed={active}
763
+ disabled={disabled || readOnly}
764
+ onMouseDown={(e) => e.preventDefault()}
765
+ onClick={() => runBuiltin(tool)}
766
+ >
767
+ {desc.icon}
768
+ </button>
769
+ );
770
+ })}
771
+ </div>
772
+ );
773
+ // eslint-disable-next-line react-hooks/exhaustive-deps
774
+ }, [tools, activeStates, disabled, readOnly, openImagePicker, startLinkPrompt, runBuiltin]);
775
+
776
+ // Get a fresh handle for custom tool callbacks
777
+ const getHandle = useCallback((): EvoRichTextHandle => ({
778
+ getHTML: () => editorRef.current?.innerHTML ?? '',
779
+ setHTML: (html: string) => {
780
+ const el = editorRef.current;
781
+ if (!el) return;
782
+ el.innerHTML = html;
783
+ emitChange();
784
+ },
785
+ getText: () => editorRef.current?.textContent ?? '',
786
+ focus: () => editorRef.current?.focus(),
787
+ insertImage: (src: string, alt = '') => insertImageAtCaret(src, alt),
788
+ insertHTML: (html: string) => {
789
+ editorRef.current?.focus();
790
+ execCommand('insertHTML', html);
791
+ emitChange();
792
+ },
793
+ clear: () => {
794
+ const el = editorRef.current;
795
+ if (!el) return;
796
+ el.innerHTML = '';
797
+ emitChange();
798
+ },
799
+ }), [emitChange, insertImageAtCaret]);
800
+
801
+ const heightStyle: React.CSSProperties = {
802
+ minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight,
803
+ maxHeight: maxHeight !== undefined ? (typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight) : undefined,
804
+ };
805
+
806
+ return (
807
+ <div className={[styles.field, fullWidth ? styles.fullWidth : '', className].filter(Boolean).join(' ')}>
808
+ {label && (
809
+ <label htmlFor={editorId} className={styles.label}>
810
+ {label}
811
+ </label>
812
+ )}
813
+
814
+ <div
815
+ className={[
816
+ styles.shell,
817
+ error ? styles.hasError : '',
818
+ disabled ? styles.disabled : '',
819
+ isDragOver ? styles.dragOver : '',
820
+ ].filter(Boolean).join(' ')}
821
+ >
822
+ {renderedTools}
823
+
824
+ <div
825
+ ref={editorRef}
826
+ id={editorId}
827
+ className={[styles.editor, isEmpty ? styles.editorEmpty : ''].filter(Boolean).join(' ')}
828
+ contentEditable={!disabled && !readOnly}
829
+ suppressContentEditableWarning
830
+ role="textbox"
831
+ aria-multiline="true"
832
+ aria-label={label ?? 'Rich text editor'}
833
+ aria-invalid={!!error}
834
+ aria-readonly={readOnly}
835
+ aria-disabled={disabled}
836
+ data-placeholder={placeholder}
837
+ style={heightStyle}
838
+ onInput={handleInput}
839
+ onKeyDown={handleKeyDown}
840
+ onPaste={handlePaste}
841
+ onDragOver={handleDragOver}
842
+ onDragLeave={handleDragLeave}
843
+ onDrop={handleDrop}
844
+ onBlur={refreshActiveStates}
845
+ />
846
+
847
+ {isDragOver && (
848
+ <div className={styles.dropOverlay} aria-hidden="true">
849
+ <span>Drop image to upload</span>
850
+ </div>
851
+ )}
852
+
853
+ {linkPrompt && (
854
+ <div className={styles.linkPrompt} role="dialog" aria-label="Insert link">
855
+ <input
856
+ type="url"
857
+ className={styles.linkInput}
858
+ value={linkPrompt.url}
859
+ autoFocus
860
+ onChange={(e) => setLinkPrompt({ ...linkPrompt, url: e.target.value })}
861
+ onKeyDown={(e) => {
862
+ if (e.key === 'Enter') { e.preventDefault(); applyLink(); }
863
+ else if (e.key === 'Escape') { e.preventDefault(); setLinkPrompt(null); }
864
+ }}
865
+ placeholder="https://example.com"
866
+ />
867
+ <button type="button" className={styles.linkBtn} onClick={applyLink}>Apply</button>
868
+ <button type="button" className={styles.linkBtnGhost} onClick={() => setLinkPrompt(null)}>Cancel</button>
869
+ </div>
870
+ )}
871
+
872
+ <input
873
+ ref={fileInputRef}
874
+ type="file"
875
+ accept={acceptedImageTypes.join(',')}
876
+ multiple
877
+ hidden
878
+ onChange={handleFilesPicked}
879
+ />
880
+ </div>
881
+
882
+ {error && <p className={styles.errorText}>{error}</p>}
883
+ {!error && helperText && <p className={styles.helperText}>{helperText}</p>}
884
+ </div>
885
+ );
886
+ });