'use client'; import React, { useState, useCallback, useMemo } from 'react'; import { createEditor, Descendant, Element as SlateElement, Node as SlateNode, Transforms, Editor, Range } from 'slate'; import { Slate, Editable, withReact, ReactEditor, RenderElementProps, RenderLeafProps, useSlate as useEditor } from 'slate-react'; import { withHistory } from 'slate-history'; import Button from '../ui/Button'; import { sanitizeHtmlSync } from '../../lib/sanitization'; import { debug } from '../../lib/debug'; interface SlateEditorProps { value: string; // HTML value for compatibility with existing code onChange: (value: string) => void; // Returns HTML for compatibility placeholder?: string; error?: string; storyId?: string; enableImageProcessing?: boolean; } // Custom types for our editor type CustomElement = { type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'image'; children: CustomText[]; src?: string; // for images alt?: string; // for images caption?: string; // for images }; type CustomText = { text: string; bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; }; declare module 'slate' { interface CustomTypes { Editor: ReactEditor; Element: CustomElement; Text: CustomText; } } // HTML to Slate conversion - preserves mixed content order const htmlToSlate = (html: string): Descendant[] => { if (!html || html.trim() === '') { return [{ type: 'paragraph', children: [{ text: '' }] }]; } const sanitizedHtml = sanitizeHtmlSync(html); const parser = new DOMParser(); const doc = parser.parseFromString(sanitizedHtml, 'text/html'); const nodes: Descendant[] = []; // Process all nodes in document order to maintain sequence const processChildNodes = (parentNode: Node): Descendant[] => { const results: Descendant[] = []; Array.from(parentNode.childNodes).forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; switch (element.tagName.toLowerCase()) { case 'h1': results.push({ type: 'heading-one', children: [{ text: element.textContent || '' }] }); break; case 'h2': results.push({ type: 'heading-two', children: [{ text: element.textContent || '' }] }); break; case 'h3': results.push({ type: 'heading-three', children: [{ text: element.textContent || '' }] }); break; case 'blockquote': case 'pre': case 'code': { // Filter out blockquotes, code blocks, and code - convert to paragraph const text = element.textContent || ''; if (text.trim()) { results.push({ type: 'paragraph', children: [{ text: text.trim() }] }); } break; } case 'img': { const img = element as HTMLImageElement; results.push({ type: 'image', src: img.src || img.getAttribute('src') || '', alt: img.alt || img.getAttribute('alt') || '', caption: img.title || img.getAttribute('title') || '', children: [{ text: '' }] // Images need children in Slate }); break; } case 'p': case 'div': { // Check if this paragraph contains mixed content (text + images) if (element.querySelector('img')) { // Process mixed content - handle both text and images in order results.push(...processChildNodes(element)); } else { const text = element.textContent || ''; if (text.trim()) { results.push({ type: 'paragraph', children: [{ text }] }); } } break; } case 'br': // Handle line breaks by creating empty paragraphs results.push({ type: 'paragraph', children: [{ text: '' }] }); break; default: { // For other elements, try to extract text or recurse const text = element.textContent || ''; if (text.trim()) { results.push({ type: 'paragraph', children: [{ text }] }); } break; } } } else if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; if (text.trim()) { results.push({ type: 'paragraph', children: [{ text: text.trim() }] }); } } }); return results; }; // Process all content nodes.push(...processChildNodes(doc.body)); // Fallback for simple text content if (nodes.length === 0 && doc.body.textContent?.trim()) { const text = doc.body.textContent.trim(); const lines = text.split('\n').filter(line => line.trim()); lines.forEach(line => { nodes.push({ type: 'paragraph', children: [{ text: line.trim() }] }); }); } return nodes.length > 0 ? nodes : [{ type: 'paragraph', children: [{ text: '' }] }]; }; // Slate to HTML conversion const slateToHtml = (nodes: Descendant[]): string => { const htmlParts: string[] = []; nodes.forEach(node => { if (SlateElement.isElement(node)) { const element = node as CustomElement; const text = SlateNode.string(node); switch (element.type) { case 'heading-one': htmlParts.push(`

${text}

`); break; case 'heading-two': htmlParts.push(`

${text}

`); break; case 'heading-three': htmlParts.push(`

${text}

`); break; case 'image': const attrs: string[] = []; if (element.src) attrs.push(`src="${element.src}"`); if (element.alt) attrs.push(`alt="${element.alt}"`); if (element.caption) attrs.push(`title="${element.caption}"`); htmlParts.push(``); break; case 'paragraph': default: htmlParts.push(text ? `

${text}

` : '

'); break; } } }); const html = htmlParts.join('\n'); return sanitizeHtmlSync(html); }; // Custom plugin to handle images const withImages = (editor: ReactEditor) => { const { insertData, isVoid } = editor; editor.isVoid = element => { return element.type === 'image' ? true : isVoid(element); }; editor.insertData = (data) => { const html = data.getData('text/html'); if (html && html.includes(' { Transforms.insertNodes(editor, node); }); debug.log(`📋 Inserted ${slateNodes.length} nodes from pasted HTML`); return; } insertData(data); }; return editor; }; // Interactive Image Component const ImageElement = ({ attributes, element, children }: { attributes: any; element: CustomElement; children: React.ReactNode; }) => { const editor = useEditor(); const [isEditing, setIsEditing] = useState(false); const [editUrl, setEditUrl] = useState(element.src || ''); const [editAlt, setEditAlt] = useState(element.alt || ''); const [editCaption, setEditCaption] = useState(element.caption || ''); const handleDelete = () => { const path = ReactEditor.findPath(editor, element); Transforms.removeNodes(editor, { at: path }); }; const handleSave = () => { const path = ReactEditor.findPath(editor, element); const newProperties: Partial = { src: editUrl, alt: editAlt, caption: editCaption, }; Transforms.setNodes(editor, newProperties, { at: path }); setIsEditing(false); }; const handleCancel = () => { setEditUrl(element.src || ''); setEditAlt(element.alt || ''); setEditCaption(element.caption || ''); setIsEditing(false); }; if (isEditing) { return (

Edit Image

setEditUrl(e.target.value)} className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="https://example.com/image.jpg" />
setEditAlt(e.target.value)} className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Describe the image" />
setEditCaption(e.target.value)} className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Image caption" />
{children}
); } return (
{ // Handle delete/backspace on focused image if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); handleDelete(); } // Handle Enter to edit if (event.key === 'Enter') { event.preventDefault(); setIsEditing(true); } }} onClick={() => { // Focus the image element when clicked const path = ReactEditor.findPath(editor, element); const start = Editor.start(editor, path); Transforms.select(editor, start); }} > {/* Control buttons - show on hover */}
{element.src ? ( <> {element.alt setIsEditing(true)} onError={(e) => { // Fallback to text block if image fails to load const target = e.target as HTMLImageElement; const parent = target.parentElement; if (parent) { parent.innerHTML = `
⚠️ Image failed to load

Source: ${element.src}

${element.alt ? `

Alt: ${element.alt}

` : ''} ${element.caption ? `

Caption: ${element.caption}

` : ''}
`; } }} /> {(element.alt || element.caption) && (
{element.caption && (

{element.caption}

)} {element.alt && element.alt !== element.caption && (

{element.alt}

)}
)} {/* External image indicator */} {element.src.startsWith('http') && (
🌐 External
)} ) : (
🖼️ Image (No Source)
{element.alt &&

Alt: {element.alt}

} {element.caption &&

Caption: {element.caption}

}
)}
{children}
); }; // Component for rendering elements const Element = ({ attributes, children, element }: RenderElementProps) => { const customElement = element as CustomElement; switch (customElement.type) { case 'heading-one': return

{children}

; case 'heading-two': return

{children}

; case 'heading-three': return

{children}

; case 'image': return ( ); default: return

{children}

; } }; // Component for rendering leaves (text formatting) const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => { const customLeaf = leaf as CustomText; if (customLeaf.bold) { children = {children}; } if (customLeaf.italic) { children = {children}; } if (customLeaf.underline) { children = {children}; } if (customLeaf.strikethrough) { children = {children}; } return {children}; }; // Toolbar component const Toolbar = ({ editor }: { editor: ReactEditor }) => { type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough'; const isMarkActive = (format: MarkFormat) => { const marks = Editor.marks(editor); return marks ? marks[format] === true : false; }; const toggleMark = (format: MarkFormat) => { const isActive = isMarkActive(format); if (isActive) { Editor.removeMark(editor, format); } else { Editor.addMark(editor, format, true); } }; const isBlockActive = (format: CustomElement['type']) => { const { selection } = editor; if (!selection) return false; const [match] = Array.from( Editor.nodes(editor, { at: Editor.unhangRange(editor, selection), match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format, }) ); return !!match; }; const toggleBlock = (format: CustomElement['type']) => { const isActive = isBlockActive(format); Transforms.setNodes( editor, { type: isActive ? 'paragraph' : format }, { match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) } ); }; const insertImage = () => { const url = prompt('Enter image URL:', 'https://'); if (url && url.trim() !== 'https://') { const imageNode: CustomElement = { type: 'image', src: url.trim(), alt: '', caption: '', children: [{ text: '' }], }; Transforms.insertNodes(editor, imageNode); // Add a paragraph after the image Transforms.insertNodes(editor, { type: 'paragraph', children: [{ text: '' }], }); } }; return (
✨ Slate.js Editor
{/* Block type buttons */}
{/* Text formatting buttons */}
{/* Image insertion button */}
); }; export default function SlateEditor({ value, onChange, placeholder = 'Write your story here...', error, storyId, enableImageProcessing = false }: SlateEditorProps) { const [isScrollable, setIsScrollable] = useState(true); // Create editor with plugins const editor = useMemo( () => withImages(withHistory(withReact(createEditor()))), [] ); // Convert HTML to Slate format for initial value const initialValue = useMemo(() => { debug.log('🚀 Slate Editor initializing with HTML:', { htmlLength: value?.length }); return htmlToSlate(value); }, [value]); // Handle changes const handleChange = useCallback((newValue: Descendant[]) => { // Convert back to HTML and call onChange const html = slateToHtml(newValue); onChange(html); debug.log('📝 Slate Editor changed:', { htmlLength: html.length, nodeCount: newValue.length }); }, [onChange]); debug.log('🎯 Slate Editor loaded!', { valueLength: value?.length, enableImageProcessing, hasStoryId: !!storyId }); return (
{ // Handle delete/backspace for selected content (including images) if (event.key === 'Delete' || event.key === 'Backspace') { const { selection } = editor; if (!selection) return; // If there's an expanded selection, let Slate handle it naturally // This will delete all selected content including images if (!Range.isCollapsed(selection)) { // Slate will handle this automatically, including void elements return; } // Handle single point deletions near images const { anchor } = selection; if (event.key === 'Delete') { // Delete key - check if next node is an image try { const [nextNode] = Editor.next(editor, { at: anchor }) || []; if (nextNode && SlateElement.isElement(nextNode) && nextNode.type === 'image') { event.preventDefault(); const path = ReactEditor.findPath(editor, nextNode); Transforms.removeNodes(editor, { at: path }); return; } } catch (error) { // Ignore navigation errors at document boundaries } } else if (event.key === 'Backspace') { // Backspace key - check if previous node is an image try { const [prevNode] = Editor.previous(editor, { at: anchor }) || []; if (prevNode && SlateElement.isElement(prevNode) && prevNode.type === 'image') { event.preventDefault(); const path = ReactEditor.findPath(editor, prevNode); Transforms.removeNodes(editor, { at: path }); return; } } catch (error) { // Ignore navigation errors at document boundaries } } } // Handle keyboard shortcuts if (!event.ctrlKey && !event.metaKey) return; // Helper function to toggle marks const toggleMarkShortcut = (format: 'bold' | 'italic' | 'underline' | 'strikethrough') => { event.preventDefault(); const marks = Editor.marks(editor); const isActive = marks ? marks[format] === true : false; if (isActive) { Editor.removeMark(editor, format); } else { Editor.addMark(editor, format, true); } }; // Helper function to toggle blocks const toggleBlockShortcut = (format: CustomElement['type']) => { event.preventDefault(); const isActive = isBlockActive(format); Transforms.setNodes( editor, { type: isActive ? 'paragraph' : format }, { match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) } ); }; // Check if block is active const isBlockActive = (format: CustomElement['type']) => { const { selection } = editor; if (!selection) return false; const [match] = Array.from( Editor.nodes(editor, { at: Editor.unhangRange(editor, selection), match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format, }) ); return !!match; }; switch (event.key) { // Text formatting shortcuts case 'b': toggleMarkShortcut('bold'); break; case 'i': toggleMarkShortcut('italic'); break; case 'u': toggleMarkShortcut('underline'); break; case 'd': // Ctrl+D for strikethrough toggleMarkShortcut('strikethrough'); break; // Block formatting shortcuts case '1': if (event.shiftKey) { // Ctrl+Shift+1 for H1 toggleBlockShortcut('heading-one'); } break; case '2': if (event.shiftKey) { // Ctrl+Shift+2 for H2 toggleBlockShortcut('heading-two'); } break; case '3': if (event.shiftKey) { // Ctrl+Shift+3 for H3 toggleBlockShortcut('heading-three'); } break; case '0': if (event.shiftKey) { // Ctrl+Shift+0 for normal paragraph toggleBlockShortcut('paragraph'); } break; // Select all case 'a': event.preventDefault(); Transforms.select(editor, { anchor: Editor.start(editor, []), focus: Editor.end(editor, []), }); break; } }} />

Slate.js Editor: Rich text editor with advanced image paste handling. {isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}

⌨️ Keyboard Shortcuts

Text Formatting:

  • Ctrl+B Bold
  • Ctrl+I Italic
  • Ctrl+U Underline
  • Ctrl+D Strikethrough

Block Formatting:

  • Ctrl+Shift+0 Paragraph
  • Ctrl+Shift+1 Heading 1
  • Ctrl+Shift+2 Heading 2
  • Ctrl+Shift+3 Heading 3
{error && (

{error}

)}
); }