Files
storycove/frontend/src/components/stories/RichTextEditor.tsx
2025-09-16 14:58:50 +02:00

1027 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<HTMLDivElement>(null);
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
const visualDivRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isUserTyping, setIsUserTyping] = useState(false);
// Image processing state
const [imageProcessingQueue, setImageProcessingQueue] = useState<string[]>([]);
const [processedImages, setProcessedImages] = useState<Set<string>>(new Set());
const [imageWarnings, setImageWarnings] = useState<string[]>([]);
const imageProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(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 = /<img[^>]+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}</${tag}>`;
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' ? '<h1>Heading 1</h1>' :
tag === 'h2' ? '<h2>Heading 2</h2>' :
tag === 'h3' ? '<h3>Heading 3</h3>' :
tag === 'h4' ? '<h4>Heading 4</h4>' :
tag === 'h5' ? '<h5>Heading 5</h5>' :
tag === 'h6' ? '<h6>Heading 6</h6>' :
`<${tag}>Formatted text</${tag}>`;
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<HTMLTextAreaElement | HTMLDivElement>) => {
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<string>((resolve) => {
item.getAsString(resolve);
});
} else if (item.type === 'text/plain' && !plainText) {
plainText = await new Promise<string>((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 = /<img[^>]+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>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).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 => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.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 => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
setIsUserTyping(true);
onChange(value + textAsHtml);
setHtmlValue(value + textAsHtml);
setTimeout(() => setIsUserTyping(false), 100);
}
}
};
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
};
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setViewMode('visual')}
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
>
Visual
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setViewMode('html')}
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
>
HTML
</Button>
</div>
<div className="flex items-center gap-1">
{/* Image processing status indicator */}
{enableImageProcessing && (
<>
{imageProcessingQueue.length > 0 && (
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
</div>
)}
{imageWarnings.length > 0 && (
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
<span></span>
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
</div>
)}
</>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={toggleMaximize}
title={isMaximized ? "Minimize editor" : "Maximize editor"}
className="font-mono"
>
{isMaximized ? "⊡" : "⊞"}
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('strong')}
title="Bold (Ctrl+B)"
className="font-bold"
>
B
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('em')}
title="Italic (Ctrl+I)"
className="italic"
>
I
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h1')}
title="Heading 1 (Ctrl+Shift+1)"
className="text-lg font-bold"
>
H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h2')}
title="Heading 2 (Ctrl+Shift+2)"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h3')}
title="Heading 3 (Ctrl+Shift+3)"
className="text-sm font-bold"
>
H3
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h4')}
title="Heading 4 (Ctrl+Shift+4)"
className="text-xs font-bold"
>
H4
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h5')}
title="Heading 5 (Ctrl+Shift+5)"
className="text-xs font-bold"
>
H5
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h6')}
title="Heading 6 (Ctrl+Shift+6)"
className="text-xs font-bold"
>
H6
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('p')}
title="Paragraph"
>
P
</Button>
</div>
</div>
{/* Editor */}
<div
className={`relative border theme-border rounded-b-lg ${
isMaximized ? 'fixed inset-4 z-50 bg-white dark:bg-gray-900 shadow-2xl' : ''
}`}
style={isMaximized ? {} : { height: containerHeight }}
>
<div
ref={containerRef}
className="h-full flex flex-col overflow-hidden"
>
{/* Maximized toolbar (shown when maximized) */}
{isMaximized && (
<div className="flex items-center justify-between p-2 theme-card border-b theme-border">
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setViewMode('visual')}
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
>
Visual
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setViewMode('html')}
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
>
HTML
</Button>
</div>
<div className="flex items-center gap-1">
{/* Image processing status indicator */}
{enableImageProcessing && (
<>
{imageProcessingQueue.length > 0 && (
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
</div>
)}
{imageWarnings.length > 0 && (
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
<span></span>
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
</div>
)}
</>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={toggleMaximize}
title="Minimize editor"
className="font-mono"
>
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('strong')}
title="Bold (Ctrl+B)"
className="font-bold"
>
B
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('em')}
title="Italic (Ctrl+I)"
className="italic"
>
I
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h1')}
title="Heading 1 (Ctrl+Shift+1)"
className="text-lg font-bold"
>
H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h2')}
title="Heading 2 (Ctrl+Shift+2)"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h3')}
title="Heading 3 (Ctrl+Shift+3)"
className="text-sm font-bold"
>
H3
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h4')}
title="Heading 4 (Ctrl+Shift+4)"
className="text-xs font-bold"
>
H4
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h5')}
title="Heading 5 (Ctrl+Shift+5)"
className="text-xs font-bold"
>
H5
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h6')}
title="Heading 6 (Ctrl+Shift+6)"
className="text-xs font-bold"
>
H6
</Button>
<div className="w-px h-4 bg-gray-300 mx-1" />
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('p')}
title="Paragraph"
>
P
</Button>
</div>
</div>
)}
{/* Editor content */}
<div className="flex-1 overflow-hidden">
{viewMode === 'visual' ? (
<div className="relative h-full">
<div
ref={visualDivRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="editor-content p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
suppressContentEditableWarning={true}
/>
{!value && (
<div className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none">
{placeholder}
</div>
)}
</div>
) : (
<Textarea
value={htmlValue}
onChange={handleHtmlChange}
placeholder="<p>Write your HTML content here...</p>"
className="border-0 rounded-none focus:ring-0 font-mono text-sm h-full resize-none"
/>
)}
</div>
</div>
{/* Resize handle (only show when not maximized) */}
{!isMaximized && (
<div
onMouseDown={handleMouseDown}
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors flex items-center justify-center"
title="Drag to resize"
>
<div className="w-8 h-0.5 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
</div>
)}
</div>
{/* Preview for HTML mode (only show when not maximized) */}
{viewMode === 'html' && value && !isMaximized && (
<div className="space-y-2">
<h4 className="text-sm font-medium theme-header">Preview:</h4>
<div
ref={previewRef}
className="editor-content p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: value }}
/>
</div>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="text-xs theme-text">
<p>
<strong>Visual mode:</strong> WYSIWYG editor - see your formatting as you type.
Paste formatted content from websites and it will preserve styling. Use toolbar buttons for formatting.
</p>
<p>
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
</p>
<p>
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
</p>
<p>
<strong>Tips:</strong> Use the button to maximize the editor for larger stories.
Drag the resize handle at the bottom to adjust height. Press Escape to exit maximized mode.
</p>
</div>
</div>
);
}