import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "canvas", "elementLibrary", "preview", "propertiesPanel", "row", "element", "dropZone", "selectedElement", "previewModal", "elementCount", "columnArrangement" ] static values = { saveUrl: String, emailCampaignId: String, existingContent: String } connect() { this.emailData = { rows: [] } this.selectedElement = null this.isDragging = false this.draggedElement = null this.draggedRowIndex = null this.activeInsertion = null this.inlineEditTimeout = null this.pendingImageInsertion = null // Listen for image selection events from the modal this.element.addEventListener('imageSelected', this.handleImageSelected.bind(this)) // Load existing content if available if (this.existingContentValue) { try { this.emailData = JSON.parse(this.existingContentValue) this.renderEmail() } catch (error) { console.warn('Failed to parse existing content:', error) } } this.initializeDragAndDrop() this.setupEventListeners() this.setupDropZones() } disconnect() { // Clean up event listeners this.element.removeEventListener('imageSelected', this.handleImageSelected.bind(this)) } initializeDragAndDrop() { // Make element library items draggable this.elementLibraryTarget.querySelectorAll('.draggable-element').forEach(element => { element.draggable = true element.addEventListener('dragstart', this.handleDragStart.bind(this)) element.addEventListener('dragend', this.handleDragEnd.bind(this)) }) // Setup drop zones this.canvasTarget.addEventListener('dragover', this.handleDragOver.bind(this)) this.canvasTarget.addEventListener('drop', this.handleDrop.bind(this)) this.canvasTarget.addEventListener('dragenter', this.handleDragEnter.bind(this)) this.canvasTarget.addEventListener('dragleave', this.handleDragLeave.bind(this)) } setupEventListeners() { // Canvas click to deselect this.canvasTarget.addEventListener('click', (e) => { if (e.target === this.canvasTarget) { this.deselectElement() } }) } setupDropZones() { // This will be called after renderEmail to set up drop zones this.updateDropZones() this.setupEmptyColumnDropZones() } updateDropZones() { // Remove existing drop zones this.canvasTarget.querySelectorAll('.drop-zone').forEach(zone => zone.remove()) // With insertion targets rendered directly in HTML, we no longer // need dynamic column drop zones here. Keep hook for future use. } setupEmptyColumnDropZones() { // Set up event listeners for empty column drop zones this.canvasTarget.querySelectorAll('.empty-column-drop-zone').forEach(dropZone => { dropZone.addEventListener('dragover', this.handleDropZoneOver.bind(this)) dropZone.addEventListener('dragleave', this.handleDropZoneLeave.bind(this)) dropZone.addEventListener('drop', this.handleDropZoneDrop.bind(this)) }) // Set up event listeners for element insertion points this.canvasTarget.querySelectorAll('.element-insertion-point').forEach(insertionPoint => { insertionPoint.addEventListener('dragover', this.handleDropZoneOver.bind(this)) insertionPoint.addEventListener('dragleave', this.handleDropZoneLeave.bind(this)) insertionPoint.addEventListener('drop', this.handleDropZoneDrop.bind(this)) }) // Set up event listeners for row drop zones this.canvasTarget.querySelectorAll('.row-drop-zone').forEach(rowDropZone => { rowDropZone.addEventListener('dragover', this.handleDropZoneOver.bind(this)) rowDropZone.addEventListener('dragleave', this.handleDropZoneLeave.bind(this)) rowDropZone.addEventListener('drop', this.handleDropZoneDrop.bind(this)) }) } handleRowDragStart(e) { const rowIndex = parseInt(e.target.dataset.rowIndex) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/row-index', rowIndex.toString()) e.target.closest('.email-row').style.opacity = '0.5' } handleRowDragEnd(e) { e.target.closest('.email-row').style.opacity = '1' } moveRowToIndex(fromIndex, toIndex) { if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return const row = this.emailData.rows.splice(fromIndex, 1)[0] const adjustedToIndex = toIndex> fromIndex ? toIndex - 1 : toIndex this.emailData.rows.splice(adjustedToIndex, 0, row) this.renderEmail() } showDropZones() { this.canvasTarget.classList.add('drag-over') this.canvasTarget.querySelectorAll('.drop-zone').forEach(zone => { zone.style.opacity = '0.6' }) } hideDropZones() { this.canvasTarget.classList.remove('drag-over') this.canvasTarget.querySelectorAll('.drop-zone').forEach(zone => { zone.style.opacity = '0' }) } addDropZone(rowIndex, columnIndex, position) { const dropZone = document.createElement('div') dropZone.className = 'drop-zone' dropZone.dataset.rowIndex = rowIndex dropZone.dataset.columnIndex = columnIndex dropZone.dataset.position = position // Position the drop zone if (position === 'new-row') { dropZone.style.cssText = ` height: 4px; background: #059669; margin: 10px 0; border-radius: 2px; opacity: 0; transition: opacity 0.2s ease; position: relative; z-index: 10; ` dropZone.innerHTML = '
Drop here to add new row
' } else if (position === 'above-row') { dropZone.style.cssText = ` height: 4px; background: #8b5cf6; margin: 10px 0; border-radius: 2px; opacity: 0; transition: opacity 0.2s ease; position: relative; z-index: 10; ` dropZone.innerHTML = '
Drop here to add row above
' } else if (position === 'first-element') { dropZone.style.cssText = ` height: 6px; background: #f59e0b; margin: 20px 0; border-radius: 3px; opacity: 0; transition: opacity 0.2s ease; position: relative; z-index: 10; ` dropZone.innerHTML = '
Drop here to start your email
' } else { dropZone.style.cssText = ` height: 2px; background: #3b82f6; margin: 2px 0; border-radius: 1px; opacity: 0; transition: opacity 0.2s ease; position: relative; z-index: 10; ` dropZone.innerHTML = '
Drop here
' } // Add event listeners dropZone.addEventListener('dragover', this.handleDropZoneOver.bind(this)) dropZone.addEventListener('dragleave', this.handleDropZoneLeave.bind(this)) dropZone.addEventListener('drop', this.handleDropZoneDrop.bind(this)) // Insert the drop zone in the appropriate location this.insertDropZone(dropZone, rowIndex, columnIndex, position) } insertDropZone(dropZone, rowIndex, columnIndex, position) { if (position === 'new-row') { this.canvasTarget.appendChild(dropZone) } else if (position === 'first-element') { // Insert at the very beginning of the canvas this.canvasTarget.insertBefore(dropZone, this.canvasTarget.firstChild) } else if (position === 'above-row') { const rowElement = this.canvasTarget.querySelector(`[data-row-index="${rowIndex}"]`) if (rowElement) { this.canvasTarget.insertBefore(dropZone, rowElement) } } else { const rowElement = this.canvasTarget.querySelector(`[data-row-index="${rowIndex}"]`) if (rowElement) { const columnElement = rowElement.querySelector(`[data-column-index="${columnIndex}"]`) if (columnElement) { const elementsContainer = columnElement.querySelector('.column-elements') if (elementsContainer) { if (position === 'top') { elementsContainer.insertBefore(dropZone, elementsContainer.firstChild) } else { elementsContainer.appendChild(dropZone) } } } } } } handleDropZoneOver(e) { e.preventDefault() e.stopPropagation() e.dataTransfer.dropEffect = 'copy' // Expand seam zone only on hover during drag e.target.style.opacity = '1' e.target.style.height = '24px' e.target.style.border = '2px dashed rgba(59,130,246,0.6)' e.target.style.background = 'rgba(239,246,255,0.8)' } handleDropZoneLeave(e) { e.stopPropagation() // Collapse seam zone on leave e.target.style.opacity = '0' e.target.style.height = '0px' e.target.style.border = '0' e.target.style.background = 'transparent' } handleDropZoneDrop(e) { e.preventDefault() e.stopPropagation() // Prevent event bubbling e.target.style.opacity = '0' e.target.style.height = '0px' e.target.style.border = '0' e.target.style.background = 'transparent' // Hide drop zones after successful drop this.hideDropZones() const elementType = e.dataTransfer.getData('text/plain') const rowIndex = parseInt(e.target.dataset.rowIndex) const columnIndex = e.target.dataset.columnIndex ? parseInt(e.target.dataset.columnIndex) : null const position = e.target.dataset.position if (elementType) { if (elementType === 'add-row') { // Handle adding a new row at specific location const arrangement = this.columnArrangementTarget.value this.addRowWithArrangementAtPosition(arrangement, rowIndex, columnIndex, position) } else { // Check if this is a row drop zone if (e.target.classList.contains('row-drop-zone')) { // For images, show the modal instead of creating the element directly if (elementType === 'image') { this.pendingImageInsertion = { rowIndex, elementType } this.showImageModal() } else { // Create a single-column row with the element this.addElementAsNewRow(elementType, rowIndex) } } else { // Handle adding an element to existing row/column const elementIndex = e.target.dataset.elementIndex ? parseInt(e.target.dataset.elementIndex) : null // For images, show the modal instead of creating the element directly if (elementType === 'image') { this.pendingImageInsertion = { rowIndex, columnIndex, elementIndex, position } this.showImageModal() } else { this.addElementToSpecificLocation(elementType, rowIndex, columnIndex, position, elementIndex) } } } } } handleDragStart(e) { this.draggedElement = e.target.dataset.elementType e.dataTransfer.effectAllowed = 'copy' e.dataTransfer.setData('text/plain', this.draggedElement) // Show drop zones when dragging starts this.showDropZones() } handleDragEnd(e) { // Hide drop zones when dragging ends (whether successful or cancelled) this.hideDropZones() } handleDragOver(e) { e.preventDefault() e.dataTransfer.dropEffect = 'copy' } handleDragEnter(e) { e.preventDefault() this.canvasTarget.classList.add('drag-over') } handleDragLeave(e) { if (!this.canvasTarget.contains(e.relatedTarget)) { this.canvasTarget.classList.remove('drag-over') } } handleDrop(e) { e.preventDefault() this.canvasTarget.classList.remove('drag-over') // Hide drop zones when drop completes this.hideDropZones() const elementType = e.dataTransfer.getData('text/plain') if (elementType) { if (elementType === 'add-row') { // Handle adding a new row const arrangement = this.columnArrangementTarget.value this.addRowWithArrangement(arrangement) this.renderEmail() } else { // Handle adding an element - if canvas is empty, create a single-column row if (this.emailData.rows.length === 0) { this.addElementAsNewRow(elementType, 0) } else { this.addElementToCanvas(elementType) } } } } addElementAsNewRow(elementType, rowIndex) { const newElement = this.createElement(elementType) const newRow = { id: this.generateId(), columns: [{ width: 100, elements: [newElement] }], styles: { backgroundColor: '#ffffff' } } // Insert the new row at the specified position this.emailData.rows.splice(rowIndex, 0, newRow) this.renderEmail() this.selectElement(0, rowIndex, 0) } addElementToSpecificLocation(elementType, rowIndex, columnIndex, position, elementIndex = null) { const newElement = this.createElement(elementType) if (position === 'new-row') { // Create a new row and add the element this.addRow() const newRow = this.emailData.rows[this.emailData.rows.length - 1] if (!newRow.columns[0].elements) { newRow.columns[0].elements = [] } newRow.columns[0].elements.push(newElement) this.renderEmail() this.selectElement(newRow.columns[0].elements.length - 1, this.emailData.rows.length - 1) } else if (position === 'first-element') { // Create the first row with the element const newRow = { id: this.generateId(), columns: [{ width: 100, elements: [newElement] }], styles: { padding: '20px', backgroundColor: '#ffffff' } } this.emailData.rows.push(newRow) this.renderEmail() this.selectElement(0, 0, 0) } else if (position === 'above-row') { // Create a new row above the specified row and add the element const newRow = { id: this.generateId(), columns: [{ width: 100, elements: [newElement] }], styles: { backgroundColor: '#ffffff' } } this.emailData.rows.splice(rowIndex, 0, newRow) this.renderEmail() this.selectElement(0, rowIndex, 0) } else { // Add to existing row/column if (this.emailData.rows[rowIndex] && this.emailData.rows[rowIndex].columns[columnIndex]) { const column = this.emailData.rows[rowIndex].columns[columnIndex] if (!column.elements) { column.elements = [] } // Handle element insertion points if (elementIndex !== null) { // Insert at specific position column.elements.splice(elementIndex, 0, newElement) } else if (position === 'top') { column.elements.unshift(newElement) } else { column.elements.push(newElement) } this.renderEmail() // Calculate the correct element index for selection let selectIndex if (elementIndex !== null) { selectIndex = elementIndex } else if (position === 'top') { selectIndex = 0 } else { selectIndex = column.elements.length - 1 } this.selectElement(selectIndex, rowIndex, columnIndex) } } } createElement(type) { const baseElement = { id: this.generateId(), type: type, content: this.getDefaultContent(type), styles: this.getDefaultStyles(type), properties: this.getDefaultProperties(type) } return baseElement } getDefaultContent(type) { const contentMap = { 'text': 'Click to edit text', 'heading': 'Your Heading Here', 'button': 'Click Me', 'image': 'https://via.placeholder.com/300x200', 'divider': '', 'spacer': '' } return contentMap[type] || '' } getDefaultStyles(type) { const styleMap = { 'text': { fontSize: '16px', color: '#333333', textAlign: 'left', lineHeight: '1.5', width: '100%', padding: '10px', margin: '0' }, 'heading': { fontSize: '24px', color: '#333333', textAlign: 'left', fontWeight: 'bold', lineHeight: '1.2', width: '100%', padding: '10px', margin: '0' }, 'button': { backgroundColor: '#007bff', color: '#ffffff', padding: '12px 24px', borderRadius: '4px', textAlign: 'center', textDecoration: 'none', display: 'inline-block', border: '1px solid #ffffff', width: 'auto' }, 'image': { width: '100%', height: 'auto', display: 'block' }, 'divider': { height: '1px', backgroundColor: '#e0e0e0', margin: '20px 0', width: '100%' }, 'spacer': { height: '20px', width: '100%' } } return styleMap[type] || {} } getDefaultProperties(type) { const propertyMap = { 'text': { editable: true }, 'heading': { editable: true, level: 1 }, 'button': { editable: true, href: '#', target: '_self' }, 'image': { editable: false, alt: 'Image' }, 'divider': { editable: false }, 'spacer': { editable: false, height: 20 } } return propertyMap[type] || {} } addRow() { const newRow = { id: this.generateId(), columns: [{ width: 100, elements: [] }], styles: { backgroundColor: '#ffffff' } } this.emailData.rows.push(newRow) } addColumn(rowIndex) { if (this.emailData.rows[rowIndex]) { const row = this.emailData.rows[rowIndex] const totalWidth = row.columns.reduce((sum, col) => sum + col.width, 0) const newWidth = Math.max(20, 100 - totalWidth) row.columns.push({ width: newWidth, elements: [] }) // Adjust existing columns to fit const remainingWidth = 100 - newWidth const existingColumns = row.columns.length - 1 const newColumnWidth = Math.floor(remainingWidth / existingColumns) row.columns.forEach((col, index) => { if (index < existingColumns) { col.width = newColumnWidth } }) this.renderEmail() } } removeColumn(rowIndex, columnIndex) { if (this.emailData.rows[rowIndex] && this.emailData.rows[rowIndex].columns.length> 1) { this.emailData.rows[rowIndex].columns.splice(columnIndex, 1) this.renderEmail() } } selectElement(elementIndex, rowIndex, columnIndex = 0) { this.selectedElementIndex = elementIndex this.selectedRowIndex = rowIndex this.selectedColumnIndex = columnIndex this.selectedElement = { elementIndex, rowIndex, columnIndex } this.updatePropertiesPanel() this.updateElementSelection() } deselectElement() { this.selectedElementIndex = null this.selectedRowIndex = null this.selectedColumnIndex = null this.selectedElement = null this.updatePropertiesPanel() this.updateElementSelection() } updateElementSelection() { // Remove previous selection this.canvasTarget.querySelectorAll('.selected').forEach(el => { el.classList.remove('selected') }) // Add selection to current element if (this.selectedElementIndex !== null && this.selectedRowIndex !== null) { const element = this.canvasTarget.querySelector( `[data-row-index="${this.selectedRowIndex}"][data-element-index="${this.selectedElementIndex}"]` ) if (element) { element.classList.add('selected') } } } updatePropertiesPanel() { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { // Add safety checks to prevent undefined errors if (this.emailData && this.emailData.rows && this.emailData.rows[this.selectedRowIndex] && this.emailData.rows[this.selectedRowIndex].columns && this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex] && this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements && this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex]) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] this.showPropertiesPanel(element) } else { this.hidePropertiesPanel() } } else { this.hidePropertiesPanel() } } showPropertiesPanel(element) { this.propertiesPanelTarget.innerHTML = this.buildPropertiesPanel(element) this.propertiesPanelTarget.classList.remove('hidden') // Set up border color picker event listeners this.setupBorderColorPicker() } hidePropertiesPanel() { this.propertiesPanelTarget.classList.add('hidden') } setupBorderColorPicker() { const borderColorContainer = this.propertiesPanelTarget.querySelector('.format-color-container') if (!borderColorContainer) return const borderColorBtn = borderColorContainer.querySelector('.format-color-btn') const borderColorPalette = borderColorContainer.querySelector('.format-color-palette') if (!borderColorBtn || !borderColorPalette) return // Initialize border color button const currentBorder = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.border || '' const borderParts = this.parseBorderValue(currentBorder) const borderColor = borderParts.color || '#000000' borderColorBtn.dataset.color = borderColor borderColorBtn.querySelector('.format-color-preview').style.backgroundColor = borderColor // Toggle border color palette borderColorBtn.addEventListener('click', (e) => { e.stopPropagation() borderColorPalette.classList.toggle('show') }) // Handle border color selection borderColorPalette.addEventListener('click', (e) => { e.stopPropagation() const colorOption = e.target.closest('.format-color-option') if (colorOption) { const selectedColor = colorOption.dataset.color borderColorBtn.dataset.color = selectedColor borderColorBtn.querySelector('.format-color-preview').style.backgroundColor = selectedColor // Update border color this.updateBorderColor(selectedColor) // Hide palette borderColorPalette.classList.remove('show') } }) // Hide palette when clicking outside document.addEventListener('click', (e) => { if (!borderColorContainer.contains(e.target)) { borderColorPalette.classList.remove('show') } }) } updateBorderColor(color) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { // Get current border values const currentBorder = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.border || '' const borderParts = this.parseBorderValue(currentBorder) // Update the color borderParts.color = color // Reconstruct the border string const newBorder = this.reconstructBorderString(borderParts) // Update the element this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.border = newBorder this.renderEmail() } } buildPropertiesPanel(element) { return `

Element Properties

${this.buildPropertyFields(element)}
` } buildPropertyFields(element) { let fields = '' // Content field removed - all elements now use inline editing or don't need content editing // Add helpful message for text elements and buttons if (['text', 'heading', 'button'].includes(element.type)) { const message = element.type === 'button' ? 'Use the toolbar above to format text, change font, size, colors, alignment, and button styling.' : 'Use the toolbar above to format text, change font, size, color, and alignment.' fields += `

${message}

` } // Add URL field for button elements if (element.type === 'button') { const href = element.properties.href || '#' const target = element.properties.target || '_self' fields += `
` } // Style fields (exclude toolbar-managed styles for text elements and buttons, and image-specific styles) const isTextElement = ['text', 'heading'].includes(element.type) const isButtonElement = element.type === 'button' const isImageElement = element.type === 'image' const textToolbarManagedStyles = ['fontFamily', 'fontSize', 'color', 'fontWeight', 'fontStyle', 'textDecoration', 'textAlign'] const buttonToolbarManagedStyles = ['fontFamily', 'fontSize', 'color', 'fontWeight', 'fontStyle', 'textDecoration', 'textAlign', 'backgroundColor'] const imageManagedStyles = ['width', 'height', 'textAlign', 'display'] // These are handled by image-specific controls Object.keys(element.styles).forEach(styleKey => { // Skip toolbar-managed styles for text elements and buttons if ((isTextElement && textToolbarManagedStyles.includes(styleKey)) || (isButtonElement && buttonToolbarManagedStyles.includes(styleKey)) || (isImageElement && imageManagedStyles.includes(styleKey))) { return } // Handle padding specially - split into individual fields if (styleKey === 'padding') { const paddingValue = element.styles[styleKey] || '' const paddingParts = this.parsePaddingValue(paddingValue) fields += `
` return } // Handle margin specially for text and heading elements - split into individual fields if (styleKey === 'margin' && ['text', 'heading'].includes(element.type)) { const marginValue = element.styles[styleKey] || '' const marginParts = this.parsePaddingValue(marginValue) // Reuse padding parser for margin fields += `
` return } // Handle display property specially - use dropdown with user-friendly names if (styleKey === 'display') { const displayValue = element.styles[styleKey] || 'block' const displayOptions = [ { value: 'block', label: 'Block (Full Width)' }, { value: 'inline-block', label: 'Inline Block (Natural Width)' }, { value: 'inline', label: 'Inline (Text Flow)' }, { value: 'flex', label: 'Flex Container' }, { value: 'grid', label: 'Grid Container' }, { value: 'none', label: 'Hidden' } ] fields += `
` return } // Handle border radius property specially - use dropdown with user-friendly names if (styleKey === 'borderRadius') { const borderRadiusValue = element.styles[styleKey] || '4px' const borderRadiusOptions = [ { value: '0px', label: 'None (0px)' }, { value: '2px', label: 'Small (2px)' }, { value: '4px', label: 'Medium (4px)' }, { value: '6px', label: 'Large (6px)' }, { value: '8px', label: 'Extra Large (8px)' }, { value: '12px', label: 'Rounded (12px)' }, { value: '16px', label: 'Very Rounded (16px)' }, { value: '50%', label: 'Pill (50%)' } ] fields += `
` return } // Handle border properties specially - separate into individual fields if (['border', 'borderWidth', 'borderColor', 'borderStyle'].includes(styleKey)) { // Skip individual border properties as we'll handle them as a group if (styleKey !== 'border') return const borderValue = element.styles[styleKey] || '' const borderParts = this.parseBorderValue(borderValue) fields += `
` return } fields += `
` }) // Add image-specific properties if (element.type === 'image') { const currentWidth = element.styles.width || '' const currentHeight = element.styles.height || '' const currentAlign = element.styles.textAlign || 'left' fields += `

Leave empty for auto-sizing

Leave empty for auto-sizing

` } return fields } formatStyleLabel(key) { return key.replace(/([A-Z])/g, ' 1ドル').replace(/^./, str => str.toUpperCase()) } parsePaddingValue(paddingValue) { if (!paddingValue) { return { top: '', right: '', bottom: '', left: '' } } // Split by spaces and handle different padding formats const parts = paddingValue.trim().split(/\s+/) if (parts.length === 1) { // Single value: all sides return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] } } else if (parts.length === 2) { // Two values: top/bottom, left/right return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] } } else if (parts.length === 3) { // Three values: top, left/right, bottom return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] } } else if (parts.length === 4) { // Four values: top, right, bottom, left return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] } } return { top: '', right: '', bottom: '', left: '' } } updatePaddingStyle(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const side = e.target.dataset.paddingSide const value = e.target.value // Get current padding values const currentPadding = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.padding || '' const paddingParts = this.parsePaddingValue(currentPadding) // Update the specific side paddingParts[side] = value // Reconstruct the padding string const newPadding = this.reconstructPaddingString(paddingParts) // Update the element this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.padding = newPadding this.renderEmail() } } updateMarginStyle(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const side = e.target.dataset.marginSide const value = e.target.value // Get current margin values const currentMargin = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.margin || '' const marginParts = this.parsePaddingValue(currentMargin) // Reuse padding parser for margin // Update the specific side marginParts[side] = value // Reconstruct the margin string const newMargin = this.reconstructPaddingString(marginParts) // Update the element this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.margin = newMargin this.renderEmail() } } reconstructPaddingString(paddingParts) { const { top, right, bottom, left } = paddingParts // If all values are the same, use single value if (top === right && right === bottom && bottom === left) { return top || '0px' } // If top/bottom are same and left/right are same, use two values if (top === bottom && left === right) { return `${top || '0px'} ${right || '0px'}` } // If left and right are same, use three values if (left === right) { return `${top || '0px'} ${right || '0px'} ${bottom || '0px'}` } // Use four values return `${top || '0px'} ${right || '0px'} ${bottom || '0px'} ${left || '0px'}` } parseBorderValue(borderValue) { if (!borderValue) { return { width: '', style: '', color: '' } } // Parse border shorthand: "1px solid #000000" const parts = borderValue.trim().split(/\s+/) let width = '' let style = '' let color = '' for (const part of parts) { if (part.match(/^\d+px$/) || part.match(/^\d+em$/) || part.match(/^\d+rem$/)) { width = part } else if (['solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset'].includes(part)) { style = part } else if (part.startsWith('#') || part.startsWith('rgb') || part.startsWith('hsl') || part.match(/^[a-zA-Z]+$/)) { color = part } } return { width, style, color } } updateBorderStyle(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const property = e.target.dataset.borderProperty const value = e.target.value // Get current border values const currentBorder = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.border || '' const borderParts = this.parseBorderValue(currentBorder) // Update the specific property borderParts[property] = value // Reconstruct the border string const newBorder = this.reconstructBorderString(borderParts) // Update the element this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles.border = newBorder this.renderEmail() } } reconstructBorderString(borderParts) { const { width, style, color } = borderParts // If no border properties are set, return empty if (!width && !style && !color) { return '' } // Build border string with available properties const parts = [] if (width) parts.push(width) if (style) parts.push(style) if (color) parts.push(color) return parts.join(' ') } updateElementContent(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].content = e.target.value this.renderEmail() } } updateElementStyle(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const styleKey = e.target.dataset.styleKey const value = e.target.value this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex].styles[styleKey] = value this.renderEmail() } } deleteElement() { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements.splice(this.selectedElementIndex, 1) this.deselectElement() this.renderEmail() } } duplicateElement() { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] const duplicatedElement = JSON.parse(JSON.stringify(element)) duplicatedElement.id = this.generateId() this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements.splice( this.selectedElementIndex + 1, 0, duplicatedElement ) this.renderEmail() this.selectElement(this.selectedElementIndex + 1, this.selectedRowIndex, this.selectedColumnIndex) } } renderEmail() { this.canvasTarget.innerHTML = this.buildEditorHTML() this.updatePreview() this.updateElementCount() this.setupDropZones() } buildEditorHTML() { if (this.emailData.rows.length === 0) { // Single row drop zone when empty return `
${this.buildRowDropZone(0, 'empty')}
` } // Build with row drop zones positioned over row seams let html = '' html += this.buildRowDropZone(0, 'above') this.emailData.rows.forEach((row, rowIndex) => { html += this.buildEditorRowHTML(row, rowIndex) // Add row drop zone between rows (except after the last row) if (rowIndex < this.emailData.rows.length - 1) { html += this.buildRowDropZone(rowIndex + 1, 'between') } }) // Add final row drop zone after last row html += this.buildRowDropZone(this.emailData.rows.length, 'below') return html } buildEmailHTML() { if (this.emailData.rows.length === 0) { // Return empty table structure for empty emails return ` ` } // Build email content with proper table structure let contentHTML = '' this.emailData.rows.forEach((row, rowIndex) => { contentHTML += this.buildRowHTML(row, rowIndex) }) return `
${contentHTML}
` } buildEditorRowHTML(row, rowIndex) { return `
${row.columns.map((column, columnIndex) => this.buildEditorColumnHTML(column, rowIndex, columnIndex)).join('')}
` } buildRowHTML(row, rowIndex) { // Calculate total width for columns const totalWidth = row.columns.reduce((sum, col) => sum + col.width, 0) // Build column HTML for email const columnHTML = row.columns.map((column, columnIndex) => { const width = Math.round((column.width / totalWidth) * 100) return this.buildColumnHTML(column, rowIndex, columnIndex, width) }).join('') return `
${columnHTML}
` } buildEditorColumnHTML(column, rowIndex, columnIndex) { // Check if any element in this column is a button or image with textAlign const buttonElement = column.elements.find(el => el.type === 'button' && el.styles.textAlign) const imageElement = column.elements.find(el => el.type === 'image' && el.styles.textAlign) const alignedElement = buttonElement || imageElement const columnStyle = alignedElement ? `text-align: ${alignedElement.styles.textAlign};` : '' // Check if column is empty const isEmpty = !column.elements || column.elements.length === 0 // Build elements with insertion points between them let elementsHTML = '' if (isEmpty) { elementsHTML = this.buildEmptyColumnDropZone(rowIndex, columnIndex) } else { // Add insertion point before first element elementsHTML += this.buildElementInsertionPoint(rowIndex, columnIndex, 0, 'before') // Add elements with insertion points between them column.elements.forEach((element, elementIndex) => { elementsHTML += this.buildEditorElementHTML(element, rowIndex, columnIndex, elementIndex) // Add insertion point after this element (before the next one) if (elementIndex < column.elements.length - 1) { elementsHTML += this.buildElementInsertionPoint(rowIndex, columnIndex, elementIndex + 1, 'after') } }) // Add insertion point after last element elementsHTML += this.buildElementInsertionPoint(rowIndex, columnIndex, column.elements.length, 'after') } return `
${elementsHTML}
` } buildEmptyColumnDropZone(rowIndex, columnIndex) { return `
Drop element here
` } buildElementInsertionPoint(rowIndex, columnIndex, elementIndex, position) { return `
` } buildRowDropZone(rowIndex, position) { const isEmpty = position === 'empty' const marginClass = position === 'between' ? '-my-3' : '' const baseStyle = isEmpty ? 'height: 64px; opacity: 1; border: 2px dashed rgba(59,130,246,0.6); background: rgba(239,246,255,0.8);' : 'height: 0; opacity: 0; border: 0; background: transparent;' return `
` } buildColumnHTML(column, rowIndex, columnIndex, width) { // Check if any element in this column is a button or image with textAlign const buttonElement = column.elements.find(el => el.type === 'button' && el.styles.textAlign) const imageElement = column.elements.find(el => el.type === 'image' && el.styles.textAlign) const alignedElement = buttonElement || imageElement const textAlign = alignedElement ? alignedElement.styles.textAlign : 'left' // Build elements HTML const elementsHTML = column.elements.map((element, elementIndex) => { return this.buildElementHTML(element, rowIndex, columnIndex, elementIndex) }).join('') return ` ${elementsHTML} ` } buildEditorElementHTML(element, rowIndex, columnIndex, elementIndex) { const elementHTML = this.getEditorElementHTML(element) return `
${elementHTML}
` } buildElementHTML(element, rowIndex, columnIndex, elementIndex) { // For email output, return clean HTML without wrapper divs return this.getElementHTML(element) } handleElementDragStart(e) { const el = e.currentTarget const origin = { rowIndex: parseInt(el.dataset.rowIndex), columnIndex: parseInt(el.dataset.columnIndex), elementIndex: parseInt(el.dataset.elementIndex) } e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('application/element-origin', JSON.stringify(origin)) // also set generic to avoid clearing other handlers e.dataTransfer.setData('text/element-origin', JSON.stringify(origin)) el.style.opacity = '0.6' } handleElementDragEnd(e) { const el = e.currentTarget el.style.opacity = '1' } getEditorElementHTML(element) { // For buttons and images, exclude textAlign from the element styles since it's applied to the column let styles = element.styles if (['button', 'image'].includes(element.type)) { styles = { ...element.styles } delete styles.textAlign } const styleString = this.buildStyleString(styles) switch (element.type) { case 'text': return `

${element.content}

` case 'heading': const level = element.properties.level || 1 return `${element.content}` case 'button': return `${element.content}` case 'image': return `${element.properties.alt}` case 'divider': return `
` case 'spacer': return `` default: return `
${element.content}
` } } getElementHTML(element) { // For buttons and images, exclude textAlign from the element styles since it's applied to the column let styles = element.styles if (['button', 'image'].includes(element.type)) { styles = { ...element.styles } delete styles.textAlign } const styleString = this.buildStyleString(styles) switch (element.type) { case 'text': return `

${element.content}

` case 'heading': const level = element.properties.level || 1 const headingMargin = level === 1 ? '0 0 20px 0' : level === 2 ? '0 0 15px 0' : '0 0 10px 0' return `${element.content}` case 'button': // Create button as table for better email client support const buttonStyle = `${styleString}; text-decoration: none; display: inline-block;` return `
${element.content}
` case 'image': return `${element.properties.alt || ''}` case 'divider': return `
` case 'spacer': return `
` default: return `
${element.content}
` } } buildStyleString(styles) { const styleString = Object.entries(styles) .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) .join('; ') // Debug logging for border radius changes if (styles.borderRadius) { console.log('buildStyleString called with styles:', styles) console.log('Generated style string:', styleString) } return styleString } camelToKebab(str) { return str.replace(/([a-z0-9])([A-Z])/g, '1ドル-2ドル').toLowerCase() } updatePreview() { if (this.hasPreviewTarget) { this.previewTarget.innerHTML = this.buildEmailHTML() } } updateElementCount() { if (this.hasElementCountTarget) { const totalElements = this.emailData.rows.reduce((count, row) => { return count + (row.elements ? row.elements.length : 0) }, 0) this.elementCountTarget.textContent = totalElements } } togglePreview() { if (this.hasPreviewModalTarget) { this.previewModalTarget.classList.toggle('hidden') if (!this.previewModalTarget.classList.contains('hidden')) { this.updatePreview() } } } closePreview() { if (this.hasPreviewModalTarget) { this.previewModalTarget.classList.add('hidden') } } addRowAction() { const arrangement = this.columnArrangementTarget.value this.addRowWithArrangement(arrangement) this.renderEmail() } stopPropagation(e) { e.stopPropagation() } addRowWithArrangement(arrangement) { const newRow = this.createRowWithArrangement(arrangement) this.emailData.rows.push(newRow) } addRowWithArrangementAtPosition(arrangement, rowIndex, columnIndex, position) { // Create the row with the specified arrangement const newRow = this.createRowWithArrangement(arrangement) if (position === 'new-row') { // Add at the end this.emailData.rows.push(newRow) } else if (position === 'first-element') { // Add at the beginning this.emailData.rows.unshift(newRow) } else if (position === 'above-row') { // Insert above the specified row this.emailData.rows.splice(rowIndex, 0, newRow) } else { // Default: add at the end this.emailData.rows.push(newRow) } this.renderEmail() } createRowWithArrangement(arrangement) { console.log('🎯 createRowWithArrangement called with arrangement:', arrangement) let columns = [] switch (arrangement) { case '1': columns = [{ width: 100, elements: [] }] break case '2-1-2': columns = [ { width: 50, elements: [] }, { width: 50, elements: [] } ] break case '2-1-3': columns = [ { width: 33.33, elements: [] }, { width: 66.67, elements: [] } ] break case '2-2-3': columns = [ { width: 66.67, elements: [] }, { width: 33.33, elements: [] } ] break case '3': columns = [ { width: 33.33, elements: [] }, { width: 33.33, elements: [] }, { width: 33.34, elements: [] } ] break case '4': columns = [ { width: 25, elements: [] }, { width: 25, elements: [] }, { width: 25, elements: [] }, { width: 25, elements: [] } ] break default: columns = [{ width: 100, elements: [] }] } return { id: this.generateId(), columns: columns, styles: { backgroundColor: '#ffffff' } } } generateId() { return 'element_' + Math.random().toString(36).substr(2, 9) } // Actions for buttons addColumn(e) { const rowIndex = parseInt(e.target.dataset.rowIndex) this.addColumn(rowIndex) } deleteRow(e) { const rowIndex = parseInt(e.target.dataset.rowIndex) this.emailData.rows.splice(rowIndex, 1) this.deselectElement() this.renderEmail() } removeColumn(e) { const rowIndex = parseInt(e.target.dataset.rowIndex) const columnIndex = parseInt(e.target.dataset.columnIndex) this.removeColumn(rowIndex, columnIndex) } selectElementAction(e) { // Find the email-element container (in case we clicked on a child element) const elementContainer = e.target.closest('.email-element') if (!elementContainer) return const elementIndex = parseInt(elementContainer.dataset.elementIndexParam) const rowIndex = parseInt(elementContainer.dataset.rowIndexParam) const columnIndex = parseInt(elementContainer.dataset.columnIndexParam) // Only proceed if we have valid indices if (!isNaN(elementIndex) && !isNaN(rowIndex) && !isNaN(columnIndex)) { this.selectElement(elementIndex, rowIndex, columnIndex) } } handleInlineEditFocus(e) { // Find the element container to get the indices const elementContainer = e.target.closest('.email-element') if (!elementContainer) { return } const rowIndex = parseInt(elementContainer.dataset.rowIndex) const columnIndex = parseInt(elementContainer.dataset.columnIndex) const elementIndex = parseInt(elementContainer.dataset.elementIndex) // Select the element and update properties panel immediately this.selectElement(elementIndex, rowIndex, columnIndex) // Show formatting toolbar for text-based elements this.showFormattingToolbar(elementContainer, e.target) } handleInlineEdit(e) { // Find the element container to get the indices const elementContainer = e.target.closest('.email-element') if (!elementContainer) return const rowIndex = parseInt(elementContainer.dataset.rowIndex) const columnIndex = parseInt(elementContainer.dataset.columnIndex) const elementIndex = parseInt(elementContainer.dataset.elementIndex) // Update the content in the data model with HTML (to preserve formatting) if (this.emailData.rows[rowIndex] && this.emailData.rows[rowIndex].columns[columnIndex] && this.emailData.rows[rowIndex].columns[columnIndex].elements[elementIndex]) { this.emailData.rows[rowIndex].columns[columnIndex].elements[elementIndex].content = e.target.innerHTML // Update toolbar button states const toolbar = this.canvasTarget.querySelector('.formatting-toolbar') if (toolbar) { this.updateToolbarButtons(toolbar, e.target) } // Clear any existing timeout if (this.inlineEditTimeout) { clearTimeout(this.inlineEditTimeout) } // Update the properties panel after a short delay (debounced) this.inlineEditTimeout = setTimeout(() => { if (this.selectedElement && this.selectedElement.rowIndex === rowIndex && this.selectedElement.columnIndex === columnIndex && this.selectedElement.elementIndex === elementIndex) { this.updatePropertiesPanel() } }, 300) // 300ms delay } } handleInlineEditBlur(e) { // Clear any pending timeout if (this.inlineEditTimeout) { clearTimeout(this.inlineEditTimeout) } // Don't hide toolbar if focus is moving to toolbar elements const toolbar = this.canvasTarget.querySelector('.formatting-toolbar') if (toolbar && toolbar.contains(e.relatedTarget)) { return } // Hide formatting toolbar this.hideFormattingToolbar() // Ensure the element is selected after editing const elementContainer = e.target.closest('.email-element') if (!elementContainer) return const rowIndex = parseInt(elementContainer.dataset.rowIndex) const columnIndex = parseInt(elementContainer.dataset.columnIndex) const elementIndex = parseInt(elementContainer.dataset.elementIndex) this.selectElement(elementIndex, rowIndex, columnIndex) } getSelectedElementData() { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { return this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] } return null } showFormattingToolbar(elementContainer, editableElement) { // Check if it's a text-based element or button const elementType = this.getSelectedElementData()?.type if (!['text', 'heading', 'button'].includes(elementType)) { return } // Remove any existing toolbar this.hideFormattingToolbar() // Create toolbar const toolbar = document.createElement('div') toolbar.className = 'formatting-toolbar' // Build toolbar content based on element type let toolbarContent = '' if (['text', 'heading'].includes(elementType)) { // Text/heading toolbar toolbarContent = `
` } else if (elementType === 'button') { // Button toolbar toolbarContent = `
` } toolbar.innerHTML = toolbarContent // Position toolbar above the element const rect = elementContainer.getBoundingClientRect() const canvasRect = this.canvasTarget.getBoundingClientRect() // Make sure canvas has relative positioning for absolute children if (getComputedStyle(this.canvasTarget).position === 'static') { this.canvasTarget.style.position = 'relative' } toolbar.style.position = 'absolute' toolbar.style.left = `${rect.left - canvasRect.left}px` toolbar.style.top = `${rect.top - canvasRect.top - 50}px` toolbar.style.zIndex = '1000' toolbar.style.pointerEvents = 'auto' // Add event listeners to toolbar toolbar.addEventListener('mousedown', (e) => { // Don't prevent default for select elements or color picker if (e.target.tagName === 'SELECT' || e.target.type === 'color') { return } e.preventDefault() e.stopPropagation() const button = e.target.closest('.format-btn') if (button) { const command = button.dataset.command this.executeFormatCommand(editableElement, command) this.updateToolbarButtons(toolbar, editableElement) } }) // Font controls const fontFamilySelect = toolbar.querySelector('.font-family-select') const fontSizeSelect = toolbar.querySelector('.font-size-select') const colorContainer = toolbar.querySelector('.format-color-container') const colorBtn = toolbar.querySelector('.format-color-btn') const colorPalette = toolbar.querySelector('.format-color-palette') // Button-specific controls const backgroundColorContainer = toolbar.querySelector('.background-color-container') const backgroundColorBtn = backgroundColorContainer?.querySelector('.format-color-btn') const backgroundColorPalette = backgroundColorContainer?.querySelector('.format-color-palette') // Initialize selects to current computed style const computed = window.getComputedStyle(editableElement) if (fontFamilySelect) { // Try to match an option by startsWith of family const fam = computed.fontFamily.replaceAll('"', "'") Array.from(fontFamilySelect.options).forEach(opt => { if (opt.value && fam && fam.toLowerCase().startsWith(opt.value.replaceAll('"', "'").toLowerCase().split(',')[0])) { fontFamilySelect.value = opt.value } }) // Add both click and change handlers fontFamilySelect.addEventListener('click', (e) => { e.stopPropagation() }) fontFamilySelect.addEventListener('change', (e) => { e.stopPropagation() const value = e.target.value if (value) { editableElement.style.fontFamily = value this.updateSelectedElementStyle('fontFamily', value) this.updateToolbarButtons(toolbar, editableElement) } }) } if (fontSizeSelect) { const size = computed.fontSize Array.from(fontSizeSelect.options).forEach(opt => { if (opt.value === size) { fontSizeSelect.value = opt.value } }) // Add both click and change handlers fontSizeSelect.addEventListener('click', (e) => { e.stopPropagation() }) fontSizeSelect.addEventListener('change', (e) => { e.stopPropagation() const value = e.target.value if (value) { editableElement.style.fontSize = value this.updateSelectedElementStyle('fontSize', value) this.updateToolbarButtons(toolbar, editableElement) } }) } if (colorContainer && colorBtn && colorPalette) { // Initialize color button to current computed color const color = computed.color let hexColor = '#333333' // Convert rgb() to hex if needed if (color.startsWith('rgb')) { const rgb = color.match(/\d+/g) if (rgb && rgb.length>= 3) { hexColor = '#' + rgb.map(x => parseInt(x).toString(16).padStart(2, '0')).join('') } } else if (color.startsWith('#')) { hexColor = color } // Update button color and data colorBtn.dataset.color = hexColor colorBtn.querySelector('.format-color-preview').style.backgroundColor = hexColor // Toggle palette visibility colorBtn.addEventListener('click', (e) => { e.stopPropagation() colorPalette.classList.toggle('show') editableElement.focus() }) // Handle color selection colorPalette.addEventListener('click', (e) => { e.stopPropagation() const colorOption = e.target.closest('.format-color-option') if (colorOption) { const selectedColor = colorOption.dataset.color colorBtn.dataset.color = selectedColor colorBtn.querySelector('.format-color-preview').style.backgroundColor = selectedColor // Check if there's a text selection const selection = window.getSelection() if (selection.rangeCount> 0 && !selection.isCollapsed) { // Apply color only to selected text const range = selection.getRangeAt(0) const span = document.createElement('span') span.style.color = selectedColor try { range.surroundContents(span) } catch (e) { // If surroundContents fails, extract content and wrap it const contents = range.extractContents() span.appendChild(contents) range.insertNode(span) } selection.removeAllRanges() } else { // No selection, apply color to entire element editableElement.style.color = selectedColor this.updateSelectedElementStyle('color', selectedColor) } this.updateToolbarButtons(toolbar, editableElement) // Hide palette colorPalette.classList.remove('show') } }) // Hide palette when clicking outside document.addEventListener('click', (e) => { if (!colorContainer.contains(e.target)) { colorPalette.classList.remove('show') } }) } // Button background color controls if (backgroundColorContainer && backgroundColorBtn && backgroundColorPalette) { // Initialize background color button const bgColor = computed.backgroundColor let bgHexColor = '#007bff' if (bgColor.startsWith('rgb')) { const rgb = bgColor.match(/\d+/g) if (rgb && rgb.length>= 3) { bgHexColor = '#' + rgb.map(x => parseInt(x).toString(16).padStart(2, '0')).join('') } } else if (bgColor.startsWith('#')) { bgHexColor = bgColor } backgroundColorBtn.dataset.color = bgHexColor backgroundColorBtn.querySelector('.format-color-preview').style.backgroundColor = bgHexColor // Toggle background color palette backgroundColorBtn.addEventListener('click', (e) => { e.stopPropagation() backgroundColorPalette.classList.toggle('show') editableElement.focus() }) // Handle background color selection backgroundColorPalette.addEventListener('click', (e) => { e.stopPropagation() const colorOption = e.target.closest('.format-color-option') if (colorOption) { const selectedColor = colorOption.dataset.color backgroundColorBtn.dataset.color = selectedColor backgroundColorBtn.querySelector('.format-color-preview').style.backgroundColor = selectedColor // Apply background color to button editableElement.style.backgroundColor = selectedColor this.updateSelectedElementStyle('backgroundColor', selectedColor) this.updateToolbarButtons(toolbar, editableElement) // Hide palette backgroundColorPalette.classList.remove('show') } }) } // Add event listeners to editable element for real-time updates const updateToolbarState = () => { this.updateToolbarButtons(toolbar, editableElement) } editableElement.addEventListener('keyup', updateToolbarState) editableElement.addEventListener('mouseup', updateToolbarState) editableElement.addEventListener('selectionchange', updateToolbarState) // Store reference for cleanup toolbar._editableElement = editableElement toolbar._updateHandler = updateToolbarState // Add to canvas this.canvasTarget.appendChild(toolbar) // Update button states this.updateToolbarButtons(toolbar, editableElement) } hideFormattingToolbar() { const existingToolbar = this.canvasTarget.querySelector('.formatting-toolbar') if (existingToolbar) { // Clean up event listeners if (existingToolbar._editableElement && existingToolbar._updateHandler) { existingToolbar._editableElement.removeEventListener('keyup', existingToolbar._updateHandler) existingToolbar._editableElement.removeEventListener('mouseup', existingToolbar._updateHandler) existingToolbar._editableElement.removeEventListener('selectionchange', existingToolbar._updateHandler) } existingToolbar.remove() } } executeFormatCommand(element, command) { // Focus the element first element.focus() // Handle link creation if (command === 'createLink') { this.createLink(element) return } // Handle alignment commands differently if (command.startsWith('justify')) { const alignment = command.replace('justify', '').toLowerCase() // Check if this is a button element const elementData = this.getSelectedElementData() const isButton = elementData && elementData.type === 'button' if (isButton) { // For buttons, align the entire button by setting text-align on the parent column const elementContainer = element.closest('.email-element') const columnElement = elementContainer.closest('.email-column') if (columnElement) { columnElement.style.textAlign = alignment // Also update the element's data model this.updateSelectedElementStyle('textAlign', alignment) } } else { // For text/heading elements, set text alignment on the element itself element.style.textAlign = alignment // Also try using document.execCommand for better compatibility try { document.execCommand(command, false, null) } catch (e) { // Fallback to style method if execCommand fails } // Force a re-render to see the change element.style.display = 'none' element.offsetHeight // Trigger reflow element.style.display = 'block' } } else { // Check if there's a text selection for text formatting commands const selection = window.getSelection() if (selection.rangeCount> 0 && !selection.isCollapsed && ['bold', 'italic', 'underline'].includes(command)) { // Apply formatting to selected text only const range = selection.getRangeAt(0) const span = document.createElement('span') switch (command) { case 'bold': span.style.fontWeight = 'bold' break case 'italic': span.style.fontStyle = 'italic' break case 'underline': span.style.textDecoration = 'underline' break } try { range.surroundContents(span) } catch (e) { // If surroundContents fails, extract content and wrap it const contents = range.extractContents() span.appendChild(contents) range.insertNode(span) } selection.removeAllRanges() } else { // No selection or not a text formatting command, use execCommand document.execCommand(command, false, null) } } // Update the data model this.updateElementContentFromInlineEdit(element) // Update toolbar buttons to reflect the change const toolbar = this.canvasTarget.querySelector('.formatting-toolbar') if (toolbar) { this.updateToolbarButtons(toolbar, element) } } updateToolbarButtons(toolbar, element) { // Update button states based on current formatting const buttons = toolbar.querySelectorAll('.format-btn') buttons.forEach(button => { const command = button.dataset.command let isActive = false // Focus the element to ensure commands work element.focus() switch (command) { case 'bold': isActive = document.queryCommandState('bold') break case 'italic': isActive = document.queryCommandState('italic') break case 'underline': isActive = document.queryCommandState('underline') break case 'justifyLeft': // Check computed style for text alignment const elementData = this.getSelectedElementData() const isButton = elementData && elementData.type === 'button' if (isButton) { const elementContainer = element.closest('.email-element') const columnElement = elementContainer.closest('.email-column') const computedStyle = window.getComputedStyle(columnElement) isActive = computedStyle.textAlign === 'left' || computedStyle.textAlign === 'start' } else { const computedStyle = window.getComputedStyle(element) isActive = computedStyle.textAlign === 'left' || computedStyle.textAlign === 'start' } break case 'justifyCenter': const elementDataCenter = this.getSelectedElementData() const isButtonCenter = elementDataCenter && elementDataCenter.type === 'button' if (isButtonCenter) { const elementContainer = element.closest('.email-element') const columnElement = elementContainer.closest('.email-column') isActive = window.getComputedStyle(columnElement).textAlign === 'center' } else { isActive = window.getComputedStyle(element).textAlign === 'center' } break case 'justifyRight': const elementDataRight = this.getSelectedElementData() const isButtonRight = elementDataRight && elementDataRight.type === 'button' if (isButtonRight) { const elementContainer = element.closest('.email-element') const columnElement = elementContainer.closest('.email-column') isActive = window.getComputedStyle(columnElement).textAlign === 'right' } else { isActive = window.getComputedStyle(element).textAlign === 'right' } break } button.classList.toggle('active', isActive) }) // Update selects (font family, size, and color) const fontFamilySelect = toolbar.querySelector('.font-family-select') const fontSizeSelect = toolbar.querySelector('.font-size-select') const colorBtn = toolbar.querySelector('.format-color-btn') const backgroundColorBtn = toolbar.querySelector('.background-color-container .format-color-btn') if (fontFamilySelect) { const fam = window.getComputedStyle(element).fontFamily.replaceAll('"', "'") let matched = '' Array.from(fontFamilySelect.options).forEach(opt => { const base = opt.value.replaceAll('"', "'").split(',')[0].trim().toLowerCase() if (!matched && base && fam.toLowerCase().startsWith(base)) { matched = opt.value } }) fontFamilySelect.value = matched || '' } if (fontSizeSelect) { const size = window.getComputedStyle(element).fontSize let found = Array.from(fontSizeSelect.options).some(opt => opt.value === size) fontSizeSelect.value = found ? size : '' } if (colorBtn) { const color = window.getComputedStyle(element).color let hexColor = '#333333' // Convert rgb() to hex if needed if (color.startsWith('rgb')) { const rgb = color.match(/\d+/g) if (rgb && rgb.length>= 3) { hexColor = '#' + rgb.map(x => parseInt(x).toString(16).padStart(2, '0')).join('') } } else if (color.startsWith('#')) { hexColor = color } // Update color button colorBtn.dataset.color = hexColor colorBtn.querySelector('.format-color-preview').style.backgroundColor = hexColor } // Update button-specific controls if (backgroundColorBtn) { const bgColor = window.getComputedStyle(element).backgroundColor let bgHexColor = '#007bff' if (bgColor.startsWith('rgb')) { const rgb = bgColor.match(/\d+/g) if (rgb && rgb.length>= 3) { bgHexColor = '#' + rgb.map(x => parseInt(x).toString(16).padStart(2, '0')).join('') } } else if (bgColor.startsWith('#')) { bgHexColor = bgColor } backgroundColorBtn.dataset.color = bgHexColor backgroundColorBtn.querySelector('.format-color-preview').style.backgroundColor = bgHexColor } } updateElementContentFromInlineEdit(element) { // Find the element container to get the indices const elementContainer = element.closest('.email-element') if (!elementContainer) return const rowIndex = parseInt(elementContainer.dataset.rowIndex) const columnIndex = parseInt(elementContainer.dataset.columnIndex) const elementIndex = parseInt(elementContainer.dataset.elementIndex) // Update the content in the data model with HTML if (this.emailData.rows[rowIndex] && this.emailData.rows[rowIndex].columns[columnIndex] && this.emailData.rows[rowIndex].columns[columnIndex].elements[elementIndex]) { this.emailData.rows[rowIndex].columns[columnIndex].elements[elementIndex].content = element.innerHTML } } updateSelectedElementStyle(styleKey, styleValue) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const el = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] el.styles = el.styles || {} el.styles[styleKey] = styleValue } } updateButtonUrl(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'button') { element.properties = element.properties || {} element.properties.href = e.target.value this.renderEmail() } } } updateButtonTarget(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'button') { element.properties = element.properties || {} element.properties.target = e.target.value this.renderEmail() } } } updateImageWidth(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'image') { const widthType = e.target.parentElement.querySelector('select').value const value = e.target.value.trim() if (value === '') { element.styles.width = 'auto' } else if (widthType === 'auto') { element.styles.width = 'auto' } else { element.styles.width = value + widthType } this.renderEmail() } } } updateImageWidthType(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'image') { const widthInput = e.target.parentElement.querySelector('input') const value = widthInput.value.trim() const widthType = e.target.value if (value === '') { element.styles.width = 'auto' } else if (widthType === 'auto') { element.styles.width = 'auto' } else { element.styles.width = value + widthType } this.renderEmail() } } } updateImageHeight(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'image') { const heightType = e.target.parentElement.querySelector('select').value const value = e.target.value.trim() if (value === '') { element.styles.height = 'auto' } else if (heightType === 'auto') { element.styles.height = 'auto' } else { element.styles.height = value + heightType } this.renderEmail() } } } updateImageHeightType(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'image') { const heightInput = e.target.parentElement.querySelector('input') const value = heightInput.value.trim() const heightType = e.target.value if (value === '') { element.styles.height = 'auto' } else if (heightType === 'auto') { element.styles.height = 'auto' } else { element.styles.height = value + heightType } this.renderEmail() } } } updateImageAlignment(e) { if (this.selectedElementIndex !== null && this.selectedRowIndex !== null && this.selectedColumnIndex !== null) { const element = this.emailData.rows[this.selectedRowIndex].columns[this.selectedColumnIndex].elements[this.selectedElementIndex] if (element.type === 'image') { const alignment = e.target.dataset.alignment // Update the element's textAlign style element.styles.textAlign = alignment // Re-render to apply the changes this.renderEmail() // Update the properties panel to reflect the new alignment this.updatePropertiesPanel() } } } setSmartImageWidth(imageElement, src) { // Get canvas width (assuming max-width of 600px for email) const canvasWidth = 600 // Create a temporary image to get its natural dimensions const img = new Image() img.onload = () => { const imageWidth = img.naturalWidth const imageHeight = img.naturalHeight console.log('🖼️ Image dimensions:', { imageWidth, imageHeight, canvasWidth }) // Set width to the smaller of image width or canvas width if (imageWidth <= canvasWidth) { // Image fits within canvas, use its natural width imageElement.styles.width = imageWidth + 'px' console.log('🖼️ Using natural width:', imageWidth + 'px') } else { // Image is larger than canvas, use canvas width imageElement.styles.width = canvasWidth + 'px' console.log('🖼️ Using canvas width:', canvasWidth + 'px') } // Set height to auto to maintain aspect ratio imageElement.styles.height = 'auto' // Re-render to apply the new dimensions this.renderEmail() } img.onerror = () => { console.warn('🖼️ Failed to load image for dimension calculation, using default width') // Fallback to a reasonable default imageElement.styles.width = '300px' imageElement.styles.height = 'auto' this.renderEmail() } img.src = src } createLink(element) { const selection = window.getSelection() // Check if there's a text selection if (selection.rangeCount> 0 && !selection.isCollapsed) { // Get the selected text const selectedText = selection.toString() // Prompt for URL const url = prompt('Enter the URL for the link:', 'https://') if (url && url.trim() !== '') { // Create the link element const link = document.createElement('a') link.href = url link.textContent = selectedText link.target = '_blank' link.style.color = '#0066cc' link.style.textDecoration = 'underline' // Replace the selected text with the link const range = selection.getRangeAt(0) range.deleteContents() range.insertNode(link) // Clear selection selection.removeAllRanges() // Update the data model this.updateElementContentFromInlineEdit(element) // Update toolbar buttons const toolbar = this.canvasTarget.querySelector('.formatting-toolbar') if (toolbar) { this.updateToolbarButtons(toolbar, element) } } } else { // No text selected, show a message alert('Please select some text to create a link.') } } // Save functionality async saveEmail() { const htmlContent = this.generateHTML() const jsonContent = JSON.stringify(this.emailData) try { const response = await fetch(this.saveUrlValue, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify({ email_campaign_id: this.emailCampaignIdValue, html_content: htmlContent, json_content: jsonContent }) }) if (response.ok) { this.showNotification('Email saved successfully!', 'success') } else { throw new Error('Failed to save email') } } catch (error) { this.showNotification('Failed to save email', 'error') } } generateHTML() { const emailHTML = ` Email
${this.buildEmailHTML()}

AltStyle によって変換されたページ (->オリジナル) /

` return emailHTML } showNotification(message, type) { const notification = document.createElement('div') notification.className = `fixed top-4 right-4 z-50 p-4 rounded-md shadow-lg ${ type === 'success' ? 'bg-green-50 border border-green-200 text-green-800' : 'bg-red-50 border border-red-200 text-red-800' }` notification.textContent = message document.body.appendChild(notification) setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification) } }, 3000) } // Image Modal Methods showImageModal() { console.log('🖼️ showImageModal called') // Find the modal controller and show it const modalElement = document.querySelector('[data-controller*="modal"]') console.log('🖼️ modalElement found:', modalElement) if (modalElement) { const modalController = this.application.getControllerForElementAndIdentifier( modalElement, 'modal' ) console.log('🖼️ modalController found:', modalController) if (modalController) { modalController.show() } } } handleImageSelected(event) { console.log('🎯 handleImageSelected called', event.detail) const { src, alt } = event.detail console.log('🎯 Image data:', { src, alt }) this.createImageElement(src, alt) } createImageElement(src, alt) { console.log('🎯 createImageElement called with:', { src, alt }) // Create image element const imageElement = this.createElement('image') imageElement.content = src imageElement.properties.alt = alt // Set smart default width - we'll calculate this after the image loads this.setSmartImageWidth(imageElement, src) console.log('🎯 Created image element:', imageElement) // Check if we have a pending insertion from a drop zone if (this.pendingImageInsertion) { const { rowIndex, columnIndex, elementIndex, position } = this.pendingImageInsertion this.pendingImageInsertion = null // Clear the pending insertion if (columnIndex !== undefined) { // Inserting into existing column console.log('🎯 Inserting image into existing column:', { rowIndex, columnIndex, elementIndex, position }) // Add the image element to the existing column if (this.emailData.rows[rowIndex] && this.emailData.rows[rowIndex].columns[columnIndex]) { const column = this.emailData.rows[rowIndex].columns[columnIndex] if (!column.elements) { column.elements = [] } // Handle element insertion points if (elementIndex !== null) { column.elements.splice(elementIndex, 0, imageElement) } else if (position === 'top') { column.elements.unshift(imageElement) } else { column.elements.push(imageElement) } this.renderEmail() // Calculate the correct element index for selection let selectIndex if (elementIndex !== null) { selectIndex = elementIndex } else if (position === 'top') { selectIndex = 0 } else { selectIndex = column.elements.length - 1 } this.selectElement(selectIndex, rowIndex, columnIndex) } } else { // Creating new row console.log('🎯 Creating new row with image at index:', rowIndex) const newRow = { id: this.generateId(), columns: [{ width: 100, elements: [imageElement] }], styles: { backgroundColor: '#ffffff' } } const idx = Math.max(0, Math.min(rowIndex, this.emailData.rows.length)) this.emailData.rows.splice(idx, 0, newRow) this.renderEmail() this.selectElement(0, idx, 0) } } else { // Add to canvas (default behavior) if (this.emailData.rows.length === 0) { console.log('🎯 Adding new row') this.addRow() } const lastRow = this.emailData.rows[this.emailData.rows.length - 1] if (!lastRow.columns[0].elements) { lastRow.columns[0].elements = [] } lastRow.columns[0].elements.push(imageElement) console.log('🎯 Added image to canvas, rendering email') this.renderEmail() this.selectElement(lastRow.columns[0].elements.length - 1, this.emailData.rows.length - 1) } } // Override the addElementToCanvas method to show modal for images addElementToCanvas(elementType) { console.log('🎯 addElementToCanvas called with:', elementType) if (elementType === 'image') { console.log('🖼️ Image element detected, showing modal') this.showImageModal() return } // Original logic for other elements if (this.emailData.rows.length === 0) { this.addRow() } const lastRow = this.emailData.rows[this.emailData.rows.length - 1] const newElement = this.createElement(elementType) if (!lastRow.columns[0].elements) { lastRow.columns[0].elements = [] } lastRow.columns[0].elements.push(newElement) this.renderEmail() this.selectElement(lastRow.columns[0].elements.length - 1, this.emailData.rows.length - 1) } }