UNPKG

ngx-wysiwyg-editor

Version:

A powerful and customizable WYSIWYG HTML editor for Angular applications

1,208 lines (1,203 loc) 182 kB
import * as i0 from '@angular/core'; import { EventEmitter, forwardRef, Component, ViewEncapsulation, ViewChild, Input, Output, NgModule } from '@angular/core'; import * as i2 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i3 from '@angular/forms'; import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'; import * as i4 from '@angular/cdk/drag-drop'; import { moveItemInArray, DragDropModule } from '@angular/cdk/drag-drop'; import * as i1 from '@angular/platform-browser'; class WysiwygEditorComponent { constructor(sanitizer, cdr) { this.sanitizer = sanitizer; this.cdr = cdr; this.config = {}; this.disabled = false; this.contentChange = new EventEmitter(); this.blockSelected = new EventEmitter(); this.blocksChange = new EventEmitter(); // Editor state this.emailBlocks = []; this.selectedBlock = null; this.selectedBlockIndex = -1; this.content = ''; // UI state this.showBlockPanel = true; this.showPropertiesPanel = true; this.activeTab = 'blocks'; this.activePropertiesTab = 'content'; this.isDragging = false; this.devicePreview = 'desktop'; this.viewMode = 'edit'; this.isMobile = false; this.showExportDropdown = false; // Email settings this._emailSettings = { width: '600px', backgroundColor: '#f4f4f4', contentBackgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif', fontSize: '14px', textColor: '#333333', linkColor: '#2196F3', padding: '20px' }; // Available blocks this.availableBlocks = [ { type: 'header', icon: '📰', label: 'Header', description: 'Logo and navigation' }, { type: 'text', icon: '📝', label: 'Text', description: 'Paragraph text block' }, { type: 'image', icon: '🖼️', label: 'Image', description: 'Single image' }, { type: 'button', icon: '🔲', label: 'Button', description: 'Call-to-action button' }, { type: 'divider', icon: '➖', label: 'Divider', description: 'Horizontal line' }, { type: 'columns', icon: '⬜', label: 'Columns', description: 'Multi-column layout' }, { type: 'social', icon: '👥', label: 'Social', description: 'Social media links' }, { type: 'spacer', icon: '⬜', label: 'Spacer', description: 'Empty space' }, { type: 'video', icon: '▶️', label: 'Video', description: 'Video thumbnail with link' }, { type: 'html', icon: '</>', label: 'HTML', description: 'Custom HTML code' } ]; // Block templates this.blockTemplates = { header: { logo: '', companyName: 'Your Company', tagline: 'Your tagline here', backgroundColor: '#2196F3', textColor: '#ffffff', height: '120px', alignment: 'center' }, text: { content: '<p>Enter your text content here. You can format it with <strong>bold</strong>, <em>italic</em>, and more.</p>', padding: '20px', fontSize: '14px', lineHeight: '1.6', textAlign: 'left' }, image: { src: 'https://via.placeholder.com/600x300', alt: 'Image description', width: '100%', alignment: 'center', padding: '10px', link: '' }, button: { text: 'Click Here', url: '#', backgroundColor: '#2196F3', textColor: '#ffffff', borderRadius: '4px', padding: '12px 24px', fontSize: '16px', alignment: 'center', width: 'auto' }, divider: { style: 'solid', width: '100%', color: '#e0e0e0', thickness: '1px', margin: '20px 0' }, columns: { count: 2, gap: '20px', columns: [ { content: '<h3>Column 1</h3><p>This is the content for the first column. You can add any HTML content here.</p>' }, { content: '<h3>Column 2</h3><p>This is the content for the second column. You can add any HTML content here.</p>' } ] }, social: { platforms: [ { name: 'facebook', url: '#', icon: '📘' }, { name: 'twitter', url: '#', icon: '🐦' }, { name: 'instagram', url: '#', icon: '📷' }, { name: 'linkedin', url: '#', icon: '💼' } ], alignment: 'center', iconSize: '32px', spacing: '10px' }, spacer: { height: '30px' }, video: { thumbnail: 'https://via.placeholder.com/600x340', videoUrl: '#', playButtonStyle: 'circle', playButtonColor: '#ffffff', playButtonBackground: 'rgba(0,0,0,0.7)' }, html: { code: '<!-- Enter your custom HTML here -->' } }; // Property editors for each block type this.currentBlockProperties = {}; this.onChange = () => { }; this.onTouched = () => { }; } set blocks(value) { if (value && Array.isArray(value)) { this.emailBlocks = [...value]; this.renderEmail(); this.emitChange(); } } set emailSettings(value) { if (value && typeof value === 'object') { this._emailSettings = { ...this._emailSettings, ...value }; this.renderEmail(); this.emitChange(); } } set initialContent(value) { if (value && typeof value === 'object') { if (value.blocks && Array.isArray(value.blocks)) { this.emailBlocks = [...value.blocks]; } if (value.settings && typeof value.settings === 'object') { this._emailSettings = { ...this._emailSettings, ...value.settings }; } this.renderEmail(); this.emitChange(); } } get emailSettings() { return this._emailSettings; } ngOnInit() { this.initializeConfig(); this.loadDefaultTemplate(); this.checkMobileView(); // Listen for window resize this.resizeListener = () => { this.checkMobileView(); }; window.addEventListener('resize', this.resizeListener); // Listen for clicks to close dropdown document.addEventListener('click', (event) => { if (this.showExportDropdown) { const target = event.target; const dropdown = target.closest('.export-dropdown'); if (!dropdown) { this.showExportDropdown = false; this.cdr.detectChanges(); } } }); } ngOnDestroy() { // Clean up resize listener if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); } } ngAfterViewInit() { this.renderEmail(); // Set initial device preview setTimeout(() => { this.updatePreviewSize(); }, 100); } checkMobileView() { const wasMobile = this.isMobile; this.isMobile = window.innerWidth <= 768; // On mobile, close panel by default when switching from desktop if (this.isMobile && !wasMobile) { this.showBlockPanel = false; } } // Getter for toolbar configuration with defaults get toolbarConfig() { return { showBlocksButton: true, showSettingsButton: true, showSaveButton: false, showLoadButton: false, showExportButton: true, showDeviceSelector: true, showViewModeToggle: true, showClearAllButton: true, ...(this.config.toolbar || {}) }; } initializeConfig() { this.config = { theme: 'light', showBlockPanel: true, showPropertiesPanel: true, emailWidth: '600px', backgroundColor: '#f4f4f4', fontFamily: 'Arial, sans-serif', ...this.config }; this.showBlockPanel = this.config.showBlockPanel !== false; this.showPropertiesPanel = this.config.showPropertiesPanel !== false; if (this.config.emailWidth) { this.emailSettings.width = this.config.emailWidth; } if (this.config.backgroundColor) { this.emailSettings.backgroundColor = this.config.backgroundColor; } if (this.config.fontFamily) { this.emailSettings.fontFamily = this.config.fontFamily; } } loadDefaultTemplate() { // Load a basic email template this.emailBlocks = [ { id: this.generateId(), type: 'header', content: { ...this.blockTemplates['header'] } }, { id: this.generateId(), type: 'text', content: { ...this.blockTemplates['text'], content: '<h2>Welcome to Our Newsletter!</h2><p>Thank you for subscribing. We\'re excited to share our latest updates with you.</p>' } }, { id: this.generateId(), type: 'button', content: { ...this.blockTemplates['button'] } } ]; } generateId() { return `block_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Drag and drop handlers onDragStarted() { this.isDragging = true; } onDragEnded() { this.isDragging = false; } drop(event) { moveItemInArray(this.emailBlocks, event.previousIndex, event.currentIndex); this.renderEmail(); this.emitChange(); } // Block management addBlock(blockType) { // Deep clone the template for proper initialization const template = JSON.parse(JSON.stringify(this.blockTemplates[blockType])); const newBlock = { id: this.generateId(), type: blockType, content: template }; if (this.selectedBlockIndex >= 0) { // Insert after selected block this.emailBlocks.splice(this.selectedBlockIndex + 1, 0, newBlock); } else { // Add at the end this.emailBlocks.push(newBlock); } this.selectBlock(newBlock, this.emailBlocks.length - 1); this.renderEmail(); this.emitChange(); } duplicateBlock(block, index) { const duplicatedBlock = { id: this.generateId(), type: block.type, content: { ...block.content } }; this.emailBlocks.splice(index + 1, 0, duplicatedBlock); this.renderEmail(); this.emitChange(); } deleteBlock(index) { this.emailBlocks.splice(index, 1); this.selectedBlock = null; this.selectedBlockIndex = -1; this.renderEmail(); this.emitChange(); } moveBlockUp(index) { if (index > 0) { const temp = this.emailBlocks[index]; this.emailBlocks[index] = this.emailBlocks[index - 1]; this.emailBlocks[index - 1] = temp; this.selectedBlockIndex = index - 1; this.renderEmail(); this.emitChange(); } } moveBlockDown(index) { if (index < this.emailBlocks.length - 1) { const temp = this.emailBlocks[index]; this.emailBlocks[index] = this.emailBlocks[index + 1]; this.emailBlocks[index + 1] = temp; this.selectedBlockIndex = index + 1; this.renderEmail(); this.emitChange(); } } selectBlock(block, index) { this.selectedBlock = block; this.selectedBlockIndex = index; this.currentBlockProperties = block ? { ...block.content } : {}; if (block) { this.blockSelected.emit(block); this.showPropertiesPanel = true; this.activePropertiesTab = 'content'; } } // Property updates (handled by enhanced version below) updateEmailSetting(property, value) { this.emailSettings[property] = value; this.renderEmail(); this.emitChange(); } // Rendering renderEmail() { if (!this.emailCanvas) return; const html = this.generateEmailHtml(); this.content = html; // Update preview iframe if in preview mode if (this.viewMode === 'preview') { const iframe = this.emailCanvas.nativeElement.querySelector('.email-preview'); //@ts-ignore if (iframe && iframe.contentDocument) { //@ts-ignore iframe.contentDocument.open(); //@ts-ignore iframe.contentDocument.write(html); //@ts-ignore iframe.contentDocument.close(); // Apply device preview size this.updatePreviewSize(); } } } generateEmailHtml() { const blocks = this.emailBlocks.map(block => this.renderBlock(block)).join(''); const isPreview = this.viewMode === 'preview'; const emailWidth = parseInt(this.emailSettings.width) || 600; return ` <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Email Template</title> <!--[if gte mso 9]> <xml> <o:OfficeDocumentSettings> <o:AllowPNG/> <o:PixelsPerInch>96</o:PixelsPerInch> </o:OfficeDocumentSettings> </xml> <![endif]--> <style type="text/css"> /* Reset styles for better email client compatibility */ body, table, td, p, a, li, blockquote { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; } /* Main body styles */ body { margin: 0 !important; padding: 0 !important; background-color: ${this.emailSettings.backgroundColor} !important; font-family: ${this.emailSettings.fontFamily} !important; font-size: ${this.emailSettings.fontSize} !important; color: ${this.emailSettings.textColor} !important; } /* Link styles */ a { color: ${this.emailSettings.linkColor}; text-decoration: underline; } /* Responsive styles */ @media only screen and (max-width: 600px) { .email-container { width: 100% !important; max-width: 100% !important; } .mobile-full-width { width: 100% !important; max-width: 100% !important; } .mobile-padding { padding: 10px !important; } } /* Hide elements for preview mode */ ${!isPreview ? `.block-wrapper { border: 1px dashed #ddd; margin: 2px 0; }` : ''} </style> </head> <body style="margin: 0; padding: 0; background-color: ${this.emailSettings.backgroundColor}; font-family: ${this.emailSettings.fontFamily}; font-size: ${this.emailSettings.fontSize}; color: ${this.emailSettings.textColor};"> <!-- Email Container Table --> <table cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color: ${this.emailSettings.backgroundColor};"> <tr> <td align="center"> <table cellpadding="0" cellspacing="0" border="0" width="${emailWidth}" class="email-container" style="max-width: ${emailWidth}px; background-color: ${this.emailSettings.contentBackgroundColor};"> <tr> <td> ${blocks} </td> </tr> </table> </td> </tr> </table> </body> </html> `; } // Helper function to properly encode and format URLs for SendGrid formatUrlForEmail(url) { if (!url || url === '#') return '#'; // If URL doesn't have protocol, add https:// if (!url.match(/^https?:\/\//i)) { url = 'https://' + url; } // Encode special characters except those needed for URL structure // SendGrid handles its own tracking parameters, so we keep URLs clean return url.replace(/[<>"]/g, (char) => { const entities = { '<': '%3C', '>': '%3E', '"': '%22' }; return entities[char] || char; }); } // Helper function to create bulletproof button HTML for better SendGrid compatibility createBulletproofButton(content) { const url = this.formatUrlForEmail(content.url); const buttonBgColor = content.backgroundColor || '#2196F3'; const buttonTextColor = content.textColor || '#ffffff'; const buttonText = content.text || 'Click Here'; const borderRadius = content.borderRadius || '4px'; const fontSize = content.fontSize || '16px'; const padding = content.padding || '12px 24px'; // Parse padding for VML const paddingParts = padding.split(' '); const verticalPadding = parseInt(paddingParts[0]) || 12; const horizontalPadding = parseInt(paddingParts[1] || paddingParts[0]) || 24; // Calculate line height based on font size for better proportions const lineHeight = parseInt(fontSize) * 1.2; const buttonHeight = verticalPadding * 2 + lineHeight; return ` <!--[if mso]> <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${url}" style="height:${buttonHeight}px;v-text-anchor:middle;width:${buttonText.length * 8 + horizontalPadding * 2}px;" arcsize="${parseInt(borderRadius) * 10}%" stroke="f" fillcolor="${buttonBgColor}"> <w:anchorlock/> <center> <![endif]--> <a href="${url}" target="_blank" rel="noopener noreferrer" style="background-color:${buttonBgColor};border-radius:${borderRadius};color:${buttonTextColor};display:inline-block;font-family:Arial,sans-serif;font-size:${fontSize};font-weight:bold;line-height:${lineHeight}px;text-align:center;text-decoration:none;width:auto;-webkit-text-size-adjust:none;padding:${padding};mso-padding-alt:0px;">${buttonText}</a> <!--[if mso]> </center> </v:roundrect> <![endif]--> `; } renderBlock(block) { const content = block.content || {}; const shouldRender = this.shouldRenderBlock(content); const isPreview = this.viewMode === 'preview'; if (!shouldRender) { return ''; } let blockContent = ''; switch (block.type) { case 'header': blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color: ${content.backgroundColor || '#2196F3'};"> <tr> <td align="${content.alignment || 'center'}" style="padding: 20px; color: ${content.textColor || '#ffffff'};"> <h1 style="margin: 0; font-size: 28px; font-family: Arial, sans-serif; font-weight: bold; color: ${content.textColor || '#ffffff'};"> ${content.companyName || 'Your Company'} </h1> <p style="margin: 10px 0 0 0; font-size: 16px; color: ${content.textColor || '#ffffff'}; opacity: 0.9;"> ${content.tagline || 'Your tagline here'} </p> </td> </tr> </table> `; break; case 'text': blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td style="padding: ${content.padding || '20px'}; text-align: ${content.textAlign || 'left'}; font-size: ${content.fontSize || '14px'}; line-height: ${content.lineHeight || '1.6'}; font-family: Arial, sans-serif;"> ${content.content || '<p>Enter your text content here.</p>'} </td> </tr> </table> `; break; case 'image': const imageWidth = content.width === '100%' ? '100%' : (parseInt(content.width) || 600); const imageUrl = this.formatUrlForEmail(content.link); const hasLink = content.link && content.link !== '#'; blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td align="${content.alignment || 'center'}" style="padding: ${content.padding || '10px'};"> ${hasLink ? `<a href="${imageUrl}" target="_blank" rel="noopener noreferrer" style="text-decoration: none; border: none; outline: none;">` : ''} <img src="${content.src || 'https://via.placeholder.com/600x300'}" alt="${content.alt || 'Image'}" style="display: block; max-width: 100%; height: auto; border: 0; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic;" width="${imageWidth}"> ${hasLink ? '</a>' : ''} </td> </tr> </table> `; break; case 'button': blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td align="${content.alignment || 'center'}" style="padding: 20px;"> ${this.createBulletproofButton(content)} </td> </tr> </table> `; break; case 'divider': const dividerColor = content.color || '#e0e0e0'; const dividerThickness = parseInt(content.thickness) || 1; blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td style="padding: ${content.margin || '20px 0'};"> <table cellpadding="0" cellspacing="0" border="0" width="${content.width || '100%'}" align="center"> <tr> <td style="border-top: ${dividerThickness}px ${content.style || 'solid'} ${dividerColor}; font-size: 0; line-height: 0;"> &nbsp; </td> </tr> </table> </td> </tr> </table> `; break; case 'columns': const columnCount = content.count || 2; const columnWidth = Math.floor(100 / columnCount); const gapValue = parseInt(content.gap) || 20; const bgColor = content.columnBackground || '#f9f9f9'; const columnCells = content.columns?.map((column, index) => `<td width="${columnWidth}%" valign="top" style="padding: 0 ${gapValue / 2}px;"> <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td style="padding: 15px; background-color: ${bgColor}; border: 1px dashed #ddd; font-family: Arial, sans-serif; font-size: 14px;"> ${column.content || `<p style="color: #999; margin: 0;">Column ${index + 1} content</p>`} </td> </tr> </table> </td>`).join('') || ''; blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td style="padding: 20px;"> <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> ${columnCells} </tr> </table> </td> </tr> </table> `; break; case 'spacer': const spacerHeight = parseInt(content.height) || 30; blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td style="font-size: 0; line-height: 0; height: ${spacerHeight}px;"> &nbsp; </td> </tr> </table> `; break; case 'video': // For email clients, we'll use a static thumbnail with a play button overlay const videoUrl = this.formatUrlForEmail(content.videoUrl); blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td align="center" style="padding: 20px;"> <a href="${videoUrl}" target="_blank" rel="noopener noreferrer" style="display: inline-block; position: relative; text-decoration: none; border: none; outline: none;"> <img src="${content.thumbnail || 'https://via.placeholder.com/600x340'}" alt="Video thumbnail" style="display: block; max-width: 500px; width: 100%; height: auto; border: 0; -ms-interpolation-mode: bicubic;"> <!--[if !mso]><!--> <div style="position: absolute; top: 50%; left: 50%; width: 60px; height: 60px; margin-left: -30px; margin-top: -30px; background-color: ${content.playButtonBackground || 'rgba(0,0,0,0.7)'}; border-radius: ${content.playButtonStyle === 'circle' ? '30px' : '8px'};"> <!-- Play button triangle --> </div> <!--<![endif]--> </a> </td> </tr> </table> `; break; case 'social': const socialIcons = content.platforms?.map((platform) => { const socialUrl = this.formatUrlForEmail(platform.url); return `<td style="padding: 0 ${parseInt(content.spacing) / 2 || 5}px;"> <a href="${socialUrl}" target="_blank" rel="noopener noreferrer" style="text-decoration: none; font-size: ${content.iconSize || '32px'}; color: inherit; border: none; outline: none;"> ${platform.icon || '📱'} </a> </td>`; }).join('') || ''; blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td align="${content.alignment || 'center'}" style="padding: 20px;"> <table cellpadding="0" cellspacing="0" border="0"> <tr> ${socialIcons} </tr> </table> </td> </tr> </table> `; break; case 'html': blockContent = ` <table cellpadding="0" cellspacing="0" border="0" width="100%"> <tr> <td> ${content.code || content.html || '<!-- Custom HTML content -->'} </td> </tr> </table> `; break; default: return ''; } // Wrap each block in a table with optional border for edit mode const borderStyle = !isPreview ? 'border: 1px dashed #ddd; margin: 2px 0;' : ''; const wrapperTable = ` <table cellpadding="0" cellspacing="0" border="0" width="100%" class="block-wrapper" style="${borderStyle}"> <tr> <td> ${blockContent} </td> </tr> </table> `; // Wrap with block markers for parsing return `<!-- block:${block.type}:start -->${wrapperTable}<!-- block:${block.type}:end -->`; } // UI Actions toggleBlockPanel() { this.showBlockPanel = !this.showBlockPanel; // On mobile, open with blocks tab by default if (this.isMobile && this.showBlockPanel) { this.activeTab = 'blocks'; } } togglePropertiesPanel() { this.showPropertiesPanel = !this.showPropertiesPanel; } showSettingsTab() { this.activeTab = 'settings'; this.showBlockPanel = true; } closeMobilePanel() { if (this.isMobile) { this.showBlockPanel = false; } } closePropertiesPanel() { if (this.isMobile) { this.showPropertiesPanel = false; } } // Device preview methods setDevicePreview(device) { this.devicePreview = device; this.updatePreviewSize(); // Re-render to apply visibility changes based on new device mode this.renderEmail(); } // View mode methods setViewMode(mode) { this.viewMode = mode; if (mode === 'preview') { // Hide panels when in preview mode this.showBlockPanel = false; this.showPropertiesPanel = false; setTimeout(() => { this.renderEmail(); }, 100); } else { // Restore panels when in edit mode this.showBlockPanel = true; } } updatePreviewSize() { if (!this.emailCanvas) return; const container = this.emailCanvas.nativeElement; const iframe = container.querySelector('.email-preview'); if (iframe && this.viewMode === 'preview') { // The container already handles the width via getDeviceWidth() // Just ensure the iframe fills its container //@ts-ignore iframe.style.width = '100%'; //@ts-ignore iframe.style.height = '600px'; //@ts-ignore iframe.style.display = 'block'; } } getDeviceWidth() { switch (this.devicePreview) { case 'mobile': return '375px'; case 'tablet': return '768px'; case 'desktop': return '100%'; default: return '100%'; } } // Export dropdown methods toggleExportDropdown() { this.showExportDropdown = !this.showExportDropdown; } exportToClipboard() { const html = this.generateEmailHtml(); navigator.clipboard.writeText(html).then(() => { // Show success feedback this.showNotification('HTML copied to clipboard successfully!'); }).catch(err => { console.error('Failed to copy to clipboard:', err); this.showNotification('Failed to copy to clipboard. Please try again.'); }); this.showExportDropdown = false; } exportToFile() { const html = this.generateEmailHtml(); const blob = new Blob([html], { type: 'text/html' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'email-template.html'; a.click(); window.URL.revokeObjectURL(url); this.showExportDropdown = false; } // Legacy method for backward compatibility exportHtml() { this.exportToFile(); } showNotification(message) { // Simple notification implementation // You could replace this with a more sophisticated notification system const notification = document.createElement('div'); notification.textContent = message; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #4CAF50; color: white; padding: 12px 24px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 10000; font-family: Arial, sans-serif; font-size: 14px; transition: all 0.3s ease; `; document.body.appendChild(notification); // Auto-remove after 3 seconds setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, 3000); } clearAll() { if (confirm('Are you sure you want to clear all blocks?')) { this.emailBlocks = []; this.selectedBlock = null; this.selectedBlockIndex = -1; this.renderEmail(); this.emitChange(); } } // Public API methods for external block management getBlocks() { return [...this.emailBlocks]; } setBlocks(blocks) { if (blocks && Array.isArray(blocks)) { this.emailBlocks = [...blocks]; this.selectedBlock = null; this.selectedBlockIndex = -1; this.renderEmail(); this.emitChange(); } } addBlockAtIndex(block, index) { if (block && index >= 0 && index <= this.emailBlocks.length) { this.emailBlocks.splice(index, 0, block); this.renderEmail(); this.emitChange(); } } removeBlockAtIndex(index) { if (index >= 0 && index < this.emailBlocks.length) { this.emailBlocks.splice(index, 1); if (this.selectedBlockIndex === index) { this.selectedBlock = null; this.selectedBlockIndex = -1; } this.renderEmail(); this.emitChange(); } } updateBlockAtIndex(index, block) { if (block && index >= 0 && index < this.emailBlocks.length) { this.emailBlocks[index] = block; if (this.selectedBlockIndex === index) { this.selectedBlock = block; } this.renderEmail(); this.emitChange(); } } // Template management saveTemplate() { const template = { blocks: this.emailBlocks, settings: this.emailSettings, timestamp: new Date().toISOString() }; localStorage.setItem('email-template', JSON.stringify(template)); alert('Template saved successfully!'); } loadTemplate() { const saved = localStorage.getItem('email-template'); if (saved) { const template = JSON.parse(saved); this.emailBlocks = template.blocks; this.emailSettings = template.settings; this.renderEmail(); this.emitChange(); } } // ControlValueAccessor methods writeValue(value) { if (value === this.content && this.emailBlocks.length > 0) { // No change needed if same value and blocks exist return; } this.content = value || ''; // If we receive HTML content, try to parse it into blocks if (value && value.trim()) { this.parseHtmlToBlocks(value); } else { // Clear blocks if no value this.emailBlocks = []; this.selectedBlock = null; this.selectedBlockIndex = -1; } // Trigger change detection and update the preview this.cdr.detectChanges(); // Update the preview after change detection setTimeout(() => { this.renderEmail(); this.cdr.detectChanges(); }, 0); } parseHtmlToBlocks(html) { // Try to extract blocks from the HTML content // This is a simplified parser - in production, you'd want a more robust solution this.emailBlocks = []; // Check if it's our generated HTML with block markers const blockMatches = html.match(/<!-- block:(\w+):start -->([\s\S]*?)<!-- block:\w+:end -->/g); if (blockMatches && blockMatches.length > 0) { // Parse our marked blocks blockMatches.forEach(match => { const typeMatch = match.match(/<!-- block:(\w+):start -->/); const contentMatch = match.match(/<!-- block:\w+:start -->([\s\S]*?)<!-- block:\w+:end -->/); if (typeMatch && contentMatch) { const type = typeMatch[1]; const blockHtml = contentMatch[1]; const block = this.parseBlockFromHtml(type, blockHtml); if (block) { this.emailBlocks.push(block); } } }); } else { // Fallback: Create a single HTML block with the entire content // Use 'code' property as that's what the template expects const htmlBlock = { id: this.generateId(), type: 'html', content: { ...this.blockTemplates['html'], code: html // Use 'code' property, not 'html' }, settings: {} }; this.emailBlocks.push(htmlBlock); } } parseBlockFromHtml(type, html) { const block = { id: this.generateId(), type: type, content: { ...this.blockTemplates[type] }, settings: {} }; // Parse content based on block type switch (type) { case 'text': // For text blocks, use 'content' property block.content.content = html.trim(); break; case 'html': // For HTML blocks, use 'code' property block.content.code = html; break; case 'header': // Extract company name and tagline from header HTML const h1Match = html.match(/<h1[^>]*>(.*?)<\/h1>/); const pMatch = html.match(/<p[^>]*>(.*?)<\/p>/); if (h1Match) block.content.companyName = h1Match[1]; if (pMatch) block.content.tagline = pMatch[1]; break; // Add more block type parsers as needed default: // For unknown types, try to use as HTML block block.type = 'html'; block.content = { ...this.blockTemplates['html'], code: html }; break; } return block; } registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } setDisabledState(isDisabled) { this.disabled = isDisabled; } emitChange() { const html = this.generateEmailHtml(); this.onChange(html); // Emit comprehensive content model const emailContent = { html: html, blocks: [...this.emailBlocks], settings: this.emailSettings }; this.contentChange.emit(emailContent); // Also emit blocks separately for backward compatibility this.blocksChange.emit([...this.emailBlocks]); } // Public method to get current email content on demand getEmailContent() { return { html: this.generateEmailHtml(), blocks: [...this.emailBlocks], settings: { ...this.emailSettings } }; } // Utility methods getSafeHtml(html) { return this.sanitizer.bypassSecurityTrustHtml(html); } // Advanced properties helper methods getAdvancedStyles(content) { let styles = ''; if (content.customMargin) { styles += `margin: ${content.customMargin}; `; } // Use device preview mode instead of window width for better preview accuracy const isMobileView = this.devicePreview === 'mobile'; const isTabletView = this.devicePreview === 'tablet'; const isDesktopView = this.devicePreview === 'desktop'; if (content.mobileWidth && isMobileView) { styles += `width: ${content.mobileWidth}; `; } if (content.visibility === 'hidden') { styles += 'display: none; '; } else if (content.visibility === 'mobile-only' && !isMobileView) { styles += 'display: none; '; } else if (content.visibility === 'tablet-only' && !isTabletView) { styles += 'display: none; '; } else if (content.visibility === 'desktop-only' && !isDesktopView) { styles += 'display: none; '; } if (content.animation && content.animation !== 'none') { const delay = content.animationDelay || 0; styles += `animation: ${content.animation} 0.5s ease-in-out ${delay}ms; `; } return styles; } getAdvancedAttributes(content) { let attributes = ''; if (content.blockId) { attributes += `id="${content.blockId}" `; } if (content.customAttributes) { const lines = content.customAttributes.split('\n'); lines.forEach((line) => { if (line.trim()) { attributes += `${line.trim()} `; } }); } if (content.debugMode) { attributes += `data-debug="true" `; } return attributes; } getWrapperClasses(content) { let classes = 'block-wrapper'; if (content.cssClass) { classes += ` ${content.cssClass}`; } if (content.visibility) { classes += ` visibility-${content.visibility}`; } if (content.animation && content.animation !== 'none') { classes += ` animated ${content.animation}`; } return classes; } shouldRenderBlock(content) { // Check if block should be excluded from export if (content.excludeFromExport) { return false; } // Check display condition (simplified - in real implementation, you'd evaluate the condition) if (content.displayCondition && content.displayCondition.trim()) { // For demo purposes, we'll just show the block // In a real implementation, you'd evaluate the condition console.log('Display condition:', content.displayCondition); } return true; } getBlockIcon(type) { const block = this.availableBlocks.find(b => b.type === type); return block ? block.icon : '📄'; } getBlockLabel(type) { const block = this.availableBlocks.find(b => b.type === type); return block ? block.label : type; } // Columns management methods updateColumnsCount(newCount) { if (!this.selectedBlock || this.selectedBlock.type !== 'columns') return; const currentColumns = this.selectedBlock.content.columns || []; const newColumns = []; // Keep existing columns and add new empty ones if needed for (let i = 0; i < newCount; i++) { if (i < currentColumns.length) { newColumns.push(currentColumns[i]); } else { newColumns.push({ content: `<h3>Column ${i + 1}</h3><p>This is the content for column ${i + 1}. You can add any HTML content here.</p>` }); } } this.selectedBlock.content.count = parseInt(newCount); this.selectedBlock.content.columns = newColumns; this.currentBlockProperties.count = newCount; this.currentBlockProperties.columns = [...newColumns]; this.renderEmail(); this.emitChange(); } updateColumnContent(columnIndex, content) { if (!this.selectedBlock || this.selectedBlock.type !== 'columns') return; if (this.selectedBlock.content.columns && this.selectedBlock.content.columns[columnIndex]) { this.selectedBlock.content.columns[columnIndex].content = content; this.currentBlockProperties.columns[columnIndex].content = content; this.renderEmail(); this.emitChange(); } } // Social media management updateSocialPlatform(platformIndex, property, value) { if (!this.selectedBlock || this.selectedBlock.type !== 'social') return; if (this.selectedBlock.content.platforms && this.selectedBlock.content.platforms[platformIndex]) { this.selectedBlock.content.platforms[platformIndex][property] = value; this.currentBlockProperties.platforms[platformIndex][property] = value; this.renderEmail(); this.emitChange(); } } // Enhanced block property update with special handling updateBlockProperty(property, value) { if (this.selectedBlock) { if (!this.selectedBlock.content) { this.selectedBlock.content = {}; } this.selectedBlock.content[property] = value; this.currentBlockProperties[property] = value; // Special handling for certain properties if (this.selectedBlock.type === 'columns' && property === 'gap') { // Ensure gap is applied to all columns this.renderEmail(); } this.renderEmail(); this.emitChange(); } } } WysiwygEditorComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: WysiwygEditorComponent, deps: [{ token: i1.DomSanitizer }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); WysiwygEditorComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: WysiwygEditorComponent, isStandalone: true, selector: "wysiwyg-editor", inputs: { config: "config", disabled: "disabled", blocks: "blocks", emailSettings: "emailSettings", initialContent: "initialContent" }, outputs: { contentChange: "contentChange", blockSelected: "blockSelected", blocksChange: "blocksChange" }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => WysiwygEditorComponent), multi: true } ], viewQueries: [{ propertyName: "emailCanvas", first: true, predicate: ["emailCanvas"], descendants: true }], ngImport: i0, template: "<div class=\"email-editor\"\n [style.height]=\"config.height || '600px'\"\n [style.min-height]=\"config.minHeight\"\n [style.max-height]=\"config.maxHeight\">\n <!-- Top Toolbar -->\n <div class=\"editor-toolbar\">\n <div class=\"toolbar-left\">\n <button type=\"button\" class=\"toolbar-btn\"\n *ngIf=\"toolbarConfig.showBlocksButton\"\n (click)=\"toggleBlockPanel()\"\n [class.active]=\"showBlockPanel\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"></rect>\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"></rect>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"></rect>\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"></rect>\n </svg>\n Blocks\n </button>\n <button type=\"button\" class=\"toolbar-btn\"\n *ngIf=\"toolbarConfig.showSettingsButton\"\n (click)=\"showSettingsTab()\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n <path d=\"M12 1v6m0 6v6m4.22-13.22l4.24 4.24M1.54 19.54l4.24-4.24M22.46 19.54l-4.24-4.24M1.54 4.46l4.24 4.24\"></path>\n </svg>\n Settings\n </button>\n </div>\n\n <div class=\"toolbar-center\">\n <span class=\"editor-title\">Email Template Editor</span>\n </div>\n\n <div class=\"toolbar-right\">\n <button type=\"button\" class=\"toolbar-btn\"\n *ngIf=\"toolbarConfig.showSaveButton\"\n (click)=\"saveTemplate()\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"></path>\n <polyline points=\"17 21 17 13 7 13 7 21\"></polyline>\n <polyline points=\"7 3 7 8 15 8\"></polyline>\n </svg>\n Save\n </button>\n <button type=\"button\" class=\"toolbar-btn\"\n *ngIf=\"toolbarConfig.showLoadButton\"\n (click)=\"loadTemplate()\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0