'use client'; import { useState, useRef, useEffect, useCallback } from 'react'; import { Textarea } from '../ui/Input'; import Button from '../ui/Button'; import { sanitizeHtmlSync } from '../../lib/sanitization'; import { storyApi } from '../../lib/api'; interface RichTextEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; error?: string; storyId?: string; // Optional - for image processing (undefined for new stories) enableImageProcessing?: boolean; // Enable background image processing } export default function RichTextEditor({ value, onChange, placeholder = 'Write your story here...', error, storyId, enableImageProcessing = false }: RichTextEditorProps) { const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual'); const [htmlValue, setHtmlValue] = useState(value); const [isMaximized, setIsMaximized] = useState(false); const [containerHeight, setContainerHeight] = useState(300); // Default height in pixels const previewRef = useRef(null); const visualTextareaRef = useRef(null); const visualDivRef = useRef(null); const containerRef = useRef(null); const [isUserTyping, setIsUserTyping] = useState(false); // Image processing state const [imageProcessingQueue, setImageProcessingQueue] = useState([]); const [processedImages, setProcessedImages] = useState>(new Set()); const [imageWarnings, setImageWarnings] = useState([]); const imageProcessingTimeoutRef = useRef(null); // Utility functions for cursor position preservation const saveCursorPosition = () => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const div = visualDivRef.current; if (!div) return null; return { startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset }; }; const restoreCursorPosition = (position: any) => { if (!position) return; try { const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.setStart(position.startContainer, position.startOffset); range.setEnd(position.endContainer, position.endOffset); selection.removeAllRanges(); selection.addRange(range); } catch (e) { console.warn('Could not restore cursor position:', e); } }; // Image processing functionality const findImageUrlsInHtml = (html: string): string[] => { const imgRegex = /]+src=["']([^"']+)["'][^>]*>/gi; const urls: string[] = []; let match; while ((match = imgRegex.exec(html)) !== null) { const url = match[1]; // Skip local URLs and data URLs if (!url.startsWith('/') && !url.startsWith('data:')) { urls.push(url); } } return urls; }; const processContentImagesDebounced = useCallback(async (content: string) => { if (!enableImageProcessing || !storyId) return; const imageUrls = findImageUrlsInHtml(content); if (imageUrls.length === 0) return; // Find new URLs that haven't been processed yet const newUrls = imageUrls.filter(url => !processedImages.has(url)); if (newUrls.length === 0) return; // Add to processing queue setImageProcessingQueue(prev => [...prev, ...newUrls]); try { // Call the API to process images const result = await storyApi.processContentImages(storyId, content); // Mark URLs as processed setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls])); // Remove from processing queue setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url))); // Update content with processed images if (result.processedContent !== content) { onChange(result.processedContent); setHtmlValue(result.processedContent); } // Handle warnings if (result.hasWarnings && result.warnings) { setImageWarnings(prev => [...prev, ...result.warnings!]); // Show brief warning notification - could be enhanced with a toast system console.warn('Image processing warnings:', result.warnings); } } catch (error) { console.error('Failed to process content images:', error); // Remove failed URLs from queue setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url))); // Show error message - could be enhanced with user notification const errorMessage = error instanceof Error ? error.message : String(error); setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]); } }, [enableImageProcessing, storyId, processedImages, onChange]); const triggerImageProcessing = useCallback((content: string) => { if (!enableImageProcessing || !storyId) return; // Clear existing timeout if (imageProcessingTimeoutRef.current) { clearTimeout(imageProcessingTimeoutRef.current); } // Set new timeout to process after user stops typing imageProcessingTimeoutRef.current = setTimeout(() => { processContentImagesDebounced(content); }, 2000); // Wait 2 seconds after user stops typing }, [enableImageProcessing, storyId, processContentImagesDebounced]); // Maximize/minimize functionality const toggleMaximize = () => { if (!isMaximized) { // Store current height before maximizing if (containerRef.current) { setContainerHeight(containerRef.current.scrollHeight || containerHeight); } } setIsMaximized(!isMaximized); }; const formatText = useCallback((tag: string) => { if (viewMode === 'visual') { const visualDiv = visualDivRef.current; if (!visualDiv) return; const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const selectedText = range.toString(); if (selectedText) { // Wrap selected text in the formatting tag const formattedElement = document.createElement(tag); formattedElement.textContent = selectedText; range.deleteContents(); range.insertNode(formattedElement); // Move cursor to end of inserted content range.selectNodeContents(formattedElement); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } else { // No selection - insert template const template = tag === 'h1' ? 'Heading 1' : tag === 'h2' ? 'Heading 2' : tag === 'h3' ? 'Heading 3' : tag === 'h4' ? 'Heading 4' : tag === 'h5' ? 'Heading 5' : tag === 'h6' ? 'Heading 6' : 'Formatted text'; const formattedElement = document.createElement(tag); formattedElement.textContent = template; range.insertNode(formattedElement); // Select the inserted text for easy editing range.selectNodeContents(formattedElement); selection.removeAllRanges(); selection.addRange(range); } // Update the state setIsUserTyping(true); const newContent = visualDiv.innerHTML; onChange(newContent); setHtmlValue(newContent); setTimeout(() => setIsUserTyping(false), 100); // Trigger image processing if enabled triggerImageProcessing(newContent); } } else { // HTML mode - existing logic with improvements const textarea = document.querySelector('textarea') as HTMLTextAreaElement; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = htmlValue.substring(start, end); if (selectedText) { const beforeText = htmlValue.substring(0, start); const afterText = htmlValue.substring(end); const formattedText = `<${tag}>${selectedText}`; const newValue = beforeText + formattedText + afterText; setHtmlValue(newValue); onChange(newValue); // Restore cursor position setTimeout(() => { textarea.focus(); textarea.setSelectionRange(start, start + formattedText.length); }, 0); } else { // No selection - insert template at cursor const template = tag === 'h1' ? '

