Files
storycove/frontend/src/components/stories/PortableTextEditor.tsx
Stefan Hardegger 62f017c4ca solr fix
2025-09-23 13:58:49 +02:00

754 lines
24 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.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
EditorProvider,
PortableTextEditable,
useEditor,
type PortableTextBlock,
type RenderDecoratorFunction,
type RenderStyleFunction,
type RenderBlockFunction,
type RenderListItemFunction,
type RenderAnnotationFunction
} from '@portabletext/editor';
import { EventListenerPlugin } from '@portabletext/editor/plugins';
import { PortableText } from '@portabletext/react';
import Button from '../ui/Button';
import { sanitizeHtmlSync } from '../../lib/sanitization';
import { editorSchema } from '../../lib/portabletext/editorSchema';
import { debug } from '../../lib/debug';
interface PortableTextEditorProps {
value: string; // HTML value for compatibility - will be converted
onChange: (value: string) => void; // Returns HTML for compatibility
placeholder?: string;
error?: string;
storyId?: string;
enableImageProcessing?: boolean;
}
// Conversion utilities
function htmlToPortableTextBlocks(html: string): PortableTextBlock[] {
if (!html || html.trim() === '') {
return [{ _type: 'block', _key: generateKey(), style: 'normal', markDefs: [], children: [{ _type: 'span', _key: generateKey(), text: '', marks: [] }] }];
}
// Basic HTML to Portable Text conversion
// This is a simplified implementation - you could enhance this
const sanitizedHtml = sanitizeHtmlSync(html);
const parser = new DOMParser();
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
const blocks: PortableTextBlock[] = [];
const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div');
if (paragraphs.length === 0) {
// Fallback: treat body text as single block, preserving newlines
const bodyText = doc.body.textContent || '';
return [{
_type: 'block',
_key: generateKey(),
style: 'normal',
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text: bodyText, // Keep newlines in the text
marks: []
}]
}];
}
// Check if we have only one paragraph that might contain newlines
if (paragraphs.length === 1) {
const singleParagraph = paragraphs[0];
const textContent = singleParagraph.textContent || '';
// If this single paragraph contains newlines, preserve them in a single block
if (textContent.includes('\n')) {
const style = getStyleFromElement(singleParagraph);
return [{
_type: 'block',
_key: generateKey(),
style,
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text: textContent, // Keep newlines in the text
marks: []
}]
}];
}
}
// Process all elements in document order to maintain sequence
const allElements = Array.from(doc.body.querySelectorAll('*'));
const processedElements = new Set<Element>();
for (const element of allElements) {
// Skip if already processed
if (processedElements.has(element)) continue;
// Handle images
if (element.tagName === 'IMG') {
const img = element as HTMLImageElement;
blocks.push({
_type: 'image',
_key: generateKey(),
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
caption: img.getAttribute('title') || '',
width: img.getAttribute('width') ? parseInt(img.getAttribute('width')!) : undefined,
height: img.getAttribute('height') ? parseInt(img.getAttribute('height')!) : undefined,
});
processedElements.add(element);
continue;
}
// Handle code blocks
if ((element.tagName === 'CODE' && element.parentElement?.tagName === 'PRE') ||
(element.tagName === 'PRE' && element.querySelector('code'))) {
const codeEl = element.tagName === 'CODE' ? element : element.querySelector('code');
if (codeEl) {
// Use innerText to preserve newlines and whitespace formatting
// innerText respects CSS white-space property, so <pre> formatting is preserved
let code = (codeEl as HTMLElement).innerText || codeEl.textContent || '';
const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
if (code.trim()) {
blocks.push({
_type: 'codeBlock',
_key: generateKey(),
code,
language,
});
processedElements.add(element);
if (element.tagName === 'PRE') processedElements.add(codeEl);
}
}
continue;
}
// Handle text blocks (paragraphs, headings, etc.)
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
// Skip if this contains already processed elements (but allow inline code)
if (element.querySelector('img')) {
processedElements.add(element);
continue;
}
// Check if this is a standalone pre/code block that should be handled specially
const hasStandaloneCodeBlock = element.querySelector('pre') && element.children.length === 1 && element.children[0].tagName === 'PRE';
if (hasStandaloneCodeBlock) {
processedElements.add(element);
continue; // Let the pre/code block handler take care of this
}
const style = getStyleFromElement(element);
// For text content that may contain inline code, preserve formatting better
let text: string;
if (element.querySelector('code') && !element.querySelector('pre')) {
// Has inline code - use innerText to preserve some formatting
text = (element as HTMLElement).innerText || element.textContent || '';
} else {
// Regular text - use textContent, but also check if this might have come from a pre block
text = (element as HTMLElement).innerText || element.textContent || '';
}
if (text.trim()) {
// Keep newlines within a single block - they'll be converted to <br> tags later
blocks.push({
_type: 'block',
_key: generateKey(),
style,
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text, // Keep newlines in the text
marks: []
}]
});
processedElements.add(element);
}
}
}
return blocks.length > 0 ? blocks : [{
_type: 'block',
_key: generateKey(),
style: 'normal',
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text: '',
marks: []
}]
}];
}
function portableTextToHtml(blocks: PortableTextBlock[]): string {
if (!blocks || blocks.length === 0) return '';
const htmlParts: string[] = [];
blocks.forEach(block => {
if (block._type === 'block' && Array.isArray(block.children)) {
const tag = getHtmlTagFromStyle((block.style as string) || 'normal');
const children = block.children as PortableTextChild[];
const text = children
.map(child => child._type === 'span' ? child.text || '' : '')
.join('') || '';
// Convert any remaining newlines in text to <br> tags for proper display
const textWithBreaks = text.replace(/\n/g, '<br>');
// Always include blocks, even empty ones (they represent line breaks/paragraph spacing)
htmlParts.push(`<${tag}>${textWithBreaks}</${tag}>`);
} else if (block._type === 'image' && isImageBlock(block)) {
// Convert image blocks back to HTML
const attrs: string[] = [];
if (block.src) attrs.push(`src="${block.src}"`);
if (block.alt) attrs.push(`alt="${block.alt}"`);
if (block.caption) attrs.push(`title="${block.caption}"`);
if (block.width) attrs.push(`width="${block.width}"`);
if (block.height) attrs.push(`height="${block.height}"`);
htmlParts.push(`<img ${attrs.join(' ')} />`);
} else if (block._type === 'codeBlock' && isCodeBlock(block)) {
// Convert code blocks back to HTML
const langClass = block.language ? ` class="language-${block.language}"` : '';
// Escape HTML entities in code content to prevent XSS and preserve formatting
const escapedCode = (block.code || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
htmlParts.push(`<pre><code${langClass}>${escapedCode}</code></pre>`);
}
});
const html = htmlParts.join('\n');
return sanitizeHtmlSync(html);
}
function getStyleFromElement(element: Element): string {
const tagName = element.tagName.toLowerCase();
const styleMap: Record<string, string> = {
'p': 'normal',
'div': 'normal',
'h1': 'h1',
'h2': 'h2',
'h3': 'h3',
'h4': 'h4',
'h5': 'h5',
'h6': 'h6',
'blockquote': 'blockquote',
};
return styleMap[tagName] || 'normal';
}
function getHtmlTagFromStyle(style: string): string {
const tagMap: Record<string, string> = {
'normal': 'p',
'h1': 'h1',
'h2': 'h2',
'h3': 'h3',
'h4': 'h4',
'h5': 'h5',
'h6': 'h6',
'blockquote': 'blockquote',
};
return tagMap[style] || 'p';
}
interface PortableTextChild {
_type: string;
_key: string;
text?: string;
marks?: string[];
}
// Type guards for custom block types
function isImageBlock(value: any): value is {
_type: 'image';
src?: string;
alt?: string;
caption?: string;
width?: number;
height?: number;
} {
return value && typeof value === 'object' && value._type === 'image';
}
function isCodeBlock(value: any): value is {
_type: 'codeBlock';
code?: string;
language?: string;
} {
return value && typeof value === 'object' && value._type === 'codeBlock';
}
function generateKey(): string {
return Math.random().toString(36).substring(2, 11);
}
// Toolbar component
function EditorToolbar({
isScrollable,
onToggleScrollable
}: {
isScrollable: boolean;
onToggleScrollable: () => void;
}) {
const editor = useEditor();
const toggleDecorator = (decorator: string) => {
editor.send({ type: 'decorator.toggle', decorator });
};
const setStyle = (style: string) => {
editor.send({ type: 'style.toggle', style });
};
return (
<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-blue-100 text-blue-800 px-2 py-1 rounded">
Portable Text Editor
</div>
{/* Style buttons */}
<div className="flex items-center gap-1 border-r pr-2 mr-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('normal')}
title="Normal paragraph"
>
P
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h1')}
title="Heading 1"
className="text-lg font-bold"
>
H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h2')}
title="Heading 2"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h3')}
title="Heading 3"
className="text-sm font-bold"
>
H3
</Button>
</div>
{/* Decorator buttons */}
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('strong')}
title="Bold (Ctrl+B)"
className="font-bold"
>
B
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('em')}
title="Italic (Ctrl+I)"
className="italic"
>
I
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('underline')}
title="Underline"
className="underline"
>
U
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('strike')}
title="Strike-through"
className="line-through"
>
S
</Button>
</div>
</div>
{/* Scrollable toggle */}
<div className="flex items-center gap-2">
<span className="text-xs theme-text">Scrollable:</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onToggleScrollable}
className={isScrollable ? 'theme-accent-bg text-white' : ''}
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
>
{isScrollable ? '📜' : '📏'}
</Button>
</div>
</div>
);
}
// Simple component that uses Portable Text editor directly
function EditorContent({
value,
onChange,
placeholder,
error
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
}) {
const [portableTextValue, setPortableTextValue] = useState<PortableTextBlock[]>(() =>
htmlToPortableTextBlocks(value)
);
const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable
// Sync HTML value with prop changes (but not for internal changes)
useEffect(() => {
// Skip re-initialization if this change came from the editor itself
if (isInternalChange.current) {
debug.log('🔄 Skipping re-initialization for internal change');
return;
}
debug.log('🔄 Editor value changed externally:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
setPortableTextValue(htmlToPortableTextBlocks(value));
}, [value]);
// Debug: log when portableTextValue changes
useEffect(() => {
debug.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue });
}, [portableTextValue]);
// Track if changes are coming from internal editor changes
const isInternalChange = useRef(false);
// Handle content changes using the EventListenerPlugin
const handleEditorChange = useCallback((event: any) => {
if (event.type === 'mutation') {
debug.log('📝 Editor content changed via EventListener:', { valueLength: event.value?.length });
const html = portableTextToHtml(event.value);
debug.log('📝 Converted to HTML:', { htmlLength: html.length, htmlPreview: html.substring(0, 200) });
// Mark this as an internal change to prevent re-initialization
isInternalChange.current = true;
onChange(html);
// Reset flag after a short delay to allow external changes
setTimeout(() => {
isInternalChange.current = false;
}, 100);
}
}, [onChange]);
// Add a ref to the editor container for direct paste handling
const editorContainerRef = useRef<HTMLDivElement>(null);
// Global paste event listener to catch ALL paste events
useEffect(() => {
const handleGlobalPaste = (event: ClipboardEvent) => {
debug.log('🌍 Global paste event captured');
// Check if the paste is happening within our editor
const target = event.target as Element;
const isInEditor = editorContainerRef.current?.contains(target);
debug.log('📋 Paste details:', {
isInEditor,
targetTag: target?.tagName,
targetClasses: target?.className,
hasClipboardData: !!event.clipboardData
});
if (isInEditor && event.clipboardData) {
const htmlData = event.clipboardData.getData('text/html');
const textData = event.clipboardData.getData('text/plain');
debug.log('📋 Clipboard contents:', {
htmlLength: htmlData.length,
textLength: textData.length,
hasImages: htmlData.includes('<img'),
htmlPreview: htmlData.substring(0, 300)
});
if (htmlData && htmlData.includes('<img')) {
debug.log('📋 Images detected in paste! Attempting to process...');
// Prevent default paste to handle it completely ourselves
event.preventDefault();
event.stopPropagation();
// Convert the pasted HTML to our blocks maintaining order
const pastedBlocks = htmlToPortableTextBlocks(htmlData);
debug.log('📋 Converted blocks:', pastedBlocks.map(block => ({
type: block._type,
key: block._key,
...(block._type === 'image' ? { src: (block as any).src, alt: (block as any).alt } : {}),
...(block._type === 'block' ? { style: (block as any).style, text: (block as any).children?.[0]?.text?.substring(0, 50) } : {})
})));
if (pastedBlocks.length > 0) {
// Insert the blocks at the end of current content (maintaining order within the paste)
setTimeout(() => {
setPortableTextValue(prev => {
const updatedBlocks = [...prev, ...pastedBlocks];
const html = portableTextToHtml(updatedBlocks);
onChange(html);
debug.log('📋 Added structured blocks maintaining order:', { pastedCount: pastedBlocks.length, totalBlocks: updatedBlocks.length });
return updatedBlocks;
});
}, 10);
}
}
}
};
// Add global event listener with capture phase to catch events early
document.addEventListener('paste', handleGlobalPaste, true);
return () => {
document.removeEventListener('paste', handleGlobalPaste, true);
};
}, [onChange]);
// Handle paste events directly on the editor container (backup approach)
const handleContainerPaste = useCallback((_event: React.ClipboardEvent) => {
debug.log('📦 Container paste handler triggered');
// This might not be reached if global handler prevents default
}, []);
// Render functions for the editor
const renderStyle: RenderStyleFunction = useCallback((props) => {
const { schemaType, children } = props;
switch (schemaType.value) {
case 'h1':
return <h1 className="text-3xl font-bold mb-4">{children}</h1>;
case 'h2':
return <h2 className="text-2xl font-bold mb-3">{children}</h2>;
case 'h3':
return <h3 className="text-xl font-bold mb-3">{children}</h3>;
case 'h4':
return <h4 className="text-lg font-bold mb-2">{children}</h4>;
case 'h5':
return <h5 className="text-base font-bold mb-2">{children}</h5>;
case 'h6':
return <h6 className="text-sm font-bold mb-2">{children}</h6>;
case 'blockquote':
return <blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
default:
return <p className="mb-2">{children}</p>;
}
}, []);
const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
const { schemaType, children } = props;
switch (schemaType.value) {
case 'strong':
return <strong>{children}</strong>;
case 'em':
return <em>{children}</em>;
case 'underline':
return <u>{children}</u>;
case 'strike':
return <s>{children}</s>;
case 'code':
return <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
default:
return <>{children}</>;
}
}, []);
const renderBlock: RenderBlockFunction = useCallback((props) => {
const { schemaType, value, children } = props;
debug.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
// Handle image blocks
if (schemaType.name === 'image' && isImageBlock(value)) {
debug.log('🖼️ Rendering image block:', value);
return (
<div className="my-4 p-3 border border-dashed border-gray-300 rounded-lg bg-gray-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🖼</span>
<span className="font-medium text-gray-700">Image Block</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<p><strong>Source:</strong> {value.src || 'No source'}</p>
{value.alt && <p><strong>Alt text:</strong> {value.alt}</p>}
{value.caption && <p><strong>Caption:</strong> {value.caption}</p>}
{(value.width || value.height) && (
<p><strong>Dimensions:</strong> {value.width || '?'} × {value.height || '?'}</p>
)}
</div>
</div>
);
}
// Handle code blocks
if (schemaType.name === 'codeBlock' && isCodeBlock(value)) {
return (
<div className="my-4 p-3 border border-dashed border-blue-300 rounded-lg bg-blue-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">💻</span>
<span className="font-medium text-blue-700">Code Block</span>
{value.language && (
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
{value.language}
</span>
)}
</div>
<pre className="text-sm text-gray-800 bg-white p-2 rounded border overflow-x-auto">
<code>{value.code || '// No code'}</code>
</pre>
</div>
);
}
// Default block rendering
return <div>{children}</div>;
}, []);
const renderListItem: RenderListItemFunction = useCallback((props) => {
return <li>{props.children}</li>;
}, []);
const renderAnnotation: RenderAnnotationFunction = useCallback((props) => {
const { schemaType, children, value } = props;
if (schemaType.name === 'link' && value && typeof value === 'object') {
const linkValue = value as { href?: string; target?: string; title?: string };
return (
<a
href={linkValue.href}
target={linkValue.target || '_self'}
title={linkValue.title}
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
);
}
return <>{children}</>;
}, []);
return (
<div className="space-y-2">
<EditorProvider
key={`editor-${portableTextValue.length}-${Date.now()}`}
initialConfig={{
schemaDefinition: editorSchema,
initialValue: portableTextValue,
}}
>
<EventListenerPlugin on={handleEditorChange} />
<EditorToolbar
isScrollable={isScrollable}
onToggleScrollable={() => setIsScrollable(!isScrollable)}
/>
<div
ref={editorContainerRef}
className="border theme-border rounded-b-lg overflow-hidden"
onPaste={handleContainerPaste}
>
<PortableTextEditable
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
isScrollable
? 'h-[400px] overflow-y-auto'
: 'min-h-[300px]'
}`}
placeholder={placeholder}
renderStyle={renderStyle}
renderDecorator={renderDecorator}
renderBlock={renderBlock}
renderListItem={renderListItem}
renderAnnotation={renderAnnotation}
/>
</div>
</EditorProvider>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="text-xs theme-text">
<p>
<strong>Portable Text Editor:</strong> Rich text editor with structured content.
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
📋 Paste detection active.
</p>
</div>
</div>
);
}
export default function PortableTextEditorNew({
value,
onChange,
placeholder = 'Write your story here...',
error,
storyId,
enableImageProcessing = false
}: PortableTextEditorProps) {
debug.log('🎯 Portable Text Editor loaded!', {
valueLength: value?.length,
enableImageProcessing,
hasStoryId: !!storyId
});
return (
<EditorContent
value={value}
onChange={onChange}
placeholder={placeholder}
error={error}
/>
);
}