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