942 lines
32 KiB
TypeScript
942 lines
32 KiB
TypeScript
'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(`<h1>${text}</h1>`);
|
||
break;
|
||
case 'heading-two':
|
||
htmlParts.push(`<h2>${text}</h2>`);
|
||
break;
|
||
case 'heading-three':
|
||
htmlParts.push(`<h3>${text}</h3>`);
|
||
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(`<img ${attrs.join(' ')} />`);
|
||
break;
|
||
case 'paragraph':
|
||
default:
|
||
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
|
||
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('<img')) {
|
||
debug.log('📋 Image paste detected in Slate editor');
|
||
|
||
// Convert HTML to Slate nodes maintaining order
|
||
const slateNodes = htmlToSlate(html);
|
||
|
||
// Insert all nodes in sequence
|
||
slateNodes.forEach(node => {
|
||
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<CustomElement> = {
|
||
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 (
|
||
<div {...attributes} contentEditable={false} className="my-4">
|
||
<div className="border border-blue-300 rounded-lg p-4 bg-blue-50">
|
||
<h4 className="font-medium text-blue-900 mb-3">Edit Image</h4>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||
Image URL *
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={editUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||
Alt Text
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editAlt}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||
Caption
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editCaption}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 mt-4">
|
||
<button
|
||
onClick={handleSave}
|
||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
onClick={handleCancel}
|
||
className="px-3 py-1 bg-gray-300 text-gray-700 text-sm rounded hover:bg-gray-400 focus:ring-2 focus:ring-gray-500"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div {...attributes} contentEditable={false} className="my-4">
|
||
<div
|
||
className="relative border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm group hover:shadow-md transition-shadow focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
// 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 */}
|
||
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => 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"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={handleDelete}
|
||
className="p-1 bg-white rounded-full shadow-sm hover:bg-red-50 border border-gray-200 text-red-600 hover:text-red-700"
|
||
title="Delete image"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{element.src ? (
|
||
<>
|
||
<img
|
||
src={element.src}
|
||
alt={element.alt || ''}
|
||
className="w-full h-auto max-h-96 object-contain cursor-pointer"
|
||
onDoubleClick={() => 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 = `
|
||
<div class="p-3 border border-dashed border-red-300 rounded-lg bg-red-50">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-lg">⚠️</span>
|
||
<span class="font-medium text-red-700">Image failed to load</span>
|
||
</div>
|
||
<div class="text-sm text-red-600 space-y-1">
|
||
<p><strong>Source:</strong> ${element.src}</p>
|
||
${element.alt ? `<p><strong>Alt:</strong> ${element.alt}</p>` : ''}
|
||
${element.caption ? `<p><strong>Caption:</strong> ${element.caption}</p>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}}
|
||
/>
|
||
{(element.alt || element.caption) && (
|
||
<div className="p-2 bg-gray-50 border-t border-gray-200">
|
||
<div className="text-sm text-gray-600">
|
||
{element.caption && (
|
||
<p className="font-medium">{element.caption}</p>
|
||
)}
|
||
{element.alt && element.alt !== element.caption && (
|
||
<p className="italic">{element.alt}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* External image indicator */}
|
||
{element.src.startsWith('http') && (
|
||
<div className="absolute top-2 right-2">
|
||
<div className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||
<span>🌐</span>
|
||
<span>External</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="p-3 border border-dashed border-gray-300 rounded-lg bg-gray-50">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-lg">🖼️</span>
|
||
<span className="font-medium text-gray-700">Image (No Source)</span>
|
||
</div>
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
{element.alt && <p><strong>Alt:</strong> {element.alt}</p>}
|
||
{element.caption && <p><strong>Caption:</strong> {element.caption}</p>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Component for rendering elements
|
||
const Element = ({ attributes, children, element }: RenderElementProps) => {
|
||
const customElement = element as CustomElement;
|
||
|
||
switch (customElement.type) {
|
||
case 'heading-one':
|
||
return <h1 {...attributes} className="text-3xl font-bold mb-4">{children}</h1>;
|
||
case 'heading-two':
|
||
return <h2 {...attributes} className="text-2xl font-bold mb-3">{children}</h2>;
|
||
case 'heading-three':
|
||
return <h3 {...attributes} className="text-xl font-bold mb-3">{children}</h3>;
|
||
case 'image':
|
||
return (
|
||
<ImageElement
|
||
attributes={attributes}
|
||
element={customElement}
|
||
children={children}
|
||
/>
|
||
);
|
||
default:
|
||
return <p {...attributes} className="mb-2">{children}</p>;
|
||
}
|
||
};
|
||
|
||
// Component for rendering leaves (text formatting)
|
||
const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
||
const customLeaf = leaf as CustomText;
|
||
|
||
if (customLeaf.bold) {
|
||
children = <strong>{children}</strong>;
|
||
}
|
||
|
||
if (customLeaf.italic) {
|
||
children = <em>{children}</em>;
|
||
}
|
||
|
||
if (customLeaf.underline) {
|
||
children = <u>{children}</u>;
|
||
}
|
||
|
||
if (customLeaf.strikethrough) {
|
||
children = <s>{children}</s>;
|
||
}
|
||
|
||
return <span {...attributes}>{children}</span>;
|
||
};
|
||
|
||
// 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 (
|
||
<div className="flex items-center gap-2 p-2 theme-card border theme-border rounded-t-lg">
|
||
<div className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||
✨ Slate.js Editor
|
||
</div>
|
||
|
||
{/* Block type buttons */}
|
||
<div className="flex items-center gap-1 border-r pr-2 mr-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleBlock('paragraph')}
|
||
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
|
||
title="Normal paragraph (Ctrl+Shift+0)"
|
||
>
|
||
P
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleBlock('heading-one')}
|
||
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Heading 1 (Ctrl+Shift+1)"
|
||
>
|
||
H1
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleBlock('heading-two')}
|
||
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Heading 2 (Ctrl+Shift+2)"
|
||
>
|
||
H2
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleBlock('heading-three')}
|
||
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Heading 3 (Ctrl+Shift+3)"
|
||
>
|
||
H3
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Text formatting buttons */}
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleMark('bold')}
|
||
className={`font-bold ${isMarkActive('bold') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Bold (Ctrl+B)"
|
||
>
|
||
B
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleMark('italic')}
|
||
className={`italic ${isMarkActive('italic') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Italic (Ctrl+I)"
|
||
>
|
||
I
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleMark('underline')}
|
||
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Underline (Ctrl+U)"
|
||
>
|
||
U
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleMark('strikethrough')}
|
||
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
|
||
title="Strikethrough (Ctrl+D)"
|
||
>
|
||
S
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Image insertion button */}
|
||
<div className="flex items-center gap-1 border-l pl-2 ml-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={insertImage}
|
||
className="text-green-600 hover:bg-green-50"
|
||
title="Insert Image"
|
||
>
|
||
🖼️
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 (
|
||
<div className="space-y-2">
|
||
<Slate editor={editor} initialValue={initialValue} onChange={handleChange}>
|
||
<Toolbar editor={editor} />
|
||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||
<Editable
|
||
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
|
||
isScrollable
|
||
? 'h-[400px] overflow-y-auto'
|
||
: 'min-h-[300px]'
|
||
}`}
|
||
placeholder={placeholder}
|
||
renderElement={Element}
|
||
renderLeaf={Leaf}
|
||
onKeyDown={(event) => {
|
||
// 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;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-between items-center">
|
||
<div className="text-xs theme-text space-y-1">
|
||
<p>
|
||
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
|
||
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
||
</p>
|
||
<details className="text-xs">
|
||
<summary className="cursor-pointer hover:theme-accent-text font-medium">⌨️ Keyboard Shortcuts</summary>
|
||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 p-2 theme-card border theme-border rounded">
|
||
<div>
|
||
<p className="font-semibold mb-1">Text Formatting:</p>
|
||
<ul className="space-y-0.5">
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+B</kbd> Bold</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+I</kbd> Italic</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+U</kbd> Underline</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+D</kbd> Strikethrough</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold mb-1">Block Formatting:</p>
|
||
<ul className="space-y-0.5">
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+0</kbd> Paragraph</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+1</kbd> Heading 1</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+2</kbd> Heading 2</li>
|
||
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+3</kbd> Heading 3</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => setIsScrollable(!isScrollable)}
|
||
className={isScrollable ? 'theme-accent-bg text-white' : ''}
|
||
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
|
||
>
|
||
{isScrollable ? '📜' : '📏'}
|
||
</Button>
|
||
</div>
|
||
</Slate>
|
||
|
||
{error && (
|
||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
} |