Neah/components/email/RichEmailEditor.tsx
2025-04-27 11:52:45 +02:00

417 lines
14 KiB
TypeScript

'use client';
import React, { useEffect, useRef, useState } from 'react';
import 'quill/dist/quill.snow.css';
import { sanitizeHtml } from '@/lib/utils/email-formatter';
interface RichEmailEditorProps {
initialContent: string;
onChange: (content: string) => void;
placeholder?: string;
minHeight?: string;
maxHeight?: string;
preserveFormatting?: boolean;
}
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);
// Initialize Quill editor when component mounts
useEffect(() => {
// Import Quill dynamically (client-side only)
const initializeQuill = async () => {
if (!editorRef.current || !toolbarRef.current) return;
const Quill = (await import('quill')).default;
// Import quill-better-table
let tableModule = null;
try {
const QuillBetterTable = await import('quill-better-table');
// Register the table module if available
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);
}
// Define custom formats/modules with table support
const emailToolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'align': [] }],
['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
}
},
// Don't initialize better-table yet - we'll do it after content is loaded
'better-table': false,
},
placeholder: placeholder,
theme: 'snow',
});
// Set initial content (sanitized)
if (initialContent) {
try {
// First, ensure we preserve the raw HTML structure
const preservedContent = sanitizeHtml(initialContent);
// Check if there are tables in the content
const hasTables = preservedContent.includes('<table');
// For content with tables, we need special handling
if (hasTables && preserveFormatting && tableModule) {
// First, set the content directly to the root
quillRef.current.root.innerHTML = preservedContent;
// Initialize better table module after content is set
setTimeout(() => {
try {
// Clean up any existing tables first
const tables = quillRef.current.root.querySelectorAll('table');
tables.forEach((table: HTMLTableElement) => {
// Add required data attributes that the module expects
if (!table.getAttribute('data-table')) {
table.setAttribute('data-table', 'true');
}
});
// Initialize the module now that content is already in place
const betterTableModule = {
operationMenu: {
items: {
unmergeCells: {
text: 'Unmerge cells'
}
}
}
};
// Force a refresh
quillRef.current.update();
// Ensure the cursor and scroll position is at the top of the editor
quillRef.current.setSelection(0, 0);
// Also scroll the container to the top
if (editorRef.current) {
editorRef.current.scrollTop = 0;
// Also find and scroll parent containers that might have scroll
const scrollContainer = editorRef.current.closest('.ql-container');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
// One more check for nested scroll containers (like overflow divs)
const parentScrollContainer = editorRef.current.closest('.rich-email-editor-container');
if (parentScrollContainer) {
parentScrollContainer.scrollTop = 0;
}
}
} catch (tableErr) {
console.error('Error initializing table module:', tableErr);
}
}, 100);
} else {
// For content without tables, use the standard paste method
quillRef.current.clipboard.dangerouslyPasteHTML(0, preservedContent);
quillRef.current.setSelection(0, 0);
}
} catch (err) {
console.error('Error setting initial content:', err);
// Fallback method if the above fails
quillRef.current.setText('');
quillRef.current.clipboard.dangerouslyPasteHTML(sanitizeHtml(initialContent));
quillRef.current.setSelection(0, 0);
}
}
// 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
useEffect(() => {
if (quillRef.current && isReady) {
const currentContent = quillRef.current.root.innerHTML;
// Only update if content changed to avoid editor position reset
if (initialContent !== currentContent) {
try {
// Preserve cursor position if possible
const selection = quillRef.current.getSelection();
// First clear the content
quillRef.current.root.innerHTML = '';
// Then insert the new content at position 0
quillRef.current.clipboard.dangerouslyPasteHTML(0, sanitizeHtml(initialContent));
// Force update
quillRef.current.update();
// Restore selection if possible
if (selection) {
setTimeout(() => quillRef.current.setSelection(selection), 10);
}
} catch (err) {
console.error('Error updating content:', err);
// Fallback update method
quillRef.current.clipboard.dangerouslyPasteHTML(sanitizeHtml(initialContent));
}
}
}
}, [initialContent, isReady]);
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-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;
}
.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;
}
: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;
}
/* 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;
}
/* Status styles for email displays */
:global(.ql-editor td[class*="status"]),
:global(.ql-editor td[class*="Status"]) {
background-color: #f8f9fa !important;
font-weight: 500 !important;
}
/* Amount styles */
:global(.ql-editor td[class*="amount"]),
:global(.ql-editor td[class*="Amount"]),
:global(.ql-editor td[class*="price"]),
:global(.ql-editor td[class*="Price"]) {
text-align: right !important;
font-family: monospace !important;
}
/* Header row styles */
:global(.ql-editor tr:first-child td),
:global(.ql-editor th) {
background-color: #f8f9fa !important;
font-weight: 600 !important;
}
/* Improve table cells with specific content */
:global(.ql-editor td:has(div[class*="number"])),
:global(.ql-editor td:has(div[class*="Number"])),
:global(.ql-editor td:has(div[class*="invoice"])),
:global(.ql-editor td:has(div[class*="Invoice"])) {
font-family: monospace !important;
letter-spacing: 0.5px !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;