phase 1 and 2 of embedded images

This commit is contained in:
Stefan Hardegger
2025-09-16 14:58:50 +02:00
parent c92308c24a
commit c7b516be31
14 changed files with 686 additions and 54 deletions

View File

@@ -4,19 +4,24 @@ 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,
export default function RichTextEditor({
value,
onChange,
placeholder = 'Write your story here...',
error
error,
storyId,
enableImageProcessing = false
}: RichTextEditorProps) {
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
const [htmlValue, setHtmlValue] = useState(value);
@@ -28,6 +33,12 @@ export default function RichTextEditor({
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();
@@ -63,6 +74,82 @@ export default function RichTextEditor({
}
};
// 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) {
@@ -120,9 +207,13 @@ export default function RichTextEditor({
// Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
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
@@ -244,6 +335,15 @@ export default function RichTextEditor({
};
}, [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;
@@ -284,6 +384,8 @@ export default function RichTextEditor({
if (newHtml !== value) {
onChange(newHtml);
setHtmlValue(newHtml);
// Trigger image processing if enabled
triggerImageProcessing(newHtml);
}
// Reset typing state after a short delay
@@ -357,13 +459,38 @@ export default function RichTextEditor({
if (htmlContent && htmlContent.trim().length > 0) {
console.log('Processing HTML content...');
console.log('Raw HTML:', htmlContent.substring(0, 500));
const sanitizedHtml = sanitizeHtmlSync(htmlContent);
// 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 / htmlContent.length;
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');
@@ -404,9 +531,12 @@ export default function RichTextEditor({
// Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
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;
@@ -493,6 +623,9 @@ export default function RichTextEditor({
const html = e.target.value;
setHtmlValue(html);
onChange(html);
// Trigger image processing if enabled
triggerImageProcessing(html);
};
const getPlainText = (html: string): string => {
@@ -532,6 +665,24 @@ export default function RichTextEditor({
</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"
@@ -673,6 +824,24 @@ export default function RichTextEditor({
</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"