pmtool / src /components /custom /SlateRichTextEditor.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
47 kB
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { createEditor, Descendant, Element as SlateElement, Transforms, Editor, BaseEditor, Node } from 'slate';
import { Slate, Editable, withReact, RenderElementProps, RenderLeafProps, ReactEditor } from 'slate-react';
import { withHistory, HistoryEditor } from 'slate-history';
import { Button } from '@/components/ui/button';
import { Bold, Italic, Underline, List, ListOrdered, AlignLeft, AlignCenter, AlignRight, Indent, Outdent, Palette } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
type RichTextEditorProps = {
initialValue?: string;
onChange: (value: string) => void;
placeholder?: string;
readOnly?: boolean;
disabled?: boolean;
minHeight?: string;
className?: string;
};
// Define the type for text nodes
type CustomText = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
color?: string;
};
// Define types for elements
type ParagraphElement = {
type: 'paragraph';
align?: 'left' | 'center' | 'right';
indent?: number;
children: CustomText[];
};
type ListItemElement = {
type: 'list-item';
align?: 'left' | 'center' | 'right';
indent?: number;
children: CustomText[];
};
type BulletedListElement = {
type: 'bulleted-list';
align?: 'left' | 'center' | 'right';
indent?: number;
children: ListItemElement[];
};
type NumberedListElement = {
type: 'numbered-list';
align?: 'left' | 'center' | 'right';
indent?: number;
children: ListItemElement[];
};
type CustomElement = ParagraphElement | ListItemElement | BulletedListElement | NumberedListElement;
// Fix type declarations for Slate
type CustomEditor = BaseEditor & ReactEditor & HistoryEditor;
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor & HistoryEditor;
Element: CustomElement;
Text: CustomText;
}
}
// Initial value for the editor
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [{ text: '' }],
},
];
// Helper to serialize editor content to HTML
const serializeToHtml = (nodes: Descendant[]): string => {
return nodes
.map(node => {
if ('type' in node) {
switch (node.type) {
case 'paragraph':
const textContent = node.children.map(child => {
let text = child.text;
if (child.bold) text = `<strong>${text}</strong>`;
if (child.italic) text = `<em>${text}</em>`;
if (child.underline) text = `<u>${text}</u>`;
if (child.color) text = `<span style="color:${child.color}">${text}</span>`;
return text;
}).join('');
const align = node.align ? ` style="text-align: ${node.align}` : '';
const indent = node.indent ? `${align ? ';' : ' style="'}padding-left: ${node.indent * 24}px` : '';
const style = (align || indent) ? `${align}${indent}"` : '';
return `<p${style}>${textContent}</p>`;
case 'bulleted-list':
const ulAlign = node.align ? ` style="text-align: ${node.align}"` : '';
const ulIndent = node.indent ? `${ulAlign ? ';' : ' style="'}padding-left: ${node.indent * 24}px"` : '';
const ulStyle = (ulAlign || ulIndent) ? `${ulAlign}${ulIndent}"` : '';
return `<ul${ulStyle}>${node.children.map(item =>
`<li>${item.children.map(child => {
let text = child.text;
if (child.bold) text = `<strong>${text}</strong>`;
if (child.italic) text = `<em>${text}</em>`;
if (child.underline) text = `<u>${text}</u>`;
if (child.color) text = `<span style="color:${child.color}">${text}</span>`;
return text;
}).join('')}</li>`
).join('')}</ul>`;
case 'numbered-list':
const olAlign = node.align ? ` style="text-align: ${node.align}"` : '';
const olIndent = node.indent ? `${olAlign ? ';' : ' style="'}padding-left: ${node.indent * 24}px"` : '';
const olStyle = (olAlign || olIndent) ? `${olAlign}${olIndent}"` : '';
return `<ol${olStyle}>${node.children.map(item =>
`<li>${item.children.map(child => {
let text = child.text;
if (child.bold) text = `<strong>${text}</strong>`;
if (child.italic) text = `<em>${text}</em>`;
if (child.underline) text = `<u>${text}</u>`;
if (child.color) text = `<span style="color:${child.color}">${text}</span>`;
return text;
}).join('')}</li>`
).join('')}</ol>`;
case 'list-item':
const liAlign = node.align ? ` style="text-align: ${node.align}"` : '';
const liIndent = node.indent ? `${liAlign ? ';' : ' style="'}padding-left: ${node.indent * 24}px"` : '';
const liStyle = (liAlign || liIndent) ? `${liAlign}${liIndent}"` : '';
return `<li${liStyle}>${node.children.map(child => {
let text = child.text;
if (child.bold) text = `<strong>${text}</strong>`;
if (child.italic) text = `<em>${text}</em>`;
if (child.underline) text = `<u>${text}</u>`;
if (child.color) text = `<span style="color:${child.color}">${text}</span>`;
return text;
}).join('')}</li>`;
default:
return '';
}
} else {
// It's a text node
let text = node.text;
if (node.bold) text = `<strong>${text}</strong>`;
if (node.italic) text = `<em>${text}</em>`;
if (node.underline) text = `<u>${text}</u>`;
if (node.color) text = `<span style="color:${node.color}">${text}</span>`;
return text;
}
})
.join('');
};
// Improved HTML parser to handle more complex HTML content
const parseFromHtml = (html: string): Descendant[] => {
if (!html) return initialValue;
try {
// Create a temporary DOM element to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const result: Descendant[] = [];
// Check if we have a plain text with bullet characters (•)
if (doc.body.textContent?.includes('•')) {
const textContent = doc.body.textContent;
const lines = textContent.split('\n').filter(line => line.trim().length > 0);
// If we have bullet characters in text, convert to proper list format
if (lines.some(line => line.trim().startsWith('•'))) {
const listItems: ListItemElement[] = [];
let currentParagraph: ParagraphElement | null = null;
lines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('•')) {
// If we had a paragraph in progress, add it to result
if (currentParagraph) {
result.push(currentParagraph);
currentParagraph = null;
}
// Add as a list item
listItems.push({
type: 'list-item',
children: [{ text: trimmedLine.substring(1).trim() }]
});
} else {
// If we had list items in progress, add them as a bulleted list
if (listItems.length > 0) {
result.push({
type: 'bulleted-list',
children: listItems
});
// Reset list items
listItems.length = 0;
}
// Add as a paragraph
if (!currentParagraph) {
currentParagraph = {
type: 'paragraph',
children: [{ text: trimmedLine }]
};
} else {
// Append to existing paragraph with a space
currentParagraph.children[0].text += ' ' + trimmedLine;
}
}
});
// Add any remaining list items or paragraphs
if (listItems.length > 0) {
result.push({
type: 'bulleted-list',
children: listItems
});
}
if (currentParagraph) {
result.push(currentParagraph);
}
return result.length > 0 ? result : initialValue;
}
}
// Process each top-level element normally
Array.from(doc.body.childNodes).forEach(node => {
if (node.nodeType === 1) { // ELEMENT_NODE is 1
const element = node as Element;
result.push(...parseElement(element));
} else if (node.nodeType === 3 && node.textContent?.trim()) { // TEXT_NODE is 3
// Handle direct text nodes
result.push({
type: 'paragraph',
children: [{ text: node.textContent || '' }]
});
}
});
return result.length > 0 ? result : initialValue;
} catch (e) {
console.error('Error parsing HTML:', e);
return initialValue;
}
};
// Helper function to parse HTML elements recursively
const parseElement = (element: Element): Descendant[] => {
const result: Descendant[] = [];
// Handle different element types
switch (element.tagName.toLowerCase()) {
case 'p':
// Handle paragraphs with formatting
const paragraph: ParagraphElement = {
type: 'paragraph',
children: parseTextWithFormatting(element),
};
// Extract alignment and indentation if available
const style = element.getAttribute('style');
if (style) {
if (style.includes('text-align: center')) {
paragraph.align = 'center';
} else if (style.includes('text-align: right')) {
paragraph.align = 'right';
} else if (style.includes('text-align: left')) {
paragraph.align = 'left';
}
// Extract indentation
const indentMatch = style.match(/padding-left:\s*(\d+)px/i);
if (indentMatch && indentMatch[1]) {
const indentPx = parseInt(indentMatch[1], 10);
paragraph.indent = Math.round(indentPx / 24); // Convert px to our indent level
}
}
result.push(paragraph);
break;
case 'div':
// Special case for divs that might be containing bulleted lists with specific styling
if (element.innerHTML.includes('•') ||
element.innerHTML.includes('&#8226;') ||
element.innerHTML.includes('&bull;')) {
// Try to extract bullet points
const children = Array.from(element.childNodes);
const listItems: ListItemElement[] = [];
children.forEach(child => {
if (child.nodeType === 3) { // TEXT_NODE
const text = child.textContent || '';
if (text.trim().startsWith('•') || text.trim().startsWith('●')) {
listItems.push({
type: 'list-item',
children: [{ text: text.replace(/^[•●]\s*/, '') }]
});
}
} else if (child.nodeType === 1) { // ELEMENT_NODE
const childElement = child as Element;
const text = childElement.textContent || '';
if (text.trim().startsWith('•') || text.trim().startsWith('●')) {
listItems.push({
type: 'list-item',
children: parseTextWithFormatting(childElement).map(node => {
// Remove bullet character
if (typeof node.text === 'string') {
return { ...node, text: node.text.replace(/^[•●]\s*/, '') };
}
return node;
})
});
}
}
});
if (listItems.length > 0) {
result.push({
type: 'bulleted-list',
children: listItems
});
break;
}
}
// For regular divs, process children and create a paragraph
const children = parseTextWithFormatting(element);
const divStyle = element.getAttribute('style');
const divParagraph: ParagraphElement = {
type: 'paragraph',
children,
};
if (divStyle) {
if (divStyle.includes('text-align: center')) {
divParagraph.align = 'center';
} else if (divStyle.includes('text-align: right')) {
divParagraph.align = 'right';
} else if (divStyle.includes('text-align: left')) {
divParagraph.align = 'left';
}
// Extract indentation
const divIndentMatch = divStyle.match(/padding-left:\s*(\d+)px/i);
if (divIndentMatch && divIndentMatch[1]) {
const indentPx = parseInt(divIndentMatch[1], 10);
divParagraph.indent = Math.round(indentPx / 24);
}
}
result.push(divParagraph);
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
// For headings, create a paragraph with formatting
const headingStyle = element.getAttribute('style');
const headingParagraph: ParagraphElement = {
type: 'paragraph',
children: parseTextWithFormatting(element),
};
if (headingStyle) {
if (headingStyle.includes('text-align: center')) {
headingParagraph.align = 'center';
} else if (headingStyle.includes('text-align: right')) {
headingParagraph.align = 'right';
} else if (headingStyle.includes('text-align: left')) {
headingParagraph.align = 'left';
}
// Extract indentation
const headingIndentMatch = headingStyle.match(/padding-left:\s*(\d+)px/i);
if (headingIndentMatch && headingIndentMatch[1]) {
const indentPx = parseInt(headingIndentMatch[1], 10);
headingParagraph.indent = Math.round(indentPx / 24);
}
}
result.push(headingParagraph);
break;
case 'ul':
// Handle unordered lists - first check if it's empty
const listItems = Array.from(element.querySelectorAll('li'));
if (listItems.length > 0) {
const ulStyle = element.getAttribute('style');
const list: BulletedListElement = {
type: 'bulleted-list',
children: listItems.map(li => {
const liStyle = li.getAttribute('style');
const listItem: ListItemElement = {
type: 'list-item',
children: parseTextWithFormatting(li),
};
if (liStyle) {
if (liStyle.includes('text-align: center')) {
listItem.align = 'center';
} else if (liStyle.includes('text-align: right')) {
listItem.align = 'right';
} else if (liStyle.includes('text-align: left')) {
listItem.align = 'left';
}
// Extract indentation for list item
const liIndentMatch = liStyle.match(/padding-left:\s*(\d+)px/i);
if (liIndentMatch && liIndentMatch[1]) {
const indentPx = parseInt(liIndentMatch[1], 10);
listItem.indent = Math.round(indentPx / 24);
}
}
return listItem;
}),
};
if (ulStyle) {
if (ulStyle.includes('text-align: center')) {
list.align = 'center';
} else if (ulStyle.includes('text-align: right')) {
list.align = 'right';
} else if (ulStyle.includes('text-align: left')) {
list.align = 'left';
}
// Extract indentation for the list
const ulIndentMatch = ulStyle.match(/padding-left:\s*(\d+)px/i);
if (ulIndentMatch && ulIndentMatch[1]) {
const indentPx = parseInt(ulIndentMatch[1], 10);
list.indent = Math.round(indentPx / 24);
}
}
result.push(list);
}
break;
case 'ol':
// Handle ordered lists - first check if it's empty
const orderedListItems = Array.from(element.querySelectorAll('li'));
if (orderedListItems.length > 0) {
const olStyle = element.getAttribute('style');
const orderedList: NumberedListElement = {
type: 'numbered-list',
children: orderedListItems.map(li => {
const liStyle = li.getAttribute('style');
const listItem: ListItemElement = {
type: 'list-item',
children: parseTextWithFormatting(li),
};
if (liStyle) {
if (liStyle.includes('text-align: center')) {
listItem.align = 'center';
} else if (liStyle.includes('text-align: right')) {
listItem.align = 'right';
} else if (liStyle.includes('text-align: left')) {
listItem.align = 'left';
}
// Extract indentation for list item
const liIndentMatch = liStyle.match(/padding-left:\s*(\d+)px/i);
if (liIndentMatch && liIndentMatch[1]) {
const indentPx = parseInt(liIndentMatch[1], 10);
listItem.indent = Math.round(indentPx / 24);
}
}
return listItem;
}),
};
if (olStyle) {
if (olStyle.includes('text-align: center')) {
orderedList.align = 'center';
} else if (olStyle.includes('text-align: right')) {
orderedList.align = 'right';
} else if (olStyle.includes('text-align: left')) {
orderedList.align = 'left';
}
// Extract indentation for the list
const olIndentMatch = olStyle.match(/padding-left:\s*(\d+)px/i);
if (olIndentMatch && olIndentMatch[1]) {
const indentPx = parseInt(olIndentMatch[1], 10);
orderedList.indent = Math.round(indentPx / 24);
}
}
result.push(orderedList);
}
break;
case 'li':
// Create standalone list item if not in a list context
const liItemStyle = element.getAttribute('style');
const listItem: ListItemElement = {
type: 'list-item',
children: parseTextWithFormatting(element),
};
if (liItemStyle) {
if (liItemStyle.includes('text-align: center')) {
listItem.align = 'center';
} else if (liItemStyle.includes('text-align: right')) {
listItem.align = 'right';
} else if (liItemStyle.includes('text-align: left')) {
listItem.align = 'left';
}
// Extract indentation
const liItemIndentMatch = liItemStyle.match(/padding-left:\s*(\d+)px/i);
if (liItemIndentMatch && liItemIndentMatch[1]) {
const indentPx = parseInt(liItemIndentMatch[1], 10);
listItem.indent = Math.round(indentPx / 24);
}
}
result.push(listItem);
break;
case 'br':
// Handle line breaks by creating a new paragraph
result.push({
type: 'paragraph',
children: [{ text: '' }],
});
break;
default:
// For other elements, recursively process children
let hasAddedContent = false;
Array.from(element.childNodes).forEach(childNode => {
if (childNode.nodeType === 1) { // ELEMENT_NODE
const childResults = parseElement(childNode as Element);
if (childResults.length > 0) {
result.push(...childResults);
hasAddedContent = true;
}
} else if (childNode.nodeType === 3 && childNode.textContent?.trim()) { // TEXT_NODE is 3
result.push({
type: 'paragraph',
children: [{ text: childNode.textContent || '' }],
});
hasAddedContent = true;
}
});
// If no content was added but the element has text, add it as a paragraph
if (!hasAddedContent && element.textContent?.trim()) {
result.push({
type: 'paragraph',
children: [{ text: element.textContent || '' }],
});
}
}
return result;
};
// Parse text with inline formatting
const parseTextWithFormatting = (element: Element): CustomText[] => {
const result: CustomText[] = [];
// If there's only text content and no children with formatting, return a simple text node
if (!element.childNodes.length ||
(element.childNodes.length === 1 && element.childNodes[0].nodeType === 3)) { // TEXT_NODE is 3
return [{ text: element.textContent || '' }];
}
// Process each child node to handle formatting
Array.from(element.childNodes).forEach(node => {
if (node.nodeType === 3) { // TEXT_NODE
// Handle text nodes
if (node.textContent?.trim()) {
result.push({ text: node.textContent || '' });
}
} else if (node.nodeType === 1) { // ELEMENT_NODE
const childElement = node as Element;
const tagName = childElement.tagName.toLowerCase();
// Handle formatting elements
switch (tagName) {
case 'strong':
case 'b':
result.push({ text: childElement.textContent || '', bold: true });
break;
case 'em':
case 'i':
result.push({ text: childElement.textContent || '', italic: true });
break;
case 'u':
result.push({ text: childElement.textContent || '', underline: true });
break;
case 'span':
// Process span with potential inline styling
const spanStyle = childElement.getAttribute('style');
const text = childElement.textContent || '';
const formatting: Partial<CustomText> = { text };
if (spanStyle) {
if (spanStyle.includes('font-weight: bold') || spanStyle.includes('font-weight:bold')) {
formatting.bold = true;
}
if (spanStyle.includes('font-style: italic') || spanStyle.includes('font-style:italic')) {
formatting.italic = true;
}
if (spanStyle.includes('text-decoration: underline') || spanStyle.includes('text-decoration:underline')) {
formatting.underline = true;
}
// Extract color if present
const colorMatch = spanStyle.match(/color:\s*(#[0-9a-f]{3,6}|rgb\([^)]+\)|[a-z]+)/i);
if (colorMatch && colorMatch[1]) {
formatting.color = colorMatch[1];
}
}
result.push(formatting as CustomText);
break;
default:
// Recursively handle other elements
result.push(...parseTextWithFormatting(childElement));
}
}
});
return result;
};
// Format button component
type FormatButtonProps = {
format: string;
icon: React.ReactNode;
isActive?: boolean;
onMouseDown: (e: React.MouseEvent) => void;
};
const FormatButton: React.FC<FormatButtonProps> = ({
format,
icon,
isActive = false,
onMouseDown
}) => {
return (
<Button
type="button"
variant={isActive ? "default" : "ghost"}
size="sm"
onMouseDown={onMouseDown}
className="h-8 w-8 p-0"
title={format.charAt(0).toUpperCase() + format.slice(1)}
>
{icon}
</Button>
);
};
// Custom type for Slate Element props with align support
interface CustomRenderElementProps extends Omit<RenderElementProps, 'element'> {
element: CustomElement;
}
// Element renderer
const Element = ({ attributes, children, element }: CustomRenderElementProps) => {
const style: React.CSSProperties = {
textAlign: element.align as React.CSSProperties['textAlign'] || undefined
};
// Apply indentation with more visible styling
if (element.indent) {
style.paddingLeft = `${element.indent * 24}px`;
style.borderLeft = '2px solid #e2e8f0';
style.marginLeft = '8px';
}
switch (element.type) {
case 'bulleted-list':
return <ul {...attributes} className="list-disc ml-6" style={style}>{children}</ul>;
case 'numbered-list':
return <ol {...attributes} className="list-decimal ml-6" style={style}>{children}</ol>;
case 'list-item':
return <li {...attributes} style={style}>{children}</li>;
default:
return <p {...attributes} style={style}>{children}</p>;
}
};
// Leaf renderer
const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
let style: React.CSSProperties = {};
// Apply color if specified
if (leaf.color) {
style.color = leaf.color;
}
let textElement = <span {...attributes} style={style}>{children}</span>;
if (leaf.bold) {
textElement = <strong>{textElement}</strong>;
}
if (leaf.italic) {
textElement = <em>{textElement}</em>;
}
if (leaf.underline) {
textElement = <u>{textElement}</u>;
}
return textElement;
};
// Custom editor commands
const CustomEditor = {
isMarkActive(editor: CustomEditor, format: string) {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
},
isBlockActive(editor: CustomEditor, format: string, attributeName = 'type') {
const { selection } = editor;
if (!selection) return false;
try {
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
(n as any)[attributeName] === format,
})
);
return !!match;
} catch (error) {
console.error('Error checking block active state:', error);
return false;
}
},
toggleMark(editor: CustomEditor, format: string) {
const isActive = CustomEditor.isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
},
toggleBlock(editor: CustomEditor, format: string) {
const isActive = CustomEditor.isBlockActive(editor, format);
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
['bulleted-list', 'numbered-list'].includes((n as any).type),
split: true,
});
const newProperties: Partial<CustomElement> = {
type: isActive ? 'paragraph' : format === 'bulleted-list' || format === 'numbered-list'
? 'list-item'
: format as any
};
Transforms.setNodes(editor, newProperties, {
match: n => !Editor.isEditor(n) && SlateElement.isElement(n),
});
if (!isActive && (format === 'bulleted-list' || format === 'numbered-list')) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block as any);
}
},
toggleAlign(editor: CustomEditor, alignment: 'left' | 'center' | 'right') {
// Check if the current alignment is already active
const isActive = CustomEditor.isBlockActive(editor, alignment, 'align');
// Set or unset the alignment
const newProperties: Partial<CustomElement> = {
align: isActive ? undefined : alignment
};
Transforms.setNodes(editor, newProperties, {
match: n => !Editor.isEditor(n) && SlateElement.isElement(n),
});
},
setColor(editor: CustomEditor, color: string) {
Editor.addMark(editor, 'color', color);
},
increaseIndent(editor: CustomEditor) {
const { selection } = editor;
if (!selection) return;
try {
const nodeEntries = Editor.nodes(editor, {
at: selection,
match: n => !Editor.isEditor(n) && SlateElement.isElement(n),
});
for (const [node, path] of nodeEntries) {
const element = node as CustomElement;
const currentIndent = element.indent || 0;
if (currentIndent < 5) {
Transforms.setNodes(
editor,
{ indent: currentIndent + 1 } as Partial<CustomElement>,
{ at: path }
);
}
}
} catch (error) {
console.error('Error increasing indent:', error);
}
},
decreaseIndent(editor: CustomEditor) {
const { selection } = editor;
if (!selection) return;
try {
const nodeEntries = Editor.nodes(editor, {
at: selection,
match: n => !Editor.isEditor(n) && SlateElement.isElement(n),
});
for (const [node, path] of nodeEntries) {
const element = node as CustomElement;
const currentIndent = element.indent || 0;
if (currentIndent > 0) {
Transforms.setNodes(
editor,
{ indent: currentIndent - 1 } as Partial<CustomElement>,
{ at: path }
);
}
}
} catch (error) {
console.error('Error decreasing indent:', error);
}
}
};
const colorOptions = [
{ color: '#000000', label: 'Black' },
{ color: '#1C7ED6', label: 'Blue' },
{ color: '#2B8A3E', label: 'Green' },
{ color: '#E03131', label: 'Red' },
{ color: '#F59F00', label: 'Orange' },
{ color: '#7048E8', label: 'Purple' },
{ color: '#868E96', label: 'Gray' },
];
// Custom color picker with custom popover
const ColorButton: React.FC<{
onSelectColor: (color: string) => void,
activeColor?: string
}> = ({ onSelectColor, activeColor = "#000000" }) => {
// State to track if the color picker is open
const [isOpen, setIsOpen] = useState(false);
// Reference to the button element
const buttonRef = useRef<HTMLButtonElement>(null);
// Reference to the dropdown element
const dropdownRef = useRef<HTMLDivElement>(null);
// Close the dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
dropdownRef.current &&
buttonRef.current &&
!dropdownRef.current.contains(event.target as Element) &&
!buttonRef.current.contains(event.target as Element)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
const handleButtonClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
};
return (
<div className="relative inline-block">
<Button
ref={buttonRef}
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 relative"
title="Text Color"
onMouseDown={handleButtonClick}
>
<Palette className="h-4 w-4" />
<div
className="absolute bottom-1 right-1 w-2 h-2 rounded-full border border-white"
style={{ backgroundColor: activeColor }}
/>
</Button>
{isOpen && (
<div
ref={dropdownRef}
className="absolute z-50 mt-1 w-40 p-2 bg-popover shadow-md rounded-md border border-border"
style={{ top: "100%", left: "50%", transform: "translateX(-50%)" }}
>
<div className="flex flex-wrap gap-1">{colorOptions.map((colorOption) => (
<Button
key={colorOption.color}
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 rounded-sm"
style={{ backgroundColor: colorOption.color }}
title={colorOption.label}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectColor(colorOption.color);
setIsOpen(false);
}}
/>
))}</div>
</div>
)}
</div>
);
};
export const SlateRichTextEditor: React.FC<RichTextEditorProps> = ({
initialValue: initialContent = '',
onChange,
placeholder = 'Enter text...',
readOnly = false,
disabled = false,
minHeight = '100px',
className = '',
}) => {
// Create a Slate editor object that won't change across renders
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
// Keep track of state for the formatting buttons
const [formats, setFormats] = useState({
bold: false,
italic: false,
underline: false,
'bulleted-list': false,
'numbered-list': false,
'align-left': false,
'align-center': false,
'align-right': false,
});
// Track the current text color
const [currentColor, setCurrentColor] = useState('#000000');
// Initialize content from props
const [editorValue, setEditorValue] = useState<Descendant[]>(() =>
parseFromHtml(initialContent)
);
// Use a key to force remount when initialContent changes
const [editorKey, setEditorKey] = useState(0);
useEffect(() => {
const newValue = parseFromHtml(initialContent);
setEditorValue(newValue);
setEditorKey(prev => prev + 1);
}, [initialContent]);
// Update content and trigger onChange
const handleChange = (value: Descendant[]) => {
setEditorValue(value);
// Update format states based on current selection
const selection = editor.selection;
if (selection) {
// Check text formatting
setFormats(prevFormats => ({
...prevFormats,
bold: CustomEditor.isMarkActive(editor, 'bold'),
italic: CustomEditor.isMarkActive(editor, 'italic'),
underline: CustomEditor.isMarkActive(editor, 'underline'),
'bulleted-list': CustomEditor.isBlockActive(editor, 'bulleted-list'),
'numbered-list': CustomEditor.isBlockActive(editor, 'numbered-list'),
}));
// Check current color
const marks = Editor.marks(editor);
if (marks && marks.color) {
setCurrentColor(marks.color as string);
}
}
// Convert to HTML and trigger the onChange
const htmlContent = serializeToHtml(value);
onChange(htmlContent);
};
// Define a rendering function for custom elements
const renderElement = useCallback((props: RenderElementProps) => {
return <Element {...props as CustomRenderElementProps} />;
}, []);
// Define a rendering function for custom text nodes
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
// Toggle mark function
const toggleMark = (e: React.MouseEvent, format: string) => {
e.preventDefault();
e.stopPropagation(); // Prevent event bubbling
if (format === 'bold' || format === 'italic' || format === 'underline') {
// For text formatting
CustomEditor.toggleMark(editor, format);
// Update format state
setFormats(prev => ({
...prev,
[format]: !CustomEditor.isMarkActive(editor, format),
}));
} else if (format.startsWith('align-')) {
// For alignment
const align = format.replace('align-', '') as 'left' | 'center' | 'right';
// Check if this alignment is already active
const isActive = CustomEditor.isBlockActive(editor, align, 'align');
// Clear other alignment formats
setFormats(prev => ({
...prev,
'align-left': format === 'align-left' && !isActive,
'align-center': format === 'align-center' && !isActive,
'align-right': format === 'align-right' && !isActive,
}));
// Set alignment on the current block
CustomEditor.toggleAlign(editor, align);
} else if (format === 'bulleted-list' || format === 'numbered-list') {
// For lists
CustomEditor.toggleBlock(editor, format);
// Update format state
setFormats(prev => ({
...prev,
'bulleted-list': format === 'bulleted-list' && !prev['bulleted-list'],
'numbered-list': format === 'numbered-list' && !prev['numbered-list'],
}));
}
};
// Handle paste to preserve formatting
const handlePaste = useCallback(
(event: React.ClipboardEvent) => {
event.preventDefault();
// Get clipboard data
const clipboardData = event.clipboardData;
// Try to get HTML content first
let html = clipboardData.getData('text/html');
const plainText = clipboardData.getData('text/plain');
// Check if plain text has bullet points
const hasBulletPoints = plainText.includes('•') || plainText.includes('●') ||
(/^\s*[-*]\s+/m).test(plainText);
// If no HTML or we detect bullet points in plain text, handle specially
if (!html.trim() || hasBulletPoints) {
// If we detect markdown-style lists or bullet characters
if (hasBulletPoints || (/^\s*[-*]\s+/m).test(plainText)) {
const lines = plainText.split('\n').filter(line => line.trim().length > 0);
const bulletedItems: ListItemElement[] = [];
const paragraphs: ParagraphElement[] = [];
lines.forEach(line => {
const trimmedLine = line.trim();
// Match various bullet styles: •, ●, -, *
if (trimmedLine.startsWith('•') || trimmedLine.startsWith('●') ||
trimmedLine.match(/^\s*[-*]\s+/)) {
// Add as list item, stripping the bullet character
bulletedItems.push({
type: 'list-item',
children: [{
text: trimmedLine.replace(/^[•●]|\s*[-*]\s+/, '').trim()
}]
});
} else {
// Regular paragraph
paragraphs.push({
type: 'paragraph',
children: [{ text: trimmedLine }]
});
}
});
// Insert paragraphs and list items
if (paragraphs.length > 0) {
editor.insertFragment(paragraphs as any);
}
if (bulletedItems.length > 0) {
editor.insertFragment([{
type: 'bulleted-list',
children: bulletedItems
}] as any);
}
return;
}
// If no special formatting detected, insert as plain text
if (plainText) {
editor.insertText(plainText);
}
return;
}
// Create valid HTML if it seems like a fragment
if (!html.includes('<html') && !html.includes('<body')) {
html = `<html><body>${html}</body></html>`;
}
// Parse HTML content to Slate format
const parsed = parseFromHtml(html);
// Insert the parsed content
editor.insertFragment(parsed);
},
[editor]
);
// Add debug logging for input events
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
console.log('Key down event:', event.key);
}, [editor]);
// Ensure editor focuses on click
const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
console.log('Editor clicked, focusing');
ReactEditor.focus(editor);
}, [editor]);
// Handle indent/outdent
const handleIndent = (e: React.MouseEvent, increase: boolean) => {
e.preventDefault();
e.stopPropagation();
// Get the current selection
const { selection } = editor;
if (!selection) {
console.warn('No selection to indent');
return;
}
// Create a reusable function to help with indentation
const modifyIndent = (element: CustomElement) => {
if (increase) {
const currentIndent = element.indent || 0;
if (currentIndent < 5) { // Limit max indent
return { ...element, indent: currentIndent + 1 };
}
} else {
const currentIndent = element.indent || 0;
if (currentIndent > 0) {
return { ...element, indent: currentIndent - 1 };
}
}
return element;
};
// Apply the indentation to all selected blocks
try {
const startPoint = selection.anchor.path[0];
const endPoint = selection.focus.path[0];
// Determine the range of blocks to indent
const start = Math.min(startPoint, endPoint);
const end = Math.max(startPoint, endPoint);
console.log(`Indenting blocks from ${start} to ${end}`);
// Modify each block in the selection range
for (let i = start; i <= end; i++) {
const path = [i];
const node = Node.get(editor, path) as CustomElement;
if (SlateElement.isElement(node)) {
// Get current indent value
const currentIndent = node.indent || 0;
const newIndent = increase
? Math.min(currentIndent + 1, 5) // Increase with max limit of 5
: Math.max(currentIndent - 1, 0); // Decrease with min limit of 0
console.log(`Block ${i}: Changing indent from ${currentIndent} to ${newIndent}`);
// Apply the change
if (newIndent !== currentIndent) {
Transforms.setNodes(
editor,
{ indent: newIndent },
{ at: path }
);
}
}
}
// Force update to ensure changes are reflected
const html = serializeToHtml(editor.children as Descendant[]);
console.log('Updated HTML:', html);
onChange(html);
} catch (error) {
console.error('Error applying indentation:', error);
}
};
// Handle color selection
const handleColorSelect = (color: string) => {
setCurrentColor(color);
CustomEditor.setColor(editor, color);
};
return (
<div
className={`border rounded-md ${className}`}
style={{ minHeight }}
onClick={e => e.stopPropagation()}
>
<Slate
key={editorKey}
editor={editor}
initialValue={editorValue}
onChange={handleChange}
>
{!readOnly && !disabled && (
<div
className="p-1 border-b flex flex-wrap gap-1 bg-muted/20"
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<FormatButton
format="bold"
icon={<Bold className="h-4 w-4" />}
isActive={formats.bold}
onMouseDown={(e) => toggleMark(e, 'bold')}
/>
<FormatButton
format="italic"
icon={<Italic className="h-4 w-4" />}
isActive={formats.italic}
onMouseDown={(e) => toggleMark(e, 'italic')}
/>
<FormatButton
format="underline"
icon={<Underline className="h-4 w-4" />}
isActive={formats.underline}
onMouseDown={(e) => toggleMark(e, 'underline')}
/>
<ColorButton
onSelectColor={handleColorSelect}
activeColor={currentColor}
/>
<div className="h-8 w-px bg-border mx-1" />
<FormatButton
format="bulleted-list"
icon={<List className="h-4 w-4" />}
isActive={formats['bulleted-list']}
onMouseDown={(e) => toggleMark(e, 'bulleted-list')}
/>
<FormatButton
format="numbered-list"
icon={<ListOrdered className="h-4 w-4" />}
isActive={formats['numbered-list']}
onMouseDown={(e) => toggleMark(e, 'numbered-list')}
/>
<div className="h-8 w-px bg-border mx-1" />
<FormatButton
format="indent"
icon={<Indent className="h-4 w-4" />}
onMouseDown={(e) => handleIndent(e, true)}
/>
<FormatButton
format="outdent"
icon={<Outdent className="h-4 w-4" />}
onMouseDown={(e) => handleIndent(e, false)}
/>
<div className="h-8 w-px bg-border mx-1" />
<FormatButton
format="align-left"
icon={<AlignLeft className="h-4 w-4" />}
isActive={formats['align-left']}
onMouseDown={(e) => toggleMark(e, 'align-left')}
/>
<FormatButton
format="align-center"
icon={<AlignCenter className="h-4 w-4" />}
isActive={formats['align-center']}
onMouseDown={(e) => toggleMark(e, 'align-center')}
/>
<FormatButton
format="align-right"
icon={<AlignRight className="h-4 w-4" />}
isActive={formats['align-right']}
onMouseDown={(e) => toggleMark(e, 'align-right')}
/>
</div>
)}
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder || 'Enter some text...'}
readOnly={readOnly || disabled}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={`outline-none p-3 ${minHeight || 'min-h-[100px]'}`}
onPaste={handlePaste}
/>
</Slate>
</div>
);
};
export default SlateRichTextEditor;