rich text editor highlight and text color feature added

This commit is contained in:
laxmanhalaki 2025-12-10 12:34:52 +05:30
parent 2c0378c63a
commit 068faad462

View File

@ -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<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
value: string;
@ -11,6 +12,23 @@ interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
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<HTMLDivElement>(null);
const [isFocused, setIsFocused] = React.useState(false);
const [activeFormats, setActiveFormats] = React.useState<Set<string>>(new Set());
const [highlightColorOpen, setHighlightColorOpen] = React.useState(false);
const [currentHighlightColor, setCurrentHighlightColor] = React.useState<string | null>(null);
const [customColor, setCustomColor] = React.useState<string>('#FFEB3B');
const [textColorOpen, setTextColorOpen] = React.useState(false);
const [currentTextColor, setCurrentTextColor] = React.useState<string | null>(null);
const [customTextColor, setCustomTextColor] = React.useState<string>('#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({
>
<Underline className="h-4 w-4" />
</Button>
{/* Highlight Color Picker */}
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0",
activeFormats.has('highlight') && "bg-blue-100 text-blue-700"
)}
title="Text Highlight"
>
<Highlighter className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2"
align="start"
onPointerDownOutside={(e) => {
// Prevent closing when clicking inside popover
const target = e.target as HTMLElement;
if (target.closest('[data-popover-content]')) {
e.preventDefault();
}
}}
>
<div className="space-y-2 relative" onClick={(e) => e.stopPropagation()}>
{/* Cancel Button */}
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-6 w-6 p-0 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
setHighlightColorOpen(false);
}}
title="Close"
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{HIGHLIGHT_COLORS.map((color) => {
const isActive = currentHighlightColor === color.value;
const isNoColor = color.value === 'transparent';
return (
<button
key={color.value}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Standard toggle: if clicking the same color, remove it
if (isActive && !isNoColor) {
applyHighlight('transparent');
} else {
applyHighlight(color.value);
}
}}
className={cn(
"w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:shadow-md relative",
color.class,
isActive && "ring-2 ring-blue-600 ring-offset-1 border-blue-600",
isNoColor && "border-gray-400 bg-white",
!isNoColor && !isActive && "border-gray-300"
)}
title={isActive && !isNoColor ? `${color.name} (Click to remove)` : color.name}
style={!isNoColor ? { backgroundColor: color.value } : {}}
>
{isNoColor && (
<span className="text-[10px] text-gray-600 font-bold">×</span>
)}
{isActive && !isNoColor && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="w-2 h-2 bg-white rounded-full shadow-sm"></span>
</span>
)}
</button>
);
})}
</div>
{/* Remove Highlight Button - Standard pattern */}
{currentHighlightColor && currentHighlightColor !== 'transparent' && (
<div className="mb-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
applyHighlight('transparent');
}}
title="Remove highlight color"
>
Remove Highlight
</Button>
</div>
)}
{/* Custom Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
<div className="flex items-center gap-2">
<input
type="color"
value={currentHighlightColor && currentHighlightColor !== 'transparent' ? currentHighlightColor : customColor}
onChange={(e) => {
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"
/>
<input
type="text"
value={currentHighlightColor && currentHighlightColor !== 'transparent' ? currentHighlightColor : customColor}
onChange={(e) => {
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"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
const color = customColor.trim();
if (/^#[0-9A-Fa-f]{6}$/i.test(color)) {
applyHighlight(color);
} else {
// If invalid, show current value or reset
setCustomColor(currentHighlightColor || '#FFEB3B');
}
}}
title="Apply custom color"
>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Text Color Picker */}
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0",
activeFormats.has('textColor') && "bg-blue-100 text-blue-700"
)}
title="Text Color"
>
<Type className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2"
align="start"
onPointerDownOutside={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-popover-content]')) {
e.preventDefault();
}
}}
>
<div className="space-y-2 relative" onClick={(e) => e.stopPropagation()}>
{/* Cancel Button */}
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-0 right-0 h-6 w-6 p-0 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
setTextColorOpen(false);
}}
title="Close"
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{/* Default/Black Color Option - First position (standard) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const isDefault = currentTextColor === '#000000' || (!currentTextColor && !activeFormats.has('textColor'));
// Toggle: if already default, do nothing (or could reset to default explicitly)
if (!isDefault) {
applyTextColor('#000000');
}
}}
className={cn(
"w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:shadow-md flex items-center justify-center bg-black",
(currentTextColor === '#000000' || (!currentTextColor && !activeFormats.has('textColor'))) && "ring-2 ring-blue-600 ring-offset-1 border-blue-600",
(currentTextColor !== '#000000' && (currentTextColor || activeFormats.has('textColor'))) && "border-gray-300"
)}
title="Default (Black)"
>
<span className="text-[10px] text-white font-bold">A</span>
{(currentTextColor === '#000000' || (!currentTextColor && !activeFormats.has('textColor'))) && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="w-2 h-2 bg-white rounded-full shadow-sm"></span>
</span>
)}
</button>
{HIGHLIGHT_COLORS.filter(c => c.value !== 'transparent').map((color) => {
const isActive = currentTextColor === color.value;
return (
<button
key={`text-${color.value}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Standard toggle: if clicking the same color, reset to default
if (isActive) {
applyTextColor('#000000');
} else {
applyTextColor(color.value);
}
}}
className={cn(
"w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:shadow-md flex items-center justify-center relative",
isActive && "ring-2 ring-blue-600 ring-offset-1 border-blue-600",
!isActive && "border-gray-300"
)}
title={isActive ? `${color.name} (Click to reset to default)` : color.name}
style={{ color: color.value, borderColor: isActive ? '#2563eb' : color.value }}
>
<span className="text-xs font-bold">A</span>
{isActive && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="w-2 h-2 bg-white rounded-full shadow-sm"></span>
</span>
)}
</button>
);
})}
</div>
{/* Remove Text Color Button - Standard pattern */}
{currentTextColor && currentTextColor !== '#000000' && (
<div className="mb-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-full h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
applyTextColor('#000000');
}}
title="Reset to default black"
>
Reset to Default
</Button>
</div>
)}
{/* Custom Text Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
<div className="flex items-center gap-2">
<input
type="color"
value={currentTextColor || customTextColor}
onChange={(e) => {
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"
/>
<input
type="text"
value={currentTextColor || customTextColor}
onChange={(e) => {
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)"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => applyTextColor(customTextColor)}
title="Apply custom text color"
>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* 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",