@zevra/support-widget 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/zone.ts ADDED
@@ -0,0 +1,284 @@
1
+ // ============================================================================
2
+ // Le choix d'une zone à capturer.
3
+ // ============================================================================
4
+ //
5
+ // Capturer la page entière répond à « voilà mon écran » ; choisir une zone
6
+ // répond à « voilà LE bouton qui ne réagit pas ». La seconde question est
7
+ // celle qu'on pose quand on signale, et c'est aussi celle qui produit une
8
+ // image qu'on peut lire dans un ticket sans zoomer.
9
+ //
10
+ // Ce module fait deux choses, séparées exprès :
11
+ //
12
+ // • la GÉOMÉTRIE (`rectangleEntre`, `zoneDansImage`) est pure et testée ;
13
+ // c'est là que vivent les bugs de recadrage, pas dans le voile ;
14
+ // • le VOILE (`choisirZone`) touche au document, ne calcule rien, et rend
15
+ // toujours la main — y compris quand la personne renonce.
16
+ //
17
+ // ⚠️ LES COORDONNÉES SONT CELLES DE LA FENÊTRE, en pixels CSS, comme
18
+ // `getBoundingClientRect`. Elles ne sont PAS celles de l'image capturée : une
19
+ // page défilée, un écran à forte densité et un élément qui déborde donnent
20
+ // trois facteurs d'écart différents. La conversion se fait en un seul endroit,
21
+ // `zoneDansImage`, et jamais à la main dans un composant.
22
+
23
+ export interface Point {
24
+ x: number
25
+ y: number
26
+ }
27
+
28
+ export interface Rectangle {
29
+ x: number
30
+ y: number
31
+ largeur: number
32
+ hauteur: number
33
+ }
34
+
35
+ /**
36
+ * En deçà, c'est un clic, pas un tracé.
37
+ *
38
+ * ⚠️ Sans ce seuil, un simple clic sur le voile rendrait un rectangle de zéro
39
+ * pixel : la capture partirait, vide, et personne ne comprendrait pourquoi la
40
+ * pièce jointe est une image blanche de 1 pixel.
41
+ */
42
+ export const COTE_MINIMAL = 8
43
+
44
+ /** Le rectangle entre deux points, quel que soit le sens du tracé. PUR. */
45
+ export function rectangleEntre(depart: Point, arrivee: Point): Rectangle {
46
+ const x = Math.min(depart.x, arrivee.x)
47
+ const y = Math.min(depart.y, arrivee.y)
48
+ return {
49
+ x,
50
+ y,
51
+ largeur: Math.abs(arrivee.x - depart.x),
52
+ hauteur: Math.abs(arrivee.y - depart.y),
53
+ }
54
+ }
55
+
56
+ /** Assez grand pour valoir une capture ? PUR. */
57
+ export function zoneExploitable(zone: Rectangle): boolean {
58
+ return zone.largeur >= COTE_MINIMAL && zone.hauteur >= COTE_MINIMAL
59
+ }
60
+
61
+ export interface EntreeZoneDansImage {
62
+ /** Le tracé, en coordonnées de FENÊTRE. */
63
+ zone: Rectangle
64
+ /** `getBoundingClientRect()` de l'élément capturé — donc fenêtre aussi. */
65
+ cadre: Point
66
+ /** Les dimensions réelles de l'image rendue par la capture. */
67
+ imageLargeur: number
68
+ imageHauteur: number
69
+ /** Les dimensions en pixels CSS de l'élément capturé. */
70
+ elementLargeur: number
71
+ elementHauteur: number
72
+ }
73
+
74
+ /**
75
+ * Traduit un tracé de fenêtre en rectangle de l'image capturée. PUR.
76
+ *
77
+ * Trois écarts à absorber, et c'est pour ça que cette fonction existe :
78
+ *
79
+ * 1. l'élément capturé n'est pas à l'origine de la fenêtre — il est défilé,
80
+ * ou décalé. `cadre` porte ce décalage, négatif quand on a défilé ;
81
+ * 2. l'image n'a pas la taille de l'élément — la bibliothèque de capture
82
+ * applique son propre facteur ;
83
+ * 3. le tracé peut déborder de l'élément, quand on glisse jusqu'au bord.
84
+ *
85
+ * ⚠️ RENDRE `null` PLUTÔT QU'UN RECTANGLE VIDE. Un tracé entièrement hors de
86
+ * l'élément (on a visé une barre fixe qui n'est pas dans la capture) donnerait
87
+ * après bornage une largeur de zéro, et un canevas de zéro pixel jette dans
88
+ * certains navigateurs et rend une image noire dans d'autres. Le `null` est
89
+ * rattrapé plus haut par un message, pas par une image fausse.
90
+ */
91
+ export function zoneDansImage(entree: EntreeZoneDansImage): Rectangle | null {
92
+ const { zone, cadre, imageLargeur, imageHauteur, elementLargeur, elementHauteur } = entree
93
+ if (!(imageLargeur > 0) || !(imageHauteur > 0)) return null
94
+ if (!(elementLargeur > 0) || !(elementHauteur > 0)) return null
95
+
96
+ const facteurX = imageLargeur / elementLargeur
97
+ const facteurY = imageHauteur / elementHauteur
98
+
99
+ // Bornage sur les DEUX bords, et dans cet ordre : on calcule les extrémités
100
+ // avant de les ramener dans l'image, sinon une largeur bornée à partir d'une
101
+ // origine déjà bornée déborde encore.
102
+ const gauche = (zone.x - cadre.x) * facteurX
103
+ const haut = (zone.y - cadre.y) * facteurY
104
+ const droite = gauche + zone.largeur * facteurX
105
+ const bas = haut + zone.hauteur * facteurY
106
+
107
+ const x = Math.max(0, Math.min(gauche, imageLargeur))
108
+ const y = Math.max(0, Math.min(haut, imageHauteur))
109
+ const x2 = Math.max(0, Math.min(droite, imageLargeur))
110
+ const y2 = Math.max(0, Math.min(bas, imageHauteur))
111
+
112
+ const largeur = Math.round(x2 - x)
113
+ const hauteur = Math.round(y2 - y)
114
+ if (largeur < 1 || hauteur < 1) return null
115
+
116
+ return { x: Math.round(x), y: Math.round(y), largeur, hauteur }
117
+ }
118
+
119
+ // ----------------------------------------------------------------------------
120
+ // Le voile de sélection — IMPUR
121
+ // ----------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Ce que le widget montre pendant qu'on trace.
125
+ *
126
+ * ⚠️ Écrit en DOM natif et non en React, volontairement. Ce voile doit
127
+ * recouvrir la page de l'hôte pendant que le tiroir est caché : le faire
128
+ * rendre par React obligerait à remonter l'état jusqu'au composant racine, à
129
+ * le redescendre dans l'onglet, et à garder les deux synchronisés pour une
130
+ * surface qui n'existe que le temps d'un glissement. Ici, tout ce qui est créé
131
+ * est détruit par `nettoyer`, dans le même fichier, à la ligne près.
132
+ */
133
+ /**
134
+ * Les classes du voile, nommées ici et nulle part ailleurs.
135
+ *
136
+ * ⚠️ ELLES NE DOIVENT RIEN PARTAGER AVEC LES CHAMPS DU FORMULAIRE. La première
137
+ * version employait `zvs-zone`, qui désigne depuis toujours la zone de texte
138
+ * du signalement : la règle du voile, posée plus bas dans la même feuille, a
139
+ * rendu le champ `position: fixed; inset: 0` — un écran blanc au premier clic,
140
+ * sans aucune erreur en console. C'est `tests/styles.test.ts` qui tient
141
+ * désormais la règle.
142
+ */
143
+ export const CLASSES_SELECTION = [
144
+ 'zvs-selection',
145
+ 'zvs-selection__voile',
146
+ 'zvs-selection__consigne',
147
+ 'zvs-selection__trace',
148
+ ] as const
149
+
150
+ interface Attelage {
151
+ voile: HTMLElement
152
+ trace: HTMLElement
153
+ nettoyer: () => void
154
+ }
155
+
156
+ /**
157
+ * Cache le widget pendant le tracé, et rend de quoi le rétablir.
158
+ *
159
+ * ⚠️ `visibility` et non `display` : `display: none` démonte la mise en page
160
+ * du tiroir, et React, en le remontant, repart d'un formulaire vide. On perdrait
161
+ * la saisie en cours au moment précis où on va la compléter d'une image.
162
+ */
163
+ function masquerLeWidget(doc: Document): () => void {
164
+ const masques = [...doc.querySelectorAll<HTMLElement>('.zvs-racine')]
165
+ const avant = masques.map((noeud) => noeud.style.visibility)
166
+ for (const noeud of masques) noeud.style.visibility = 'hidden'
167
+ return () => {
168
+ masques.forEach((noeud, index) => {
169
+ noeud.style.visibility = avant[index] ?? ''
170
+ })
171
+ }
172
+ }
173
+
174
+ function poserLAttelage(doc: Document, surEchap: () => void): Attelage {
175
+ const voile = doc.createElement('div')
176
+ voile.className = CLASSES_SELECTION[0]
177
+ voile.setAttribute('role', 'presentation')
178
+
179
+ const voileSombre = doc.createElement('div')
180
+ voileSombre.className = CLASSES_SELECTION[1]
181
+
182
+ const consigne = doc.createElement('p')
183
+ consigne.className = CLASSES_SELECTION[2]
184
+ consigne.textContent = 'Tracez la zone à capturer — Échap pour annuler'
185
+
186
+ const trace = doc.createElement('div')
187
+ trace.className = CLASSES_SELECTION[3]
188
+ trace.hidden = true
189
+
190
+ voile.append(voileSombre, consigne, trace)
191
+ doc.body.appendChild(voile)
192
+
193
+ const auClavier = (evenement: KeyboardEvent) => {
194
+ if (evenement.key === 'Escape') {
195
+ evenement.preventDefault()
196
+ surEchap()
197
+ }
198
+ }
199
+ doc.addEventListener('keydown', auClavier, true)
200
+
201
+ return {
202
+ voile,
203
+ trace,
204
+ nettoyer: () => {
205
+ doc.removeEventListener('keydown', auClavier, true)
206
+ voile.remove()
207
+ },
208
+ }
209
+ }
210
+
211
+ function dessiner(trace: HTMLElement, zone: Rectangle): void {
212
+ trace.hidden = false
213
+ trace.style.left = `${zone.x}px`
214
+ trace.style.top = `${zone.y}px`
215
+ trace.style.width = `${zone.largeur}px`
216
+ trace.style.height = `${zone.hauteur}px`
217
+ }
218
+
219
+ /**
220
+ * Laisse tracer une zone. Rend `null` si on renonce.
221
+ *
222
+ * NE JETTE JAMAIS, et rend toujours la main : le voile est retiré et le widget
223
+ * rétabli dans tous les chemins de sortie — tracé validé, Échap, tracé trop
224
+ * petit, pointeur annulé par le système. Un voile qui survivrait à une
225
+ * exception laisserait l'app hôte inutilisable, avec une surface invisible
226
+ * par-dessus tout, et rien à cliquer pour s'en sortir.
227
+ */
228
+ export function choisirZone(doc?: Document | null): Promise<Rectangle | null> {
229
+ const document_ = doc ?? (typeof document === 'undefined' ? null : document)
230
+ if (!document_ || !document_.body) return Promise.resolve(null)
231
+
232
+ return new Promise<Rectangle | null>((resoudre) => {
233
+ const retablir = masquerLeWidget(document_)
234
+ let depart: Point | null = null
235
+ let zone: Rectangle | null = null
236
+ let fini = false
237
+
238
+ const finir = (resultat: Rectangle | null) => {
239
+ if (fini) return
240
+ fini = true
241
+ attelage.nettoyer()
242
+ document_.removeEventListener('pointermove', auMouvement, true)
243
+ document_.removeEventListener('pointerup', auRelachement, true)
244
+ document_.removeEventListener('pointercancel', auRenoncement, true)
245
+ retablir()
246
+ resoudre(resultat)
247
+ }
248
+
249
+ const auMouvement = (evenement: PointerEvent) => {
250
+ if (depart === null) return
251
+ zone = rectangleEntre(depart, { x: evenement.clientX, y: evenement.clientY })
252
+ dessiner(attelage.trace, zone)
253
+ }
254
+
255
+ const auRelachement = () => {
256
+ // Un tracé minuscule est un clic qui a ripé : on renonce plutôt que de
257
+ // joindre une image de huit pixels.
258
+ finir(zone !== null && zoneExploitable(zone) ? zone : null)
259
+ }
260
+
261
+ const auRenoncement = () => finir(null)
262
+
263
+ const attelage = poserLAttelage(document_, () => finir(null))
264
+
265
+ attelage.voile.addEventListener('pointerdown', (evenement: PointerEvent) => {
266
+ // Le bouton secondaire ouvre le menu du navigateur : le laisser
267
+ // commencer un tracé donnerait un rectangle qu'aucun relâchement ne
268
+ // vient fermer.
269
+ if (evenement.button !== 0) return
270
+ evenement.preventDefault()
271
+ depart = { x: evenement.clientX, y: evenement.clientY }
272
+ zone = null
273
+ attelage.trace.hidden = true
274
+ })
275
+
276
+ // Sur le DOCUMENT et en capture : le pointeur sort régulièrement du voile
277
+ // (une barre fixe de l'hôte par-dessus, le bord de la fenêtre), et des
278
+ // écouteurs posés sur le seul voile perdraient alors le relâchement — le
279
+ // tracé resterait collé au pointeur jusqu'à la fin des temps.
280
+ document_.addEventListener('pointermove', auMouvement, true)
281
+ document_.addEventListener('pointerup', auRelachement, true)
282
+ document_.addEventListener('pointercancel', auRenoncement, true)
283
+ })
284
+ }