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

@@ -5,8 +5,7 @@
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var", "sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
"ul", "ol", "li", "dl", "dt", "dd", "ul", "ol", "li", "dl", "dt", "dd",
"a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
"blockquote", "cite", "q", "hr", "details", "summary", "blockquote", "cite", "q", "hr", "details", "summary"
"section", "article", "font", "center", "abbr", "dfn", "tt"
], ],
"allowedAttributes": { "allowedAttributes": {
"p": ["class", "style"], "p": ["class", "style"],
@@ -33,44 +32,12 @@
"pre": ["class", "style"], "pre": ["class", "style"],
"code": ["class", "style"], "code": ["class", "style"],
"details": ["class", "style"], "details": ["class", "style"],
"summary": ["class", "style"], "summary": ["class", "style"]
"section": ["class", "style"],
"article": ["class", "style"],
"font": ["class", "style", "color", "size", "face"],
"center": ["class", "style"],
"abbr": ["class", "style", "title"],
"dfn": ["class", "style"],
"tt": ["class", "style"],
"b": ["class", "style"],
"strong": ["class", "style"],
"i": ["class", "style"],
"em": ["class", "style"],
"u": ["class", "style"],
"s": ["class", "style"],
"small": ["class", "style"],
"big": ["class", "style"],
"mark": ["class", "style"],
"sup": ["class", "style"],
"sub": ["class", "style"],
"del": ["class", "style"],
"ins": ["class", "style"],
"strike": ["class", "style"],
"kbd": ["class", "style"],
"samp": ["class", "style"],
"var": ["class", "style"]
}, },
"allowedCssProperties": [ "allowedCssProperties": [
"color", "background-color", "font-size", "font-weight", "color", "font-size", "font-weight",
"font-style", "text-align", "text-decoration", "margin", "font-style", "text-align", "text-decoration", "margin",
"padding", "text-indent", "line-height", "padding", "text-indent", "line-height"
"border", "border-color", "border-width", "border-style",
"font-family", "font-variant", "font-variant-ligatures",
"font-variant-caps", "font-variant-numeric", "font-variant-east-asian",
"font-variant-alternates", "font-variant-position", "font-variant-emoji",
"font-stretch", "letter-spacing", "word-spacing",
"text-transform", "text-shadow", "white-space",
"vertical-align", "display", "float", "clear",
"width", "height", "min-width", "min-height", "max-width", "max-height"
], ],
"removedAttributes": { "removedAttributes": {
"a": ["href", "target"] "a": ["href", "target"]

View File

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