Enhance Richtext editor

This commit is contained in:
Stefan Hardegger
2025-07-24 13:35:47 +02:00
parent aae6091ef4
commit d48e217cbb
2 changed files with 132 additions and 108 deletions

View File

@@ -22,6 +22,7 @@ export default function RichTextEditor({
const [htmlValue, setHtmlValue] = useState(value);
const previewRef = useRef<HTMLDivElement>(null);
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
const visualDivRef = useRef<HTMLDivElement>(null);
// Preload sanitization config
useEffect(() => {
@@ -45,7 +46,16 @@ export default function RichTextEditor({
setHtmlValue(htmlContent);
};
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const handleVisualContentChange = () => {
const div = visualDivRef.current;
if (div) {
const newHtml = div.innerHTML;
onChange(newHtml);
setHtmlValue(newHtml);
}
};
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
if (viewMode !== 'visual') return;
e.preventDefault();
@@ -123,53 +133,92 @@ export default function RichTextEditor({
console.warn('Sanitization removed >90% of content - this might be too aggressive');
}
// Insert at cursor position instead of just appending
// Insert HTML directly into contentEditable div or at cursor in textarea
const visualDiv = visualDivRef.current;
const textarea = visualTextareaRef.current;
if (textarea) {
if (visualDiv && viewMode === 'visual') {
// For contentEditable div, insert HTML at current selection
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
// Create a temporary container to parse the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = sanitizedHtml;
// Insert the nodes from the temporary container
while (tempDiv.firstChild) {
range.insertNode(tempDiv.firstChild);
}
// Move cursor to end of inserted content
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection, append to end
visualDiv.innerHTML += sanitizedHtml;
}
// Update the state
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
} else if (textarea) {
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentPlainText = getPlainText(value);
// Split current content at cursor position
const beforeCursor = currentPlainText.substring(0, start);
const afterCursor = currentPlainText.substring(end);
// Convert plain text parts back to HTML
const beforeHtml = beforeCursor ? beforeCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('\n') : '';
const newHtmlValue = beforeHtml + (beforeHtml ? '\n' : '') + sanitizedHtml + (afterHtml ? '\n' : '') + afterHtml;
onChange(newHtmlValue);
setHtmlValue(newHtmlValue);
} else {
// Fallback: just append
const newHtmlValue = value + sanitizedHtml;
onChange(newHtmlValue);
setHtmlValue(newHtmlValue);
}
} else if (plainText && plainText.trim().length > 0) {
console.log('Processing plain text content...');
// For plain text, convert to paragraphs and insert at cursor
const textarea = visualTextareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentPlainText = getPlainText(value);
// For plain text, insert directly into contentEditable div
const visualDiv = visualDivRef.current;
if (visualDiv) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
// Split plain text into paragraphs and insert as HTML
const paragraphs = plainText.split('\n\n').filter(p => p.trim());
paragraphs.forEach((paragraph, index) => {
if (index > 0) {
// Add some spacing between paragraphs
range.insertNode(document.createElement('br'));
}
const p = document.createElement('p');
p.textContent = paragraph.replace(/\n/g, ' ');
range.insertNode(p);
});
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection, append to end
const textAsHtml = plainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
visualDiv.innerHTML += textAsHtml;
}
const beforeCursor = currentPlainText.substring(0, start);
const afterCursor = currentPlainText.substring(end);
const newPlainText = beforeCursor + plainText + afterCursor;
// Convert to HTML
const textAsHtml = newPlainText
.split('\n\n')
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
onChange(textAsHtml);
setHtmlValue(textAsHtml);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
}
} else {
console.log('No usable clipboard content found');
@@ -208,41 +257,48 @@ export default function RichTextEditor({
const formatText = (tag: string) => {
if (viewMode === 'visual') {
// For visual mode, we'll insert formatted HTML directly
const textarea = visualTextareaRef.current;
if (!textarea) return;
const visualDiv = visualDivRef.current;
if (!visualDiv) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = getPlainText(value).substring(start, end);
if (selectedText) {
// Create formatted HTML
const formattedHtml = `<${tag}>${selectedText}</${tag}>`;
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const selectedText = range.toString();
// Get current plain text
const currentPlainText = getPlainText(value);
const beforeText = currentPlainText.substring(0, start);
const afterText = currentPlainText.substring(end);
if (selectedText) {
// Wrap selected text in the formatting tag
const formattedElement = document.createElement(tag);
formattedElement.textContent = selectedText;
range.deleteContents();
range.insertNode(formattedElement);
// Move cursor to end of inserted content
range.selectNodeContents(formattedElement);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
} else {
// No selection - insert template
const template = tag === 'h1' ? 'Heading 1' :
tag === 'h2' ? 'Heading 2' :
tag === 'h3' ? 'Heading 3' :
'Formatted text';
const formattedElement = document.createElement(tag);
formattedElement.textContent = template;
range.insertNode(formattedElement);
// Select the inserted text for easy editing
range.selectNodeContents(formattedElement);
selection.removeAllRanges();
selection.addRange(range);
}
// Convert back to HTML while preserving existing formatting
// This is a simplified approach - in practice you'd want more sophisticated HTML merging
const beforeHtml = beforeText ? `<p>${beforeText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
const afterHtml = afterText ? `<p>${afterText.replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>` : '';
const newHtmlValue = beforeHtml + formattedHtml + afterHtml;
setHtmlValue(newHtmlValue);
onChange(newHtmlValue);
} else {
// No selection - insert template
const template = tag === 'h1' ? '<h1>Heading 1</h1>' :
tag === 'h2' ? '<h2>Heading 2</h2>' :
tag === 'h3' ? '<h3>Heading 3</h3>' :
`<${tag}>Formatted text</${tag}>`;
const newHtmlValue = value + template;
setHtmlValue(newHtmlValue);
onChange(newHtmlValue);
// Update the state
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
}
} else {
// HTML mode - existing logic with improvements
@@ -382,14 +438,15 @@ export default function RichTextEditor({
{/* Editor */}
<div className="border theme-border rounded-b-lg overflow-hidden">
{viewMode === 'visual' ? (
<Textarea
ref={visualTextareaRef}
value={getPlainText(value)}
onChange={handleVisualChange}
<div
ref={visualDivRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
placeholder={placeholder}
rows={12}
className="border-0 rounded-none focus:ring-0"
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
style={{ minHeight: '300px' }}
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
suppressContentEditableWarning={true}
/>
) : (
<Textarea
@@ -420,11 +477,11 @@ export default function RichTextEditor({
<div className="text-xs theme-text">
<p>
<strong>Visual mode:</strong> Write in plain text or paste formatted content.
Bold, italic, and other basic formatting will be preserved when pasting.
<strong>Visual mode:</strong> WYSIWYG editor - see your formatting as you type.
Paste formatted content from websites and it will preserve styling. Use toolbar buttons for formatting.
</p>
<p>
<strong>HTML mode:</strong> Write HTML directly for advanced formatting.
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
</p>
</div>