'
}
// 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 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 `
`
}
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 `
`
}
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 `