1import { Controller } from "@hotwired/stimulus"; 2import "emoji-mart"; // Use this if you installed emoji-mart with importmap 3// import { Picker } from "emoji-mart"; // Use this if you installed emoji-mart with npm or yarn 4import data from "@emoji-mart/data"; 5 6export default class extends Controller { 7 static targets = ["input", "pickerContainer", "button"]; // Targets for the input field, picker container, and button 8 static values = { 9 autoSubmit: { type: Boolean, default: false }, // Whether to automatically submit the form when an emoji is selected 10 insertMode: { type: Boolean, default: false }, // Whether to insert emoji at cursor position instead of replacing entire value 11 targetSelector: { type: String, default: "" }, // CSS selector for a specific input/textarea to insert emoji into 12 }; 13 14 currentTheme = null; // Store the current theme 15 lastCursorPosition = { start: 0, end: 0 }; // Store cursor position 16 lastFocusedField = null; // Store the last focused field 17 18 connect() { 19 // Prevent duplicate initialization 20 if (this.element.dataset.emojiPickerInitialized === "true") { 21 return; 22 } 23 24 // Mark as initialized 25 this.element.dataset.emojiPickerInitialized = "true"; 26 27 // Use setTimeout to ensure DOM is fully rendered after Turbo navigation 28 setTimeout(() => { 29 this.initializePicker(); 30 this.setupEventListeners(); 31 this.findForm(); 32 this.setupThemeObserver(); 33 }, 0); 34 } 35 36 findForm() { 37 // Find the closest form element 38 this.form = this.element.closest("form") || this.element.querySelector("form"); 39 } 40 41 setupEventListeners() { 42 // Prevent duplicate event listeners 43 if (this.eventListenersSetup) { 44 return; 45 } 46 this.eventListenersSetup = true; 47 48 // Add document click event listener to handle outside clicks 49 this.outsideClickHandler = this.handleOutsideClick.bind(this); 50 document.addEventListener("click", this.outsideClickHandler); 51 52 // Add keyboard event listener for escape key and navigation 53 this.keydownHandler = this.handleKeydown.bind(this); 54 document.addEventListener("keydown", this.keydownHandler); 55 56 // Add keydown listener to button for backspace/delete functionality 57 if (this.hasButtonTarget) { 58 this.buttonKeydownHandler = this.handleButtonKeydown.bind(this); 59 this.buttonTarget.addEventListener("keydown", this.buttonKeydownHandler); 60 // Make button focusable 61 this.buttonTarget.setAttribute("tabindex", "0"); 62 // Add tooltip hint for backspace functionality only when not in insert mode 63 if (!this.insertModeValue) { 64 this.buttonTarget.setAttribute("title", "Press Backspace or Delete to remove emoji"); 65 } 66 } 67 68 // Track focus on target fields 69 this.setupFieldTracking(); 70 } 71 72 setupFieldTracking() { 73 const targetField = this.getTargetField(); 74 if (targetField) { 75 // Track focus events 76 this.focusHandler = () => { 77 this.lastFocusedField = targetField; 78 }; 79 80 // Track selection changes 81 this.selectionHandler = () => { 82 if (targetField === document.activeElement) { 83 this.lastCursorPosition = { 84 start: targetField.selectionStart, 85 end: targetField.selectionEnd, 86 }; 87 } 88 }; 89 90 targetField.addEventListener("focus", this.focusHandler); 91 targetField.addEventListener("click", this.selectionHandler); 92 targetField.addEventListener("keyup", this.selectionHandler); 93 targetField.addEventListener("select", this.selectionHandler); 94 } 95 } 96 97 handleButtonKeydown(event) { 98 // Delete emoji if backspace or delete is pressed (only when not in insert mode) 99 if ((event.key === "Backspace" || event.key === "Delete") && !this.insertModeValue) { 100 event.preventDefault(); 101 this.clearEmoji(); 102 } 103 } 104 105 clearEmoji() { 106 if (this.hasInputTarget) { 107 // Clear the input value 108 this.inputTarget.value = ""; 109 110 // Update button HTML to show default icon 111 if (this.hasButtonTarget) { 112 this.updateButtonToDefault(); 113 } 114 115 // Submit the form to save the change only if auto-submit is enabled 116 if (this.autoSubmitValue && this.form) { 117 this.form.requestSubmit(); 118 } 119 } 120 } 121 122 updateButtonToDefault() { 123 // Default emoji face icon 124 const iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-4,7c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4,6c-1.531,0-2.859-1.14-3.089-2.651-.034-.221,.039-.444,.193-.598,.151-.15,.358-.217,.572-.185,1.526,.24,3.106,.24,4.638,.001h0c.217-.032,.428,.036,.583,.189,.153,.153,.225,.373,.192,.589-.229,1.513-1.557,2.654-3.089,2.654Zm3-5c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"></path></g></svg>`; 125 this.buttonTarget.innerHTML = iconHtml; 126 } 127 128 initializePicker() { 129 try { 130 // Check if we have the pickerContainer target 131 if (!this.hasPickerContainerTarget) { 132 return; 133 } 134 135 // Prevent duplicate picker initialization 136 if (this.picker) { 137 return; 138 } 139 140 // Detect the current theme from the document 141 const isDarkMode = document.documentElement.classList.contains("dark"); 142 this.currentTheme = isDarkMode ? "dark" : "light"; 143 144 // Use imported emoji data instead of CDN to avoid network issues 145 this.picker = new EmojiMart.Picker({ 146 data: data, 147 onEmojiSelect: this.onEmojiSelect.bind(this), 148 emojiButtonColors: "#FF9E66", 149 emojiVersion: 15, 150 previewPosition: "none", 151 dynamicWidth: true, 152 theme: this.currentTheme, 153 }); 154 155 this.pickerContainerTarget.innerHTML = ""; 156 this.pickerContainerTarget.appendChild(this.picker); 157 } catch (error) { 158 console.error("Error initializing emoji picker:", error); 159 } 160 } 161 162 toggle(event) { 163 event.preventDefault(); 164 event.stopPropagation(); // Prevent this click from being caught by the document listener 165 166 try { 167 // Check if we have the pickerContainer target 168 if (!this.hasPickerContainerTarget) { 169 return; 170 } 171 172 // Store cursor position before opening picker (for insert mode) 173 if (this.insertModeValue) { 174 const targetField = this.getTargetField(); 175 if (targetField) { 176 // If the field was never focused, don't update cursor position 177 if (targetField === document.activeElement || this.lastFocusedField === targetField) { 178 this.lastFocusedField = targetField; 179 this.lastCursorPosition = { 180 start: targetField.selectionStart || 0, 181 end: targetField.selectionEnd || 0, 182 }; 183 } 184 } 185 } 186 187 this.pickerContainerTarget.classList.toggle("hidden"); 188 } catch (error) { 189 console.error("Error toggling emoji picker:", error); 190 } 191 } 192 193 handleOutsideClick(event) { 194 // If picker is visible and click is outside picker and outside the toggle button 195 if ( 196 this.hasPickerContainerTarget && 197 !this.pickerContainerTarget.classList.contains("hidden") && 198 !this.pickerContainerTarget.contains(event.target) && 199 (!this.hasButtonTarget || !this.buttonTarget.contains(event.target)) 200 ) { 201 this.pickerContainerTarget.classList.add("hidden"); 202 } 203 } 204 205 handleKeydown(event) { 206 // Close picker when Escape key is pressed 207 if ( 208 event.key === "Escape" && 209 this.hasPickerContainerTarget && 210 !this.pickerContainerTarget.classList.contains("hidden") 211 ) { 212 this.pickerContainerTarget.classList.add("hidden"); 213 return; 214 } 215 216 // Focus on search input when picker is open and navigation/typing occurs 217 if ( 218 this.hasPickerContainerTarget && 219 !this.pickerContainerTarget.classList.contains("hidden") && 220 this.shouldFocusSearch(event) 221 ) { 222 this.focusSearchInput(); 223 } 224 } 225 226 shouldFocusSearch(event) { 227 // Check for arrow keys 228 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) { 229 return true; 230 } 231 232 // Check for typing (alphanumeric characters, space, etc.) 233 if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { 234 return true; 235 } 236 237 return false;
238 } 239 240 focusSearchInput() { 241 try { 242 // The emoji picker uses shadow DOM, so we need to access the search input through it 243 if (this.picker && this.picker.shadowRoot) { 244 const searchInput = this.picker.shadowRoot.querySelector('input[type="search"]'); 245 if (searchInput) { 246 searchInput.focus(); 247 } 248 } 249 } catch (error) { 250 console.error("Error focusing search input:", error); 251 } 252 } 253 254 onEmojiSelect(emoji) { 255 try { 256 const targetField = this.getTargetField(); 257 258 if (!targetField && !this.hasInputTarget) { 259 return; 260 } 261 262 // Insert or replace based on mode 263 if (this.insertModeValue && targetField) { 264 // Insert emoji at cursor position 265 this.insertAtCursor(targetField, emoji.native); 266 } else if (this.hasInputTarget) { 267 // Replace entire value (original behavior) 268 this.inputTarget.value = emoji.native; 269 } 270 271 // Update the button with the selected emoji 272 if (this.hasButtonTarget && !this.insertModeValue) { 273 this.buttonTarget.innerHTML = `<span class="size-6 text-xl shrink-0 flex items-center justify-center">${emoji.native}</span>`; 274 } 275 276 if (this.hasPickerContainerTarget) { 277 this.pickerContainerTarget.classList.add("hidden"); 278 } 279 280 // Submit the form if it exists and auto-submit is enabled 281 if (this.autoSubmitValue && this.form) { 282 this.form.requestSubmit(); 283 } 284 } catch (error) { 285 console.error("Error selecting emoji:", error); 286 } 287 } 288 289 disconnect() { 290 // Clean up initialization flag 291 if (this.element) { 292 delete this.element.dataset.emojiPickerInitialized; 293 } 294 295 // Clean up event listeners when controller disconnects 296 if (this.outsideClickHandler) { 297 document.removeEventListener("click", this.outsideClickHandler); 298 this.outsideClickHandler = null; 299 } 300 if (this.keydownHandler) { 301 document.removeEventListener("keydown", this.keydownHandler); 302 this.keydownHandler = null; 303 } 304 305 // Remove button keydown listener if it exists 306 if (this.hasButtonTarget && this.buttonKeydownHandler) { 307 this.buttonTarget.removeEventListener("keydown", this.buttonKeydownHandler); 308 this.buttonKeydownHandler = null; 309 } 310 311 // Clean up field tracking listeners 312 const targetField = this.getTargetField(); 313 if (targetField) { 314 if (this.focusHandler) { 315 targetField.removeEventListener("focus", this.focusHandler); 316 } 317 if (this.selectionHandler) { 318 targetField.removeEventListener("click", this.selectionHandler); 319 targetField.removeEventListener("keyup", this.selectionHandler); 320 targetField.removeEventListener("select", this.selectionHandler); 321 } 322 } 323 324 // Clean up picker 325 if (this.picker && this.hasPickerContainerTarget && this.pickerContainerTarget.contains(this.picker)) { 326 this.pickerContainerTarget.removeChild(this.picker); 327 this.picker = null; 328 } 329 330 // Reset flags 331 this.eventListenersSetup = false; 332 this.currentTheme = null; 333 this.lastCursorPosition = { start: 0, end: 0 }; 334 this.lastFocusedField = null; 335 336 // Clean up mutation observer 337 if (this.themeObserver) { 338 this.themeObserver.disconnect(); 339 } 340 } 341 342 setupThemeObserver() { 343 // Create a mutation observer to watch for theme changes 344 this.themeObserver = new MutationObserver((mutations) => { 345 mutations.forEach((mutation) => { 346 if (mutation.type === "attributes" && mutation.attributeName === "class") { 347 const newTheme = document.documentElement.classList.contains("dark") ? "dark" : "light"; 348 349 // If theme changed, re-initialize the picker 350 if (newTheme !== this.currentTheme) { 351 this.reinitializePicker(); 352 } 353 } 354 }); 355 }); 356 357 // Start observing the document element for class changes 358 this.themeObserver.observe(document.documentElement, { 359 attributes: true, 360 attributeFilter: ["class"], 361 }); 362 } 363 364 reinitializePicker() { 365 // Clean up existing picker 366 if (this.picker) { 367 this.picker = null; 368 if (this.hasPickerContainerTarget) { 369 this.pickerContainerTarget.innerHTML = ""; 370 } 371 } 372 373 // Re-initialize with new theme 374 this.initializePicker(); 375 } 376 377 // Get the actual input/textarea element to insert emoji into 378 getTargetField() { 379 // If a custom selector is provided, use that 380 if (this.targetSelectorValue) { 381 const customTarget = document.querySelector(this.targetSelectorValue); 382 if (customTarget && (customTarget.tagName === "INPUT" || customTarget.tagName === "TEXTAREA")) { 383 return customTarget; 384 } 385 } 386 387 // Otherwise use the input target 388 return this.hasInputTarget ? this.inputTarget : null; 389 } 390 391 // Insert text at cursor position in input/textarea 392 insertAtCursor(field, text) { 393 if (!field) return; 394 395 // Save current scroll position 396 const scrollPos = field.scrollTop; 397 398 // If field was never focused/selected, append at the end 399 let startPos, endPos; 400 if (this.lastFocusedField !== field && this.lastCursorPosition.start === 0 && this.lastCursorPosition.end === 0) { 401 // Field was never selected, append at end 402 startPos = field.value.length; 403 endPos = field.value.length; 404 } else { 405 // Use stored cursor position if available, otherwise use current 406 startPos = this.lastCursorPosition.start || field.selectionStart || 0; 407 endPos = this.lastCursorPosition.end || field.selectionEnd || 0; 408 } 409 410 // Insert the text 411 const beforeText = field.value.substring(0, startPos); 412 const afterText = field.value.substring(endPos);
413 field.value = beforeText + text + afterText; 414 415 // Set cursor position after the inserted text 416 const newCursorPos = startPos + text.length; 417 field.setSelectionRange(newCursorPos, newCursorPos); 418 419 // Update stored position 420 this.lastCursorPosition = { 421 start: newCursorPos, 422 end: newCursorPos, 423 }; 424 425 // Restore scroll position 426 field.scrollTop = scrollPos; 427 428 // Focus the field 429 field.focus(); 430 431 // Trigger input event for any listeners (like autogrow) 432 field.dispatchEvent(new Event("input", { bubbles: true })); 433 } 434};
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.