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