open-text-editor-latest 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1021 @@
1
+ import React, { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
2
+ import {
3
+ Undo, Redo, Bold, Italic, Underline, Strikethrough, Code,
4
+ AlignLeft, AlignCenter, AlignRight, AlignJustify,
5
+ Link as LinkIcon, Image as ImageIcon, Table as TableIcon,
6
+ Indent, Outdent, RemoveFormatting, ChevronDown, X,
7
+ List, ListOrdered, Quote, Minus, Type, Highlighter,
8
+ Superscript, Subscript, Trash2, Maximize2,
9
+ ArrowUp, ArrowDown, ArrowLeft, ArrowRight, MoreHorizontal,
10
+ GripVertical, FileCode
11
+ } from 'lucide-react';
12
+
13
+ const DEFAULT_INITIAL_CONTENT = "";
14
+
15
+ const OpenTextEditor = forwardRef(({
16
+ initialValue = DEFAULT_INITIAL_CONTENT,
17
+ onChange,
18
+ className = "w-full h-full",
19
+ placeholder = "Start typing..."
20
+ }, ref) => {
21
+ const editorRef = useRef(null);
22
+ const sourceRef = useRef(null);
23
+ const fileInputRef = useRef(null);
24
+ const textColorRef = useRef(null);
25
+ const bgColorRef = useRef(null);
26
+ const savedSelection = useRef(null); // Track last valid selection in editor
27
+
28
+ const [activeFormats, setActiveFormats] = useState({});
29
+ const [showHeadingDropdown, setShowHeadingDropdown] = useState(false);
30
+ const [showFontDropdown, setShowFontDropdown] = useState(false);
31
+ const [showFontSizeDropdown, setShowFontSizeDropdown] = useState(false);
32
+ const [headingLabel, setHeadingLabel] = useState('Normal');
33
+ const [fontLabel, setFontLabel] = useState('Sans Serif');
34
+ const [fontSizeLabel, setFontSizeLabel] = useState('16px');
35
+ const [showTableModal, setShowTableModal] = useState(false);
36
+ const [tableRows, setTableRows] = useState(3);
37
+ const [tableCols, setTableCols] = useState(3);
38
+ const [isEmpty, setIsEmpty] = useState(true);
39
+
40
+ // --- Source View State ---
41
+ const [isSourceMode, setIsSourceMode] = useState(false);
42
+ // Initialize sourceContent with initialValue to prevent empty restore on first toggle
43
+ const [sourceContent, setSourceContent] = useState(initialValue || "");
44
+
45
+ // --- Selection & Resizing State ---
46
+ const [selectedNode, setSelectedNode] = useState(null);
47
+ const [activeCell, setActiveCell] = useState(null);
48
+ const [overlayStyle, setOverlayStyle] = useState(null);
49
+
50
+ // Array to store position/info for all column resize handles
51
+ const [colResizers, setColResizers] = useState([]);
52
+
53
+ const [isResizing, setIsResizing] = useState(false);
54
+ const [isColResizing, setIsColResizing] = useState(false);
55
+
56
+ const resizeRef = useRef({ startX: 0, startWidth: 0, target: null });
57
+ const colResizeRef = useRef({ startX: 0, startWidth: 0, target: null });
58
+
59
+ // --- External API (Plugins) ---
60
+ const restoreSelection = () => {
61
+ if (savedSelection.current) {
62
+ const selection = window.getSelection();
63
+ selection.removeAllRanges();
64
+ selection.addRange(savedSelection.current);
65
+ } else {
66
+ editorRef.current?.focus();
67
+ }
68
+ };
69
+
70
+ useImperativeHandle(ref, () => ({
71
+ focus: () => editorRef.current?.focus(),
72
+ getHtml: () => editorRef.current?.innerHTML || "",
73
+ setHtml: (html) => {
74
+ if (editorRef.current) {
75
+ editorRef.current.innerHTML = html;
76
+ setSourceContent(html);
77
+ handleInput(); // Update empty state
78
+ }
79
+ },
80
+ insertHtml: (html) => {
81
+ restoreSelection();
82
+ execCommand('insertHTML', html);
83
+ },
84
+ execCommand: (command, value) => {
85
+ restoreSelection();
86
+ execCommand(command, value);
87
+ },
88
+ insertTable: (rows, cols) => {
89
+ // Allows external app to trigger insert table with specific dims
90
+ insertTable(rows, cols);
91
+ },
92
+ tableAction: (action) => {
93
+ // Allows external app to modify table structure (add col/row)
94
+ // Note: Requires activeCell to be set (user clicked inside table)
95
+ restoreSelection();
96
+ tableAction(action);
97
+ }
98
+ }));
99
+
100
+ // --- Initialization & Prop Updates ---
101
+
102
+ useEffect(() => {
103
+ // Handle initial load and subsequent prop updates for HTML content
104
+ if (editorRef.current && !isSourceMode) {
105
+ if (initialValue && initialValue !== editorRef.current.innerHTML) {
106
+ editorRef.current.innerHTML = initialValue;
107
+ setSourceContent(initialValue); // Sync source content as well
108
+ setIsEmpty(false);
109
+ } else if (!initialValue && !editorRef.current.innerHTML) {
110
+ editorRef.current.innerHTML = '<p><br/></p>';
111
+ setSourceContent('<p><br/></p>');
112
+ setIsEmpty(true);
113
+ }
114
+ }
115
+ }, [initialValue, isSourceMode]);
116
+
117
+ // --- Editor Command Handlers ---
118
+
119
+ const handleInput = () => {
120
+ checkActiveFormats();
121
+ updateOverlay();
122
+
123
+ if (editorRef.current) {
124
+ const text = editorRef.current.textContent;
125
+ setIsEmpty(!text || text.trim() === '');
126
+
127
+ if (onChange) {
128
+ onChange(editorRef.current.innerHTML);
129
+ }
130
+ }
131
+ };
132
+
133
+ // Handle Source View Input
134
+ const handleSourceInput = (e) => {
135
+ const newHtml = e.target.value;
136
+ setSourceContent(newHtml);
137
+ if (onChange) {
138
+ onChange(newHtml);
139
+ }
140
+ };
141
+
142
+ const toggleSourceMode = () => {
143
+ if (isSourceMode) {
144
+ // Switch TO Visual Mode
145
+ setIsSourceMode(false);
146
+ } else {
147
+ // Switch TO Source Mode
148
+ if (editorRef.current) {
149
+ setSourceContent(editorRef.current.innerHTML);
150
+
151
+ // Clear selection overlays
152
+ setSelectedNode(null);
153
+ setOverlayStyle(null);
154
+ setActiveCell(null);
155
+ setColResizers([]);
156
+ }
157
+ setIsSourceMode(true);
158
+ }
159
+ };
160
+
161
+ // Restore content when switching back to Visual Mode
162
+ useEffect(() => {
163
+ if (!isSourceMode && editorRef.current) {
164
+ if (editorRef.current.innerHTML !== sourceContent) {
165
+ editorRef.current.innerHTML = sourceContent || '<p><br/></p>';
166
+ handleInput();
167
+ }
168
+ }
169
+ }, [isSourceMode]);
170
+
171
+ const execCommand = (command, value = null) => {
172
+ if (isSourceMode) return;
173
+ document.execCommand(command, false, value);
174
+ handleInput();
175
+ editorRef.current?.focus();
176
+ };
177
+
178
+ const handleHeading = (tag, label) => {
179
+ execCommand('formatBlock', tag);
180
+ setHeadingLabel(label);
181
+ setShowHeadingDropdown(false);
182
+ };
183
+
184
+ const handleFont = (font, label) => {
185
+ execCommand('fontName', font);
186
+ setFontLabel(label);
187
+ setShowFontDropdown(false);
188
+ };
189
+
190
+ const handleFontSize = (size, label) => {
191
+ execCommand('fontSize', size);
192
+ setFontSizeLabel(label);
193
+ setShowFontSizeDropdown(false);
194
+ };
195
+
196
+ const handleImageUpload = (event) => {
197
+ const file = event.target.files[0];
198
+ if (file) {
199
+ const reader = new FileReader();
200
+ reader.onload = (e) => {
201
+ const imgHtml = `<img src="${e.target.result}" style="width: 300px; max-width: 100%; height: auto; border-radius: 4px; display: inline-block; cursor: pointer; border: 1px solid transparent;" draggable="true" />`;
202
+ execCommand('insertHTML', imgHtml);
203
+ };
204
+ reader.readAsDataURL(file);
205
+ }
206
+ event.target.value = '';
207
+ };
208
+
209
+ const insertLink = () => {
210
+ const url = prompt('Enter URL:');
211
+ if (url) execCommand('createLink', url);
212
+ };
213
+
214
+ const insertTable = (overrideRows, overrideCols) => {
215
+ restoreSelection();
216
+
217
+ // Use override args if provided (from external API), else use state
218
+ const r = overrideRows !== undefined ? overrideRows : tableRows;
219
+ const c = overrideCols !== undefined ? overrideCols : tableCols;
220
+
221
+ const rows = parseInt(r) || 3;
222
+ const cols = parseInt(c) || 3;
223
+
224
+ const tableHtml = `
225
+ <table style="border-collapse: collapse; width: 100%; margin: 1em 0; border: 1px solid #e5e7eb; table-layout: fixed;">
226
+ <tbody>
227
+ ${Array(rows).fill(0).map(() => `
228
+ <tr>
229
+ ${Array(cols).fill(0).map(() => `
230
+ <td style="border: 1px solid #d1d5db; padding: 8px; min-width: 30px; position: relative; width: ${100/cols}%;">
231
+ <br />
232
+ </td>
233
+ `).join('')}
234
+ </tr>
235
+ `).join('')}
236
+ </tbody>
237
+ </table>
238
+ <p><br/></p>
239
+ `;
240
+
241
+ execCommand('insertHTML', tableHtml);
242
+ setShowTableModal(false);
243
+ };
244
+
245
+ // --- Table Operations ---
246
+
247
+ const tableAction = (action) => {
248
+ if (!activeCell || !selectedNode) return;
249
+ if (selectedNode.tagName !== 'TABLE') return;
250
+
251
+ const row = activeCell.closest('tr');
252
+ if (!row) return;
253
+
254
+ const table = selectedNode;
255
+ const tbody = table.querySelector('tbody');
256
+ const rowIndex = row.rowIndex;
257
+ const colIndex = activeCell.cellIndex;
258
+
259
+ if (colIndex === -1) return;
260
+
261
+ const createCell = () => {
262
+ const td = document.createElement('td');
263
+ td.style.border = '1px solid #d1d5db';
264
+ td.style.padding = '8px';
265
+ td.style.minWidth = '30px';
266
+ td.innerHTML = '<br>';
267
+ return td;
268
+ };
269
+
270
+ if (action === 'row-above') {
271
+ const newRow = row.cloneNode(false);
272
+ Array.from(row.children).forEach(() => newRow.appendChild(createCell()));
273
+ tbody.insertBefore(newRow, row);
274
+ } else if (action === 'row-below') {
275
+ const newRow = row.cloneNode(false);
276
+ Array.from(row.children).forEach(() => newRow.appendChild(createCell()));
277
+ tbody.insertBefore(newRow, row.nextSibling);
278
+ } else if (action === 'col-left') {
279
+ Array.from(table.rows).forEach(tr => {
280
+ const refCell = tr.children[colIndex];
281
+ if (refCell) tr.insertBefore(createCell(), refCell);
282
+ });
283
+ } else if (action === 'col-right') {
284
+ Array.from(table.rows).forEach(tr => {
285
+ const refCell = tr.children[colIndex];
286
+ if (refCell) tr.insertBefore(createCell(), refCell.nextSibling);
287
+ });
288
+ } else if (action === 'del-row') {
289
+ row.remove();
290
+ if (tbody.children.length === 0) {
291
+ table.remove();
292
+ setSelectedNode(null);
293
+ setActiveCell(null);
294
+ } else {
295
+ if (!activeCell.isConnected) setActiveCell(null);
296
+ }
297
+ } else if (action === 'del-col') {
298
+ Array.from(table.rows).forEach(tr => {
299
+ if (tr.children[colIndex]) tr.children[colIndex].remove();
300
+ });
301
+ if (table.rows[0] && table.rows[0].children.length === 0) {
302
+ table.remove();
303
+ setSelectedNode(null);
304
+ setActiveCell(null);
305
+ } else {
306
+ if (!activeCell.isConnected) setActiveCell(null);
307
+ }
308
+ } else if (action === 'del-table') {
309
+ table.remove();
310
+ setSelectedNode(null);
311
+ setActiveCell(null);
312
+ }
313
+
314
+ handleInput();
315
+ updateOverlay();
316
+ };
317
+
318
+ // --- Selection & Overlay Logic ---
319
+
320
+ const updateOverlay = useCallback(() => {
321
+ if (!editorRef.current || isSourceMode) return;
322
+
323
+ // 1. Update Selection Box (Image/Table border)
324
+ if (selectedNode && selectedNode.isConnected) {
325
+ const rect = selectedNode.getBoundingClientRect();
326
+ const editorRect = editorRef.current.getBoundingClientRect();
327
+
328
+ setOverlayStyle({
329
+ top: rect.top - editorRect.top,
330
+ left: rect.left - editorRect.left,
331
+ width: rect.width,
332
+ height: rect.height,
333
+ });
334
+ } else {
335
+ setOverlayStyle(null);
336
+ }
337
+
338
+ // 2. Update Column Resize Handles
339
+ const table = selectedNode?.tagName === 'TABLE' ? selectedNode : activeCell?.closest('table');
340
+
341
+ if (table && table.isConnected) {
342
+ const tableRect = table.getBoundingClientRect();
343
+ const editorRect = editorRef.current.getBoundingClientRect();
344
+ const newResizers = [];
345
+
346
+ const firstRow = table.rows[0];
347
+ if (firstRow) {
348
+ Array.from(firstRow.cells).forEach((cell) => {
349
+ const cellRect = cell.getBoundingClientRect();
350
+ newResizers.push({
351
+ left: (cellRect.left - editorRect.left) + cellRect.width,
352
+ top: tableRect.top - editorRect.top,
353
+ height: tableRect.height,
354
+ cell: cell
355
+ });
356
+ });
357
+ }
358
+ setColResizers(newResizers);
359
+ } else {
360
+ setColResizers([]);
361
+ }
362
+
363
+ }, [selectedNode, activeCell, isSourceMode]);
364
+
365
+ useEffect(() => {
366
+ document.execCommand('enableObjectResizing', false, 'false');
367
+
368
+ const handleScroll = () => {
369
+ updateOverlay();
370
+ };
371
+ const handleResize = () => {
372
+ updateOverlay();
373
+ };
374
+
375
+ const scrollContainer = editorRef.current;
376
+ if (scrollContainer) {
377
+ scrollContainer.addEventListener('scroll', handleScroll);
378
+ }
379
+ window.addEventListener('resize', handleResize);
380
+
381
+ return () => {
382
+ if (scrollContainer) scrollContainer.removeEventListener('scroll', handleScroll);
383
+ window.removeEventListener('resize', handleResize);
384
+ };
385
+ }, [updateOverlay]);
386
+
387
+ const handleEditorClick = (e) => {
388
+ if (isSourceMode) return;
389
+ const target = e.target;
390
+
391
+ const cell = target.closest('td') || target.closest('th');
392
+ const tableTarget = target.closest('table');
393
+ const imgTarget = target.tagName === 'IMG' ? target : target.closest('img');
394
+
395
+ if (cell) {
396
+ setActiveCell(cell);
397
+ const table = cell.closest('table');
398
+ if (table && selectedNode !== table) {
399
+ setSelectedNode(table);
400
+ }
401
+ } else {
402
+ if (!isColResizing) setActiveCell(null);
403
+ }
404
+
405
+ if (imgTarget) {
406
+ if (selectedNode !== imgTarget) {
407
+ setSelectedNode(imgTarget);
408
+ }
409
+ return;
410
+ }
411
+
412
+ if (tableTarget && tableTarget !== editorRef.current) {
413
+ if (selectedNode !== tableTarget) {
414
+ setSelectedNode(tableTarget);
415
+ }
416
+ return;
417
+ }
418
+
419
+ if (!isResizing && !isColResizing && selectedNode && !tableTarget && !imgTarget && !cell) {
420
+ setSelectedNode(null);
421
+ setOverlayStyle(null);
422
+ setActiveCell(null);
423
+ setColResizers([]);
424
+ }
425
+
426
+ checkActiveFormats();
427
+ };
428
+
429
+ const handleContextMenu = (e) => {
430
+ if (isSourceMode) return;
431
+ const target = e.target;
432
+ const imgTarget = target.tagName === 'IMG' ? target : target.closest('img');
433
+ const cellTarget = target.closest('td') || target.closest('th');
434
+ const tableTarget = target.closest('table');
435
+
436
+ if (imgTarget) {
437
+ e.preventDefault(); // Prevent browser context menu
438
+ if (selectedNode !== imgTarget) {
439
+ setSelectedNode(imgTarget);
440
+ }
441
+ setTimeout(updateOverlay, 0);
442
+ return;
443
+ }
444
+
445
+ if (cellTarget || tableTarget) {
446
+ e.preventDefault();
447
+
448
+ // Determine table
449
+ const table = tableTarget || (cellTarget ? cellTarget.closest('table') : null);
450
+
451
+ // Set active cell if clicked on one
452
+ if (cellTarget) {
453
+ setActiveCell(cellTarget);
454
+ }
455
+
456
+ // Select the table
457
+ if (table && selectedNode !== table) {
458
+ setSelectedNode(table);
459
+ }
460
+
461
+ setTimeout(updateOverlay, 0);
462
+ }
463
+ };
464
+
465
+ // --- Resize Logic (Table & Image) ---
466
+
467
+ const startResize = (e) => {
468
+ e.preventDefault();
469
+ e.stopPropagation();
470
+ if (!selectedNode) return;
471
+
472
+ setIsResizing(true);
473
+ resizeRef.current = {
474
+ startX: e.clientX,
475
+ startWidth: selectedNode.offsetWidth,
476
+ target: selectedNode
477
+ };
478
+
479
+ document.addEventListener('mousemove', handleMouseMove);
480
+ document.addEventListener('mouseup', handleMouseUp);
481
+ };
482
+
483
+ const handleMouseMove = (e) => {
484
+ if (!resizeRef.current.target) return;
485
+
486
+ const dx = e.clientX - resizeRef.current.startX;
487
+ const newWidth = Math.max(50, resizeRef.current.startWidth + dx);
488
+ resizeRef.current.target.style.width = `${newWidth}px`;
489
+ updateOverlay();
490
+ };
491
+
492
+ const handleMouseUp = () => {
493
+ setIsResizing(false);
494
+ resizeRef.current = { startX: 0, startWidth: 0, target: null };
495
+ document.removeEventListener('mousemove', handleMouseMove);
496
+ document.removeEventListener('mouseup', handleMouseUp);
497
+ };
498
+
499
+ // --- Column Resize Logic ---
500
+
501
+ const startColResize = (e, cell) => {
502
+ e.preventDefault();
503
+ e.stopPropagation();
504
+ if (!cell) return;
505
+
506
+ setIsColResizing(true);
507
+ colResizeRef.current = {
508
+ startX: e.clientX,
509
+ startWidth: cell.offsetWidth,
510
+ target: cell
511
+ };
512
+
513
+ document.addEventListener('mousemove', handleColMouseMove);
514
+ document.addEventListener('mouseup', handleColMouseUp);
515
+ };
516
+
517
+ const handleColMouseMove = (e) => {
518
+ if (!colResizeRef.current.target) return;
519
+ const dx = e.clientX - colResizeRef.current.startX;
520
+ const newWidth = Math.max(20, colResizeRef.current.startWidth + dx);
521
+ colResizeRef.current.target.style.width = `${newWidth}px`;
522
+ updateOverlay();
523
+ };
524
+
525
+ const handleColMouseUp = () => {
526
+ setIsColResizing(false);
527
+ colResizeRef.current = { startX: 0, startWidth: 0, target: null };
528
+ document.removeEventListener('mousemove', handleColMouseMove);
529
+ document.removeEventListener('mouseup', handleColMouseUp);
530
+ };
531
+
532
+ // --- Node Manipulation Actions ---
533
+
534
+ const alignNode = (alignment) => {
535
+ if (!selectedNode) return;
536
+
537
+ // Reset layout styles
538
+ selectedNode.style.display = '';
539
+ selectedNode.style.float = '';
540
+ selectedNode.style.margin = '';
541
+ selectedNode.style.outline = 'none';
542
+
543
+ if (alignment === 'left') {
544
+ selectedNode.style.float = 'left';
545
+ selectedNode.style.margin = '0 1rem 1rem 0';
546
+ } else if (alignment === 'right') {
547
+ selectedNode.style.float = 'right';
548
+ selectedNode.style.margin = '0 0 1rem 1rem';
549
+ } else if (alignment === 'center') {
550
+ selectedNode.style.display = 'block';
551
+ selectedNode.style.marginLeft = 'auto';
552
+ selectedNode.style.marginRight = 'auto';
553
+ } else {
554
+ // Default / Reset
555
+ selectedNode.style.display = 'inline-block';
556
+ }
557
+
558
+ // Force immediate update of overlay after style change
559
+ setTimeout(updateOverlay, 0);
560
+ };
561
+
562
+ // --- Existing State Check ---
563
+
564
+ const checkActiveFormats = () => {
565
+ if (!editorRef.current || isSourceMode) return;
566
+ const selection = window.getSelection();
567
+
568
+ if (selection.rangeCount > 0) {
569
+ const range = selection.getRangeAt(0);
570
+ if (editorRef.current.contains(range.commonAncestorContainer)) {
571
+ savedSelection.current = range.cloneRange();
572
+
573
+ let container = range.commonAncestorContainer;
574
+ if (container.nodeType === 3) container = container.parentNode;
575
+
576
+ const cell = container.closest('td') || container.closest('th');
577
+ const table = container.closest('table');
578
+
579
+ if (cell) {
580
+ if (activeCell !== cell) setActiveCell(cell);
581
+ }
582
+
583
+ if (table) {
584
+ if (selectedNode !== table) setSelectedNode(table);
585
+ } else if (!isResizing && !isColResizing && !container.closest('img')) {
586
+ if(selectedNode && selectedNode.tagName === 'TABLE') {
587
+ setSelectedNode(null);
588
+ setActiveCell(null);
589
+ }
590
+ }
591
+
592
+ const fontSizeVal = document.queryCommandValue('fontSize');
593
+ const sizeMap = {
594
+ '1': '10px', '2': '13px', '3': '16px', '4': '18px',
595
+ '5': '24px', '6': '32px', '7': '48px'
596
+ };
597
+ if (fontSizeVal && sizeMap[fontSizeVal]) {
598
+ setFontSizeLabel(sizeMap[fontSizeVal]);
599
+ } else {
600
+ setFontSizeLabel('16px');
601
+ }
602
+
603
+ const fontFace = document.queryCommandValue('fontName');
604
+ if (fontFace.includes('Serif')) setFontLabel('Serif');
605
+ else if (fontFace.includes('Mono')) setFontLabel('Monospace');
606
+ else setFontLabel('Sans Serif');
607
+ }
608
+ }
609
+
610
+ const formats = {
611
+ bold: document.queryCommandState('bold'),
612
+ italic: document.queryCommandState('italic'),
613
+ underline: document.queryCommandState('underline'),
614
+ strikethrough: document.queryCommandState('strikethrough'),
615
+ subscript: document.queryCommandState('subscript'),
616
+ superscript: document.queryCommandState('superscript'),
617
+ justifyLeft: document.queryCommandState('justifyLeft'),
618
+ justifyCenter: document.queryCommandState('justifyCenter'),
619
+ justifyRight: document.queryCommandState('justifyRight'),
620
+ justifyFull: document.queryCommandState('justifyFull'),
621
+ insertUnorderedList: document.queryCommandState('insertUnorderedList'),
622
+ insertOrderedList: document.queryCommandState('insertOrderedList'),
623
+ };
624
+ setActiveFormats(formats);
625
+
626
+ if (selection.rangeCount > 0) {
627
+ try {
628
+ const container = selection.getRangeAt(0).commonAncestorContainer;
629
+ const parentBlock = container.nodeType === 3 ? container.parentNode : container;
630
+ const tagName = parentBlock?.tagName?.toLowerCase();
631
+
632
+ if (tagName === 'h1') setHeadingLabel('Heading 1');
633
+ else if (tagName === 'h2') setHeadingLabel('Heading 2');
634
+ else if (tagName === 'h3') setHeadingLabel('Heading 3');
635
+ else if (tagName === 'blockquote') setHeadingLabel('Quote');
636
+ else setHeadingLabel('Normal');
637
+
638
+ } catch (e) { }
639
+ }
640
+ };
641
+
642
+ useEffect(() => {
643
+ const handleSelectionChange = () => checkActiveFormats();
644
+ document.addEventListener('selectionchange', handleSelectionChange);
645
+ return () => document.removeEventListener('selectionchange', handleSelectionChange);
646
+ }, [activeCell, selectedNode, isResizing, isColResizing, isSourceMode]);
647
+
648
+ const ToolbarButton = ({ onClick, isActive, icon: Icon, title, hasArrow = false, disabled = false }) => (
649
+ <button
650
+ type="button"
651
+ onClick={onClick}
652
+ disabled={disabled}
653
+ title={title}
654
+ className={`p-1.5 rounded-md transition-colors duration-150 flex items-center justify-center gap-1
655
+ ${isActive
656
+ ? 'bg-indigo-100 text-indigo-700'
657
+ : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'}
658
+ ${disabled ? 'opacity-30 cursor-not-allowed' : ''}
659
+ `}
660
+ >
661
+ <Icon size={18} strokeWidth={2.5} />
662
+ {hasArrow && <ChevronDown size={12} className="opacity-50" />}
663
+ </button>
664
+ );
665
+
666
+ const Separator = () => (
667
+ <div className="w-px h-6 bg-gray-300 mx-1 self-center" />
668
+ );
669
+
670
+ return (
671
+ <div className={`bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden flex flex-col font-sans text-gray-800 min-h-[300px] ${className}`}>
672
+
673
+ {/* --- Toolbar --- */}
674
+ <div className="flex flex-wrap items-center gap-0.5 p-2 border-b border-gray-200 bg-white sticky top-0 z-20 select-none shadow-sm">
675
+
676
+ {/* Source View Toggle (Leftmost or Rightmost) - Left for prominence */}
677
+ <ToolbarButton
678
+ onClick={toggleSourceMode}
679
+ isActive={isSourceMode}
680
+ icon={FileCode}
681
+ title={isSourceMode ? "Switch to Visual Editor" : "View Source / HTML"}
682
+ />
683
+ <Separator />
684
+
685
+ <div className="flex items-center">
686
+ <ToolbarButton onClick={() => execCommand('undo')} icon={Undo} title="Undo" disabled={isSourceMode} />
687
+ <ToolbarButton onClick={() => execCommand('redo')} icon={Redo} title="Redo" disabled={isSourceMode} />
688
+ </div>
689
+ <Separator />
690
+
691
+ <div className="flex items-center gap-1">
692
+ <div className="relative">
693
+ <button
694
+ disabled={isSourceMode}
695
+ onClick={() => setShowHeadingDropdown(!showHeadingDropdown)}
696
+ className={`flex items-center justify-between gap-2 px-2 py-1.5 text-sm font-medium rounded-md min-w-[100px] ${isSourceMode ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100'}`}
697
+ >
698
+ <span className="truncate">{headingLabel}</span>
699
+ <ChevronDown size={14} />
700
+ </button>
701
+ {showHeadingDropdown && !isSourceMode && (
702
+ <div className="absolute top-full left-0 mt-1 w-40 bg-white border border-gray-200 rounded shadow-lg py-1 z-30">
703
+ {[
704
+ { label: 'Normal', tag: 'P' },
705
+ { label: 'Heading 1', tag: 'H1' },
706
+ { label: 'Heading 2', tag: 'H2' },
707
+ { label: 'Heading 3', tag: 'H3' },
708
+ { label: 'Quote', tag: 'BLOCKQUOTE' },
709
+ ].map((item) => (
710
+ <button
711
+ key={item.tag}
712
+ onClick={() => handleHeading(item.tag, item.label)}
713
+ className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
714
+ >
715
+ {item.label}
716
+ </button>
717
+ ))}
718
+ </div>
719
+ )}
720
+ </div>
721
+ {/* Font Family Dropdown */}
722
+ <div className="relative hidden md:block">
723
+ <button
724
+ disabled={isSourceMode}
725
+ onClick={() => setShowFontDropdown(!showFontDropdown)}
726
+ className={`flex items-center justify-between gap-2 px-2 py-1.5 text-sm font-medium rounded-md min-w-[130px] ${isSourceMode ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100'}`}
727
+ >
728
+ <span className="truncate">{fontLabel}</span>
729
+ <ChevronDown size={14} />
730
+ </button>
731
+ {showFontDropdown && !isSourceMode && (
732
+ <div className="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg py-1 z-30 max-h-60 overflow-y-auto">
733
+ {[
734
+ { label: 'Sans Serif', value: 'ui-sans-serif, system-ui, sans-serif' },
735
+ { label: 'Serif', value: 'ui-serif, Georgia, serif' },
736
+ { label: 'Monospace', value: 'ui-monospace, monospace' },
737
+ { label: 'Arial', value: 'Arial, Helvetica, sans-serif' },
738
+ { label: 'Arial Black', value: '"Arial Black", Gadget, sans-serif' },
739
+ { label: 'Comic Sans MS', value: '"Comic Sans MS", "Comic Sans", cursive' },
740
+ { label: 'Courier New', value: '"Courier New", Courier, monospace' },
741
+ { label: 'Georgia', value: 'Georgia, serif' },
742
+ { label: 'Impact', value: 'Impact, Charcoal, sans-serif' },
743
+ { label: 'Lucida Console', value: '"Lucida Console", Monaco, monospace' },
744
+ { label: 'Tahoma', value: 'Tahoma, Geneva, sans-serif' },
745
+ { label: 'Times New Roman', value: '"Times New Roman", Times, serif' },
746
+ { label: 'Trebuchet MS', value: '"Trebuchet MS", Helvetica, sans-serif' },
747
+ { label: 'Verdana', value: 'Verdana, Geneva, sans-serif' },
748
+ ].map((item) => (
749
+ <button
750
+ key={item.value}
751
+ onClick={() => handleFont(item.value, item.label)}
752
+ className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
753
+ style={{ fontFamily: item.value }}
754
+ >
755
+ {item.label}
756
+ </button>
757
+ ))}
758
+ </div>
759
+ )}
760
+ </div>
761
+
762
+ {/* Font Size Dropdown */}
763
+ <div className="relative hidden md:block">
764
+ <button
765
+ disabled={isSourceMode}
766
+ onClick={() => setShowFontSizeDropdown(!showFontSizeDropdown)}
767
+ className={`flex items-center justify-between gap-2 px-2 py-1.5 text-sm font-medium rounded-md min-w-[70px] ${isSourceMode ? 'text-gray-300 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-100'}`}
768
+ >
769
+ <span className="truncate">{fontSizeLabel}</span>
770
+ <ChevronDown size={14} />
771
+ </button>
772
+ {showFontSizeDropdown && !isSourceMode && (
773
+ <div className="absolute top-full left-0 mt-1 w-24 bg-white border border-gray-200 rounded shadow-lg py-1 z-30 max-h-60 overflow-y-auto">
774
+ {[
775
+ { label: '10px', value: '1' },
776
+ { label: '13px', value: '2' },
777
+ { label: '16px', value: '3' },
778
+ { label: '18px', value: '4' },
779
+ { label: '24px', value: '5' },
780
+ { label: '32px', value: '6' },
781
+ { label: '48px', value: '7' },
782
+ ].map((item) => (
783
+ <button
784
+ key={item.value}
785
+ onClick={() => handleFontSize(item.value, item.label)}
786
+ className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
787
+ >
788
+ {item.label}
789
+ </button>
790
+ ))}
791
+ </div>
792
+ )}
793
+ </div>
794
+ </div>
795
+ <Separator />
796
+
797
+ {/* Formats */}
798
+ <div className="flex items-center">
799
+ <ToolbarButton onClick={() => execCommand('bold')} isActive={activeFormats.bold} icon={Bold} title="Bold" disabled={isSourceMode} />
800
+ <ToolbarButton onClick={() => execCommand('italic')} isActive={activeFormats.italic} icon={Italic} title="Italic" disabled={isSourceMode} />
801
+ <ToolbarButton onClick={() => execCommand('underline')} isActive={activeFormats.underline} icon={Underline} title="Underline" disabled={isSourceMode} />
802
+ <ToolbarButton onClick={() => execCommand('strikethrough')} isActive={activeFormats.strikethrough} icon={Strikethrough} title="Strikethrough" disabled={isSourceMode} />
803
+ </div>
804
+ <Separator />
805
+ {/* Sub/Sup/Code */}
806
+ <div className="flex items-center">
807
+ <ToolbarButton onClick={() => execCommand('subscript')} isActive={activeFormats.subscript} icon={Subscript} title="Subscript" disabled={isSourceMode} />
808
+ <ToolbarButton onClick={() => execCommand('superscript')} isActive={activeFormats.superscript} icon={Superscript} title="Superscript" disabled={isSourceMode} />
809
+ <ToolbarButton onClick={() => execCommand('formatBlock', 'PRE')} icon={Code} title="Code Block" disabled={isSourceMode} />
810
+ <ToolbarButton onClick={() => execCommand('formatBlock', 'BLOCKQUOTE')} icon={Quote} title="Blockquote" disabled={isSourceMode} />
811
+ </div>
812
+ <Separator />
813
+ {/* Colors */}
814
+ <div className="flex items-center relative">
815
+ <div className="relative">
816
+ <ToolbarButton onClick={() => textColorRef.current.click()} icon={Type} title="Text Color" disabled={isSourceMode} />
817
+ <input
818
+ type="color"
819
+ ref={textColorRef}
820
+ disabled={isSourceMode}
821
+ className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
822
+ onChange={(e) => execCommand('foreColor', e.target.value)}
823
+ />
824
+ </div>
825
+ <div className="relative">
826
+ <ToolbarButton onClick={() => bgColorRef.current.click()} icon={Highlighter} title="Highlight Color" disabled={isSourceMode} />
827
+ <input
828
+ type="color"
829
+ ref={bgColorRef}
830
+ disabled={isSourceMode}
831
+ defaultValue="#ffff00"
832
+ className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
833
+ onChange={(e) => execCommand('hiliteColor', e.target.value)}
834
+ />
835
+ </div>
836
+ </div>
837
+ <Separator />
838
+ {/* Align/List */}
839
+ <div className="flex items-center">
840
+ <ToolbarButton onClick={() => execCommand('justifyLeft')} isActive={activeFormats.justifyLeft} icon={AlignLeft} title="Align Left" disabled={isSourceMode} />
841
+ <ToolbarButton onClick={() => execCommand('justifyCenter')} isActive={activeFormats.justifyCenter} icon={AlignCenter} title="Align Center" disabled={isSourceMode} />
842
+ <ToolbarButton onClick={() => execCommand('justifyRight')} isActive={activeFormats.justifyRight} icon={AlignRight} title="Align Right" disabled={isSourceMode} />
843
+ <div className="mx-1"></div>
844
+ <ToolbarButton onClick={() => execCommand('insertUnorderedList')} isActive={activeFormats.insertUnorderedList} icon={List} title="Bullet List" disabled={isSourceMode} />
845
+ <ToolbarButton onClick={() => execCommand('insertOrderedList')} isActive={activeFormats.insertOrderedList} icon={ListOrdered} title="Numbered List" disabled={isSourceMode} />
846
+ </div>
847
+ <Separator />
848
+ {/* Indent */}
849
+ <div className="flex items-center">
850
+ <ToolbarButton onClick={() => execCommand('indent')} icon={Indent} title="Indent" disabled={isSourceMode} />
851
+ <ToolbarButton onClick={() => execCommand('outdent')} icon={Outdent} title="Outdent" disabled={isSourceMode} />
852
+ </div>
853
+ <Separator />
854
+ {/* Inserts */}
855
+ <div className="flex items-center">
856
+ <ToolbarButton onClick={insertLink} icon={LinkIcon} title="Insert Link" disabled={isSourceMode} />
857
+ <div className="relative">
858
+ <ToolbarButton onClick={() => fileInputRef.current?.click()} icon={ImageIcon} title="Upload Image" disabled={isSourceMode} />
859
+ <input type="file" accept="image/*" ref={fileInputRef} onChange={handleImageUpload} className="hidden" disabled={isSourceMode} />
860
+ </div>
861
+ <ToolbarButton onClick={() => execCommand('insertHorizontalRule')} icon={Minus} title="Horizontal Line" disabled={isSourceMode} />
862
+ <div className="relative">
863
+ <ToolbarButton onClick={() => setShowTableModal(!showTableModal)} icon={TableIcon} title="Insert Table" disabled={isSourceMode} />
864
+ {showTableModal && !isSourceMode && (
865
+ <div
866
+ className="absolute top-full left-0 mt-2 p-3 bg-white border border-gray-200 shadow-xl rounded-lg z-30 w-48"
867
+ onClick={(e) => e.stopPropagation()}
868
+ >
869
+ <h4 className="text-xs font-semibold text-gray-500 mb-2 uppercase tracking-wider">Table Size</h4>
870
+ <div className="flex flex-col gap-2 mb-3">
871
+ <label className="text-sm flex justify-between">Rows: <input type="number" min="1" max="10" value={tableRows} onChange={(e) => setTableRows(e.target.value)} className="w-12 border border-gray-300 rounded px-1 text-right" /></label>
872
+ <label className="text-sm flex justify-between">Cols: <input type="number" min="1" max="10" value={tableCols} onChange={(e) => setTableCols(e.target.value)} className="w-12 border border-gray-300 rounded px-1 text-right" /></label>
873
+ </div>
874
+ <button onClick={() => insertTable()} className="w-full bg-indigo-600 text-white text-xs font-bold py-1.5 rounded hover:bg-indigo-700 transition-colors">Insert Table</button>
875
+ </div>
876
+ )}
877
+ </div>
878
+ </div>
879
+ <div className="flex-grow" />
880
+ <div className="flex items-center border-l border-gray-200 pl-2">
881
+ <ToolbarButton onClick={() => execCommand('removeFormat')} icon={RemoveFormatting} title="Clear Formatting" disabled={isSourceMode} />
882
+ <div className="ml-1 pl-1 border-l border-gray-200 hidden md:block">
883
+ <ToolbarButton onClick={() => {}} icon={X} title="Close" />
884
+ </div>
885
+ </div>
886
+ </div>
887
+
888
+ {/* --- Content Area --- */}
889
+ <div className="flex-grow overflow-hidden flex flex-col relative">
890
+
891
+ {isSourceMode ? (
892
+ <textarea
893
+ ref={sourceRef}
894
+ value={sourceContent}
895
+ onChange={handleSourceInput}
896
+ className="flex-grow p-4 w-full h-full resize-none font-mono text-sm bg-gray-50 text-gray-800 outline-none"
897
+ spellCheck="false"
898
+ />
899
+ ) : (
900
+ <>
901
+ {/* Editor */}
902
+ <div
903
+ ref={editorRef}
904
+ contentEditable
905
+ suppressContentEditableWarning
906
+ className="flex-grow p-4 md:p-8 overflow-y-auto outline-none prose prose-indigo max-w-none prose-lg relative z-0"
907
+ onInput={handleInput}
908
+ onClick={handleEditorClick}
909
+ onContextMenu={handleContextMenu}
910
+ onKeyDown={(e) => {
911
+ if (e.key === 'Tab') { e.preventDefault(); execCommand('insertHTML', '&nbsp;&nbsp;&nbsp;&nbsp;'); }
912
+ }}
913
+ />
914
+
915
+ {/* Placeholder Overlay */}
916
+ {isEmpty && (
917
+ <div className="absolute top-4 left-4 md:top-8 md:left-8 right-4 md:right-8 pointer-events-none">
918
+ <div className="prose prose-indigo max-w-none prose-lg text-gray-400">
919
+ <p>{placeholder}</p>
920
+ </div>
921
+ </div>
922
+ )}
923
+
924
+ {/* --- Interactive Overlay for Resize/Move --- */}
925
+ {selectedNode && overlayStyle && (
926
+ <div
927
+ className="absolute pointer-events-none z-50"
928
+ style={{
929
+ top: overlayStyle.top,
930
+ left: overlayStyle.left,
931
+ width: overlayStyle.width,
932
+ height: overlayStyle.height,
933
+ border: '2px solid #3b82f6', // blue-500
934
+ boxSizing: 'border-box'
935
+ }}
936
+ >
937
+ {/* Toolbar floating above node */}
938
+ <div className="absolute -top-10 left-1/2 transform -translate-x-1/2 bg-white shadow-lg border border-gray-200 rounded-md flex p-1 pointer-events-auto gap-1 whitespace-nowrap">
939
+ {selectedNode.tagName === 'IMG' && (
940
+ <>
941
+ <button onMouseDown={(e) => {e.preventDefault(); alignNode('left')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Align Left"><AlignLeft size={16}/></button>
942
+ <button onMouseDown={(e) => {e.preventDefault(); alignNode('center')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Align Center"><AlignCenter size={16}/></button>
943
+ <button onMouseDown={(e) => {e.preventDefault(); alignNode('right')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Align Right"><AlignRight size={16}/></button>
944
+ </>
945
+ )}
946
+
947
+ {/* Table Specific Options */}
948
+ {selectedNode.tagName === 'TABLE' && activeCell && (
949
+ <>
950
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('row-above')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Insert Row Above"><ArrowUp size={16}/></button>
951
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('row-below')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Insert Row Below"><ArrowDown size={16}/></button>
952
+ <div className="w-px h-4 bg-gray-300 mx-1 self-center"></div>
953
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('col-left')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Insert Col Left"><ArrowLeft size={16}/></button>
954
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('col-right')}} className="p-1 hover:bg-gray-100 rounded text-gray-600" title="Insert Col Right"><ArrowRight size={16}/></button>
955
+ <div className="w-px h-4 bg-gray-300 mx-1 self-center"></div>
956
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('del-row')}} className="p-1 hover:bg-red-50 text-red-500 rounded font-xs flex items-center" title="Delete Row"><Trash2 size={14} className="mr-0.5"/>R</button>
957
+ <button onMouseDown={(e) => {e.preventDefault(); tableAction('del-col')}} className="p-1 hover:bg-red-50 text-red-500 rounded font-xs flex items-center" title="Delete Column"><Trash2 size={14} className="mr-0.5"/>C</button>
958
+ </>
959
+ )}
960
+
961
+ <div className="w-px h-4 bg-gray-300 mx-1 self-center"></div>
962
+ <button onMouseDown={(e) => {e.preventDefault(); selectedNode.tagName === 'TABLE' ? tableAction('del-table') : selectedNode.remove(); setSelectedNode(null);}} className="p-1 hover:bg-red-50 text-red-500 rounded" title={selectedNode.tagName === 'TABLE' ? "Delete Table" : "Delete"}>
963
+ <Trash2 size={16}/>
964
+ </button>
965
+ </div>
966
+
967
+ {/* Resize Handle (Bottom Right) */}
968
+ <div
969
+ className="absolute bottom-1 right-1 w-4 h-4 bg-blue-500 rounded-full cursor-nwse-resize pointer-events-auto shadow-sm hover:scale-125 transition-transform border-2 border-white z-50"
970
+ onMouseDown={startResize}
971
+ />
972
+ </div>
973
+ )}
974
+
975
+ {/* --- Column Resize Handles (All Columns) --- */}
976
+ {colResizers.map((resizer, index) => (
977
+ <div
978
+ key={index}
979
+ className="absolute z-50 cursor-col-resize flex items-center justify-center hover:bg-blue-400 hover:opacity-50 transition-colors"
980
+ style={{
981
+ top: resizer.top,
982
+ left: resizer.left - 4, // Center on border
983
+ width: '8px',
984
+ height: resizer.height,
985
+ pointerEvents: 'auto',
986
+ }}
987
+ onMouseDown={(e) => startColResize(e, resizer.cell)}
988
+ title="Resize Column"
989
+ >
990
+ {/* Visual Line */}
991
+ <div className="w-0.5 h-full bg-blue-500 opacity-0 hover:opacity-100 transition-opacity"></div>
992
+ </div>
993
+ ))}
994
+ </>
995
+ )}
996
+
997
+ </div>
998
+
999
+ <style>{`
1000
+ .prose { display: flow-root; }
1001
+ .prose p { display: flow-root; margin-top: 0.5em; margin-bottom: 0.5em; line-height: 1.4; }
1002
+ .prose table { width: auto; max-width: 100%; table-layout: fixed; border-collapse: collapse; margin: 1em 0; }
1003
+ .prose td, .prose th { border: 1px solid #d1d5db; padding: 0.75rem; vertical-align: top; position: relative; }
1004
+ .prose th { background-color: #f3f4f6; font-weight: 600; }
1005
+ .prose h1 { font-size: 2.25em; margin-top: 0.5em; margin-bottom: 0.5em; line-height: 1.1; font-weight: 800; color: #111827; }
1006
+ .prose h2 { font-size: 1.75em; margin-top: 0.5em; margin-bottom: 0.5em; line-height: 1.2; font-weight: 700; color: #1f2937; }
1007
+ .prose h3 { font-size: 1.5em; margin-top: 0.5em; margin-bottom: 0.5em; line-height: 1.3; font-weight: 600; color: #374151; }
1008
+ .prose ul { list-style-type: disc; padding-left: 1.5em; margin: 0.5em 0; }
1009
+ .prose ol { list-style-type: decimal; padding-left: 1.5em; margin: 0.5em 0; }
1010
+ .prose li { margin: 0.5em 0; }
1011
+ .prose blockquote { border-left: 4px solid #6366f1; padding-left: 1rem; margin: 1rem 0; font-style: italic; color: #4b5563; background: #f9fafb; padding: 1rem; border-radius: 0 0.5rem 0.5rem 0; }
1012
+ .prose pre { background-color: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-family: monospace; }
1013
+ .prose img { border-radius: 0.5rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
1014
+ .prose hr { border: 0; border-top: 1px solid #e5e7eb; margin: 2em 0; }
1015
+ ::selection { background-color: #c7d2fe; color: #1e1b4b; }
1016
+ `}</style>
1017
+ </div>
1018
+ );
1019
+ });
1020
+
1021
+ export default OpenTextEditor;