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.