Skip to content

Instantly share code, notes, and snippets.

@yaodong
Created December 31, 2025 06:39
Show Gist options
  • Select an option

  • Save yaodong/b55d3d66bf5726a3284a64e91dc3e7f4 to your computer and use it in GitHub Desktop.

Select an option

Save yaodong/b55d3d66bf5726a3284a64e91dc3e7f4 to your computer and use it in GitHub Desktop.
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