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 * as React from "react";
|
||||||
import { cn } from "./utils";
|
import { cn } from "./utils";
|
||||||
import { Button } from "./button";
|
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'> {
|
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
value: string;
|
value: string;
|
||||||
@ -11,6 +12,23 @@ interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
|
|||||||
minHeight?: string;
|
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
|
* RichTextEditor Component
|
||||||
*
|
*
|
||||||
@ -28,6 +46,12 @@ export function RichTextEditor({
|
|||||||
const editorRef = React.useRef<HTMLDivElement>(null);
|
const editorRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
const [activeFormats, setActiveFormats] = React.useState<Set<string>>(new Set());
|
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
|
// Sync external value to editor
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -249,6 +273,84 @@ export function RichTextEditor({
|
|||||||
if (style.textAlign === 'right') formats.add('right');
|
if (style.textAlign === 'right') formats.add('right');
|
||||||
if (style.textAlign === 'left') formats.add('left');
|
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;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -285,6 +387,265 @@ export function RichTextEditor({
|
|||||||
setTimeout(checkActiveFormats, 10);
|
setTimeout(checkActiveFormats, 10);
|
||||||
}, [isFocused, onChange, checkActiveFormats]);
|
}, [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
|
// Handle input changes
|
||||||
const handleInput = React.useCallback(() => {
|
const handleInput = React.useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@ -387,6 +748,424 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<Underline className="h-4 w-4" />
|
<Underline className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Headings */}
|
{/* Headings */}
|
||||||
@ -549,6 +1328,8 @@ export function RichTextEditor({
|
|||||||
"[&_strong]:font-bold",
|
"[&_strong]:font-bold",
|
||||||
"[&_em]:italic",
|
"[&_em]:italic",
|
||||||
"[&_u]:underline",
|
"[&_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",
|
"[&_h1]:text-xl [&_h1]:font-bold [&_h1]:my-2",
|
||||||
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2",
|
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2",
|
||||||
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",
|
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user