From 90428894b4f4863925941311d401bc293440dd2f Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 24 Jul 2025 12:34:27 +0200 Subject: [PATCH] Improve Richtext Editor --- .../src/components/stories/RichTextEditor.tsx | 188 +++++++++++++----- frontend/src/lib/sanitization.ts | 56 +++++- 2 files changed, 184 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index 13dd396..b15cae0 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -114,26 +114,84 @@ export default function RichTextEditor({ const formatText = (tag: string) => { if (viewMode === 'visual') { - // For visual mode, we'll just show formatting helpers - // In a real implementation, you'd want a proper WYSIWYG editor - return; - } + // For visual mode, we'll insert formatted HTML directly + const textarea = visualTextareaRef.current; + if (!textarea) return; - const textarea = document.querySelector('textarea') as HTMLTextAreaElement; - if (!textarea) return; - - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const selectedText = htmlValue.substring(start, end); - - if (selectedText) { - const beforeText = htmlValue.substring(0, start); - const afterText = htmlValue.substring(end); - const formattedText = `<${tag}>${selectedText}`; - const newValue = beforeText + formattedText + afterText; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = getPlainText(value).substring(start, end); - setHtmlValue(newValue); - onChange(newValue); + if (selectedText) { + // Create formatted HTML + const formattedHtml = `<${tag}>${selectedText}`; + + // Get current plain text + const currentPlainText = getPlainText(value); + const beforeText = currentPlainText.substring(0, start); + const afterText = currentPlainText.substring(end); + + // 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); + } + } else { + // HTML mode - existing logic with improvements + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = htmlValue.substring(start, end); + + if (selectedText) { + const beforeText = htmlValue.substring(0, start); + const afterText = htmlValue.substring(end); + const formattedText = `<${tag}>${selectedText}`; + const newValue = beforeText + formattedText + afterText; + + setHtmlValue(newValue); + onChange(newValue); + + // Restore cursor position + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start, start + formattedText.length); + }, 0); + } else { + // No selection - insert template at cursor + const template = tag === 'h1' ? '

Heading 1

' : + tag === 'h2' ? '

Heading 2

' : + tag === 'h3' ? '

Heading 3

' : + `<${tag}>Formatted text`; + + const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start); + setHtmlValue(newValue); + onChange(newValue); + + // Position cursor inside the new tag + setTimeout(() => { + const tagLength = `<${tag}>`.length; + const newPosition = start + tagLength; + textarea.focus(); + textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length)); + }, 0); + } } }; @@ -162,37 +220,69 @@ export default function RichTextEditor({ - {viewMode === 'html' && ( -
- - - -
- )} +
+ + +
+ + + +
+ +
{/* Editor */} diff --git a/frontend/src/lib/sanitization.ts b/frontend/src/lib/sanitization.ts index 5737adb..ce86522 100644 --- a/frontend/src/lib/sanitization.ts +++ b/frontend/src/lib/sanitization.ts @@ -94,18 +94,22 @@ function createDOMPurifyConfig(config: SanitizationConfig) { }); } - return { + // Create a proper DOMPurify configuration + // DOMPurify expects ALLOWED_ATTR to be an array of allowed attributes + // We need to flatten the tag-specific attributes into a global list + const flattenedAttributes = Object.values(allowedAttributes).flat(); + const uniqueAttributes = Array.from(new Set(flattenedAttributes)); + + const domPurifyConfig: DOMPurify.Config = { ALLOWED_TAGS: allowedTags, - ALLOWED_ATTR: Object.keys(allowedAttributes).reduce((acc: string[], tag) => { - return [...acc, ...allowedAttributes[tag]]; - }, []), - // Allow style attribute but sanitize CSS properties + ALLOWED_ATTR: uniqueAttributes, ALLOW_UNKNOWN_PROTOCOLS: false, SANITIZE_DOM: true, KEEP_CONTENT: true, - // Custom hook to sanitize style attributes ALLOW_DATA_ATTR: false, - } as DOMPurify.Config; + }; + + return domPurifyConfig; } /** @@ -133,21 +137,51 @@ export async function sanitizeHtml(html: string): Promise { /** * Synchronous sanitization using cached config (for cases where async is not possible) - * Falls back to basic DOMPurify if no config is cached + * Falls back to a safe configuration if no config is cached */ export function sanitizeHtmlSync(html: string): string { if (!html || html.trim() === '') { return ''; } + // If we have cached config, use it if (cachedConfig) { const domPurifyConfig = createDOMPurifyConfig(cachedConfig); return DOMPurify.sanitize(html, domPurifyConfig as any).toString(); } - // Fallback to basic DOMPurify - console.warn('No cached sanitization config available, using DOMPurify defaults'); - return DOMPurify.sanitize(html).toString(); + // If we don't have cached config but there's an ongoing request, wait for it + if (configPromise) { + console.log('Sanitization config loading in progress, using fallback for now'); + } else { + // No config and no ongoing request - try to load it for next time + console.warn('No cached sanitization config available, triggering load for future use'); + fetchSanitizationConfig().catch(error => { + console.error('Failed to load sanitization config:', error); + }); + } + + // Use comprehensive fallback configuration that preserves formatting + console.log('Using fallback sanitization configuration with formatting support'); + const fallbackConfig: DOMPurify.Config = { + ALLOWED_TAGS: [ + 'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins', + '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' + ], + ALLOWED_ATTR: [ + 'class', 'style', 'colspan', 'rowspan' + ], + ALLOW_UNKNOWN_PROTOCOLS: false, + SANITIZE_DOM: true, + KEEP_CONTENT: true, + ALLOW_DATA_ATTR: false, + }; + + return DOMPurify.sanitize(html, fallbackConfig as any).toString(); } /**