PageSourceSearch

https://assets.feedbin.com/assets/controllers/search_token_control…8fb4fe8b8be62e215343fbe5c951c41820ffb1ba.js

js feedbin.com collected 2026-09-24 09:23:14 UTC 7,580 bytes, 286 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { hydrate, userTitle } from "helpers"
3
4// Connects to data-controller="search-token"
5export default class extends Controller {
6  static targets = [
7    "query",
8    "queryExtra",
9    "focusable",
10    "preview",
11    "previewSource",
12    "results",
13    "resultTemplate",
14    "headerTemplate",
15    "tagIconTemplate",
16    "tokenText",
17    "tokenIcon",
18  ]
19
20  static values = {
21    tokenVisible: Boolean,
22    autocompleteVisible: Boolean,
23  }
24
25  initialize() {
26    this.sourceableTargetCount = 0
27  }
28
29  search() {
30    this.autocompleteVisibleValue = false
31    this.skipAfterFocus = true
32  }
33
34  hideSearch() {
35    this.queryTarget.value = ""
36    this.autocompleteVisibleValue = false
37    this.focusableTargets.forEach((element) => element.blur())
38  }
39
40  deleteToken(focusQuery = true) {
41    this.tokenVisibleValue = false
42    this.tokenTextTarget.innerHTML = ""
43    this.tokenIconTarget.innerHTML = ""
44    this.queryExtraTarget.value = ""
45    this.updatePreview()
46    if (focusQuery) {
47      this.queryTarget.focus()
48    }
49  }
50
51  clickOff(event) {
52    if (event && this.element.contains(event.target)) {
53      return
54    }
55    this.autocompleteVisibleValue = false
56  }
57
58  tokenSelected(event) {
59    let index = event.params.index
60    if (index === "") {
61      let form = this.queryTarget.closest("form")
62      this.skipAfterFocus = true
63      window.$(form).submit()
64      this.queryTarget.focus()
65    } else {
66      let item = this.jumpableItems[index]
67      this.fillToken(item)
68      this.queryTarget.focus()
69    }
70    this.autocompleteVisibleValue = false
71    this.resultsTarget.innerHTML = ""
72    event.preventDefault()
73  }
74
75  fillToken(item) {
76    this.tokenTextTarget.textContent = item.title
77    this.tokenIconTarget.innerHTML = ""
78    if ("icon" in item) {
79      this.tokenIconTarget.append(item.icon.cloneNode(true))
80    }
81    this.tokenVisibleValue = true
82    this.queryTarget.value = ""
83    this.queryExtraTarget.value = item.queryFilter
84  }
85
86  updateToken(event) {
87    const detail = event.detail.data
88    if (detail.jumpable) {
89      this.deleteToken(false)
90      let item = this.buildItem(detail, event.detail.target)
91      setTimeout(() => {
92        this.fillToken(item)
93      }, 150)
94    } else {
95      this.deleteToken(false)
96    }
97  }
98
99  buildAutocomplete() {
100    if (this.tokenVisibleValue) {
101      return
102    }
103
104    const resultTemplate = this.resultTemplateTarget.content
105    const headerTemplate = this.headerTemplateTarget.content
106
107    let items = this.jumpableItems.filter((item) => {
108      if (item.type === "feed") {
109        item["title"] = userTitle(item.id, item.title)
110      }
111      // feeds.title is nullable and a JSON Feed that omits its title persists
112      // NULL, so one untitled feed can reach this with no title at all. Throwing
113      // here would abort the whole filter pass and leave the panel empty for
114      // every source, so skip the row instead: there is nothing to match on.
115      if (!item.title) {
116        return false
117      }
118      const titleFolded = item.title.foldToASCII()
119      const queryFolded = this.queryTarget.value.foldToASCII()
120      item.score = titleFolded.score(queryFolded)
121      return item.score > 0
122    })
123    items = window._.sortBy(items, function (item) {
124      return -item.score
125    })
126    items = window._.groupBy(items, function (item) {
127      return item.section
128    })
129
130    let elements = []
131    const sections = Object.keys(items)
132    sections.sort()
133    sections.forEach((section) => {
134      let header = headerTemplate.cloneNode(true)
135      let element = hydrate(header, [
136        {
137          type: "text",
138          selector: "text",
139          value: section,
140        },
141      ])
142      elements.push(element)
143      items[section].slice(0, 5).forEach((item) => {
144        let element = resultTemplate.cloneNode(true)
145        let updates = [
146          {
147            // Text, not html: a tag name reaches here straight out of the
148            // database, and an imported OPML's outline titles become tag names.
149            type: "text",
150            selector: "text",
151            value: item.title,
152          },
153          {
154            type: "attribute",
155            selector: `[data-${this.identifier}-index-param]`,
156            attribute: `data-${this.identifier}-index-param`,
157            value: item.index,
158          },
159        ]
160
161        if ("icon" in item) {
162          updates.push({
163            type: "html",
164            selector: "icon",
165            value: item.icon.cloneNode(true),
166          })
167        }
168
169        elements.push(hydrate(element, updates))
170      })
171    })
172
173    this.resultsTarget.innerHTML = ""
174    this.resultsTarget.append(...elements)
175  }
176
177  updatePreview() {
178    this.previewTarget.textContent = this.queryTarget.value
179
180    let sourceText = ""
181    if (this.tokenTextTarget.textContent != "") {
182      sourceText = this.tokenTextTarget.textContent
183    }
184    this.previewSourceTarget.textContent = sourceText
185  }
186
187  keyup() {
188    if (!this.skipAfterFocus && this.queryTarget.value.length > 0) {
189      this.autocompleteVisibleValue = true
190    } else {
191      this.autocompleteVisibleValue = false
192    }
193
194    this.skipAfterFocus = false
195    this.updatePreview()
196    this.buildAutocomplete()
197  }
198
199  checkToken(event) {
200    this.updatePreview()
201    if (event.key !== "Backspace") {
202      return
203    }
204
205    // command + delete
206    if (event.metaKey) {
207      this.deleteToken()
208    } else if (this.queryTarget.value.length === 0) {
209      this.deleteToken()
210    }
211  }
212
213  focused() {
214    this.currentFocusable = this.focusableTargets[0]
215    if (this.tokenVisibleValue) {
216      return
217    }
218    if (this.queryTarget.value.length > 0) {
219      this.autocompleteVisibleValue = true
220    }
221  }
222
223  navigate(event) {
224    const count = this.focusableTargets.length
225    if (!this.autocompleteVisibleValue || count == 0) {
226      return
227    }
228
229    const currentIndex = this.focusableTargets.indexOf(this.currentFocusable)
230
231    let nextIndex = (currentIndex + 1) % count
232    if (event.key === "ArrowUp") {
233      nextIndex = (currentIndex + count - 1) % count
234    }
235
236    this.currentFocusable = this.focusableTargets[nextIndex]
237    this.currentFocusable.focus()
238
239    if ("setSelectionRange" in this.currentFocusable) {
240      this.currentFocusable.setSelectionRange(
241        this.currentFocusable.value.length,
242        this.currentFocusable.value.length
243      )
244    }
245
246    event.preventDefault()
247    event.stopPropagation()
248  }
249
250  buildItem(data, element) {
251    const tagIconTemplate = this.tagIconTemplateTarget.content
252    const icon =
253      element.querySelector(".favicon-wrap") || tagIconTemplate.cloneNode(true)
254    data["icon"] = icon.cloneNode(true)
255    data["element"] = element
256    data["queryFilter"] = `${data.type}_id:${data.id}`
257
258    return data
259  }
260
261  buildJumpable() {
262    let sourceableTargets = [
263      ...document.querySelectorAll("[data-sourceable-target]"),
264    ]
265    if (this.sourceableTargetCount === sourceableTargets.length) {
266      return
267    }
268    this.sourceableTargetCount = sourceableTargets.length
269
270    let uniqueSources = new Set()
271    this.jumpableItems = sourceableTargets
272      .reduce((filtered, element) => {
273        let data = JSON.parse(element.dataset.sourceablePayloadParam)
274        let item = this.buildItem(data, element)
275        if (item.jumpable && !uniqueSources.has(item.queryFilter)) {
276          filtered.push(item)
277          uniqueSources.add(item.queryFilter)
278        }
279        return filtered
280      }, [])
281      .map((item, index) => {
282        item.index = index
283        return item
284      })
285  }
286};

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.