PageSourceSearch

https://www.orient-latin.com/assets/controllers/site_mention_contr…128637dd1b3137b56acd6a71df4783dc8b285c47.js

js orient-latin.com collected 2026-10-02 11:47:06 UTC 9,235 bytes, 280 lines download raw bytes

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.