Heading 1

' : tag === 'h2' ? '

Heading 2

' : tag === 'h3' ? '

Heading 3

' : tag === 'h4' ? '

Heading 4

' : tag === 'h5' ? '
Heading 5
' : tag === 'h6' ? '
Heading 6
' : `<${tag}>Formatted text`; const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start); setHtmlValue(newValue); onChange(newValue); // Position cursor inside the new tag setTimeout(() => { const tagLength = `<${tag}>`.length; const newPosition = start + tagLength; textarea.focus(); textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length)); }, 0); } } }, [viewMode, htmlValue, onChange]); // Handle manual resize when dragging resize handle const handleMouseDown = (e: React.MouseEvent) => { if (isMaximized) return; // Don't allow resize when maximized e.preventDefault(); const startY = e.clientY; const startHeight = containerHeight; const handleMouseMove = (e: MouseEvent) => { const deltaY = e.clientY - startY; const newHeight = Math.max(200, Math.min(800, startHeight + deltaY)); // Min 200px, Max 800px setContainerHeight(newHeight); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Keyboard shortcuts handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Escape key to exit maximized mode if (e.key === 'Escape' && isMaximized) { setIsMaximized(false); return; } // Heading shortcuts: Ctrl+Shift+1-6 if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { const num = parseInt(e.key); if (num >= 1 && num <= 6) { e.preventDefault(); formatText(`h${num}`); return; } } // Additional common shortcuts if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { switch (e.key.toLowerCase()) { case 'b': e.preventDefault(); formatText('strong'); return; case 'i': e.preventDefault(); formatText('em'); return; } } }; document.addEventListener('keydown', handleKeyDown); if (isMaximized) { // Prevent body from scrolling when maximized document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [isMaximized, formatText]); // Cleanup image processing timeout on unmount useEffect(() => { return () => { if (imageProcessingTimeoutRef.current) { clearTimeout(imageProcessingTimeoutRef.current); } }; }, []); // Set initial content when component mounts useEffect(() => { const div = visualDivRef.current; if (div && div.innerHTML !== value) { div.innerHTML = value || ''; } }, []); // Update div content when value changes externally (not from user typing) useEffect(() => { const div = visualDivRef.current; if (div && !isUserTyping && div.innerHTML !== value) { const cursorPosition = saveCursorPosition(); div.innerHTML = value || ''; if (cursorPosition) { setTimeout(() => restoreCursorPosition(cursorPosition), 0); } } }, [value, isUserTyping]); // Preload sanitization config useEffect(() => { // Clear cache and reload config to get latest sanitization rules import('../../lib/sanitization').then(({ clearSanitizationCache, preloadSanitizationConfig }) => { clearSanitizationCache(); preloadSanitizationConfig().catch(console.error); }); }, []); const handleVisualContentChange = () => { const div = visualDivRef.current; if (div) { const newHtml = div.innerHTML; setIsUserTyping(true); // Only call onChange if content actually changed if (newHtml !== value) { onChange(newHtml); setHtmlValue(newHtml); // Trigger image processing if enabled triggerImageProcessing(newHtml); } // Reset typing state after a short delay setTimeout(() => setIsUserTyping(false), 100); } }; const handlePaste = async (e: React.ClipboardEvent) => { if (viewMode !== 'visual') return; e.preventDefault(); try { // Try multiple approaches to get clipboard data const clipboardData = e.clipboardData; let htmlContent = ''; let plainText = ''; // Method 1: Try direct getData calls first (more reliable) try { htmlContent = clipboardData.getData('text/html'); plainText = clipboardData.getData('text/plain'); console.log('Paste debug - Direct method:'); console.log('HTML length:', htmlContent.length); console.log('HTML preview:', htmlContent.substring(0, 200)); console.log('Plain text length:', plainText.length); } catch (e) { console.log('Direct getData failed:', e); } // Method 2: If direct method didn't work, try items approach if (!htmlContent && clipboardData?.items) { console.log('Trying items approach...'); const items = Array.from(clipboardData.items); console.log('Available clipboard types:', items.map(item => item.type)); for (const item of items) { if (item.type === 'text/html' && !htmlContent) { htmlContent = await new Promise((resolve) => { item.getAsString(resolve); }); } else if (item.type === 'text/plain' && !plainText) { plainText = await new Promise((resolve) => { item.getAsString(resolve); }); } } } console.log('Final clipboard data:'); console.log('HTML content length:', htmlContent.length); console.log('Plain text length:', plainText.length); // Additional debugging for clipboard types and content if (clipboardData?.types) { console.log('Clipboard types available:', clipboardData.types); for (const type of clipboardData.types) { try { const data = clipboardData.getData(type); console.log(`Type "${type}" content length:`, data.length); if (data.length > 0 && data.length < 1000) { console.log(`Type "${type}" content:`, data); } } catch (e) { console.log(`Failed to get data for type "${type}":`, e); } } } // Process HTML content if available if (htmlContent && htmlContent.trim().length > 0) { console.log('Processing HTML content...'); console.log('Raw HTML:', htmlContent.substring(0, 500)); // Check if we have embedded images and image processing is enabled const hasImages = /]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent); let processedHtml = htmlContent; if (hasImages && enableImageProcessing && storyId) { console.log('Found images in pasted content, processing before sanitization...'); try { // Process images synchronously before sanitization const result = await storyApi.processContentImages(storyId, htmlContent); processedHtml = result.processedContent; console.log('Image processing completed, processed content length:', processedHtml.length); // Update image processing state if (result.downloadedImages && result.downloadedImages.length > 0) { setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages])); } if (result.warnings && result.warnings.length > 0) { setImageWarnings(prev => [...prev, ...result.warnings!]); } } catch (error) { console.error('Image processing failed during paste:', error); // Continue with original content if image processing fails } } const sanitizedHtml = sanitizeHtmlSync(processedHtml); console.log('Sanitized HTML length:', sanitizedHtml.length); console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500)); // Check if sanitization removed too much content const ratio = sanitizedHtml.length / processedHtml.length; console.log('Sanitization ratio (kept/original):', ratio.toFixed(3)); if (ratio < 0.1) { console.warn('Sanitization removed >90% of content - this might be too aggressive'); } // Insert HTML directly into contentEditable div or at cursor in textarea const visualDiv = visualDivRef.current; const textarea = visualTextareaRef.current; if (visualDiv && viewMode === 'visual') { // For contentEditable div, insert HTML at current selection const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); // Create a temporary container to parse the HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = sanitizedHtml; // Create a document fragment to insert all nodes at once const fragment = document.createDocumentFragment(); while (tempDiv.firstChild) { fragment.appendChild(tempDiv.firstChild); } // Insert the entire fragment at once to preserve order range.insertNode(fragment); // Move cursor to end of inserted content range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } else { // No selection, append to end visualDiv.innerHTML += sanitizedHtml; } // Update the state setIsUserTyping(true); const newContent = visualDiv.innerHTML; onChange(newContent); setHtmlValue(newContent); setTimeout(() => setIsUserTyping(false), 100); // Note: Image processing already completed during paste, no need to trigger again } else if (textarea) { // Fallback for textarea mode (shouldn't happen in visual mode but good to have) const start = textarea.selectionStart; const end = textarea.selectionEnd; const currentPlainText = getPlainText(value); const beforeCursor = currentPlainText.substring(0, start); const afterCursor = currentPlainText.substring(end); const beforeHtml = beforeCursor ? beforeCursor.split('\n\n').filter(p => p.trim()).map(p => `

${p.replace(/\n/g, '
')}

`).join('\n') : ''; const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `

${p.replace(/\n/g, '
')}

`).join('\n') : ''; const newHtmlValue = beforeHtml + (beforeHtml ? '\n' : '') + sanitizedHtml + (afterHtml ? '\n' : '') + afterHtml; onChange(newHtmlValue); setHtmlValue(newHtmlValue); } } else if (plainText && plainText.trim().length > 0) { console.log('Processing plain text content...'); // For plain text, insert directly into contentEditable div const visualDiv = visualDivRef.current; if (visualDiv) { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); // Split plain text into paragraphs and insert as HTML const paragraphs = plainText.split('\n\n').filter(p => p.trim()); const fragment = document.createDocumentFragment(); paragraphs.forEach((paragraph, index) => { if (index > 0) { // Add some spacing between paragraphs fragment.appendChild(document.createElement('br')); } const p = document.createElement('p'); p.textContent = paragraph.replace(/\n/g, ' '); fragment.appendChild(p); }); range.insertNode(fragment); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } else { // No selection, append to end const textAsHtml = plainText .split('\n\n') .filter(paragraph => paragraph.trim()) .map(paragraph => `

${paragraph.replace(/\n/g, '
')}

`) .join('\n'); visualDiv.innerHTML += textAsHtml; } setIsUserTyping(true); onChange(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML); setTimeout(() => setIsUserTyping(false), 100); } } else { console.log('No usable clipboard content found'); } } catch (error) { console.error('Error handling paste:', error); // Fallback to default paste behavior const plainText = e.clipboardData.getData('text/plain'); if (plainText) { const textAsHtml = plainText .split('\n\n') .filter(paragraph => paragraph.trim()) .map(paragraph => `

${paragraph.replace(/\n/g, '
')}

`) .join('\n'); setIsUserTyping(true); onChange(value + textAsHtml); setHtmlValue(value + textAsHtml); setTimeout(() => setIsUserTyping(false), 100); } } }; const handleHtmlChange = (e: React.ChangeEvent) => { const html = e.target.value; setHtmlValue(html); onChange(html); // Trigger image processing if enabled triggerImageProcessing(html); }; const getPlainText = (html: string): string => { // Simple HTML to plain text conversion return html .replace(/<\/p>/gi, '\n\n') .replace(//gi, '\n') .replace(/<[^>]*>/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); }; return (
{/* Toolbar */}
{/* Image processing status indicator */} {enableImageProcessing && ( <> {imageProcessingQueue.length > 0 && (
Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...
)} {imageWarnings.length > 0 && (
⚠️ {imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}
)} )}
{/* Editor */}
{/* Maximized toolbar (shown when maximized) */} {isMaximized && (
{/* Image processing status indicator */} {enableImageProcessing && ( <> {imageProcessingQueue.length > 0 && (
Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...
)} {imageWarnings.length > 0 && (
⚠️ {imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}
)} )}
)} {/* Editor content */}
{viewMode === 'visual' ? (
{!value && (
{placeholder}
)}
) : (