1import { Controller } from '@hotwired/stimulus'; 2import Modal from '../../js/modal.js'; 3 4/** 5 * Mail Merge Controller 6 * 7 * Handles insertion of mail merge tags into various input types: 8 * - Regular text inputs 9 * - Textareas 10 * - WYSIWYG editors (via TipTap) 11 * 12 * Usage: 13 * <div data-controller="admin--mail-merge" 14 * data-admin--mail-merge-admin--wysiwyg-outlet="#wysiwyg-wrapper"> 15 * <button data-action="click->admin--mail-merge#insertTag" 16 * data-tag="{{customer.firstName}}"> 17 * Insert First Name 18 * </button> 19 * </div> 20 */ 21export default class extends Controller { 22 static outlets = ['admin--wysiwyg']; 23 static targets = ['input']; 24 static values = { 25 targetSelector: String, // Optional: selector for the target input 26 category: String // Optional: category for filtering merge tags (e.g., 'customer', 'gift_card') 27 }; 28 29 /** 30 * Store reference to the input target when button is clicked 31 * This ensures we know which input to insert into when using the modal 32 */ 33 storeInput(event) { 34 if (this.hasInputTarget) { 35 window._lastFocusedInput = this.inputTarget; 36 } 37 } 38 39 /** 40 * Store reference to the wysiwyg outlet when button is clicked from toolbar 41 */ 42 storeWysiwyg(event) { 43 if (this.hasAdminWysiwygOutlet) { 44 window._lastFocusedWysiwyg = this.adminWysiwygOutlet; 45 } 46 // Store the category for filtering tags in the modal 47 if (this.hasCategoryValue) { 48 window._mailMergeCategory = this.categoryValue; 49 } else { 50 window._mailMergeCategory = null; 51 } 52 } 53 54 /** 55 * Insert a mail merge tag 56 * Can be called with data-tag attribute or by passing the tag directly 57 */ 58 insertTag(event) { 59 const button = event.currentTarget; 60 const tag = button.dataset.tag || event.detail?.tag; 61 62 if (!tag) { 63 console.warn('No tag provided for insertion'); 64 return; 65 } 66 67 // Check if we have a stored wysiwyg reference (from toolbar button click) 68 if (window._lastFocusedWysiwyg && window._lastFocusedWysiwyg.editor) { 69 this.insertIntoWysiwygEditor(window._lastFocusedWysiwyg.editor, tag); 70 this.closeModal(); 71 return; 72 } 73 74 // Check if we have a stored input reference (from React components or buttons) 75 if (window._lastFocusedInput) { 76 this.insertIntoInput(tag); 77 } 78 // Try to insert into WYSIWYG editor if outlet is connected 79 else if (this.hasAdminWysiwygOutlet && this.adminWysiwygOutlet.editor) { 80 this.insertIntoWysiwyg(tag); 81 } 82 // Fall back to regular input/textarea insertion 83 else { 84 this.insertIntoInput(tag); 85 } 86 87 // Close the modal and clear the stored input reference 88 this.closeModal(); 89 } 90 91 /** 92 * Close the modal and clean up 93 */ 94 closeModal() { 95 // Clear the stored references 96 window._lastFocusedInput = null; 97 window._lastFocusedWysiwyg = null; 98 window._mailMergeCategory = null; 99 100 // Close the modal using the imported Modal class 101 const modalElement = document.getElementById('mail_merge_modal'); 102 if (modalElement) { 103 Modal.close(modalElement); 104 } 105 } 106 107 /** 108 * Insert tag into TipTap WYSIWYG editor 109 */ 110 insertIntoWysiwyg(tag) { 111 const editor = this.adminWysiwygOutlet.editor; 112 this.insertIntoWysiwygEditor(editor, tag); 113 } 114 115 /** 116 * Insert tag into a specific TipTap editor instance 117 */ 118 insertIntoWysiwygEditor(editor, tag) { 119 120 if (!editor) { 121 console.warn('WYSIWYG editor not initialized'); 122 return; 123 } 124 125 // Insert the tag at current cursor position 126 editor.chain().focus().insertContent(tag).run(); 127 } 128 129 /** 130 * Insert tag into regular input or textarea 131 */ 132 insertIntoInput(tag) { 133 let input; 134 135 // Check for globally stored last focused input (for React components) 136 if (window._lastFocusedInput) { 137 input = window._lastFocusedInput; 138 } 139 // Try to find input using target 140 else if (this.hasInputTarget) { 141 input = this.inputTarget; 142 } 143 // Try using targetSelector value 144 else if (this.hasTargetSelectorValue) { 145 input = document.querySelector(this.targetSelectorValue); 146 } 147 // Fall back to finding any input/textarea in the controller element 148 else { 149 input = this.element.querySelector('input[type="text"], input[type="email"], textarea'); 150 } 151 152 if (!input) { 153 console.warn('No input element found for tag insertion'); 154 return; 155 } 156 157 // Get cursor position
158 const cursorPos = input.selectionStart || input.value.length; 159 const textBefore = input.value.substring(0, cursorPos); 160 const textAfter = input.value.substring(input.selectionEnd || cursorPos); 161 162 // Insert tag at cursor position 163 input.value = textBefore + tag + textAfter; 164 165 // Set cursor position after inserted tag 166 const newCursorPos = cursorPos + tag.length; 167 input.setSelectionRange(newCursorPos, newCursorPos); 168 169 // Focus the input 170 input.focus(); 171 172 // For React inputs, we need to trigger the onChange handler manually 173 // React 16+ stores event handlers in a special property 174 const reactKey = Object.keys(input).find(key => key.startsWith('__reactProps$')); 175 if (reactKey && input[reactKey]?.onChange) { 176 input[reactKey].onChange({ target: input }); 177 } else { 178 // Fallback: Try native value setter trick 179 const prototype = input instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; 180 const nativeInputValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set; 181 nativeInputValueSetter.call(input, input.value); 182 183 // Dispatch input event 184 const inputEvent = new Event('input', { bubbles: true }); 185 input.dispatchEvent(inputEvent); 186 } 187 } 188 189 /** 190 * Insert tag using custom event (alternative API) 191 * Usage: this.element.dispatchEvent(new CustomEvent('mail-merge:insert', { detail: { tag: '{{value}}' }})) 192 */ 193 connect() { 194 this.element.addEventListener('mail-merge:insert', (event) => { 195 const tag = event.detail?.tag; 196 if (tag) { 197 this.insertTag({ 198 currentTarget: this.element, 199 detail: { tag } 200 }); 201 } 202 }); 203 } 204}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.