diff --git a/src/components/ui/rich-text-editor.tsx b/src/components/ui/rich-text-editor.tsx index 3f4b470..db1a11c 100644 --- a/src/components/ui/rich-text-editor.tsx +++ b/src/components/ui/rich-text-editor.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { cn } from "./utils"; import { Button } from "./button"; -import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight } from "lucide-react"; +import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; interface RichTextEditorProps extends Omit, 'onChange'> { value: string; @@ -11,6 +12,23 @@ interface RichTextEditorProps extends Omit, minHeight?: string; } +// Predefined highlight colors - 12 major colors in 2 rows (6 columns x 2 rows) +// "No Color" is first for easy access (standard pattern) +const HIGHLIGHT_COLORS = [ + { name: 'No Color', value: 'transparent', class: 'bg-gray-200 border-2 border-gray-400', icon: '×' }, + { name: 'Yellow', value: '#FFEB3B', class: 'bg-yellow-400' }, + { name: 'Green', value: '#4CAF50', class: 'bg-green-400' }, + { name: 'Blue', value: '#2196F3', class: 'bg-blue-400' }, + { name: 'Red', value: '#F44336', class: 'bg-red-400' }, + { name: 'Orange', value: '#FF9800', class: 'bg-orange-400' }, + { name: 'Purple', value: '#9C27B0', class: 'bg-purple-400' }, + { name: 'Pink', value: '#E91E63', class: 'bg-pink-400' }, + { name: 'Cyan', value: '#00BCD4', class: 'bg-cyan-400' }, + { name: 'Teal', value: '#009688', class: 'bg-teal-400' }, + { name: 'Amber', value: '#FFC107', class: 'bg-amber-400' }, + { name: 'Indigo', value: '#3F51B5', class: 'bg-indigo-400' }, +]; + /** * RichTextEditor Component * @@ -28,6 +46,12 @@ export function RichTextEditor({ const editorRef = React.useRef(null); const [isFocused, setIsFocused] = React.useState(false); const [activeFormats, setActiveFormats] = React.useState>(new Set()); + const [highlightColorOpen, setHighlightColorOpen] = React.useState(false); + const [currentHighlightColor, setCurrentHighlightColor] = React.useState(null); + const [customColor, setCustomColor] = React.useState('#FFEB3B'); + const [textColorOpen, setTextColorOpen] = React.useState(false); + const [currentTextColor, setCurrentTextColor] = React.useState(null); + const [customTextColor, setCustomTextColor] = React.useState('#000000'); // Sync external value to editor React.useEffect(() => { @@ -249,6 +273,84 @@ export function RichTextEditor({ if (style.textAlign === 'right') formats.add('right'); if (style.textAlign === 'left') formats.add('left'); + // Convert RGB/RGBA to hex for comparison + const colorToHex = (color: string): string | null => { + // If already hex format + if (color.startsWith('#')) { + return color.toUpperCase(); + } + // If RGB/RGBA format + const result = color.match(/\d+/g); + if (!result || result.length < 3) return null; + const r = result[0]; + const g = result[1]; + const b = result[2]; + if (!r || !g || !b) return null; + const rHex = parseInt(r).toString(16).padStart(2, '0'); + const gHex = parseInt(g).toString(16).padStart(2, '0'); + const bHex = parseInt(b).toString(16).padStart(2, '0'); + return `#${rHex}${gHex}${bHex}`.toUpperCase(); + }; + + // Check for background color (highlight) + const bgColor = style.backgroundColor; + // Check if background color is set and not transparent/default + if (bgColor && + bgColor !== 'rgba(0, 0, 0, 0)' && + bgColor !== 'transparent' && + bgColor !== 'rgb(255, 255, 255)' && + bgColor !== '#ffffff' && + bgColor !== '#FFFFFF') { + formats.add('highlight'); + const hexColor = colorToHex(bgColor); + if (hexColor) { + // Find matching color from our palette (allowing for slight variations) + const matchedColor = HIGHLIGHT_COLORS.find(c => { + if (c.value === 'transparent') return false; + // Compare hex values (case-insensitive) + return c.value.toUpperCase() === hexColor; + }); + if (matchedColor) { + setCurrentHighlightColor(matchedColor.value); + } else { + // Store the actual color even if not in our palette + setCurrentHighlightColor(hexColor); + } + } + } else if (!formats.has('highlight')) { + // Only reset if we haven't found a highlight yet + setCurrentHighlightColor(null); + } + + // Check for text color + const textColor = style.color; + // Convert to hex for comparison + const hexTextColor = colorToHex(textColor); + // Check if text color is set and not default black + if (textColor && hexTextColor && + textColor !== 'rgba(0, 0, 0, 0)' && + hexTextColor !== '#000000') { + formats.add('textColor'); + // Find matching color from our palette + const matchedColor = HIGHLIGHT_COLORS.find(c => { + if (c.value === 'transparent') return false; + return c.value.toUpperCase() === hexTextColor; + }); + if (matchedColor) { + setCurrentTextColor(matchedColor.value); + } else { + // Store the actual color even if not in our palette + setCurrentTextColor(hexTextColor); + } + } else if (!formats.has('textColor')) { + // Only reset if we haven't found a text color yet (default to black) + if (hexTextColor === '#000000' || !hexTextColor) { + setCurrentTextColor('#000000'); + } else { + setCurrentTextColor(null); + } + } + element = element.parentElement; } } @@ -285,6 +387,265 @@ export function RichTextEditor({ setTimeout(checkActiveFormats, 10); }, [isFocused, onChange, checkActiveFormats]); + // Apply highlight color + const applyHighlight = React.useCallback((color: string) => { + if (!editorRef.current) return; + + // Restore focus if needed + if (!isFocused) { + editorRef.current.focus(); + } + + // Save current selection + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + // If no selection, focus the editor + editorRef.current.focus(); + return; + } + + // Check if this color is already applied by checking the selection's style + let isAlreadyApplied = false; + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const commonAncestor = range.commonAncestorContainer; + let element: HTMLElement | null = null; + + if (commonAncestor.nodeType === Node.TEXT_NODE) { + element = commonAncestor.parentElement; + } else { + element = commonAncestor as HTMLElement; + } + + // Check if the selected element has the same background color + while (element && element !== editorRef.current) { + const style = window.getComputedStyle(element); + const bgColor = style.backgroundColor; + if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && + bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') { + // Convert to hex and compare + const colorToHex = (c: string): string | null => { + if (c.startsWith('#')) return c.toUpperCase(); + const result = c.match(/\d+/g); + if (!result || result.length < 3) return null; + const r = result[0]; + const g = result[1]; + const b = result[2]; + if (!r || !g || !b) return null; + const rHex = parseInt(r).toString(16).padStart(2, '0'); + const gHex = parseInt(g).toString(16).padStart(2, '0'); + const bHex = parseInt(b).toString(16).padStart(2, '0'); + return `#${rHex}${gHex}${bHex}`.toUpperCase(); + }; + const hexBgColor = colorToHex(bgColor); + if (hexBgColor && hexBgColor === color.toUpperCase()) { + isAlreadyApplied = true; + break; + } + } + element = element.parentElement; + } + } + + // Use backColor command for highlight (background color) + if (color === 'transparent' || isAlreadyApplied) { + // Remove highlight - use a more aggressive approach to fully remove + const range = selection.getRangeAt(0); + if (!range.collapsed) { + // Store the range before manipulation + const contents = range.extractContents(); + + // Create a new text node or span without background color + const fragment = document.createDocumentFragment(); + + // Process extracted contents to remove background colors + const processNode = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + return node.cloneNode(true); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const newEl = document.createElement(el.tagName.toLowerCase()); + + // Copy all attributes except style-related ones + Array.from(el.attributes).forEach(attr => { + if (attr.name !== 'style' && attr.name !== 'class') { + newEl.setAttribute(attr.name, attr.value); + } + }); + + // Process children and copy without background color + Array.from(el.childNodes).forEach(child => { + const processed = processNode(child); + if (processed) { + newEl.appendChild(processed); + } + }); + + // Remove background color if present + if (el.style.backgroundColor) { + newEl.style.backgroundColor = ''; + } + + return newEl; + } + return null; + }; + + Array.from(contents.childNodes).forEach(child => { + const processed = processNode(child); + if (processed) { + fragment.appendChild(processed); + } + }); + + // Insert the cleaned fragment + range.insertNode(fragment); + + // Also use execCommand to ensure removal + document.execCommand('removeFormat', false); + } else { + // No selection - remove format from current position + document.execCommand('removeFormat', false); + } + setCurrentHighlightColor(null); + setCustomColor('#FFEB3B'); // Reset to default + } else { + // Apply new highlight color - only if there's a selection + if (selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed) { + document.execCommand('backColor', false, color); + setCurrentHighlightColor(color); + // Update custom color input if it's a valid hex color (not transparent) + if (color !== 'transparent' && /^#[0-9A-Fa-f]{6}$/i.test(color)) { + setCustomColor(color); + } + } else { + // No selection - don't apply (prevents sticky mode where it applies to next typed text) + return; + } + } + + // Clear selection immediately after applying to prevent "sticky" highlight mode + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + } + + // Update content + if (editorRef.current) { + onChange(editorRef.current.innerHTML); + } + + // Close popover + setHighlightColorOpen(false); + + // Refocus editor after a short delay and check formats + setTimeout(() => { + if (editorRef.current) { + editorRef.current.focus(); + } + checkActiveFormats(); + }, 50); + }, [isFocused, onChange, checkActiveFormats]); + + // Apply text color + const applyTextColor = React.useCallback((color: string) => { + if (!editorRef.current) return; + + // Restore focus if needed + if (!isFocused) { + editorRef.current.focus(); + } + + // Save current selection + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + // If no selection, focus the editor + editorRef.current.focus(); + return; + } + + // Check if this color is already applied by checking the selection's style + let isAlreadyApplied = false; + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const commonAncestor = range.commonAncestorContainer; + let element: HTMLElement | null = null; + + if (commonAncestor.nodeType === Node.TEXT_NODE) { + element = commonAncestor.parentElement; + } else { + element = commonAncestor as HTMLElement; + } + + // Check if the selected element has the same text color + while (element && element !== editorRef.current) { + const style = window.getComputedStyle(element); + const textColor = style.color; + if (textColor) { + // Convert to hex and compare + const colorToHex = (c: string): string | null => { + if (c.startsWith('#')) return c.toUpperCase(); + const result = c.match(/\d+/g); + if (!result || result.length < 3) return null; + const r = result[0]; + const g = result[1]; + const b = result[2]; + if (!r || !g || !b) return null; + const rHex = parseInt(r).toString(16).padStart(2, '0'); + const gHex = parseInt(g).toString(16).padStart(2, '0'); + const bHex = parseInt(b).toString(16).padStart(2, '0'); + return `#${rHex}${gHex}${bHex}`.toUpperCase(); + }; + const hexTextColor = colorToHex(textColor); + // For black, also check if it's the default (no custom color) + if (color === '#000000') { + // If it's black and matches, or if no custom color is set (default black) + if (hexTextColor === '#000000' || !hexTextColor) { + isAlreadyApplied = true; + break; + } + } else if (hexTextColor && hexTextColor === color.toUpperCase()) { + isAlreadyApplied = true; + break; + } + } + element = element.parentElement; + } + } + + // Use foreColor command for text color + if (color === 'transparent' || color === 'default' || isAlreadyApplied) { + // Remove text color by removing format or setting to default + // If clicking black when it's already default, do nothing or reset + if (color === '#000000' && isAlreadyApplied) { + // Already default, no need to change + setTextColorOpen(false); + return; + } + document.execCommand('removeFormat', false); + setCurrentTextColor(null); + setCustomTextColor('#000000'); // Reset to default black + } else { + document.execCommand('foreColor', false, color); + setCurrentTextColor(color); + // Update custom text color input if it's a valid hex color + if (color !== 'transparent' && /^#[0-9A-Fa-f]{6}$/i.test(color)) { + setCustomTextColor(color); + } + } + + // Update content + if (editorRef.current) { + onChange(editorRef.current.innerHTML); + } + + // Close popover + setTextColorOpen(false); + + // Check active formats after a short delay + setTimeout(checkActiveFormats, 10); + }, [isFocused, onChange, checkActiveFormats]); + // Handle input changes const handleInput = React.useCallback(() => { if (editorRef.current) { @@ -387,6 +748,424 @@ export function RichTextEditor({ > + + {/* Highlight Color Picker */} + + + + + { + // Prevent closing when clicking inside popover + const target = e.target as HTMLElement; + if (target.closest('[data-popover-content]')) { + e.preventDefault(); + } + }} + > +
e.stopPropagation()}> + {/* Cancel Button */} + + +
Highlight Color
+
+ {HIGHLIGHT_COLORS.map((color) => { + const isActive = currentHighlightColor === color.value; + const isNoColor = color.value === 'transparent'; + return ( + + ); + })} +
+ + {/* Remove Highlight Button - Standard pattern */} + {currentHighlightColor && currentHighlightColor !== 'transparent' && ( +
+ +
+ )} + + {/* Custom Color Picker */} +
+
Custom Color
+
+ { + const color = e.target.value; + setCustomColor(color); + // Only apply if there's selected text + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed) { + applyHighlight(color); + } + }} + className="w-8 h-8 rounded border border-gray-300 cursor-pointer" + title="Pick a custom color" + /> + { + e.stopPropagation(); + const color = e.target.value; + // Allow typing freely - don't restrict input + setCustomColor(color); + }} + onKeyDown={(e) => { + // Prevent popover from closing + e.stopPropagation(); + // Allow all keys including backspace, delete, etc. + if (e.key === 'Enter') { + e.preventDefault(); + const color = e.currentTarget.value.trim(); + // Apply if valid hex color + if (/^#[0-9A-Fa-f]{6}$/i.test(color)) { + applyHighlight(color); + } + } + }} + onPaste={(e) => { + e.stopPropagation(); + // Get pasted text from clipboard + const pastedText = e.clipboardData.getData('text').trim(); + e.preventDefault(); + + // Process after paste event completes + setTimeout(() => { + // Check if it's a valid hex color with # + if (/^#[0-9A-Fa-f]{6}$/i.test(pastedText)) { + setCustomColor(pastedText); + // Auto-apply if text is selected in editor + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed) { + applyHighlight(pastedText); + } + } else { + // Check if it's a hex color without # prefix (6 hex digits) + const hexMatch = pastedText.match(/^([0-9A-Fa-f]{6})$/); + if (hexMatch) { + const colorWithHash = `#${hexMatch[1]}`; + setCustomColor(colorWithHash); + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed) { + applyHighlight(colorWithHash); + } + } else { + // If no match, allow default paste behavior (setTimeout allows default paste) + // Value will be updated by onChange + } + } + }, 0); + }} + onClick={(e) => { + e.stopPropagation(); + }} + onFocus={(e) => { + e.stopPropagation(); + // Select all text on focus for easy replacement + e.target.select(); + }} + onBlur={(e) => { + // On blur, if valid color, apply it + const color = e.target.value.trim(); + if (/^#[0-9A-Fa-f]{6}$/i.test(color)) { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.getRangeAt(0).collapsed) { + applyHighlight(color); + } + } else if (color === '') { + // Reset to current if cleared + setCustomColor(currentHighlightColor || '#FFEB3B'); + } else { + // Keep the typed value even if invalid (user might be typing) + setCustomColor(color); + } + }} + placeholder="#FFEB3B" + className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + title="Enter hex color code (Press Enter or click Apply). Supports copy-paste." + autoComplete="off" + spellCheck="false" + /> + +
+
+
+
+
+ + {/* Text Color Picker */} + + + + + { + const target = e.target as HTMLElement; + if (target.closest('[data-popover-content]')) { + e.preventDefault(); + } + }} + > +
e.stopPropagation()}> + {/* Cancel Button */} + + +
Text Color
+
+ {/* Default/Black Color Option - First position (standard) */} + + {HIGHLIGHT_COLORS.filter(c => c.value !== 'transparent').map((color) => { + const isActive = currentTextColor === color.value; + return ( + + ); + })} +
+ + {/* Remove Text Color Button - Standard pattern */} + {currentTextColor && currentTextColor !== '#000000' && ( +
+ +
+ )} + + {/* Custom Text Color Picker */} +
+
Custom Color
+
+ { + const color = e.target.value; + setCustomTextColor(color); + applyTextColor(color); + }} + className="w-8 h-8 rounded border border-gray-300 cursor-pointer" + title="Pick a custom text color" + /> + { + const color = e.target.value; + setCustomTextColor(color); + // Don't auto-apply, let user press Apply or Enter + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const color = e.currentTarget.value; + if (/^#[0-9A-Fa-f]{6}$/.test(color)) { + applyTextColor(color); + } + } + }} + onBlur={(e) => { + // On blur, if valid color, apply it + const color = e.target.value; + if (/^#[0-9A-Fa-f]{6}$/.test(color)) { + applyTextColor(color); + } else if (color === '') { + // Reset to current if cleared + setCustomTextColor(currentTextColor || '#000000'); + } + }} + placeholder="#000000" + className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" + title="Enter hex color code (Press Enter or click Apply)" + /> + +
+
+
+
+
{/* Headings */} @@ -549,6 +1328,8 @@ export function RichTextEditor({ "[&_strong]:font-bold", "[&_em]:italic", "[&_u]:underline", + // Highlight color styles - preserve background colors on spans + "[&_span[style*='background']]:px-0.5 [&_span[style*='background']]:rounded", "[&_h1]:text-xl [&_h1]:font-bold [&_h1]:my-2", "[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2", "[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",