@jhits/plugin-blog 0.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.
Files changed (75) hide show
  1. package/README.md +216 -0
  2. package/package.json +57 -0
  3. package/src/api/README.md +224 -0
  4. package/src/api/categories.ts +43 -0
  5. package/src/api/check-title.ts +60 -0
  6. package/src/api/handler.ts +419 -0
  7. package/src/api/index.ts +33 -0
  8. package/src/api/route.ts +116 -0
  9. package/src/api/router.ts +114 -0
  10. package/src/api-server.ts +11 -0
  11. package/src/config.ts +161 -0
  12. package/src/hooks/README.md +91 -0
  13. package/src/hooks/index.ts +8 -0
  14. package/src/hooks/useBlog.ts +85 -0
  15. package/src/hooks/useBlogs.ts +123 -0
  16. package/src/index.server.ts +12 -0
  17. package/src/index.tsx +354 -0
  18. package/src/init.tsx +72 -0
  19. package/src/lib/blocks/BlockRenderer.tsx +141 -0
  20. package/src/lib/blocks/index.ts +6 -0
  21. package/src/lib/index.ts +9 -0
  22. package/src/lib/layouts/blocks/ColumnsBlock.tsx +134 -0
  23. package/src/lib/layouts/blocks/SectionBlock.tsx +104 -0
  24. package/src/lib/layouts/blocks/index.ts +8 -0
  25. package/src/lib/layouts/index.ts +52 -0
  26. package/src/lib/layouts/registerLayoutBlocks.ts +59 -0
  27. package/src/lib/mappers/apiMapper.ts +223 -0
  28. package/src/lib/migration/index.ts +6 -0
  29. package/src/lib/migration/mapper.ts +140 -0
  30. package/src/lib/rich-text/RichTextEditor.tsx +826 -0
  31. package/src/lib/rich-text/RichTextPreview.tsx +210 -0
  32. package/src/lib/rich-text/index.ts +10 -0
  33. package/src/lib/utils/blockHelpers.ts +72 -0
  34. package/src/lib/utils/configValidation.ts +137 -0
  35. package/src/lib/utils/index.ts +8 -0
  36. package/src/lib/utils/slugify.ts +79 -0
  37. package/src/registry/BlockRegistry.ts +142 -0
  38. package/src/registry/index.ts +11 -0
  39. package/src/state/EditorContext.tsx +277 -0
  40. package/src/state/index.ts +8 -0
  41. package/src/state/reducer.ts +694 -0
  42. package/src/state/types.ts +160 -0
  43. package/src/types/block.ts +269 -0
  44. package/src/types/index.ts +15 -0
  45. package/src/types/post.ts +165 -0
  46. package/src/utils/README.md +75 -0
  47. package/src/utils/client.ts +122 -0
  48. package/src/utils/index.ts +9 -0
  49. package/src/views/CanvasEditor/BlockWrapper.tsx +459 -0
  50. package/src/views/CanvasEditor/CanvasEditorView.tsx +917 -0
  51. package/src/views/CanvasEditor/EditorBody.tsx +475 -0
  52. package/src/views/CanvasEditor/EditorHeader.tsx +179 -0
  53. package/src/views/CanvasEditor/LayoutContainer.tsx +494 -0
  54. package/src/views/CanvasEditor/SaveConfirmationModal.tsx +233 -0
  55. package/src/views/CanvasEditor/components/CustomBlockItem.tsx +92 -0
  56. package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +130 -0
  57. package/src/views/CanvasEditor/components/LibraryItem.tsx +80 -0
  58. package/src/views/CanvasEditor/components/PrivacySettingsSection.tsx +212 -0
  59. package/src/views/CanvasEditor/components/index.ts +17 -0
  60. package/src/views/CanvasEditor/index.ts +16 -0
  61. package/src/views/PostManager/EmptyState.tsx +42 -0
  62. package/src/views/PostManager/PostActionsMenu.tsx +112 -0
  63. package/src/views/PostManager/PostCards.tsx +192 -0
  64. package/src/views/PostManager/PostFilters.tsx +80 -0
  65. package/src/views/PostManager/PostManagerView.tsx +280 -0
  66. package/src/views/PostManager/PostStats.tsx +81 -0
  67. package/src/views/PostManager/PostTable.tsx +225 -0
  68. package/src/views/PostManager/index.ts +15 -0
  69. package/src/views/Preview/PreviewBridgeView.tsx +64 -0
  70. package/src/views/Preview/index.ts +7 -0
  71. package/src/views/README.md +82 -0
  72. package/src/views/Settings/SettingsView.tsx +298 -0
  73. package/src/views/Settings/index.ts +7 -0
  74. package/src/views/SlugSEO/SlugSEOManagerView.tsx +94 -0
  75. package/src/views/SlugSEO/index.ts +7 -0
