Image Handling
This commit is contained in:
@@ -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, '&')
|
||||
.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>');
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user