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}${tag}>`;
- 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}${tag}>`;
+
+ // 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${tag}>`;
+
+ 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}${tag}>`;
+ 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${tag}>`;
+
+ 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();
}
/**