Neah/components/email/RichEmailEditor.tsx
2025-05-01 17:22:15 +02:00

742 lines
28 KiB
TypeScript

'use client';
import React, { useEffect, useRef, useState } from 'react';
import 'quill/dist/quill.snow.css';
import { sanitizeHtml } from '@/lib/utils/dom-purify-config';
import { detectTextDirection } from '@/lib/utils/text-direction';
import { processHtmlContent } from '@/lib/utils/email-content';
interface RichEmailEditorProps {
initialContent: string;
onChange: (content: string) => void;
placeholder?: string;
minHeight?: string;
maxHeight?: string;
preserveFormatting?: boolean;
}
/**
* Clean up problematic table structures that cause issues with quill-better-table
*/
function cleanupTableStructures(htmlContent: string, isReplyOrForward: boolean = false): string {
if (!htmlContent) return htmlContent;
try {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Find tables with problematic nested structures
const tables = tempDiv.querySelectorAll('table');
// Check if content looks like a forwarded email or reply content
const isForwardedEmail =
htmlContent.includes('---------- Forwarded message ----------') ||
htmlContent.includes('Forwarded message') ||
htmlContent.includes('forwarded message') ||
(htmlContent.includes('From:') && htmlContent.includes('Date:') && htmlContent.includes('Subject:'));
const isReplyEmail =
htmlContent.includes('wrote:') ||
htmlContent.includes('<blockquote') ||
htmlContent.includes('gmail_quote');
// For reply/forward content, force convert ALL tables to divs to avoid Quill errors
const shouldConvertAllTables = isReplyOrForward || isForwardedEmail || isReplyEmail;
if (tables.length > 0) {
console.log(`Found ${tables.length} tables in ${shouldConvertAllTables ? 'reply/forward' : 'regular'} content`);
let convertedCount = 0;
tables.forEach(table => {
// In reply/forward mode, convert ALL tables to divs to avoid quill-better-table issues
if (shouldConvertAllTables) {
const replacementDiv = document.createElement('div');
replacementDiv.className = 'converted-table';
replacementDiv.style.border = '1px solid #ddd';
replacementDiv.style.margin = '10px 0';
replacementDiv.style.padding = '10px';
// Preserve the original table structure visually
// Create a simplified HTML representation of the table
let tableHtml = '';
// Process each row
const rows = table.querySelectorAll('tr');
rows.forEach(row => {
const rowDiv = document.createElement('div');
rowDiv.style.display = 'flex';
rowDiv.style.flexWrap = 'wrap';
rowDiv.style.marginBottom = '5px';
// Process each cell in the row
const cells = row.querySelectorAll('td, th');
cells.forEach(cell => {
const cellDiv = document.createElement('div');
cellDiv.style.flex = '1';
cellDiv.style.padding = '5px';
cellDiv.style.borderBottom = '1px solid #eee';
cellDiv.innerHTML = cell.innerHTML;
rowDiv.appendChild(cellDiv);
});
replacementDiv.appendChild(rowDiv);
});
// If no rows were processed, just use the table's inner HTML
if (rows.length === 0) {
replacementDiv.innerHTML = table.innerHTML;
}
// Replace the table with the div
if (table.parentNode) {
table.parentNode.replaceChild(replacementDiv, table);
convertedCount++;
}
}
// For regular content, just add width attributes to make quill-better-table happy
else {
// Skip simple tables that are likely to work fine with Quill
// Check more conditions to identify simple tables
const isSimpleTable =
table.rows.length <= 3 &&
table.querySelectorAll('td, th').length <= 6 &&
!table.querySelector('table') && // No nested tables
!table.innerHTML.includes('rowspan') && // No rowspan
!table.innerHTML.includes('colspan'); // No colspan
if (isSimpleTable) {
console.log('Preserving simple table structure');
// Add width attribute
table.setAttribute('width', '100%');
// Add width to cells
const cells = table.querySelectorAll('td, th');
cells.forEach(cell => {
if (cell instanceof HTMLTableCellElement && !cell.hasAttribute('width')) {
cell.setAttribute('width', '100');
}
});
} else {
// Convert complex tables to divs
const replacementDiv = document.createElement('div');
replacementDiv.className = 'converted-table';
replacementDiv.style.border = '1px solid #ddd';
replacementDiv.style.margin = '10px 0';
replacementDiv.style.padding = '10px';
// Copy the table's innerHTML
replacementDiv.innerHTML = table.innerHTML;
// Replace the table with the div
if (table.parentNode) {
table.parentNode.replaceChild(replacementDiv, table);
convertedCount++;
}
}
}
});
console.log(`Converted ${convertedCount} tables to divs to prevent Quill errors`);
return tempDiv.innerHTML;
}
return htmlContent;
} catch (error) {
console.error('Error cleaning up table structures:', error);
return htmlContent;
}
}
const RichEmailEditor: React.FC<RichEmailEditorProps> = ({
initialContent,
onChange,
placeholder = 'Write your message here...',
minHeight = '200px',
maxHeight = 'calc(100vh - 400px)',
preserveFormatting = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<any>(null);
const [isReady, setIsReady] = useState(false);
const [isReplyOrForward, setIsReplyOrForward] = useState(false);
// Initialize Quill editor when component mounts
useEffect(() => {
// Import Quill dynamically (client-side only)
const initializeQuill = async () => {
if (!editorRef.current || !toolbarRef.current) return;
// First, detect if content is reply/forward to determine editor mode
const contentIsReplyOrForward = initialContent ? (
initialContent.includes('wrote:') ||
initialContent.includes('<blockquote') ||
initialContent.includes('Forwarded message') ||
initialContent.includes('---------- Forwarded message ----------')
) : false;
// Store this information for future reference
setIsReplyOrForward(contentIsReplyOrForward);
console.log('Initializing editor in', contentIsReplyOrForward ? 'reply/forward' : 'compose', 'mode');
const Quill = (await import('quill')).default;
// Import quill-better-table conditionally based on content type
let tableModule = null;
if (!contentIsReplyOrForward) {
// Only try to load table module for regular content, not for replies/forwards
try {
const QuillBetterTable = await import('quill-better-table');
// Register the table module if available and not in reply/forward mode
if (QuillBetterTable && QuillBetterTable.default) {
Quill.register({
'modules/better-table': QuillBetterTable.default
}, true);
tableModule = QuillBetterTable.default;
console.log('Better Table module registered successfully');
}
} catch (err) {
console.warn('Table module not available:', err);
}
} else {
console.log('Skipping better-table module for reply/forward content');
}
// Define custom formats/modules with table support
const emailToolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'align': [] }],
[{ 'direction': 'rtl' }], // Add direction to toolbar
['link'],
['clean'],
];
// Create new Quill instance with the DOM element and custom toolbar
const editorElement = editorRef.current;
quillRef.current = new Quill(editorElement, {
modules: {
toolbar: {
container: toolbarRef.current,
handlers: {
// Add any custom toolbar handlers here
}
},
clipboard: {
matchVisual: false // Disable clipboard matching for better HTML handling
},
// Only enable better-table for regular content, not for replies/forwards
'better-table': tableModule && !contentIsReplyOrForward ? true : false,
},
placeholder: placeholder,
theme: 'snow',
});
// Set initial content properly
if (initialContent) {
try {
console.log('Setting initial content in editor', {
length: initialContent.length,
startsWithHtml: initialContent.trim().startsWith('<'),
containsForwardedMessage: initialContent.includes('---------- Forwarded message ----------'),
containsReplyIndicator: initialContent.includes('wrote:'),
hasBlockquote: initialContent.includes('<blockquote')
});
// Process HTML content using centralized utility with special settings for replies/forwards
const processed = processHtmlContent(initialContent, {
sanitize: true,
preserveReplyFormat: contentIsReplyOrForward
});
const sanitizedContent = processed.sanitizedContent;
const direction = processed.direction; // Use direction from processed result
// Log sanitized content details for debugging
console.log('Sanitized content details:', {
length: sanitizedContent.length,
isEmpty: sanitizedContent.trim().length === 0,
startsWithDiv: sanitizedContent.trim().startsWith('<div'),
containsForwardedMessage: sanitizedContent.includes('---------- Forwarded message ----------'),
containsQuoteHeader: sanitizedContent.includes('wrote:'),
hasTable: sanitizedContent.includes('<table'),
hasBlockquote: sanitizedContent.includes('<blockquote'),
isReplyOrForward: contentIsReplyOrForward,
firstNChars: sanitizedContent.substring(0, 100).replace(/\n/g, '\\n')
});
// Check if sanitized content is valid
if (sanitizedContent.trim().length === 0) {
console.warn('Sanitized content is empty after processing, using fallback approach');
// Try to extract text content if HTML processing failed
try {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = initialContent;
const textContent = tempDiv.textContent || tempDiv.innerText || 'Empty content';
// Set text directly to ensure something displays
quillRef.current.setText(textContent);
} catch (e) {
console.error('Text extraction fallback failed:', e);
quillRef.current.setText('Error loading content');
}
} else {
// Special handling for reply or forwarded content
if (contentIsReplyOrForward) {
console.log('Using special handling for reply/forward content');
// For reply/forward content, convert ALL tables to divs
const cleanedContent = cleanupTableStructures(sanitizedContent, true);
// Use direct innerHTML setting with minimal processing for reply/forward content
quillRef.current.root.innerHTML = cleanedContent;
} else {
// For regular content, use normal processing
const cleanedContent = cleanupTableStructures(sanitizedContent, false);
// Use direct innerHTML setting for regular content
quillRef.current.root.innerHTML = cleanedContent;
}
// Set the direction for the content
if (quillRef.current && quillRef.current.format) {
quillRef.current.format('direction', direction);
if (direction === 'rtl') {
quillRef.current.format('align', 'right');
}
} else {
console.warn('Cannot format content: editor not fully initialized');
}
}
// Set cursor at the beginning
quillRef.current.setSelection(0, 0);
// Ensure the cursor and scroll position is at the top of the editor
if (editorRef.current) {
editorRef.current.scrollTop = 0;
// Find and scroll parent containers that might have scroll
const scrollable = [
editorRef.current.closest('.ql-container'),
editorRef.current.closest('.rich-email-editor-container'),
editorRef.current.closest('.overflow-y-auto'),
document.querySelector('.overflow-y-auto')
];
scrollable.forEach(el => {
if (el instanceof HTMLElement) {
el.scrollTop = 0;
}
});
}
} catch (err) {
console.error('Error setting initial content:', err);
// Enhanced fallback mechanism for complex content
try {
// First try to extract text from HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = initialContent;
const textContent = tempDiv.textContent || tempDiv.innerText || '';
if (textContent.trim()) {
console.log('Using extracted text fallback, length:', textContent.length);
quillRef.current.setText(textContent);
} else {
// If text extraction fails or returns empty, provide a message
console.log('Using empty content fallback');
quillRef.current.setText('Unable to load original content');
}
} catch (e) {
console.error('All fallbacks failed:', e);
quillRef.current.setText('Error loading content');
}
}
}
// Add change listener
quillRef.current.on('text-change', () => {
const html = quillRef.current.root.innerHTML;
onChange(html);
});
// Improve editor layout
const editorContainer = editorElement.closest('.ql-container');
if (editorContainer) {
editorContainer.classList.add('email-editor-container');
}
setIsReady(true);
};
initializeQuill().catch(err => {
console.error('Failed to initialize Quill editor:', err);
});
// Clean up on unmount
return () => {
if (quillRef.current) {
// Clean up any event listeners or resources
quillRef.current.off('text-change');
}
};
}, []);
// Update content from props if changed externally - using a simpler approach
useEffect(() => {
if (quillRef.current && isReady && initialContent) {
const currentContent = quillRef.current.root.innerHTML;
// Only update if content changed to avoid editor position reset
if (initialContent !== currentContent) {
try {
console.log('Updating content in editor:', {
contentLength: initialContent.length,
startsWithHtml: initialContent.trim().startsWith('<'),
containsForwardedMessage: initialContent.includes('---------- Forwarded message ----------'),
containsQuoteHeader: initialContent.includes('wrote:'),
hasBlockquote: initialContent.includes('<blockquote'),
hasTable: initialContent.includes('<table'),
firstNChars: initialContent.substring(0, 100).replace(/\n/g, '\\n')
});
// Check if content is reply or forward to use special handling
const contentIsReplyOrForward =
initialContent.includes('wrote:') ||
initialContent.includes('<blockquote') ||
initialContent.includes('Forwarded message') ||
initialContent.includes('---------- Forwarded message ----------');
// If content type changed (from reply to regular or vice versa), we need to reload
if (contentIsReplyOrForward !== isReplyOrForward) {
console.log('Content type changed from', isReplyOrForward ? 'reply/forward' : 'regular',
'to', contentIsReplyOrForward ? 'reply/forward' : 'regular',
'- reloading editor');
setIsReplyOrForward(contentIsReplyOrForward);
// Force a complete re-initialization of the editor by unmounting
if (quillRef.current) {
quillRef.current.off('text-change');
quillRef.current = null;
}
setIsReady(false);
return;
}
// Process HTML content using centralized utility
const processed = processHtmlContent(initialContent, {
sanitize: true,
preserveReplyFormat: contentIsReplyOrForward
});
const sanitizedContent = processed.sanitizedContent;
const direction = processed.direction; // Use direction from processed result
// Log sanitized content details for debugging
console.log('Sanitized content details:', {
length: sanitizedContent.length,
isEmpty: sanitizedContent.trim().length === 0,
startsWithDiv: sanitizedContent.trim().startsWith('<div'),
containsForwardedMessage: sanitizedContent.includes('---------- Forwarded message ----------'),
containsQuoteHeader: sanitizedContent.includes('wrote:'),
hasTable: sanitizedContent.includes('<table'),
hasBlockquote: sanitizedContent.includes('<blockquote'),
isReplyOrForward: contentIsReplyOrForward,
firstNChars: sanitizedContent.substring(0, 100).replace(/\n/g, '\\n')
});
// Check if content is valid HTML
if (sanitizedContent.trim().length === 0) {
console.warn('Sanitized content is empty, using original content');
// If sanitized content is empty, try to extract text from original
const tempDiv = document.createElement('div');
tempDiv.innerHTML = initialContent;
const textContent = tempDiv.textContent || tempDiv.innerText || '';
// Create simple HTML with text content
if (quillRef.current) {
quillRef.current.setText(textContent || 'No content available');
}
} else {
// Process content based on type
if (contentIsReplyOrForward) {
console.log('Using special handling for reply/forward content update');
// For reply/forward content, convert ALL tables to divs
const cleanedContent = cleanupTableStructures(sanitizedContent, true);
// Set content without table handling
if (quillRef.current && quillRef.current.root) {
quillRef.current.root.innerHTML = cleanedContent;
// Delay applying formatting to ensure Quill is fully ready
setTimeout(() => {
if (quillRef.current) {
try {
// Set the direction for the content
quillRef.current.format('direction', direction);
if (direction === 'rtl') {
quillRef.current.format('align', 'right');
}
// Force update
quillRef.current.update();
// Set selection to beginning
quillRef.current.setSelection(0, 0);
} catch (innerError) {
console.error('Error applying delayed formatting:', innerError);
}
}
}, 100);
}
} else {
// For regular content, use normal processing
const cleanedContent = cleanupTableStructures(sanitizedContent, false);
if (quillRef.current && quillRef.current.root) {
quillRef.current.root.innerHTML = cleanedContent;
// Safely apply formatting
try {
quillRef.current.format('direction', direction);
if (direction === 'rtl') {
quillRef.current.format('align', 'right');
}
// Force update
quillRef.current.update();
// Set selection to beginning
quillRef.current.setSelection(0, 0);
} catch (formatError) {
console.error('Error applying formatting:', formatError);
}
}
}
}
} catch (err) {
console.error('Error updating content:', err);
// Safer fallback that avoids clipboard API
try {
// Extract basic text if everything else fails
const tempDiv = document.createElement('div');
tempDiv.innerHTML = initialContent;
const textContent = tempDiv.textContent || tempDiv.innerText || '';
if (quillRef.current) {
quillRef.current.setText(textContent || 'Error loading content');
}
} catch (e) {
console.error('All fallbacks failed:', e);
// Last resort
if (quillRef.current) {
quillRef.current.setText('Error loading content');
}
}
}
}
}
}, [initialContent, isReady, isReplyOrForward]);
return (
<div className="rich-email-editor-wrapper">
{/* Custom toolbar container */}
<div ref={toolbarRef} className="ql-toolbar ql-snow">
<span className="ql-formats">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-strike"></button>
</span>
<span className="ql-formats">
<select className="ql-color"></select>
<select className="ql-background"></select>
</span>
<span className="ql-formats">
<button className="ql-list" value="ordered"></button>
<button className="ql-list" value="bullet"></button>
</span>
<span className="ql-formats">
<button className="ql-indent" value="-1"></button>
<button className="ql-indent" value="+1"></button>
</span>
<span className="ql-formats">
<select className="ql-align"></select>
</span>
<span className="ql-formats">
<button className="ql-direction" value="rtl"></button>
</span>
<span className="ql-formats">
<button className="ql-link"></button>
</span>
<span className="ql-formats">
<button className="ql-clean"></button>
</span>
</div>
{/* Editor container with improved scrolling */}
<div className="rich-email-editor-container">
<div
ref={editorRef}
className="quill-editor"
/>
{/* Loading indicator */}
{!isReady && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
</div>
)}
</div>
{/* Custom styles for email context */}
<style jsx>{`
.rich-email-editor-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 6px;
flex: 1;
border: 1px solid #e2e8f0;
}
.rich-email-editor-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: auto;
flex: 1;
position: relative;
}
.quill-editor {
width: 100%;
min-height: ${minHeight};
max-height: ${maxHeight};
overflow-y: auto;
overflow-x: hidden;
}
/* Hide the editor until it's ready */
.quill-editor ${!isReady ? '{ display: none; }' : ''}
/* Hide duplicate toolbar */
:global(.ql-toolbar.ql-snow + .ql-toolbar.ql-snow) {
display: none !important;
}
/* Add RTL support styles */
:global([dir="rtl"] .ql-editor) {
text-align: right;
direction: rtl;
}
:global(.ql-editor[dir="rtl"]) {
text-align: right;
direction: rtl;
}
:global(.ql-container) {
border: none !important;
height: auto !important;
min-height: ${minHeight};
max-height: none !important;
overflow: visible;
}
:global(.ql-editor) {
padding: 12px;
min-height: ${minHeight};
overflow-y: auto !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333 !important;
}
/* Ensure all text is visible */
:global(.ql-editor p),
:global(.ql-editor div),
:global(.ql-editor span),
:global(.ql-editor li) {
color: #333 !important;
}
/* Ensure placeholder text is visible but distinct */
:global(.ql-editor.ql-blank::before) {
color: #aaa !important;
font-style: italic !important;
}
/* Force blockquote styling */
:global(.ql-editor blockquote) {
border-left: 2px solid #ddd !important;
margin: 0 !important;
padding: 10px 0 10px 15px !important;
color: #505050 !important;
background-color: #f9f9f9 !important;
border-radius: 4px !important;
font-size: 13px !important;
}
/* RTL blockquote styling */
:global(.ql-editor[dir="rtl"] blockquote),
:global([dir="rtl"] .ql-editor blockquote) {
border-left: none !important;
border-right: 2px solid #ddd !important;
padding: 10px 15px 10px 0 !important;
}
/* Fix table rendering */
:global(.ql-editor table) {
width: 100% !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 10px 0 !important;
border: 1px solid #ddd !important;
}
:global(.ql-editor td),
:global(.ql-editor th) {
border: 1px solid #ddd !important;
padding: 6px 8px !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
min-width: 30px !important;
font-size: 13px !important;
}
/* Email quote styling */
:global(.email-original-content) {
margin-top: 20px !important;
padding-top: 10px !important;
border-top: 1px solid #ddd !important;
color: #555 !important;
font-size: 13px !important;
}
/* Fix quoted paragraphs */
:global(.ql-editor blockquote p) {
margin-bottom: 8px !important;
margin-top: 0 !important;
}
/* Fix for reply headers */
:global(.ql-editor div[style*="font-weight: 400"]) {
margin-top: 20px !important;
margin-bottom: 8px !important;
color: #555 !important;
font-size: 13px !important;
}
`}</style>
</div>
);
};
export default RichEmailEditor;