PageSourceSearch

https://www.hinsley.ai/assets/controllers/ui/mentions_controller-94706dcc.js

js hinsley.ai collected 2026-10-05 16:11:31 UTC 5,335 bytes, 177 lines download raw bytes

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.