@skalfa/skalfa-component 1.0.26 → 1.0.28

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,924 @@
1
+ "use client"
2
+
3
+ import { ReactNode, Ref, useCallback, useEffect, useRef, useState } from "react";
4
+ import { Icon } from "@skalfa/skalfa-icon";
5
+ import { cn, pcn, useInputHandler, useInputRandomId, useValidation, ValidationRules } from "@utils";
6
+ import { COLOR_MAP, parseContentToHtml, parseHtmlToContent } from "../wrap/ContentWrapper.component";
7
+ import { ButtonComponent } from "../button/Button.component";
8
+
9
+ type CT = "label" | "tip" | "error" | "base" | "toolbar" | "editor";
10
+
11
+ export type ToolbarControlName =
12
+ | "HEADER"
13
+ | "BOLD"
14
+ | "ITALIC"
15
+ | "UNDERLINE"
16
+ | "STRIKETHROUGH"
17
+ | "TEXT_SIZE"
18
+ | "FONT_SIZE"
19
+ | "ALIGN_LEFT"
20
+ | "ALIGN_CENTER"
21
+ | "ALIGN_RIGHT"
22
+ | "ALIGN_JUSTIFY"
23
+ | "LINK"
24
+ | "COLOR"
25
+ | "LIST_BULLET"
26
+ | "BULLET_LIST"
27
+ | "LIST_NUMBER"
28
+ | "NUMBER_LIST"
29
+ | "DIVIDER"
30
+ | "SEP";
31
+
32
+ export type ToolbarControlItem = ToolbarControlName | ReactNode;
33
+
34
+ export const DEFAULT_TOOLBAR_CONTROLS: ToolbarControlItem[] = [
35
+ "HEADER",
36
+ "TEXT_SIZE",
37
+ "BOLD",
38
+ "ITALIC",
39
+ "UNDERLINE",
40
+ "STRIKETHROUGH",
41
+ "ALIGN_LEFT",
42
+ "ALIGN_CENTER",
43
+ "ALIGN_RIGHT",
44
+ "ALIGN_JUSTIFY",
45
+ "LINK",
46
+ "COLOR",
47
+ "LIST_BULLET",
48
+ "LIST_NUMBER",
49
+ "DIVIDER",
50
+ ];
51
+
52
+ export interface InputContentProps {
53
+ label ?: string;
54
+ tip ?: string | ReactNode;
55
+ name ?: string;
56
+ placeholder ?: string;
57
+ disabled ?: boolean;
58
+
59
+ value ?: string;
60
+ invalid ?: string;
61
+
62
+ validations ?: ValidationRules;
63
+ toolbarControl ?: Array<ToolbarControlItem>;
64
+
65
+ onChange ?: (value: string) => any;
66
+ register ?: (name: string, validations?: ValidationRules) => void;
67
+ unregister ?: (name: string) => void;
68
+
69
+ ref ?: Ref<HTMLDivElement>;
70
+
71
+ className ?: string;
72
+ }
73
+
74
+ export function InputContentComponent({
75
+ label,
76
+ tip,
77
+ className = "",
78
+
79
+ value,
80
+ invalid,
81
+
82
+ validations,
83
+ toolbarControl,
84
+
85
+ register,
86
+ unregister,
87
+ onChange,
88
+
89
+ ref,
90
+ ...props
91
+ }: InputContentProps) {
92
+ const inputHandler = useInputHandler(props.name, value, validations, register, false, unregister);
93
+ const randomId = useInputRandomId();
94
+
95
+ const [invalidMessage] = useValidation(inputHandler.value, validations, invalid, inputHandler.idle);
96
+
97
+ const editorRef = useRef<HTMLDivElement>(null);
98
+ const isInternalChange = useRef(false);
99
+ const [showColorPicker, setShowColorPicker] = useState(false);
100
+ const [showLinkInput, setShowLinkInput] = useState(false);
101
+ const [linkUrl, setLinkUrl] = useState("");
102
+ const savedSelectionRef = useRef<Range | null>(null);
103
+
104
+ const [textSize, setTextSize] = useState<number>(14);
105
+ const [activeColor, setActiveColor] = useState<string>("normal");
106
+ const [activeStates, setActiveStates] = useState({
107
+ header: false,
108
+ bold: false,
109
+ italic: false,
110
+ underline: false,
111
+ strikeThrough: false,
112
+ justifyLeft: false,
113
+ justifyCenter: false,
114
+ justifyRight: false,
115
+ justifyFull: false,
116
+ insertUnorderedList: false,
117
+ insertOrderedList: false,
118
+ });
119
+
120
+ useEffect(() => {
121
+ if (isInternalChange.current) {
122
+ isInternalChange.current = false;
123
+ return;
124
+ }
125
+ if (editorRef.current) {
126
+ const html = parseContentToHtml(inputHandler.value || "");
127
+ if (editorRef.current.innerHTML !== html) {
128
+ editorRef.current.innerHTML = html;
129
+ }
130
+ }
131
+ }, [inputHandler.value]);
132
+
133
+ const saveSelection = useCallback(() => {
134
+ const sel = window.getSelection();
135
+ if (sel && sel.rangeCount > 0) {
136
+ savedSelectionRef.current = sel.getRangeAt(0).cloneRange();
137
+ }
138
+ }, []);
139
+
140
+ const restoreSelection = useCallback(() => {
141
+ const sel = window.getSelection();
142
+ if (sel && savedSelectionRef.current) {
143
+ sel.removeAllRanges();
144
+ sel.addRange(savedSelectionRef.current);
145
+ }
146
+ }, []);
147
+
148
+ const handleEditorChange = useCallback(() => {
149
+ if (!editorRef.current) return;
150
+ const html = editorRef.current.innerHTML;
151
+ const customFormat = parseHtmlToContent(html);
152
+
153
+ isInternalChange.current = true;
154
+ inputHandler.setValue(customFormat);
155
+ inputHandler.setIdle(false);
156
+ if (onChange) onChange(customFormat);
157
+ }, [onChange, inputHandler]);
158
+
159
+ const execCommand = useCallback((command: string, value?: string) => {
160
+ editorRef.current?.focus();
161
+ restoreSelection();
162
+ document.execCommand(command, false, value);
163
+ saveSelection();
164
+ handleEditorChange();
165
+ }, [restoreSelection, saveSelection, handleEditorChange]);
166
+
167
+ const isCommandActive = useCallback((command: string): boolean => {
168
+ try {
169
+ return document.queryCommandState(command);
170
+ } catch {
171
+ return false;
172
+ }
173
+ }, []);
174
+
175
+ const handleBold = useCallback(() => execCommand("bold"), [execCommand]);
176
+ const handleItalic = useCallback(() => execCommand("italic"), [execCommand]);
177
+ const handleUnderline = useCallback(() => execCommand("underline"), [execCommand]);
178
+ const handleStrikethrough = useCallback(() => execCommand("strikeThrough"), [execCommand]);
179
+
180
+ const handleHeader = useCallback(() => {
181
+ editorRef.current?.focus();
182
+ restoreSelection();
183
+
184
+ const sel = window.getSelection();
185
+ if (!sel || sel.rangeCount === 0) return;
186
+
187
+ let node: Node | null = sel.anchorNode;
188
+ let isInHeader = false;
189
+ while (node && node !== editorRef.current) {
190
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "H2") {
191
+ isInHeader = true;
192
+ break;
193
+ }
194
+ node = node.parentNode;
195
+ }
196
+
197
+ if (isInHeader) {
198
+ document.execCommand("formatBlock", false, "p");
199
+ } else {
200
+ document.execCommand("formatBlock", false, "h2");
201
+ }
202
+
203
+ saveSelection();
204
+ handleEditorChange();
205
+ }, [restoreSelection, saveSelection, handleEditorChange]);
206
+
207
+ const handleFontSizeChange = useCallback((newSize: number) => {
208
+ const clampedSize = Math.max(8, Math.min(72, newSize));
209
+ setTextSize(clampedSize);
210
+
211
+ editorRef.current?.focus();
212
+ restoreSelection();
213
+
214
+ const sel = window.getSelection();
215
+ if (!sel || sel.rangeCount === 0) return;
216
+
217
+ const range = sel.getRangeAt(0);
218
+
219
+ if (sel.isCollapsed) {
220
+ let containerNode: Node | null = range.commonAncestorContainer;
221
+ if (containerNode.nodeType === Node.TEXT_NODE) containerNode = containerNode.parentNode;
222
+ if (containerNode && (containerNode as HTMLElement).dataset?.size) {
223
+ (containerNode as HTMLElement).style.fontSize = `${clampedSize}px`;
224
+ (containerNode as HTMLElement).dataset.size = clampedSize.toString();
225
+ } else {
226
+ const span = document.createElement("span");
227
+ span.style.fontSize = `${clampedSize}px`;
228
+ span.dataset.size = clampedSize.toString();
229
+ span.appendChild(document.createTextNode("\u200B"));
230
+
231
+ range.insertNode(span);
232
+ const newRange = document.createRange();
233
+ newRange.setStart(span.firstChild!, 1);
234
+ newRange.collapse(true);
235
+ sel.removeAllRanges();
236
+ sel.addRange(newRange);
237
+ }
238
+ } else {
239
+ const span = document.createElement("span");
240
+ span.style.fontSize = `${clampedSize}px`;
241
+ span.dataset.size = clampedSize.toString();
242
+
243
+ try {
244
+ range.surroundContents(span);
245
+ } catch {
246
+ const fragment = range.extractContents();
247
+ span.appendChild(fragment);
248
+ range.insertNode(span);
249
+ }
250
+
251
+ sel.removeAllRanges();
252
+ const newRange = document.createRange();
253
+ newRange.selectNodeContents(span);
254
+ sel.addRange(newRange);
255
+ }
256
+
257
+ saveSelection();
258
+ handleEditorChange();
259
+ }, [restoreSelection, saveSelection, handleEditorChange]);
260
+
261
+ const handleAlign = useCallback((align: string) => {
262
+ const commandMap: Record<string, string> = {
263
+ left: "justifyLeft",
264
+ center: "justifyCenter",
265
+ right: "justifyRight",
266
+ justify: "justifyFull",
267
+ };
268
+ const targetCmd = commandMap[align] || "justifyLeft";
269
+ if (isCommandActive(targetCmd)) {
270
+ execCommand("justifyLeft");
271
+ } else {
272
+ execCommand(targetCmd);
273
+ }
274
+ }, [execCommand, isCommandActive]);
275
+
276
+ const handleColor = useCallback((colorKey: string) => {
277
+ const targetColor = activeColor === colorKey ? "normal" : colorKey;
278
+ const colorInfo = COLOR_MAP[targetColor];
279
+ if (!colorInfo) return;
280
+
281
+ editorRef.current?.focus();
282
+ restoreSelection();
283
+
284
+ const sel = window.getSelection();
285
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
286
+ setShowColorPicker(false);
287
+ return;
288
+ }
289
+
290
+ const range = sel.getRangeAt(0);
291
+
292
+ if (targetColor === "normal") {
293
+ let containerNode: Node | null = range.commonAncestorContainer;
294
+ if (containerNode.nodeType === Node.TEXT_NODE) containerNode = containerNode.parentNode;
295
+ if (containerNode && (containerNode as HTMLElement).dataset?.color) {
296
+ const parent = containerNode.parentNode;
297
+ while (containerNode.firstChild) {
298
+ parent?.insertBefore(containerNode.firstChild, containerNode);
299
+ }
300
+ parent?.removeChild(containerNode);
301
+ }
302
+ } else {
303
+ const span = document.createElement("span");
304
+ span.className = colorInfo.tw;
305
+ span.style.color = colorInfo.css;
306
+ span.dataset.color = targetColor;
307
+
308
+ try {
309
+ range.surroundContents(span);
310
+ } catch {
311
+ const fragment = range.extractContents();
312
+ span.appendChild(fragment);
313
+ range.insertNode(span);
314
+ }
315
+
316
+ sel.removeAllRanges();
317
+ const newRange = document.createRange();
318
+ newRange.selectNodeContents(span);
319
+ sel.addRange(newRange);
320
+ }
321
+
322
+ setActiveColor(targetColor);
323
+ saveSelection();
324
+ setShowColorPicker(false);
325
+ handleEditorChange();
326
+ }, [activeColor, restoreSelection, saveSelection, handleEditorChange]);
327
+
328
+ const handleBulletList = useCallback(() => execCommand("insertUnorderedList"), [execCommand]);
329
+ const handleNumberList = useCallback(() => execCommand("insertOrderedList"), [execCommand]);
330
+
331
+ const handleLink = useCallback(() => {
332
+ saveSelection();
333
+ setShowLinkInput(true);
334
+ setLinkUrl("");
335
+ }, [saveSelection]);
336
+
337
+ const handleLinkSubmit = useCallback(() => {
338
+ if (!linkUrl) {
339
+ setShowLinkInput(false);
340
+ return;
341
+ }
342
+
343
+ editorRef.current?.focus();
344
+ restoreSelection();
345
+
346
+ const sel = window.getSelection();
347
+ if (!sel || sel.rangeCount === 0) {
348
+ setShowLinkInput(false);
349
+ return;
350
+ }
351
+
352
+ const range = sel.getRangeAt(0);
353
+ const linkText = sel.isCollapsed ? linkUrl : range.toString();
354
+
355
+ const a = document.createElement("a");
356
+ a.href = linkUrl;
357
+ a.className = "text-primary underline";
358
+ a.dataset.link = linkUrl;
359
+ a.target = "_blank";
360
+ a.rel = "noopener";
361
+ a.textContent = linkText;
362
+
363
+ if (!sel.isCollapsed) {
364
+ range.deleteContents();
365
+ }
366
+ range.insertNode(a);
367
+
368
+ const newRange = document.createRange();
369
+ newRange.setStartAfter(a);
370
+ newRange.collapse(true);
371
+ sel.removeAllRanges();
372
+ sel.addRange(newRange);
373
+
374
+ saveSelection();
375
+ setShowLinkInput(false);
376
+ setLinkUrl("");
377
+ handleEditorChange();
378
+ }, [linkUrl, restoreSelection, saveSelection, handleEditorChange]);
379
+
380
+ const handleDivider = useCallback(() => {
381
+ editorRef.current?.focus();
382
+ restoreSelection();
383
+
384
+ const sel = window.getSelection();
385
+ if (!sel || sel.rangeCount === 0) return;
386
+
387
+ const range = sel.getRangeAt(0);
388
+
389
+ const dividerWrapper = document.createElement("div");
390
+ dividerWrapper.className = "skcontent-divider";
391
+ const hr = document.createElement("hr");
392
+ dividerWrapper.appendChild(hr);
393
+
394
+ const newP = document.createElement("p");
395
+ newP.innerHTML = "<br>";
396
+
397
+ range.deleteContents();
398
+ range.insertNode(newP);
399
+ range.insertNode(dividerWrapper);
400
+
401
+ const newRange = document.createRange();
402
+ newRange.setStart(newP, 0);
403
+ newRange.collapse(true);
404
+ sel.removeAllRanges();
405
+ sel.addRange(newRange);
406
+
407
+ saveSelection();
408
+ handleEditorChange();
409
+ }, [restoreSelection, saveSelection, handleEditorChange]);
410
+
411
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
412
+ if (e.key === "Backspace") {
413
+ const sel = window.getSelection();
414
+ if (sel && sel.rangeCount > 0 && sel.isCollapsed) {
415
+ const range = sel.getRangeAt(0);
416
+ if (range.startOffset === 0) {
417
+ let node: Node | null = range.startContainer;
418
+ let currentBlock: HTMLElement | null = null;
419
+ while (node && node !== editorRef.current) {
420
+ if (node.nodeType === Node.ELEMENT_NODE && node.parentNode === editorRef.current) {
421
+ currentBlock = node as HTMLElement;
422
+ break;
423
+ }
424
+ node = node.parentNode;
425
+ }
426
+
427
+ if (currentBlock) {
428
+ const prev = currentBlock.previousElementSibling as HTMLElement | null;
429
+ if (prev && (prev.classList.contains("skcontent-divider") || prev.tagName === "HR")) {
430
+ e.preventDefault();
431
+ prev.remove();
432
+ handleEditorChange();
433
+ return;
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ if (e.key === "Delete") {
441
+ const sel = window.getSelection();
442
+ if (sel && sel.rangeCount > 0 && sel.isCollapsed) {
443
+ const range = sel.getRangeAt(0);
444
+ let node: Node | null = range.startContainer;
445
+ const textLen = node.textContent?.length || 0;
446
+ if (range.startOffset >= textLen) {
447
+ let currentBlock: HTMLElement | null = null;
448
+ while (node && node !== editorRef.current) {
449
+ if (node.nodeType === Node.ELEMENT_NODE && node.parentNode === editorRef.current) {
450
+ currentBlock = node as HTMLElement;
451
+ break;
452
+ }
453
+ node = node.parentNode;
454
+ }
455
+
456
+ if (currentBlock) {
457
+ const next = currentBlock.nextElementSibling as HTMLElement | null;
458
+ if (next && (next.classList.contains("skcontent-divider") || next.tagName === "HR")) {
459
+ e.preventDefault();
460
+ next.remove();
461
+ handleEditorChange();
462
+ return;
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }, [handleEditorChange]);
469
+
470
+ const updateActiveStates = useCallback(() => {
471
+ const sel = window.getSelection();
472
+ let inH2 = false;
473
+ if (sel && sel.rangeCount > 0) {
474
+ let node: Node | null = sel.anchorNode;
475
+ while (node && node !== editorRef.current) {
476
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "H2") {
477
+ inH2 = true;
478
+ break;
479
+ }
480
+ node = node.parentNode;
481
+ }
482
+ }
483
+
484
+ setActiveStates({
485
+ header: inH2,
486
+ bold: isCommandActive("bold"),
487
+ italic: isCommandActive("italic"),
488
+ underline: isCommandActive("underline"),
489
+ strikeThrough: isCommandActive("strikeThrough"),
490
+ justifyLeft: isCommandActive("justifyLeft"),
491
+ justifyCenter: isCommandActive("justifyCenter"),
492
+ justifyRight: isCommandActive("justifyRight"),
493
+ justifyFull: isCommandActive("justifyFull"),
494
+ insertUnorderedList: isCommandActive("insertUnorderedList"),
495
+ insertOrderedList: isCommandActive("insertOrderedList"),
496
+ });
497
+
498
+ if (sel && sel.rangeCount > 0) {
499
+ let node: Node | null = sel.anchorNode;
500
+ let detectedColor = "normal";
501
+ let detectedSize: number | null = null;
502
+ while (node && node !== editorRef.current) {
503
+ if (node.nodeType === Node.ELEMENT_NODE) {
504
+ const el = node as HTMLElement;
505
+ if (el.dataset.color && detectedColor === "normal") {
506
+ detectedColor = el.dataset.color;
507
+ }
508
+ if (el.dataset.size && detectedSize === null) {
509
+ const parsed = parseInt(el.dataset.size);
510
+ if (!isNaN(parsed)) detectedSize = parsed;
511
+ }
512
+ }
513
+ node = node.parentNode;
514
+ }
515
+ setActiveColor(detectedColor);
516
+ if (detectedSize !== null) {
517
+ setTextSize(detectedSize);
518
+ }
519
+ }
520
+ }, [isCommandActive]);
521
+
522
+ const renderControlItem = useCallback((item: ToolbarControlItem, index: number) => {
523
+ if (typeof item !== "string") {
524
+ return <div key={index}>{item}</div>;
525
+ }
526
+
527
+ switch (item) {
528
+ case "HEADER":
529
+ return (
530
+ <ButtonComponent
531
+ key={index}
532
+ variant={activeStates.header ? "light" : "simple"}
533
+ paint="primary"
534
+ size="sm"
535
+ icon="solid/text-h"
536
+ tips="Header"
537
+ className={cn("toolbar-btn", activeStates.header && "toolbar-btn-active")}
538
+ onClick={(e: any) => { e?.preventDefault?.(); handleHeader(); }}
539
+ />
540
+ );
541
+ case "TEXT_SIZE":
542
+ case "FONT_SIZE":
543
+ return (
544
+ <div className="flex items-center gap-0.5" key={index}>
545
+ <ButtonComponent
546
+ variant="simple"
547
+ paint="primary"
548
+ size="sm"
549
+ icon="solid/minus"
550
+ tips="Decrease Font Size"
551
+ className="toolbar-btn"
552
+ onClick={(e: any) => { e?.preventDefault?.(); handleFontSizeChange(textSize - 1); }}
553
+ />
554
+ <input
555
+ type="number"
556
+ value={textSize}
557
+ onChange={(e) => handleFontSizeChange(parseInt(e.target.value) || 14)}
558
+ onFocus={() => saveSelection()}
559
+ className="w-10 h-7 text-xs text-center border border-stroke rounded focus:outline-none focus:border-primary bg-background text-foreground select-none"
560
+ />
561
+ <ButtonComponent
562
+ variant="simple"
563
+ paint="primary"
564
+ size="sm"
565
+ icon="solid/plus"
566
+ tips="Increase Font Size"
567
+ className="toolbar-btn"
568
+ onClick={(e: any) => { e?.preventDefault?.(); handleFontSizeChange(textSize + 1); }}
569
+ />
570
+ </div>
571
+ );
572
+ case "BOLD":
573
+ return (
574
+ <ButtonComponent
575
+ key={index}
576
+ variant={activeStates.bold ? "light" : "simple"}
577
+ paint="primary"
578
+ size="sm"
579
+ icon="solid/text-b"
580
+ tips="Bold"
581
+ className={cn("toolbar-btn", activeStates.bold && "toolbar-btn-active")}
582
+ onClick={(e: any) => { e?.preventDefault?.(); handleBold(); }}
583
+ />
584
+ );
585
+ case "ITALIC":
586
+ return (
587
+ <ButtonComponent
588
+ key={index}
589
+ variant={activeStates.italic ? "light" : "simple"}
590
+ paint="primary"
591
+ size="sm"
592
+ icon="solid/text-italic"
593
+ tips="Italic"
594
+ className={cn("toolbar-btn", activeStates.italic && "toolbar-btn-active")}
595
+ onClick={(e: any) => { e?.preventDefault?.(); handleItalic(); }}
596
+ />
597
+ );
598
+ case "UNDERLINE":
599
+ return (
600
+ <ButtonComponent
601
+ key={index}
602
+ variant={activeStates.underline ? "light" : "simple"}
603
+ paint="primary"
604
+ size="sm"
605
+ icon="solid/text-underline"
606
+ tips="Underline"
607
+ className={cn("toolbar-btn", activeStates.underline && "toolbar-btn-active")}
608
+ onClick={(e: any) => { e?.preventDefault?.(); handleUnderline(); }}
609
+ />
610
+ );
611
+ case "STRIKETHROUGH":
612
+ return (
613
+ <ButtonComponent
614
+ key={index}
615
+ variant={activeStates.strikeThrough ? "light" : "simple"}
616
+ paint="primary"
617
+ size="sm"
618
+ icon="solid/text-slash"
619
+ tips="Strikethrough"
620
+ className={cn("toolbar-btn", activeStates.strikeThrough && "toolbar-btn-active")}
621
+ onClick={(e: any) => { e?.preventDefault?.(); handleStrikethrough(); }}
622
+ />
623
+ );
624
+ case "ALIGN_LEFT":
625
+ return (
626
+ <ButtonComponent
627
+ key={index}
628
+ variant={activeStates.justifyLeft ? "light" : "simple"}
629
+ paint="primary"
630
+ size="sm"
631
+ icon="solid/text-left"
632
+ tips="Align Left"
633
+ className={cn("toolbar-btn", activeStates.justifyLeft && "toolbar-btn-active")}
634
+ onClick={(e: any) => { e?.preventDefault?.(); handleAlign("left"); }}
635
+ />
636
+ );
637
+ case "ALIGN_CENTER":
638
+ return (
639
+ <ButtonComponent
640
+ key={index}
641
+ variant={activeStates.justifyCenter ? "light" : "simple"}
642
+ paint="primary"
643
+ size="sm"
644
+ icon="solid/text-center"
645
+ tips="Align Center"
646
+ className={cn("toolbar-btn", activeStates.justifyCenter && "toolbar-btn-active")}
647
+ onClick={(e: any) => { e?.preventDefault?.(); handleAlign("center"); }}
648
+ />
649
+ );
650
+ case "ALIGN_RIGHT":
651
+ return (
652
+ <ButtonComponent
653
+ key={index}
654
+ variant={activeStates.justifyRight ? "light" : "simple"}
655
+ paint="primary"
656
+ size="sm"
657
+ icon="solid/text-right"
658
+ tips="Align Right"
659
+ className={cn("toolbar-btn", activeStates.justifyRight && "toolbar-btn-active")}
660
+ onClick={(e: any) => { e?.preventDefault?.(); handleAlign("right"); }}
661
+ />
662
+ );
663
+ case "ALIGN_JUSTIFY":
664
+ return (
665
+ <ButtonComponent
666
+ key={index}
667
+ variant={activeStates.justifyFull ? "light" : "simple"}
668
+ paint="primary"
669
+ size="sm"
670
+ icon="solid/text-justify"
671
+ tips="Align Justify"
672
+ className={cn("toolbar-btn", activeStates.justifyFull && "toolbar-btn-active")}
673
+ onClick={(e: any) => { e?.preventDefault?.(); handleAlign("justify"); }}
674
+ />
675
+ );
676
+ case "LINK":
677
+ return (
678
+ <div className="relative" key={index}>
679
+ <ButtonComponent
680
+ variant={showLinkInput ? "light" : "simple"}
681
+ paint="primary"
682
+ size="sm"
683
+ icon="solid/link"
684
+ tips="Link"
685
+ className={cn("toolbar-btn", showLinkInput && "toolbar-btn-active")}
686
+ onClick={(e: any) => { e?.preventDefault?.(); handleLink(); }}
687
+ />
688
+
689
+ {showLinkInput && (
690
+ <div className="skcontent-dropdown skcontent-link-input">
691
+ <input
692
+ type="url"
693
+ placeholder="https://..."
694
+ value={linkUrl}
695
+ onChange={(e) => setLinkUrl(e.target.value)}
696
+ onKeyDown={(e) => {
697
+ if (e.key === "Enter") {
698
+ e.preventDefault();
699
+ handleLinkSubmit();
700
+ }
701
+ if (e.key === "Escape") {
702
+ setShowLinkInput(false);
703
+ }
704
+ }}
705
+ className="skcontent-link-url-input"
706
+ autoFocus
707
+ />
708
+ <ButtonComponent
709
+ variant="solid"
710
+ paint="primary"
711
+ size="xs"
712
+ icon="solid/check"
713
+ onClick={(e: any) => { e?.preventDefault?.(); handleLinkSubmit(); }}
714
+ />
715
+ </div>
716
+ )}
717
+ </div>
718
+ );
719
+ case "COLOR":
720
+ return (
721
+ <div className="relative" key={index}>
722
+ <ButtonComponent
723
+ variant={activeColor !== "normal" ? "light" : "simple"}
724
+ paint="primary"
725
+ size="sm"
726
+ label={<div><div className="skcontent-color-dot" style={{ backgroundColor: COLOR_MAP[activeColor]?.css || COLOR_MAP.normal.css }} /></div>}
727
+ tips={`Text Color (${COLOR_MAP[activeColor]?.label || "Normal"})`}
728
+ className={cn("toolbar-btn", (activeColor !== "normal" || showColorPicker) && "toolbar-btn-active")}
729
+ onClick={(e: any) => { e?.preventDefault?.(); setShowColorPicker(!showColorPicker); }}
730
+ />
731
+
732
+ {showColorPicker && (
733
+ <div className="skcontent-dropdown skcontent-color-picker">
734
+ {Object.entries(COLOR_MAP).map(([key, info]) => (
735
+ <button
736
+ key={key}
737
+ type="button"
738
+ title={info.label}
739
+ className={cn(
740
+ "skcontent-color-dot",
741
+ activeColor === key && "scale-125 border-2 border-primary"
742
+ )}
743
+ style={{ backgroundColor: info.css }}
744
+ onMouseDown={(e) => {
745
+ e.preventDefault();
746
+ handleColor(key);
747
+ }}
748
+ />
749
+ ))}
750
+ </div>
751
+ )}
752
+ </div>
753
+ );
754
+ case "LIST_BULLET":
755
+ case "BULLET_LIST":
756
+ return (
757
+ <ButtonComponent
758
+ key={index}
759
+ variant={activeStates.insertUnorderedList ? "light" : "simple"}
760
+ paint="primary"
761
+ size="sm"
762
+ icon="solid/list-bullet"
763
+ tips="Bullet List"
764
+ className={cn("toolbar-btn", activeStates.insertUnorderedList && "toolbar-btn-active")}
765
+ onClick={(e: any) => { e?.preventDefault?.(); handleBulletList(); }}
766
+ />
767
+ );
768
+ case "LIST_NUMBER":
769
+ case "NUMBER_LIST":
770
+ return (
771
+ <ButtonComponent
772
+ key={index}
773
+ variant={activeStates.insertOrderedList ? "light" : "simple"}
774
+ paint="primary"
775
+ size="sm"
776
+ icon="solid/list-number"
777
+ tips="Numbered List"
778
+ className={cn("toolbar-btn", activeStates.insertOrderedList && "toolbar-btn-active")}
779
+ onClick={(e: any) => { e?.preventDefault?.(); handleNumberList(); }}
780
+ />
781
+ );
782
+ case "DIVIDER":
783
+ return (
784
+ <ButtonComponent
785
+ key={index}
786
+ variant="simple"
787
+ paint="primary"
788
+ size="sm"
789
+ icon="solid/minus"
790
+ tips="Divider"
791
+ className="toolbar-btn"
792
+ onClick={(e: any) => { e?.preventDefault?.(); handleDivider(); }}
793
+ />
794
+ );
795
+ case "SEP":
796
+ return <div className="skcontent-toolbar-sep" key={index} />;
797
+ default:
798
+ return null;
799
+ }
800
+ }, [
801
+ activeStates,
802
+ showLinkInput,
803
+ linkUrl,
804
+ activeColor,
805
+ showColorPicker,
806
+ textSize,
807
+ handleHeader,
808
+ handleFontSizeChange,
809
+ handleBold,
810
+ handleItalic,
811
+ handleUnderline,
812
+ handleStrikethrough,
813
+ handleAlign,
814
+ handleLink,
815
+ handleLinkSubmit,
816
+ handleColor,
817
+ handleBulletList,
818
+ handleNumberList,
819
+ handleDivider,
820
+ saveSelection,
821
+ ]);
822
+
823
+ return (
824
+ <div className="relative flex flex-col gap-y-0.5 w-full">
825
+ {label && (
826
+ <label
827
+ htmlFor={randomId}
828
+ className={cn(
829
+ "input-label",
830
+ props.disabled && "input-label-disabled",
831
+ !!invalidMessage && "input-label-error",
832
+ pcn<CT>(className, "label"),
833
+ props.disabled && pcn<CT>(className, "label", "disabled"),
834
+ !!invalidMessage && pcn<CT>(className, "label", "error"),
835
+ )}
836
+ >
837
+ {label}
838
+ {validations && (validations as any)?.required && <span className="text-danger ml-1">*</span>}
839
+ </label>
840
+ )}
841
+
842
+ {tip && (
843
+ <small
844
+ className={cn(
845
+ "input-tip",
846
+ props.disabled && "input-tip-disabled",
847
+ pcn<CT>(className, "tip"),
848
+ props.disabled && pcn<CT>(className, "tip", "disabled"),
849
+ )}
850
+ >
851
+ {tip}
852
+ </small>
853
+ )}
854
+
855
+ <div
856
+ className={cn(
857
+ "skcontent-container",
858
+ props.disabled && "skcontent-container-disabled",
859
+ !!invalidMessage && "skcontent-container-error",
860
+ pcn<CT>(className, "base"),
861
+ !!invalidMessage && pcn<CT>(className, "base", "error"),
862
+ )}
863
+ >
864
+ <div
865
+ className={cn(
866
+ "skcontent-toolbar",
867
+ pcn<CT>(className, "toolbar"),
868
+ )}
869
+ >
870
+ {(toolbarControl || DEFAULT_TOOLBAR_CONTROLS).map(renderControlItem)}
871
+ </div>
872
+
873
+ <div
874
+ ref={(node) => {
875
+ (editorRef as any).current = node;
876
+ if (typeof ref === "function") ref(node);
877
+ else if (ref && "current" in ref) (ref as any).current = node;
878
+ }}
879
+ id={randomId}
880
+ contentEditable={!props.disabled}
881
+ suppressContentEditableWarning
882
+ className={cn(
883
+ "skcontent-editor",
884
+ props.disabled && "skcontent-editor-disabled",
885
+ pcn<CT>(className, "editor"),
886
+ )}
887
+ onInput={() => {
888
+ saveSelection();
889
+ handleEditorChange();
890
+ updateActiveStates();
891
+ }}
892
+ onKeyDown={handleKeyDown}
893
+ onMouseUp={() => {
894
+ saveSelection();
895
+ updateActiveStates();
896
+ }}
897
+ onKeyUp={() => {
898
+ saveSelection();
899
+ updateActiveStates();
900
+ }}
901
+ onFocus={() => {
902
+ inputHandler.setFocus(true);
903
+ setShowColorPicker(false);
904
+ }}
905
+ onBlur={() => {
906
+ setTimeout(() => inputHandler.setFocus(false), 150);
907
+ }}
908
+ data-placeholder={props.placeholder || "Write content..."}
909
+ />
910
+ </div>
911
+
912
+ {invalidMessage && (
913
+ <small
914
+ className={cn(
915
+ "input-error-message",
916
+ pcn<CT>(className, "error"),
917
+ )}
918
+ >
919
+ {invalidMessage}
920
+ </small>
921
+ )}
922
+ </div>
923
+ );
924
+ }