Spaces:
Sleeping
Sleeping
| 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('•') || | |
| 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<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; |