PageSourceSearch

https://railsblocks.com/assets/controllers/emoji_picker_controller…be0b521a9edfbe5a8d1c60d06e4e96e988b30962.js

js railsblocks.com collected 2026-09-25 20:23:16 UTC 14,623 bytes, 434 lines download raw bytes

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.