@@ -1,16 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import AppLayout from './AppLayout';
|
||||
|
||||
interface ImportTab {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
}
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
import ImportLayoutContent from './ImportLayoutContent';
|
||||
|
||||
interface ImportLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -18,112 +11,23 @@ interface ImportLayoutProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const importTabs: ImportTab[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
label: 'Manual Entry',
|
||||
href: '/add-story',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
label: 'Import from URL',
|
||||
href: '/import',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
id: 'epub',
|
||||
label: 'Import EPUB',
|
||||
href: '/import/epub',
|
||||
description: 'Import a story from an EPUB file'
|
||||
},
|
||||
{
|
||||
id: 'bulk',
|
||||
label: 'Bulk Import',
|
||||
href: '/import/bulk',
|
||||
description: 'Import multiple stories from a list of URLs'
|
||||
}
|
||||
];
|
||||
|
||||
export default function ImportLayout({ children, title, description }: ImportLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
|
||||
// Determine which tab is active
|
||||
const getActiveTab = () => {
|
||||
if (pathname === '/add-story') {
|
||||
return 'manual';
|
||||
} else if (pathname === '/import') {
|
||||
return 'url';
|
||||
} else if (pathname === '/import/epub') {
|
||||
return 'epub';
|
||||
} else if (pathname === '/import/bulk') {
|
||||
return 'bulk';
|
||||
}
|
||||
return 'manual';
|
||||
};
|
||||
|
||||
const activeTab = getActiveTab();
|
||||
|
||||
export default function ImportLayout({
|
||||
children,
|
||||
title,
|
||||
description
|
||||
}: ImportLayoutProps) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold theme-header">{title}</h1>
|
||||
{description && (
|
||||
<p className="theme-text mt-2 text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="theme-card theme-shadow rounded-lg overflow-hidden">
|
||||
{/* Tab Headers */}
|
||||
<div className="flex border-b theme-border overflow-x-auto">
|
||||
{importTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`flex-1 min-w-0 px-4 py-3 text-sm font-medium text-center transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'theme-accent-bg text-white border-b-2 border-transparent'
|
||||
: 'theme-text hover:theme-accent-light hover:theme-accent-text'
|
||||
}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
{tab.label}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
{/* Tab Descriptions */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-center">
|
||||
<p className="text-sm theme-text text-center">
|
||||
{importTabs.find(tab => tab.id === activeTab)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
}>
|
||||
<ImportLayoutContent title={title} description={description}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/library"
|
||||
className="theme-text hover:theme-accent transition-colors text-sm"
|
||||
>
|
||||
← Back to Library
|
||||
</Link>
|
||||
</div>
|
||||
</ImportLayoutContent>
|
||||
</Suspense>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
116
frontend/src/components/layout/ImportLayoutContent.tsx
Normal file
116
frontend/src/components/layout/ImportLayoutContent.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
interface ImportTab {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ImportLayoutContentProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const importTabs: ImportTab[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
label: 'Manual Entry',
|
||||
href: '/add-story',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
label: 'Import from URL',
|
||||
href: '/import',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
id: 'epub',
|
||||
label: 'Import EPUB',
|
||||
href: '/import/epub',
|
||||
description: 'Import a story from an EPUB file'
|
||||
},
|
||||
{
|
||||
id: 'bulk',
|
||||
label: 'Bulk Import',
|
||||
href: '/import/bulk',
|
||||
description: 'Import multiple stories from URLs'
|
||||
}
|
||||
];
|
||||
|
||||
export default function ImportLayoutContent({
|
||||
children,
|
||||
title,
|
||||
description
|
||||
}: ImportLayoutContentProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Determine active tab based on current path
|
||||
const activeTab = importTabs.find(tab => {
|
||||
if (tab.href === pathname) return true;
|
||||
if (tab.href === '/import' && pathname === '/import') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">{title}</h1>
|
||||
{description && (
|
||||
<p className="theme-text mt-2">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/library"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium theme-button theme-border border rounded-lg hover:theme-button-hover transition-colors"
|
||||
>
|
||||
← Back to Library
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Import Method Tabs */}
|
||||
<div className="border-b theme-border">
|
||||
<nav className="-mb-px flex space-x-8 overflow-x-auto">
|
||||
{importTabs.map((tab) => {
|
||||
const isActive = activeTab?.id === tab.id;
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`
|
||||
group inline-flex items-center px-1 py-4 border-b-2 font-medium text-sm whitespace-nowrap
|
||||
${isActive
|
||||
? 'border-theme-accent text-theme-accent'
|
||||
: 'border-transparent theme-text hover:text-theme-header hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span>{tab.label}</span>
|
||||
<span className="text-xs theme-text mt-1 group-hover:text-theme-header">
|
||||
{tab.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
610
frontend/src/components/stories/PortableTextEditor.tsx
Normal file
610
frontend/src/components/stories/PortableTextEditor.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
671
frontend/src/components/stories/PortableTextEditorNew.tsx
Normal file
671
frontend/src/components/stories/PortableTextEditorNew.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
'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';
|
||||
|
||||
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(() => {
|
||||
console.log('🔄 Editor value changed:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
|
||||
setPortableTextValue(htmlToPortableTextBlocks(value));
|
||||
}, [value]);
|
||||
|
||||
// Debug: log when portableTextValue changes
|
||||
useEffect(() => {
|
||||
console.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) => {
|
||||
console.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);
|
||||
|
||||
console.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');
|
||||
|
||||
console.log('📋 Clipboard contents:', {
|
||||
htmlLength: htmlData.length,
|
||||
textLength: textData.length,
|
||||
hasImages: htmlData.includes('<img'),
|
||||
htmlPreview: htmlData.substring(0, 300)
|
||||
});
|
||||
|
||||
if (htmlData && htmlData.includes('<img')) {
|
||||
console.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);
|
||||
|
||||
console.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);
|
||||
console.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) => {
|
||||
console.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;
|
||||
|
||||
console.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
|
||||
|
||||
// Handle image blocks
|
||||
if (schemaType.name === 'image' && isImageBlock(value)) {
|
||||
console.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) {
|
||||
console.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