From 131e2e8c252e60beecfbe56abbc00df8043246a8 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 24 Jul 2025 13:07:36 +0200 Subject: [PATCH] Bugfixes --- .../com/storycove/service/AuthorService.java | 5 +- .../src/components/stories/RichTextEditor.tsx | 134 ++++++++++++++---- frontend/src/lib/sanitization.ts | 27 +++- 3 files changed, 134 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/com/storycove/service/AuthorService.java b/backend/src/main/java/com/storycove/service/AuthorService.java index a6632d0..e2f8ce0 100644 --- a/backend/src/main/java/com/storycove/service/AuthorService.java +++ b/backend/src/main/java/com/storycove/service/AuthorService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -339,8 +340,10 @@ public class AuthorService { existing.setAuthorRating(updates.getAuthorRating()); } if (updates.getUrls() != null) { + // Create a defensive copy to avoid issues when existing and updates are the same object + List urlsCopy = new ArrayList<>(updates.getUrls()); existing.getUrls().clear(); - existing.getUrls().addAll(updates.getUrls()); + existing.getUrls().addAll(urlsCopy); } } } \ No newline at end of file diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index b15cae0..62fda84 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -47,18 +47,35 @@ export default function RichTextEditor({ e.preventDefault(); try { - // Try to get HTML content from clipboard - const items = e.clipboardData?.items; + // Try multiple approaches to get clipboard data + const clipboardData = e.clipboardData; let htmlContent = ''; let plainText = ''; - if (items) { - for (const item of Array.from(items)) { - if (item.type === 'text/html') { + // Method 1: Try direct getData calls first (more reliable) + try { + htmlContent = clipboardData.getData('text/html'); + plainText = clipboardData.getData('text/plain'); + console.log('Paste debug - Direct method:'); + console.log('HTML length:', htmlContent.length); + console.log('HTML preview:', htmlContent.substring(0, 200)); + console.log('Plain text length:', plainText.length); + } catch (e) { + console.log('Direct getData failed:', e); + } + + // Method 2: If direct method didn't work, try items approach + if (!htmlContent && clipboardData?.items) { + console.log('Trying items approach...'); + const items = Array.from(clipboardData.items); + console.log('Available clipboard types:', items.map(item => item.type)); + + for (const item of items) { + if (item.type === 'text/html' && !htmlContent) { htmlContent = await new Promise((resolve) => { item.getAsString(resolve); }); - } else if (item.type === 'text/plain') { + } else if (item.type === 'text/plain' && !plainText) { plainText = await new Promise((resolve) => { item.getAsString(resolve); }); @@ -66,33 +83,98 @@ export default function RichTextEditor({ } } - // If we have HTML content, sanitize it and merge with current content - if (htmlContent) { + console.log('Final clipboard data:'); + console.log('HTML content length:', htmlContent.length); + console.log('Plain text length:', plainText.length); + + // Additional debugging for clipboard types and content + if (clipboardData?.types) { + console.log('Clipboard types available:', clipboardData.types); + for (const type of clipboardData.types) { + try { + const data = clipboardData.getData(type); + console.log(`Type "${type}" content length:`, data.length); + if (data.length > 0 && data.length < 1000) { + console.log(`Type "${type}" content:`, data); + } + } catch (e) { + console.log(`Failed to get data for type "${type}":`, e); + } + } + } + + // Process HTML content if available + if (htmlContent && htmlContent.trim().length > 0) { + console.log('Processing HTML content...'); + console.log('Raw HTML:', htmlContent.substring(0, 500)); + const sanitizedHtml = sanitizeHtmlSync(htmlContent); + console.log('Sanitized HTML:', sanitizedHtml.substring(0, 500)); - // Simply append the sanitized HTML to current content - // This approach maintains the HTML formatting while being simpler - const newHtmlValue = value + sanitizedHtml; - - onChange(newHtmlValue); - setHtmlValue(newHtmlValue); - } else if (plainText) { - // For plain text, convert to paragraphs and append - const textAsHtml = plainText - .split('\n\n') - .filter(paragraph => paragraph.trim()) - .map(paragraph => `

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

`) - .join('\n'); - - const newHtmlValue = value + textAsHtml; - onChange(newHtmlValue); - setHtmlValue(newHtmlValue); + // Insert at cursor position instead of just appending + const textarea = visualTextareaRef.current; + if (textarea) { + 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); + + 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); + } + } else { + console.log('No usable clipboard content found'); } } catch (error) { console.error('Error handling paste:', error); // Fallback to default paste behavior const plainText = e.clipboardData.getData('text/plain'); - handleVisualChange({ target: { value: plainText } } as React.ChangeEvent); + if (plainText) { + const textAsHtml = plainText + .split('\n\n') + .filter(paragraph => paragraph.trim()) + .map(paragraph => `

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

`) + .join('\n'); + onChange(value + textAsHtml); + setHtmlValue(value + textAsHtml); + } } }; diff --git a/frontend/src/lib/sanitization.ts b/frontend/src/lib/sanitization.ts index ce86522..ab04faa 100644 --- a/frontend/src/lib/sanitization.ts +++ b/frontend/src/lib/sanitization.ts @@ -165,20 +165,37 @@ export function sanitizeHtmlSync(html: string): string { 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', + // Basic block elements + 'p', 'br', 'div', 'span', 'section', 'article', + // Headers + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + // Text formatting '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', + 'sup', 'sub', 'small', 'big', 'mark', 'abbr', 'dfn', + // Code and preformatted + 'pre', 'code', 'kbd', 'samp', 'var', 'tt', + // Lists + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', + // Links (but href will be removed by backend config) + 'a', + // Tables 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', - 'blockquote', 'cite', 'q', 'hr', 'details', 'summary' + // Quotes and misc + 'blockquote', 'cite', 'q', 'hr', 'details', 'summary', + // Common website elements that might have formatting + 'font', 'center' ], ALLOWED_ATTR: [ - 'class', 'style', 'colspan', 'rowspan' + 'class', 'style', 'colspan', 'rowspan', 'align', 'valign', + // Font attributes (though deprecated, websites still use them) + 'color', 'size', 'face' ], ALLOW_UNKNOWN_PROTOCOLS: false, SANITIZE_DOM: true, KEEP_CONTENT: true, ALLOW_DATA_ATTR: false, + // Don't strip style attributes completely - let them through for basic formatting + FORBID_ATTR: [], }; return DOMPurify.sanitize(html, fallbackConfig as any).toString();