formatting is preserved
- let code = (codeEl as HTMLElement).innerText || codeEl.textContent || '';
-
- const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
-
- if (code.trim()) {
- blocks.push({
- _type: 'codeBlock',
- _key: generateKey(),
- code,
- language,
- });
- processedElements.add(element);
- if (element.tagName === 'PRE') processedElements.add(codeEl);
- }
- }
- continue;
- }
-
- // Handle text blocks (paragraphs, headings, etc.)
- if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
- // Skip if this contains already processed elements (but allow inline code)
- if (element.querySelector('img')) {
- processedElements.add(element);
- continue;
- }
-
- // Check if this is a standalone pre/code block that should be handled specially
- const hasStandaloneCodeBlock = element.querySelector('pre') && element.children.length === 1 && element.children[0].tagName === 'PRE';
- if (hasStandaloneCodeBlock) {
- processedElements.add(element);
- continue; // Let the pre/code block handler take care of this
- }
-
- const style = getStyleFromElement(element);
-
- // For text content that may contain inline code, preserve formatting better
- let text: string;
- if (element.querySelector('code') && !element.querySelector('pre')) {
- // Has inline code - use innerText to preserve some formatting
- text = (element as HTMLElement).innerText || element.textContent || '';
- } else {
- // Regular text - use textContent, but also check if this might have come from a pre block
- text = (element as HTMLElement).innerText || element.textContent || '';
- }
-
- if (text.trim()) {
- // Keep newlines within a single block - they'll be converted to
tags later
- blocks.push({
- _type: 'block',
- _key: generateKey(),
- style,
- markDefs: [],
- children: [{
- _type: 'span',
- _key: generateKey(),
- text, // Keep newlines in the text
- marks: []
- }]
- });
- processedElements.add(element);
- }
- }
- }
-
- return blocks.length > 0 ? blocks : [{
- _type: 'block',
- _key: generateKey(),
- style: 'normal',
- markDefs: [],
- children: [{
- _type: 'span',
- _key: generateKey(),
- text: '',
- marks: []
- }]
- }];
-}
-
-function portableTextToHtml(blocks: PortableTextBlock[]): string {
- if (!blocks || blocks.length === 0) return '';
-
- const htmlParts: string[] = [];
-
- blocks.forEach(block => {
- if (block._type === 'block' && Array.isArray(block.children)) {
- const tag = getHtmlTagFromStyle((block.style as string) || 'normal');
- const children = block.children as PortableTextChild[];
- const text = children
- .map(child => child._type === 'span' ? child.text || '' : '')
- .join('') || '';
-
- // Convert any remaining newlines in text to
tags for proper display
- const textWithBreaks = text.replace(/\n/g, '
');
- // Always include blocks, even empty ones (they represent line breaks/paragraph spacing)
- htmlParts.push(`<${tag}>${textWithBreaks}${tag}>`);
- } else if (block._type === 'image' && isImageBlock(block)) {
- // Convert image blocks back to HTML
- const attrs: string[] = [];
- if (block.src) attrs.push(`src="${block.src}"`);
- if (block.alt) attrs.push(`alt="${block.alt}"`);
- if (block.caption) attrs.push(`title="${block.caption}"`);
- if (block.width) attrs.push(`width="${block.width}"`);
- if (block.height) attrs.push(`height="${block.height}"`);
-
- htmlParts.push(`
`);
- } else if (block._type === 'codeBlock' && isCodeBlock(block)) {
- // Convert code blocks back to HTML
- const langClass = block.language ? ` class="language-${block.language}"` : '';
- // Escape HTML entities in code content to prevent XSS and preserve formatting
- const escapedCode = (block.code || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- htmlParts.push(`${escapedCode}
`);
- }
- });
-
- const html = htmlParts.join('\n');
- return sanitizeHtmlSync(html);
-}
-
-function getStyleFromElement(element: Element): string {
- const tagName = element.tagName.toLowerCase();
- const styleMap: Record = {
- 'p': 'normal',
- 'div': 'normal',
- 'h1': 'h1',
- 'h2': 'h2',
- 'h3': 'h3',
- 'h4': 'h4',
- 'h5': 'h5',
- 'h6': 'h6',
- 'blockquote': 'blockquote',
- };
- return styleMap[tagName] || 'normal';
-}
-
-function getHtmlTagFromStyle(style: string): string {
- const tagMap: Record = {
- 'normal': 'p',
- 'h1': 'h1',
- 'h2': 'h2',
- 'h3': 'h3',
- 'h4': 'h4',
- 'h5': 'h5',
- 'h6': 'h6',
- 'blockquote': 'blockquote',
- };
- return tagMap[style] || 'p';
-}
-
-interface PortableTextChild {
- _type: string;
- _key: string;
- text?: string;
- marks?: string[];
-}
-
-// Type guards for custom block types
-function isImageBlock(value: any): value is {
- _type: 'image';
- src?: string;
- alt?: string;
- caption?: string;
- width?: number;
- height?: number;
-} {
- return value && typeof value === 'object' && value._type === 'image';
-}
-
-function isCodeBlock(value: any): value is {
- _type: 'codeBlock';
- code?: string;
- language?: string;
-} {
- return value && typeof value === 'object' && value._type === 'codeBlock';
-}
-
-function generateKey(): string {
- return Math.random().toString(36).substring(2, 11);
-}
-
-
-// Toolbar component
-function EditorToolbar({
- isScrollable,
- onToggleScrollable
-}: {
- isScrollable: boolean;
- onToggleScrollable: () => void;
-}) {
- const editor = useEditor();
-
- const toggleDecorator = (decorator: string) => {
- editor.send({ type: 'decorator.toggle', decorator });
- };
-
- const setStyle = (style: string) => {
- editor.send({ type: 'style.toggle', style });
- };
-
- return (
-
-
-
- ✨ Portable Text Editor
-
-
- {/* Style buttons */}
-
-
-
-
-
-
-
- {/* Decorator buttons */}
-
-
-
-
-
-
-
-
- {/* Scrollable toggle */}
-
- Scrollable:
-
-
-
- );
-}
-
-
-// Simple component that uses Portable Text editor directly
-function EditorContent({
- value,
- onChange,
- placeholder,
- error
-}: {
- value: string;
- onChange: (value: string) => void;
- placeholder?: string;
- error?: string;
-}) {
- const [portableTextValue, setPortableTextValue] = useState(() =>
- htmlToPortableTextBlocks(value)
- );
- const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable
-
- // Sync HTML value with prop changes (but not for internal changes)
- useEffect(() => {
- // Skip re-initialization if this change came from the editor itself
- if (isInternalChange.current) {
- debug.log('🔄 Skipping re-initialization for internal change');
- return;
- }
-
- debug.log('🔄 Editor value changed externally:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
- setPortableTextValue(htmlToPortableTextBlocks(value));
- }, [value]);
-
- // Debug: log when portableTextValue changes
- useEffect(() => {
- debug.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue });
- }, [portableTextValue]);
-
- // Track if changes are coming from internal editor changes
- const isInternalChange = useRef(false);
-
- // Handle content changes using the EventListenerPlugin
- const handleEditorChange = useCallback((event: any) => {
- if (event.type === 'mutation') {
- debug.log('📝 Editor content changed via EventListener:', { valueLength: event.value?.length });
- const html = portableTextToHtml(event.value);
- debug.log('📝 Converted to HTML:', { htmlLength: html.length, htmlPreview: html.substring(0, 200) });
-
- // Mark this as an internal change to prevent re-initialization
- isInternalChange.current = true;
- onChange(html);
- // Reset flag after a short delay to allow external changes
- setTimeout(() => {
- isInternalChange.current = false;
- }, 100);
- }
- }, [onChange]);
-
- // Add a ref to the editor container for direct paste handling
- const editorContainerRef = useRef(null);
-
- // Global paste event listener to catch ALL paste events
- useEffect(() => {
- const handleGlobalPaste = (event: ClipboardEvent) => {
- debug.log('🌍 Global paste event captured');
-
- // Check if the paste is happening within our editor
- const target = event.target as Element;
- const isInEditor = editorContainerRef.current?.contains(target);
-
- debug.log('📋 Paste details:', {
- isInEditor,
- targetTag: target?.tagName,
- targetClasses: target?.className,
- hasClipboardData: !!event.clipboardData
- });
-
- if (isInEditor && event.clipboardData) {
- const htmlData = event.clipboardData.getData('text/html');
- const textData = event.clipboardData.getData('text/plain');
-
- debug.log('📋 Clipboard contents:', {
- htmlLength: htmlData.length,
- textLength: textData.length,
- hasImages: htmlData.includes('
({
- type: block._type,
- key: block._key,
- ...(block._type === 'image' ? { src: (block as any).src, alt: (block as any).alt } : {}),
- ...(block._type === 'block' ? { style: (block as any).style, text: (block as any).children?.[0]?.text?.substring(0, 50) } : {})
- })));
-
- if (pastedBlocks.length > 0) {
- // Insert the blocks at the end of current content (maintaining order within the paste)
- setTimeout(() => {
- setPortableTextValue(prev => {
- const updatedBlocks = [...prev, ...pastedBlocks];
- const html = portableTextToHtml(updatedBlocks);
- onChange(html);
- debug.log('📋 Added structured blocks maintaining order:', { pastedCount: pastedBlocks.length, totalBlocks: updatedBlocks.length });
- return updatedBlocks;
- });
- }, 10);
- }
- }
- }
- };
-
- // Add global event listener with capture phase to catch events early
- document.addEventListener('paste', handleGlobalPaste, true);
-
- return () => {
- document.removeEventListener('paste', handleGlobalPaste, true);
- };
- }, [onChange]);
-
- // Handle paste events directly on the editor container (backup approach)
- const handleContainerPaste = useCallback((_event: React.ClipboardEvent) => {
- debug.log('📦 Container paste handler triggered');
- // This might not be reached if global handler prevents default
- }, []);
-
- // Render functions for the editor
- const renderStyle: RenderStyleFunction = useCallback((props) => {
- const { schemaType, children } = props;
-
- switch (schemaType.value) {
- case 'h1':
- return {children}
;
- case 'h2':
- return {children}
;
- case 'h3':
- return {children}
;
- case 'h4':
- return {children}
;
- case 'h5':
- return {children}
;
- case 'h6':
- return {children}
;
- case 'blockquote':
- return {children}
;
- default:
- return {children}
;
- }
- }, []);
-
- const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
- const { schemaType, children } = props;
-
- switch (schemaType.value) {
- case 'strong':
- return {children};
- case 'em':
- return {children};
- case 'underline':
- return {children};
- case 'strike':
- return {children};
- case 'code':
- return {children};
- default:
- return <>{children}>;
- }
- }, []);
-
- const renderBlock: RenderBlockFunction = useCallback((props) => {
- const { schemaType, value, children } = props;
-
- debug.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
-
- // Handle image blocks
- if (schemaType.name === 'image' && isImageBlock(value)) {
- debug.log('🖼️ Rendering image block:', value);
- return (
-
-
- 🖼️
- Image Block
-
-
- Source: {value.src || 'No source'}
- {value.alt && Alt text: {value.alt}
}
- {value.caption && Caption: {value.caption}
}
- {(value.width || value.height) && (
- Dimensions: {value.width || '?'} × {value.height || '?'}
- )}
-
-
- );
- }
-
- // Handle code blocks
- if (schemaType.name === 'codeBlock' && isCodeBlock(value)) {
- return (
-
-
- 💻
- Code Block
- {value.language && (
-
- {value.language}
-
- )}
-
-
- {value.code || '// No code'}
-
-
- );
- }
-
- // Default block rendering
- return {children};
- }, []);
-
- const renderListItem: RenderListItemFunction = useCallback((props) => {
- return {props.children} ;
- }, []);
-
- const renderAnnotation: RenderAnnotationFunction = useCallback((props) => {
- const { schemaType, children, value } = props;
-
- if (schemaType.name === 'link' && value && typeof value === 'object') {
- const linkValue = value as { href?: string; target?: string; title?: string };
- return (
-
- {children}
-
- );
- }
-
- return <>{children}>;
- }, []);
-
- return (
-
-
-
- setIsScrollable(!isScrollable)}
- />
-
-
-
-
-
- {error && (
- {error}
- )}
-
-
-
- Portable Text Editor: Rich text editor with structured content.
- {isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
- 📋 Paste detection active.
-
-
-
- );
-}
-
-export default function PortableTextEditorNew({
- value,
- onChange,
- placeholder = 'Write your story here...',
- error,
- storyId,
- enableImageProcessing = false
-}: PortableTextEditorProps) {
- debug.log('🎯 Portable Text Editor loaded!', {
- valueLength: value?.length,
- enableImageProcessing,
- hasStoryId: !!storyId
- });
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/frontend/src/components/stories/SlateEditor.tsx b/frontend/src/components/stories/SlateEditor.tsx
new file mode 100644
index 0000000..6b328b7
--- /dev/null
+++ b/frontend/src/components/stories/SlateEditor.tsx
@@ -0,0 +1,892 @@
+'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' | 'blockquote' | 'image' | 'code-block';
+ children: CustomText[];
+ src?: string; // for images
+ alt?: string; // for images
+ caption?: string; // for images
+ language?: string; // for code blocks
+};
+
+type CustomText = {
+ text: string;
+ bold?: boolean;
+ italic?: boolean;
+ underline?: boolean;
+ strikethrough?: boolean;
+ code?: 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':
+ results.push({
+ type: 'blockquote',
+ children: [{ text: element.textContent || '' }]
+ });
+ 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 'pre':
+ const codeEl = element.querySelector('code');
+ const code = codeEl ? codeEl.textContent || '' : element.textContent || '';
+ const language = codeEl?.className?.replace('language-', '') || '';
+ results.push({
+ type: 'code-block',
+ language,
+ children: [{ text: code }]
+ });
+ 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 'blockquote':
+ 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 'code-block':
+ const langClass = element.language ? ` class="language-${element.language}"` : '';
+ const escapedText = text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ htmlParts.push(`${escapedText}
`);
+ 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 ? (
+ <>
+
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 'blockquote':
+ return {children}
;
+ case 'image':
+ return (
+
+ );
+ case 'code-block':
+ return (
+
+ {children}
+
+ );
+ 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};
+ }
+
+ if (customLeaf.code) {
+ children = {children};
+ }
+
+ return {children};
+};
+
+// Toolbar component
+const Toolbar = ({ editor }: { editor: ReactEditor }) => {
+ type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code';
+
+ 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;
+
+ switch (event.key) {
+ case 'b': {
+ event.preventDefault();
+ const marks = Editor.marks(editor);
+ const isActive = marks ? marks.bold === true : false;
+ if (isActive) {
+ Editor.removeMark(editor, 'bold');
+ } else {
+ Editor.addMark(editor, 'bold', true);
+ }
+ break;
+ }
+ case 'i': {
+ event.preventDefault();
+ const marks = Editor.marks(editor);
+ const isActive = marks ? marks.italic === true : false;
+ if (isActive) {
+ Editor.removeMark(editor, 'italic');
+ } else {
+ Editor.addMark(editor, 'italic', true);
+ }
+ break;
+ }
+ case 'a': {
+ // Handle Ctrl+A / Cmd+A to select all
+ 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.'}
+
+
+
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/lib/portabletext/conversion.ts b/frontend/src/lib/portabletext/conversion.ts
deleted file mode 100644
index 7134c0c..0000000
--- a/frontend/src/lib/portabletext/conversion.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * Conversion utilities between HTML and Portable Text
- * Maintains compatibility with existing sanitization strategy
- */
-
-import type { PortableTextBlock } from '@portabletext/types';
-import type { CustomPortableTextBlock } from './schema';
-import { createTextBlock, createImageBlock } from './schema';
-import { sanitizeHtmlSync } from '../sanitization';
-
-/**
- * Convert HTML to Portable Text
- * This maintains backward compatibility with existing HTML content
- */
-export function htmlToPortableText(html: string): CustomPortableTextBlock[] {
- if (!html || html.trim() === '') {
- return [createTextBlock()];
- }
-
- // First sanitize the HTML using existing strategy
- const sanitizedHtml = sanitizeHtmlSync(html);
-
- // Parse the sanitized HTML into Portable Text blocks
- const parser = new DOMParser();
- const doc = parser.parseFromString(sanitizedHtml, 'text/html');
-
- const blocks: CustomPortableTextBlock[] = [];
-
- // Process each child element in the body
- const walker = doc.createTreeWalker(
- doc.body,
- NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
- );
-
- let currentBlock: PortableTextBlock | null = null;
- let node = walker.nextNode();
-
- while (node) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- const element = node as Element;
-
- // Handle block-level elements
- if (isBlockElement(element.tagName)) {
- // Finish current block if any
- if (currentBlock) {
- blocks.push(currentBlock);
- currentBlock = null;
- }
-
- // Handle images separately
- if (element.tagName === 'IMG') {
- const img = element as HTMLImageElement;
- blocks.push(createImageBlock(
- img.src,
- img.alt,
- img.title || undefined
- ));
- } else {
- // Create new block for this element
- const style = getBlockStyle(element.tagName);
- const text = element.textContent || '';
- currentBlock = createTextBlock(text, style);
- }
- } else {
- // Handle inline elements - add to current block
- if (!currentBlock) {
- currentBlock = createTextBlock();
- }
- // Inline elements are handled by processing their text content
- // Mark handling would go here for future enhancement
- }
- } else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
- // Handle text nodes
- if (!currentBlock) {
- currentBlock = createTextBlock();
- }
- // Text content is already included in the parent element processing
- }
-
- node = walker.nextNode();
- }
-
- // Add final block if any
- if (currentBlock) {
- blocks.push(currentBlock);
- }
-
- // If no blocks were created, return empty content
- if (blocks.length === 0) {
- return [createTextBlock()];
- }
-
- return blocks;
-}
-
-/**
- * Convert Portable Text to HTML
- * This ensures compatibility with existing backend processing
- */
-export function portableTextToHtml(blocks: CustomPortableTextBlock[]): string {
- if (!blocks || blocks.length === 0) {
- return '';
- }
-
- const htmlParts: string[] = [];
-
- for (const block of blocks) {
- if (block._type === 'block') {
- const portableBlock = block as PortableTextBlock;
- const tag = getHtmlTag(portableBlock.style || 'normal');
- const text = extractTextFromBlock(portableBlock);
-
- if (text.trim() || portableBlock.style !== 'normal') {
- htmlParts.push(`<${tag}>${text}${tag}>`);
- }
- } else if (block._type === 'image') {
- const imgBlock = block as any; // Type assertion for custom image block
- const alt = imgBlock.alt ? ` alt="${escapeHtml(imgBlock.alt)}"` : '';
- const title = imgBlock.caption ? ` title="${escapeHtml(imgBlock.caption)}"` : '';
- htmlParts.push(`
`);
- }
- }
-
- const html = htmlParts.join('\n');
-
- // Apply final sanitization to ensure security
- return sanitizeHtmlSync(html);
-}
-
-/**
- * Extract plain text from a Portable Text block
- */
-function extractTextFromBlock(block: PortableTextBlock): string {
- if (!block.children) return '';
-
- return block.children
- .map(child => {
- if (child._type === 'span') {
- return child.text || '';
- }
- return '';
- })
- .join('');
-}
-
-/**
- * Determine if an HTML tag is a block-level element
- */
-function isBlockElement(tagName: string): boolean {
- const blockElements = [
- 'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
- 'BLOCKQUOTE', 'UL', 'OL', 'LI', 'IMG', 'BR'
- ];
- return blockElements.includes(tagName.toUpperCase());
-}
-
-/**
- * Get Portable Text block style from HTML tag
- */
-function getBlockStyle(tagName: string): string {
- const styleMap: Record = {
- 'P': 'normal',
- 'DIV': 'normal',
- 'H1': 'h1',
- 'H2': 'h2',
- 'H3': 'h3',
- 'H4': 'h4',
- 'H5': 'h5',
- 'H6': 'h6',
- 'BLOCKQUOTE': 'blockquote',
- };
-
- return styleMap[tagName.toUpperCase()] || 'normal';
-}
-
-/**
- * Get HTML tag from Portable Text block style
- */
-function getHtmlTag(style: string): string {
- const tagMap: Record = {
- 'normal': 'p',
- 'h1': 'h1',
- 'h2': 'h2',
- 'h3': 'h3',
- 'h4': 'h4',
- 'h5': 'h5',
- 'h6': 'h6',
- 'blockquote': 'blockquote',
- };
-
- return tagMap[style] || 'p';
-}
-
-/**
- * Escape HTML entities
- */
-function escapeHtml(text: string): string {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-/**
- * Simple HTML parsing for converting existing content
- * This is a basic implementation - could be enhanced with more sophisticated parsing
- */
-export function parseHtmlToBlocks(html: string): CustomPortableTextBlock[] {
- if (!html || html.trim() === '') {
- return [createTextBlock()];
- }
-
- // Sanitize first
- const sanitizedHtml = sanitizeHtmlSync(html);
-
- // Split by block-level elements and convert
- const blocks: CustomPortableTextBlock[] = [];
-
- // Simple regex-based parsing for common elements
- const blockElements = sanitizedHtml.split(/(<\/?(?:p|div|h[1-6]|blockquote|img)[^>]*>)/i)
- .filter(part => part.trim().length > 0);
-
- let currentText = '';
- let currentStyle = 'normal';
-
- for (const part of blockElements) {
- if (part.match(/^<(h[1-6]|p|div|blockquote)/i)) {
- // Start of block element
- const match = part.match(/^<(h[1-6]|p|div|blockquote)/i);
- if (match) {
- currentStyle = getBlockStyle(match[1]);
- }
- } else if (part.match(/^
</em>
150
51200
- true
- authorRating
- averageStoryRating
- storyCount
- 1
- count