new editor
This commit is contained in:
6542
frontend/package-lock.json
generated
Normal file
6542
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@portabletext/editor": "2.12.1",
|
|
||||||
"@portabletext/keyboard-shortcuts": "^1.1.1",
|
|
||||||
"@portabletext/react": "4.0.3",
|
|
||||||
"@portabletext/types": "2.0.14",
|
|
||||||
"@sanity/schema": "^4.9.0",
|
|
||||||
"@sanity/types": "^4.9.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.7.7",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"next": "^14.2.32",
|
"next": "^14.2.32",
|
||||||
@@ -28,6 +22,9 @@
|
|||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"slate": "^0.118.1",
|
||||||
|
"slate-react": "^0.117.4",
|
||||||
|
"slate-history": "^0.113.1",
|
||||||
"tailwindcss": "^3.3.0"
|
"tailwindcss": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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/PortableTextEditor';
|
import SlateEditor from '../../components/stories/SlateEditor';
|
||||||
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';
|
||||||
@@ -451,7 +451,7 @@ export default function AddStoryContent() {
|
|||||||
<label className="block text-sm font-medium theme-header mb-2">
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
Story Content *
|
Story Content *
|
||||||
</label>
|
</label>
|
||||||
<PortableTextEditor
|
<SlateEditor
|
||||||
value={formData.contentHtml}
|
value={formData.contentHtml}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
placeholder="Write or paste your story content here..."
|
placeholder="Write or paste your story content here..."
|
||||||
|
|||||||
@@ -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/PortableTextEditor';
|
import SlateEditor from '../../../../components/stories/SlateEditor';
|
||||||
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';
|
||||||
@@ -337,7 +337,7 @@ export default function EditStoryPage() {
|
|||||||
<label className="block text-sm font-medium theme-header mb-2">
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
Story Content *
|
Story Content *
|
||||||
</label>
|
</label>
|
||||||
<PortableTextEditor
|
<SlateEditor
|
||||||
value={formData.contentHtml}
|
value={formData.contentHtml}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
placeholder="Edit your story content here..."
|
placeholder="Edit your story content here..."
|
||||||
|
|||||||
@@ -1,754 +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 { EventListenerPlugin } from '@portabletext/editor/plugins';
|
|
||||||
import { PortableText } from '@portabletext/react';
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
|
||||||
import { editorSchema } from '../../lib/portabletext/editorSchema';
|
|
||||||
import { debug } from '../../lib/debug';
|
|
||||||
|
|
||||||
interface PortableTextEditorProps {
|
|
||||||
value: string; // HTML value for compatibility - will be converted
|
|
||||||
onChange: (value: string) => void; // Returns HTML for compatibility
|
|
||||||
placeholder?: string;
|
|
||||||
error?: string;
|
|
||||||
storyId?: string;
|
|
||||||
enableImageProcessing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conversion utilities
|
|
||||||
function htmlToPortableTextBlocks(html: string): PortableTextBlock[] {
|
|
||||||
if (!html || html.trim() === '') {
|
|
||||||
return [{ _type: 'block', _key: generateKey(), style: 'normal', markDefs: [], children: [{ _type: 'span', _key: generateKey(), text: '', marks: [] }] }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic HTML to Portable Text conversion
|
|
||||||
// This is a simplified implementation - you could enhance this
|
|
||||||
const sanitizedHtml = sanitizeHtmlSync(html);
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
|
|
||||||
|
|
||||||
const blocks: PortableTextBlock[] = [];
|
|
||||||
const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div');
|
|
||||||
|
|
||||||
if (paragraphs.length === 0) {
|
|
||||||
// Fallback: treat body text as single block, preserving newlines
|
|
||||||
const bodyText = doc.body.textContent || '';
|
|
||||||
return [{
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style: 'normal',
|
|
||||||
markDefs: [],
|
|
||||||
children: [{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text: bodyText, // Keep newlines in the text
|
|
||||||
marks: []
|
|
||||||
}]
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have only one paragraph that might contain newlines
|
|
||||||
if (paragraphs.length === 1) {
|
|
||||||
const singleParagraph = paragraphs[0];
|
|
||||||
const textContent = singleParagraph.textContent || '';
|
|
||||||
|
|
||||||
// If this single paragraph contains newlines, preserve them in a single block
|
|
||||||
if (textContent.includes('\n')) {
|
|
||||||
const style = getStyleFromElement(singleParagraph);
|
|
||||||
return [{
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style,
|
|
||||||
markDefs: [],
|
|
||||||
children: [{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text: textContent, // Keep newlines in the text
|
|
||||||
marks: []
|
|
||||||
}]
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process all elements in document order to maintain sequence
|
|
||||||
const allElements = Array.from(doc.body.querySelectorAll('*'));
|
|
||||||
const processedElements = new Set<Element>();
|
|
||||||
|
|
||||||
for (const element of allElements) {
|
|
||||||
// Skip if already processed
|
|
||||||
if (processedElements.has(element)) continue;
|
|
||||||
|
|
||||||
// Handle images
|
|
||||||
if (element.tagName === 'IMG') {
|
|
||||||
const img = element as HTMLImageElement;
|
|
||||||
blocks.push({
|
|
||||||
_type: 'image',
|
|
||||||
_key: generateKey(),
|
|
||||||
src: img.getAttribute('src') || '',
|
|
||||||
alt: img.getAttribute('alt') || '',
|
|
||||||
caption: img.getAttribute('title') || '',
|
|
||||||
width: img.getAttribute('width') ? parseInt(img.getAttribute('width')!) : undefined,
|
|
||||||
height: img.getAttribute('height') ? parseInt(img.getAttribute('height')!) : undefined,
|
|
||||||
});
|
|
||||||
processedElements.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle code blocks
|
|
||||||
if ((element.tagName === 'CODE' && element.parentElement?.tagName === 'PRE') ||
|
|
||||||
(element.tagName === 'PRE' && element.querySelector('code'))) {
|
|
||||||
const codeEl = element.tagName === 'CODE' ? element : element.querySelector('code');
|
|
||||||
if (codeEl) {
|
|
||||||
// Use innerText to preserve newlines and whitespace formatting
|
|
||||||
// innerText respects CSS white-space property, so <pre> formatting is preserved
|
|
||||||
let code = (codeEl as HTMLElement).innerText || codeEl.textContent || '';
|
|
||||||
|
|
||||||
const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
|
|
||||||
|
|
||||||
if (code.trim()) {
|
|
||||||
blocks.push({
|
|
||||||
_type: 'codeBlock',
|
|
||||||
_key: generateKey(),
|
|
||||||
code,
|
|
||||||
language,
|
|
||||||
});
|
|
||||||
processedElements.add(element);
|
|
||||||
if (element.tagName === 'PRE') processedElements.add(codeEl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle text blocks (paragraphs, headings, etc.)
|
|
||||||
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
|
|
||||||
// Skip if this contains already processed elements (but allow inline code)
|
|
||||||
if (element.querySelector('img')) {
|
|
||||||
processedElements.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a standalone pre/code block that should be handled specially
|
|
||||||
const hasStandaloneCodeBlock = element.querySelector('pre') && element.children.length === 1 && element.children[0].tagName === 'PRE';
|
|
||||||
if (hasStandaloneCodeBlock) {
|
|
||||||
processedElements.add(element);
|
|
||||||
continue; // Let the pre/code block handler take care of this
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = getStyleFromElement(element);
|
|
||||||
|
|
||||||
// For text content that may contain inline code, preserve formatting better
|
|
||||||
let text: string;
|
|
||||||
if (element.querySelector('code') && !element.querySelector('pre')) {
|
|
||||||
// Has inline code - use innerText to preserve some formatting
|
|
||||||
text = (element as HTMLElement).innerText || element.textContent || '';
|
|
||||||
} else {
|
|
||||||
// Regular text - use textContent, but also check if this might have come from a pre block
|
|
||||||
text = (element as HTMLElement).innerText || element.textContent || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.trim()) {
|
|
||||||
// Keep newlines within a single block - they'll be converted to <br> tags later
|
|
||||||
blocks.push({
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style,
|
|
||||||
markDefs: [],
|
|
||||||
children: [{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text, // Keep newlines in the text
|
|
||||||
marks: []
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
processedElements.add(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.length > 0 ? blocks : [{
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style: 'normal',
|
|
||||||
markDefs: [],
|
|
||||||
children: [{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text: '',
|
|
||||||
marks: []
|
|
||||||
}]
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
function portableTextToHtml(blocks: PortableTextBlock[]): string {
|
|
||||||
if (!blocks || blocks.length === 0) return '';
|
|
||||||
|
|
||||||
const htmlParts: string[] = [];
|
|
||||||
|
|
||||||
blocks.forEach(block => {
|
|
||||||
if (block._type === 'block' && Array.isArray(block.children)) {
|
|
||||||
const tag = getHtmlTagFromStyle((block.style as string) || 'normal');
|
|
||||||
const children = block.children as PortableTextChild[];
|
|
||||||
const text = children
|
|
||||||
.map(child => child._type === 'span' ? child.text || '' : '')
|
|
||||||
.join('') || '';
|
|
||||||
|
|
||||||
// Convert any remaining newlines in text to <br> tags for proper display
|
|
||||||
const textWithBreaks = text.replace(/\n/g, '<br>');
|
|
||||||
// Always include blocks, even empty ones (they represent line breaks/paragraph spacing)
|
|
||||||
htmlParts.push(`<${tag}>${textWithBreaks}</${tag}>`);
|
|
||||||
} else if (block._type === 'image' && isImageBlock(block)) {
|
|
||||||
// Convert image blocks back to HTML
|
|
||||||
const attrs: string[] = [];
|
|
||||||
if (block.src) attrs.push(`src="${block.src}"`);
|
|
||||||
if (block.alt) attrs.push(`alt="${block.alt}"`);
|
|
||||||
if (block.caption) attrs.push(`title="${block.caption}"`);
|
|
||||||
if (block.width) attrs.push(`width="${block.width}"`);
|
|
||||||
if (block.height) attrs.push(`height="${block.height}"`);
|
|
||||||
|
|
||||||
htmlParts.push(`<img ${attrs.join(' ')} />`);
|
|
||||||
} else if (block._type === 'codeBlock' && isCodeBlock(block)) {
|
|
||||||
// Convert code blocks back to HTML
|
|
||||||
const langClass = block.language ? ` class="language-${block.language}"` : '';
|
|
||||||
// Escape HTML entities in code content to prevent XSS and preserve formatting
|
|
||||||
const escapedCode = (block.code || '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
htmlParts.push(`<pre><code${langClass}>${escapedCode}</code></pre>`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = htmlParts.join('\n');
|
|
||||||
return sanitizeHtmlSync(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStyleFromElement(element: Element): string {
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
const styleMap: Record<string, string> = {
|
|
||||||
'p': 'normal',
|
|
||||||
'div': 'normal',
|
|
||||||
'h1': 'h1',
|
|
||||||
'h2': 'h2',
|
|
||||||
'h3': 'h3',
|
|
||||||
'h4': 'h4',
|
|
||||||
'h5': 'h5',
|
|
||||||
'h6': 'h6',
|
|
||||||
'blockquote': 'blockquote',
|
|
||||||
};
|
|
||||||
return styleMap[tagName] || 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHtmlTagFromStyle(style: string): string {
|
|
||||||
const tagMap: Record<string, string> = {
|
|
||||||
'normal': 'p',
|
|
||||||
'h1': 'h1',
|
|
||||||
'h2': 'h2',
|
|
||||||
'h3': 'h3',
|
|
||||||
'h4': 'h4',
|
|
||||||
'h5': 'h5',
|
|
||||||
'h6': 'h6',
|
|
||||||
'blockquote': 'blockquote',
|
|
||||||
};
|
|
||||||
return tagMap[style] || 'p';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PortableTextChild {
|
|
||||||
_type: string;
|
|
||||||
_key: string;
|
|
||||||
text?: string;
|
|
||||||
marks?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type guards for custom block types
|
|
||||||
function isImageBlock(value: any): value is {
|
|
||||||
_type: 'image';
|
|
||||||
src?: string;
|
|
||||||
alt?: string;
|
|
||||||
caption?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
} {
|
|
||||||
return value && typeof value === 'object' && value._type === 'image';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodeBlock(value: any): value is {
|
|
||||||
_type: 'codeBlock';
|
|
||||||
code?: string;
|
|
||||||
language?: string;
|
|
||||||
} {
|
|
||||||
return value && typeof value === 'object' && value._type === 'codeBlock';
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateKey(): string {
|
|
||||||
return Math.random().toString(36).substring(2, 11);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Toolbar component
|
|
||||||
function EditorToolbar({
|
|
||||||
isScrollable,
|
|
||||||
onToggleScrollable
|
|
||||||
}: {
|
|
||||||
isScrollable: boolean;
|
|
||||||
onToggleScrollable: () => void;
|
|
||||||
}) {
|
|
||||||
const editor = useEditor();
|
|
||||||
|
|
||||||
const toggleDecorator = (decorator: string) => {
|
|
||||||
editor.send({ type: 'decorator.toggle', decorator });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setStyle = (style: string) => {
|
|
||||||
editor.send({ type: 'style.toggle', style });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
||||||
✨ Portable Text Editor
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Style buttons */}
|
|
||||||
<div className="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setStyle('normal')}
|
|
||||||
title="Normal paragraph"
|
|
||||||
>
|
|
||||||
P
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setStyle('h1')}
|
|
||||||
title="Heading 1"
|
|
||||||
className="text-lg font-bold"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setStyle('h2')}
|
|
||||||
title="Heading 2"
|
|
||||||
className="text-base font-bold"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setStyle('h3')}
|
|
||||||
title="Heading 3"
|
|
||||||
className="text-sm font-bold"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorator buttons */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => toggleDecorator('strong')}
|
|
||||||
title="Bold (Ctrl+B)"
|
|
||||||
className="font-bold"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => toggleDecorator('em')}
|
|
||||||
title="Italic (Ctrl+I)"
|
|
||||||
className="italic"
|
|
||||||
>
|
|
||||||
I
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => toggleDecorator('underline')}
|
|
||||||
title="Underline"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
U
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => toggleDecorator('strike')}
|
|
||||||
title="Strike-through"
|
|
||||||
className="line-through"
|
|
||||||
>
|
|
||||||
S
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs theme-text">Scrollable:</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onToggleScrollable}
|
|
||||||
className={isScrollable ? 'theme-accent-bg text-white' : ''}
|
|
||||||
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
|
|
||||||
>
|
|
||||||
{isScrollable ? '📜' : '📏'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Simple component that uses Portable Text editor directly
|
|
||||||
function EditorContent({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
error
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
error?: string;
|
|
||||||
}) {
|
|
||||||
const [portableTextValue, setPortableTextValue] = useState<PortableTextBlock[]>(() =>
|
|
||||||
htmlToPortableTextBlocks(value)
|
|
||||||
);
|
|
||||||
const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable
|
|
||||||
|
|
||||||
// Sync HTML value with prop changes (but not for internal changes)
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip re-initialization if this change came from the editor itself
|
|
||||||
if (isInternalChange.current) {
|
|
||||||
debug.log('🔄 Skipping re-initialization for internal change');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug.log('🔄 Editor value changed externally:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
|
|
||||||
setPortableTextValue(htmlToPortableTextBlocks(value));
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Debug: log when portableTextValue changes
|
|
||||||
useEffect(() => {
|
|
||||||
debug.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue });
|
|
||||||
}, [portableTextValue]);
|
|
||||||
|
|
||||||
// Track if changes are coming from internal editor changes
|
|
||||||
const isInternalChange = useRef(false);
|
|
||||||
|
|
||||||
// Handle content changes using the EventListenerPlugin
|
|
||||||
const handleEditorChange = useCallback((event: any) => {
|
|
||||||
if (event.type === 'mutation') {
|
|
||||||
debug.log('📝 Editor content changed via EventListener:', { valueLength: event.value?.length });
|
|
||||||
const html = portableTextToHtml(event.value);
|
|
||||||
debug.log('📝 Converted to HTML:', { htmlLength: html.length, htmlPreview: html.substring(0, 200) });
|
|
||||||
|
|
||||||
// Mark this as an internal change to prevent re-initialization
|
|
||||||
isInternalChange.current = true;
|
|
||||||
onChange(html);
|
|
||||||
// Reset flag after a short delay to allow external changes
|
|
||||||
setTimeout(() => {
|
|
||||||
isInternalChange.current = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
// Add a ref to the editor container for direct paste handling
|
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Global paste event listener to catch ALL paste events
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalPaste = (event: ClipboardEvent) => {
|
|
||||||
debug.log('🌍 Global paste event captured');
|
|
||||||
|
|
||||||
// Check if the paste is happening within our editor
|
|
||||||
const target = event.target as Element;
|
|
||||||
const isInEditor = editorContainerRef.current?.contains(target);
|
|
||||||
|
|
||||||
debug.log('📋 Paste details:', {
|
|
||||||
isInEditor,
|
|
||||||
targetTag: target?.tagName,
|
|
||||||
targetClasses: target?.className,
|
|
||||||
hasClipboardData: !!event.clipboardData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isInEditor && event.clipboardData) {
|
|
||||||
const htmlData = event.clipboardData.getData('text/html');
|
|
||||||
const textData = event.clipboardData.getData('text/plain');
|
|
||||||
|
|
||||||
debug.log('📋 Clipboard contents:', {
|
|
||||||
htmlLength: htmlData.length,
|
|
||||||
textLength: textData.length,
|
|
||||||
hasImages: htmlData.includes('<img'),
|
|
||||||
htmlPreview: htmlData.substring(0, 300)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (htmlData && htmlData.includes('<img')) {
|
|
||||||
debug.log('📋 Images detected in paste! Attempting to process...');
|
|
||||||
|
|
||||||
// Prevent default paste to handle it completely ourselves
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
// Convert the pasted HTML to our blocks maintaining order
|
|
||||||
const pastedBlocks = htmlToPortableTextBlocks(htmlData);
|
|
||||||
|
|
||||||
debug.log('📋 Converted blocks:', pastedBlocks.map(block => ({
|
|
||||||
type: block._type,
|
|
||||||
key: block._key,
|
|
||||||
...(block._type === 'image' ? { src: (block as any).src, alt: (block as any).alt } : {}),
|
|
||||||
...(block._type === 'block' ? { style: (block as any).style, text: (block as any).children?.[0]?.text?.substring(0, 50) } : {})
|
|
||||||
})));
|
|
||||||
|
|
||||||
if (pastedBlocks.length > 0) {
|
|
||||||
// Insert the blocks at the end of current content (maintaining order within the paste)
|
|
||||||
setTimeout(() => {
|
|
||||||
setPortableTextValue(prev => {
|
|
||||||
const updatedBlocks = [...prev, ...pastedBlocks];
|
|
||||||
const html = portableTextToHtml(updatedBlocks);
|
|
||||||
onChange(html);
|
|
||||||
debug.log('📋 Added structured blocks maintaining order:', { pastedCount: pastedBlocks.length, totalBlocks: updatedBlocks.length });
|
|
||||||
return updatedBlocks;
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add global event listener with capture phase to catch events early
|
|
||||||
document.addEventListener('paste', handleGlobalPaste, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('paste', handleGlobalPaste, true);
|
|
||||||
};
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
// Handle paste events directly on the editor container (backup approach)
|
|
||||||
const handleContainerPaste = useCallback((_event: React.ClipboardEvent) => {
|
|
||||||
debug.log('📦 Container paste handler triggered');
|
|
||||||
// This might not be reached if global handler prevents default
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Render functions for the editor
|
|
||||||
const renderStyle: RenderStyleFunction = useCallback((props) => {
|
|
||||||
const { schemaType, children } = props;
|
|
||||||
|
|
||||||
switch (schemaType.value) {
|
|
||||||
case 'h1':
|
|
||||||
return <h1 className="text-3xl font-bold mb-4">{children}</h1>;
|
|
||||||
case 'h2':
|
|
||||||
return <h2 className="text-2xl font-bold mb-3">{children}</h2>;
|
|
||||||
case 'h3':
|
|
||||||
return <h3 className="text-xl font-bold mb-3">{children}</h3>;
|
|
||||||
case 'h4':
|
|
||||||
return <h4 className="text-lg font-bold mb-2">{children}</h4>;
|
|
||||||
case 'h5':
|
|
||||||
return <h5 className="text-base font-bold mb-2">{children}</h5>;
|
|
||||||
case 'h6':
|
|
||||||
return <h6 className="text-sm font-bold mb-2">{children}</h6>;
|
|
||||||
case 'blockquote':
|
|
||||||
return <blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
|
|
||||||
default:
|
|
||||||
return <p className="mb-2">{children}</p>;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
|
|
||||||
const { schemaType, children } = props;
|
|
||||||
|
|
||||||
switch (schemaType.value) {
|
|
||||||
case 'strong':
|
|
||||||
return <strong>{children}</strong>;
|
|
||||||
case 'em':
|
|
||||||
return <em>{children}</em>;
|
|
||||||
case 'underline':
|
|
||||||
return <u>{children}</u>;
|
|
||||||
case 'strike':
|
|
||||||
return <s>{children}</s>;
|
|
||||||
case 'code':
|
|
||||||
return <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
|
|
||||||
default:
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderBlock: RenderBlockFunction = useCallback((props) => {
|
|
||||||
const { schemaType, value, children } = props;
|
|
||||||
|
|
||||||
debug.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
|
|
||||||
|
|
||||||
// Handle image blocks
|
|
||||||
if (schemaType.name === 'image' && isImageBlock(value)) {
|
|
||||||
debug.log('🖼️ Rendering image block:', value);
|
|
||||||
return (
|
|
||||||
<div className="my-4 p-3 border border-dashed border-gray-300 rounded-lg bg-gray-50">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🖼️</span>
|
|
||||||
<span className="font-medium text-gray-700">Image Block</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 space-y-1">
|
|
||||||
<p><strong>Source:</strong> {value.src || 'No source'}</p>
|
|
||||||
{value.alt && <p><strong>Alt text:</strong> {value.alt}</p>}
|
|
||||||
{value.caption && <p><strong>Caption:</strong> {value.caption}</p>}
|
|
||||||
{(value.width || value.height) && (
|
|
||||||
<p><strong>Dimensions:</strong> {value.width || '?'} × {value.height || '?'}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle code blocks
|
|
||||||
if (schemaType.name === 'codeBlock' && isCodeBlock(value)) {
|
|
||||||
return (
|
|
||||||
<div className="my-4 p-3 border border-dashed border-blue-300 rounded-lg bg-blue-50">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">💻</span>
|
|
||||||
<span className="font-medium text-blue-700">Code Block</span>
|
|
||||||
{value.language && (
|
|
||||||
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
|
|
||||||
{value.language}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<pre className="text-sm text-gray-800 bg-white p-2 rounded border overflow-x-auto">
|
|
||||||
<code>{value.code || '// No code'}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default block rendering
|
|
||||||
return <div>{children}</div>;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderListItem: RenderListItemFunction = useCallback((props) => {
|
|
||||||
return <li>{props.children}</li>;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderAnnotation: RenderAnnotationFunction = useCallback((props) => {
|
|
||||||
const { schemaType, children, value } = props;
|
|
||||||
|
|
||||||
if (schemaType.name === 'link' && value && typeof value === 'object') {
|
|
||||||
const linkValue = value as { href?: string; target?: string; title?: string };
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={linkValue.href}
|
|
||||||
target={linkValue.target || '_self'}
|
|
||||||
title={linkValue.title}
|
|
||||||
className="text-blue-600 hover:text-blue-800 underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<EditorProvider
|
|
||||||
key={`editor-${portableTextValue.length}-${Date.now()}`}
|
|
||||||
initialConfig={{
|
|
||||||
schemaDefinition: editorSchema,
|
|
||||||
initialValue: portableTextValue,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EventListenerPlugin on={handleEditorChange} />
|
|
||||||
<EditorToolbar
|
|
||||||
isScrollable={isScrollable}
|
|
||||||
onToggleScrollable={() => setIsScrollable(!isScrollable)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={editorContainerRef}
|
|
||||||
className="border theme-border rounded-b-lg overflow-hidden"
|
|
||||||
onPaste={handleContainerPaste}
|
|
||||||
>
|
|
||||||
<PortableTextEditable
|
|
||||||
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
|
|
||||||
isScrollable
|
|
||||||
? 'h-[400px] overflow-y-auto'
|
|
||||||
: 'min-h-[300px]'
|
|
||||||
}`}
|
|
||||||
placeholder={placeholder}
|
|
||||||
renderStyle={renderStyle}
|
|
||||||
renderDecorator={renderDecorator}
|
|
||||||
renderBlock={renderBlock}
|
|
||||||
renderListItem={renderListItem}
|
|
||||||
renderAnnotation={renderAnnotation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</EditorProvider>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-xs theme-text">
|
|
||||||
<p>
|
|
||||||
<strong>Portable Text Editor:</strong> Rich text editor with structured content.
|
|
||||||
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
|
||||||
📋 Paste detection active.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PortableTextEditorNew({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Write your story here...',
|
|
||||||
error,
|
|
||||||
storyId,
|
|
||||||
enableImageProcessing = false
|
|
||||||
}: PortableTextEditorProps) {
|
|
||||||
debug.log('🎯 Portable Text Editor loaded!', {
|
|
||||||
valueLength: value?.length,
|
|
||||||
enableImageProcessing,
|
|
||||||
hasStoryId: !!storyId
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EditorContent
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
892
frontend/src/components/stories/SlateEditor.tsx
Normal file
892
frontend/src/components/stories/SlateEditor.tsx
Normal file
@@ -0,0 +1,892 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
createEditor,
|
||||||
|
Descendant,
|
||||||
|
Element as SlateElement,
|
||||||
|
Node as SlateNode,
|
||||||
|
Transforms,
|
||||||
|
Editor,
|
||||||
|
Range
|
||||||
|
} from 'slate';
|
||||||
|
import {
|
||||||
|
Slate,
|
||||||
|
Editable,
|
||||||
|
withReact,
|
||||||
|
ReactEditor,
|
||||||
|
RenderElementProps,
|
||||||
|
RenderLeafProps,
|
||||||
|
useSlate as useEditor
|
||||||
|
} from 'slate-react';
|
||||||
|
import { withHistory } from 'slate-history';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||||
|
import { debug } from '../../lib/debug';
|
||||||
|
|
||||||
|
interface SlateEditorProps {
|
||||||
|
value: string; // HTML value for compatibility with existing code
|
||||||
|
onChange: (value: string) => void; // Returns HTML for compatibility
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
storyId?: string;
|
||||||
|
enableImageProcessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom types for our editor
|
||||||
|
type CustomElement = {
|
||||||
|
type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'blockquote' | 'image' | 'code-block';
|
||||||
|
children: CustomText[];
|
||||||
|
src?: string; // for images
|
||||||
|
alt?: string; // for images
|
||||||
|
caption?: string; // for images
|
||||||
|
language?: string; // for code blocks
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomText = {
|
||||||
|
text: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strikethrough?: boolean;
|
||||||
|
code?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module 'slate' {
|
||||||
|
interface CustomTypes {
|
||||||
|
Editor: ReactEditor;
|
||||||
|
Element: CustomElement;
|
||||||
|
Text: CustomText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML to Slate conversion - preserves mixed content order
|
||||||
|
const htmlToSlate = (html: string): Descendant[] => {
|
||||||
|
if (!html || html.trim() === '') {
|
||||||
|
return [{ type: 'paragraph', children: [{ text: '' }] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(html);
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
|
||||||
|
|
||||||
|
const nodes: Descendant[] = [];
|
||||||
|
|
||||||
|
// Process all nodes in document order to maintain sequence
|
||||||
|
const processChildNodes = (parentNode: Node): Descendant[] => {
|
||||||
|
const results: Descendant[] = [];
|
||||||
|
|
||||||
|
Array.from(parentNode.childNodes).forEach(node => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const element = node as Element;
|
||||||
|
|
||||||
|
switch (element.tagName.toLowerCase()) {
|
||||||
|
case 'h1':
|
||||||
|
results.push({
|
||||||
|
type: 'heading-one',
|
||||||
|
children: [{ text: element.textContent || '' }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
results.push({
|
||||||
|
type: 'heading-two',
|
||||||
|
children: [{ text: element.textContent || '' }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
results.push({
|
||||||
|
type: 'heading-three',
|
||||||
|
children: [{ text: element.textContent || '' }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'blockquote':
|
||||||
|
results.push({
|
||||||
|
type: 'blockquote',
|
||||||
|
children: [{ text: element.textContent || '' }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'img':
|
||||||
|
const img = element as HTMLImageElement;
|
||||||
|
results.push({
|
||||||
|
type: 'image',
|
||||||
|
src: img.src || img.getAttribute('src') || '',
|
||||||
|
alt: img.alt || img.getAttribute('alt') || '',
|
||||||
|
caption: img.title || img.getAttribute('title') || '',
|
||||||
|
children: [{ text: '' }] // Images need children in Slate
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'pre':
|
||||||
|
const codeEl = element.querySelector('code');
|
||||||
|
const code = codeEl ? codeEl.textContent || '' : element.textContent || '';
|
||||||
|
const language = codeEl?.className?.replace('language-', '') || '';
|
||||||
|
results.push({
|
||||||
|
type: 'code-block',
|
||||||
|
language,
|
||||||
|
children: [{ text: code }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
case 'div':
|
||||||
|
// Check if this paragraph contains mixed content (text + images)
|
||||||
|
if (element.querySelector('img')) {
|
||||||
|
// Process mixed content - handle both text and images in order
|
||||||
|
results.push(...processChildNodes(element));
|
||||||
|
} else {
|
||||||
|
const text = element.textContent || '';
|
||||||
|
if (text.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'br':
|
||||||
|
// Handle line breaks by creating empty paragraphs
|
||||||
|
results.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text: '' }]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For other elements, try to extract text or recurse
|
||||||
|
const text = element.textContent || '';
|
||||||
|
if (text.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent || '';
|
||||||
|
if (text.trim()) {
|
||||||
|
results.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text: text.trim() }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all content
|
||||||
|
nodes.push(...processChildNodes(doc.body));
|
||||||
|
|
||||||
|
// Fallback for simple text content
|
||||||
|
if (nodes.length === 0 && doc.body.textContent?.trim()) {
|
||||||
|
const text = doc.body.textContent.trim();
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
lines.forEach(line => {
|
||||||
|
nodes.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text: line.trim() }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.length > 0 ? nodes : [{ type: 'paragraph', children: [{ text: '' }] }];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slate to HTML conversion
|
||||||
|
const slateToHtml = (nodes: Descendant[]): string => {
|
||||||
|
const htmlParts: string[] = [];
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (SlateElement.isElement(node)) {
|
||||||
|
const element = node as CustomElement;
|
||||||
|
const text = SlateNode.string(node);
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case 'heading-one':
|
||||||
|
htmlParts.push(`<h1>${text}</h1>`);
|
||||||
|
break;
|
||||||
|
case 'heading-two':
|
||||||
|
htmlParts.push(`<h2>${text}</h2>`);
|
||||||
|
break;
|
||||||
|
case 'heading-three':
|
||||||
|
htmlParts.push(`<h3>${text}</h3>`);
|
||||||
|
break;
|
||||||
|
case 'blockquote':
|
||||||
|
htmlParts.push(`<blockquote>${text}</blockquote>`);
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
const attrs: string[] = [];
|
||||||
|
if (element.src) attrs.push(`src="${element.src}"`);
|
||||||
|
if (element.alt) attrs.push(`alt="${element.alt}"`);
|
||||||
|
if (element.caption) attrs.push(`title="${element.caption}"`);
|
||||||
|
htmlParts.push(`<img ${attrs.join(' ')} />`);
|
||||||
|
break;
|
||||||
|
case 'code-block':
|
||||||
|
const langClass = element.language ? ` class="language-${element.language}"` : '';
|
||||||
|
const escapedText = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
htmlParts.push(`<pre><code${langClass}>${escapedText}</code></pre>`);
|
||||||
|
break;
|
||||||
|
case 'paragraph':
|
||||||
|
default:
|
||||||
|
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = htmlParts.join('\n');
|
||||||
|
return sanitizeHtmlSync(html);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom plugin to handle images
|
||||||
|
const withImages = (editor: ReactEditor) => {
|
||||||
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
|
editor.isVoid = element => {
|
||||||
|
return element.type === 'image' ? true : isVoid(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.insertData = (data) => {
|
||||||
|
const html = data.getData('text/html');
|
||||||
|
|
||||||
|
if (html && html.includes('<img')) {
|
||||||
|
debug.log('📋 Image paste detected in Slate editor');
|
||||||
|
|
||||||
|
// Convert HTML to Slate nodes maintaining order
|
||||||
|
const slateNodes = htmlToSlate(html);
|
||||||
|
|
||||||
|
// Insert all nodes in sequence
|
||||||
|
slateNodes.forEach(node => {
|
||||||
|
Transforms.insertNodes(editor, node);
|
||||||
|
});
|
||||||
|
|
||||||
|
debug.log(`📋 Inserted ${slateNodes.length} nodes from pasted HTML`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interactive Image Component
|
||||||
|
const ImageElement = ({ attributes, element, children }: {
|
||||||
|
attributes: any;
|
||||||
|
element: CustomElement;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const editor = useEditor();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editUrl, setEditUrl] = useState(element.src || '');
|
||||||
|
const [editAlt, setEditAlt] = useState(element.alt || '');
|
||||||
|
const [editCaption, setEditCaption] = useState(element.caption || '');
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
const path = ReactEditor.findPath(editor, element);
|
||||||
|
Transforms.removeNodes(editor, { at: path });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const path = ReactEditor.findPath(editor, element);
|
||||||
|
const newProperties: Partial<CustomElement> = {
|
||||||
|
src: editUrl,
|
||||||
|
alt: editAlt,
|
||||||
|
caption: editCaption,
|
||||||
|
};
|
||||||
|
Transforms.setNodes(editor, newProperties, { at: path });
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditUrl(element.src || '');
|
||||||
|
setEditAlt(element.alt || '');
|
||||||
|
setEditCaption(element.caption || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div {...attributes} contentEditable={false} className="my-4">
|
||||||
|
<div className="border border-blue-300 rounded-lg p-4 bg-blue-50">
|
||||||
|
<h4 className="font-medium text-blue-900 mb-3">Edit Image</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||||||
|
Image URL *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editUrl}
|
||||||
|
onChange={(e) => setEditUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||||||
|
Alt Text
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAlt}
|
||||||
|
onChange={(e) => setEditAlt(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Describe the image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-800 mb-1">
|
||||||
|
Caption
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editCaption}
|
||||||
|
onChange={(e) => setEditCaption(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Image caption"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-3 py-1 bg-gray-300 text-gray-700 text-sm rounded hover:bg-gray-400 focus:ring-2 focus:ring-gray-500"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...attributes} contentEditable={false} className="my-4">
|
||||||
|
<div
|
||||||
|
className="relative border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm group hover:shadow-md transition-shadow focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
// Handle delete/backspace on focused image
|
||||||
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
|
// Handle Enter to edit
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// Focus the image element when clicked
|
||||||
|
const path = ReactEditor.findPath(editor, element);
|
||||||
|
const start = Editor.start(editor, path);
|
||||||
|
Transforms.select(editor, start);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Control buttons - show on hover */}
|
||||||
|
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="p-1 bg-white rounded-full shadow-sm hover:bg-blue-50 border border-gray-200 text-blue-600 hover:text-blue-700"
|
||||||
|
title="Edit image"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-1 bg-white rounded-full shadow-sm hover:bg-red-50 border border-gray-200 text-red-600 hover:text-red-700"
|
||||||
|
title="Delete image"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{element.src ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={element.src}
|
||||||
|
alt={element.alt || ''}
|
||||||
|
className="w-full h-auto max-h-96 object-contain cursor-pointer"
|
||||||
|
onDoubleClick={() => setIsEditing(true)}
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to text block if image fails to load
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
const parent = target.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.innerHTML = `
|
||||||
|
<div class="p-3 border border-dashed border-red-300 rounded-lg bg-red-50">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-lg">⚠️</span>
|
||||||
|
<span class="font-medium text-red-700">Image failed to load</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-red-600 space-y-1">
|
||||||
|
<p><strong>Source:</strong> ${element.src}</p>
|
||||||
|
${element.alt ? `<p><strong>Alt:</strong> ${element.alt}</p>` : ''}
|
||||||
|
${element.caption ? `<p><strong>Caption:</strong> ${element.caption}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(element.alt || element.caption) && (
|
||||||
|
<div className="p-2 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{element.caption && (
|
||||||
|
<p className="font-medium">{element.caption}</p>
|
||||||
|
)}
|
||||||
|
{element.alt && element.alt !== element.caption && (
|
||||||
|
<p className="italic">{element.alt}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External image indicator */}
|
||||||
|
{element.src.startsWith('http') && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||||
|
<span>🌐</span>
|
||||||
|
<span>External</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="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 (No Source)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
{element.alt && <p><strong>Alt:</strong> {element.alt}</p>}
|
||||||
|
{element.caption && <p><strong>Caption:</strong> {element.caption}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for rendering elements
|
||||||
|
const Element = ({ attributes, children, element }: RenderElementProps) => {
|
||||||
|
const customElement = element as CustomElement;
|
||||||
|
|
||||||
|
switch (customElement.type) {
|
||||||
|
case 'heading-one':
|
||||||
|
return <h1 {...attributes} className="text-3xl font-bold mb-4">{children}</h1>;
|
||||||
|
case 'heading-two':
|
||||||
|
return <h2 {...attributes} className="text-2xl font-bold mb-3">{children}</h2>;
|
||||||
|
case 'heading-three':
|
||||||
|
return <h3 {...attributes} className="text-xl font-bold mb-3">{children}</h3>;
|
||||||
|
case 'blockquote':
|
||||||
|
return <blockquote {...attributes} className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<ImageElement
|
||||||
|
attributes={attributes}
|
||||||
|
element={customElement}
|
||||||
|
children={children}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'code-block':
|
||||||
|
return (
|
||||||
|
<pre {...attributes} className="my-4 p-3 bg-gray-100 rounded-lg overflow-x-auto">
|
||||||
|
<code className="text-sm font-mono">{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <p {...attributes} className="mb-2">{children}</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for rendering leaves (text formatting)
|
||||||
|
const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
||||||
|
const customLeaf = leaf as CustomText;
|
||||||
|
|
||||||
|
if (customLeaf.bold) {
|
||||||
|
children = <strong>{children}</strong>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customLeaf.italic) {
|
||||||
|
children = <em>{children}</em>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customLeaf.underline) {
|
||||||
|
children = <u>{children}</u>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customLeaf.strikethrough) {
|
||||||
|
children = <s>{children}</s>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customLeaf.code) {
|
||||||
|
children = <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span {...attributes}>{children}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toolbar component
|
||||||
|
const Toolbar = ({ editor }: { editor: ReactEditor }) => {
|
||||||
|
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code';
|
||||||
|
|
||||||
|
const isMarkActive = (format: MarkFormat) => {
|
||||||
|
const marks = Editor.marks(editor);
|
||||||
|
return marks ? marks[format] === true : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMark = (format: MarkFormat) => {
|
||||||
|
const isActive = isMarkActive(format);
|
||||||
|
if (isActive) {
|
||||||
|
Editor.removeMark(editor, format);
|
||||||
|
} else {
|
||||||
|
Editor.addMark(editor, format, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBlockActive = (format: CustomElement['type']) => {
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return false;
|
||||||
|
|
||||||
|
const [match] = Array.from(
|
||||||
|
Editor.nodes(editor, {
|
||||||
|
at: Editor.unhangRange(editor, selection),
|
||||||
|
match: n =>
|
||||||
|
!Editor.isEditor(n) &&
|
||||||
|
SlateElement.isElement(n) &&
|
||||||
|
n.type === format,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBlock = (format: CustomElement['type']) => {
|
||||||
|
const isActive = isBlockActive(format);
|
||||||
|
|
||||||
|
Transforms.setNodes(
|
||||||
|
editor,
|
||||||
|
{ type: isActive ? 'paragraph' : format },
|
||||||
|
{ match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertImage = () => {
|
||||||
|
const url = prompt('Enter image URL:', 'https://');
|
||||||
|
if (url && url.trim() !== 'https://') {
|
||||||
|
const imageNode: CustomElement = {
|
||||||
|
type: 'image',
|
||||||
|
src: url.trim(),
|
||||||
|
alt: '',
|
||||||
|
caption: '',
|
||||||
|
children: [{ text: '' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
Transforms.insertNodes(editor, imageNode);
|
||||||
|
// Add a paragraph after the image
|
||||||
|
Transforms.insertNodes(editor, {
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [{ text: '' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-2 theme-card border theme-border rounded-t-lg">
|
||||||
|
<div className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
||||||
|
✨ Slate.js Editor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block type buttons */}
|
||||||
|
<div className="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleBlock('paragraph')}
|
||||||
|
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
|
||||||
|
title="Normal paragraph"
|
||||||
|
>
|
||||||
|
P
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleBlock('heading-one')}
|
||||||
|
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleBlock('heading-two')}
|
||||||
|
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleBlock('heading-three')}
|
||||||
|
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Heading 3"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text formatting buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleMark('bold')}
|
||||||
|
className={`font-bold ${isMarkActive('bold') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleMark('italic')}
|
||||||
|
className={`italic ${isMarkActive('italic') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleMark('underline')}
|
||||||
|
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleMark('strikethrough')}
|
||||||
|
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
|
||||||
|
title="Strike-through"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image insertion button */}
|
||||||
|
<div className="flex items-center gap-1 border-l pl-2 ml-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={insertImage}
|
||||||
|
className="text-green-600 hover:bg-green-50"
|
||||||
|
title="Insert Image"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SlateEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Write your story here...',
|
||||||
|
error,
|
||||||
|
storyId,
|
||||||
|
enableImageProcessing = false
|
||||||
|
}: SlateEditorProps) {
|
||||||
|
const [isScrollable, setIsScrollable] = useState(true);
|
||||||
|
|
||||||
|
// Create editor with plugins
|
||||||
|
const editor = useMemo(
|
||||||
|
() => withImages(withHistory(withReact(createEditor()))),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert HTML to Slate format for initial value
|
||||||
|
const initialValue = useMemo(() => {
|
||||||
|
debug.log('🚀 Slate Editor initializing with HTML:', { htmlLength: value?.length });
|
||||||
|
return htmlToSlate(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Handle changes
|
||||||
|
const handleChange = useCallback((newValue: Descendant[]) => {
|
||||||
|
// Convert back to HTML and call onChange
|
||||||
|
const html = slateToHtml(newValue);
|
||||||
|
onChange(html);
|
||||||
|
|
||||||
|
debug.log('📝 Slate Editor changed:', {
|
||||||
|
htmlLength: html.length,
|
||||||
|
nodeCount: newValue.length
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
debug.log('🎯 Slate Editor loaded!', {
|
||||||
|
valueLength: value?.length,
|
||||||
|
enableImageProcessing,
|
||||||
|
hasStoryId: !!storyId
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Slate editor={editor} initialValue={initialValue} onChange={handleChange}>
|
||||||
|
<Toolbar editor={editor} />
|
||||||
|
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||||
|
<Editable
|
||||||
|
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
|
||||||
|
isScrollable
|
||||||
|
? 'h-[400px] overflow-y-auto'
|
||||||
|
: 'min-h-[300px]'
|
||||||
|
}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderElement={Element}
|
||||||
|
renderLeaf={Leaf}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
// Handle delete/backspace for selected content (including images)
|
||||||
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
// If there's an expanded selection, let Slate handle it naturally
|
||||||
|
// This will delete all selected content including images
|
||||||
|
if (!Range.isCollapsed(selection)) {
|
||||||
|
// Slate will handle this automatically, including void elements
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single point deletions near images
|
||||||
|
const { anchor } = selection;
|
||||||
|
|
||||||
|
if (event.key === 'Delete') {
|
||||||
|
// Delete key - check if next node is an image
|
||||||
|
try {
|
||||||
|
const [nextNode] = Editor.next(editor, { at: anchor }) || [];
|
||||||
|
if (nextNode && SlateElement.isElement(nextNode) && nextNode.type === 'image') {
|
||||||
|
event.preventDefault();
|
||||||
|
const path = ReactEditor.findPath(editor, nextNode);
|
||||||
|
Transforms.removeNodes(editor, { at: path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore navigation errors at document boundaries
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Backspace') {
|
||||||
|
// Backspace key - check if previous node is an image
|
||||||
|
try {
|
||||||
|
const [prevNode] = Editor.previous(editor, { at: anchor }) || [];
|
||||||
|
if (prevNode && SlateElement.isElement(prevNode) && prevNode.type === 'image') {
|
||||||
|
event.preventDefault();
|
||||||
|
const path = ReactEditor.findPath(editor, prevNode);
|
||||||
|
Transforms.removeNodes(editor, { at: path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore navigation errors at document boundaries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts
|
||||||
|
if (!event.ctrlKey && !event.metaKey) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'b': {
|
||||||
|
event.preventDefault();
|
||||||
|
const marks = Editor.marks(editor);
|
||||||
|
const isActive = marks ? marks.bold === true : false;
|
||||||
|
if (isActive) {
|
||||||
|
Editor.removeMark(editor, 'bold');
|
||||||
|
} else {
|
||||||
|
Editor.addMark(editor, 'bold', true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'i': {
|
||||||
|
event.preventDefault();
|
||||||
|
const marks = Editor.marks(editor);
|
||||||
|
const isActive = marks ? marks.italic === true : false;
|
||||||
|
if (isActive) {
|
||||||
|
Editor.removeMark(editor, 'italic');
|
||||||
|
} else {
|
||||||
|
Editor.addMark(editor, 'italic', true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
// Handle Ctrl+A / Cmd+A to select all
|
||||||
|
event.preventDefault();
|
||||||
|
Transforms.select(editor, {
|
||||||
|
anchor: Editor.start(editor, []),
|
||||||
|
focus: Editor.end(editor, []),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-xs theme-text">
|
||||||
|
<p>
|
||||||
|
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
|
||||||
|
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsScrollable(!isScrollable)}
|
||||||
|
className={isScrollable ? 'theme-accent-bg text-white' : ''}
|
||||||
|
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
|
||||||
|
>
|
||||||
|
{isScrollable ? '📜' : '📏'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Slate>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* Conversion utilities between HTML and Portable Text
|
|
||||||
* Maintains compatibility with existing sanitization strategy
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PortableTextBlock } from '@portabletext/types';
|
|
||||||
import type { CustomPortableTextBlock } from './schema';
|
|
||||||
import { createTextBlock, createImageBlock } from './schema';
|
|
||||||
import { sanitizeHtmlSync } from '../sanitization';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert HTML to Portable Text
|
|
||||||
* This maintains backward compatibility with existing HTML content
|
|
||||||
*/
|
|
||||||
export function htmlToPortableText(html: string): CustomPortableTextBlock[] {
|
|
||||||
if (!html || html.trim() === '') {
|
|
||||||
return [createTextBlock()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// First sanitize the HTML using existing strategy
|
|
||||||
const sanitizedHtml = sanitizeHtmlSync(html);
|
|
||||||
|
|
||||||
// Parse the sanitized HTML into Portable Text blocks
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
|
|
||||||
|
|
||||||
const blocks: CustomPortableTextBlock[] = [];
|
|
||||||
|
|
||||||
// Process each child element in the body
|
|
||||||
const walker = doc.createTreeWalker(
|
|
||||||
doc.body,
|
|
||||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
let currentBlock: PortableTextBlock | null = null;
|
|
||||||
let node = walker.nextNode();
|
|
||||||
|
|
||||||
while (node) {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element;
|
|
||||||
|
|
||||||
// Handle block-level elements
|
|
||||||
if (isBlockElement(element.tagName)) {
|
|
||||||
// Finish current block if any
|
|
||||||
if (currentBlock) {
|
|
||||||
blocks.push(currentBlock);
|
|
||||||
currentBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle images separately
|
|
||||||
if (element.tagName === 'IMG') {
|
|
||||||
const img = element as HTMLImageElement;
|
|
||||||
blocks.push(createImageBlock(
|
|
||||||
img.src,
|
|
||||||
img.alt,
|
|
||||||
img.title || undefined
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// Create new block for this element
|
|
||||||
const style = getBlockStyle(element.tagName);
|
|
||||||
const text = element.textContent || '';
|
|
||||||
currentBlock = createTextBlock(text, style);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle inline elements - add to current block
|
|
||||||
if (!currentBlock) {
|
|
||||||
currentBlock = createTextBlock();
|
|
||||||
}
|
|
||||||
// Inline elements are handled by processing their text content
|
|
||||||
// Mark handling would go here for future enhancement
|
|
||||||
}
|
|
||||||
} else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
|
||||||
// Handle text nodes
|
|
||||||
if (!currentBlock) {
|
|
||||||
currentBlock = createTextBlock();
|
|
||||||
}
|
|
||||||
// Text content is already included in the parent element processing
|
|
||||||
}
|
|
||||||
|
|
||||||
node = walker.nextNode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add final block if any
|
|
||||||
if (currentBlock) {
|
|
||||||
blocks.push(currentBlock);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no blocks were created, return empty content
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
return [createTextBlock()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Portable Text to HTML
|
|
||||||
* This ensures compatibility with existing backend processing
|
|
||||||
*/
|
|
||||||
export function portableTextToHtml(blocks: CustomPortableTextBlock[]): string {
|
|
||||||
if (!blocks || blocks.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlParts: string[] = [];
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block._type === 'block') {
|
|
||||||
const portableBlock = block as PortableTextBlock;
|
|
||||||
const tag = getHtmlTag(portableBlock.style || 'normal');
|
|
||||||
const text = extractTextFromBlock(portableBlock);
|
|
||||||
|
|
||||||
if (text.trim() || portableBlock.style !== 'normal') {
|
|
||||||
htmlParts.push(`<${tag}>${text}</${tag}>`);
|
|
||||||
}
|
|
||||||
} else if (block._type === 'image') {
|
|
||||||
const imgBlock = block as any; // Type assertion for custom image block
|
|
||||||
const alt = imgBlock.alt ? ` alt="${escapeHtml(imgBlock.alt)}"` : '';
|
|
||||||
const title = imgBlock.caption ? ` title="${escapeHtml(imgBlock.caption)}"` : '';
|
|
||||||
htmlParts.push(`<img src="${escapeHtml(imgBlock.src)}"${alt}${title} />`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = htmlParts.join('\n');
|
|
||||||
|
|
||||||
// Apply final sanitization to ensure security
|
|
||||||
return sanitizeHtmlSync(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract plain text from a Portable Text block
|
|
||||||
*/
|
|
||||||
function extractTextFromBlock(block: PortableTextBlock): string {
|
|
||||||
if (!block.children) return '';
|
|
||||||
|
|
||||||
return block.children
|
|
||||||
.map(child => {
|
|
||||||
if (child._type === 'span') {
|
|
||||||
return child.text || '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if an HTML tag is a block-level element
|
|
||||||
*/
|
|
||||||
function isBlockElement(tagName: string): boolean {
|
|
||||||
const blockElements = [
|
|
||||||
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
||||||
'BLOCKQUOTE', 'UL', 'OL', 'LI', 'IMG', 'BR'
|
|
||||||
];
|
|
||||||
return blockElements.includes(tagName.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Portable Text block style from HTML tag
|
|
||||||
*/
|
|
||||||
function getBlockStyle(tagName: string): string {
|
|
||||||
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.toUpperCase()] || 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get HTML tag from Portable Text block style
|
|
||||||
*/
|
|
||||||
function getHtmlTag(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';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML entities
|
|
||||||
*/
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple HTML parsing for converting existing content
|
|
||||||
* This is a basic implementation - could be enhanced with more sophisticated parsing
|
|
||||||
*/
|
|
||||||
export function parseHtmlToBlocks(html: string): CustomPortableTextBlock[] {
|
|
||||||
if (!html || html.trim() === '') {
|
|
||||||
return [createTextBlock()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize first
|
|
||||||
const sanitizedHtml = sanitizeHtmlSync(html);
|
|
||||||
|
|
||||||
// Split by block-level elements and convert
|
|
||||||
const blocks: CustomPortableTextBlock[] = [];
|
|
||||||
|
|
||||||
// Simple regex-based parsing for common elements
|
|
||||||
const blockElements = sanitizedHtml.split(/(<\/?(?:p|div|h[1-6]|blockquote|img)[^>]*>)/i)
|
|
||||||
.filter(part => part.trim().length > 0);
|
|
||||||
|
|
||||||
let currentText = '';
|
|
||||||
let currentStyle = 'normal';
|
|
||||||
|
|
||||||
for (const part of blockElements) {
|
|
||||||
if (part.match(/^<(h[1-6]|p|div|blockquote)/i)) {
|
|
||||||
// Start of block element
|
|
||||||
const match = part.match(/^<(h[1-6]|p|div|blockquote)/i);
|
|
||||||
if (match) {
|
|
||||||
currentStyle = getBlockStyle(match[1]);
|
|
||||||
}
|
|
||||||
} else if (part.match(/^<img/i)) {
|
|
||||||
// Image element
|
|
||||||
const srcMatch = part.match(/src=['"']([^'"']+)['"']/);
|
|
||||||
const altMatch = part.match(/alt=['"']([^'"']+)['"']/);
|
|
||||||
const titleMatch = part.match(/title=['"']([^'"']+)['"']/);
|
|
||||||
|
|
||||||
if (srcMatch) {
|
|
||||||
blocks.push(createImageBlock(
|
|
||||||
srcMatch[1],
|
|
||||||
altMatch?.[1],
|
|
||||||
titleMatch?.[1]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else if (part.match(/^<\//)) {
|
|
||||||
// End tag - finalize current block
|
|
||||||
if (currentText.trim()) {
|
|
||||||
blocks.push(createTextBlock(currentText.trim(), currentStyle));
|
|
||||||
currentText = '';
|
|
||||||
currentStyle = 'normal';
|
|
||||||
}
|
|
||||||
} else if (!part.match(/^</)) {
|
|
||||||
// Text content
|
|
||||||
currentText += part;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle remaining text
|
|
||||||
if (currentText.trim()) {
|
|
||||||
blocks.push(createTextBlock(currentText.trim(), currentStyle));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no blocks created, return empty block
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
return [createTextBlock()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to generate unique keys
|
|
||||||
function generateKey(): string {
|
|
||||||
return Math.random().toString(36).substr(2, 9);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* Portable Text Editor Schema Definition
|
|
||||||
* Defines the structure and capabilities of the editor
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineSchema } from '@portabletext/editor';
|
|
||||||
import type { SchemaDefinition } from '@portabletext/editor';
|
|
||||||
|
|
||||||
export const editorSchema: SchemaDefinition = defineSchema({
|
|
||||||
// Text decorators (inline formatting)
|
|
||||||
decorators: [
|
|
||||||
{ name: 'strong' },
|
|
||||||
{ name: 'em' },
|
|
||||||
{ name: 'underline' },
|
|
||||||
{ name: 'strike' },
|
|
||||||
{ name: 'code' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Block styles (paragraph types)
|
|
||||||
styles: [
|
|
||||||
{ name: 'normal' },
|
|
||||||
{ name: 'h1' },
|
|
||||||
{ name: 'h2' },
|
|
||||||
{ name: 'h3' },
|
|
||||||
{ name: 'h4' },
|
|
||||||
{ name: 'h5' },
|
|
||||||
{ name: 'h6' },
|
|
||||||
{ name: 'blockquote' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// List types
|
|
||||||
lists: [
|
|
||||||
{ name: 'bullet' },
|
|
||||||
{ name: 'number' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Annotations (links, etc.)
|
|
||||||
annotations: [
|
|
||||||
{
|
|
||||||
name: 'link',
|
|
||||||
type: 'object',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'href',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Block objects (custom content types)
|
|
||||||
blockObjects: [
|
|
||||||
{
|
|
||||||
name: 'image',
|
|
||||||
type: 'object',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'src',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'alt',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'caption',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'width',
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'height',
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'codeBlock',
|
|
||||||
type: 'object',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'code',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'language',
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Type exports for use in components
|
|
||||||
export type EditorSchema = typeof editorSchema;
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* Portable Text schema definition matching current RichTextEditor functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
PortableTextBlock,
|
|
||||||
ArbitraryTypedObject,
|
|
||||||
PortableTextMarkDefinition,
|
|
||||||
PortableTextSpan
|
|
||||||
} from '@portabletext/types';
|
|
||||||
|
|
||||||
// Define custom marks (inline formatting)
|
|
||||||
export interface StrongMark extends PortableTextMarkDefinition {
|
|
||||||
_type: 'strong';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmMark extends PortableTextMarkDefinition {
|
|
||||||
_type: 'em';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnderlineMark extends PortableTextMarkDefinition {
|
|
||||||
_type: 'underline';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StrikeMark extends PortableTextMarkDefinition {
|
|
||||||
_type: 'strike';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodeMark extends PortableTextMarkDefinition {
|
|
||||||
_type: 'code';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom block types for images (future enhancement)
|
|
||||||
export interface ImageBlock extends ArbitraryTypedObject {
|
|
||||||
_type: 'image';
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
caption?: string;
|
|
||||||
isProcessing?: boolean;
|
|
||||||
originalUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the schema configuration
|
|
||||||
export const portableTextSchema = {
|
|
||||||
// Block styles (paragraph, headings)
|
|
||||||
styles: [
|
|
||||||
{ title: 'Normal', value: 'normal' },
|
|
||||||
{ title: 'Heading 1', value: 'h1' },
|
|
||||||
{ title: 'Heading 2', value: 'h2' },
|
|
||||||
{ title: 'Heading 3', value: 'h3' },
|
|
||||||
{ title: 'Heading 4', value: 'h4' },
|
|
||||||
{ title: 'Heading 5', value: 'h5' },
|
|
||||||
{ title: 'Heading 6', value: 'h6' },
|
|
||||||
{ title: 'Quote', value: 'blockquote' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// List types
|
|
||||||
lists: [
|
|
||||||
{ title: 'Bullet', value: 'bullet' },
|
|
||||||
{ title: 'Number', value: 'number' },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Marks (inline formatting)
|
|
||||||
marks: {
|
|
||||||
// Decorators
|
|
||||||
decorators: [
|
|
||||||
{ title: 'Strong', value: 'strong' },
|
|
||||||
{ title: 'Emphasis', value: 'em' },
|
|
||||||
{ title: 'Underline', value: 'underline' },
|
|
||||||
{ title: 'Strike', value: 'strike' },
|
|
||||||
{ title: 'Code', value: 'code' },
|
|
||||||
],
|
|
||||||
// Annotations (links, etc.)
|
|
||||||
annotations: [
|
|
||||||
{
|
|
||||||
title: 'URL',
|
|
||||||
name: 'link',
|
|
||||||
type: 'object',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
title: 'URL',
|
|
||||||
name: 'href',
|
|
||||||
type: 'url',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Custom block types
|
|
||||||
blockTypes: [
|
|
||||||
{
|
|
||||||
title: 'Image',
|
|
||||||
name: 'image',
|
|
||||||
type: 'object',
|
|
||||||
fields: [
|
|
||||||
{ name: 'src', type: 'string', title: 'Image URL' },
|
|
||||||
{ name: 'alt', type: 'string', title: 'Alt Text' },
|
|
||||||
{ name: 'caption', type: 'string', title: 'Caption' },
|
|
||||||
{ name: 'isProcessing', type: 'boolean', title: 'Processing' },
|
|
||||||
{ name: 'originalUrl', type: 'string', title: 'Original URL' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Type definitions for our Portable Text content
|
|
||||||
export type CustomPortableTextBlock = PortableTextBlock | ImageBlock;
|
|
||||||
|
|
||||||
export type CustomMarkDefinition =
|
|
||||||
| StrongMark
|
|
||||||
| EmMark
|
|
||||||
| UnderlineMark
|
|
||||||
| StrikeMark
|
|
||||||
| CodeMark;
|
|
||||||
|
|
||||||
export type CustomPortableTextSpan = PortableTextSpan & {
|
|
||||||
marks?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to create a basic block
|
|
||||||
export function createTextBlock(
|
|
||||||
text: string = '',
|
|
||||||
style: string = 'normal'
|
|
||||||
): PortableTextBlock {
|
|
||||||
return {
|
|
||||||
_type: 'block',
|
|
||||||
_key: generateKey(),
|
|
||||||
style,
|
|
||||||
markDefs: [],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
_type: 'span',
|
|
||||||
_key: generateKey(),
|
|
||||||
text,
|
|
||||||
marks: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create an image block
|
|
||||||
export function createImageBlock(
|
|
||||||
src: string,
|
|
||||||
alt?: string,
|
|
||||||
caption?: string,
|
|
||||||
isProcessing?: boolean,
|
|
||||||
originalUrl?: string
|
|
||||||
): ImageBlock {
|
|
||||||
return {
|
|
||||||
_type: 'image',
|
|
||||||
_key: generateKey(),
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
caption,
|
|
||||||
isProcessing,
|
|
||||||
originalUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to generate unique keys
|
|
||||||
function generateKey(): string {
|
|
||||||
return Math.random().toString(36).substr(2, 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default empty content
|
|
||||||
export const emptyPortableTextContent: CustomPortableTextBlock[] = [
|
|
||||||
createTextBlock('', 'normal')
|
|
||||||
];
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -85,12 +85,6 @@
|
|||||||
<str name="hl.simple.post"></em></str>
|
<str name="hl.simple.post"></em></str>
|
||||||
<str name="hl.fragsize">150</str>
|
<str name="hl.fragsize">150</str>
|
||||||
<str name="hl.maxAnalyzedChars">51200</str>
|
<str name="hl.maxAnalyzedChars">51200</str>
|
||||||
<str name="facet">true</str>
|
|
||||||
<str name="facet.field">authorRating</str>
|
|
||||||
<str name="facet.range">averageStoryRating</str>
|
|
||||||
<str name="facet.range">storyCount</str>
|
|
||||||
<str name="facet.mincount">1</str>
|
|
||||||
<str name="facet.sort">count</str>
|
|
||||||
</lst>
|
</lst>
|
||||||
</requestHandler>
|
</requestHandler>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user