1027 lines
37 KiB
TypeScript
1027 lines
37 KiB
TypeScript
'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>
|
||
);
|
||
} |