revert richtext replacement
This commit is contained in:
2025-09-21 10:13:48 +02:00
parent b1dbd85346
commit a5628019f8
28 changed files with 10558 additions and 3337 deletions

View File

@@ -1,610 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { PortableText } from '@portabletext/react';
import type { PortableTextBlock } from '@portabletext/types';
import Button from '../ui/Button';
import { Textarea } from '../ui/Input';
import { sanitizeHtmlSync } from '../../lib/sanitization';
import { storyApi } from '../../lib/api';
import {
htmlToPortableText,
portableTextToHtml,
parseHtmlToBlocks
} from '../../lib/portabletext/conversion';
import {
createTextBlock,
createImageBlock,
emptyPortableTextContent,
portableTextSchema
} from '../../lib/portabletext/schema';
import type { CustomPortableTextBlock } from '../../lib/portabletext/schema';
interface PortableTextEditorProps {
value: string; // HTML value for compatibility
onChange: (value: string) => void; // Returns HTML for compatibility
placeholder?: string;
error?: string;
storyId?: string;
enableImageProcessing?: boolean;
}
export default function PortableTextEditor({
value,
onChange,
placeholder = 'Write your story here...',
error,
storyId,
enableImageProcessing = false
}: PortableTextEditorProps) {
console.log('🎯 PortableTextEditor loaded!', { value: value?.length, enableImageProcessing });
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
const [portableTextValue, setPortableTextValue] = useState<CustomPortableTextBlock[]>(emptyPortableTextContent);
const [htmlValue, setHtmlValue] = useState(value);
const [isMaximized, setIsMaximized] = useState(false);
const [containerHeight, setContainerHeight] = useState(300);
const containerRef = useRef<HTMLDivElement>(null);
const editableRef = useRef<HTMLDivElement>(null);
// 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);
// Initialize Portable Text content from HTML value
useEffect(() => {
if (value && value !== htmlValue) {
const blocks = parseHtmlToBlocks(value);
setPortableTextValue(blocks);
setHtmlValue(value);
}
}, [value]);
// Convert Portable Text to HTML when content changes
const updateHtmlFromPortableText = useCallback((blocks: CustomPortableTextBlock[]) => {
const html = portableTextToHtml(blocks);
setHtmlValue(html);
onChange(html);
}, [onChange]);
// Image processing functionality (maintained from original)
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];
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;
const newUrls = imageUrls.filter(url => !processedImages.has(url));
if (newUrls.length === 0) return;
setImageProcessingQueue(prev => [...prev, ...newUrls]);
try {
const result = await storyApi.processContentImages(storyId, content);
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
if (result.processedContent !== content) {
const newBlocks = parseHtmlToBlocks(result.processedContent);
setPortableTextValue(newBlocks);
onChange(result.processedContent);
setHtmlValue(result.processedContent);
}
if (result.hasWarnings && result.warnings) {
setImageWarnings(prev => [...prev, ...result.warnings!]);
}
} catch (error) {
console.error('Failed to process content images:', error);
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
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;
if (imageProcessingTimeoutRef.current) {
clearTimeout(imageProcessingTimeoutRef.current);
}
imageProcessingTimeoutRef.current = setTimeout(() => {
processContentImagesDebounced(content);
}, 2000);
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
// Toolbar functionality
const insertTextWithFormat = (format: string) => {
const newBlock = createTextBlock('New ' + format, format === 'normal' ? 'normal' : format);
const newBlocks = [...portableTextValue, newBlock];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
};
const formatText = useCallback((format: string) => {
if (viewMode === 'visual') {
// In visual mode, add a new formatted block
insertTextWithFormat(format);
} else {
// HTML mode - maintain original functionality
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 = `<${format}>${selectedText}</${format}>`;
const newValue = beforeText + formattedText + afterText;
setHtmlValue(newValue);
onChange(newValue);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start, start + formattedText.length);
}, 0);
} else {
const template = format === 'h1' ? '<h1>Heading 1</h1>' :
format === 'h2' ? '<h2>Heading 2</h2>' :
format === 'h3' ? '<h3>Heading 3</h3>' :
format === 'h4' ? '<h4>Heading 4</h4>' :
format === 'h5' ? '<h5>Heading 5</h5>' :
format === 'h6' ? '<h6>Heading 6</h6>' :
`<${format}>Formatted text</${format}>`;
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
setHtmlValue(newValue);
onChange(newValue);
setTimeout(() => {
const tagLength = `<${format}>`.length;
const newPosition = start + tagLength;
textarea.focus();
textarea.setSelectionRange(newPosition, newPosition + (template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
}, 0);
}
}
}, [viewMode, htmlValue, onChange, portableTextValue, updateHtmlFromPortableText]);
// Handle HTML mode changes
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const html = e.target.value;
setHtmlValue(html);
onChange(html);
// Update Portable Text representation
const blocks = parseHtmlToBlocks(html);
setPortableTextValue(blocks);
triggerImageProcessing(html);
};
// Handle visual mode content changes
const handleVisualContentChange = () => {
if (editableRef.current) {
const html = editableRef.current.innerHTML;
const blocks = parseHtmlToBlocks(html);
setPortableTextValue(blocks);
updateHtmlFromPortableText(blocks);
triggerImageProcessing(html);
}
};
// Paste handling
const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
if (viewMode !== 'visual') return;
e.preventDefault();
try {
const clipboardData = e.clipboardData;
let htmlContent = '';
let plainText = '';
try {
htmlContent = clipboardData.getData('text/html');
plainText = clipboardData.getData('text/plain');
} catch (e) {
console.log('Direct getData failed:', e);
}
if (htmlContent && htmlContent.trim().length > 0) {
let processedHtml = htmlContent;
if (enableImageProcessing && storyId) {
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
if (hasImages) {
try {
const result = await storyApi.processContentImages(storyId, htmlContent);
processedHtml = result.processedContent;
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);
}
}
}
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
const blocks = parseHtmlToBlocks(sanitizedHtml);
// Insert at current position
const newBlocks = [...portableTextValue, ...blocks];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
} else if (plainText && plainText.trim().length > 0) {
const textBlocks = plainText
.split('\n\n')
.filter(p => p.trim())
.map(p => createTextBlock(p.trim()));
const newBlocks = [...portableTextValue, ...textBlocks];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
}
} catch (error) {
console.error('Error handling paste:', error);
}
};
// Maximize/minimize functionality
const toggleMaximize = () => {
if (!isMaximized) {
if (containerRef.current) {
setContainerHeight(containerRef.current.scrollHeight || containerHeight);
}
}
setIsMaximized(!isMaximized);
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isMaximized) {
setIsMaximized(false);
return;
}
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;
}
}
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) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isMaximized, formatText]);
// Cleanup
useEffect(() => {
return () => {
if (imageProcessingTimeoutRef.current) {
clearTimeout(imageProcessingTimeoutRef.current);
}
};
}, []);
// Custom components for Portable Text rendering
const portableTextComponents = {
types: {
image: ({ value }: { value: any }) => (
<div className="image-block my-4">
<img
src={value.src}
alt={value.alt || ''}
className="max-w-full h-auto"
loading="lazy"
/>
{value.caption && (
<p className="text-sm text-gray-600 mt-2 italic">{value.caption}</p>
)}
</div>
),
},
block: {
normal: ({ children }: any) => <p className="mb-2">{children}</p>,
h1: ({ children }: any) => <h1 className="text-3xl font-bold mb-4">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-2xl font-bold mb-3">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xl font-bold mb-3">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-lg font-bold mb-2">{children}</h4>,
h5: ({ children }: any) => <h5 className="text-base font-bold mb-2">{children}</h5>,
h6: ({ children }: any) => <h6 className="text-sm font-bold mb-2">{children}</h6>,
blockquote: ({ children }: any) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>
),
},
marks: {
strong: ({ children }: any) => <strong>{children}</strong>,
em: ({ children }: any) => <em>{children}</em>,
underline: ({ children }: any) => <u>{children}</u>,
strike: ({ children }: any) => <s>{children}</s>,
code: ({ children }: any) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>
),
},
};
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">
<div className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Portable Text Editor
</div>
<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 */}
{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"
>
{/* Editor content */}
<div className="flex-1 overflow-hidden">
{viewMode === 'visual' ? (
<div className="relative h-full">
<div
ref={editableRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 resize-none"
suppressContentEditableWarning={true}
>
<PortableText
value={portableTextValue}
components={portableTextComponents}
/>
</div>
{(!portableTextValue || portableTextValue.length === 0 ||
(portableTextValue.length === 1 && !portableTextValue[0])) && (
<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>
</div>
{/* Preview for HTML mode */}
{viewMode === 'html' && htmlValue && !isMaximized && (
<div className="space-y-2">
<h4 className="text-sm font-medium theme-header">Preview:</h4>
<div className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto">
<PortableText
value={portableTextValue}
components={portableTextComponents}
/>
</div>
</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> Structured content editor with rich formatting.
Paste content from websites and it will be converted to structured format.
</p>
<p>
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
Content is automatically sanitized for security.
</p>
<p>
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
</p>
</div>
</div>
);
}