Image Handling

This commit is contained in:
Stefan Hardegger
2025-10-09 14:39:55 +02:00
parent 4e02cd8eaa
commit 20d0652c85
6 changed files with 390 additions and 244 deletions

View File

@@ -35,12 +35,11 @@ interface SlateEditorProps {
// Custom types for our editor
type CustomElement = {
type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'blockquote' | 'image' | 'code-block';
type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'image';
children: CustomText[];
src?: string; // for images
alt?: string; // for images
caption?: string; // for images
language?: string; // for code blocks
};
type CustomText = {
@@ -49,7 +48,6 @@ type CustomText = {
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: boolean;
};
declare module 'slate' {
@@ -100,12 +98,19 @@ const htmlToSlate = (html: string): Descendant[] => {
});
break;
case 'blockquote':
results.push({
type: 'blockquote',
children: [{ text: element.textContent || '' }]
});
case 'pre':
case 'code': {
// Filter out blockquotes, code blocks, and code - convert to paragraph
const text = element.textContent || '';
if (text.trim()) {
results.push({
type: 'paragraph',
children: [{ text: text.trim() }]
});
}
break;
case 'img':
}
case 'img': {
const img = element as HTMLImageElement;
results.push({
type: 'image',
@@ -115,18 +120,9 @@ const htmlToSlate = (html: string): Descendant[] => {
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':
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
@@ -141,6 +137,7 @@ const htmlToSlate = (html: string): Descendant[] => {
}
}
break;
}
case 'br':
// Handle line breaks by creating empty paragraphs
results.push({
@@ -148,7 +145,7 @@ const htmlToSlate = (html: string): Descendant[] => {
children: [{ text: '' }]
});
break;
default:
default: {
// For other elements, try to extract text or recurse
const text = element.textContent || '';
if (text.trim()) {
@@ -158,6 +155,7 @@ const htmlToSlate = (html: string): Descendant[] => {
});
}
break;
}
}
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
@@ -210,9 +208,6 @@ const slateToHtml = (nodes: Descendant[]): string => {
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}"`);
@@ -220,16 +215,6 @@ const slateToHtml = (nodes: Descendant[]): string => {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
htmlParts.push(`<pre><code${langClass}>${escapedText}</code></pre>`);
break;
case 'paragraph':
default:
htmlParts.push(text ? `<p>${text}</p>` : '<p></p>');
@@ -500,8 +485,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
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
@@ -510,12 +493,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => {
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>;
}
@@ -541,16 +518,12 @@ const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
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';
type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough';
const isMarkActive = (format: MarkFormat) => {
const marks = Editor.marks(editor);
@@ -627,7 +600,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('paragraph')}
className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''}
title="Normal paragraph"
title="Normal paragraph (Ctrl+Shift+0)"
>
P
</Button>
@@ -637,7 +610,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-one')}
className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 1"
title="Heading 1 (Ctrl+Shift+1)"
>
H1
</Button>
@@ -647,7 +620,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-two')}
className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 2"
title="Heading 2 (Ctrl+Shift+2)"
>
H2
</Button>
@@ -657,7 +630,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleBlock('heading-three')}
className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`}
title="Heading 3"
title="Heading 3 (Ctrl+Shift+3)"
>
H3
</Button>
@@ -691,7 +664,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleMark('underline')}
className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`}
title="Underline"
title="Underline (Ctrl+U)"
>
U
</Button>
@@ -701,7 +674,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => {
variant="ghost"
onClick={() => toggleMark('strikethrough')}
className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`}
title="Strike-through"
title="Strikethrough (Ctrl+D)"
>
S
</Button>
@@ -826,49 +799,126 @@ export default function SlateEditor({
// Handle keyboard shortcuts
if (!event.ctrlKey && !event.metaKey) return;
// Helper function to toggle marks
const toggleMarkShortcut = (format: 'bold' | 'italic' | 'underline' | 'strikethrough') => {
event.preventDefault();
const marks = Editor.marks(editor);
const isActive = marks ? marks[format] === true : false;
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
// Helper function to toggle blocks
const toggleBlockShortcut = (format: CustomElement['type']) => {
event.preventDefault();
const isActive = isBlockActive(format);
Transforms.setNodes(
editor,
{ type: isActive ? 'paragraph' : format },
{ match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) }
);
};
// Check if block is active
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;
};
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);
// Text formatting shortcuts
case 'b':
toggleMarkShortcut('bold');
break;
case 'i':
toggleMarkShortcut('italic');
break;
case 'u':
toggleMarkShortcut('underline');
break;
case 'd':
// Ctrl+D for strikethrough
toggleMarkShortcut('strikethrough');
break;
// Block formatting shortcuts
case '1':
if (event.shiftKey) {
// Ctrl+Shift+1 for H1
toggleBlockShortcut('heading-one');
}
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);
case '2':
if (event.shiftKey) {
// Ctrl+Shift+2 for H2
toggleBlockShortcut('heading-two');
}
break;
}
case 'a': {
// Handle Ctrl+A / Cmd+A to select all
case '3':
if (event.shiftKey) {
// Ctrl+Shift+3 for H3
toggleBlockShortcut('heading-three');
}
break;
case '0':
if (event.shiftKey) {
// Ctrl+Shift+0 for normal paragraph
toggleBlockShortcut('paragraph');
}
break;
// Select all
case 'a':
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">
<div className="text-xs theme-text space-y-1">
<p>
<strong>Slate.js Editor:</strong> Rich text editor with advanced image paste handling.
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
</p>
<details className="text-xs">
<summary className="cursor-pointer hover:theme-accent-text font-medium"> Keyboard Shortcuts</summary>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 p-2 theme-card border theme-border rounded">
<div>
<p className="font-semibold mb-1">Text Formatting:</p>
<ul className="space-y-0.5">
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+B</kbd> Bold</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+I</kbd> Italic</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+U</kbd> Underline</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+D</kbd> Strikethrough</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">Block Formatting:</p>
<ul className="space-y-0.5">
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+0</kbd> Paragraph</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+1</kbd> Heading 1</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+2</kbd> Heading 2</li>
<li><kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs">Ctrl+Shift+3</kbd> Heading 3</li>
</ul>
</div>
</div>
</details>
</div>
<Button