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