1import { Controller } from "@hotwired/stimulus" 2import { get } from "@rails/request.js" 3 4// @-mention typeahead for a Trix editor. Watches the text before the caret for 5// an "@query" token, fetches matching users, and inserts the chosen one as a 6// Trix content attachment (signed global id + rendered chip) - the same 7// attachable Rails resolves and re-renders server-side. Modeled on Basecamp's 8// mention flow. 9// 10// Connect the controller to a position-relative wrapper around the editor and 11// mark the <trix-editor> with data-ui--mentions-target="editor"; the result 12// menu is positioned against that wrapper. 13export default class extends Controller { 14 static targets = ["editor"] 15 static values = { url: String } 16 17 connect() { 18 this.results = [] 19 this.activeIndex = 0 20 this.markerIndex = null // document index of the "@" that opened the menu 21 22 this.menu = this.buildMenu() 23 this.element.appendChild(this.menu) 24 25 this.onChange = this.handleChange.bind(this) 26 this.onKeydown = this.handleKeydown.bind(this) 27 this.onBlur = this.handleBlur.bind(this) 28 29 // Capture keydown so arrow/enter selection beats Trix's own handling. 30 this.editorTarget.addEventListener("trix-change", this.onChange) 31 this.editorTarget.addEventListener("keydown", this.onKeydown, true) 32 this.editorTarget.addEventListener("blur", this.onBlur) 33 } 34 35 disconnect() { 36 this.editorTarget.removeEventListener("trix-change", this.onChange) 37 this.editorTarget.removeEventListener("keydown", this.onKeydown, true) 38 this.editorTarget.removeEventListener("blur", this.onBlur) 39 this.menu.remove() 40 } 41 42 get editor() { 43 return this.editorTarget.editor 44 } 45 46 get menuOpen() { 47 return !this.menu.classList.contains("d-none") 48 } 49 50 handleChange() { 51 const editor = this.editor 52 if (!editor) return 53 54 const range = editor.getSelectedRange() 55 // Only act on a collapsed caret, never on a selection. 56 if (range[0] !== range[1]) return this.closeMenu() 57 58 const caret = range[1] 59 const text = editor.getDocument().toString().slice(0, caret) 60 // "@" at the start or after whitespace, then a contiguous non-space token. 61 // Avoids triggering on email addresses ("foo@bar"). 62 const match = text.match(/(?:^|\s)@([^\s@]{0,30})$/) 63 if (!match) return this.closeMenu() 64 65 this.markerIndex = caret - match[1].length - 1 66 this.search(match[1]) 67 } 68 69 async search(query) { 70 const response = await get(this.urlValue, { 71 query: { q: query }, 72 responseKind: "json", 73 }) 74 if (!response.ok) return this.closeMenu() 75 76 this.results = await response.json 77 this.activeIndex = 0 78 79 if (this.results.length === 0) return this.closeMenu() 80 this.renderMenu() 81 } 82 83 handleKeydown(event) { 84 if (!this.menuOpen) return 85 86 switch (event.key) { 87 case "ArrowDown": 88 event.preventDefault() 89 this.activeIndex = (this.activeIndex + 1) % this.results.length 90 this.renderMenu() 91 break 92 case "ArrowUp": 93 event.preventDefault() 94 this.activeIndex = (this.activeIndex - 1 + this.results.length) % this.results.length 95 this.renderMenu() 96 break 97 case "Enter": 98 case "Tab": 99 event.preventDefault() 100 this.insert(this.results[this.activeIndex]) 101 break 102 case "Escape": 103 event.preventDefault() 104 this.closeMenu() 105 break 106 } 107 } 108 109 handleBlur() { 110 // Defer so a mousedown on a menu item registers before teardown. 111 setTimeout(() => this.closeMenu(), 150) 112 } 113 114 insert(person) { 115 if (!person) return 116 117 const editor = this.editor 118 const caret = editor.getSelectedRange()[1] 119 120 // Replace the typed "@query" token with the attachment. 121 editor.setSelectedRange([this.markerIndex, caret]) 122 123 const attachment = new Trix.Attachment({ 124 sgid: person.sgid, 125 content: person.content, 126 contentType: "application/vnd.actiontext.user", 127 }) 128 editor.insertAttachment(attachment) 129 editor.insertString(" ") 130 131 this.closeMenu() 132 } 133 134 buildMenu() { 135 const menu = document.createElement("div") 136 menu.className = "mention-menu d-none" 137 menu.setAttribute("role", "listbox") 138 return menu 139 } 140 141 // Anchor the menu under the "@" that opened it, relative to the wrapper. 142 positionMenu() { 143 const rect = this.editor.getClientRectAtPosition(this.markerIndex) 144 if (!rect) return 145 146 const origin = this.element.getBoundingClientRect() 147 this.menu.style.left = `${rect.left - origin.left}px` 148 this.menu.style.top = `${rect.bottom - origin.top}px` 149 } 150 151 renderMenu() { 152 this.positionMenu() 153 this.menu.replaceChildren() 154
155 this.results.forEach((person, index) => { 156 const item = document.createElement("button") 157 item.type = "button" 158 item.className = "dropdown-item d-flex align-items-center gap-2" 159 item.classList.toggle("active", index === this.activeIndex) 160 item.innerHTML = person.content 161 // mousedown (not click) so selection beats the editor's blur. 162 item.addEventListener("mousedown", (event) => { 163 event.preventDefault() 164 this.insert(person) 165 }) 166 this.menu.appendChild(item) 167 }) 168 169 this.menu.classList.remove("d-none") 170 } 171 172 closeMenu() { 173 this.markerIndex = null 174 this.results = [] 175 this.menu.classList.add("d-none") 176 } 177}
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.