Created
December 31, 2025 06:39
-
-
Save yaodong/b55d3d66bf5726a3284a64e91dc3e7f4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Controller } from "@hotwired/stimulus" | |
| export default class extends Controller { | |
| static targets = ["editor", "results"] | |
| static values = { | |
| debounceMs: { type: Number, default: 300 }, | |
| initialized: { type: Boolean, default: false }, | |
| isMobile: { type: Boolean, default: false } | |
| } | |
| connect() { | |
| this.resultsTarget.hidden = true | |
| this.reset() | |
| // Bind methods to preserve context | |
| this.handleKeydown = this.handleKeydown.bind(this) | |
| this.handleTrixInitialize = this.handleTrixInitialize.bind(this) | |
| this.handleTouchStart = this.handleTouchStart.bind(this) | |
| this.handleClickOutside = this.handleClickOutside.bind(this) | |
| this.debouncedSearch = this.debounce(this.searchContacts.bind(this), this.debounceMs) | |
| // Detect mobile device | |
| this.detectMobileDevice() | |
| window.addEventListener("resize", this.debounce(this.detectMobileDevice.bind(this), 250)) | |
| // Setup outside click handler | |
| document.addEventListener("click", this.handleClickOutside) | |
| // Wait for Trix to initialize before accessing the editor | |
| this.editorTarget.addEventListener("trix-initialize", this.handleTrixInitialize) | |
| } | |
| detectMobileDevice() { | |
| const isMobile = window.innerWidth < 768 | |
| this.isMobileValue = isMobile | |
| // Adjust the dropdown max-height for mobile | |
| if (isMobile) { | |
| this.resultsTarget.style.maxHeight = "150px" | |
| } else { | |
| this.resultsTarget.style.maxHeight = "200px" | |
| } | |
| } | |
| handleClickOutside(event) { | |
| // Only handle if dropdown is visible | |
| if (this.resultsTarget.hidden) return | |
| // Close dropdown if click is outside the dropdown and editor | |
| if (!this.resultsTarget.contains(event.target) && | |
| !this.editorTarget.contains(event.target)) { | |
| this.hideResults() | |
| } | |
| } | |
| handleTrixInitialize() { | |
| // Guard against multiple initializations | |
| if (this.initializedValue) return | |
| this.editor = this.editorTarget.editor | |
| if (!this.editor) { | |
| console.error("Trix editor not available") | |
| return | |
| } | |
| this.initializedValue = true | |
| // Attach event listeners | |
| this.editorTarget.addEventListener("keydown", this.handleKeydown) | |
| // Add touch events for mobile | |
| if (this.isMobileValue) { | |
| this.editorTarget.addEventListener("touchstart", this.handleTouchStart) | |
| } | |
| this.editorTarget.addEventListener("trix-change", () => { | |
| // Check if we're currently searching | |
| if (!this.isSearching) return | |
| // Make sure the editor is available | |
| if (!this.editor) return | |
| const position = this.editor.getSelectedRange()[0] | |
| const text = this.editor.getDocument().toString().substring(0, position) | |
| // Check if @ is still in text before the cursor | |
| if (!text.includes("@")) { | |
| this.hideResults() | |
| return | |
| } | |
| // Get text after the @ symbol | |
| const mentionText = text.substring(text.lastIndexOf("@") + 1) | |
| // Show loading state | |
| this.showLoadingState() | |
| // Search with whatever text we have after @, including empty string | |
| this.debouncedSearch(mentionText) | |
| }) | |
| } | |
| disconnect() { | |
| // Clean up all event listeners | |
| if (this.editorTarget) { | |
| this.editorTarget.removeEventListener("keydown", this.handleKeydown) | |
| this.editorTarget.removeEventListener("trix-initialize", this.handleTrixInitialize) | |
| if (this.isMobileValue) { | |
| this.editorTarget.removeEventListener("touchstart", this.handleTouchStart) | |
| } | |
| } | |
| // Remove global event listeners | |
| document.removeEventListener("click", this.handleClickOutside) | |
| window.removeEventListener("resize", this.detectMobileDevice) | |
| // Clear any pending timeouts | |
| if (this._searchTimeout) { | |
| clearTimeout(this._searchTimeout) | |
| } | |
| this.initializedValue = false | |
| } | |
| // Handle touch events on mobile | |
| handleTouchStart(event) { | |
| // Only look for "@" character in the editor | |
| if (!this.editor) return | |
| const position = this.editor.getSelectedRange()[0] | |
| const text = this.editor.getDocument().toString().substring(0, position) | |
| // If we're already searching, continue | |
| if (this.isSearching) return | |
| // Check if text contains @ symbol | |
| if (text.includes("@")) { | |
| this.isSearching = true | |
| const mentionText = text.substring(text.lastIndexOf("@") + 1) | |
| this.showLoadingState() | |
| this.debouncedSearch(mentionText) | |
| } | |
| } | |
| // Debounce helper function | |
| debounce(func, wait) { | |
| let timeout | |
| return function(...args) { | |
| clearTimeout(timeout) | |
| this._searchTimeout = timeout = setTimeout(() => func.apply(this, args), wait) | |
| } | |
| } | |
| reset() { | |
| this.isSearching = false | |
| this.selectedIndex = 0 | |
| this.searchResults = [] | |
| } | |
| handleKeydown(event) { | |
| // Safety check for editor availability | |
| if (!this.editor) return | |
| // Start searching when @ is typed | |
| if (event.key === "@") { | |
| this.isSearching = true | |
| // Immediately start a search with empty query to show all contacts | |
| this.showLoadingState() | |
| this.debouncedSearch("") | |
| return | |
| } | |
| // If we're not in search mode, do nothing | |
| if (!this.isSearching) return | |
| switch(event.key) { | |
| case "Escape": | |
| this.hideResults() | |
| event.preventDefault() | |
| break | |
| case "ArrowUp": | |
| this.selectedIndex = Math.max(0, this.selectedIndex - 1) | |
| this.highlightSelected() | |
| event.preventDefault() | |
| break | |
| case "ArrowDown": | |
| this.selectedIndex = Math.min(this.searchResults.length - 1, this.selectedIndex + 1) | |
| this.highlightSelected() | |
| event.preventDefault() | |
| break | |
| case "Enter": | |
| if (this.resultsTarget.hidden) return | |
| if (this.searchResults.length > 0 && this.selectedIndex >= 0) { | |
| this.insertMention(this.searchResults[this.selectedIndex]) | |
| event.preventDefault() | |
| } | |
| break | |
| } | |
| } | |
| showLoadingState() { | |
| this.resultsTarget.innerHTML = "<div class='mention-loading'>Loading...</div>" | |
| this.resultsTarget.hidden = false | |
| } | |
| searchContacts(query) { | |
| // Safety check | |
| if (!this.initializedValue) return | |
| // Sanitize the input to prevent XSS | |
| const sanitizedQuery = this.sanitizeInput(query) | |
| fetch(`/contacts/search?query=${encodeURIComponent(sanitizedQuery)}`) | |
| .then(response => { | |
| if (!response.ok) { | |
| throw new Error(`Network response was not ok: ${response.status}`) | |
| } | |
| return response.json() | |
| }) | |
| .then(data => { | |
| // Safety check if component was disconnected during the request | |
| if (!this.initializedValue) return | |
| this.searchResults = data | |
| if (data.length > 0) { | |
| this.showResults(data) | |
| } else { | |
| this.showNoResults() | |
| } | |
| }) | |
| .catch(error => { | |
| // Safety check if component was disconnected during the request | |
| if (!this.initializedValue) return | |
| console.error("Error fetching contacts:", error) | |
| this.showErrorState(error.message) | |
| }) | |
| } | |
| sanitizeInput(input) { | |
| // Basic sanitization to prevent XSS | |
| return input.replace(/[<>]/g, '') | |
| } | |
| showErrorState(message) { | |
| this.resultsTarget.innerHTML = `<div class='mention-error'>Error: ${this.sanitizeInput(message)}</div>` | |
| this.resultsTarget.hidden = false | |
| } | |
| showNoResults() { | |
| this.resultsTarget.innerHTML = "<div class='mention-no-results'>No contacts found</div>" | |
| this.resultsTarget.hidden = false | |
| } | |
| showResults(contacts) { | |
| this.resultsTarget.innerHTML = "" | |
| this.resultsTarget.setAttribute("role", "listbox") | |
| this.resultsTarget.setAttribute("aria-label", "Contact suggestions") | |
| this.selectedIndex = 0 | |
| contacts.forEach((contact, index) => { | |
| const span = document.createElement("span") | |
| span.classList.add("mention-result") | |
| span.setAttribute("role", "option") | |
| span.setAttribute("aria-selected", index === 0 ? "true" : "false") | |
| if (index === 0) span.classList.add("selected") | |
| // Add additional touch target size for mobile | |
| if (this.isMobileValue) { | |
| span.classList.add("mention-result-mobile") | |
| } | |
| span.textContent = contact.name | |
| span.dataset.action = "click->mention#selectContact" | |
| // Add both click and touch events | |
| if (this.isMobileValue) { | |
| span.dataset.action = "click->mention#selectContact touchend->mention#selectContact" | |
| } | |
| span.dataset.index = index | |
| this.resultsTarget.appendChild(span) | |
| }) | |
| this.resultsTarget.hidden = false | |
| } | |
| hideResults() { | |
| this.resultsTarget.hidden = true | |
| this.reset() | |
| } | |
| highlightSelected() { | |
| this.resultsTarget.querySelectorAll(".mention-result").forEach((el, index) => { | |
| if (index === this.selectedIndex) { | |
| el.classList.add("selected") | |
| el.setAttribute("aria-selected", "true") | |
| // Scroll into view if needed | |
| if (el.offsetTop < this.resultsTarget.scrollTop) { | |
| this.resultsTarget.scrollTop = el.offsetTop | |
| } else if (el.offsetTop + el.offsetHeight > this.resultsTarget.scrollTop + this.resultsTarget.offsetHeight) { | |
| this.resultsTarget.scrollTop = el.offsetTop + el.offsetHeight - this.resultsTarget.offsetHeight | |
| } | |
| } else { | |
| el.classList.remove("selected") | |
| el.setAttribute("aria-selected", "false") | |
| } | |
| }) | |
| } | |
| selectContact(event) { | |
| // Prevent default touch/click behavior | |
| event.preventDefault() | |
| const index = parseInt(event.currentTarget.dataset.index) | |
| if (isNaN(index) || index < 0 || index >= this.searchResults.length) { | |
| console.error("Invalid contact index:", index) | |
| return | |
| } | |
| this.insertMention(this.searchResults[index]) | |
| } | |
| insertMention(contact) { | |
| // Make sure editor is available | |
| if (!this.editor) { | |
| console.error("Trix editor not available for inserting mention") | |
| return | |
| } | |
| try { | |
| // Delete the @ and the search text | |
| const position = this.editor.getSelectedRange()[0] | |
| const text = this.editor.getDocument().toString().substring(0, position) | |
| const mentionStartPos = text.lastIndexOf("@") | |
| if (mentionStartPos === -1) { | |
| console.error("Could not find @ symbol in text") | |
| return | |
| } | |
| this.editor.setSelectedRange([mentionStartPos, position]) | |
| this.editor.deleteInDirection("backward") | |
| // Insert the attachment with proper attributes for Action Text | |
| const attachment = new Trix.Attachment({ | |
| sgid: contact.sgid, | |
| content: "<span class='mention'>@" + this.sanitizeInput(contact.name) + "</span>", | |
| // contentType: 'text/html' | |
| }) | |
| this.editor.insertAttachment(attachment) | |
| this.editor.insertString(" ") // Add a space after the mention | |
| } catch (e) { | |
| console.error("Error inserting mention:", e) | |
| } finally { | |
| this.hideResults() | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment