@h4md1/visual-image-tool 0.1.5 → 0.2.1

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.
@@ -4,800 +4,927 @@
4
4
  */
5
5
 
6
6
  class VisualImageTool {
7
- /**
8
- * Crée une instance de l'outil d'image
9
- * @param {Object} options - Options de configuration
10
- * @param {HTMLElement|string} options.imageElement - Élément image ou sélecteur CSS
11
- * @param {Object} [options.focusPoint] - Configuration du point focal
12
- * @param {boolean} [options.focusPoint.enabled=true] - Activer la fonctionnalité de point focal
13
- * @param {Object} [options.focusPoint.style] - Styles personnalisés pour le marqueur de point focal
14
- * @param {Object} [options.cropZone] - Configuration de la zone de recadrage
15
- * @param {boolean} [options.cropZone.enabled=true] - Activer la fonctionnalité de zone de recadrage
16
- * @param {Object} [options.cropZone.style] - Styles personnalisés pour l'overlay de recadrage
17
- * @param {Function} [options.onChange] - Callback appelé lors des changements
18
- */
19
- constructor(options) {
20
- // Valider les options
21
- if (!options || !options.imageElement) {
22
- throw new Error('L\'élément image est requis');
23
- }
24
-
25
- // Initialiser les propriétés
26
- this.imageElement = typeof options.imageElement === 'string'
27
- ? document.querySelector(options.imageElement)
28
- : options.imageElement;
29
-
30
- if (!this.imageElement || this.imageElement.tagName !== 'IMG') {
31
- throw new Error('Élément image invalide');
32
- }
33
-
34
- // Options par défaut
35
- this.options = {
36
- focusPoint: {
37
- enabled: true,
38
- style: {
39
- width: '30px',
40
- height: '30px',
41
- border: '3px solid white',
42
- boxShadow: '0 0 0 2px black, 0 0 5px rgba(0,0,0,0.5)',
43
- backgroundColor: 'rgba(255, 0, 0, 0.5)'
44
- },
45
- ...options.focusPoint
46
- },
47
- cropZone: {
48
- enabled: true,
49
- style: {
50
- border: '1px dashed #fff',
51
- backgroundColor: 'rgba(0, 0, 0, 0.4)'
52
- },
53
- handleStyle: {
54
- width: '14px',
55
- height: '14px',
56
- backgroundColor: 'white',
57
- border: '2px solid black',
58
- boxShadow: '0 0 3px rgba(0,0,0,0.5)'
59
- },
60
- ...options.cropZone
61
- },
62
- onChange: options.onChange || (() => {})
63
- };
64
-
65
- // État interne
66
- this.state = {
67
- focusMarker: null,
68
- cropOverlay: null,
69
- focusActive: false,
70
- cropActive: false,
71
- focusPoint: { x: 0, y: 0 },
72
- cropZone: { x: 0, y: 0, width: 0, height: 0 },
73
- originalWidth: 1,
74
- originalHeight: 1,
75
- displayWidth: 1,
76
- displayHeight: 1,
77
- scaleX: 1,
78
- scaleY: 1
79
- };
80
-
81
- // Variables pour le suivi des interactions
82
- this.interaction = {
83
- focusDragging: false,
84
- focusDragOffsetX: 0,
85
- focusDragOffsetY: 0,
86
- cropDragging: false,
87
- cropResizing: false,
88
- activeHandle: null,
89
- startX: 0,
90
- startY: 0,
91
- startWidth: 0,
92
- startHeight: 0,
93
- startMouseX: 0,
94
- startMouseY: 0
95
- };
96
-
97
- // Initialiser l'outil
98
- this._init();
99
- }
100
-
101
- /**
102
- * Initialise l'outil d'image
103
- * @private
104
- */
105
- _init() {
106
- // Préparer le conteneur parent
107
- this._prepareContainer();
108
-
109
- // Initialiser les dimensions
110
- this._updateScaling();
111
-
112
- // Créer les éléments d'interface si activés
113
- if (this.options.focusPoint.enabled) {
114
- this._createFocusMarker();
115
- }
116
-
117
- if (this.options.cropZone.enabled) {
118
- this._createCropOverlay();
119
- }
120
-
121
- // Ajouter les écouteurs d'événements
122
- this._setupEventListeners();
123
- }
124
-
125
- /**
126
- * Prépare le conteneur parent de l'image
127
- * @private
128
- */
129
- _prepareContainer() {
130
- // S'assurer que le parent est positionné
131
- if (this.imageElement.parentNode) {
132
- this.imageElement.parentNode.style.position = 'relative';
133
- }
134
- }
135
-
136
- /**
137
- * Met à jour les facteurs d'échelle
138
- * @private
139
- */
140
- _updateScaling() {
141
- this.state.originalWidth = this.imageElement.naturalWidth || 1;
142
- this.state.originalHeight = this.imageElement.naturalHeight || 1;
143
- this.state.displayWidth = this.imageElement.offsetWidth;
144
- this.state.displayHeight = this.imageElement.offsetHeight;
145
- this.state.scaleX = this.state.displayWidth / this.state.originalWidth;
146
- this.state.scaleY = this.state.displayHeight / this.state.originalHeight;
147
-
148
- // Repositionner les éléments si actifs
149
- if (this.state.focusActive) {
150
- this._updateFocusMarkerPosition();
151
- }
152
-
153
- if (this.state.cropActive) {
154
- this._updateCropOverlayPosition();
155
- }
156
- }
157
-
158
- /**
159
- * Convertit des coordonnées d'affichage en coordonnées originales
160
- * @private
161
- * @param {number} scaledX - Coordonnée X à l'échelle d'affichage
162
- * @param {number} scaledY - Coordonnée Y à l'échelle d'affichage
163
- * @returns {Object} Coordonnées originales
164
- */
165
- _toOriginalCoords(scaledX, scaledY) {
166
- return {
167
- x: scaledX / this.state.scaleX,
168
- y: scaledY / this.state.scaleY
169
- };
170
- }
171
-
172
- /**
173
- * Convertit des coordonnées originales en coordonnées d'affichage
174
- * @private
175
- * @param {number} originalX - Coordonnée X originale
176
- * @param {number} originalY - Coordonnée Y originale
177
- * @returns {Object} Coordonnées à l'échelle d'affichage
178
- */
179
- _toScaledCoords(originalX, originalY) {
180
- return {
181
- x: originalX * this.state.scaleX,
182
- y: originalY * this.state.scaleY
183
- };
184
- }
185
-
186
- /**
187
- * Crée le marqueur de point focal
188
- * @private
189
- */
190
- _createFocusMarker() {
191
- if (this.state.focusMarker) return;
192
-
193
- const marker = document.createElement('div');
194
- marker.style.position = 'absolute';
195
- marker.style.width = this.options.focusPoint.style.width;
196
- marker.style.height = this.options.focusPoint.style.height;
197
- marker.style.border = this.options.focusPoint.style.border;
198
- marker.style.boxShadow = this.options.focusPoint.style.boxShadow;
199
- marker.style.backgroundColor = this.options.focusPoint.style.backgroundColor;
200
- marker.style.cursor = 'move';
201
- marker.style.display = 'none';
202
- marker.style.zIndex = '999';
203
- marker.style.clipPath = 'polygon(40% 0%, 60% 0%, 60% 40%, 100% 40%, 100% 60%, 60% 60%, 60% 100%, 40% 100%, 40% 60%, 0% 60%, 0% 40%, 40% 40%)';
204
-
205
- this.imageElement.parentNode.appendChild(marker);
206
- this.state.focusMarker = marker;
207
-
208
- // Ajouter les écouteurs d'événements au marqueur
209
- marker.addEventListener('mousedown', this._handleFocusMarkerMouseDown.bind(this));
210
- }
211
-
212
- /**
213
- * Gère l'événement mousedown sur le marqueur de point focal
214
- * @private
215
- * @param {MouseEvent} e - Événement mousedown
216
- */
217
- _handleFocusMarkerMouseDown(e) {
218
- this.interaction.focusDragging = true;
219
- this.state.focusMarker.style.cursor = 'grabbing';
220
-
221
- // Calculer le décalage par rapport au centre du marqueur
222
- const rect = this.state.focusMarker.getBoundingClientRect();
223
- this.interaction.focusDragOffsetX = e.clientX - (rect.left + rect.width / 2);
224
- this.interaction.focusDragOffsetY = e.clientY - (rect.top + rect.height / 2);
225
-
226
- e.preventDefault();
227
- }
228
-
229
- /**
230
- * Crée l'overlay de zone de recadrage
231
- * @private
232
- */
233
- _createCropOverlay() {
234
- if (this.state.cropOverlay) return;
235
-
236
- const overlay = document.createElement('div');
237
- overlay.style.position = 'absolute';
238
- overlay.style.border = this.options.cropZone.style.border;
239
- overlay.style.backgroundColor = this.options.cropZone.style.backgroundColor;
240
- overlay.style.boxSizing = 'border-box';
241
- overlay.style.cursor = 'move';
242
- overlay.style.display = 'none';
243
- overlay.style.zIndex = '998';
244
-
245
- // Ajouter les poignées de redimensionnement
246
- const handles = ['tl', 'tm', 'tr', 'ml', 'mr', 'bl', 'bm', 'br'];
247
- handles.forEach(handleType => {
248
- const handle = document.createElement('div');
249
- handle.style.position = 'absolute';
250
- handle.style.width = this.options.cropZone.handleStyle.width;
251
- handle.style.height = this.options.cropZone.handleStyle.height;
252
- handle.style.backgroundColor = this.options.cropZone.handleStyle.backgroundColor;
253
- handle.style.border = this.options.cropZone.handleStyle.border;
254
- handle.style.boxShadow = this.options.cropZone.handleStyle.boxShadow;
255
- handle.style.boxSizing = 'border-box';
256
- handle.dataset.handle = handleType;
257
-
258
- // Positionner la poignée
259
- switch (handleType) {
260
- case 'tl': // Top-left
261
- handle.style.top = '-7px';
262
- handle.style.left = '-7px';
263
- handle.style.cursor = 'nwse-resize';
264
- break;
265
- case 'tm': // Top-middle
266
- handle.style.top = '-7px';
267
- handle.style.left = '50%';
268
- handle.style.marginLeft = '-7px';
269
- handle.style.cursor = 'ns-resize';
270
- break;
271
- case 'tr': // Top-right
272
- handle.style.top = '-7px';
273
- handle.style.right = '-7px';
274
- handle.style.cursor = 'nesw-resize';
275
- break;
276
- case 'ml': // Middle-left
277
- handle.style.top = '50%';
278
- handle.style.left = '-7px';
279
- handle.style.marginTop = '-7px';
280
- handle.style.cursor = 'ew-resize';
281
- break;
282
- case 'mr': // Middle-right
283
- handle.style.top = '50%';
284
- handle.style.right = '-7px';
285
- handle.style.marginTop = '-7px';
286
- handle.style.cursor = 'ew-resize';
287
- break;
288
- case 'bl': // Bottom-left
289
- handle.style.bottom = '-7px';
290
- handle.style.left = '-7px';
291
- handle.style.cursor = 'nesw-resize';
292
- break;
293
- case 'bm': // Bottom-middle
294
- handle.style.bottom = '-7px';
295
- handle.style.left = '50%';
296
- handle.style.marginLeft = '-7px';
297
- handle.style.cursor = 'ns-resize';
298
- break;
299
- case 'br': // Bottom-right
300
- handle.style.bottom = '-7px';
301
- handle.style.right = '-7px';
302
- handle.style.cursor = 'nwse-resize';
303
- break;
304
- }
305
-
306
- // Ajouter l'écouteur d'événement
307
- handle.addEventListener('mousedown', (e) => this._handleCropHandleMouseDown(e, handleType));
308
-
309
- overlay.appendChild(handle);
310
- });
311
-
312
- // Ajouter l'écouteur pour le déplacement de l'overlay
313
- overlay.addEventListener('mousedown', this._handleCropOverlayMouseDown.bind(this));
314
-
315
- this.imageElement.parentNode.appendChild(overlay);
316
- this.state.cropOverlay = overlay;
317
- }
318
-
319
- /**
320
- * Gère l'événement mousedown sur une poignée de redimensionnement
321
- * @private
322
- * @param {MouseEvent} e - Événement mousedown
323
- * @param {string} handleType - Type de poignée
324
- */
325
- _handleCropHandleMouseDown(e, handleType) {
326
- this.interaction.cropResizing = true;
327
- this.interaction.activeHandle = handleType;
328
-
329
- // Enregistrer les dimensions et position initiales
330
- this.interaction.startX = parseInt(this.state.cropOverlay.style.left, 10) || 0;
331
- this.interaction.startY = parseInt(this.state.cropOverlay.style.top, 10) || 0;
332
- this.interaction.startWidth = this.state.cropOverlay.offsetWidth;
333
- this.interaction.startHeight = this.state.cropOverlay.offsetHeight;
334
- this.interaction.startMouseX = e.clientX;
335
- this.interaction.startMouseY = e.clientY;
336
-
337
- e.preventDefault();
338
- e.stopPropagation();
339
- }
340
-
341
- /**
342
- * Gère l'événement mousedown sur l'overlay de recadrage
343
- * @private
344
- * @param {MouseEvent} e - Événement mousedown
345
- */
346
- _handleCropOverlayMouseDown(e) {
347
- // Ignorer si on clique sur une poignée
348
- if (e.target !== this.state.cropOverlay) return;
349
-
350
- this.interaction.cropDragging = true;
351
- this.state.cropOverlay.style.cursor = 'grabbing';
352
-
353
- // Enregistrer la position initiale
354
- this.interaction.startX = parseInt(this.state.cropOverlay.style.left, 10) || 0;
355
- this.interaction.startY = parseInt(this.state.cropOverlay.style.top, 10) || 0;
356
- this.interaction.startMouseX = e.clientX;
357
- this.interaction.startMouseY = e.clientY;
358
-
359
- e.preventDefault();
360
- }
361
-
362
- /**
363
- * Configure les écouteurs d'événements globaux
364
- * @private
365
- */
366
- _setupEventListeners() {
367
- // Écouteur pour le redimensionnement de la fenêtre
368
- window.addEventListener('resize', this._updateScaling.bind(this));
369
-
370
- // Écouteur pour le chargement de l'image
371
- if (!this.imageElement.complete) {
372
- this.imageElement.addEventListener('load', this._updateScaling.bind(this));
373
- }
374
-
375
- // Écouteurs pour les interactions de souris
376
- document.addEventListener('mouseup', this._handleMouseUp.bind(this));
377
- document.addEventListener('mousemove', this._handleMouseMove.bind(this));
378
- }
379
-
380
- /**
381
- * Gère l'événement mouseup global
382
- * @private
383
- */
384
- _handleMouseUp() {
385
- if (this.interaction.focusDragging) {
386
- this.interaction.focusDragging = false;
387
- if (this.state.focusMarker) this.state.focusMarker.style.cursor = 'move';
388
- }
389
-
390
- if (this.interaction.cropDragging) {
391
- this.interaction.cropDragging = false;
392
- if (this.state.cropOverlay) this.state.cropOverlay.style.cursor = 'move';
393
- }
394
-
395
- this.interaction.cropResizing = false;
396
- this.interaction.activeHandle = null;
397
- document.body.style.cursor = 'default';
398
- }
399
-
400
- /**
401
- * Gère l'événement mousemove global
402
- * @private
403
- * @param {MouseEvent} e - Événement mousemove
404
- */
405
- _handleMouseMove(e) {
406
- // Gestion du déplacement du point focal
407
- if (this.interaction.focusDragging && this.state.focusMarker) {
408
- this._handleFocusMarkerDrag(e);
409
- }
410
-
411
- // Gestion du déplacement de la zone de recadrage
412
- if (this.interaction.cropDragging) {
413
- this._handleCropOverlayDrag(e);
414
- }
415
-
416
- // Gestion du redimensionnement de la zone de recadrage
417
- if (this.interaction.cropResizing) {
418
- this._handleCropOverlayResize(e);
419
- }
420
- }
421
-
422
- /**
423
- * Gère le déplacement du marqueur de point focal
424
- * @private
425
- * @param {MouseEvent} e - Événement mousemove
426
- */
427
- _handleFocusMarkerDrag(e) {
428
- const imageRect = this.imageElement.getBoundingClientRect();
429
-
430
- // Calculer la position cible relative à l'image
431
- const targetScaledX = e.clientX - imageRect.left - this.interaction.focusDragOffsetX;
432
- const targetScaledY = e.clientY - imageRect.top - this.interaction.focusDragOffsetY;
433
-
434
- // Convertir en coordonnées originales
435
- const original = this._toOriginalCoords(targetScaledX, targetScaledY);
436
-
437
- // Mettre à jour le point focal
438
- this.setFocusPoint(original.x, original.y);
439
- }
440
-
441
- /**
442
- * Gère le déplacement de l'overlay de recadrage
443
- * @private
444
- * @param {MouseEvent} e - Événement mousemove
445
- */
446
- _handleCropOverlayDrag(e) {
447
- const deltaX = e.clientX - this.interaction.startMouseX;
448
- const deltaY = e.clientY - this.interaction.startMouseY;
449
-
450
- // Calculer la nouvelle position en pixels d'affichage
451
- let newX = this.interaction.startX + deltaX;
452
- let newY = this.interaction.startY + deltaY;
453
-
454
- // Convertir en coordonnées originales
455
- const original = this._toOriginalCoords(newX, newY);
456
-
457
- // Obtenir les dimensions actuelles en coordonnées originales
458
- const { width, height } = this.state.cropZone;
459
-
460
- // Mettre à jour la zone de recadrage
461
- this.setCropZone(original.x, original.y, width, height);
462
- }
463
-
464
- /**
465
- * Gère le redimensionnement de l'overlay de recadrage
466
- * @private
467
- * @param {MouseEvent} e - Événement mousemove
468
- */
469
- _handleCropOverlayResize(e) {
470
- if (!this.interaction.activeHandle) return;
471
-
472
- const deltaX = e.clientX - this.interaction.startMouseX;
473
- const deltaY = e.clientY - this.interaction.startMouseY;
474
-
475
- let newX = this.interaction.startX;
476
- let newY = this.interaction.startY;
477
- let newWidth = this.interaction.startWidth;
478
- let newHeight = this.interaction.startHeight;
479
-
480
- // Ajuster en fonction de la poignée active
481
- if (this.interaction.activeHandle.includes('t')) { // Top
482
- newY = this.interaction.startY + deltaY;
483
- newHeight = this.interaction.startHeight - deltaY;
484
- }
485
- if (this.interaction.activeHandle.includes('b')) { // Bottom
486
- newHeight = this.interaction.startHeight + deltaY;
487
- }
488
- if (this.interaction.activeHandle.includes('l')) { // Left
489
- newX = this.interaction.startX + deltaX;
490
- newWidth = this.interaction.startWidth - deltaX;
491
- }
492
- if (this.interaction.activeHandle.includes('r')) { // Right
493
- newWidth = this.interaction.startWidth + deltaX;
494
- }
495
-
496
- // Empêcher les dimensions négatives
497
- if (newWidth < 10) {
498
- if (this.interaction.activeHandle.includes('l')) {
499
- newX = this.interaction.startX + this.interaction.startWidth - 10;
500
- }
501
- newWidth = 10;
502
- }
503
- if (newHeight < 10) {
504
- if (this.interaction.activeHandle.includes('t')) {
505
- newY = this.interaction.startY + this.interaction.startHeight - 10;
506
- }
507
- newHeight = 10;
508
- }
509
-
510
- // Convertir les coordonnées d'affichage en coordonnées originales
511
- const originalX = newX / this.state.scaleX;
512
- const originalY = newY / this.state.scaleY;
513
- const originalWidth = newWidth / this.state.scaleX;
514
- const originalHeight = newHeight / this.state.scaleY;
515
-
516
- // Mettre à jour la zone de recadrage
517
- this.setCropZone(originalX, originalY, originalWidth, originalHeight);
518
- }
519
-
520
- /**
521
- * Met à jour la position du marqueur de point focal
522
- * @private
523
- */
524
- _updateFocusMarkerPosition() {
525
- if (!this.state.focusMarker) return;
526
-
527
- const { x, y } = this.state.focusPoint;
528
-
529
- // Limiter les coordonnées aux dimensions de l'image
530
- const clampedX = Math.max(0, Math.min(x, this.state.originalWidth));
531
- const clampedY = Math.max(0, Math.min(y, this.state.originalHeight));
532
-
533
- // Mettre à jour l'état si les coordonnées ont été limitées
534
- if (x !== clampedX || y !== clampedY) {
535
- this.state.focusPoint = { x: clampedX, y: clampedY };
536
- }
537
-
538
- const scaled = this._toScaledCoords(clampedX, clampedY);
539
-
540
- // Ajuster pour centrer le marqueur
541
- this.state.focusMarker.style.left = (scaled.x - this.state.focusMarker.offsetWidth / 2) + 'px';
542
- this.state.focusMarker.style.top = (scaled.y - this.state.focusMarker.offsetHeight / 2) + 'px';
543
- }
544
-
545
- /**
546
- * Met à jour la position et les dimensions de l'overlay de recadrage
547
- * @private
548
- */
549
- _updateCropOverlayPosition() {
550
- if (!this.state.cropOverlay) return;
551
-
552
- const { x, y, width, height } = this.state.cropZone;
553
-
554
- // Limiter aux dimensions de l'image
555
- const clampedX = Math.max(0, Math.min(x, this.state.originalWidth - width));
556
- const clampedY = Math.max(0, Math.min(y, this.state.originalHeight - height));
557
- const clampedWidth = Math.max(10, Math.min(width, this.state.originalWidth - clampedX));
558
- const clampedHeight = Math.max(10, Math.min(height, this.state.originalHeight - clampedY));
559
-
560
- // Mettre à jour l'état si les valeurs ont été limitées
561
- if (x !== clampedX || y !== clampedY || width !== clampedWidth || height !== clampedHeight) {
562
- this.state.cropZone = { x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight };
563
- }
564
-
565
- // Convertir en coordonnées d'affichage
566
- const scaled = this._toScaledCoords(clampedX, clampedY);
567
- const scaledWidth = clampedWidth * this.state.scaleX;
568
- const scaledHeight = clampedHeight * this.state.scaleY;
569
-
570
- // Mettre à jour l'overlay
571
- this.state.cropOverlay.style.left = scaled.x + 'px';
572
- this.state.cropOverlay.style.top = scaled.y + 'px';
573
- this.state.cropOverlay.style.width = scaledWidth + 'px';
574
- this.state.cropOverlay.style.height = scaledHeight + 'px';
575
- }
576
-
577
- /**
578
- * Active ou désactive le point focal
579
- * @public
580
- * @param {boolean} active - État d'activation
581
- * @returns {ImageTool} Instance pour chaînage
582
- */
583
- toggleFocusPoint(active) {
584
- if (!this.options.focusPoint.enabled) return this;
585
-
586
- if (active === undefined) {
587
- active = !this.state.focusActive;
588
- }
589
-
590
- if (active && !this.state.focusActive) {
591
- // Activer le point focal
592
- if (!this.state.focusMarker) {
593
- this._createFocusMarker();
594
- }
595
-
596
- // Positionner au centre de l'image par défaut si pas déjà défini
597
- if (this.state.focusPoint.x === 0 && this.state.focusPoint.y === 0) {
598
- this.state.focusPoint = {
599
- x: this.state.originalWidth / 2,
600
- y: this.state.originalHeight / 2
601
- };
602
- }
603
-
604
- this._updateFocusMarkerPosition();
605
- this.state.focusMarker.style.display = 'block';
606
- this.state.focusActive = true;
607
- } else if (!active && this.state.focusActive) {
608
- // Désactiver le point focal
609
- if (this.state.focusMarker) {
610
- this.state.focusMarker.style.display = 'none';
611
- }
612
- this.state.focusActive = false;
613
- }
614
-
615
- // Notifier le changement
616
- this._notifyChange();
617
-
618
- return this;
619
- }
620
-
621
- /**
622
- * Active ou désactive la zone de recadrage
623
- * @public
624
- * @param {boolean} active - État d'activation
625
- * @returns {ImageTool} Instance pour chaînage
626
- */
627
- toggleCropZone(active) {
628
- if (!this.options.cropZone.enabled) return this;
629
-
630
- if (active === undefined) {
631
- active = !this.state.cropActive;
632
- }
633
-
634
- if (active && !this.state.cropActive) {
635
- // Activer la zone de recadrage
636
- if (!this.state.cropOverlay) {
637
- this._createCropOverlay();
638
- }
639
-
640
- // Définir une zone par défaut si pas déjà définie
641
- if (this.state.cropZone.width === 0 || this.state.cropZone.height === 0) {
642
- const defaultWidth = this.state.originalWidth / 2;
643
- const defaultHeight = this.state.originalHeight / 2;
644
- const defaultX = (this.state.originalWidth - defaultWidth) / 2;
645
- const defaultY = (this.state.originalHeight - defaultHeight) / 2;
646
-
647
- this.state.cropZone = {
648
- x: defaultX,
649
- y: defaultY,
650
- width: defaultWidth,
651
- height: defaultHeight
652
- };
653
- }
654
-
655
- this._updateCropOverlayPosition();
656
- this.state.cropOverlay.style.display = 'block';
657
- this.state.cropActive = true;
658
- } else if (!active && this.state.cropActive) {
659
- // Désactiver la zone de recadrage
660
- if (this.state.cropOverlay) {
661
- this.state.cropOverlay.style.display = 'none';
662
- }
663
- this.state.cropActive = false;
664
- }
665
-
666
- // Notifier le changement
667
- this._notifyChange();
668
-
669
- return this;
670
- }
671
-
672
- /**
673
- * Définit la position du point focal
674
- * @public
675
- * @param {number} x - Coordonnée X en pixels originaux
676
- * @param {number} y - Coordonnée Y en pixels originaux
677
- * @returns {ImageTool} Instance pour chaînage
678
- */
679
- setFocusPoint(x, y) {
680
- // Limiter les coordonnées aux dimensions de l'image
681
- const clampedX = Math.max(0, Math.min(x, this.state.originalWidth));
682
- const clampedY = Math.max(0, Math.min(y, this.state.originalHeight));
683
-
684
- this.state.focusPoint = { x: clampedX, y: clampedY };
685
-
686
- if (this.state.focusActive && this.state.focusMarker) {
687
- this._updateFocusMarkerPosition();
688
- }
689
-
690
- // Notifier le changement
691
- this._notifyChange();
692
-
693
- return this;
694
- }
695
-
696
- /**
697
- * Définit la position et les dimensions de la zone de recadrage
698
- * @public
699
- * @param {number} x - Coordonnée X en pixels originaux
700
- * @param {number} y - Coordonnée Y en pixels originaux
701
- * @param {number} width - Largeur en pixels originaux
702
- * @param {number} height - Hauteur en pixels originaux
703
- * @returns {ImageTool} Instance pour chaînage
704
- */
705
- setCropZone(x, y, width, height) {
706
- // Limiter aux dimensions de l'image
707
- const clampedX = Math.max(0, Math.min(x, this.state.originalWidth - width));
708
- const clampedY = Math.max(0, Math.min(y, this.state.originalHeight - height));
709
- const clampedWidth = Math.max(10, Math.min(width, this.state.originalWidth - clampedX));
710
- const clampedHeight = Math.max(10, Math.min(height, this.state.originalHeight - clampedY));
711
-
712
- this.state.cropZone = {
713
- x: clampedX,
714
- y: clampedY,
715
- width: clampedWidth,
716
- height: clampedHeight
717
- };
718
-
719
- if (this.state.cropActive && this.state.cropOverlay) {
720
- this._updateCropOverlayPosition();
721
- }
722
-
723
- // Notifier le changement
724
- this._notifyChange();
725
-
726
- return this;
727
- }
728
-
729
- /**
730
- * Obtient la position actuelle du point focal
731
- * @public
732
- * @returns {Object} Coordonnées du point focal {x, y}
733
- */
734
- getFocusPoint() {
735
- return { ...this.state.focusPoint };
736
- }
737
-
738
- /**
739
- * Obtient la position et les dimensions actuelles de la zone de recadrage
740
- * @public
741
- * @returns {Object} Zone de recadrage {x, y, width, height}
742
- */
743
- getCropZone() {
744
- return { ...this.state.cropZone };
745
- }
746
-
747
- /**
748
- * Obtient les dimensions originales de l'image
749
- * @public
750
- * @returns {Object} Dimensions {width, height}
751
- */
752
- getImageDimensions() {
753
- return {
754
- width: this.state.originalWidth,
755
- height: this.state.originalHeight
756
- };
757
- }
758
-
759
- /**
760
- * Notifie les changements via le callback
761
- * @private
762
- */
763
- _notifyChange() {
764
- if (typeof this.options.onChange === 'function') {
765
- this.options.onChange({
766
- focusPoint: this.getFocusPoint(),
767
- cropZone: this.getCropZone(),
768
- focusActive: this.state.focusActive,
769
- cropActive: this.state.cropActive
770
- });
771
- }
772
- }
773
-
774
- /**
775
- * Détruit l'instance et nettoie les ressources
776
- * @public
777
- */
778
- destroy() {
779
- // Supprimer les éléments DOM
780
- if (this.state.focusMarker && this.state.focusMarker.parentNode) {
781
- this.state.focusMarker.parentNode.removeChild(this.state.focusMarker);
782
- }
783
-
784
- if (this.state.cropOverlay && this.state.cropOverlay.parentNode) {
785
- this.state.cropOverlay.parentNode.removeChild(this.state.cropOverlay);
786
- }
787
-
788
- // Supprimer les écouteurs d'événements
789
- window.removeEventListener('resize', this._updateScaling.bind(this));
790
- document.removeEventListener('mouseup', this._handleMouseUp.bind(this));
791
- document.removeEventListener('mousemove', this._handleMouseMove.bind(this));
792
-
793
- // Réinitialiser l'état
794
- this.state = null;
795
- this.interaction = null;
796
- this.options = null;
797
- this.imageElement = null;
798
- }
799
- }
800
-
801
- // Exporter la classe
802
- export default VisualImageTool;
803
-
7
+ /**
8
+ * Crée une instance de l'outil d'image
9
+ * @param {Object} options - Options de configuration
10
+ * @param {HTMLElement|string} options.imageElement - Élément image ou sélecteur CSS
11
+ * @param {Object} [options.focusPoint] - Configuration du point focal
12
+ * @param {boolean} [options.focusPoint.enabled=true] - Activer la fonctionnalité de point focal
13
+ * @param {Object} [options.focusPoint.style] - Styles personnalisés pour le marqueur de point focal
14
+ * @param {Object} [options.cropZone] - Configuration de la zone de recadrage
15
+ * @param {boolean} [options.cropZone.enabled=true] - Activer la fonctionnalité de zone de recadrage
16
+ * @param {Object} [options.cropZone.style] - Styles personnalisés pour l'overlay de recadrage
17
+ * @param {Function} [options.onChange] - Callback appelé lors des changements
18
+ */
19
+ constructor(options) {
20
+ // Valider les options
21
+ if (!options || !options.imageElement) {
22
+ throw new Error("L'élément image est requis");
23
+ }
24
+
25
+ // Initialiser les propriétés
26
+ this.imageElement =
27
+ typeof options.imageElement === "string"
28
+ ? document.querySelector(options.imageElement)
29
+ : options.imageElement;
30
+
31
+ if (!this.imageElement || this.imageElement.tagName !== "IMG") {
32
+ throw new Error("Élément image invalide");
33
+ }
34
+
35
+ // Options par défaut
36
+ this.options = {
37
+ focusPoint: {
38
+ enabled: true,
39
+ style: {
40
+ width: "30px",
41
+ height: "30px",
42
+ border: "3px solid white",
43
+ boxShadow: "0 0 0 2px black, 0 0 5px rgba(0,0,0,0.5)",
44
+ backgroundColor: "rgba(255, 0, 0, 0.5)",
45
+ },
46
+ ...options.focusPoint,
47
+ },
48
+ cropZone: {
49
+ enabled: true,
50
+ style: {
51
+ border: "1px dashed #fff",
52
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
53
+ },
54
+ handleStyle: {
55
+ width: "14px",
56
+ height: "14px",
57
+ backgroundColor: "white",
58
+ border: "2px solid black",
59
+ boxShadow: "0 0 3px rgba(0,0,0,0.5)",
60
+ },
61
+ ...options.cropZone,
62
+ },
63
+ onChange: options.onChange || (() => {}),
64
+ debug: options.debug || false,
65
+ };
66
+
67
+ // État interne
68
+ this.state = {
69
+ focusMarker: null,
70
+ cropOverlay: null,
71
+ focusActive: false,
72
+ cropActive: false,
73
+ focusPoint: { x: 0, y: 0 },
74
+ cropZone: { x: 0, y: 0, width: 0, height: 0 },
75
+ originalWidth: 1,
76
+ originalHeight: 1,
77
+ displayWidth: 1,
78
+ displayHeight: 1,
79
+ scaleX: 1,
80
+ scaleY: 1,
81
+ };
82
+
83
+ // Variables pour le suivi des interactions
84
+ this.interaction = {
85
+ focusDragging: false,
86
+ focusDragOffsetX: 0,
87
+ focusDragOffsetY: 0,
88
+ cropDragging: false,
89
+ cropResizing: false,
90
+ activeHandle: null,
91
+ startX: 0,
92
+ startY: 0,
93
+ startWidth: 0,
94
+ startHeight: 0,
95
+ startMouseX: 0,
96
+ startMouseY: 0,
97
+ };
98
+
99
+ // Initialiser l'outil
100
+ this._init();
101
+ }
102
+
103
+ /**
104
+ * Initialise l'outil d'image
105
+ * @private
106
+ */
107
+ _init() {
108
+ // Préparer le conteneur parent
109
+ this._prepareContainer();
110
+
111
+ // Initialiser les dimensions
112
+ this._updateScaling();
113
+
114
+ // Créer les éléments d'interface si activés
115
+ if (this.options.focusPoint.enabled) {
116
+ this._createFocusMarker();
117
+ }
118
+
119
+ if (this.options.cropZone.enabled) {
120
+ this._createCropOverlay();
121
+ }
122
+
123
+ // Ajouter les écouteurs d'événements
124
+ this._setupEventListeners();
125
+ }
126
+
127
+ /**
128
+ * Prépare le conteneur parent de l'image
129
+ * @private
130
+ */
131
+ _prepareContainer() {
132
+ // S'assurer que le parent est positionné
133
+ if (this.imageElement.parentNode) {
134
+ this.imageElement.parentNode.style.position = "relative";
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Met à jour les facteurs d'échelle
140
+ * @private
141
+ */
142
+ _updateScaling() {
143
+ this.state.originalWidth = this.imageElement.naturalWidth || 1;
144
+ this.state.originalHeight = this.imageElement.naturalHeight || 1;
145
+ this.state.displayWidth = this.imageElement.offsetWidth;
146
+ this.state.displayHeight = this.imageElement.offsetHeight;
147
+ this.state.scaleX = this.state.displayWidth / this.state.originalWidth;
148
+ this.state.scaleY = this.state.displayHeight / this.state.originalHeight;
149
+
150
+ // Repositionner les éléments si actifs
151
+ if (this.state.focusActive) {
152
+ this._updateFocusMarkerPosition();
153
+ }
154
+
155
+ if (this.state.cropActive) {
156
+ this._updateCropOverlayPosition();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Convertit des coordonnées d'affichage en coordonnées originales
162
+ * @private
163
+ * @param {number} scaledX - Coordonnée X à l'échelle d'affichage
164
+ * @param {number} scaledY - Coordonnée Y à l'échelle d'affichage
165
+ * @returns {Object} Coordonnées originales
166
+ */
167
+ _toOriginalCoords(scaledX, scaledY) {
168
+ return {
169
+ x: scaledX / this.state.scaleX,
170
+ y: scaledY / this.state.scaleY,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Convertit des coordonnées originales en coordonnées d'affichage
176
+ * @private
177
+ * @param {number} originalX - Coordonnée X originale
178
+ * @param {number} originalY - Coordonnée Y originale
179
+ * @returns {Object} Coordonnées à l'échelle d'affichage
180
+ */
181
+ _toScaledCoords(originalX, originalY) {
182
+ return {
183
+ x: originalX * this.state.scaleX,
184
+ y: originalY * this.state.scaleY,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Crée le marqueur de point focal
190
+ * @private
191
+ */
192
+ _createFocusMarker() {
193
+ if (this.state.focusMarker) return;
194
+
195
+ const marker = document.createElement("div");
196
+ marker.style.position = "absolute";
197
+ marker.style.width = this.options.focusPoint.style.width;
198
+ marker.style.height = this.options.focusPoint.style.height;
199
+ marker.style.border = this.options.focusPoint.style.border;
200
+ marker.style.boxShadow = this.options.focusPoint.style.boxShadow;
201
+ marker.style.backgroundColor =
202
+ this.options.focusPoint.style.backgroundColor;
203
+ marker.style.cursor = "move";
204
+ marker.style.display = "none";
205
+ marker.style.zIndex = "999";
206
+ marker.style.clipPath =
207
+ "polygon(40% 0%, 60% 0%, 60% 40%, 100% 40%, 100% 60%, 60% 60%, 60% 100%, 40% 100%, 40% 60%, 0% 60%, 0% 40%, 40% 40%)";
208
+
209
+ this.imageElement.parentNode.appendChild(marker);
210
+ this.state.focusMarker = marker;
211
+
212
+ // Ajouter les écouteurs d'événements au marqueur
213
+ marker.addEventListener(
214
+ "mousedown",
215
+ this._handleFocusMarkerMouseDown.bind(this),
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Gère l'événement mousedown sur le marqueur de point focal
221
+ * @private
222
+ * @param {MouseEvent} e - Événement mousedown
223
+ */
224
+ _handleFocusMarkerMouseDown(e) {
225
+ this.interaction.focusDragging = true;
226
+ this.state.focusMarker.style.cursor = "grabbing";
227
+
228
+ // Calculer le décalage par rapport au centre du marqueur
229
+ const rect = this.state.focusMarker.getBoundingClientRect();
230
+ this.interaction.focusDragOffsetX =
231
+ e.clientX - (rect.left + rect.width / 2);
232
+ this.interaction.focusDragOffsetY =
233
+ e.clientY - (rect.top + rect.height / 2);
234
+
235
+ e.preventDefault();
236
+ }
237
+
238
+ /**
239
+ * Crée l'overlay de zone de recadrage
240
+ * @private
241
+ */
242
+ _createCropOverlay() {
243
+ if (this.state.cropOverlay) return;
244
+
245
+ const overlay = document.createElement("div");
246
+ overlay.style.position = "absolute";
247
+ overlay.style.border = this.options.cropZone.style.border;
248
+ overlay.style.backgroundColor = this.options.cropZone.style.backgroundColor;
249
+ overlay.style.boxSizing = "border-box";
250
+ overlay.style.cursor = "move";
251
+ overlay.style.display = "none";
252
+ overlay.style.zIndex = "998";
253
+
254
+ // Ajouter les poignées de redimensionnement
255
+ const handles = ["tl", "tm", "tr", "ml", "mr", "bl", "bm", "br"];
256
+ for (const handleType of handles) {
257
+ const handle = document.createElement("div");
258
+ handle.style.position = "absolute";
259
+ handle.style.width = this.options.cropZone.handleStyle.width;
260
+ handle.style.height = this.options.cropZone.handleStyle.height;
261
+ handle.style.backgroundColor =
262
+ this.options.cropZone.handleStyle.backgroundColor;
263
+ handle.style.border = this.options.cropZone.handleStyle.border;
264
+ handle.style.boxShadow = this.options.cropZone.handleStyle.boxShadow;
265
+ handle.style.boxSizing = "border-box";
266
+ handle.dataset.handle = handleType;
267
+
268
+ // Positionner la poignée
269
+ switch (handleType) {
270
+ case "tl": // Top-left
271
+ handle.style.top = "-7px";
272
+ handle.style.left = "-7px";
273
+ handle.style.cursor = "nwse-resize";
274
+ break;
275
+ case "tm": // Top-middle
276
+ handle.style.top = "-7px";
277
+ handle.style.left = "50%";
278
+ handle.style.marginLeft = "-7px";
279
+ handle.style.cursor = "ns-resize";
280
+ break;
281
+ case "tr": // Top-right
282
+ handle.style.top = "-7px";
283
+ handle.style.right = "-7px";
284
+ handle.style.cursor = "nesw-resize";
285
+ break;
286
+ case "ml": // Middle-left
287
+ handle.style.top = "50%";
288
+ handle.style.left = "-7px";
289
+ handle.style.marginTop = "-7px";
290
+ handle.style.cursor = "ew-resize";
291
+ break;
292
+ case "mr": // Middle-right
293
+ handle.style.top = "50%";
294
+ handle.style.right = "-7px";
295
+ handle.style.marginTop = "-7px";
296
+ handle.style.cursor = "ew-resize";
297
+ break;
298
+ case "bl": // Bottom-left
299
+ handle.style.bottom = "-7px";
300
+ handle.style.left = "-7px";
301
+ handle.style.cursor = "nesw-resize";
302
+ break;
303
+ case "bm": // Bottom-middle
304
+ handle.style.bottom = "-7px";
305
+ handle.style.left = "50%";
306
+ handle.style.marginLeft = "-7px";
307
+ handle.style.cursor = "ns-resize";
308
+ break;
309
+ case "br": // Bottom-right
310
+ handle.style.bottom = "-7px";
311
+ handle.style.right = "-7px";
312
+ handle.style.cursor = "nwse-resize";
313
+ break;
314
+ }
315
+
316
+ // Ajouter l'écouteur d'événement
317
+ handle.addEventListener("mousedown", (e) =>
318
+ this._handleCropHandleMouseDown(e, handleType),
319
+ );
320
+
321
+ overlay.appendChild(handle);
322
+ }
323
+
324
+ // Ajouter l'écouteur pour le déplacement de l'overlay
325
+ overlay.addEventListener(
326
+ "mousedown",
327
+ this._handleCropOverlayMouseDown.bind(this),
328
+ );
329
+
330
+ this.imageElement.parentNode.appendChild(overlay);
331
+ this.state.cropOverlay = overlay;
332
+ }
333
+
334
+ /**
335
+ * Gère l'événement mousedown sur une poignée de redimensionnement
336
+ * @private
337
+ * @param {MouseEvent} e - Événement mousedown
338
+ * @param {string} handleType - Type de poignée
339
+ */
340
+ _handleCropHandleMouseDown(e, handleType) {
341
+ this.interaction.cropResizing = true;
342
+ this.interaction.activeHandle = handleType;
343
+
344
+ // Enregistrer les dimensions et position initiales
345
+ this.interaction.startX =
346
+ Number.parseInt(this.state.cropOverlay.style.left, 10) || 0;
347
+ this.interaction.startY =
348
+ Number.parseInt(this.state.cropOverlay.style.top, 10) || 0;
349
+ this.interaction.startWidth = this.state.cropOverlay.offsetWidth;
350
+ this.interaction.startHeight = this.state.cropOverlay.offsetHeight;
351
+ this.interaction.startMouseX = e.clientX;
352
+ this.interaction.startMouseY = e.clientY;
353
+
354
+ e.preventDefault();
355
+ e.stopPropagation();
356
+ }
357
+
358
+ /**
359
+ * Gère l'événement mousedown sur l'overlay de recadrage
360
+ * @private
361
+ * @param {MouseEvent} e - Événement mousedown
362
+ */
363
+ _handleCropOverlayMouseDown(e) {
364
+ // Ignorer si on clique sur une poignée
365
+ if (e.target !== this.state.cropOverlay) return;
366
+
367
+ this.interaction.cropDragging = true;
368
+ this.state.cropOverlay.style.cursor = "grabbing";
369
+
370
+ // Enregistrer la position initiale
371
+ this.interaction.startX =
372
+ Number.parseInt(this.state.cropOverlay.style.left, 10) || 0;
373
+ this.interaction.startY =
374
+ Number.parseInt(this.state.cropOverlay.style.top, 10) || 0;
375
+ this.interaction.startMouseX = e.clientX;
376
+ this.interaction.startMouseY = e.clientY;
377
+
378
+ e.preventDefault();
379
+ }
380
+
381
+ /**
382
+ * Configure les écouteurs d'événements globaux
383
+ * @private
384
+ */
385
+ _setupEventListeners() {
386
+ // Écouteur pour le redimensionnement de la fenêtre
387
+ window.addEventListener("resize", this._updateScaling.bind(this));
388
+
389
+ // Écouteur pour le chargement de l'image
390
+ if (!this.imageElement.complete) {
391
+ this.imageElement.addEventListener(
392
+ "load",
393
+ this._updateScaling.bind(this),
394
+ );
395
+ }
396
+
397
+ // Écouteurs pour les interactions de souris
398
+ document.addEventListener("mouseup", this._handleMouseUp.bind(this));
399
+ document.addEventListener("mousemove", this._handleMouseMove.bind(this));
400
+ }
401
+
402
+ /**
403
+ * Gère l'événement mouseup global
404
+ * @private
405
+ */
406
+ _handleMouseUp() {
407
+ if (this.interaction.focusDragging) {
408
+ this.interaction.focusDragging = false;
409
+ if (this.state.focusMarker) this.state.focusMarker.style.cursor = "move";
410
+ }
411
+
412
+ if (this.interaction.cropDragging) {
413
+ this.interaction.cropDragging = false;
414
+ if (this.state.cropOverlay) this.state.cropOverlay.style.cursor = "move";
415
+ }
416
+
417
+ this.interaction.cropResizing = false;
418
+ this.interaction.activeHandle = null;
419
+ document.body.style.cursor = "default";
420
+ }
421
+
422
+ /**
423
+ * Gère l'événement mousemove global
424
+ * @private
425
+ * @param {MouseEvent} e - Événement mousemove
426
+ */
427
+ _handleMouseMove(e) {
428
+ // Gestion du déplacement du point focal
429
+ if (this.interaction.focusDragging && this.state.focusMarker) {
430
+ this._handleFocusMarkerDrag(e);
431
+ }
432
+
433
+ // Gestion du déplacement de la zone de recadrage
434
+ if (this.interaction.cropDragging) {
435
+ this._handleCropOverlayDrag(e);
436
+ }
437
+
438
+ // Gestion du redimensionnement de la zone de recadrage
439
+ if (this.interaction.cropResizing) {
440
+ this._handleCropOverlayResize(e);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Gère le déplacement du marqueur de point focal
446
+ * @private
447
+ * @param {MouseEvent} e - Événement mousemove
448
+ */
449
+ _handleFocusMarkerDrag(e) {
450
+ const imageRect = this.imageElement.getBoundingClientRect();
451
+
452
+ // Calculer la position cible relative à l'image
453
+ const targetScaledX =
454
+ e.clientX - imageRect.left - this.interaction.focusDragOffsetX;
455
+ const targetScaledY =
456
+ e.clientY - imageRect.top - this.interaction.focusDragOffsetY;
457
+
458
+ // Convertir en coordonnées originales
459
+ const original = this._toOriginalCoords(targetScaledX, targetScaledY);
460
+
461
+ // Mettre à jour le point focal
462
+ this.setFocusPoint(original.x, original.y);
463
+ }
464
+
465
+ /**
466
+ * Gère le déplacement de l'overlay de recadrage
467
+ * @private
468
+ * @param {MouseEvent} e - Événement mousemove
469
+ */
470
+ _handleCropOverlayDrag(e) {
471
+ const deltaX = e.clientX - this.interaction.startMouseX;
472
+ const deltaY = e.clientY - this.interaction.startMouseY;
473
+
474
+ // Calculer la nouvelle position en pixels d'affichage
475
+ const newX = this.interaction.startX + deltaX;
476
+ const newY = this.interaction.startY + deltaY;
477
+
478
+ // Convertir en coordonnées originales
479
+ const original = this._toOriginalCoords(newX, newY);
480
+
481
+ // Obtenir les dimensions actuelles en coordonnées originales
482
+ const { width, height } = this.state.cropZone;
483
+
484
+ // Mettre à jour la zone de recadrage
485
+ this.setCropZone(original.x, original.y, width, height);
486
+ }
487
+
488
+ /**
489
+ * Gère le redimensionnement de l'overlay de recadrage
490
+ * @private
491
+ * @param {MouseEvent} e - Événement mousemove
492
+ */
493
+ _handleCropOverlayResize(e) {
494
+ if (!this.interaction.activeHandle) return;
495
+
496
+ const deltaX = e.clientX - this.interaction.startMouseX;
497
+ const deltaY = e.clientY - this.interaction.startMouseY;
498
+
499
+ let newX = this.interaction.startX;
500
+ let newY = this.interaction.startY;
501
+ let newWidth = this.interaction.startWidth;
502
+ let newHeight = this.interaction.startHeight;
503
+
504
+ // Ajuster en fonction de la poignée active
505
+ if (this.interaction.activeHandle.includes("t")) {
506
+ // Top
507
+ newY = this.interaction.startY + deltaY;
508
+ newHeight = this.interaction.startHeight - deltaY;
509
+ }
510
+ if (this.interaction.activeHandle.includes("b")) {
511
+ // Bottom
512
+ newHeight = this.interaction.startHeight + deltaY;
513
+ }
514
+ if (this.interaction.activeHandle.includes("l")) {
515
+ // Left
516
+ newX = this.interaction.startX + deltaX;
517
+ newWidth = this.interaction.startWidth - deltaX;
518
+ }
519
+ if (this.interaction.activeHandle.includes("r")) {
520
+ // Right
521
+ newWidth = this.interaction.startWidth + deltaX;
522
+ }
523
+
524
+ // Empêcher les dimensions négatives
525
+ if (newWidth < 10) {
526
+ if (this.interaction.activeHandle.includes("l")) {
527
+ newX = this.interaction.startX + this.interaction.startWidth - 10;
528
+ }
529
+ newWidth = 10;
530
+ }
531
+ if (newHeight < 10) {
532
+ if (this.interaction.activeHandle.includes("t")) {
533
+ newY = this.interaction.startY + this.interaction.startHeight - 10;
534
+ }
535
+ newHeight = 10;
536
+ }
537
+
538
+ // Convertir les coordonnées d'affichage en coordonnées originales
539
+ const originalX = newX / this.state.scaleX;
540
+ const originalY = newY / this.state.scaleY;
541
+ const originalWidth = newWidth / this.state.scaleX;
542
+ const originalHeight = newHeight / this.state.scaleY;
543
+
544
+ // Mettre à jour la zone de recadrage
545
+ this.setCropZone(originalX, originalY, originalWidth, originalHeight);
546
+ }
547
+
548
+ /**
549
+ * Met à jour la position du marqueur de point focal
550
+ * @private
551
+ */
552
+ _updateFocusMarkerPosition() {
553
+ if (!this.state.focusMarker) return;
554
+
555
+ const { x, y } = this.state.focusPoint;
556
+
557
+ // Limiter les coordonnées aux dimensions de l'image
558
+ const clampedX = Math.max(0, Math.min(x, this.state.originalWidth));
559
+ const clampedY = Math.max(0, Math.min(y, this.state.originalHeight));
560
+
561
+ // Mettre à jour l'état si les coordonnées ont été limitées
562
+ if (x !== clampedX || y !== clampedY) {
563
+ this.state.focusPoint = { x: clampedX, y: clampedY };
564
+ }
565
+
566
+ const scaled = this._toScaledCoords(clampedX, clampedY);
567
+
568
+ // LOGGING: Validate padding offset
569
+ if (this.options.debug) {
570
+ const container = this.imageElement.parentNode;
571
+ const computedStyle = window.getComputedStyle(container);
572
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft);
573
+ const paddingTop = Number.parseFloat(computedStyle.paddingTop);
574
+ console.log(
575
+ "[FocusMarker] scaled:",
576
+ scaled,
577
+ "paddingLeft:",
578
+ paddingLeft,
579
+ "paddingTop:",
580
+ paddingTop,
581
+ "containerRect:",
582
+ container.getBoundingClientRect(),
583
+ );
584
+ }
585
+
586
+ // Ajuster pour centrer le marqueur
587
+ // Adjust for container padding (use unique variable names)
588
+ let focusPaddingLeft = 0;
589
+ let focusPaddingTop = 0;
590
+ {
591
+ const container = this.imageElement.parentNode;
592
+ const computedStyle = window.getComputedStyle(container);
593
+ focusPaddingLeft = Number.parseFloat(computedStyle.paddingLeft);
594
+ focusPaddingTop = Number.parseFloat(computedStyle.paddingTop);
595
+ }
596
+
597
+ this.state.focusMarker.style.left = `${
598
+ scaled.x + focusPaddingLeft - this.state.focusMarker.offsetWidth / 2
599
+ }px`;
600
+ this.state.focusMarker.style.top = `${
601
+ scaled.y + focusPaddingTop - this.state.focusMarker.offsetHeight / 2
602
+ }px`;
603
+ if (this.options.debug) {
604
+ console.log(
605
+ "[FocusMarker] style.left:",
606
+ this.state.focusMarker.style.left,
607
+ "style.top:",
608
+ this.state.focusMarker.style.top,
609
+ );
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Met à jour la position et les dimensions de l'overlay de recadrage
615
+ * @private
616
+ */
617
+ _updateCropOverlayPosition() {
618
+ if (!this.state.cropOverlay) return;
619
+
620
+ const { x, y, width, height } = this.state.cropZone;
621
+
622
+ // Limiter aux dimensions de l'image
623
+ const clampedX = Math.max(0, Math.min(x, this.state.originalWidth - width));
624
+ const clampedY = Math.max(
625
+ 0,
626
+ Math.min(y, this.state.originalHeight - height),
627
+ );
628
+ const clampedWidth = Math.max(
629
+ 10,
630
+ Math.min(width, this.state.originalWidth - clampedX),
631
+ );
632
+ const clampedHeight = Math.max(
633
+ 10,
634
+ Math.min(height, this.state.originalHeight - clampedY),
635
+ );
636
+
637
+ // Mettre à jour l'état si les valeurs ont été limitées
638
+ if (
639
+ x !== clampedX ||
640
+ y !== clampedY ||
641
+ width !== clampedWidth ||
642
+ height !== clampedHeight
643
+ ) {
644
+ this.state.cropZone = {
645
+ x: clampedX,
646
+ y: clampedY,
647
+ width: clampedWidth,
648
+ height: clampedHeight,
649
+ };
650
+ }
651
+
652
+ // Convertir en coordonnées d'affichage
653
+ const scaled = this._toScaledCoords(clampedX, clampedY);
654
+ const scaledWidth = clampedWidth * this.state.scaleX;
655
+ const scaledHeight = clampedHeight * this.state.scaleY;
656
+
657
+ // LOGGING: Validate padding offset
658
+ if (this.options.debug) {
659
+ const container = this.imageElement.parentNode;
660
+ const computedStyle = window.getComputedStyle(container);
661
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft);
662
+ const paddingTop = Number.parseFloat(computedStyle.paddingTop);
663
+ console.log(
664
+ "[CropOverlay] scaled:",
665
+ scaled,
666
+ "paddingLeft:",
667
+ paddingLeft,
668
+ "paddingTop:",
669
+ paddingTop,
670
+ "containerRect:",
671
+ container.getBoundingClientRect(),
672
+ );
673
+ }
674
+
675
+ // Mettre à jour l'overlay
676
+ // Adjust for container padding (use unique variable names)
677
+ let cropPaddingLeft = 0;
678
+ let cropPaddingTop = 0;
679
+ {
680
+ const container = this.imageElement.parentNode;
681
+ const computedStyle = window.getComputedStyle(container);
682
+ cropPaddingLeft = Number.parseFloat(computedStyle.paddingLeft);
683
+ cropPaddingTop = Number.parseFloat(computedStyle.paddingTop);
684
+ }
685
+
686
+ this.state.cropOverlay.style.left = `${scaled.x + cropPaddingLeft}px`;
687
+ this.state.cropOverlay.style.top = `${scaled.y + cropPaddingTop}px`;
688
+ this.state.cropOverlay.style.width = `${scaledWidth}px`;
689
+ this.state.cropOverlay.style.height = `${scaledHeight}px`;
690
+ if (this.options.debug) {
691
+ console.log(
692
+ "[CropOverlay] style.left:",
693
+ this.state.cropOverlay.style.left,
694
+ "style.top:",
695
+ this.state.cropOverlay.style.top,
696
+ );
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Active ou désactive le point focal
702
+ * @public
703
+ * @param {boolean} active - État d'activation
704
+ * @returns {ImageTool} Instance pour chaînage
705
+ */
706
+ toggleFocusPoint(active) {
707
+ if (!this.options.focusPoint.enabled) return this;
708
+
709
+ const isActive = active === undefined ? !this.state.focusActive : active;
710
+
711
+ if (active && !this.state.focusActive) {
712
+ // Activer le point focal
713
+ if (!this.state.focusMarker) {
714
+ this._createFocusMarker();
715
+ }
716
+
717
+ // Positionner au centre de l'image par défaut si pas déjà défini
718
+ if (this.state.focusPoint.x === 0 && this.state.focusPoint.y === 0) {
719
+ this.state.focusPoint = {
720
+ x: this.state.originalWidth / 2,
721
+ y: this.state.originalHeight / 2,
722
+ };
723
+ }
724
+
725
+ this._updateFocusMarkerPosition();
726
+ this.state.focusMarker.style.display = "block";
727
+ this.state.focusActive = true;
728
+ } else if (!active && this.state.focusActive) {
729
+ // Désactiver le point focal
730
+ if (this.state.focusMarker) {
731
+ this.state.focusMarker.style.display = "none";
732
+ }
733
+ this.state.focusActive = false;
734
+ }
735
+
736
+ // Notifier le changement
737
+ this._notifyChange();
738
+
739
+ return this;
740
+ }
741
+
742
+ /**
743
+ * Active ou désactive la zone de recadrage
744
+ * @public
745
+ * @param {boolean} active - État d'activation
746
+ * @returns {ImageTool} Instance pour chaînage
747
+ */
748
+ toggleCropZone(active) {
749
+ if (!this.options.cropZone.enabled) return this;
750
+
751
+ const isActive = active === undefined ? !this.state.cropActive : active;
752
+
753
+ if (active && !this.state.cropActive) {
754
+ // Activer la zone de recadrage
755
+ if (!this.state.cropOverlay) {
756
+ this._createCropOverlay();
757
+ }
758
+
759
+ // Définir une zone par défaut si pas déjà définie
760
+ if (this.state.cropZone.width === 0 || this.state.cropZone.height === 0) {
761
+ const defaultWidth = this.state.originalWidth / 2;
762
+ const defaultHeight = this.state.originalHeight / 2;
763
+ const defaultX = (this.state.originalWidth - defaultWidth) / 2;
764
+ const defaultY = (this.state.originalHeight - defaultHeight) / 2;
765
+
766
+ this.state.cropZone = {
767
+ x: defaultX,
768
+ y: defaultY,
769
+ width: defaultWidth,
770
+ height: defaultHeight,
771
+ };
772
+ }
773
+
774
+ this._updateCropOverlayPosition();
775
+ this.state.cropOverlay.style.display = "block";
776
+ this.state.cropActive = true;
777
+ } else if (!active && this.state.cropActive) {
778
+ // Désactiver la zone de recadrage
779
+ if (this.state.cropOverlay) {
780
+ this.state.cropOverlay.style.display = "none";
781
+ }
782
+ this.state.cropActive = false;
783
+ }
784
+
785
+ // Notifier le changement
786
+ this._notifyChange();
787
+
788
+ return this;
789
+ }
790
+
791
+ /**
792
+ * Définit la position du point focal
793
+ * @public
794
+ * @param {number} x - Coordonnée X en pixels originaux
795
+ * @param {number} y - Coordonnée Y en pixels originaux
796
+ * @returns {ImageTool} Instance pour chaînage
797
+ */
798
+ setFocusPoint(x, y) {
799
+ // Limiter les coordonnées aux dimensions de l'image
800
+ const clampedX = Math.max(0, Math.min(x, this.state.originalWidth));
801
+ const clampedY = Math.max(0, Math.min(y, this.state.originalHeight));
802
+
803
+ this.state.focusPoint = { x: clampedX, y: clampedY };
804
+
805
+ if (this.state.focusActive && this.state.focusMarker) {
806
+ this._updateFocusMarkerPosition();
807
+ }
808
+
809
+ // Notifier le changement
810
+ this._notifyChange();
811
+
812
+ return this;
813
+ }
814
+
815
+ /**
816
+ * Définit la position et les dimensions de la zone de recadrage
817
+ * @public
818
+ * @param {number} x - Coordonnée X en pixels originaux
819
+ * @param {number} y - Coordonnée Y en pixels originaux
820
+ * @param {number} width - Largeur en pixels originaux
821
+ * @param {number} height - Hauteur en pixels originaux
822
+ * @returns {ImageTool} Instance pour chaînage
823
+ */
824
+ setCropZone(x, y, width, height) {
825
+ // Limiter aux dimensions de l'image
826
+ const clampedX = Math.max(0, Math.min(x, this.state.originalWidth - width));
827
+ const clampedY = Math.max(
828
+ 0,
829
+ Math.min(y, this.state.originalHeight - height),
830
+ );
831
+ const clampedWidth = Math.max(
832
+ 10,
833
+ Math.min(width, this.state.originalWidth - clampedX),
834
+ );
835
+ const clampedHeight = Math.max(
836
+ 10,
837
+ Math.min(height, this.state.originalHeight - clampedY),
838
+ );
839
+
840
+ this.state.cropZone = {
841
+ x: clampedX,
842
+ y: clampedY,
843
+ width: clampedWidth,
844
+ height: clampedHeight,
845
+ };
846
+
847
+ if (this.state.cropActive && this.state.cropOverlay) {
848
+ this._updateCropOverlayPosition();
849
+ }
850
+
851
+ // Notifier le changement
852
+ this._notifyChange();
853
+
854
+ return this;
855
+ }
856
+
857
+ /**
858
+ * Obtient la position actuelle du point focal
859
+ * @public
860
+ * @returns {Object} Coordonnées du point focal {x, y}
861
+ */
862
+ getFocusPoint() {
863
+ return { ...this.state.focusPoint };
864
+ }
865
+
866
+ /**
867
+ * Obtient la position et les dimensions actuelles de la zone de recadrage
868
+ * @public
869
+ * @returns {Object} Zone de recadrage {x, y, width, height}
870
+ */
871
+ getCropZone() {
872
+ return { ...this.state.cropZone };
873
+ }
874
+
875
+ /**
876
+ * Obtient les dimensions originales de l'image
877
+ * @public
878
+ * @returns {Object} Dimensions {width, height}
879
+ */
880
+ getImageDimensions() {
881
+ return {
882
+ width: this.state.originalWidth,
883
+ height: this.state.originalHeight,
884
+ };
885
+ }
886
+
887
+ /**
888
+ * Notifie les changements via le callback
889
+ * @private
890
+ */
891
+ _notifyChange() {
892
+ if (typeof this.options.onChange === "function") {
893
+ this.options.onChange({
894
+ focusPoint: this.getFocusPoint(),
895
+ cropZone: this.getCropZone(),
896
+ focusActive: this.state.focusActive,
897
+ cropActive: this.state.cropActive,
898
+ });
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Détruit l'instance et nettoie les ressources
904
+ * @public
905
+ */
906
+ destroy() {
907
+ // Supprimer les éléments DOM
908
+ if (this.state.focusMarker?.parentNode) {
909
+ this.state.focusMarker.parentNode.removeChild(this.state.focusMarker);
910
+ }
911
+
912
+ if (this.state.cropOverlay?.parentNode) {
913
+ this.state.cropOverlay.parentNode.removeChild(this.state.cropOverlay);
914
+ }
915
+
916
+ // Supprimer les écouteurs d'événements
917
+ window.removeEventListener("resize", this._updateScaling.bind(this));
918
+ document.removeEventListener("mouseup", this._handleMouseUp.bind(this));
919
+ document.removeEventListener("mousemove", this._handleMouseMove.bind(this));
920
+
921
+ // Réinitialiser l'état
922
+ this.state = null;
923
+ this.interaction = null;
924
+ this.options = null;
925
+ this.imageElement = null;
926
+ }
927
+ }
928
+
929
+ // Exporter la classe
930
+ export default VisualImageTool;