import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["editor", "toolbar", "content"] static values = { content: String, placeholder: String } connect() { this.initializeEditor() this.setupEventListeners() this.updateToolbar() } initializeEditor() { // Set up the contenteditable div this.editorTarget.contentEditable = true this.editorTarget.innerHTML = this.contentValue || this.placeholderValue || "" // Add email-specific CSS classes this.editorTarget.classList.add("email-editor-content") // Set up placeholder behavior this.setupPlaceholder() } setupPlaceholder() { const editor = this.editorTarget if (!editor.textContent.trim()) { editor.innerHTML = `

${this.placeholderValue || "Enter your email content here..."}

` } editor.addEventListener('focus', () => { if (editor.textContent.trim() === this.placeholderValue) { editor.innerHTML = "" } }) editor.addEventListener('blur', () => { if (!editor.textContent.trim()) { editor.innerHTML = `

${this.placeholderValue || "Enter your email content here..."}

` } }) } setupEventListeners() { // Listen for content changes this.editorTarget.addEventListener('input', () => { this.updateContent() this.updateToolbar() this.makeImagesClickable() // Make new images clickable this.makeButtonsEditable() // Make new buttons editable }) // Listen for selection changes document.addEventListener('selectionchange', () => { this.updateToolbar() }) // Listen for clicks on images in the editor this.editorTarget.addEventListener('click', (event) => { if (event.target.tagName === 'IMG') { event.preventDefault() this.replaceImage(event.target) } }) // Make all existing images clickable this.makeImagesClickable() // Make all existing buttons editable this.makeButtonsEditable() } makeImagesClickable() { const images = this.editorTarget.querySelectorAll('img') images.forEach(img => { if (!img.hasAttribute('data-clickable')) { img.style.cursor = 'pointer' img.title = 'Click to replace image' img.setAttribute('data-clickable', 'true') // Add hover effect only (no click handler - using global one) img.addEventListener('mouseenter', () => { img.style.opacity = '0.8' img.style.border = '2px solid #3b82f6' img.style.borderRadius = '4px' }) img.addEventListener('mouseleave', () => { img.style.opacity = '1' img.style.border = 'none' img.style.borderRadius = '0' }) } }) } makeButtonsEditable() { const buttons = this.editorTarget.querySelectorAll('a[style*="background"]') buttons.forEach(button => { if (!button.hasAttribute('data-editable')) { button.style.cursor = 'pointer' button.title = 'Click to edit button' button.setAttribute('data-editable', 'true') // Add hover effect button.addEventListener('mouseenter', () => { button.style.opacity = '0.9' button.style.transform = 'scale(1.02)' }) button.addEventListener('mouseleave', () => { button.style.opacity = '1' button.style.transform = 'scale(1)' }) // Add click handler for editing button.addEventListener('click', (event) => { event.preventDefault() this.editButton(button) }) } }) } // Toolbar actions bold() { document.execCommand('bold', false, null) this.editorTarget.focus() } italic() { document.execCommand('italic', false, null) this.editorTarget.focus() } underline() { document.execCommand('underline', false, null) this.editorTarget.focus() } alignLeft() { this.alignText('left') } alignCenter() { this.alignText('center') } alignRight() { this.alignText('right') } alignText(alignment) { const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) let container = range.commonAncestorContainer // Find the paragraph element while (container && container.nodeType !== Node.ELEMENT_NODE) { container = container.parentNode } while (container && !container.matches('p, h1, h2, h3, h4, h5, h6')) { container = container.parentNode } if (container) { container.style.textAlign = alignment } } } changeFont(event) { const fontFamily = event.target.value this.applyFormatting('fontFamily', fontFamily) this.editorTarget.focus() } changeFontSize(event) { const fontSize = event.target.value this.applyFormatting('fontSize', fontSize) this.editorTarget.focus() } applyFormatting(property, value) { const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) // If there's a selection, apply to the selection if (!range.collapsed) { // Create a span to wrap the selection const span = document.createElement('span') span.style[property] = value try { range.surroundContents(span) } catch (e) { // If surroundContents fails, extract content and wrap it const contents = range.extractContents() span.appendChild(contents) range.insertNode(span) } } else { // If no selection, apply to the current paragraph let container = range.commonAncestorContainer // Find the paragraph element while (container && container.nodeType !== Node.ELEMENT_NODE) { container = container.parentNode } while (container && !container.matches('p, h1, h2, h3, h4, h5, h6, span, div')) { container = container.parentNode } if (container) { container.style[property] = value } } } } createLink() { const url = prompt('Enter URL:') if (url) { document.execCommand('createLink', false, url) } this.editorTarget.focus() } insertButton() { const text = prompt('Enter button text:', 'Click Here') const url = prompt('Enter button URL:', '#') if (text && url) { const button = document.createElement('a') button.href = url button.textContent = text button.style.background = '#3b82f6' button.style.color = 'white' button.style.padding = '12px 24px' button.style.textDecoration = 'none' button.style.borderRadius = '6px' button.style.display = 'inline-block' button.style.fontWeight = '600' button.style.fontSize = '16px' button.style.margin = '10px 0' button.style.cursor = 'pointer' button.title = 'Click to edit button' // Add click handler to make button editable button.addEventListener('click', (event) => { event.preventDefault() this.editButton(button) }) const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) range.insertNode(button) range.setStartAfter(button) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } this.updateContent() } this.editorTarget.focus() } editButton(buttonElement) { const newText = prompt('Enter new button text:', buttonElement.textContent) const newUrl = prompt('Enter new button URL:', buttonElement.href) if (newText !== null) { buttonElement.textContent = newText } if (newUrl !== null) { buttonElement.href = newUrl } this.updateContent() } insertImage() { // Create a hidden file input const fileInput = document.createElement('input') fileInput.type = 'file' fileInput.accept = 'image/*' fileInput.style.display = 'none' fileInput.addEventListener('change', (event) => { const file = event.target.files[0] if (file) { this.uploadImage(file) } }) // Trigger the file dialog document.body.appendChild(fileInput) fileInput.click() document.body.removeChild(fileInput) this.editorTarget.focus() } uploadImage(file) { // Create a FileReader to convert file to data URL const reader = new FileReader() reader.onload = (event) => { const img = document.createElement('img') img.src = event.target.result img.style.maxWidth = '100%' img.style.height = 'auto' img.style.display = 'block' img.style.margin = '10px 0' img.style.cursor = 'pointer' img.title = 'Click to replace image' // Mark as clickable for styling (global click handler will handle clicks) img.setAttribute('data-clickable', 'true') const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) range.insertNode(img) range.setStartAfter(img) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } this.updateContent() } reader.readAsDataURL(file) } replaceImage(imgElement) { // Create a hidden file input for replacement const fileInput = document.createElement('input') fileInput.type = 'file' fileInput.accept = 'image/*' fileInput.style.display = 'none' fileInput.addEventListener('change', (event) => { const file = event.target.files[0] if (file) { const reader = new FileReader() reader.onload = (event) => { imgElement.src = event.target.result this.updateContent() } reader.readAsDataURL(file) } }) // Trigger the file dialog document.body.appendChild(fileInput) fileInput.click() document.body.removeChild(fileInput) } insertTable() { const table = this.createEmailTable(2, 2) const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) range.insertNode(table) } this.editorTarget.focus() } createEmailTable(rows, cols) { const table = document.createElement('table') table.style.width = '100%' table.style.borderCollapse = 'collapse' table.style.margin = '10px 0' table.style.border = '1px solid #ddd' for (let i = 0; i < rows; i++) { const tr = document.createElement('tr') for (let j = 0; j < cols; j++) { const td = document.createElement('td') td.style.border = '1px solid #ddd' td.style.padding = '10px' td.style.verticalAlign = 'top' td.innerHTML = ' ' tr.appendChild(td) } table.appendChild(tr) } return table } insertHorizontalRule() { const hr = document.createElement('hr') hr.style.border = 'none' hr.style.borderTop = '1px solid #ddd' hr.style.margin = '20px 0' const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) range.insertNode(hr) } this.editorTarget.focus() } insertUnorderedList() { document.execCommand('insertUnorderedList', false, null) this.editorTarget.focus() } insertOrderedList() { document.execCommand('insertOrderedList', false, null) this.editorTarget.focus() } insertBlockquote() { const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) const blockquote = document.createElement('blockquote') blockquote.style.borderLeft = '4px solid #ddd' blockquote.style.margin = '10px 0' blockquote.style.paddingLeft = '20px' blockquote.style.fontStyle = 'italic' blockquote.style.color = '#666' try { range.surroundContents(blockquote) } catch (e) { // If surroundContents fails, insert the blockquote blockquote.innerHTML = range.toString() range.deleteContents() range.insertNode(blockquote) } } this.editorTarget.focus() } insertCodeBlock() { const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) const pre = document.createElement('pre') const code = document.createElement('code') code.style.backgroundColor = '#f5f5f5' code.style.padding = '10px' code.style.display = 'block' code.style.fontFamily = 'monospace' code.style.border = '1px solid #ddd' code.style.borderRadius = '4px' pre.appendChild(code) try { range.surroundContents(pre) } catch (e) { code.innerHTML = range.toString() range.deleteContents() range.insertNode(pre) } } this.editorTarget.focus() } // Email Templates insertTemplateFromDropdown(event) { const templateType = event.target.value if (!templateType) return // Load template via Turbo this.loadTemplate(templateType) // Reset the dropdown to the default option event.target.value = '' this.editorTarget.focus() } async loadTemplate(templateType) { try { const response = await fetch(`/admin/email_templates/${templateType}?preview=true&t=${Date.now()}`, { headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' } }) if (!response.ok) { throw new Error(`Failed to load template: ${response.statusText}`) } const templateHTML = await response.text() this.insertTemplateFromHTML(templateHTML) } catch (error) { console.error('Error loading template:', error) // Disabled legacy fallback: always require server templates alert('Failed to load template from server. Please refresh and try again.') } } insertTemplateFromHTML(htmlString) { // Clear existing content and set the template HTML directly this.editorTarget.innerHTML = htmlString // Make the template interactive this.makeImagesClickable() this.makeButtonsEditable() this.updateContent() } // Legacy fallback removed: templates must load from server insertTemplate(template) { // Clear existing content and insert template this.editorTarget.innerHTML = '' // Ensure template is a valid DOM element if (template && template.nodeType === Node.ELEMENT_NODE) { this.editorTarget.appendChild(template) } else { console.error('Template is not a valid DOM element:', template) return } this.updateContent() this.editorTarget.focus() } // Update toolbar state based on selection updateToolbar() { const buttons = this.toolbarTarget.querySelectorAll('button') buttons.forEach(button => { const command = button.dataset.command if (command) { button.classList.toggle('active', document.queryCommandState(command)) } }) // Update font controls this.updateFontControls() } updateFontControls() { const selection = window.getSelection() if (selection.rangeCount> 0) { const range = selection.getRangeAt(0) let container = range.commonAncestorContainer // Find the element with font styling while (container && container.nodeType !== Node.ELEMENT_NODE) { container = container.parentNode } while (container && !container.matches('p, h1, h2, h3, h4, h5, h6, span, div')) { container = container.parentNode } if (container) { const computedStyle = window.getComputedStyle(container) const fontFamily = computedStyle.fontFamily const fontSize = computedStyle.fontSize // Update font family select const fontSelect = this.toolbarTarget.querySelector('.font-select') if (fontSelect) { const options = fontSelect.querySelectorAll('option') for (let option of options) { if (fontFamily.includes(option.value.split(',')[0])) { fontSelect.value = option.value break } } } // Update font size select const fontSizeSelect = this.toolbarTarget.querySelector('.font-size-select') if (fontSizeSelect) { const options = fontSizeSelect.querySelectorAll('option') for (let option of options) { if (option.value === fontSize) { fontSizeSelect.value = option.value break } } } } } } // Update the hidden content field updateContent() { const content = this.generateEmailHTML() this.contentTarget.value = content } // Generate email-compatible HTML generateEmailHTML() { const content = this.editorTarget.innerHTML // Check if content already has a table structure (from templates) const hasTableStructure = content.includes('') let emailHTML if (hasTableStructure) { // For template-based content, don't wrap in additional table structure emailHTML = ` Email

Email

${this.processContentForEmail(content)}

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

` } else { // For non-template content, wrap in email-compatible structure emailHTML = ` Email
${this.processContentForEmail(content)}
` } return emailHTML } // Process content to be email-compatible processContentForEmail(content) { // For table-based templates, return content as-is to preserve structure if (content.includes(']*)>/g, '') content = content.replace(/<\/div>/g, '

') // Ensure all elements have inline styles content = this.addInlineStyles(content) return content } // Add inline styles to elements addInlineStyles(content) { const temp = document.createElement('div') temp.innerHTML = content // Process all elements const elements = temp.querySelectorAll('*') elements.forEach(element => { const computedStyle = window.getComputedStyle(element) const inlineStyle = element.getAttribute('style') || '' // Add common email styles if (element.tagName === 'P' && !inlineStyle.includes('margin')) { element.style.margin = '10px 0' } if (element.tagName === 'H1' && !inlineStyle.includes('font-size')) { element.style.fontSize = '24px' element.style.fontWeight = 'bold' element.style.margin = '20px 0 10px 0' } if (element.tagName === 'H2' && !inlineStyle.includes('font-size')) { element.style.fontSize = '20px' element.style.fontWeight = 'bold' element.style.margin = '15px 0 8px 0' } if (element.tagName === 'H3' && !inlineStyle.includes('font-size')) { element.style.fontSize = '18px' element.style.fontWeight = 'bold' element.style.margin = '12px 0 6px 0' } }) return temp.innerHTML } // Get the current content getContent() { return this.contentTarget.value } // Set content setContent(content) { this.editorTarget.innerHTML = content this.updateContent() } }