'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(); 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
 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 
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
tags for proper display const textWithBreaks = text.replace(/\n/g, '
'); // Always include blocks, even empty ones (they represent line breaks/paragraph spacing) htmlParts.push(`<${tag}>${textWithBreaks}`); } 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(``); } 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); htmlParts.push(`
${escapedCode}
`); } }); const html = htmlParts.join('\n'); return sanitizeHtmlSync(html); } function getStyleFromElement(element: Element): string { const tagName = element.tagName.toLowerCase(); const styleMap: Record = { '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 = { '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 (
✨ Portable Text Editor
{/* Style buttons */}
{/* Decorator buttons */}
{/* Scrollable toggle */}
Scrollable:
); } // 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(() => 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(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(' ({ 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

{children}

; case 'h2': return

{children}

; case 'h3': return

{children}

; case 'h4': return

{children}

; case 'h5': return
{children}
; case 'h6': return
{children}
; case 'blockquote': return
{children}
; default: return

{children}

; } }, []); const renderDecorator: RenderDecoratorFunction = useCallback((props) => { const { schemaType, children } = props; switch (schemaType.value) { case 'strong': return {children}; case 'em': return {children}; case 'underline': return {children}; case 'strike': return {children}; case 'code': return {children}; 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 (
🖼️ Image Block

Source: {value.src || 'No source'}

{value.alt &&

Alt text: {value.alt}

} {value.caption &&

Caption: {value.caption}

} {(value.width || value.height) && (

Dimensions: {value.width || '?'} × {value.height || '?'}

)}
); } // Handle code blocks if (schemaType.name === 'codeBlock' && isCodeBlock(value)) { return (
💻 Code Block {value.language && ( {value.language} )}
            {value.code || '// No code'}
          
); } // Default block rendering return
{children}
; }, []); const renderListItem: RenderListItemFunction = useCallback((props) => { return
  • {props.children}
  • ; }, []); 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 ( {children} ); } return <>{children}; }, []); return (
    setIsScrollable(!isScrollable)} />
    {error && (

    {error}

    )}

    Portable Text Editor: Rich text editor with structured content. {isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'} 📋 Paste detection active.

    ); } 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 ( ); }