@@ -0,0 +1,826 @@
1
+ /**
2
+ * Rich Text Editor Component
3
+ * Provides formatting toolbar (bold, italic, underline, links, colors)
4
+ * Only shows options if client has provided styles for them
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, { useState, useRef, useEffect } from 'react';
10
+ import { Bold, Italic, Underline, Link, Palette } from 'lucide-react';
11
+
12
+ export interface RichTextFormattingConfig {
13
+ /** Whether bold formatting is available */
14
+ bold?: boolean;
15
+ /** Whether italic formatting is available */
16
+ italic?: boolean;
17
+ /** Whether underline formatting is available */
18
+ underline?: boolean;
19
+ /** Whether links are available */
20
+ links?: boolean;
21
+ /** Available colors (array of color values or Tailwind classes) */
22
+ colors?: string[];
23
+ /** Custom CSS classes for formatted text */
24
+ styles?: {
25
+ bold?: string;
26
+ italic?: string;
27
+ underline?: string;
28
+ link?: string;
29
+ /** Color classes mapped by color value */
30
+ colorClasses?: Record<string, string>;
31
+ };
32
+ }
33
+
34
+ export interface RichTextEditorProps {
35
+ /** HTML content value */
36
+ value: string;
37
+ /** Change handler */
38
+ onChange: (html: string) => void;
39
+ /** Placeholder text */
40
+ placeholder?: string;
41
+ /** Formatting configuration */
42
+ formatting?: RichTextFormattingConfig;
43
+ /** Additional CSS classes */
44
+ className?: string;
45
+ /** Whether the editor is focused */
46
+ isFocused?: boolean;
47
+ /** Custom keydown handler (called before default handler) */
48
+ onKeyDown?: (e: React.KeyboardEvent) => void;
49
+ }
50
+
51
+ export function RichTextEditor({
52
+ value,
53
+ onChange,
54
+ placeholder = 'Enter text...',
55
+ formatting,
56
+ className = '',
57
+ isFocused = false,
58
+ onKeyDown: customOnKeyDown,
59
+ }: RichTextEditorProps) {
60
+ const editorRef = useRef<HTMLDivElement>(null);
61
+ const [showToolbar, setShowToolbar] = useState(false);
62
+ const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
63
+ const [showLinkDialog, setShowLinkDialog] = useState(false);
64
+ const [linkUrl, setLinkUrl] = useState('');
65
+ const [showColorPicker, setShowColorPicker] = useState(false);
66
+ const [selectedText, setSelectedText] = useState('');
67
+ const [currentColor, setCurrentColor] = useState<string | null>(null);
68
+
69
+ // Check which formatting options are available
70
+ const hasBold = formatting?.bold !== false;
71
+ const hasItalic = formatting?.italic !== false;
72
+ const hasUnderline = formatting?.underline !== false;
73
+ const hasLinks = formatting?.links !== false;
74
+ const hasColors = formatting?.colors && formatting.colors.length > 0;
75
+
76
+ // Initialize content when component mounts
77
+ useEffect(() => {
78
+ if (editorRef.current && !editorRef.current.innerHTML && value) {
79
+ editorRef.current.innerHTML = value;
80
+ }
81
+ }, []); // Only run on mount
82
+
83
+ // Update content when value prop changes (but avoid if user is editing)
84
+ useEffect(() => {
85
+ if (editorRef.current && document.activeElement !== editorRef.current) {
86
+ if (editorRef.current.innerHTML !== value) {
87
+ editorRef.current.innerHTML = value || '';
88
+ }
89
+ }
90
+ }, [value]);
91
+
92
+ // Handle focus prop
93
+ useEffect(() => {
94
+ if (isFocused && editorRef.current) {
95
+ // Use requestAnimationFrame to ensure DOM is ready
96
+ requestAnimationFrame(() => {
97
+ if (editorRef.current) {
98
+ editorRef.current.focus();
99
+ // Place cursor at the end
100
+ const range = document.createRange();
101
+ range.selectNodeContents(editorRef.current);
102
+ range.collapse(false);
103
+ const selection = window.getSelection();
104
+ if (selection) {
105
+ selection.removeAllRanges();
106
+ selection.addRange(range);
107
+ }
108
+ }
109
+ });
110
+ }
111
+ }, [isFocused]);
112
+
113
+ // Close color picker and link dialog when clicking outside, and hide toolbar when no selection
114
+ useEffect(() => {
115
+ const handleClickOutside = (e: MouseEvent) => {
116
+ const target = e.target as HTMLElement;
117
+
118
+ // Check if click is outside color picker
119
+ if (showColorPicker) {
120
+ const colorPicker = target.closest('[data-color-picker]');
121
+ const colorPickerButton = target.closest('[data-color-picker-button]');
122
+ if (!colorPicker && !colorPickerButton) {
123
+ setShowColorPicker(false);
124
+ }
125
+ }
126
+
127
+ // Check if click is outside link dialog
128
+ if (showLinkDialog) {
129
+ const linkDialog = target.closest('[data-link-dialog]');
130
+ const linkButton = target.closest('[data-link-button]');
131
+ if (!linkDialog && !linkButton) {
132
+ setShowLinkDialog(false);
133
+ }
134
+ }
135
+
136
+ // Check if click is outside editor and toolbar - hide toolbar if no selection
137
+ if (editorRef.current && !editorRef.current.contains(target)) {
138
+ const toolbar = target.closest('[class*="z-50"]');
139
+ if (!toolbar) {
140
+ // Check if there's actually a selection
141
+ setTimeout(() => {
142
+ const selection = window.getSelection();
143
+ if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).collapsed) {
144
+ setShowToolbar(false);
145
+ setSelectedText('');
146
+ } else {
147
+ // Re-check selection to ensure it's still valid
148
+ handleSelectionChange();
149
+ }
150
+ }, 0);
151
+ }
152
+ } else if (editorRef.current && editorRef.current.contains(target)) {
153
+ // Clicked inside editor - check selection after a brief delay
154
+ setTimeout(() => {
155
+ handleSelectionChange();
156
+ }, 10);
157
+ }
158
+ };
159
+
160
+ document.addEventListener('mousedown', handleClickOutside);
161
+ return () => {
162
+ document.removeEventListener('mousedown', handleClickOutside);
163
+ };
164
+ }, [showColorPicker, showLinkDialog]);
165
+
166
+ // Handle selection change to show/hide toolbar
167
+ const handleSelectionChange = () => {
168
+ // Don't hide toolbar if color picker or link dialog is open
169
+ if (showColorPicker || showLinkDialog) {
170
+ return;
171
+ }
172
+
173
+ const selection = window.getSelection();
174
+ if (!selection || selection.rangeCount === 0) {
175
+ setShowToolbar(false);
176
+ setSelectedText('');
177
+ return;
178
+ }
179
+
180
+ const range = selection.getRangeAt(0);
181
+
182
+ // Check if selection is collapsed (no text selected)
183
+ if (range.collapsed) {
184
+ setShowToolbar(false);
185
+ setSelectedText('');
186
+ return;
187
+ }
188
+
189
+ // Check if the selection is within our editor
190
+ if (!editorRef.current || !editorRef.current.contains(range.commonAncestorContainer)) {
191
+ setShowToolbar(false);
192
+ setSelectedText('');
193
+ return;
194
+ }
195
+
196
+ // Get selected text and check if it's not empty
197
+ const selectedText = selection.toString().trim();
198
+ if (!selectedText) {
199
+ setShowToolbar(false);
200
+ setSelectedText('');
201
+ return;
202
+ }
203
+
204
+ setSelectedText(selectedText);
205
+
206
+ // Calculate toolbar position
207
+ const rect = range.getBoundingClientRect();
208
+ const editorRect = editorRef.current?.getBoundingClientRect();
209
+
210
+ if (editorRect) {
211
+ setToolbarPosition({
212
+ top: rect.top - editorRect.top - 40,
213
+ left: rect.left - editorRect.left + (rect.width / 2),
214
+ });
215
+ setShowToolbar(true);
216
+ }
217
+ };
218
+
219
+ // Store selection range before button clicks
220
+ const savedRangeRef = useRef<Range | null>(null);
221
+
222
+ // Save selection before it's lost
223
+ const saveSelection = () => {
224
+ const selection = window.getSelection();
225
+ if (selection && selection.rangeCount > 0) {
226
+ savedRangeRef.current = selection.getRangeAt(0).cloneRange();
227
+ }
228
+ };
229
+
230
+ // Restore saved selection
231
+ const restoreSelection = () => {
232
+ if (savedRangeRef.current && editorRef.current) {
233
+ const selection = window.getSelection();
234
+ if (selection) {
235
+ selection.removeAllRanges();
236
+ selection.addRange(savedRangeRef.current);
237
+ }
238
+ }
239
+ };
240
+
241
+ // Apply formatting command
242
+ // Note: Client styles are applied in RichTextPreview component
243
+ // The editor stores standard HTML tags, and preview applies client styles
244
+ const applyFormat = (command: string, value?: string) => {
245
+ document.execCommand(command, false, value);
246
+ editorRef.current?.focus();
247
+ updateContent();
248
+ };
249
+
250
+ // Update content from editor
251
+ const updateContent = () => {
252
+ if (editorRef.current) {
253
+ onChange(editorRef.current.innerHTML);
254
+ }
255
+ };
256
+
257
+ // Handle input
258
+ const handleInput = () => {
259
+ updateContent();
260
+ };
261
+
262
+ // Handle keyboard shortcuts
263
+ const handleKeyDown = (e: React.KeyboardEvent) => {
264
+ // Call custom handler first (allows parent to intercept keys like Enter)
265
+ if (customOnKeyDown) {
266
+ customOnKeyDown(e);
267
+ // If custom handler prevented default, don't process further
268
+ if (e.defaultPrevented) {
269
+ return;
270
+ }
271
+ }
272
+
273
+ // Check for Ctrl/Cmd + key combinations
274
+ const isModifierPressed = e.ctrlKey || e.metaKey;
275
+
276
+ if (isModifierPressed) {
277
+ switch (e.key.toLowerCase()) {
278
+ case 'b':
279
+ if (hasBold) {
280
+ e.preventDefault();
281
+ e.stopPropagation(); // Prevent event from bubbling to window-level listeners
282
+ applyFormat('bold');
283
+ }
284
+ break;
285
+ case 'i':
286
+ if (hasItalic) {
287
+ e.preventDefault();
288
+ e.stopPropagation(); // Prevent event from bubbling to window-level listeners
289
+ applyFormat('italic');
290
+ }
291
+ break;
292
+ case 'u':
293
+ if (hasUnderline) {
294
+ e.preventDefault();
295
+ e.stopPropagation(); // Prevent event from bubbling to window-level listeners
296
+ applyFormat('underline');
297
+ }
298
+ break;
299
+ case 'k':
300
+ if (hasLinks) {
301
+ e.preventDefault();
302
+ e.stopPropagation(); // Prevent event from bubbling to window-level listeners
303
+ // Get selected text and open link dialog
304
+ const selection = window.getSelection();
305
+ if (selection && selection.rangeCount > 0) {
306
+ const range = selection.getRangeAt(0);
307
+ if (!range.collapsed) {
308
+ // Check if selection is already a link
309
+ const linkElement = range.commonAncestorContainer.parentElement?.closest('a');
310
+ if (linkElement) {
311
+ setLinkUrl((linkElement as HTMLAnchorElement).href);
312
+ }
313
+ setShowLinkDialog(true);
314
+ }
315
+ }
316
+ }
317
+ break;
318
+ }
319
+ }
320
+ };
321
+
322
+ // Handle paste (clean HTML)
323
+ const handlePaste = (e: React.ClipboardEvent) => {
324
+ e.preventDefault();
325
+ const text = e.clipboardData.getData('text/plain');
326
+ document.execCommand('insertText', false, text);
327
+ updateContent();
328
+ };
329
+
330
+ // Handle link creation
331
+ const handleCreateLink = () => {
332
+ if (!linkUrl.trim()) return;
333
+
334
+ const selection = window.getSelection();
335
+ if (selection && selection.rangeCount > 0) {
336
+ const range = selection.getRangeAt(0);
337
+ if (!range.collapsed) {
338
+ applyFormat('createLink', linkUrl);
339
+ setShowLinkDialog(false);
340
+ setLinkUrl('');
341
+ }
342
+ }
343
+ };
344
+
345
+ // Check if selected text has a color applied
346
+ const getSelectedColor = (): string | null => {
347
+ // Use saved selection if available (when color picker is open)
348
+ let range: Range | null = null;
349
+ if (savedRangeRef.current) {
350
+ range = savedRangeRef.current;
351
+ } else {
352
+ const selection = window.getSelection();
353
+ if (!selection || selection.rangeCount === 0) return null;
354
+ range = selection.getRangeAt(0);
355
+ }
356
+
357
+ if (!range || range.collapsed) return null;
358
+
359
+ // Check if the selection is within a span with a color class
360
+ let node: Node | null = range.commonAncestorContainer;
361
+
362
+ // If the node is a text node, check its parent
363
+ if (node.nodeType === Node.TEXT_NODE) {
364
+ node = node.parentElement;
365
+ }
366
+
367
+ // Check if the node or any parent has a color class
368
+ while (node && node !== editorRef.current) {
369
+ if (node.nodeType === Node.ELEMENT_NODE) {
370
+ const element = node as HTMLElement;
371
+ const className = element.className;
372
+
373
+ // Check if this element has any of the configured color classes
374
+ if (formatting?.styles?.colorClasses) {
375
+ for (const [colorKey, colorClass] of Object.entries(formatting.styles.colorClasses)) {
376
+ if (typeof className === 'string' && className.includes(colorClass)) {
377
+ return colorKey;
378
+ }
379
+ }
380
+ }
381
+
382
+ // Also check direct color classes
383
+ if (formatting?.colors) {
384
+ for (const color of formatting.colors) {
385
+ const colorClass = formatting?.styles?.colorClasses?.[color] || color;
386
+ if (typeof className === 'string' && className.includes(colorClass)) {
387
+ return color;
388
+ }
389
+ }
390
+ }
391
+ }
392
+ node = node.parentElement;
393
+ }
394
+
395
+ return null;
396
+ };
397
+
398
+ // Remove color from selected text
399
+ const handleRemoveColor = () => {
400
+ // Restore the saved selection first
401
+ restoreSelection();
402
+
403
+ const selection = window.getSelection();
404
+ if (!selection || selection.rangeCount === 0) return;
405
+
406
+ const range = selection.getRangeAt(0);
407
+ if (range.collapsed) return;
408
+
409
+ // Get all color classes to check for
410
+ const colorClassesToRemove: string[] = [];
411
+ if (formatting?.styles?.colorClasses) {
412
+ colorClassesToRemove.push(...Object.values(formatting.styles.colorClasses));
413
+ }
414
+ if (formatting?.colors) {
415
+ for (const color of formatting.colors) {
416
+ const colorClass = formatting?.styles?.colorClasses?.[color] || color;
417
+ if (!colorClassesToRemove.includes(colorClass)) {
418
+ colorClassesToRemove.push(colorClass);
419
+ }
420
+ }
421
+ }
422
+
423
+ if (colorClassesToRemove.length === 0) return;
424
+
425
+ // Get the editor element
426
+ if (!editorRef.current) return;
427
+
428
+ // Find all spans in the editor that intersect with the selection
429
+ const allSpans = editorRef.current.querySelectorAll('span');
430
+ const spansToProcess: HTMLElement[] = [];
431
+
432
+ allSpans.forEach(span => {
433
+ // Check if span intersects with selection
434
+ if (range.intersectsNode(span)) {
435
+ const className = span.className || '';
436
+ // Check if span has any color class
437
+ for (const colorClass of colorClassesToRemove) {
438
+ if (className.includes(colorClass)) {
439
+ spansToProcess.push(span);
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ });
445
+
446
+ // Process spans - remove color classes or unwrap
447
+ spansToProcess.forEach(span => {
448
+ const className = span.className || '';
449
+ let newClassName = className;
450
+
451
+ // Remove all color classes
452
+ for (const colorClass of colorClassesToRemove) {
453
+ const escapedClass = colorClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
454
+ newClassName = newClassName
455
+ .replace(new RegExp(`\\b${escapedClass}\\b`, 'g'), '')
456
+ .replace(/\s+/g, ' ')
457
+ .trim();
458
+ }
459
+
460
+ // Update or remove class
461
+ if (!newClassName) {
462
+ // Unwrap the span - move children to parent
463
+ const parent = span.parentNode;
464
+ if (parent) {
465
+ const fragment = document.createDocumentFragment();
466
+ while (span.firstChild) {
467
+ fragment.appendChild(span.firstChild);
468
+ }
469
+ parent.replaceChild(fragment, span);
470
+ }
471
+ } else {
472
+ span.className = newClassName;
473
+ }
474
+ });
475
+
476
+ // Normalize to merge text nodes
477
+ if (editorRef.current) {
478
+ editorRef.current.normalize();
479
+ }
480
+
481
+ // Update content
482
+ updateContent();
483
+ setShowColorPicker(false);
484
+ setCurrentColor(null);
485
+
486
+ // Restore focus
487
+ editorRef.current?.focus();
488
+ };
489
+
490
+ // Apply color
491
+ const handleApplyColor = (color: string) => {
492
+ // Restore the saved selection first
493
+ restoreSelection();
494
+
495
+ const selection = window.getSelection();
496
+ if (!selection || selection.rangeCount === 0) {
497
+ return;
498
+ }
499
+ const range = selection.getRangeAt(0);
500
+ if (!range.collapsed) {
501
+ // Check if the selected text already has this color
502
+ const currentColor = getSelectedColor();
503
+ if (currentColor === color) {
504
+ // If clicking the same color, remove it
505
+ handleRemoveColor();
506
+ return;
507
+ }
508
+
509
+ // Remove any existing color first
510
+ if (currentColor) {
511
+ handleRemoveColor();
512
+ // Restore selection after removing color
513
+ restoreSelection();
514
+ }
515
+
516
+ const colorClass = formatting?.styles?.colorClasses?.[color] || color;
517
+ // Create a span with the color class
518
+ const span = document.createElement('span');
519
+ span.className = colorClass;
520
+ try {
521
+ range.surroundContents(span);
522
+ } catch (e) {
523
+ // If surroundContents fails, try a different approach
524
+ span.appendChild(range.extractContents());
525
+ range.insertNode(span);
526
+ }
527
+ updateContent();
528
+ setShowColorPicker(false);
529
+ setCurrentColor(null);
530
+ // Ensure the cursor stays active
531
+ editorRef.current?.focus();
532
+ }
533
+ };
534
+
535
+ // Check if format is active
536
+ const isFormatActive = (command: string): boolean => {
537
+ return document.queryCommandState(command);
538
+ };
539
+
540
+ return (
541
+ <div className={`relative ${className}`}>
542
+ {/* Toolbar - appears on text selection */}
543
+ {showToolbar && selectedText && selectedText.trim().length > 0 && (hasBold || hasItalic || hasUnderline || hasLinks || hasColors) && (
544
+ <div
545
+ className="absolute z-50 flex items-center gap-1 p-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg"
546
+ style={{
547
+ top: `${toolbarPosition.top}px`,
548
+ left: `${toolbarPosition.left}px`,
549
+ transform: 'translateX(-50%)',
550
+ }}
551
+ >
552
+ {hasBold && (
553
+ <button
554
+ type="button"
555
+ onMouseDown={(e) => e.preventDefault()}
556
+ onClick={() => applyFormat('bold')}
557
+ className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${isFormatActive('bold') ? 'bg-primary/10 text-primary' : 'text-neutral-600 dark:text-neutral-400'
558
+ }`}
559
+ title="Bold"
560
+ >
561
+ <Bold size={14} />
562
+ </button>
563
+ )}
564
+
565
+ {hasItalic && (
566
+ <button
567
+ type="button"
568
+ onMouseDown={(e) => e.preventDefault()}
569
+ onClick={() => applyFormat('italic')}
570
+ className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${isFormatActive('italic') ? 'bg-primary/10 text-primary' : 'text-neutral-600 dark:text-neutral-400'
571
+ }`}
572
+ title="Italic"
573
+ >
574
+ <Italic size={14} />
575
+ </button>
576
+ )}
577
+
578
+ {hasUnderline && (
579
+ <button
580
+ type="button"
581
+ onMouseDown={(e) => e.preventDefault()}
582
+ onClick={() => applyFormat('underline')}
583
+ className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${isFormatActive('underline') ? 'bg-primary/10 text-primary' : 'text-neutral-600 dark:text-neutral-400'
584
+ }`}
585
+ title="Underline"
586
+ >
587
+ <Underline size={14} />
588
+ </button>
589
+ )}
590
+
591
+ {hasLinks && (
592
+ <div className="relative">
593
+ <button
594
+ type="button"
595
+ data-link-button
596
+ onMouseDown={(e) => e.preventDefault()}
597
+ onClick={(e) => {
598
+ e.stopPropagation();
599
+ const selection = window.getSelection();
600
+ if (selection && selection.rangeCount > 0) {
601
+ const range = selection.getRangeAt(0);
602
+ const linkElement = range.commonAncestorContainer.parentElement?.closest('a');
603
+ if (linkElement) {
604
+ setLinkUrl((linkElement as HTMLAnchorElement).href);
605
+ }
606
+ }
607
+ setShowLinkDialog(!showLinkDialog);
608
+ }}
609
+ className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${isFormatActive('unlink') ? 'bg-primary/10 text-primary' : 'text-neutral-600 dark:text-neutral-400'
610
+ }`}
611
+ title="Link"
612
+ >
613
+ <Link size={14} />
614
+ </button>
615
+
616
+ {showLinkDialog && (
617
+ <div
618
+ data-link-dialog
619
+ className="absolute top-full left-0 mt-2 p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg min-w-[300px] z-[60]"
620
+ onMouseDown={(e) => e.stopPropagation()}
621
+ onClick={(e) => e.stopPropagation()}
622
+ >
623
+ <input
624
+ type="url"
625
+ value={linkUrl}
626
+ onChange={(e) => setLinkUrl(e.target.value)}
627
+ placeholder="Enter URL..."
628
+ className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 outline-none focus:border-primary"
629
+ onKeyDown={(e) => {
630
+ if (e.key === 'Enter') {
631
+ handleCreateLink();
632
+ } else if (e.key === 'Escape') {
633
+ setShowLinkDialog(false);
634
+ }
635
+ }}
636
+ autoFocus
637
+ />
638
+ <div className="flex gap-2 mt-2">
639
+ <button
640
+ type="button"
641
+ onClick={handleCreateLink}
642
+ className="flex-1 px-3 py-1.5 text-xs font-bold bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
643
+ >
644
+ Apply
645
+ </button>
646
+ <button
647
+ type="button"
648
+ onClick={() => {
649
+ applyFormat('unlink');
650
+ setShowLinkDialog(false);
651
+ }}
652
+ className="px-3 py-1.5 text-xs font-bold border border-neutral-300 dark:border-neutral-700 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
653
+ >
654
+ Remove
655
+ </button>
656
+ </div>
657
+ </div>
658
+ )}
659
+ </div>
660
+ )}
661
+
662
+ {hasColors && (
663
+ <div className="relative">
664
+ <button
665
+ type="button"
666
+ data-color-picker-button
667
+ onMouseDown={(e) => {
668
+ e.preventDefault();
669
+ saveSelection(); // Save selection before opening color picker
670
+ // Also check current color when saving selection
671
+ setTimeout(() => {
672
+ const color = getSelectedColor();
673
+ setCurrentColor(color);
674
+ }, 0);
675
+ }}
676
+ onClick={(e) => {
677
+ e.stopPropagation();
678
+ // Check current color before opening picker
679
+ const color = getSelectedColor();
680
+ setCurrentColor(color);
681
+ setShowColorPicker(!showColorPicker);
682
+ }}
683
+ className="p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors text-neutral-600 dark:text-neutral-400"
684
+ title="Text Color"
685
+ >
686
+ <Palette size={14} />
687
+ </button>
688
+
689
+ {showColorPicker && (
690
+ <div
691
+ data-color-picker
692
+ className="absolute top-full left-1/2 -translate-x-1/2 mt-2 p-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-xl z-[60]"
693
+ style={{
694
+ minWidth: 'fit-content',
695
+ maxWidth: 'none',
696
+ isolation: 'isolate'
697
+ }}
698
+ onMouseDown={(e) => e.stopPropagation()}
699
+ onClick={(e) => e.stopPropagation()}
700
+ >
701
+ <div className="flex items-center gap-2 flex-nowrap">
702
+ {/* Remove Color Button - only show if a color is currently applied */}
703
+ {currentColor && (
704
+ <button
705
+ type="button"
706
+ onMouseDown={(e) => {
707
+ e.preventDefault();
708
+ e.stopPropagation();
709
+ }}
710
+ onClick={(e) => {
711
+ e.preventDefault();
712
+ e.stopPropagation();
713
+ handleRemoveColor();
714
+ setCurrentColor(null);
715
+ }}
716
+ className="flex-shrink-0 px-2.5 py-1.5 text-xs font-medium border border-neutral-300 dark:border-neutral-600 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors text-neutral-700 dark:text-neutral-300"
717
+ title="Remove Color"
718
+ >
719
+ Remove
720
+ </button>
721
+ )}
722
+
723
+ {formatting?.colors?.map((color) => {
724
+ // Map text color classes to background colors for display
725
+ const getBackgroundColor = (colorKey: string): string => {
726
+ // Remove 'text-' prefix if present
727
+ const baseColor = colorKey.replace(/^text-/, '');
728
+
729
+ // Map to actual color values
730
+ const colorMap: Record<string, string> = {
731
+ 'forest': '#6B7C5A',
732
+ 'sage': '#9CAF88',
733
+ 'primary': '#94b17b',
734
+ 'soft-green': '#A8C09A',
735
+ };
736
+
737
+ return colorMap[baseColor] || '#6B7C5A'; // Default to forest
738
+ };
739
+
740
+ const bgColor = getBackgroundColor(color);
741
+ const colorName = color.replace(/^text-/, '');
742
+
743
+ return (
744
+ <button
745
+ key={color}
746
+ type="button"
747
+ onMouseDown={(e) => {
748
+ e.preventDefault();
749
+ e.stopPropagation();
750
+ }}
751
+ onClick={(e) => {
752
+ e.preventDefault();
753
+ e.stopPropagation();
754
+ handleApplyColor(color);
755
+ }}
756
+ className="relative group flex-shrink-0 w-8 h-8 rounded-md border border-neutral-300 dark:border-neutral-600 hover:border-neutral-500 dark:hover:border-neutral-400 transition-all duration-150 hover:scale-110 active:scale-95"
757
+ style={{
758
+ backgroundColor: bgColor,
759
+ boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.1)'
760
+ }}
761
+ title={colorName.charAt(0).toUpperCase() + colorName.slice(1)}
762
+ >
763
+ <span className="sr-only">{colorName}</span>
764
+ </button>
765
+ );
766
+ })}
767
+ </div>
768
+ </div>
769
+ )}
770
+ </div>
771
+ )}
772
+ </div>
773
+ )}
774
+
775
+ {/* Content Editable Editor */}
776
+ <div
777
+ ref={editorRef}
778
+ contentEditable
779
+ onInput={handleInput}
780
+ onPaste={handlePaste}
781
+ onKeyDown={handleKeyDown}
782
+ onSelect={() => {
783
+ // Small delay to ensure selection is updated
784
+ setTimeout(() => {
785
+ handleSelectionChange();
786
+ }, 0);
787
+ }}
788
+ onMouseUp={(e) => {
789
+ // Don't trigger selection change if clicking on toolbar/dropdowns
790
+ const target = e.target as HTMLElement;
791
+ if (!target.closest('[class*="z-50"], [class*="z-[60]"]')) {
792
+ // Small delay to ensure selection is updated
793
+ setTimeout(() => {
794
+ handleSelectionChange();
795
+ }, 0);
796
+ }
797
+ }}
798
+ onKeyUp={() => {
799
+ // Small delay to ensure selection is updated
800
+ setTimeout(() => {
801
+ handleSelectionChange();
802
+ }, 0);
803
+ }}
804
+ onBlur={() => {
805
+ // Hide toolbar when editor loses focus (unless dropdowns are open)
806
+ if (!showColorPicker && !showLinkDialog) {
807
+ setTimeout(() => {
808
+ const selection = window.getSelection();
809
+ if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).collapsed) {
810
+ setShowToolbar(false);
811
+ setSelectedText('');
812
+ }
813
+ }, 100); // Small delay to allow dropdown clicks
814
+ }
815
+ }}
816
+ data-placeholder={placeholder}
817
+ className={`outline-none min-h-[24px] ${!value || value === '<br>' || value === '<div><br></div>'
818
+ ? 'before:content-[attr(data-placeholder)] before:text-neutral-400 dark:before:text-neutral-500 before:pointer-events-none'
819
+ : ''
820
+ }`}
821
+ style={{ userSelect: 'text' }}
822
+ />
823
+ </div>
824
+ );
825
+ }
826
+