rich text editor highlight and text color feature added
This commit is contained in:
parent
2c0378c63a
commit
068faad462
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user