diff --git a/frontend/src/app/add-story/AddStoryContent.tsx b/frontend/src/app/add-story/AddStoryContent.tsx index 63ab267..5667b18 100644 --- a/frontend/src/app/add-story/AddStoryContent.tsx +++ b/frontend/src/app/add-story/AddStoryContent.tsx @@ -6,7 +6,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { Input, Textarea } from '../../components/ui/Input'; import Button from '../../components/ui/Button'; import TagInput from '../../components/stories/TagInput'; -import PortableTextEditor from '../../components/stories/PortableTextEditorNew'; +import PortableTextEditor from '../../components/stories/PortableTextEditor'; import ImageUpload from '../../components/ui/ImageUpload'; import AuthorSelector from '../../components/stories/AuthorSelector'; import SeriesSelector from '../../components/stories/SeriesSelector'; diff --git a/frontend/src/app/stories/[id]/edit/page.tsx b/frontend/src/app/stories/[id]/edit/page.tsx index ff889ad..e9cffa0 100644 --- a/frontend/src/app/stories/[id]/edit/page.tsx +++ b/frontend/src/app/stories/[id]/edit/page.tsx @@ -7,7 +7,7 @@ import { Input, Textarea } from '../../../../components/ui/Input'; import Button from '../../../../components/ui/Button'; import TagInput from '../../../../components/stories/TagInput'; import TagSuggestions from '../../../../components/tags/TagSuggestions'; -import PortableTextEditor from '../../../../components/stories/PortableTextEditorNew'; +import PortableTextEditor from '../../../../components/stories/PortableTextEditor'; import ImageUpload from '../../../../components/ui/ImageUpload'; import AuthorSelector from '../../../../components/stories/AuthorSelector'; import SeriesSelector from '../../../../components/stories/SeriesSelector'; diff --git a/frontend/src/components/stories/PortableTextEditor.tsx b/frontend/src/components/stories/PortableTextEditor.tsx index 1c82fc5..fd39394 100644 --- a/frontend/src/components/stories/PortableTextEditor.tsx +++ b/frontend/src/components/stories/PortableTextEditor.tsx @@ -1,27 +1,26 @@ '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 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'; +import { editorSchema } from '../../lib/portabletext/editorSchema'; +import { debug } from '../../lib/debug'; interface PortableTextEditorProps { - value: string; // HTML value for compatibility + value: string; // HTML value for compatibility - will be converted onChange: (value: string) => void; // Returns HTML for compatibility placeholder?: string; error?: string; @@ -29,416 +28,299 @@ interface PortableTextEditorProps { 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(emptyPortableTextContent); - const [htmlValue, setHtmlValue] = useState(value); - const [isMaximized, setIsMaximized] = useState(false); - const [containerHeight, setContainerHeight] = useState(300); - const containerRef = useRef(null); - const editableRef = useRef(null); +// 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: [] }] }]; + } - // Image processing state - const [imageProcessingQueue, setImageProcessingQueue] = useState([]); - const [processedImages, setProcessedImages] = useState>(new Set()); - const [imageWarnings, setImageWarnings] = useState([]); - const imageProcessingTimeoutRef = useRef(null); + // 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'); - // Initialize Portable Text content from HTML value - useEffect(() => { - if (value && value !== htmlValue) { - const blocks = parseHtmlToBlocks(value); - setPortableTextValue(blocks); - setHtmlValue(value); - } - }, [value]); + const blocks: PortableTextBlock[] = []; + const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div'); - // Convert Portable Text to HTML when content changes - const updateHtmlFromPortableText = useCallback((blocks: CustomPortableTextBlock[]) => { - const html = portableTextToHtml(blocks); - setHtmlValue(html); - onChange(html); - }, [onChange]); + if (paragraphs.length === 0) { + // Fallback: treat as single paragraph + return [{ + _type: 'block', + _key: generateKey(), + style: 'normal', + markDefs: [], + children: [{ + _type: 'span', + _key: generateKey(), + text: doc.body.textContent || '', + marks: [] + }] + }]; + } - // Image processing functionality (maintained from original) - const findImageUrlsInHtml = (html: string): string[] => { - const imgRegex = /]+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; - }; + // Process all elements in document order to maintain sequence + const allElements = Array.from(doc.body.querySelectorAll('*')); + const processedElements = new Set(); - const processContentImagesDebounced = useCallback(async (content: string) => { - if (!enableImageProcessing || !storyId) return; + for (const element of allElements) { + // Skip if already processed + if (processedElements.has(element)) continue; - 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); + // 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; } - imageProcessingTimeoutRef.current = setTimeout(() => { - processContentImagesDebounced(content); - }, 2000); - }, [enableImageProcessing, storyId, processContentImagesDebounced]); + // 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) { + const code = codeEl.textContent || ''; + const language = codeEl.getAttribute('class')?.replace('language-', '') || ''; - // 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}`; - const newValue = beforeText + formattedText + afterText; - - setHtmlValue(newValue); - onChange(newValue); - - setTimeout(() => { - textarea.focus(); - textarea.setSelectionRange(start, start + formattedText.length); - }, 0); - } else { - const template = format === 'h1' ? '

Heading 1

' : - format === 'h2' ? '

Heading 2

' : - format === 'h3' ? '

Heading 3

' : - format === 'h4' ? '

Heading 4

' : - format === 'h5' ? '
Heading 5
' : - format === 'h6' ? '
Heading 6
' : - `<${format}>Formatted text`; - - 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) => { - 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) => { - 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 = /]+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 (code.trim()) { + blocks.push({ + _type: 'codeBlock', + _key: generateKey(), + code, + language, + }); + processedElements.add(element); + if (element.tagName === 'PRE') processedElements.add(codeEl); } } - - 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 = ''; + continue; } - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [isMaximized, formatText]); - - // Cleanup - useEffect(() => { - return () => { - if (imageProcessingTimeoutRef.current) { - clearTimeout(imageProcessingTimeoutRef.current); + // 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 + if (element.querySelector('img') || (element.querySelector('code') && element.querySelector('pre'))) { + processedElements.add(element); + continue; } - }; - }, []); - // Custom components for Portable Text rendering - const portableTextComponents = { - types: { - image: ({ value }: { value: any }) => ( -
- {value.alt - {value.caption && ( -

{value.caption}

- )} -
- ), - }, - block: { - normal: ({ children }: any) =>

{children}

, - h1: ({ children }: any) =>

{children}

, - h2: ({ children }: any) =>

{children}

, - h3: ({ children }: any) =>

{children}

, - h4: ({ children }: any) =>

{children}

, - h5: ({ children }: any) =>
{children}
, - h6: ({ children }: any) =>
{children}
, - blockquote: ({ children }: any) => ( -
{children}
- ), - }, - marks: { - strong: ({ children }: any) => {children}, - em: ({ children }: any) => {children}, - underline: ({ children }: any) => {children}, - strike: ({ children }: any) => {children}, - code: ({ children }: any) => ( - {children} - ), - }, + const style = getStyleFromElement(element); + const text = element.textContent || ''; + + if (text.trim()) { + blocks.push({ + _type: 'block', + _key: generateKey(), + style, + markDefs: [], + children: [{ + _type: 'span', + _key: generateKey(), + 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('') || ''; + + if (text.trim() || block.style !== 'normal') { + htmlParts.push(`<${tag}>${text}`); + } + } 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}"` : ''; + htmlParts.push(`
${block.code || ''}
`); + } + }); + + 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 ( -
- {/* Toolbar */} -
-
-
- ✨ Portable Text Editor -
+
+
+
+ ✨ Portable Text Editor +
+ + {/* Style buttons */} +
+ +
+ {/* Decorator buttons */}
- {/* Image processing status */} - {enableImageProcessing && ( - <> - {imageProcessingQueue.length > 0 && ( -
-
- Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}... -
- )} - {imageWarnings.length > 0 && ( -
- ⚠️ - {imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''} -
- )} - - )} - -
- -
- - - - -
-
- {/* Editor */} -
-
+ Scrollable: +