@hilum/ui 3.2.3 → 3.2.4

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.
@@ -0,0 +1,448 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ AlignLeft,
6
+ Bold,
7
+ FileText,
8
+ Heading1,
9
+ Heading2,
10
+ Heading3,
11
+ Image,
12
+ Italic,
13
+ Link,
14
+ List,
15
+ ListOrdered,
16
+ Minus,
17
+ Strikethrough,
18
+ Underline,
19
+ XCircle,
20
+ } from "lucide-react";
21
+ import { Button } from "./button";
22
+ import { Input } from "./input";
23
+ import { Separator } from "./separator";
24
+ import { cn } from "../lib/utils";
25
+
26
+ type RichTextImageUrlHandler = () => string | null | undefined | Promise<string | null | undefined>;
27
+
28
+ interface RichTextEditorProps {
29
+ value: string;
30
+ onChange: (html: string) => void;
31
+ placeholder?: string;
32
+ minHeight?: string | number;
33
+ id?: string;
34
+ "aria-label"?: string;
35
+ className?: string;
36
+ editorClassName?: string;
37
+ toolbarClassName?: string;
38
+ onRequestImageUrl?: RichTextImageUrlHandler;
39
+ }
40
+
41
+ interface ToolbarButtonConfig {
42
+ icon: React.ComponentType<{ className?: string }>;
43
+ command: string;
44
+ title: string;
45
+ value?: string;
46
+ }
47
+
48
+ function wrapSelectionInline(tag: string, attrs?: Record<string, string>) {
49
+ const selection = window.getSelection();
50
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
51
+
52
+ const range = selection.getRangeAt(0);
53
+ const wrapper = document.createElement(tag);
54
+
55
+ Object.entries(attrs ?? {}).forEach(([key, value]) => {
56
+ wrapper.setAttribute(key, value);
57
+ });
58
+
59
+ wrapper.appendChild(range.extractContents());
60
+ range.insertNode(wrapper);
61
+
62
+ selection.removeAllRanges();
63
+ const nextRange = document.createRange();
64
+ nextRange.selectNodeContents(wrapper);
65
+ selection.addRange(nextRange);
66
+ }
67
+
68
+ function formatBlock(tag: string) {
69
+ const selection = window.getSelection();
70
+ if (!selection || selection.rangeCount === 0) return;
71
+
72
+ const range = selection.getRangeAt(0);
73
+ let block = range.startContainer as HTMLElement;
74
+
75
+ if (block.nodeType === Node.TEXT_NODE) block = block.parentElement as HTMLElement;
76
+ while (block && block.isContentEditable !== true) {
77
+ const parent = block.parentElement;
78
+ if (!parent || parent.getAttribute("contenteditable") === "true") break;
79
+ block = parent;
80
+ }
81
+
82
+ const nextBlock = document.createElement(tag);
83
+ if (block && block.getAttribute("contenteditable") !== "true") {
84
+ nextBlock.innerHTML = block.innerHTML;
85
+ block.replaceWith(nextBlock);
86
+ } else {
87
+ nextBlock.appendChild(range.extractContents());
88
+ range.insertNode(nextBlock);
89
+ }
90
+
91
+ selection.removeAllRanges();
92
+ const nextRange = document.createRange();
93
+ nextRange.selectNodeContents(nextBlock);
94
+ selection.addRange(nextRange);
95
+ }
96
+
97
+ function insertList(ordered: boolean) {
98
+ const selection = window.getSelection();
99
+ if (!selection || selection.rangeCount === 0) return;
100
+
101
+ const range = selection.getRangeAt(0);
102
+ const fragment = range.extractContents();
103
+ const list = document.createElement(ordered ? "ol" : "ul");
104
+ const children = Array.from(fragment.childNodes);
105
+
106
+ if (children.length === 0) {
107
+ const item = document.createElement("li");
108
+ item.innerHTML = "\u200B";
109
+ list.appendChild(item);
110
+ } else {
111
+ children.forEach((child) => {
112
+ const item = document.createElement("li");
113
+ item.appendChild(child);
114
+ list.appendChild(item);
115
+ });
116
+ }
117
+
118
+ range.insertNode(list);
119
+ selection.removeAllRanges();
120
+ const nextRange = document.createRange();
121
+ nextRange.selectNodeContents(list);
122
+ selection.addRange(nextRange);
123
+ }
124
+
125
+ function insertElement(element: HTMLElement) {
126
+ const selection = window.getSelection();
127
+ if (!selection || selection.rangeCount === 0) return;
128
+
129
+ const range = selection.getRangeAt(0);
130
+ range.deleteContents();
131
+ range.insertNode(element);
132
+
133
+ const nextRange = document.createRange();
134
+ nextRange.setStartAfter(element);
135
+ nextRange.collapse(true);
136
+ selection.removeAllRanges();
137
+ selection.addRange(nextRange);
138
+ }
139
+
140
+ function removeFormat() {
141
+ const selection = window.getSelection();
142
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
143
+
144
+ const range = selection.getRangeAt(0);
145
+ const fragment = range.extractContents();
146
+ const cleaned = document.createDocumentFragment();
147
+
148
+ Array.from(fragment.childNodes).forEach((child) => {
149
+ cleaned.appendChild(document.createTextNode(child.textContent ?? ""));
150
+ });
151
+
152
+ range.insertNode(cleaned);
153
+ }
154
+
155
+ function RichTextEditor({
156
+ value,
157
+ onChange,
158
+ placeholder = "Start writing...",
159
+ minHeight = "200px",
160
+ id,
161
+ "aria-label": ariaLabel,
162
+ className,
163
+ editorClassName,
164
+ toolbarClassName,
165
+ onRequestImageUrl,
166
+ }: RichTextEditorProps) {
167
+ const editorRef = React.useRef<HTMLDivElement>(null);
168
+ const savedRangeRef = React.useRef<Range | null>(null);
169
+ const [showLinkInput, setShowLinkInput] = React.useState(false);
170
+ const [linkUrl, setLinkUrl] = React.useState("");
171
+
172
+ React.useEffect(() => {
173
+ const editor = editorRef.current;
174
+ if (!editor || document.activeElement === editor || editor.innerHTML === value) return;
175
+ editor.innerHTML = value;
176
+ }, [value]);
177
+
178
+ const emitChange = React.useCallback(() => {
179
+ if (editorRef.current) onChange(editorRef.current.innerHTML);
180
+ }, [onChange]);
181
+
182
+ const saveSelection = React.useCallback(() => {
183
+ const selection = window.getSelection();
184
+ if (selection && selection.rangeCount > 0) {
185
+ savedRangeRef.current = selection.getRangeAt(0).cloneRange();
186
+ }
187
+ }, []);
188
+
189
+ const restoreSelection = React.useCallback(() => {
190
+ const range = savedRangeRef.current;
191
+ const selection = window.getSelection();
192
+ if (!range || !selection) return;
193
+ selection.removeAllRanges();
194
+ selection.addRange(range);
195
+ savedRangeRef.current = null;
196
+ }, []);
197
+
198
+ const runCommand = React.useCallback(
199
+ (command: string, commandValue?: string) => {
200
+ editorRef.current?.focus();
201
+
202
+ switch (command) {
203
+ case "bold":
204
+ wrapSelectionInline("strong");
205
+ break;
206
+ case "italic":
207
+ wrapSelectionInline("em");
208
+ break;
209
+ case "underline":
210
+ wrapSelectionInline("u");
211
+ break;
212
+ case "strikeThrough":
213
+ wrapSelectionInline("s");
214
+ break;
215
+ case "formatBlock":
216
+ if (commandValue) formatBlock(commandValue);
217
+ break;
218
+ case "insertUnorderedList":
219
+ insertList(false);
220
+ break;
221
+ case "insertOrderedList":
222
+ insertList(true);
223
+ break;
224
+ case "insertHorizontalRule":
225
+ insertElement(document.createElement("hr"));
226
+ break;
227
+ case "removeFormat":
228
+ removeFormat();
229
+ break;
230
+ default:
231
+ break;
232
+ }
233
+
234
+ emitChange();
235
+ },
236
+ [emitChange],
237
+ );
238
+
239
+ const insertLink = React.useCallback(() => {
240
+ const url = linkUrl.trim();
241
+ if (!url) return;
242
+
243
+ restoreSelection();
244
+ wrapSelectionInline("a", { href: url });
245
+ editorRef.current?.focus();
246
+ setLinkUrl("");
247
+ setShowLinkInput(false);
248
+ emitChange();
249
+ }, [emitChange, linkUrl, restoreSelection]);
250
+
251
+ const insertImage = React.useCallback(async () => {
252
+ const url =
253
+ (await onRequestImageUrl?.()) ??
254
+ (typeof window !== "undefined" ? window.prompt("Image URL:") : null);
255
+
256
+ if (!url) return;
257
+
258
+ const image = document.createElement("img");
259
+ image.src = url;
260
+ image.alt = "";
261
+ editorRef.current?.focus();
262
+ insertElement(image);
263
+ emitChange();
264
+ }, [emitChange, onRequestImageUrl]);
265
+
266
+ const toolbarGroups: ToolbarButtonConfig[][] = [
267
+ [
268
+ { icon: Bold, command: "bold", title: "Bold (Ctrl+B)" },
269
+ { icon: Italic, command: "italic", title: "Italic (Ctrl+I)" },
270
+ { icon: Underline, command: "underline", title: "Underline (Ctrl+U)" },
271
+ { icon: Strikethrough, command: "strikeThrough", title: "Strikethrough" },
272
+ ],
273
+ [
274
+ { icon: Heading1, command: "formatBlock", title: "Heading 1", value: "h1" },
275
+ { icon: Heading2, command: "formatBlock", title: "Heading 2", value: "h2" },
276
+ { icon: Heading3, command: "formatBlock", title: "Heading 3", value: "h3" },
277
+ ],
278
+ [
279
+ { icon: List, command: "insertUnorderedList", title: "Bullet list" },
280
+ { icon: ListOrdered, command: "insertOrderedList", title: "Numbered list" },
281
+ { icon: AlignLeft, command: "formatBlock", title: "Blockquote", value: "blockquote" },
282
+ { icon: FileText, command: "formatBlock", title: "Code block", value: "pre" },
283
+ ],
284
+ ];
285
+
286
+ const renderToolbarButton = ({
287
+ icon: Icon,
288
+ command,
289
+ title,
290
+ value: commandValue,
291
+ }: ToolbarButtonConfig) => (
292
+ <Button
293
+ key={`${command}-${title}`}
294
+ type="button"
295
+ variant="ghost"
296
+ size="icon"
297
+ className="size-10 text-muted-foreground hover:text-foreground"
298
+ title={title}
299
+ aria-label={title}
300
+ onMouseDown={(event) => {
301
+ event.preventDefault();
302
+ runCommand(command, commandValue);
303
+ }}
304
+ >
305
+ <Icon className="size-4" aria-hidden="true" />
306
+ </Button>
307
+ );
308
+
309
+ return (
310
+ <div
311
+ className={cn(
312
+ "overflow-hidden rounded-xl border border-border bg-card shadow-natural",
313
+ "focus-within:border-brand-primary/50 focus-within:ring-2 focus-within:ring-brand-primary/25",
314
+ className,
315
+ )}
316
+ data-slot="rich-text-editor"
317
+ >
318
+ <div
319
+ className={cn(
320
+ "flex min-w-0 flex-wrap items-center gap-1 border-b border-border bg-muted/40 p-1.5",
321
+ toolbarClassName,
322
+ )}
323
+ role="toolbar"
324
+ aria-label="Text formatting"
325
+ >
326
+ {toolbarGroups.map((group, index) => (
327
+ <React.Fragment key={`toolbar-group-${index}`}>
328
+ {index > 0 && <Separator orientation="vertical" className="mx-1 h-6" />}
329
+ {group.map(renderToolbarButton)}
330
+ </React.Fragment>
331
+ ))}
332
+ <Separator orientation="vertical" className="mx-1 h-6" />
333
+ <Button
334
+ type="button"
335
+ variant="ghost"
336
+ size="icon"
337
+ className="size-10 text-muted-foreground hover:text-foreground"
338
+ title="Insert link"
339
+ aria-label="Insert link"
340
+ onMouseDown={(event) => {
341
+ event.preventDefault();
342
+ saveSelection();
343
+ setShowLinkInput((current) => !current);
344
+ }}
345
+ >
346
+ <Link className="size-4" aria-hidden="true" />
347
+ </Button>
348
+ <Button
349
+ type="button"
350
+ variant="ghost"
351
+ size="icon"
352
+ className="size-10 text-muted-foreground hover:text-foreground"
353
+ title="Insert image"
354
+ aria-label="Insert image"
355
+ onMouseDown={(event) => {
356
+ event.preventDefault();
357
+ void insertImage();
358
+ }}
359
+ >
360
+ <Image className="size-4" aria-hidden="true" />
361
+ </Button>
362
+ {renderToolbarButton({
363
+ icon: Minus,
364
+ command: "insertHorizontalRule",
365
+ title: "Horizontal rule",
366
+ })}
367
+ {renderToolbarButton({
368
+ icon: XCircle,
369
+ command: "removeFormat",
370
+ title: "Clear formatting",
371
+ })}
372
+ </div>
373
+
374
+ {showLinkInput && (
375
+ <div className="flex min-w-0 flex-col gap-2 border-b border-border bg-muted/60 p-2 sm:flex-row">
376
+ <Input
377
+ type="url"
378
+ value={linkUrl}
379
+ onChange={(event) => setLinkUrl(event.target.value)}
380
+ placeholder="https://..."
381
+ className="h-10 flex-1 bg-background"
382
+ onKeyDown={(event) => {
383
+ if (event.key === "Enter") {
384
+ event.preventDefault();
385
+ insertLink();
386
+ }
387
+ }}
388
+ autoFocus
389
+ aria-label="Link URL"
390
+ />
391
+ <div className="grid grid-cols-2 gap-2 sm:flex sm:shrink-0">
392
+ <Button type="button" size="sm" className="h-10" onClick={insertLink}>
393
+ Insert
394
+ </Button>
395
+ <Button
396
+ type="button"
397
+ variant="outline"
398
+ size="sm"
399
+ className="h-10"
400
+ onClick={() => setShowLinkInput(false)}
401
+ >
402
+ Cancel
403
+ </Button>
404
+ </div>
405
+ </div>
406
+ )}
407
+
408
+ <div
409
+ ref={editorRef}
410
+ id={id}
411
+ contentEditable
412
+ role="textbox"
413
+ aria-multiline="true"
414
+ aria-label={ariaLabel ?? "Content editor"}
415
+ className={cn(
416
+ "rich-text-editor-content min-w-0 px-4 py-3 text-sm leading-6 text-foreground outline-none",
417
+ "empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
418
+ editorClassName,
419
+ )}
420
+ style={{ minHeight }}
421
+ dangerouslySetInnerHTML={{ __html: value }}
422
+ onInput={emitChange}
423
+ onBlur={emitChange}
424
+ data-placeholder={placeholder}
425
+ suppressContentEditableWarning
426
+ />
427
+
428
+ <style>{`
429
+ .rich-text-editor-content h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; margin: 0.75rem 0 0.5rem; text-wrap: balance; }
430
+ .rich-text-editor-content h2 { font-size: 1.25rem; font-weight: 650; line-height: 1.25; margin: 0.75rem 0 0.5rem; text-wrap: balance; }
431
+ .rich-text-editor-content h3 { font-size: 1.1rem; font-weight: 650; line-height: 1.3; margin: 0.5rem 0 0.25rem; text-wrap: balance; }
432
+ .rich-text-editor-content blockquote { border-left: 3px solid var(--border); color: var(--muted-foreground); margin: 0.75rem 0; padding-left: 1rem; text-wrap: pretty; }
433
+ .rich-text-editor-content pre { background: var(--muted); border-radius: 0.5rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.85rem; margin: 0.75rem 0; overflow-x: auto; padding: 0.75rem; }
434
+ .rich-text-editor-content a { color: var(--brand-primary); text-decoration: underline; text-underline-offset: 3px; }
435
+ .rich-text-editor-content img { border-radius: 0.5rem; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); height: auto; margin: 0.5rem 0; max-width: 100%; }
436
+ .dark .rich-text-editor-content img { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); }
437
+ .rich-text-editor-content hr { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
438
+ .rich-text-editor-content ul, .rich-text-editor-content ol { margin: 0.5rem 0; padding-left: 1.5rem; }
439
+ .rich-text-editor-content li { margin: 0.25rem 0; }
440
+ `}</style>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ RichTextEditor.displayName = "RichTextEditor";
446
+
447
+ export { RichTextEditor };
448
+ export type { RichTextEditorProps, RichTextImageUrlHandler };