@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.
- package/README.md +216 -0
- package/package.json +57 -0
- package/src/api/README.md +224 -0
- package/src/api/categories.ts +43 -0
- package/src/api/check-title.ts +60 -0
- package/src/api/handler.ts +419 -0
- package/src/api/index.ts +33 -0
- package/src/api/route.ts +116 -0
- package/src/api/router.ts +114 -0
- package/src/api-server.ts +11 -0
- package/src/config.ts +161 -0
- package/src/hooks/README.md +91 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useBlog.ts +85 -0
- package/src/hooks/useBlogs.ts +123 -0
- package/src/index.server.ts +12 -0
- package/src/index.tsx +354 -0
- package/src/init.tsx +72 -0
- package/src/lib/blocks/BlockRenderer.tsx +141 -0
- package/src/lib/blocks/index.ts +6 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/layouts/blocks/ColumnsBlock.tsx +134 -0
- package/src/lib/layouts/blocks/SectionBlock.tsx +104 -0
- package/src/lib/layouts/blocks/index.ts +8 -0
- package/src/lib/layouts/index.ts +52 -0
- package/src/lib/layouts/registerLayoutBlocks.ts +59 -0
- package/src/lib/mappers/apiMapper.ts +223 -0
- package/src/lib/migration/index.ts +6 -0
- package/src/lib/migration/mapper.ts +140 -0
- package/src/lib/rich-text/RichTextEditor.tsx +826 -0
- package/src/lib/rich-text/RichTextPreview.tsx +210 -0
- package/src/lib/rich-text/index.ts +10 -0
- package/src/lib/utils/blockHelpers.ts +72 -0
- package/src/lib/utils/configValidation.ts +137 -0
- package/src/lib/utils/index.ts +8 -0
- package/src/lib/utils/slugify.ts +79 -0
- package/src/registry/BlockRegistry.ts +142 -0
- package/src/registry/index.ts +11 -0
- package/src/state/EditorContext.tsx +277 -0
- package/src/state/index.ts +8 -0
- package/src/state/reducer.ts +694 -0
- package/src/state/types.ts +160 -0
- package/src/types/block.ts +269 -0
- package/src/types/index.ts +15 -0
- package/src/types/post.ts +165 -0
- package/src/utils/README.md +75 -0
- package/src/utils/client.ts +122 -0
- package/src/utils/index.ts +9 -0
- package/src/views/CanvasEditor/BlockWrapper.tsx +459 -0
- package/src/views/CanvasEditor/CanvasEditorView.tsx +917 -0
- package/src/views/CanvasEditor/EditorBody.tsx +475 -0
- package/src/views/CanvasEditor/EditorHeader.tsx +179 -0
- package/src/views/CanvasEditor/LayoutContainer.tsx +494 -0
- package/src/views/CanvasEditor/SaveConfirmationModal.tsx +233 -0
- package/src/views/CanvasEditor/components/CustomBlockItem.tsx +92 -0
- package/src/views/CanvasEditor/components/FeaturedMediaSection.tsx +130 -0
- package/src/views/CanvasEditor/components/LibraryItem.tsx +80 -0
- package/src/views/CanvasEditor/components/PrivacySettingsSection.tsx +212 -0
- package/src/views/CanvasEditor/components/index.ts +17 -0
- package/src/views/CanvasEditor/index.ts +16 -0
- package/src/views/PostManager/EmptyState.tsx +42 -0
- package/src/views/PostManager/PostActionsMenu.tsx +112 -0
- package/src/views/PostManager/PostCards.tsx +192 -0
- package/src/views/PostManager/PostFilters.tsx +80 -0
- package/src/views/PostManager/PostManagerView.tsx +280 -0
- package/src/views/PostManager/PostStats.tsx +81 -0
- package/src/views/PostManager/PostTable.tsx +225 -0
- package/src/views/PostManager/index.ts +15 -0
- package/src/views/Preview/PreviewBridgeView.tsx +64 -0
- package/src/views/Preview/index.ts +7 -0
- package/src/views/README.md +82 -0
- package/src/views/Settings/SettingsView.tsx +298 -0
- package/src/views/Settings/index.ts +7 -0
- package/src/views/SlugSEO/SlugSEOManagerView.tsx +94 -0
- 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
|
+
|