fix saving stories.
This commit is contained in:
@@ -6,7 +6,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { Input, Textarea } from '../../components/ui/Input';
|
import { Input, Textarea } from '../../components/ui/Input';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import TagInput from '../../components/stories/TagInput';
|
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 ImageUpload from '../../components/ui/ImageUpload';
|
||||||
import AuthorSelector from '../../components/stories/AuthorSelector';
|
import AuthorSelector from '../../components/stories/AuthorSelector';
|
||||||
import SeriesSelector from '../../components/stories/SeriesSelector';
|
import SeriesSelector from '../../components/stories/SeriesSelector';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Input, Textarea } from '../../../../components/ui/Input';
|
|||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import TagInput from '../../../../components/stories/TagInput';
|
import TagInput from '../../../../components/stories/TagInput';
|
||||||
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
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 ImageUpload from '../../../../components/ui/ImageUpload';
|
||||||
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||||
import SeriesSelector from '../../../../components/stories/SeriesSelector';
|
import SeriesSelector from '../../../../components/stories/SeriesSelector';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,672 +0,0 @@
|
|||||||
'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 { 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 as single paragraph
|
|
||||||
return [{
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style: 'normal',
|
|
||||||
markDefs: [],
|
|
||||||
children: [{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text: doc.body.textContent || '',
|
|
||||||
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) {
|
|
||||||
const code = 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
|
|
||||||
if (element.querySelector('img') || (element.querySelector('code') && element.querySelector('pre'))) {
|
|
||||||
processedElements.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}</${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}"` : '';
|
|
||||||
htmlParts.push(`<pre><code${langClass}>${block.code || ''}</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
|
|
||||||
useEffect(() => {
|
|
||||||
debug.log('🔄 Editor value changed:', { 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]);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user