'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 (
);
}
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 */}
setIsEditing(true)}
className="p-1 bg-white rounded-full shadow-sm hover:bg-blue-50 border border-gray-200 text-blue-600 hover:text-blue-700"
title="Edit image"
>
{element.src ? (
<>
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') && (
)}
>
) : (
🖼️
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 */}
toggleBlock('paragraph')}
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
title="Normal paragraph (Ctrl+Shift+0)"
>
P
toggleBlock('heading-one')}
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 1 (Ctrl+Shift+1)"
>
H1
toggleBlock('heading-two')}
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 2 (Ctrl+Shift+2)"
>
H2
toggleBlock('heading-three')}
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 3 (Ctrl+Shift+3)"
>
H3
{/* Text formatting buttons */}
toggleMark('bold')}
className={`font-bold ${isMarkActive('bold') ? 'theme-accent-bg text-white' : ''}`}
title="Bold (Ctrl+B)"
>
B
toggleMark('italic')}
className={`italic ${isMarkActive('italic') ? 'theme-accent-bg text-white' : ''}`}
title="Italic (Ctrl+I)"
>
I
toggleMark('underline')}
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
title="Underline (Ctrl+U)"
>
U
toggleMark('strikethrough')}
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
title="Strikethrough (Ctrl+D)"
>
S
{/* 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
setIsScrollable(!isScrollable)}
className={isScrollable ? 'theme-accent-bg text-white' : ''}
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
>
{isScrollable ? '📜' : '📏'}
{error && (
{error}
)}
);
}