From d48e217cbb5af4a9ac5418ecb1c36fb164e36534 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 24 Jul 2025 13:35:47 +0200 Subject: [PATCH] Enhance Richtext editor --- .../resources/html-sanitization-config.json | 41 +--- .../src/components/stories/RichTextEditor.tsx | 199 +++++++++++------- 2 files changed, 132 insertions(+), 108 deletions(-) diff --git a/backend/src/main/resources/html-sanitization-config.json b/backend/src/main/resources/html-sanitization-config.json index f1bd063..7fc5fb9 100644 --- a/backend/src/main/resources/html-sanitization-config.json +++ b/backend/src/main/resources/html-sanitization-config.json @@ -5,8 +5,7 @@ "sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var", "ul", "ol", "li", "dl", "dt", "dd", "a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", - "blockquote", "cite", "q", "hr", "details", "summary", - "section", "article", "font", "center", "abbr", "dfn", "tt" + "blockquote", "cite", "q", "hr", "details", "summary" ], "allowedAttributes": { "p": ["class", "style"], @@ -33,44 +32,12 @@ "pre": ["class", "style"], "code": ["class", "style"], "details": ["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"] + "summary": ["class", "style"] }, "allowedCssProperties": [ - "color", "background-color", "font-size", "font-weight", + "color", "font-size", "font-weight", "font-style", "text-align", "text-decoration", "margin", - "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" + "padding", "text-indent", "line-height" ], "removedAttributes": { "a": ["href", "target"] diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index 7935f35..7a33cc4 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -22,6 +22,7 @@ export default function RichTextEditor({ const [htmlValue, setHtmlValue] = useState(value); const previewRef = useRef(null); const visualTextareaRef = useRef(null); + const visualDivRef = useRef(null); // Preload sanitization config useEffect(() => { @@ -45,7 +46,16 @@ export default function RichTextEditor({ setHtmlValue(htmlContent); }; - const handlePaste = async (e: React.ClipboardEvent) => { + const handleVisualContentChange = () => { + const div = visualDivRef.current; + if (div) { + const newHtml = div.innerHTML; + onChange(newHtml); + setHtmlValue(newHtml); + } + }; + + const handlePaste = async (e: React.ClipboardEvent) => { 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.replace(/\n/g, '
')}

`).join('\n') : ''; const afterHtml = afterCursor ? afterCursor.split('\n\n').filter(p => p.trim()).map(p => `

${p.replace(/\n/g, '
')}

`).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 => `

${paragraph.replace(/\n/g, '
')}

`) + .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 => `

${paragraph.replace(/\n/g, '
')}

`) - .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}`; + 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 ? `

${beforeText.replace(/\n\n/g, '

').replace(/\n/g, '
')}

` : ''; - const afterHtml = afterText ? `

${afterText.replace(/\n\n/g, '

').replace(/\n/g, '
')}

` : ''; - - const newHtmlValue = beforeHtml + formattedHtml + afterHtml; - setHtmlValue(newHtmlValue); - onChange(newHtmlValue); - } else { - // No selection - insert template - const template = tag === 'h1' ? '

Heading 1

' : - tag === 'h2' ? '

Heading 2

' : - tag === 'h3' ? '

Heading 3

' : - `<${tag}>Formatted text`; - - 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 */}
{viewMode === 'visual' ? ( -