1import { Controller } from "@hotwired/stimulus" 2 3// Autocomplétion des références #identifier dans les champs markdown admin. 4// Posé sur le formulaire (délégation), il surveille les champs marqués 5// `data-site-mention-field` : dès qu'un « # » de référence est tapé (mêmes 6// frontières que Markup::REF â pas en milieu de mot, pas de ##), une liste 7// flottante s'ouvre au curseur et se filtre à la frappe sur l'identifier et 8// tous les toponymes (liste des sites embarquée par le partial 9// admin/shared/site_mention_data). Flèches puis Entrée/Tab, ou clic, insèrent 10// l'identifier ; Ãchap ferme. La liste n'est qu'une aide : fermée, rien n'est 11// intercepté et la saisie manuelle continue exactement comme avant. 12export default class extends Controller { 13 static targets = ["data"] 14 15 connect() { 16 this.sites = JSON.parse(this.dataTarget.textContent || "[]").map((site) => ({ 17 ...site, 18 key: normalize(site.id), 19 words: normalize(site.search).split(/\s+/) 20 })) 21 this.field = null 22 this.hashPos = null 23 this.matches = [] 24 this.index = 0 25 this.suppress = false 26 27 this.list = document.createElement("div") 28 this.list.className = "site-mention" 29 this.list.hidden = true 30 document.body.appendChild(this.list) 31 32 this._onInput = this.onInput.bind(this) 33 this._onKeydown = this.onKeydown.bind(this) 34 this._onFocusOut = this.onFocusOut.bind(this) 35 this._onScroll = () => this.close() 36 this.element.addEventListener("input", this._onInput) 37 this.element.addEventListener("keydown", this._onKeydown) 38 this.element.addEventListener("focusout", this._onFocusOut) 39 } 40 41 disconnect() { 42 this.element.removeEventListener("input", this._onInput) 43 this.element.removeEventListener("keydown", this._onKeydown) 44 this.element.removeEventListener("focusout", this._onFocusOut) 45 this.close() 46 this.list.remove() 47 this.mirror?.remove() 48 } 49 50 onInput(event) { 51 if (this.suppress) { 52 this.suppress = false 53 return 54 } 55 56 const field = event.target.closest?.("[data-site-mention-field]") 57 if (!field) return 58 59 // Contexte de mention : le texte avant le curseur se termine par un « # » 60 // de référence suivi du fragment d'identifier en cours (possiblement vide). 61 const caret = field.selectionStart ?? field.value.length 62 const match = field.value.slice(0, caret).match(/(?:^|[^\w#])#([A-Za-z][A-Za-z0-9_-]*)?$/) 63 if (!match) return this.close() 64 65 const query = match[1] || "" 66 this.field = field 67 this.hashPos = caret - query.length - 1 68 this.matches = this.filter(query) 69 if (this.matches.length === 0) return this.close() 70 71 this.index = 0 72 this.render() 73 this.open() 74 this.position() 75 } 76 77 onKeydown(event) { 78 if (this.list.hidden || event.target !== this.field) return 79 80 switch (event.key) { 81 case "ArrowDown": 82 event.preventDefault() 83 this.move(1) 84 break 85 case "ArrowUp": 86 event.preventDefault() 87 this.move(-1) 88 break 89 case "Enter": 90 case "Tab": 91 event.preventDefault() 92 this.apply(this.matches[this.index]) 93 break 94 case "Escape": 95 event.preventDefault() 96 event.stopPropagation() 97 this.close() 98 break 99 } 100 } 101 102 onFocusOut() { 103 this.close() 104 } 105 106 // Classement : identifier commençant par la requête, puis toponyme 107 // commençant par elle, puis simple inclusion â alphabétique dans chaque 108 // groupe. Requête vide (« # » seul) : tous les sites, alphabétique. 109 filter(query) { 110 const q = normalize(query) 111 const scored = [] 112 for (const site of this.sites) { 113 let score 114 if (!q) score = 2 115 else if (site.key.startsWith(q)) score = 0 116 else if (site.words.some((word) => word.startsWith(q))) score = 1 117 else if (site.words.some((word) => word.includes(q))) score = 2 118 else continue 119 scored.push([score, site]) 120 } 121 return scored 122 .sort((a, b) => a[0] - b[0] || a[1].label.localeCompare(b[1].label, "fr")) 123 .slice(0, 8) 124 .map((entry) => entry[1]) 125 } 126 127 render() { 128 this.list.textContent = ""
129 this.matches.forEach((site, i) => { 130 const row = document.createElement("button") 131 row.type = "button" 132 row.className = "site-mention__item" 133 row.classList.toggle("is-active", i === this.index) 134 135 const label = document.createElement("span") 136 label.className = "site-mention__label" 137 label.textContent = site.label 138 row.append(label) 139 140 // Brouillon : le « ! » rouge de la convention maison (panneaux miroir), 141 // accolé au nom pour laisser la colonne des identifiers nette. 142 if (site.draft) { 143 const badge = document.createElement("span") 144 badge.className = "site-mention__draft" 145 badge.title = "Brouillon" 146 badge.textContent = "!" 147 row.append(badge) 148 } 149 150 const id = document.createElement("code") 151 id.className = "site-mention__id" 152 id.textContent = `#${site.id}` 153 row.append(id) 154 155 // mousedown + preventDefault : insérer sans voler le focus du champ 156 // (même geste que les palettes toponymes/images). 157 row.addEventListener("mousedown", (e) => { 158 e.preventDefault() 159 this.apply(site) 160 }) 161 this.list.appendChild(row) 162 }) 163 } 164 165 move(delta) { 166 this.index = (this.index + delta + this.matches.length) % this.matches.length 167 this.list.querySelectorAll(".site-mention__item").forEach((el, i) => { 168 el.classList.toggle("is-active", i === this.index) 169 }) 170 } 171 172 apply(site) { 173 const field = this.field 174 const before = field.value.slice(0, this.hashPos) 175 const after = field.value.slice(field.selectionStart) 176 const ref = `#${site.id}` 177 field.value = before + ref + after 178 const caret = before.length + ref.length 179 field.setSelectionRange(caret, caret) 180 this.close() 181 182 // Prévenir les autres contrôleurs du champ (autogrow, publishableâ¦) sans 183 // rouvrir la liste : l'identifier fraîchement inséré re-matche le contexte. 184 this.suppress = true 185 field.dispatchEvent(new Event("input", { bubbles: true })) 186 } 187 188 open() { 189 if (!this.list.hidden) return 190 this.list.hidden = false 191 // Tout défilement invalide la position calculée : on referme (capture, pour 192 // attraper aussi le défilement interne du textarea et des conteneurs). 193 window.addEventListener("scroll", this._onScroll, true) 194 } 195 196 close() { 197 if (this.list.hidden) return 198 this.list.hidden = true 199 window.removeEventListener("scroll", this._onScroll, true) 200 } 201 202 // Position de la liste : coordonnées du « # » dans le textarea, obtenues par 203 // un miroir hors écran reproduisant sa composition (technique classique â un 204 // <span> marque le fragment, son rect donne la position du caret). 205 position() { 206 const field = this.field 207 const style = window.getComputedStyle(field) 208 this.mirror ||= document.createElement("div") 209 const mirror = this.mirror 210 211 for (const prop of MIRROR_PROPS) mirror.style[prop] = style[prop] 212 Object.assign(mirror.style, { 213 position: "absolute", 214 top: "0", 215 left: "-9999px", 216 visibility: "hidden", 217 whiteSpace: "pre-wrap", 218 wordWrap: "break-word", 219 overflow: "hidden", 220 boxSizing: "border-box", 221 borderStyle: "solid", 222 borderColor: "transparent", 223 width: `${field.clientWidth + field.clientLeft * 2}px` 224 }) 225 226 mirror.textContent = field.value.slice(0, this.hashPos) 227 const marker = document.createElement("span") 228 marker.textContent = field.value.slice(this.hashPos, field.selectionStart) || "#" 229 mirror.appendChild(marker) 230 document.body.appendChild(mirror) 231 232 const fieldRect = field.getBoundingClientRect() 233 const mirrorRect = mirror.getBoundingClientRect() 234 const markerRect = marker.getBoundingClientRect() 235 const caretTop = fieldRect.top + (markerRect.top - mirrorRect.top) - field.scrollTop 236 const lineHeight = markerRect.height || parseFloat(style.lineHeight) || 20 237 238 let left = fieldRect.left + (markerRect.left - mirrorRect.left) - field.scrollLeft 239 let top = caretTop + lineHeight 240 241 left = Math.max(8, Math.min(left, window.innerWidth - this.list.offsetWidth - 8)) 242 if (top + this.list.offsetHeight > window.innerHeight - 8) { 243 top = caretTop - this.list.offsetHeight 244 } 245 this.list.style.left = `${left}px` 246 this.list.style.top = `${top}px` 247 } 248} 249 250// Le matching ignore casse et diacritiques (décomposition NFD, accents ôtés) : 251// « saone » trouve Saône, « sahyun » trouve á¹¢ahyÅ«n. 252function normalize(text) { 253 return text 254 .toLowerCase() 255 .normalize("NFD") 256 .replace(/[\u0300-\u036f]/g, "") 257} 258
259// Propriétés recopiées du textarea vers le miroir : tout ce qui influe sur la 260// composition du texte (fonte, interlignage, retraits). 261const MIRROR_PROPS = [ 262 "fontFamily", 263 "fontSize", 264 "fontWeight", 265 "fontStyle", 266 "letterSpacing", 267 "lineHeight", 268 "textTransform", 269 "wordSpacing", 270 "textIndent", 271 "tabSize", 272 "paddingTop", 273 "paddingRight", 274 "paddingBottom", 275 "paddingLeft", 276 "borderTopWidth", 277 "borderRightWidth", 278 "borderBottomWidth", 279 "borderLeftWidth" 280];
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.