@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/README.md +80 -0
- package/package.json +26 -0
- package/src/capture.ts +260 -0
- package/src/client-relais.ts +130 -0
- package/src/composants/briques.tsx +146 -0
- package/src/composants/onglet-beta.tsx +156 -0
- package/src/composants/onglet-mes-demandes.tsx +256 -0
- package/src/composants/onglet-signaler.tsx +369 -0
- package/src/composants/tiroir.tsx +163 -0
- package/src/contexte.ts +173 -0
- package/src/index.tsx +241 -0
- package/src/styles.ts +211 -0
- package/src/tampon-erreurs.ts +211 -0
- package/src/types.ts +105 -0
- package/src/validation.ts +113 -0
- package/src/zone.ts +284 -0
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
|
+
}
|