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 = `${text}`; if (child.italic) text = `${text}`; if (child.underline) text = `${text}`; if (child.color) text = `${text}`; 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 `${textContent}

`; 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 `${node.children.map(item => `
  • ${item.children.map(child => { let text = child.text; if (child.bold) text = `${text}`; if (child.italic) text = `${text}`; if (child.underline) text = `${text}`; if (child.color) text = `${text}`; return text; }).join('')}
  • ` ).join('')}`; 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 `${node.children.map(item => `
  • ${item.children.map(child => { let text = child.text; if (child.bold) text = `${text}`; if (child.italic) text = `${text}`; if (child.underline) text = `${text}`; if (child.color) text = `${text}`; return text; }).join('')}
  • ` ).join('')}`; 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 `${node.children.map(child => { let text = child.text; if (child.bold) text = `${text}`; if (child.italic) text = `${text}`; if (child.underline) text = `${text}`; if (child.color) text = `${text}`; return text; }).join('')}`; default: return ''; } } else { // It's a text node let text = node.text; if (node.bold) text = `${text}`; if (node.italic) text = `${text}`; if (node.underline) text = `${text}`; if (node.color) text = `${text}`; 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('•') || element.innerHTML.includes('•')) { // 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 = { 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 = ({ format, icon, isActive = false, onMouseDown }) => { return ( ); }; // Custom type for Slate Element props with align support interface CustomRenderElementProps extends Omit { 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
      {children}
    ; case 'numbered-list': return
      {children}
    ; case 'list-item': return
  • {children}
  • ; default: return

    {children}

    ; } }; // 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 = {children}; if (leaf.bold) { textElement = {textElement}; } if (leaf.italic) { textElement = {textElement}; } if (leaf.underline) { textElement = {textElement}; } 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 = { 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 = { 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, { 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, { 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(null); // Reference to the dropdown element const dropdownRef = useRef(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 (
    {isOpen && (
    {colorOptions.map((colorOption) => (
    )}
    ); }; export const SlateRichTextEditor: React.FC = ({ 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(() => 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 ; }, []); // Define a rendering function for custom text nodes const renderLeaf = useCallback((props: RenderLeafProps) => , []); // 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}`; } // 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) => { console.log('Key down event:', event.key); }, [editor]); // Ensure editor focuses on click const handleClick = useCallback((event: React.MouseEvent) => { 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 (
    e.stopPropagation()} > {!readOnly && !disabled && (
    e.stopPropagation()} onClick={e => e.stopPropagation()} > } isActive={formats.bold} onMouseDown={(e) => toggleMark(e, 'bold')} /> } isActive={formats.italic} onMouseDown={(e) => toggleMark(e, 'italic')} /> } isActive={formats.underline} onMouseDown={(e) => toggleMark(e, 'underline')} />
    } isActive={formats['bulleted-list']} onMouseDown={(e) => toggleMark(e, 'bulleted-list')} /> } isActive={formats['numbered-list']} onMouseDown={(e) => toggleMark(e, 'numbered-list')} />
    } onMouseDown={(e) => handleIndent(e, true)} /> } onMouseDown={(e) => handleIndent(e, false)} />
    } isActive={formats['align-left']} onMouseDown={(e) => toggleMark(e, 'align-left')} /> } isActive={formats['align-center']} onMouseDown={(e) => toggleMark(e, 'align-center')} /> } isActive={formats['align-right']} onMouseDown={(e) => toggleMark(e, 'align-right')} />
    )}
    ); }; export default SlateRichTextEditor;