@justin_evo/evo-ui 1.2.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.
- package/LICENSE +21 -21
- package/README.md +70 -70
- package/dist/declarations.d.ts +6 -6
- package/package.json +52 -52
- package/src/Alert/Alert.tsx +49 -49
- package/src/AutoComplete/AutoComplete.tsx +810 -810
- package/src/Badge/Badge.tsx +53 -53
- package/src/Breadcrumb/Breadcrumb.tsx +53 -53
- package/src/Button/Button.tsx +125 -125
- package/src/Card/Card.tsx +257 -257
- package/src/Checkbox/Checkbox.tsx +59 -59
- package/src/CommandPalette/CommandPalette.tsx +185 -185
- package/src/Container/Container.tsx +31 -31
- package/src/Divider/Divider.tsx +31 -31
- package/src/Form/Form.tsx +185 -185
- package/src/Grid/Grid.tsx +66 -66
- package/src/ImageCropper/ImageCropper.tsx +911 -911
- package/src/Input/Input.tsx +74 -74
- package/src/Modal/Modal.tsx +77 -77
- package/src/Nav/Nav.tsx +708 -708
- package/src/Notification/Notification.tsx +1503 -1503
- package/src/Pagination/Pagination.tsx +76 -76
- package/src/Radio/Radio.tsx +69 -69
- package/src/RichTextArea/RichTextArea.tsx +886 -886
- package/src/Select/Select.tsx +515 -515
- package/src/Skeleton/Skeleton.tsx +70 -70
- package/src/Stack/Stack.tsx +52 -52
- package/src/Table/Table.tsx +335 -335
- package/src/Tabs/Tabs.tsx +90 -90
- package/src/Theme/ThemeProvider.tsx +253 -253
- package/src/Theme/ThemeToggle.tsx +79 -79
- package/src/Toggle/Toggle.tsx +48 -48
- package/src/Tooltip/Tooltip.tsx +38 -38
- package/src/TopNav/TopNav.tsx +1163 -1163
- package/src/TreeSelect/TreeSelect.tsx +825 -825
- package/src/css/alert.module.scss +93 -93
- package/src/css/autocomplete.module.scss +416 -416
- package/src/css/badge.module.scss +82 -82
- package/src/css/base/_color.scss +159 -159
- package/src/css/base/_theme.scss +237 -237
- package/src/css/base/_variables.scss +161 -161
- package/src/css/breadcrumb.module.scss +50 -50
- package/src/css/button.module.scss +385 -385
- package/src/css/card.module.scss +217 -217
- package/src/css/checkbox.module.scss +123 -123
- package/src/css/commandpalette.module.scss +211 -211
- package/src/css/container.module.scss +18 -18
- package/src/css/divider.module.scss +41 -41
- package/src/css/form.module.scss +245 -245
- package/src/css/imagecropper.module.scss +397 -397
- package/src/css/input.module.scss +89 -89
- package/src/css/modal.module.scss +105 -105
- package/src/css/nav.module.scss +494 -494
- package/src/css/notification.module.scss +691 -691
- package/src/css/pagination.module.scss +63 -63
- package/src/css/radio.module.scss +89 -89
- package/src/css/richtextarea.module.scss +307 -307
- package/src/css/select.module.scss +525 -525
- package/src/css/skeleton.module.scss +30 -30
- package/src/css/table.module.scss +386 -386
- package/src/css/tabs.module.scss +63 -63
- package/src/css/theme-toggle.module.scss +83 -83
- package/src/css/toggle.module.scss +54 -54
- package/src/css/tooltip.module.scss +97 -97
- package/src/css/topnav.module.scss +568 -568
- package/src/css/treeselect.module.scss +558 -558
- package/src/css/utilities/_borders.scss +111 -111
- package/src/css/utilities/_colors.scss +66 -66
- package/src/css/utilities/_effects.scss +216 -216
- package/src/css/utilities/_layout.scss +181 -181
- package/src/css/utilities/_position.scss +75 -75
- package/src/css/utilities/_sizing.scss +138 -138
- package/src/css/utilities/_spacing.scss +99 -99
- package/src/css/utilities/_typography.scss +121 -121
- package/src/css/utilities/index.scss +24 -24
- package/src/declarations.d.ts +6 -6
- package/src/index.ts +60 -60
|
@@ -1,886 +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
|
-
const el = editorRef.current;
|
|
506
|
-
if (!el) return;
|
|
507
|
-
el.focus();
|
|
508
|
-
const safeSrc = src.replace(/"/g, '"');
|
|
509
|
-
const safeAlt = alt.replace(/"/g, '"');
|
|
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
|
-
});
|
|
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, '"');
|
|
509
|
+
const safeAlt = alt.replace(/"/g, '"');
|
|
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
|
+
});
|