@bensitu/image-editor 1.2.1 → 1.2.2

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.
@@ -1,15 +1,10 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.2.1
4
+ * @version 1.2.2
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
8
- *
9
- * This source file is free software, available under the MIT license.
10
- * It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
11
- * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12
- * See the license files for details.
13
8
  */
14
9
 
15
10
  let fabric = null;
@@ -64,7 +59,7 @@ function ensureFabric() {
64
59
  * @param {number} [options.rotationStep=90] - Rotation step in degrees.
65
60
  * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image/mask.
66
61
  * @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
67
- * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover canvas (at least one side fits, allowing overflow).
62
+ * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
68
63
  * @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
69
64
  * @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
70
65
  * @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
@@ -176,9 +171,9 @@ function ensureFabric() {
176
171
 
177
172
  // Runtime state
178
173
  this.canvas = null;
179
- this.canvasEl = null;
180
- this.containerEl = null;
181
- this.placeholderEl = null;
174
+ this.canvasElement = null;
175
+ this.containerElement = null;
176
+ this.placeholderElement = null;
182
177
 
183
178
  this.originalImage = null; // fabric.Image
184
179
  this.baseImageScale = 1;
@@ -190,16 +185,20 @@ function ensureFabric() {
190
185
  this.isImageLoadedToCanvas = false;
191
186
  this.maxHistorySize = 50;
192
187
 
193
- this._boundHandlers = {};
188
+ this._handlersByElementKey = {};
194
189
 
195
190
  this._lastMask = null;
196
191
  this._lastMaskInitialLeft = null;
197
192
  this._lastMaskInitialTop = null;
198
193
  this._lastMaskInitialWidth = null;
194
+ this._lastSnapshot = null;
199
195
 
200
196
  this._cropMode = false;
201
197
  this._cropRect = null;
202
198
  this._cropHandlers = [];
199
+ this._cropPrevEvented = null;
200
+ this._prevSelectionSetting = undefined;
201
+ this._containerOriginalOverflow = undefined;
203
202
 
204
203
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
205
204
 
@@ -207,6 +206,39 @@ function ensureFabric() {
207
206
  this.historyManager = new HistoryManager(this.maxHistorySize);
208
207
  }
209
208
 
209
+ /**
210
+ * @deprecated Use canvasElement instead.
211
+ */
212
+ get canvasEl() {
213
+ return this.canvasElement;
214
+ }
215
+
216
+ set canvasEl(value) {
217
+ this.canvasElement = value;
218
+ }
219
+
220
+ /**
221
+ * @deprecated Use containerElement instead.
222
+ */
223
+ get containerEl() {
224
+ return this.containerElement;
225
+ }
226
+
227
+ set containerEl(value) {
228
+ this.containerElement = value;
229
+ }
230
+
231
+ /**
232
+ * @deprecated Use placeholderElement instead.
233
+ */
234
+ get placeholderEl() {
235
+ return this.placeholderElement;
236
+ }
237
+
238
+ set placeholderEl(value) {
239
+ this.placeholderElement = value;
240
+ }
241
+
210
242
  /**
211
243
  * Initializes the editor, binds to DOM elements, sets up event handlers,
212
244
  * and (optionally) loads an initial image.
@@ -299,48 +331,93 @@ function ensureFabric() {
299
331
  * @private
300
332
  */
301
333
  _initCanvas() {
302
- const canvasEl = document.getElementById(this.elements.canvas);
303
- if (!canvasEl) throw new Error('Canvas is not found: ' + this.elements.canvas);
304
- this.canvasEl = canvasEl;
334
+ const canvasElement = document.getElementById(this.elements.canvas);
335
+ if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
336
+ this.canvasElement = canvasElement;
305
337
 
306
338
  // Decide which element acts as "viewport" (for width/height fallback)
307
339
  if (this.elements.canvasContainer) {
308
- const ce = document.getElementById(this.elements.canvasContainer);
309
- this.containerEl = ce || canvasEl.parentElement;
340
+ const containerElement = document.getElementById(this.elements.canvasContainer);
341
+ this.containerElement = containerElement || canvasElement.parentElement;
310
342
  } else {
311
- this.containerEl = canvasEl.parentElement;
343
+ this.containerElement = canvasElement.parentElement;
312
344
  }
313
345
 
314
- this.placeholderEl = document.getElementById(this.elements.imgPlaceholder) || null;
346
+ this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
315
347
 
316
348
  // Initial size — take container size if available
317
- let initialW = this.options.canvasWidth;
318
- let initialH = this.options.canvasHeight;
319
- if (this.containerEl) {
320
- const cw = Math.floor(this.containerEl.clientWidth);
321
- const ch = Math.floor(this.containerEl.clientHeight);
322
- if (cw > 0 && ch > 0) { initialW = cw; initialH = ch; }
349
+ let initialWidth = this.options.canvasWidth;
350
+ let initialHeight = this.options.canvasHeight;
351
+ if (this.containerElement) {
352
+ const containerWidth = Math.floor(this.containerElement.clientWidth);
353
+ const containerHeight = Math.floor(this.containerElement.clientHeight);
354
+ if (containerWidth > 0 && containerHeight > 0) {
355
+ initialWidth = containerWidth;
356
+ initialHeight = containerHeight;
357
+ }
323
358
  }
324
359
 
325
- this.canvas = new fabric.Canvas(canvasEl, {
326
- width: initialW,
327
- height: initialH,
360
+ this.canvas = new fabric.Canvas(canvasElement, {
361
+ width: initialWidth,
362
+ height: initialHeight,
328
363
  backgroundColor: this.options.backgroundColor,
329
364
  selection: this.options.groupSelection,
330
365
  preserveObjectStacking: true
331
366
  });
332
367
 
333
368
  // Fabric event wiring
334
- this.canvas.on('selection:created', (e) => this._onSelectionChanged(e.selected));
335
- this.canvas.on('selection:updated', (e) => this._onSelectionChanged(e.selected));
336
- this.canvas.on('selection:cleared', () => this._onSelectionChanged([]));
337
- this.canvas.on('object:moving', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
338
- this.canvas.on('object:scaling', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
339
- this.canvas.on('object:rotating', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
340
- this.canvas.on('object:modified', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
369
+ this.canvas.on('selection:created', (event) => this._handleSelectionChanged(event.selected));
370
+ this.canvas.on('selection:updated', (event) => this._handleSelectionChanged(event.selected));
371
+ this.canvas.on('selection:cleared', () => this._handleSelectionChanged([]));
372
+ this.canvas.on('object:moving', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
373
+ this.canvas.on('object:scaling', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
374
+ this.canvas.on('object:rotating', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
375
+ this.canvas.on('object:modified', (event) => this._handleObjectModified(event.target));
341
376
 
342
377
  // Avoid inline-element whitespace artefacts
343
- this.canvasEl.style.display = 'block';
378
+ this.canvasElement.style.display = 'block';
379
+ }
380
+
381
+ _handleObjectModified(target) {
382
+ const masks = this._getModifiedMasks(target);
383
+ if (!masks.length) return;
384
+ masks.forEach(mask => {
385
+ if (typeof mask.setCoords === 'function') mask.setCoords();
386
+ this._syncMaskLabel(mask);
387
+ this._expandCanvasToFitObject(mask);
388
+ });
389
+ this.saveState();
390
+ }
391
+
392
+ _getModifiedMasks(target) {
393
+ if (!target) return [];
394
+ if (target.maskId) return [target];
395
+
396
+ const objects = typeof target.getObjects === 'function' ? target.getObjects() : [];
397
+
398
+ return Array.isArray(objects) ? objects.filter(object => object && object.maskId) : [];
399
+ }
400
+
401
+ _syncContainerOverflow() {
402
+ if (!this.containerElement || !this.containerElement.style) return;
403
+ if (this._containerOriginalOverflow === undefined) {
404
+ this._containerOriginalOverflow = this.containerElement.style.overflow || '';
405
+ }
406
+
407
+ if (this.options.coverImageToCanvas) {
408
+ const shouldResetScroll = !this.isImageLoadedToCanvas;
409
+ this.containerElement.style.overflow = 'scroll';
410
+ if (shouldResetScroll) {
411
+ this.containerElement.scrollLeft = 0;
412
+ this.containerElement.scrollTop = 0;
413
+ }
414
+ } else if (this.options.fitImageToCanvas) {
415
+ this.containerElement.style.overflow = 'auto';
416
+ this.containerElement.scrollLeft = 0;
417
+ this.containerElement.scrollTop = 0;
418
+ } else {
419
+ this.containerElement.style.overflow = this._containerOriginalOverflow;
420
+ }
344
421
  }
345
422
 
346
423
  /**
@@ -349,67 +426,72 @@ function ensureFabric() {
349
426
  */
350
427
  _bindEvents() {
351
428
  // Click anywhere on the upload area opens the native file dialog
352
- this._bindIfExists('uploadArea', 'click', () => document.getElementById(this.elements.imageInput)?.click());
429
+ this._bindIfExists('uploadArea', 'click', () => {
430
+ const uploadAreaElement = document.getElementById(this.elements.uploadArea);
431
+ if (this._isElementDisabled(uploadAreaElement)) return;
432
+ document.getElementById(this.elements.imageInput)?.click();
433
+ });
353
434
  // File-input change
354
- const inputEl = document.getElementById(this.elements.imageInput);
355
- if (inputEl) {
356
- inputEl.addEventListener('change', (e) => {
357
- const f = e.target.files && e.target.files[0];
358
- if (f) this._loadImageFile(f);
359
- });
360
- }
435
+ this._bindIfExists('imageInput', 'change', (event) => {
436
+ const file = event.target.files && event.target.files[0];
437
+ if (file) this._loadImageFile(file);
438
+ });
361
439
  // Zoom & reset
362
440
  this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
363
441
  this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
364
- this._bindIfExists('resetBtn', 'click', () => { this.reset(); });
442
+ this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
365
443
  // Mask management
366
- this._bindIfExists('addMaskBtn', 'click', () => this.addMask());
444
+ this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
367
445
  this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
368
446
  this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
369
447
  // Merge + download
370
- this._bindIfExists('mergeBtn', 'click', () => this.merge());
448
+ this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
371
449
  this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
372
450
  // Undo + Redo
373
451
  this._bindIfExists('undoBtn', 'click', () => this.undo());
374
452
  this._bindIfExists('redoBtn', 'click', () => this.redo());
375
453
 
376
454
  // Rotation buttons (step can be overridden by two input fields)
377
- const rotLeftBtn = document.getElementById(this.elements.rotateLeftBtn);
378
- const rotRightBtn = document.getElementById(this.elements.rotateRightBtn);
379
- if (rotLeftBtn) rotLeftBtn.addEventListener('click', () => {
380
- const el = document.getElementById(this.elements.rotationLeftInput);
455
+ this._bindIfExists('rotateLeftBtn', 'click', () => {
456
+ const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
381
457
  let step = this.options.rotationStep;
382
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
458
+ if (rotationInputElement) {
459
+ const parsedStep = parseFloat(rotationInputElement.value);
460
+ if (!isNaN(parsedStep)) step = parsedStep;
461
+ }
383
462
  this.rotateImage(this.currentRotation - step);
384
463
  });
385
- if (rotRightBtn) rotRightBtn.addEventListener('click', () => {
386
- const el = document.getElementById(this.elements.rotationRightInput);
464
+ this._bindIfExists('rotateRightBtn', 'click', () => {
465
+ const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
387
466
  let step = this.options.rotationStep;
388
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
467
+ if (rotationInputElement) {
468
+ const parsedStep = parseFloat(rotationInputElement.value);
469
+ if (!isNaN(parsedStep)) step = parsedStep;
470
+ }
389
471
  this.rotateImage(this.currentRotation + step);
390
472
  });
391
473
 
392
474
  // Crop bindings (optional: bound only if element IDs exist in elements)
393
475
  this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
394
- this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(e => this._reportError('applyCrop failed', e)); });
476
+ this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
395
477
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
396
478
  }
397
479
 
398
480
  /**
399
481
  * Event binding element check
400
482
  *
401
- * @param {*} event
483
+ * @param {*} eventName
402
484
  * @param {*} handler
403
485
  * @param {*} key
404
486
  * @private
405
487
  */
406
- _bindIfExists(key, event, handler) {
407
- const el = document.getElementById(this.elements[key]);
408
- if (el) {
409
- el.addEventListener(event, handler);
410
- this._boundHandlers = this._boundHandlers || {};
411
- if (!this._boundHandlers[key]) this._boundHandlers[key] = [];
412
- this._boundHandlers[key].push({ event, handler });
488
+ _bindIfExists(key, eventName, handler) {
489
+ const element = document.getElementById(this.elements[key]);
490
+ if (element) {
491
+ element.addEventListener(eventName, handler);
492
+ this._handlersByElementKey = this._handlersByElementKey || {};
493
+ if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
494
+ this._handlersByElementKey[key].push({ eventName, handler });
413
495
  }
414
496
  }
415
497
 
@@ -422,100 +504,99 @@ function ensureFabric() {
422
504
  _loadImageFile(file) {
423
505
  if (!file || !file.type.startsWith('image/')) return;
424
506
  const reader = new FileReader();
425
- reader.onload = (e) => this.loadImage(e.target.result);
426
- reader.onerror = (e) => { this._reportError('Image file could not be read', e); };
507
+ reader.onload = (event) => this.loadImage(event.target.result);
508
+ reader.onerror = (event) => { this._reportError('Image file could not be read', event); };
427
509
  reader.readAsDataURL(file);
428
510
  }
429
511
 
430
512
  /**
431
513
  * Load a base64 encoded image string into fabric.
432
514
  * @async
433
- * @param {String} base64
434
- */
435
- async loadImage(base64) {
515
+ * @param {String} imageBase64
516
+ */
517
+ async loadImage(imageBase64) {
436
518
  if (!this._fabricLoaded) return;
437
519
  if (!this.canvas) return;
438
- if (!base64 || typeof base64 !== 'string' || !base64.startsWith('data:image/')) return;
520
+ if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
439
521
 
440
522
  this._setPlaceholderVisible(false);
523
+ this._syncContainerOverflow();
441
524
 
442
- const imgEl = await this._createImageElement(base64);
525
+ const imageElement = await this._createImageElement(imageBase64);
443
526
 
444
- let loadSrc = base64;
527
+ let loadSource = imageBase64;
445
528
  if (this.options.downsampleOnLoad) {
446
- const needResize =
447
- imgEl.naturalWidth > this.options.downsampleMaxWidth ||
448
- imgEl.naturalHeight > this.options.downsampleMaxHeight;
449
- if (needResize) {
529
+ const shouldResize =
530
+ imageElement.naturalWidth > this.options.downsampleMaxWidth ||
531
+ imageElement.naturalHeight > this.options.downsampleMaxHeight;
532
+ if (shouldResize) {
450
533
  const ratio = Math.min(
451
- this.options.downsampleMaxWidth / imgEl.naturalWidth,
452
- this.options.downsampleMaxHeight / imgEl.naturalHeight
534
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
535
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
453
536
  );
454
- const tw = Math.round(imgEl.naturalWidth * ratio);
455
- const th = Math.round(imgEl.naturalHeight * ratio);
456
- loadSrc = this._resampleImageToDataURL(imgEl, tw, th, this.options.downsampleQuality);
537
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
538
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
539
+ loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
457
540
  }
458
541
  }
459
542
 
460
543
  // Create fabric.Image from URL
461
544
  return new Promise((resolve, reject) => {
462
- fabric.Image.fromURL(loadSrc, (fimg) => {
545
+ fabric.Image.fromURL(loadSource, (fabricImage) => {
463
546
  try {
464
- if (!fimg) throw new Error('Image could not be loaded');
547
+ if (!fabricImage) throw new Error('Image could not be loaded');
465
548
 
466
549
  this.canvas.discardActiveObject();
467
550
  this._hideAllMaskLabels();
468
551
  this.canvas.clear();
469
552
  this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
470
553
 
471
- fimg.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
554
+ fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
472
555
 
473
- const imgW = fimg.width;
474
- const imgH = fimg.height;
556
+ const imageWidth = fabricImage.width;
557
+ const imageHeight = fabricImage.height;
475
558
 
476
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || this.options.canvasWidth) : this.options.canvasWidth;
477
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || this.options.canvasHeight) : this.options.canvasHeight;
559
+ const viewport = this._getContainerViewportSize();
560
+ const minWidth = viewport.width;
561
+ const minHeight = viewport.height;
478
562
 
479
563
  if (this.options.fitImageToCanvas) {
480
564
  // Fit into current canvas (shrink only) and ensure canvas does not exceed container
481
- const cw = Math.max(1, Math.min(this.options.canvasWidth, minW) - 1)
482
- const ch = Math.max(1, Math.min(this.options.canvasHeight, minH) - 1);
483
- this._setCanvasSizeInt(cw, ch);
484
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
485
- fimg.set({ left: 0, top: 0 });
486
- fimg.scale(fitScale);
487
- this.baseImageScale = fimg.scaleX || 1;
565
+ const canvasWidth = Math.max(1, Math.min(this.options.canvasWidth, minWidth) - 1)
566
+ const canvasHeight = Math.max(1, Math.min(this.options.canvasHeight, minHeight) - 1);
567
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
568
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
569
+ fabricImage.set({ left: 0, top: 0 });
570
+ fabricImage.scale(fitScale);
571
+ this.baseImageScale = fabricImage.scaleX || 1;
488
572
  } else if (this.options.coverImageToCanvas) {
489
- // Cover canvas: scale to cover, allowing overflow (at least one side fits)
490
- const cw = Math.max(this.options.canvasWidth, minW);
491
- const ch = Math.max(this.options.canvasHeight, minH);
492
- this._setCanvasSizeInt(cw, ch);
493
- const coverScale = Math.min(1, Math.max(cw / imgW, ch / imgH));
494
- fimg.set({ left: 0, top: 0 });
495
- fimg.scale(coverScale);
496
- this.baseImageScale = fimg.scaleX || 1;
573
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
574
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
575
+ fabricImage.set({ left: 0, top: 0 });
576
+ fabricImage.scale(layout.scale);
577
+ this.baseImageScale = fabricImage.scaleX || 1;
497
578
  } else if (this.options.expandCanvasToImage) {
498
579
  // Expand canvas so that it fully contains the image
499
- const cw = Math.max(minW, Math.floor(imgW));
500
- const ch = Math.max(minH, Math.floor(imgH));
501
- this._setCanvasSizeInt(cw, ch);
502
- fimg.set({ left: 0, top: 0 });
503
- fimg.scale(1);
580
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
581
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
582
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
583
+ fabricImage.set({ left: 0, top: 0 });
584
+ fabricImage.scale(1);
504
585
  this.baseImageScale = 1;
505
586
  } else {
506
587
  // Keep existing canvas size and center the image
507
- const cw = Math.max(this.options.canvasWidth, minW);
508
- const ch = Math.max(this.options.canvasHeight, minH);
509
- this._setCanvasSizeInt(cw, ch);
510
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
511
- fimg.set({ left: 0, top: 0 });
512
- fimg.scale(fitScale);
513
- this.baseImageScale = fimg.scaleX || 1;
588
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
589
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
590
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
591
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
592
+ fabricImage.set({ left: 0, top: 0 });
593
+ fabricImage.scale(fitScale);
594
+ this.baseImageScale = fabricImage.scaleX || 1;
514
595
  }
515
596
  // Put the image onto the canvas
516
- this.originalImage = fimg;
517
- this.canvas.add(fimg);
518
- this.canvas.sendToBack(fimg);
597
+ this.originalImage = fabricImage;
598
+ this.canvas.add(fabricImage);
599
+ this.canvas.sendToBack(fabricImage);
519
600
 
520
601
  // Reset mask placement memory
521
602
  this._lastMask = null;
@@ -532,14 +613,19 @@ function ensureFabric() {
532
613
  this.isImageLoadedToCanvas = true;
533
614
  this._updateUI();
534
615
  this.canvas.renderAll();
616
+ try {
617
+ this._lastSnapshot = this._serializeCanvasState();
618
+ } catch (error) {
619
+ this._reportWarning('loadImage: failed to capture initial canvas snapshot', error);
620
+ }
535
621
 
536
622
  if (typeof this.onImageLoaded === 'function') {
537
623
  this.onImageLoaded();
538
624
  }
539
625
 
540
626
  resolve();
541
- } catch (err) {
542
- reject(err);
627
+ } catch (error) {
628
+ reject(error);
543
629
  }
544
630
  }, { crossOrigin: 'anonymous' });
545
631
  });
@@ -563,44 +649,44 @@ function ensureFabric() {
563
649
  /**
564
650
  * Creates an HTMLImageElement from a given data URL.
565
651
  *
566
- * @param {string} dataURL - A data URL representing the image (e.g., "data:image/png;base64,...").
652
+ * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
567
653
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
568
654
  * @private
569
655
  */
570
- _createImageElement(dataURL) {
571
- return new Promise((res, rej) => {
572
- const img = new Image();
573
- img.onload = () => {
574
- img.onload = null;
575
- img.onerror = null;
576
- res(img);
656
+ _createImageElement(dataUrl) {
657
+ return new Promise((resolve, reject) => {
658
+ const imageElement = new Image();
659
+ imageElement.onload = () => {
660
+ imageElement.onload = null;
661
+ imageElement.onerror = null;
662
+ resolve(imageElement);
577
663
  };
578
- img.onerror = (e) => {
579
- img.onload = null;
580
- img.onerror = null;
581
- rej(e);
664
+ imageElement.onerror = (error) => {
665
+ imageElement.onload = null;
666
+ imageElement.onerror = null;
667
+ reject(error);
582
668
  };
583
- img.src = dataURL;
669
+ imageElement.src = dataUrl;
584
670
  });
585
671
  }
586
672
 
587
673
  /**
588
674
  * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
589
675
  *
590
- * @param {HTMLImageElement} imgEl - The image element to resample.
591
- * @param {number} w - Target width (in pixels) for the resampled image.
592
- * @param {number} h - Target height (in pixels) for the resampled image.
676
+ * @param {HTMLImageElement} imageElement - The image element to resample.
677
+ * @param {number} targetWidth - Target width (in pixels) for the resampled image.
678
+ * @param {number} targetHeight - Target height (in pixels) for the resampled image.
593
679
  * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
594
680
  * @returns {string} A data URL representing the resampled image as JPEG.
595
681
  * @private
596
682
  */
597
- _resampleImageToDataURL(imgEl, w, h, quality = 0.92) {
598
- const oc = document.createElement('canvas');
599
- oc.width = w;
600
- oc.height = h;
601
- const ctx = oc.getContext('2d');
602
- ctx.drawImage(imgEl, 0, 0, imgEl.naturalWidth, imgEl.naturalHeight, 0, 0, w, h);
603
- return oc.toDataURL('image/jpeg', quality);
683
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
684
+ const offscreenCanvas = document.createElement('canvas');
685
+ offscreenCanvas.width = targetWidth;
686
+ offscreenCanvas.height = targetHeight;
687
+ const context = offscreenCanvas.getContext('2d');
688
+ context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
689
+ return offscreenCanvas.toDataURL('image/jpeg', quality);
604
690
  }
605
691
 
606
692
  /**
@@ -619,60 +705,401 @@ function ensureFabric() {
619
705
  this.canvas.setHeight(ih);
620
706
  if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
621
707
  // Keep DOM element in sync (avoid fractional CSS pixels)
622
- if (this.canvasEl) {
623
- this.canvasEl.style.width = iw + 'px';
624
- this.canvasEl.style.height = ih + 'px';
625
- this.canvasEl.style.maxWidth = 'none';
708
+ if (this.canvasElement) {
709
+ this.canvasElement.style.width = iw + 'px';
710
+ this.canvasElement.style.height = ih + 'px';
711
+ this.canvasElement.style.maxWidth = 'none';
626
712
  }
627
713
  }
628
714
 
715
+ _ceilCanvasDimension(value) {
716
+ const numericValue = Number(value) || 0;
717
+ const roundedValue = Math.round(numericValue);
718
+ if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
719
+ return Math.ceil(numericValue);
720
+ }
721
+
722
+ _getContainerViewportSize() {
723
+ if (!this.containerElement) {
724
+ return {
725
+ width: Math.max(1, Math.floor(this.options.canvasWidth || 1)),
726
+ height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
727
+ };
728
+ }
729
+
730
+ if (this._hasFixedContainerScrollbars()) {
731
+ return {
732
+ width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
733
+ height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
734
+ };
735
+ }
736
+
737
+ const previousOverflow = this.containerElement.style.overflow;
738
+ this.containerElement.style.overflow = 'hidden';
739
+
740
+ const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
741
+ const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
742
+
743
+ this.containerElement.style.overflow = previousOverflow;
744
+ return { width, height };
745
+ }
746
+
747
+ _hasFixedContainerScrollbars() {
748
+ if (!this.containerElement) return false;
749
+ const inlineOverflow = this.containerElement.style.overflow;
750
+ const inlineOverflowX = this.containerElement.style.overflowX;
751
+ const inlineOverflowY = this.containerElement.style.overflowY;
752
+ let computedOverflow = '';
753
+ let computedOverflowX = '';
754
+ let computedOverflowY = '';
755
+
756
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
757
+ const style = window.getComputedStyle(this.containerElement);
758
+ computedOverflow = style.overflow;
759
+ computedOverflowX = style.overflowX;
760
+ computedOverflowY = style.overflowY;
761
+ }
762
+
763
+ return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY]
764
+ .some(value => value === 'scroll');
765
+ }
766
+
767
+ _getScrollbarSize() {
768
+ if (typeof document === 'undefined' || !document.createElement || !document.body) {
769
+ return { width: 0, height: 0 };
770
+ }
771
+
772
+ const probe = document.createElement('div');
773
+ probe.style.position = 'absolute';
774
+ probe.style.visibility = 'hidden';
775
+ probe.style.overflow = 'scroll';
776
+ probe.style.width = '100px';
777
+ probe.style.height = '100px';
778
+ probe.style.top = '-9999px';
779
+ document.body.appendChild(probe);
780
+
781
+ const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
782
+ const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
783
+ document.body.removeChild(probe);
784
+
785
+ return { width, height };
786
+ }
787
+
788
+ _getScrollSafetyMargin() {
789
+ return 2;
790
+ }
791
+
792
+ _getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
793
+ if (this._hasFixedContainerScrollbars()) {
794
+ const safetyMargin = this._getScrollSafetyMargin();
795
+ const safeWidth = Math.max(1, viewport.width - safetyMargin);
796
+ const safeHeight = Math.max(1, viewport.height - safetyMargin);
797
+ return {
798
+ width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
799
+ height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
800
+ viewportWidth: viewport.width,
801
+ viewportHeight: viewport.height,
802
+ hasHorizontal: true,
803
+ hasVertical: true
804
+ };
805
+ }
806
+
807
+ const scrollbar = this._getScrollbarSize();
808
+ let hasVertical = false;
809
+ let hasHorizontal = false;
810
+ let effectiveWidth = viewport.width;
811
+ let effectiveHeight = viewport.height;
812
+
813
+ for (let i = 0; i < 4; i += 1) {
814
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
815
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
816
+
817
+ const nextHasVertical = contentHeight > effectiveHeight + 0.5;
818
+ const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
819
+
820
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
821
+ hasVertical = nextHasVertical;
822
+ hasHorizontal = nextHasHorizontal;
823
+ }
824
+
825
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
826
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
827
+
828
+ return {
829
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
830
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
831
+ viewportWidth: effectiveWidth,
832
+ viewportHeight: effectiveHeight,
833
+ hasHorizontal,
834
+ hasVertical
835
+ };
836
+ }
837
+
838
+ _calculateCoverCanvasLayout(imageWidth, imageHeight) {
839
+ const viewport = this._getContainerViewportSize();
840
+
841
+ if (this._hasFixedContainerScrollbars()) {
842
+ const safetyMargin = this._getScrollSafetyMargin();
843
+ const targetWidth = Math.max(1, viewport.width - safetyMargin);
844
+ const targetHeight = Math.max(1, viewport.height - safetyMargin);
845
+ const scale = Math.min(1, Math.max(targetWidth / imageWidth, targetHeight / imageHeight));
846
+ const contentWidth = imageWidth * scale;
847
+ const contentHeight = imageHeight * scale;
848
+ const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
849
+ return {
850
+ scale,
851
+ canvasWidth: canvasSize.width,
852
+ canvasHeight: canvasSize.height
853
+ };
854
+ }
855
+
856
+ const scrollbar = this._getScrollbarSize();
857
+ let hasVertical = false;
858
+ let hasHorizontal = false;
859
+ let scale = 1;
860
+ let contentWidth = imageWidth;
861
+ let contentHeight = imageHeight;
862
+ let effectiveWidth = viewport.width;
863
+ let effectiveHeight = viewport.height;
864
+
865
+ for (let i = 0; i < 4; i += 1) {
866
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
867
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
868
+ scale = Math.min(1, Math.max(effectiveWidth / imageWidth, effectiveHeight / imageHeight));
869
+ contentWidth = imageWidth * scale;
870
+ contentHeight = imageHeight * scale;
871
+
872
+ const nextHasVertical = contentHeight > effectiveHeight + 0.5;
873
+ const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
874
+
875
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
876
+ hasVertical = nextHasVertical;
877
+ hasHorizontal = nextHasHorizontal;
878
+ }
879
+
880
+ const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
881
+ return {
882
+ scale,
883
+ canvasWidth: canvasSize.width,
884
+ canvasHeight: canvasSize.height
885
+ };
886
+ }
887
+
888
+ _getStateProperties() {
889
+ return [
890
+ 'maskId',
891
+ 'maskName',
892
+ 'maskLabel',
893
+ 'isCropRect',
894
+ 'originalAlpha',
895
+ 'originalStroke',
896
+ 'originalStrokeWidth',
897
+ 'selectable',
898
+ 'evented',
899
+ 'hasControls',
900
+ 'lockRotation',
901
+ 'borderColor',
902
+ 'cornerColor',
903
+ 'cornerSize',
904
+ 'transparentCorners',
905
+ 'strokeUniform',
906
+ 'strokeDashArray'
907
+ ];
908
+ }
909
+
910
+ _getMaskNormalStyle(mask) {
911
+ const strokeWidth = Number(mask && mask.originalStrokeWidth);
912
+ const opacity = Number(mask && mask.originalAlpha);
913
+ const style = {
914
+ stroke: (mask && mask.originalStroke) || '#ccc',
915
+ strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
916
+ };
917
+ if (Number.isFinite(opacity)) style.opacity = opacity;
918
+ return style;
919
+ }
920
+
921
+ _withNormalizedMaskStyles(callback) {
922
+ if (!this.canvas) return callback();
923
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
924
+ const maskStyleBackups = masks.map(mask => ({
925
+ object: mask,
926
+ stroke: mask.stroke,
927
+ strokeWidth: mask.strokeWidth,
928
+ opacity: mask.opacity
929
+ }));
930
+
931
+ try {
932
+ masks.forEach(mask => {
933
+ mask.set(this._getMaskNormalStyle(mask));
934
+ });
935
+ return callback();
936
+ } finally {
937
+ maskStyleBackups.forEach(backup => {
938
+ try {
939
+ backup.object.set({
940
+ stroke: backup.stroke,
941
+ strokeWidth: backup.strokeWidth,
942
+ opacity: backup.opacity
943
+ });
944
+ } catch (error) { void error; }
945
+ });
946
+ }
947
+ }
948
+
949
+ _restoreMaskControls(mask) {
950
+ if (!mask) return;
951
+
952
+ const cornerSize = Number(mask.cornerSize);
953
+ mask.set({
954
+ selectable: mask.selectable !== false,
955
+ evented: mask.evented !== false,
956
+ hasControls: mask.hasControls !== false,
957
+ lockRotation: typeof mask.lockRotation === 'boolean' ? mask.lockRotation : !this.options.maskRotatable,
958
+ borderColor: mask.borderColor || 'red',
959
+ cornerColor: mask.cornerColor || 'black',
960
+ cornerSize: Number.isFinite(cornerSize) ? cornerSize : 8,
961
+ transparentCorners: mask.transparentCorners === true,
962
+ strokeUniform: mask.strokeUniform !== false
963
+ });
964
+ if (typeof mask.setCoords === 'function') mask.setCoords();
965
+ }
966
+
967
+ _serializeCanvasState() {
968
+ if (!this.canvas) return null;
969
+ return this._withNormalizedMaskStyles(() => {
970
+ const jsonObject = this.canvas.toJSON(this._getStateProperties());
971
+ if (Array.isArray(jsonObject.objects)) {
972
+ jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
973
+ }
974
+ return JSON.stringify(jsonObject);
975
+ });
976
+ }
977
+
978
+ _normalizeQuality(quality) {
979
+ const numericQuality = Number(quality);
980
+ if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
981
+ return Math.max(0, Math.min(1, numericQuality));
982
+ }
983
+
984
+ _normalizeImageFormat(format) {
985
+ const typeMapping = {
986
+ 'jpeg': 'jpeg',
987
+ 'jpg': 'jpeg',
988
+ 'image/jpeg': 'jpeg',
989
+ 'png': 'png',
990
+ 'image/png': 'png',
991
+ 'webp': 'webp',
992
+ 'image/webp': 'webp'
993
+ };
994
+ return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
995
+ }
996
+
997
+ _getClampedCanvasRegion(bounds, options = {}) {
998
+ const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
999
+ const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
1000
+ const left = Number(bounds.left) || 0;
1001
+ const top = Number(bounds.top) || 0;
1002
+ const width = Math.max(0, Number(bounds.width) || 0);
1003
+ const height = Math.max(0, Number(bounds.height) || 0);
1004
+ const includePartialPixels = options.includePartialPixels !== false;
1005
+ const roundEnd = includePartialPixels ? Math.ceil : Math.floor;
1006
+ const sourceX = Math.min(canvasWidth - 1, Math.max(0, Math.floor(left)));
1007
+ const sourceY = Math.min(canvasHeight - 1, Math.max(0, Math.floor(top)));
1008
+ const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
1009
+ const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
1010
+
1011
+ return {
1012
+ sx: sourceX,
1013
+ sy: sourceY,
1014
+ sw: Math.max(1, endX - sourceX),
1015
+ sh: Math.max(1, endY - sourceY)
1016
+ };
1017
+ }
1018
+
1019
+ async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1020
+ return new Promise((resolve, reject) => {
1021
+ const imageElement = new Image();
1022
+ imageElement.onload = () => {
1023
+ try {
1024
+ const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1025
+ const scaledSourceX = Math.round(sourceX * safeMultiplier);
1026
+ const scaledSourceY = Math.round(sourceY * safeMultiplier);
1027
+ const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1028
+ const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1029
+ const offscreenCanvas = document.createElement('canvas');
1030
+ offscreenCanvas.width = scaledSourceWidth;
1031
+ offscreenCanvas.height = scaledSourceHeight;
1032
+ const context = offscreenCanvas.getContext('2d');
1033
+
1034
+ context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1035
+ resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
1036
+ } catch (error) {
1037
+ reject(error);
1038
+ }
1039
+ };
1040
+ imageElement.onerror = reject;
1041
+ imageElement.src = dataUrl;
1042
+ });
1043
+ }
1044
+
1045
+ async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1046
+ const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1047
+ const fullDataUrl = this.canvas.toDataURL({
1048
+ format,
1049
+ quality,
1050
+ multiplier: safeMultiplier
1051
+ });
1052
+
1053
+ return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
1054
+ }
1055
+
629
1056
  /**
630
1057
  * Gets the top-left corner coordinates of the given object.
631
1058
  * Used for geometry calculations (e.g., scale, rotate).
632
1059
  *
633
- * @param {Object} obj - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
1060
+ * @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
634
1061
  * @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
635
1062
  * @private
636
1063
  */
637
- _getObjectTopLeftPoint(obj) {
638
- if (!obj) return { x: 0, y: 0 };
639
- obj.setCoords();
640
- const coords = typeof obj.getCoords === 'function' ? obj.getCoords() : null;
1064
+ _getObjectTopLeftPoint(fabricObject) {
1065
+ if (!fabricObject) return { x: 0, y: 0 };
1066
+ fabricObject.setCoords();
1067
+ const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
641
1068
  if (coords && coords.length) return coords[0];
642
- const br = obj.getBoundingRect(true, true);
643
- return { x: br.left, y: br.top };
1069
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1070
+ return { x: boundingRect.left, y: boundingRect.top };
644
1071
  }
645
1072
 
646
1073
  /**
647
1074
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
648
1075
  *
649
- * @param {Object} obj - The object to modify. Should support set, setPositionByOrigin, and setCoords.
1076
+ * @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
650
1077
  * @param {string} originX - The new originX ("left", "center", "right", etc.).
651
1078
  * @param {string} originY - The new originY ("top", "center", "bottom", etc.).
652
1079
  * @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
653
1080
  * @private
654
1081
  */
655
- _setObjectOriginKeepingPosition(obj, originX, originY, refPoint) {
656
- if (!obj || !refPoint || !obj.setPositionByOrigin) return;
657
- obj.set({ originX, originY });
658
- obj.setPositionByOrigin(refPoint, originX, originY);
659
- obj.setCoords();
1082
+ _setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
1083
+ if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
1084
+ fabricObject.set({ originX, originY });
1085
+ fabricObject.setPositionByOrigin(refPoint, originX, originY);
1086
+ fabricObject.setCoords();
660
1087
  }
661
1088
 
662
1089
  /**
663
1090
  * Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
664
1091
  *
665
- * @param {Object} obj - The object to align.
1092
+ * @param {Object} fabricObject - The object to align.
666
1093
  * @private
667
1094
  */
668
- _alignObjectBoundingBoxToCanvasTopLeft(obj) {
669
- if (!obj) return;
670
- obj.setCoords();
671
- const br = obj.getBoundingRect(true, true);
672
- const dx = br.left;
673
- const dy = br.top;
674
- obj.set({ left: (obj.left || 0) - dx, top: (obj.top || 0) - dy });
675
- obj.setCoords();
1095
+ _alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
1096
+ if (!fabricObject) return;
1097
+ fabricObject.setCoords();
1098
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1099
+ const deltaX = boundingRect.left;
1100
+ const deltaY = boundingRect.top;
1101
+ fabricObject.set({ left: (fabricObject.left || 0) - deltaX, top: (fabricObject.top || 0) - deltaY });
1102
+ fabricObject.setCoords();
676
1103
  this.canvas.renderAll();
677
1104
  }
678
1105
 
@@ -684,22 +1111,27 @@ function ensureFabric() {
684
1111
  _updateCanvasSizeToImageBounds() {
685
1112
  if (!this.originalImage) return;
686
1113
  this.originalImage.setCoords();
687
- const br = this.originalImage.getBoundingRect(true, true);
1114
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
688
1115
 
689
- // Container integer sizes
690
- const containerW = this.containerEl ? Math.ceil(this.containerEl.clientWidth || 0) : 0;
691
- const containerH = this.containerEl ? Math.ceil(this.containerEl.clientHeight || 0) : 0;
1116
+ const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
1117
+ this._setCanvasSizeInt(size.width, size.height);
1118
+ }
692
1119
 
693
- // If image smaller or equal than container in BOTH dims => keep canvas equal to container
694
- if (containerW > 0 && containerH > 0 && br.width <= containerW && br.height <= containerH) {
695
- this._setCanvasSizeInt(containerW, containerH);
696
- return;
1120
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1121
+ if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage) return;
1122
+ try {
1123
+ fabricObject.setCoords();
1124
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1125
+ const requiredWidth = Math.ceil(boundingRect.left + boundingRect.width + padding);
1126
+ const requiredHeight = Math.ceil(boundingRect.top + boundingRect.height + padding);
1127
+ const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1128
+ const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1129
+ const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1130
+ const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1131
+ this._setCanvasSizeInt(newWidth, newHeight);
1132
+ } catch (error) {
1133
+ this._reportWarning('expandCanvasToFitObject: failed to expand canvas', error);
697
1134
  }
698
-
699
- // Else canvas follows image bounding box but not smaller than container dims individually
700
- const newW = Math.max(containerW || 0, Math.floor(br.width));
701
- const newH = Math.max(containerH || 0, Math.floor(br.height));
702
- this._setCanvasSizeInt(newW, newH);
703
1135
  }
704
1136
 
705
1137
  /**
@@ -709,8 +1141,8 @@ function ensureFabric() {
709
1141
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
710
1142
  * @public
711
1143
  */
712
- scaleImage(factor) {
713
- return this.animQueue.add(() => this._scaleImageImpl(factor));
1144
+ scaleImage(factor, options = {}) {
1145
+ return this.animQueue.add(() => this._scaleImageImpl(factor, options));
714
1146
  }
715
1147
 
716
1148
  /**
@@ -720,50 +1152,53 @@ function ensureFabric() {
720
1152
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
721
1153
  * @private
722
1154
  */
723
- _scaleImageImpl(factor) {
1155
+ _scaleImageImpl(factor, options = {}) {
724
1156
  if (!this.originalImage) return Promise.resolve();
725
1157
  if (this.isAnimating) return Promise.resolve();
1158
+ const saveHistory = options.saveHistory !== false;
726
1159
  factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
727
1160
  this.currentScale = factor;
728
1161
  this.isAnimating = true;
729
1162
  this._updateUI();
730
1163
 
731
- const targetAbs = this.baseImageScale * factor;
1164
+ const targetScale = this.baseImageScale * factor;
732
1165
 
733
1166
  // Scale around current top-left (recompute)
734
1167
  const topLeft = this._getObjectTopLeftPoint(this.originalImage);
735
1168
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
736
1169
 
737
- const p1 = new Promise((res) => {
738
- this.originalImage.animate('scaleX', targetAbs, {
1170
+ const scaleXAnimation = new Promise((resolve) => {
1171
+ this.originalImage.animate('scaleX', targetScale, {
739
1172
  duration: this.options.animationDuration,
740
1173
  onChange: this.canvas.renderAll.bind(this.canvas),
741
- onComplete: res
1174
+ onComplete: resolve
742
1175
  });
743
1176
  });
744
- const p2 = new Promise((res) => {
745
- this.originalImage.animate('scaleY', targetAbs, {
1177
+ const scaleYAnimation = new Promise((resolve) => {
1178
+ this.originalImage.animate('scaleY', targetScale, {
746
1179
  duration: this.options.animationDuration,
747
1180
  onChange: this.canvas.renderAll.bind(this.canvas),
748
- onComplete: res
1181
+ onComplete: resolve
749
1182
  });
750
1183
  });
751
1184
 
752
- return Promise.all([p1, p2]).then(() => {
753
- this.originalImage.set({ scaleX: targetAbs, scaleY: targetAbs });
1185
+ return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1186
+ this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
754
1187
  this.originalImage.setCoords();
755
1188
 
756
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1189
+ if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1190
+ this._updateCanvasSizeToImageBounds();
1191
+ }
757
1192
 
758
1193
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
759
1194
 
760
1195
  // Sync mask labels
761
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1196
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
762
1197
 
763
1198
  this.isAnimating = false;
764
1199
  this._updateInputs();
765
1200
  this._updateUI();
766
- this.saveState();
1201
+ if (saveHistory) this.saveState();
767
1202
  }).catch(() => {
768
1203
  this.isAnimating = false;
769
1204
  this._updateUI();
@@ -777,8 +1212,8 @@ function ensureFabric() {
777
1212
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
778
1213
  * @public
779
1214
  */
780
- rotateImage(deg) {
781
- return this.animQueue.add(() => this._rotateImageImpl(deg));
1215
+ rotateImage(degrees, options = {}) {
1216
+ return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
782
1217
  }
783
1218
 
784
1219
  /**
@@ -788,10 +1223,11 @@ function ensureFabric() {
788
1223
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
789
1224
  * @private
790
1225
  */
791
- _rotateImageImpl(degrees) {
1226
+ _rotateImageImpl(degrees, options = {}) {
792
1227
  if (!this.originalImage) return Promise.resolve();
793
1228
  if (this.isAnimating) return Promise.resolve();
794
1229
  if (isNaN(degrees)) return Promise.resolve();
1230
+ const saveHistory = options.saveHistory !== false;
795
1231
  this.currentRotation = degrees;
796
1232
  this.isAnimating = true;
797
1233
  this._updateUI();
@@ -799,19 +1235,21 @@ function ensureFabric() {
799
1235
  const center = this.originalImage.getCenterPoint();
800
1236
  this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
801
1237
 
802
- const p = new Promise((res) => {
1238
+ const rotationAnimation = new Promise((resolve) => {
803
1239
  this.originalImage.animate('angle', degrees, {
804
1240
  duration: this.options.animationDuration,
805
1241
  onChange: this.canvas.renderAll.bind(this.canvas),
806
- onComplete: res
1242
+ onComplete: resolve
807
1243
  });
808
1244
  });
809
1245
 
810
- return p.then(() => {
1246
+ return rotationAnimation.then(() => {
811
1247
  this.originalImage.set('angle', degrees);
812
1248
  this.originalImage.setCoords();
813
1249
 
814
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1250
+ if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1251
+ this._updateCanvasSizeToImageBounds();
1252
+ }
815
1253
 
816
1254
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
817
1255
 
@@ -819,12 +1257,12 @@ function ensureFabric() {
819
1257
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
820
1258
 
821
1259
  // Sync mask labels
822
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1260
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
823
1261
 
824
1262
  this.isAnimating = false;
825
1263
  this._updateInputs();
826
1264
  this._updateUI();
827
- this.saveState();
1265
+ if (saveHistory) this.saveState();
828
1266
  }).catch(() => {
829
1267
  this.isAnimating = false;
830
1268
  this._updateUI();
@@ -832,20 +1270,28 @@ function ensureFabric() {
832
1270
  }
833
1271
 
834
1272
  /**
835
- * Resets the image: scales to 1 and rotates to 0 degrees.
1273
+ * Resets the image transform: scales to 1 and rotates to 0 degrees.
836
1274
  * @returns {Promise<void>} Promise that resolves when reset is complete.
837
1275
  */
838
- reset() {
1276
+ resetImageTransform() {
839
1277
  if (!this.originalImage) return Promise.resolve();
840
1278
 
841
- return this.scaleImage(1)
842
- .then(() => this.rotateImage(0))
843
- .then(() => {
844
- this.saveState();
845
- })
846
- .catch(err => {
847
- this._reportError('reset() failed', err);
848
- });
1279
+ return this.animQueue.add(async () => {
1280
+ const before = this._serializeCanvasState();
1281
+ await this._scaleImageImpl(1, { saveHistory: false });
1282
+ await this._rotateImageImpl(0, { saveHistory: false });
1283
+ const after = this._serializeCanvasState();
1284
+ this._pushStateTransition(before, after);
1285
+ }).catch(err => {
1286
+ this._reportError('resetImageTransform() failed', err);
1287
+ });
1288
+ }
1289
+
1290
+ /**
1291
+ * @deprecated Use resetImageTransform() instead.
1292
+ */
1293
+ reset() {
1294
+ return this.resetImageTransform();
849
1295
  }
850
1296
 
851
1297
  /**
@@ -853,47 +1299,66 @@ function ensureFabric() {
853
1299
  * @param {string} jsonString - the JSON string returned by fabric.toJSON().
854
1300
  */
855
1301
  loadFromState(jsonString) {
856
- if (!jsonString || !this.canvas) return;
857
-
858
- try {
859
- const json = (typeof jsonString === 'string')
860
- ? JSON.parse(jsonString)
861
- : jsonString;
862
-
863
- this.canvas.loadFromJSON(json, () => {
864
- try {
865
- this._hideAllMaskLabels();
866
- const objs = this.canvas.getObjects();
867
- this.originalImage = objs.find(o => o.type === 'image' && !o.maskId) || null;
1302
+ if (!jsonString || !this.canvas) return Promise.resolve();
868
1303
 
869
- if (this.originalImage) {
870
- this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
871
- this.canvas.sendToBack(this.originalImage);
872
- }
1304
+ return new Promise((resolve) => {
1305
+ try {
1306
+ const json = (typeof jsonString === 'string')
1307
+ ? JSON.parse(jsonString)
1308
+ : jsonString;
873
1309
 
874
- const masks = objs.filter(o => o.maskId);
875
- this.maskCounter = masks.reduce((max, m) =>
876
- Math.max(max, m.maskId), 0);
877
- this._lastMask = masks.length ? masks[masks.length - 1] : null;
878
- if (!this._lastMask) {
879
- this._lastMaskInitialLeft = null;
880
- this._lastMaskInitialTop = null;
881
- this._lastMaskInitialWidth = null;
1310
+ this.canvas.loadFromJSON(json, () => {
1311
+ try {
1312
+ this._hideAllMaskLabels();
1313
+ const canvasObjects = this.canvas.getObjects();
1314
+ this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
1315
+
1316
+ if (this.originalImage) {
1317
+ this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
1318
+ this.canvas.sendToBack(this.originalImage);
1319
+ this.currentRotation = Number(this.originalImage.angle) || 0;
1320
+ const baseScale = Number(this.baseImageScale) || 1;
1321
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1322
+ this.currentScale = imageScale / baseScale;
1323
+ } else {
1324
+ this.currentScale = 1;
1325
+ this.currentRotation = 0;
1326
+ }
1327
+
1328
+ const masks = canvasObjects.filter(object => object.maskId);
1329
+ masks.forEach(mask => {
1330
+ this._restoreMaskControls(mask);
1331
+ this._rebindMaskEvents(mask);
1332
+ mask.set(this._getMaskNormalStyle(mask));
1333
+ });
1334
+ this.maskCounter = masks.reduce((max, mask) =>
1335
+ Math.max(max, mask.maskId), 0);
1336
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
1337
+ if (!this._lastMask) {
1338
+ this._lastMaskInitialLeft = null;
1339
+ this._lastMaskInitialTop = null;
1340
+ this._lastMaskInitialWidth = null;
1341
+ }
1342
+ this.isImageLoadedToCanvas = !!this.originalImage;
1343
+
1344
+ this.canvas.renderAll();
1345
+ this._updateInputs();
1346
+ this._updateMaskList();
1347
+ this._updatePlaceholderStatus();
1348
+ this._lastSnapshot = this._serializeCanvasState();
1349
+ this._updateUI();
1350
+ } catch (callbackError) {
1351
+ this._reportError('loadFromState() failed', callbackError);
1352
+ } finally {
1353
+ resolve();
882
1354
  }
883
- this.isImageLoadedToCanvas = !!this.originalImage;
884
-
885
- this.canvas.renderAll();
886
- this._updateMaskList();
887
- this._updatePlaceholderStatus();
888
- this._updateUI();
889
- } catch (callbackError) {
890
- this._reportError('loadFromState() failed', callbackError);
891
- }
892
- });
1355
+ });
893
1356
 
894
- } catch (e) {
895
- this._reportError('loadFromState() failed', e);
896
- }
1357
+ } catch (error) {
1358
+ this._reportError('loadFromState() failed', error);
1359
+ resolve();
1360
+ }
1361
+ });
897
1362
  }
898
1363
 
899
1364
  /**
@@ -901,59 +1366,116 @@ function ensureFabric() {
901
1366
  */
902
1367
  saveState() {
903
1368
  if (!this.canvas) return;
904
- const activeObj = this.canvas.getActiveObject();
1369
+ const activeObject = this.canvas.getActiveObject();
905
1370
  this._hideAllMaskLabels();
906
1371
 
907
1372
  try {
908
- // request JSON including the custom flag 'isCropRect' so we can filter it out
909
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
910
- if (Array.isArray(jsonObj.objects)) {
911
- // filter out crop-rect objects before stringifying
912
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
913
- }
914
- const after = JSON.stringify(jsonObj);
1373
+ const after = this._serializeCanvasState();
915
1374
  const before = this._lastSnapshot || after;
1375
+ if (after === before) return;
916
1376
  let executedOnce = false;
917
1377
 
918
- const cmd = new Command(
1378
+ const command = new Command(
919
1379
  () => {
920
1380
  if (executedOnce) {
921
- this.loadFromState(after);
1381
+ return this.loadFromState(after);
922
1382
  }
923
1383
  executedOnce = true;
1384
+ return undefined;
924
1385
  },
925
- () => {
926
- this.loadFromState(before);
927
- }
1386
+ () => this.loadFromState(before)
928
1387
  );
929
1388
 
930
- this.historyManager.execute(cmd);
1389
+ this.historyManager.execute(command);
931
1390
  this._lastSnapshot = after;
932
- if (activeObj && activeObj.maskId) {
933
- this._showLabelForMask(activeObj);
1391
+ } catch (error) {
1392
+ this._reportWarning('saveState: failed to save canvas snapshot', error);
1393
+ } finally {
1394
+ if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
1395
+ this._handleSelectionChanged([activeObject]);
934
1396
  }
935
1397
  this._updateUI();
936
- } catch (err) {
937
- this._reportWarning('saveState: failed to save canvas snapshot', err);
938
1398
  }
939
1399
  }
940
1400
 
1401
+ _pushStateTransition(before, after) {
1402
+ if (!before || !after) return;
1403
+ if (before === after) return;
1404
+ if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1405
+
1406
+ const command = new Command(
1407
+ () => this.loadFromState(after),
1408
+ () => this.loadFromState(before)
1409
+ );
1410
+ this.historyManager.push(command);
1411
+ this._lastSnapshot = after;
1412
+ this._updateUI();
1413
+ }
1414
+
941
1415
  /**
942
1416
  * Undo the last state change, if possible.
943
1417
  */
944
1418
  undo() {
945
- this.historyManager.undo();
1419
+ return this.historyManager.undo()
1420
+ .then(() => { this._updateUI(); })
1421
+ .catch(error => { this._reportError('undo failed', error); });
946
1422
  }
947
1423
 
948
1424
  /**
949
1425
  * Redo the next state change, if possible.
950
1426
  */
951
1427
  redo() {
952
- this.historyManager.redo();
1428
+ return this.historyManager.redo()
1429
+ .then(() => { this._updateUI(); })
1430
+ .catch(error => { this._reportError('redo failed', error); });
1431
+ }
1432
+
1433
+ _rebindMaskEvents(mask) {
1434
+ if (!mask) return;
1435
+ if (mask.__imageEditorMaskHandlers) {
1436
+ try {
1437
+ mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
1438
+ mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
1439
+ } catch (e) { void e; }
1440
+ }
1441
+
1442
+ const metadata = {};
1443
+ if (!Number.isFinite(Number(mask.originalAlpha))) {
1444
+ metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
1445
+ }
1446
+ if (!mask.originalStroke) metadata.originalStroke = mask.stroke || '#ccc';
1447
+ if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
1448
+ metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1449
+ }
1450
+ if (Object.keys(metadata).length) mask.set(metadata);
1451
+
1452
+ const normalStyle = {
1453
+ stroke: mask.originalStroke || '#ccc',
1454
+ strokeWidth: mask.originalStrokeWidth,
1455
+ opacity: mask.originalAlpha
1456
+ };
1457
+ const hoverStyle = {
1458
+ stroke: '#ff5500',
1459
+ strokeWidth: 2,
1460
+ opacity: Math.min(mask.originalAlpha + 0.2, 1)
1461
+ };
1462
+
1463
+ const mouseover = () => {
1464
+ mask.set(hoverStyle);
1465
+ if (mask.canvas) mask.canvas.requestRenderAll();
1466
+ };
1467
+ const mouseout = () => {
1468
+ mask.set(normalStyle);
1469
+ if (mask.canvas) mask.canvas.requestRenderAll();
1470
+ };
1471
+
1472
+ mask.on('mouseover', mouseover);
1473
+ mask.on('mouseout', mouseout);
1474
+ mask.__imageEditorMaskHandlers = { mouseover, mouseout };
953
1475
  }
954
1476
 
955
1477
  /**
956
- * Adds a rectangular mask to the canvas.
1478
+ * Creates a mask and adds it to the canvas.
957
1479
  * Mask placement and properties are determined by the provided config and instance options.
958
1480
  * Canvas and list UI are updated accordingly.
959
1481
  * @param {Object} [config={}] - Optional mask configuration overrides:
@@ -967,15 +1489,15 @@ function ensureFabric() {
967
1489
  * @param {boolean} [config.selectable=true]
968
1490
  * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
969
1491
  * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
970
- * @param {function} [config.fabricGenerator] - (cfg) => new FabricObj
1492
+ * @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
971
1493
  * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
972
1494
  * @public
973
1495
  */
974
- addMask(config = {}) {
1496
+ createMask(config = {}) {
975
1497
  if (!this.canvas) return null;
976
1498
  const shapeType = config.shape || 'rect';
977
1499
  // Default config
978
- const cfg = {
1500
+ const maskConfig = {
979
1501
  shape: shapeType,
980
1502
  width: this.options.defaultMaskWidth,
981
1503
  height: this.options.defaultMaskHeight,
@@ -994,84 +1516,73 @@ function ensureFabric() {
994
1516
  let left = firstOffset;
995
1517
  let top = firstOffset;
996
1518
 
997
- const resolveValue = (val, fallback) => {
998
- if (typeof val === 'function')
999
- return val(this.canvas, this.options); // This context is this of addMask
1000
- if (typeof val === 'string' && val.endsWith('%')) {
1001
- const percent = parseFloat(val) / 100;
1519
+ const resolveValue = (value, fallback) => {
1520
+ if (typeof value === 'function')
1521
+ return value(this.canvas, this.options);
1522
+ if (typeof value === 'string' && value.endsWith('%')) {
1523
+ const percent = parseFloat(value) / 100;
1002
1524
  return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1003
1525
  }
1004
- return val != null ? val : fallback;
1526
+ return value != null ? value : fallback;
1005
1527
  }
1006
1528
 
1007
- if (cfg.left === undefined && this._lastMask) {
1008
- const prev = this._lastMask;
1009
- let prevRight = prev.left;
1529
+ if (maskConfig.left === undefined && this._lastMask) {
1530
+ const previousMask = this._lastMask;
1531
+ let previousMaskRight = previousMask.left;
1010
1532
 
1011
- if (prev.getScaledWidth) {
1012
- prevRight += prev.getScaledWidth();
1013
- } else if (prev.width) {
1014
- prevRight += prev.width * (prev.scaleX ?? 1);
1533
+ if (previousMask.getScaledWidth) {
1534
+ previousMaskRight += previousMask.getScaledWidth();
1535
+ } else if (previousMask.width) {
1536
+ previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1015
1537
  }
1016
- left = Math.round(prevRight + cfg.gap);
1017
- top = prev.top ?? firstOffset;
1538
+ left = Math.round(previousMaskRight + maskConfig.gap);
1539
+ top = previousMask.top ?? firstOffset;
1018
1540
  } else {
1019
- left = resolveValue(cfg.left, firstOffset);
1020
- top = resolveValue(cfg.top, firstOffset);
1541
+ left = resolveValue(maskConfig.left, firstOffset);
1542
+ top = resolveValue(maskConfig.top, firstOffset);
1021
1543
  }
1022
1544
 
1023
- cfg.width = resolveValue(cfg.width, this.options.defaultMaskWidth);
1024
- cfg.height = resolveValue(cfg.height, this.options.defaultMaskHeight);
1025
-
1026
- // If expandCanvasToImage mode, ensure canvas large enough to hold mask initial placement
1027
- if (this.options.expandCanvasToImage && shapeType === 'rect') {
1028
- const requiredW = Math.ceil(left + cfg.width + 10);
1029
- const requiredH = Math.ceil(top + cfg.height + 10);
1030
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
1031
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
1032
- const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
1033
- const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
1034
- this._setCanvasSizeInt(newW, newH);
1035
- }
1545
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1546
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1036
1547
 
1037
1548
  let mask;
1038
- if (typeof cfg.fabricGenerator === 'function') {
1039
- mask = cfg.fabricGenerator(cfg, this.canvas, this.options);
1549
+ if (typeof maskConfig.fabricGenerator === 'function') {
1550
+ mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
1040
1551
  } else {
1041
1552
  switch (shapeType) {
1042
1553
  case 'circle':
1043
1554
  mask = new fabric.Circle({
1044
1555
  left, top,
1045
- radius: resolveValue(cfg.radius, Math.min(cfg.width, cfg.height) / 2),
1046
- fill: cfg.color,
1047
- opacity: cfg.alpha,
1048
- angle: cfg.angle,
1049
- ...cfg.styles
1556
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1557
+ fill: maskConfig.color,
1558
+ opacity: maskConfig.alpha,
1559
+ angle: maskConfig.angle,
1560
+ ...maskConfig.styles
1050
1561
  });
1051
1562
  break;
1052
1563
  case 'ellipse':
1053
1564
  mask = new fabric.Ellipse({
1054
1565
  left, top,
1055
- rx: resolveValue(cfg.rx, cfg.width / 2),
1056
- ry: resolveValue(cfg.ry, cfg.height / 2),
1057
- fill: cfg.color,
1058
- opacity: cfg.alpha,
1059
- angle: cfg.angle,
1060
- ...cfg.styles
1566
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1567
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1568
+ fill: maskConfig.color,
1569
+ opacity: maskConfig.alpha,
1570
+ angle: maskConfig.angle,
1571
+ ...maskConfig.styles
1061
1572
  });
1062
1573
  break;
1063
1574
  case 'polygon': {
1064
- let polyPoints = cfg.points || [];
1065
- if (Array.isArray(polyPoints) && polyPoints.length && typeof polyPoints[0] === 'object') {
1575
+ let polygonPoints = maskConfig.points || [];
1576
+ if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === 'object') {
1066
1577
  // Ensure numeric {x,y} objects for fabric.Polygon
1067
- polyPoints = polyPoints.map(pt => ({ x: Number(pt.x), y: Number(pt.y) }));
1578
+ polygonPoints = polygonPoints.map(point => ({ x: Number(point.x), y: Number(point.y) }));
1068
1579
  }
1069
- mask = new fabric.Polygon(polyPoints, {
1580
+ mask = new fabric.Polygon(polygonPoints, {
1070
1581
  left, top,
1071
- fill: cfg.color,
1072
- opacity: cfg.alpha,
1073
- angle: cfg.angle,
1074
- ...cfg.styles
1582
+ fill: maskConfig.color,
1583
+ opacity: maskConfig.alpha,
1584
+ angle: maskConfig.angle,
1585
+ ...maskConfig.styles
1075
1586
  });
1076
1587
  break;
1077
1588
  }
@@ -1079,85 +1590,98 @@ function ensureFabric() {
1079
1590
  default:
1080
1591
  mask = new fabric.Rect({
1081
1592
  left, top,
1082
- width: resolveValue(cfg.width, this.options.defaultMaskWidth),
1083
- height: resolveValue(cfg.height, this.options.defaultMaskHeight),
1084
- fill: cfg.color,
1085
- opacity: cfg.alpha,
1086
- angle: cfg.angle,
1087
- rx: cfg.rx, // Rounded Corners
1088
- ry: cfg.ry,
1089
- ...cfg.styles
1593
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1594
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1595
+ fill: maskConfig.color,
1596
+ opacity: maskConfig.alpha,
1597
+ angle: maskConfig.angle,
1598
+ rx: maskConfig.rx, // Rounded Corners
1599
+ ry: maskConfig.ry,
1600
+ ...maskConfig.styles
1090
1601
  });
1091
1602
  }
1092
1603
  }
1093
1604
 
1094
- mask.selectable = cfg.selectable !== false;
1095
- mask.hasControls = ('hasControls' in cfg) ? cfg.hasControls : true;
1096
- mask.lockRotation = !this.options.maskRotatable;
1097
- mask.borderColor = cfg.borderColor || 'red';
1098
- mask.cornerColor = cfg.cornerColor || 'black';
1099
- mask.cornerSize = cfg.cornerSize || 8;
1100
- mask.transparentCorners = ('transparentCorners' in cfg) ? cfg.transparentCorners : false;
1101
- mask.stroke = (cfg.styles && cfg.styles.stroke) || '#ccc';
1102
- mask.strokeWidth = (cfg.styles && cfg.styles.strokeWidth) || 1;
1103
- mask.strokeUniform = ('strokeUniform' in cfg) ? cfg.strokeUniform : true;
1104
- if (cfg.styles && cfg.styles.strokeDashArray) mask.strokeDashArray = cfg.styles.strokeDashArray;
1105
-
1106
- mask.originalAlpha = cfg.alpha;
1107
- const normalStyle = { stroke: mask.stroke, strokeWidth: mask.strokeWidth, opacity: mask.originalAlpha };
1108
- const hoverStyle = { stroke: '#ff5500', strokeWidth: 2, opacity: Math.min(mask.originalAlpha + 0.2, 1) };
1109
-
1110
- mask.on('mouseover', () => {
1111
- mask.set(hoverStyle);
1112
- mask.canvas.requestRenderAll();
1113
- });
1114
-
1115
- mask.on('mouseout', () => {
1116
- mask.set(normalStyle);
1117
- mask.canvas.requestRenderAll();
1605
+ const styles = maskConfig.styles || {};
1606
+ const hasStyle = property => Object.prototype.hasOwnProperty.call(styles, property);
1607
+ const maskSettings = {
1608
+ selectable: maskConfig.selectable !== false,
1609
+ hasControls: ('hasControls' in maskConfig) ? maskConfig.hasControls : true,
1610
+ lockRotation: !this.options.maskRotatable,
1611
+ borderColor: ('borderColor' in maskConfig) ? maskConfig.borderColor : 'red',
1612
+ cornerColor: ('cornerColor' in maskConfig) ? maskConfig.cornerColor : 'black',
1613
+ cornerSize: ('cornerSize' in maskConfig) ? maskConfig.cornerSize : 8,
1614
+ transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
1615
+ stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
1616
+ strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
1617
+ strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
1618
+ };
1619
+ if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
1620
+ mask.set(maskSettings);
1621
+ mask.setCoords();
1622
+
1623
+ mask.set({
1624
+ originalAlpha: maskConfig.alpha,
1625
+ originalStroke: mask.stroke || '#ccc',
1626
+ originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1118
1627
  });
1628
+ this._rebindMaskEvents(mask);
1629
+ this._expandCanvasToFitObject(mask);
1119
1630
 
1120
1631
  // Remember initial for next one
1121
1632
  this._lastMaskInitialLeft = left;
1122
1633
  this._lastMaskInitialTop = top;
1123
- this._lastMaskInitialWidth = resolveValue(cfg.width, this.options.defaultMaskWidth);
1634
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1124
1635
 
1125
- mask.maskId = ++this.maskCounter;
1126
- mask.maskName = `${this.options.maskName}${mask.maskId}`;
1636
+ const maskId = ++this.maskCounter;
1637
+ mask.set({
1638
+ maskId,
1639
+ maskName: `${this.options.maskName}${maskId}`
1640
+ });
1127
1641
  this._lastMask = mask;
1128
1642
 
1129
1643
  this.canvas.add(mask);
1130
1644
  this.canvas.bringToFront(mask);
1131
- if (cfg.selectable) this.canvas.setActiveObject(mask);
1132
- this._onSelectionChanged([mask]);
1645
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1646
+ this._handleSelectionChanged([mask]);
1133
1647
  this._updateMaskList();
1134
1648
  this._updateUI();
1135
1649
  this.canvas.renderAll();
1136
1650
  this.saveState();
1137
1651
 
1138
- if (typeof cfg.onCreate === 'function') cfg.onCreate(mask, this.canvas);
1652
+ if (typeof maskConfig.onCreate === 'function') maskConfig.onCreate(mask, this.canvas);
1139
1653
  return mask;
1140
1654
  }
1141
1655
 
1656
+ /**
1657
+ * @deprecated Use createMask() instead.
1658
+ */
1659
+ addMask(config = {}) {
1660
+ return this.createMask(config);
1661
+ }
1662
+
1142
1663
  /**
1143
1664
  * Removes the currently selected mask from the canvas, if any.
1144
1665
  * The associated label is also removed. UI and mask list are updated.
1145
1666
  */
1146
1667
  removeSelectedMask() {
1147
- const active = this.canvas.getActiveObject();
1148
- if (!active || !active.maskId) return;
1149
- this._removeLabelForMask(active);
1150
- this.canvas.remove(active);
1151
- if (this._lastMask === active) {
1152
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1153
- this._lastMask = masks.length ? masks[masks.length - 1] : null;
1154
- if (!this._lastMask) {
1155
- this._lastMaskInitialLeft = null;
1156
- this._lastMaskInitialTop = null;
1157
- this._lastMaskInitialWidth = null;
1158
- }
1159
- }
1668
+ const activeObject = this.canvas.getActiveObject();
1669
+ const selectedMasks = this._getModifiedMasks(activeObject);
1670
+ if (!selectedMasks.length) return;
1671
+
1160
1672
  this.canvas.discardActiveObject();
1673
+ selectedMasks.forEach(mask => {
1674
+ this._removeLabelForMask(mask);
1675
+ this.canvas.remove(mask);
1676
+ });
1677
+
1678
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1679
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
1680
+ if (!this._lastMask) {
1681
+ this._lastMaskInitialLeft = null;
1682
+ this._lastMaskInitialTop = null;
1683
+ this._lastMaskInitialWidth = null;
1684
+ }
1161
1685
  this._updateMaskList();
1162
1686
  this._updateUI();
1163
1687
  this.canvas.renderAll();
@@ -1168,10 +1692,11 @@ function ensureFabric() {
1168
1692
  * Removes all masks from the canvas, including their labels.
1169
1693
  * UI and internal mask placement memory are reset.
1170
1694
  */
1171
- removeAllMasks() {
1172
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1173
- masks.forEach(m => this._removeLabelForMask(m));
1174
- masks.forEach(m => this.canvas.remove(m));
1695
+ removeAllMasks(options = {}) {
1696
+ const saveHistory = options.saveHistory !== false;
1697
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1698
+ masks.forEach(mask => this._removeLabelForMask(mask));
1699
+ masks.forEach(mask => this.canvas.remove(mask));
1175
1700
  this.canvas.discardActiveObject();
1176
1701
  this._lastMask = null;
1177
1702
  this._lastMaskInitialLeft = null;
@@ -1180,7 +1705,7 @@ function ensureFabric() {
1180
1705
  this._updateMaskList();
1181
1706
  this._updateUI();
1182
1707
  this.canvas.renderAll();
1183
- this.saveState();
1708
+ if (saveHistory) this.saveState();
1184
1709
  }
1185
1710
 
1186
1711
  /**
@@ -1193,12 +1718,12 @@ function ensureFabric() {
1193
1718
  if (!mask || !this.canvas) return;
1194
1719
  if (mask.__label) {
1195
1720
  try {
1196
- const objs = this.canvas.getObjects();
1197
- if (objs.includes(mask.__label)) {
1721
+ const canvasObjects = this.canvas.getObjects();
1722
+ if (canvasObjects.includes(mask.__label)) {
1198
1723
  this.canvas.remove(mask.__label);
1199
1724
  }
1200
- } catch (e) { void e; }
1201
- try { delete mask.__label; } catch (e) { void e; }
1725
+ } catch (error) { void error; }
1726
+ try { delete mask.__label; } catch (error) { void error; }
1202
1727
  }
1203
1728
  }
1204
1729
 
@@ -1212,12 +1737,12 @@ function ensureFabric() {
1212
1737
  _createLabelForMask(mask) {
1213
1738
  if (!mask || !this.options.maskLabelOnSelect) return;
1214
1739
  this._removeLabelForMask(mask);
1215
- let textObj = null;
1740
+ let textObject = null;
1216
1741
  if (this.options.label && typeof this.options.label.create === 'function') {
1217
- textObj = this.options.label.create(mask, fabric);
1742
+ textObject = this.options.label.create(mask, fabric);
1218
1743
  }
1219
- if (!textObj) {
1220
- let txt = mask.maskName; // Default
1744
+ if (!textObject) {
1745
+ let labelText = mask.maskName;
1221
1746
  let textOptions = {
1222
1747
  left: 0,
1223
1748
  top: 0,
@@ -1232,20 +1757,22 @@ function ensureFabric() {
1232
1757
  };
1233
1758
  if (this.options.label) {
1234
1759
  if (typeof this.options.label.getText === 'function') {
1235
- txt = this.options.label.getText(mask, this.maskCounter);
1760
+ const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
1761
+ const maskIndex = Math.max(0, masks.indexOf(mask));
1762
+ labelText = this.options.label.getText(mask, maskIndex);
1236
1763
  }
1237
1764
  // Merge external styles
1238
1765
  if (this.options.label.textOptions) {
1239
1766
  Object.assign(textOptions, this.options.label.textOptions);
1240
1767
  }
1241
1768
  }
1242
- textObj = new fabric.Text(txt, textOptions);
1769
+ textObject = new fabric.Text(labelText, textOptions);
1243
1770
  }
1244
1771
 
1245
- textObj.maskLabel = true;
1246
- mask.__label = textObj;
1247
- this.canvas.add(textObj);
1248
- this.canvas.bringToFront(textObj);
1772
+ textObject.maskLabel = true;
1773
+ mask.__label = textObject;
1774
+ this.canvas.add(textObject);
1775
+ this.canvas.bringToFront(textObject);
1249
1776
  this._syncMaskLabel(mask);
1250
1777
  }
1251
1778
 
@@ -1256,14 +1783,18 @@ function ensureFabric() {
1256
1783
  */
1257
1784
  _hideAllMaskLabels() {
1258
1785
  if (!this.canvas) return;
1259
- const objs = this.canvas.getObjects();
1260
- const labels = objs.filter(o => o.maskLabel);
1261
- labels.forEach(l => {
1786
+ const canvasObjects = this.canvas.getObjects();
1787
+ const labels = canvasObjects.filter(object => object.maskLabel);
1788
+ labels.forEach(label => {
1262
1789
  try {
1263
- if (objs.includes(l)) this.canvas.remove(l);
1264
- } catch (e) { void e; }
1790
+ if (canvasObjects.includes(label)) this.canvas.remove(label);
1791
+ } catch (error) { void error; }
1792
+ });
1793
+ canvasObjects.forEach(object => {
1794
+ if (object.maskId && object.__label) {
1795
+ try { delete object.__label; } catch (error) { void error; }
1796
+ }
1265
1797
  });
1266
- objs.forEach(o => { if (o.maskId && o.__label) { try { delete o.__label; } catch (e) { void e; } } });
1267
1798
  }
1268
1799
 
1269
1800
  /**
@@ -1303,7 +1834,11 @@ function ensureFabric() {
1303
1834
  visible: true
1304
1835
  });
1305
1836
  mask.__label.setCoords();
1306
- this.canvas.renderAll();
1837
+ if (typeof this.canvas.requestRenderAll === 'function') {
1838
+ this.canvas.requestRenderAll();
1839
+ } else {
1840
+ this.canvas.renderAll();
1841
+ }
1307
1842
  }
1308
1843
 
1309
1844
  /**
@@ -1316,7 +1851,7 @@ function ensureFabric() {
1316
1851
  if (!mask) return;
1317
1852
  if (!this.options.maskLabelOnSelect) return;
1318
1853
  if (!mask.__label) this._createLabelForMask(mask);
1319
- mask.__label.visible = true;
1854
+ mask.__label.set({ visible: true });
1320
1855
  this._syncMaskLabel(mask);
1321
1856
  }
1322
1857
 
@@ -1327,18 +1862,22 @@ function ensureFabric() {
1327
1862
  * @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
1328
1863
  * @private
1329
1864
  */
1330
- _onSelectionChanged(selected) {
1331
- const selectedMask = (selected || []).find(o => o.maskId);
1332
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1333
- masks.forEach(m => {
1334
- if (m !== selectedMask) {
1335
- if (m.__label) {
1336
- try { this.canvas.remove(m.__label); } catch (e) { void e; }
1337
- delete m.__label;
1865
+ _handleSelectionChanged(selected) {
1866
+ const selectedMask = (selected || []).find(object => object.maskId);
1867
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1868
+ masks.forEach(mask => {
1869
+ if (mask !== selectedMask) {
1870
+ if (mask.__label) {
1871
+ try { this.canvas.remove(mask.__label); } catch (error) { void error; }
1872
+ delete mask.__label;
1338
1873
  }
1339
- m.set({ stroke: '#ccc', strokeWidth: 1 });
1874
+ const originalStrokeWidth = Number(mask.originalStrokeWidth);
1875
+ mask.set({
1876
+ stroke: mask.originalStroke || '#ccc',
1877
+ strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
1878
+ });
1340
1879
  } else {
1341
- m.set({ stroke: '#ff0000', strokeWidth: 1 });
1880
+ mask.set({ stroke: '#ff0000', strokeWidth: 1 });
1342
1881
  }
1343
1882
  });
1344
1883
 
@@ -1355,16 +1894,16 @@ function ensureFabric() {
1355
1894
  * @private
1356
1895
  */
1357
1896
  _updateMaskList() {
1358
- const listEl = document.getElementById(this.elements.maskList);
1359
- if (!listEl) return;
1360
- listEl.innerHTML = '';
1361
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1897
+ const maskListElement = document.getElementById(this.elements.maskList);
1898
+ if (!maskListElement) return;
1899
+ maskListElement.innerHTML = '';
1900
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1362
1901
  masks.forEach(mask => {
1363
- const li = document.createElement('li');
1364
- li.className = 'list-group-item mask-item';
1365
- li.textContent = mask.maskName;
1366
- li.onclick = () => { this.canvas.setActiveObject(mask); this._onSelectionChanged([mask]); };
1367
- listEl.appendChild(li);
1902
+ const listItemElement = document.createElement('li');
1903
+ listItemElement.className = 'list-group-item mask-item';
1904
+ listItemElement.textContent = mask.maskName;
1905
+ listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
1906
+ maskListElement.appendChild(listItemElement);
1368
1907
  });
1369
1908
  }
1370
1909
 
@@ -1375,10 +1914,10 @@ function ensureFabric() {
1375
1914
  * @private
1376
1915
  */
1377
1916
  _updateMaskListSelection(selectedMask) {
1378
- const listEl = document.getElementById(this.elements.maskList);
1379
- if (!listEl) return;
1380
- const items = listEl.querySelectorAll('.mask-item');
1381
- items.forEach(item => {
1917
+ const maskListElement = document.getElementById(this.elements.maskList);
1918
+ if (!maskListElement) return;
1919
+ const maskItems = maskListElement.querySelectorAll('.mask-item');
1920
+ maskItems.forEach(item => {
1382
1921
  const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
1383
1922
  item.classList.toggle('active', isSelected);
1384
1923
  });
@@ -1390,25 +1929,33 @@ function ensureFabric() {
1390
1929
  * @async
1391
1930
  * @returns {Promise<void>} Resolves when merge and load are complete.
1392
1931
  */
1393
- async merge() {
1932
+ async mergeMasks() {
1394
1933
  if (!this.originalImage) return;
1395
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1934
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1396
1935
  if (!masks.length) return;
1397
1936
 
1398
1937
  this.canvas.discardActiveObject();
1399
1938
  this.canvas.renderAll();
1400
1939
 
1401
1940
  try {
1402
- const merged = await this.getImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1403
- this.removeAllMasks();
1941
+ const beforeJson = this._serializeCanvasState();
1942
+ const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1943
+ this.removeAllMasks({ saveHistory: false });
1404
1944
  await this.loadImage(merged);
1405
- this.saveState();
1945
+ const afterJson = this._serializeCanvasState();
1946
+ this._pushStateTransition(beforeJson, afterJson);
1406
1947
  } catch (err) {
1407
1948
  this._reportError('merge error', err);
1408
- if (this.canvasEl) this.canvasEl.style.visibility = '';
1409
1949
  }
1410
1950
  }
1411
1951
 
1952
+ /**
1953
+ * @deprecated Use mergeMasks() instead.
1954
+ */
1955
+ async merge() {
1956
+ return this.mergeMasks();
1957
+ }
1958
+
1412
1959
  /**
1413
1960
  * Triggers a JPEG image download of the current canvas (image plus masks if configured).
1414
1961
  * The image area and multiplier are controlled by options.
@@ -1417,7 +1964,7 @@ function ensureFabric() {
1417
1964
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1418
1965
  if (!this.originalImage) return;
1419
1966
  const exportImageArea = this.options.exportImageAreaByDefault;
1420
- this.getImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1967
+ this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1421
1968
  .then(base64 => {
1422
1969
  const link = document.createElement('a');
1423
1970
  link.download = fileName;
@@ -1430,113 +1977,108 @@ function ensureFabric() {
1430
1977
  }
1431
1978
 
1432
1979
  /**
1433
- * Exports the image as a Base64-encoded JPEG.
1980
+ * Exports the image as a Base64-encoded image data URL.
1434
1981
  * Can export either the original, or the current view including masks (clipped/cropped).
1435
1982
  * Will restore masks' state after temporary modifications for export.
1436
1983
  * @async
1437
- * @param {Object} [opts={}] - Export options.
1438
- * @param {boolean} [opts.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1439
- * @param {number} [opts.multiplier=1] - Scaling multiplier for output (resolution).
1440
- * @returns {Promise<string>} Promise resolving to a JPEG image data URL.
1984
+ * @param {Object} [options={}] - Export options.
1985
+ * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1986
+ * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
1987
+ * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
1988
+ * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
1989
+ * @returns {Promise<string>} Promise resolving to an image data URL.
1441
1990
  * @throws {Error} If there is no image loaded.
1442
1991
  */
1443
- async getImageBase64(opts = {}) {
1992
+ async exportImageBase64(options = {}) {
1444
1993
  if (!this.originalImage) throw new Error('No image loaded');
1445
- const exportImageArea = typeof opts.exportImageArea === 'boolean' ? opts.exportImageArea : this.options.exportImageAreaByDefault;
1446
- const multiplier = opts.multiplier || this.options.exportMultiplier || 1;
1994
+ const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
1995
+ const multiplier = options.multiplier || this.options.exportMultiplier || 1;
1996
+ const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
1997
+ const format = this._normalizeImageFormat(options.fileType || options.format);
1447
1998
 
1448
1999
  if (!exportImageArea) {
1449
- // Export original image pixels
1450
- const imgEl = this.originalImage.getElement ? this.originalImage.getElement() : (this.originalImage._element || null);
1451
- if (!imgEl) return this.canvas.toDataURL({ format: 'jpeg', quality: this.options.downsampleQuality, multiplier });
1452
- const w = this.originalImage.width;
1453
- const h = this.originalImage.height;
1454
- const oc = document.createElement('canvas');
1455
- oc.width = w;
1456
- oc.height = h;
1457
- const ctx = oc.getContext('2d');
1458
- ctx.drawImage(imgEl, 0, 0, w, h);
1459
- return oc.toDataURL('image/jpeg', this.options.downsampleQuality);
2000
+ const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
2001
+ const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
2002
+
2003
+ try {
2004
+ masks.forEach(mask => { mask.set({ visible: false }); });
2005
+ this.canvas.discardActiveObject();
2006
+ this.canvas.renderAll();
2007
+
2008
+ this.originalImage.setCoords();
2009
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2010
+ const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2011
+ return await this._exportCanvasRegionToDataURL({
2012
+ sx,
2013
+ sy,
2014
+ sw,
2015
+ sh,
2016
+ multiplier,
2017
+ quality,
2018
+ format
2019
+ });
2020
+ } finally {
2021
+ maskVisibilityBackups.forEach(backup => {
2022
+ try { backup.object.set({ visible: backup.visible }); } catch (error) { void error; }
2023
+ });
2024
+ this.canvas.renderAll();
2025
+ }
1460
2026
  }
1461
2027
 
1462
2028
  // Export current scaled image area (masks clipped)
1463
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1464
- const masksBackup = masks.map(m => ({
1465
- obj: m,
1466
- opacity: m.opacity,
1467
- fill: m.fill,
1468
- strokeWidth: m.strokeWidth,
1469
- stroke: m.stroke,
1470
- selectable: m.selectable,
1471
- lockRotation: m.lockRotation
2029
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
2030
+ const maskStyleBackups = masks.map(mask => ({
2031
+ object: mask,
2032
+ opacity: mask.opacity,
2033
+ fill: mask.fill,
2034
+ strokeWidth: mask.strokeWidth,
2035
+ stroke: mask.stroke,
2036
+ selectable: mask.selectable,
2037
+ lockRotation: mask.lockRotation
1472
2038
  }));
1473
2039
 
1474
2040
  let finalBase64;
1475
2041
  try {
1476
2042
  // Remove labels, deselect
1477
- masks.forEach(m => this._removeLabelForMask(m));
2043
+ masks.forEach(mask => this._removeLabelForMask(mask));
1478
2044
  this.canvas.discardActiveObject();
1479
2045
  this.canvas.renderAll();
1480
2046
 
1481
2047
  // Set masks to opaque black no border
1482
- masks.forEach(m => {
1483
- m.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
1484
- m.setCoords();
2048
+ masks.forEach(mask => {
2049
+ mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
2050
+ mask.setCoords();
1485
2051
  });
1486
2052
  this.canvas.renderAll();
1487
2053
 
1488
2054
  // Compute integer bounding box for image
1489
2055
  this.originalImage.setCoords();
1490
- const imgBr = this.originalImage.getBoundingRect(true, true);
1491
- const sx = Math.max(0, Math.round(imgBr.left));
1492
- const sy = Math.max(0, Math.round(imgBr.top));
1493
- const sw = Math.max(1, Math.round(imgBr.width));
1494
- const sh = Math.max(1, Math.round(imgBr.height));
2056
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2057
+ const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1495
2058
 
1496
2059
  // Crop precisely in offscreen canvas
1497
- finalBase64 = await new Promise((resolve, reject) => {
1498
- try {
1499
- const fullDataUrl = this.canvas.toDataURL({
1500
- format: 'jpeg',
1501
- quality: this.options.downsampleQuality,
1502
- multiplier: multiplier
1503
- });
1504
-
1505
- const img = new Image();
1506
- img.onload = () => {
1507
- try {
1508
- const sxM = Math.round(sx * multiplier);
1509
- const syM = Math.round(sy * multiplier);
1510
- const swM = Math.round(sw * multiplier);
1511
- const shM = Math.round(sh * multiplier);
1512
-
1513
- const oc = document.createElement('canvas');
1514
- oc.width = swM;
1515
- oc.height = shM;
1516
- const ctx = oc.getContext('2d');
1517
-
1518
- ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
1519
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality);
1520
- resolve(out);
1521
- } catch (e) { reject(e); }
1522
- };
1523
- img.onerror = reject;
1524
- img.src = fullDataUrl;
1525
- } catch (e) { reject(e); }
2060
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2061
+ sx,
2062
+ sy,
2063
+ sw,
2064
+ sh,
2065
+ multiplier,
2066
+ quality,
2067
+ format
1526
2068
  });
1527
2069
  } finally {
1528
- masksBackup.forEach(b => {
2070
+ maskStyleBackups.forEach(backup => {
1529
2071
  try {
1530
- b.obj.set({
1531
- opacity: b.opacity,
1532
- fill: b.fill,
1533
- strokeWidth: b.strokeWidth,
1534
- stroke: b.stroke,
1535
- selectable: b.selectable,
1536
- lockRotation: b.lockRotation
2072
+ backup.object.set({
2073
+ opacity: backup.opacity,
2074
+ fill: backup.fill,
2075
+ strokeWidth: backup.strokeWidth,
2076
+ stroke: backup.stroke,
2077
+ selectable: backup.selectable,
2078
+ lockRotation: backup.lockRotation
1537
2079
  });
1538
- b.obj.setCoords();
1539
- } catch (e) { void e; }
2080
+ backup.object.setCoords();
2081
+ } catch (error) { void error; }
1540
2082
  });
1541
2083
 
1542
2084
  this.canvas.renderAll();
@@ -1545,23 +2087,30 @@ function ensureFabric() {
1545
2087
  return finalBase64;
1546
2088
  }
1547
2089
 
2090
+ /**
2091
+ * @deprecated Use exportImageBase64() instead.
2092
+ */
2093
+ async getImageBase64(options = {}) {
2094
+ return this.exportImageBase64(options);
2095
+ }
2096
+
1548
2097
  /**
1549
2098
  * Exports the current canvas (with or without masks) as a File object.
1550
2099
  * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
1551
2100
  *
1552
2101
  * @async
1553
- * @param {Object} [opts={}] - Export options.
1554
- * @param {boolean} [opts.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
1555
- * @param {string} [opts.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
1556
- * @param {number} [opts.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
1557
- * @param {number} [opts.multiplier=1] - Output resolution multiplier.
1558
- * @param {string} [opts.fileName] - Optional file name (only used for download).
2102
+ * @param {Object} [options={}] - Export options.
2103
+ * @param {boolean} [options.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
2104
+ * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
2105
+ * @param {number} [options.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
2106
+ * @param {number} [options.multiplier=1] - Output resolution multiplier.
2107
+ * @param {string} [options.fileName] - Optional file name (only used for download).
1559
2108
  * @returns {Promise<File>} Resolves with the exported image as a File object.
1560
2109
  *
1561
2110
  * @example
1562
2111
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
1563
2112
  */
1564
- async exportImageFile(opts = {}) {
2113
+ async exportImageFile(options = {}) {
1565
2114
  if (!this.originalImage) throw new Error('No image loaded');
1566
2115
  const {
1567
2116
  mergeMask = true,
@@ -1569,30 +2118,25 @@ function ensureFabric() {
1569
2118
  quality = this.options.downsampleQuality ?? 0.92,
1570
2119
  multiplier = this.options.exportMultiplier ?? 1,
1571
2120
  fileName = this.options.defaultDownloadFileName ?? 'exported_image.jpg'
1572
- } = opts;
2121
+ } = options;
1573
2122
 
1574
- const typeMapping = {
1575
- 'jpeg': 'jpeg',
1576
- 'jpg': 'jpeg',
1577
- 'image/jpeg': 'jpeg',
1578
- 'png': 'png',
1579
- 'image/png': 'png',
1580
- 'webp': 'webp',
1581
- 'image/webp': 'webp'
1582
- };
1583
- const safeFileType = typeMapping[String(fileType).toLowerCase()] || 'jpeg';
2123
+ const safeFileType = this._normalizeImageFormat(fileType);
1584
2124
 
1585
2125
  // Get Base64
1586
2126
  let base64;
1587
2127
  if (mergeMask) {
1588
- base64 = await this.getImageBase64({
2128
+ base64 = await this.exportImageBase64({
1589
2129
  exportImageArea: true,
1590
2130
  multiplier,
2131
+ quality,
2132
+ fileType: safeFileType
1591
2133
  });
1592
2134
  } else {
1593
- base64 = await this.getImageBase64({
2135
+ base64 = await this.exportImageBase64({
1594
2136
  exportImageArea: false,
1595
2137
  multiplier,
2138
+ quality,
2139
+ fileType: safeFileType
1596
2140
  });
1597
2141
  }
1598
2142
 
@@ -1601,34 +2145,95 @@ function ensureFabric() {
1601
2145
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1602
2146
  // Redraw if not required format
1603
2147
  imageDataUrl = await new Promise((resolve, reject) => {
1604
- const img = new window.Image();
1605
- img.crossOrigin = "Anonymous";
1606
- img.onload = () => {
2148
+ const imageElement = new window.Image();
2149
+ imageElement.crossOrigin = "Anonymous";
2150
+ imageElement.onload = () => {
1607
2151
  try {
1608
- const oc = document.createElement('canvas');
1609
- oc.width = img.width;
1610
- oc.height = img.height;
1611
- const ctx = oc.getContext('2d');
1612
- ctx.drawImage(img, 0, 0);
1613
- const durl = oc.toDataURL(`image/${safeFileType}`, quality);
1614
- resolve(durl);
1615
- } catch (e) { reject(e); }
2152
+ const offscreenCanvas = document.createElement('canvas');
2153
+ offscreenCanvas.width = imageElement.width;
2154
+ offscreenCanvas.height = imageElement.height;
2155
+ const context = offscreenCanvas.getContext('2d');
2156
+ context.drawImage(imageElement, 0, 0);
2157
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2158
+ resolve(convertedDataUrl);
2159
+ } catch (error) { reject(error); }
1616
2160
  };
1617
- img.onerror = reject;
1618
- img.src = base64;
2161
+ imageElement.onerror = reject;
2162
+ imageElement.src = base64;
1619
2163
  });
1620
2164
  }
1621
2165
 
1622
2166
  // Convert DataURL to Blob and then to File
1623
- const bstr = atob(imageDataUrl.split(',')[1]);
2167
+ const binaryString = atob(imageDataUrl.split(',')[1]);
1624
2168
  const mime = `image/${safeFileType}`;
1625
- let n = bstr.length;
1626
- const u8arr = new Uint8Array(n);
1627
- while (n--) {
1628
- u8arr[n] = bstr.charCodeAt(n);
2169
+ let byteIndex = binaryString.length;
2170
+ const bytes = new Uint8Array(byteIndex);
2171
+ while (byteIndex--) {
2172
+ bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
1629
2173
  }
1630
- const file = new File([u8arr], fileName, { type: mime });
1631
- return file;
2174
+ return new File([bytes], fileName, { type: mime });
2175
+ }
2176
+
2177
+ _clearMaskPlacementMemory() {
2178
+ this._lastMask = null;
2179
+ this._lastMaskInitialLeft = null;
2180
+ this._lastMaskInitialTop = null;
2181
+ this._lastMaskInitialWidth = null;
2182
+ }
2183
+
2184
+ async _restoreStateAfterCropFailure(beforeJson, message, error) {
2185
+ this._reportError(message, error);
2186
+
2187
+ if (this._cropRect && this.canvas) this._removeCropRect();
2188
+ this._cropRect = null;
2189
+ this._cropMode = false;
2190
+ if (this.canvas && this._prevSelectionSetting !== undefined) {
2191
+ this.canvas.selection = !!this._prevSelectionSetting;
2192
+ }
2193
+ this._prevSelectionSetting = undefined;
2194
+
2195
+ if (beforeJson) {
2196
+ try {
2197
+ await this.loadFromState(beforeJson);
2198
+ } catch (restoreError) {
2199
+ this._reportError('applyCrop: rollback failed', restoreError);
2200
+ }
2201
+ }
2202
+
2203
+ this._updateUI();
2204
+ if (this.canvas) this.canvas.renderAll();
2205
+ }
2206
+
2207
+ _restoreCropObjectState() {
2208
+ if (Array.isArray(this._cropPrevEvented)) {
2209
+ this._cropPrevEvented.forEach(state => {
2210
+ try {
2211
+ state.object.set({
2212
+ evented: state.evented,
2213
+ selectable: state.selectable,
2214
+ visible: state.visible
2215
+ });
2216
+ } catch (error) { void error; }
2217
+ });
2218
+ }
2219
+ this._cropPrevEvented = null;
2220
+ }
2221
+
2222
+ _removeCropRect() {
2223
+ if (!this._cropRect) return;
2224
+ try {
2225
+ if (this._cropHandlers && this._cropHandlers.length) {
2226
+ this._cropHandlers.forEach(targetHandlers => {
2227
+ targetHandlers.handlers.forEach(handlerRecord => {
2228
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2229
+ });
2230
+ });
2231
+ }
2232
+ } catch (error) { void error; }
2233
+
2234
+ try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
2235
+ this._cropRect = null;
2236
+ this._cropHandlers = [];
1632
2237
  }
1633
2238
 
1634
2239
  /**
@@ -1649,13 +2254,13 @@ function ensureFabric() {
1649
2254
 
1650
2255
  // Create initial crop rect centered on the image bounding box
1651
2256
  this.originalImage.setCoords();
1652
- const imgBr = this.originalImage.getBoundingRect(true, true);
2257
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
1653
2258
  // Provide small inset so user can see a margin
1654
2259
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
1655
- const left = Math.max(0, Math.floor(imgBr.left + padding));
1656
- const top = Math.max(0, Math.floor(imgBr.top + padding));
1657
- const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imgBr.width - padding * 2));
1658
- const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imgBr.height - padding * 2));
2260
+ const left = Math.max(0, Math.floor(imageBounds.left + padding));
2261
+ const top = Math.max(0, Math.floor(imageBounds.top + padding));
2262
+ const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imageBounds.width - padding * 2));
2263
+ const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imageBounds.height - padding * 2));
1659
2264
 
1660
2265
  // Visual style: translucent fill + dashed stroke
1661
2266
  const cropRect = new fabric.Rect({
@@ -1687,21 +2292,36 @@ function ensureFabric() {
1687
2292
  // While in crop mode: we want only the cropRect to be interactive
1688
2293
  // but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
1689
2294
  this._cropPrevEvented = [];
1690
- this.canvas.getObjects().forEach(o => {
1691
- if (o !== cropRect) {
1692
- this._cropPrevEvented.push({ obj: o, evented: o.evented, selectable: o.selectable });
1693
- try { o.evented = false; o.selectable = false; } catch (e) { void e; }
2295
+ const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
2296
+ this.canvas.getObjects().forEach(object => {
2297
+ if (object !== cropRect) {
2298
+ this._cropPrevEvented.push({ object, evented: object.evented, selectable: object.selectable, visible: object.visible });
2299
+ try {
2300
+ const updates = {
2301
+ evented: false,
2302
+ selectable: false
2303
+ };
2304
+ if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
2305
+ object.set(updates);
2306
+ } catch (error) { void error; }
1694
2307
  }
1695
2308
  });
1696
2309
 
1697
2310
  // When the crop rect changes, re-render
1698
- const onModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (e) { void e; } };
1699
- cropRect.on('modified', onModified);
1700
- cropRect.on('moving', onModified);
1701
- cropRect.on('scaling', onModified);
2311
+ const handleCropRectModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (error) { void error; } };
2312
+ cropRect.on('modified', handleCropRectModified);
2313
+ cropRect.on('moving', handleCropRectModified);
2314
+ cropRect.on('scaling', handleCropRectModified);
1702
2315
 
1703
2316
  // Keep handlers to remove later
1704
- this._cropHandlers.push({ target: cropRect, handlers: [{ evt: 'modified', fn: onModified }, { evt: 'moving', fn: onModified }, { evt: 'scaling', fn: onModified }] });
2317
+ this._cropHandlers.push({
2318
+ target: cropRect,
2319
+ handlers: [
2320
+ { eventName: 'modified', handler: handleCropRectModified },
2321
+ { eventName: 'moving', handler: handleCropRectModified },
2322
+ { eventName: 'scaling', handler: handleCropRectModified }
2323
+ ]
2324
+ });
1705
2325
 
1706
2326
  this._updateUI();
1707
2327
  this.canvas.renderAll();
@@ -1713,27 +2333,8 @@ function ensureFabric() {
1713
2333
  */
1714
2334
  cancelCrop() {
1715
2335
  if (!this.canvas || !this._cropMode) return;
1716
- // Remove handlers if any and remove object
1717
- if (this._cropRect) {
1718
- try {
1719
- if (this._cropHandlers && this._cropHandlers.length) {
1720
- this._cropHandlers.forEach(h => {
1721
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1722
- });
1723
- }
1724
- } catch (e) { void e; }
1725
-
1726
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
1727
- this._cropRect = null;
1728
- }
1729
- // restore evented/selectable flags
1730
- if (Array.isArray(this._cropPrevEvented)) {
1731
- this._cropPrevEvented.forEach(i => {
1732
- try { i.obj.evented = i.evented; i.obj.selectable = i.selectable; } catch (e) { void e; }
1733
- });
1734
- }
1735
- this._cropPrevEvented = null;
1736
- this._cropHandlers = [];
2336
+ this._removeCropRect();
2337
+ this._restoreCropObjectState();
1737
2338
  this._cropMode = false;
1738
2339
  // restore selection setting
1739
2340
  this.canvas.selection = !!this._prevSelectionSetting;
@@ -1756,62 +2357,57 @@ function ensureFabric() {
1756
2357
  this._cropRect.setCoords();
1757
2358
  const rectBounds = this._cropRect.getBoundingRect(true, true);
1758
2359
 
1759
- // Compute integer crop region clamped to canvas
1760
- const sx = Math.max(0, Math.round(rectBounds.left));
1761
- const sy = Math.max(0, Math.round(rectBounds.top));
1762
- const sw = Math.max(1, Math.round(Math.min(rectBounds.width, this.canvas.getWidth() - sx)));
1763
- const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
2360
+ const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
2361
+ const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2362
+
2363
+ this._restoreCropObjectState();
1764
2364
 
1765
- // Include isCropRect in toJSON whitelist so we can detect and filter them out.
1766
2365
  let beforeJson = null;
1767
2366
  try {
1768
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1769
- if (Array.isArray(jsonObj.objects)) {
1770
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
1771
- }
1772
- beforeJson = JSON.stringify(jsonObj);
1773
- } catch (e) {
1774
- this._reportWarning('applyCrop: could not serialize before state', e);
2367
+ beforeJson = this._serializeCanvasState();
2368
+ } catch (error) {
2369
+ this._reportWarning('applyCrop: could not serialize before state', error);
1775
2370
  beforeJson = null;
1776
2371
  }
1777
2372
 
2373
+ const preservedMasks = [];
1778
2374
 
1779
- // Remove ALL un-merged masks so they won't be baked into exported pixels
1780
2375
  try {
1781
- const masks = this.canvas.getObjects().filter(o => o.maskId);
2376
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1782
2377
  if (masks && masks.length) {
1783
- masks.forEach(m => {
2378
+ masks.forEach(mask => {
1784
2379
  try {
1785
- this._removeLabelForMask(m);
1786
- this.canvas.remove(m);
1787
- } catch (err) {
1788
- this._reportWarning('applyCrop: failed to remove mask', err);
2380
+ mask.setCoords();
2381
+ const maskBounds = mask.getBoundingRect(true, true);
2382
+ const intersectsCrop =
2383
+ maskBounds.left < sx + sw &&
2384
+ maskBounds.left + maskBounds.width > sx &&
2385
+ maskBounds.top < sy + sh &&
2386
+ maskBounds.top + maskBounds.height > sy;
2387
+ this._removeLabelForMask(mask);
2388
+ this.canvas.remove(mask);
2389
+ if (shouldPreserveMasks && intersectsCrop) {
2390
+ mask.set({
2391
+ left: (mask.left || 0) - sx,
2392
+ top: (mask.top || 0) - sy,
2393
+ visible: true
2394
+ });
2395
+ mask.setCoords();
2396
+ preservedMasks.push(mask);
2397
+ }
2398
+ } catch (error) {
2399
+ this._reportWarning('applyCrop: failed to remove mask', error);
1789
2400
  }
1790
2401
  });
1791
- this._lastMask = null;
1792
- this._lastMaskInitialLeft = null;
1793
- this._lastMaskInitialTop = null;
1794
- this._lastMaskInitialWidth = null;
2402
+ this._clearMaskPlacementMemory();
1795
2403
  this.canvas.discardActiveObject();
1796
2404
  this.canvas.renderAll();
1797
2405
  }
1798
- } catch (e) {
1799
- this._reportWarning('applyCrop: error while removing masks', e);
2406
+ } catch (error) {
2407
+ this._reportWarning('applyCrop: error while removing masks', error);
1800
2408
  }
1801
2409
 
1802
- try {
1803
- if (this._cropRect) {
1804
- try {
1805
- if (this._cropHandlers && this._cropHandlers.length) {
1806
- this._cropHandlers.forEach(h => {
1807
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1808
- });
1809
- }
1810
- } catch (e) { void e; }
1811
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
1812
- this._cropRect = null;
1813
- }
1814
- } catch (e) { void e; }
2410
+ this._removeCropRect();
1815
2411
 
1816
2412
  // End crop mode
1817
2413
  this._cropMode = false;
@@ -1821,78 +2417,50 @@ function ensureFabric() {
1821
2417
  // Export full canvas and crop on offscreen canvas
1822
2418
  let croppedBase64;
1823
2419
  try {
1824
- const fullDataUrl = this.canvas.toDataURL({
1825
- format: 'jpeg',
1826
- quality: this.options.downsampleQuality || 0.92,
1827
- multiplier: 1
1828
- });
1829
-
1830
- croppedBase64 = await new Promise((resolve, reject) => {
1831
- const img = new Image();
1832
- img.onload = () => {
1833
- try {
1834
- const oc = document.createElement('canvas');
1835
- oc.width = sw;
1836
- oc.height = sh;
1837
- const ctx = oc.getContext('2d');
1838
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
1839
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality || 0.92);
1840
- resolve(out);
1841
- } catch (err) {
1842
- reject(err);
1843
- }
1844
- };
1845
- img.onerror = (e) => reject(e);
1846
- img.src = fullDataUrl;
2420
+ croppedBase64 = await this._exportCanvasRegionToDataURL({
2421
+ sx,
2422
+ sy,
2423
+ sw,
2424
+ sh,
2425
+ multiplier: 1,
2426
+ quality: this._normalizeQuality(this.options.downsampleQuality),
2427
+ format: 'jpeg'
1847
2428
  });
1848
- } catch (e) {
1849
- this._reportError('applyCrop: failed to create cropped image', e);
1850
- this._updateUI();
2429
+ } catch (error) {
2430
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
1851
2431
  return;
1852
2432
  }
1853
2433
 
1854
2434
  // Load the cropped image as the new base image
1855
2435
  try {
1856
2436
  await this.loadImage(croppedBase64);
2437
+ if (preservedMasks.length) {
2438
+ preservedMasks.forEach(mask => {
2439
+ this._rebindMaskEvents(mask);
2440
+ this.canvas.add(mask);
2441
+ this.canvas.bringToFront(mask);
2442
+ });
2443
+ this._lastMask = preservedMasks[preservedMasks.length - 1];
2444
+ this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
2445
+ this._updateMaskList();
2446
+ this.canvas.renderAll();
2447
+ }
1857
2448
  } catch (e) {
1858
- this._reportError('applyCrop: loadImage(croppedBase64) failed', e);
1859
- this._updateUI();
2449
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', e);
1860
2450
  return;
1861
2451
  }
1862
2452
 
1863
2453
  // Create "after" snapshot (also exclude crop rect if any) and push history command
1864
2454
  let afterJson = null;
1865
2455
  try {
1866
- const jsonObj2 = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1867
- if (Array.isArray(jsonObj2.objects)) {
1868
- jsonObj2.objects = jsonObj2.objects.filter(o => !o.isCropRect);
1869
- }
1870
- afterJson = JSON.stringify(jsonObj2);
2456
+ afterJson = this._serializeCanvasState();
1871
2457
  } catch (e) {
1872
2458
  this._reportWarning('applyCrop: failed to serialize after state', e);
1873
2459
  afterJson = null;
1874
2460
  }
1875
2461
 
1876
2462
  try {
1877
- const self = this;
1878
- const cmd = new Command(
1879
- () => { if (afterJson) self.loadFromState(afterJson); },
1880
- () => { if (beforeJson) self.loadFromState(beforeJson); }
1881
- );
1882
-
1883
- if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1884
-
1885
- // trim future redo history
1886
- if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
1887
- this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
1888
- }
1889
-
1890
- this.historyManager.history.push(cmd);
1891
- if (this.historyManager.history.length > this.historyManager.maxSize) {
1892
- this.historyManager.history.shift();
1893
- } else {
1894
- this.historyManager.currentIndex++;
1895
- }
2463
+ this._pushStateTransition(beforeJson, afterJson);
1896
2464
  } catch (e) {
1897
2465
  this._reportWarning('applyCrop: failed to push history command', e);
1898
2466
  }
@@ -1911,8 +2479,8 @@ function ensureFabric() {
1911
2479
  * @private
1912
2480
  */
1913
2481
  _updateInputs() {
1914
- const scaleEl = document.getElementById(this.elements.scaleRate);
1915
- if (scaleEl) scaleEl.value = Math.round(this.currentScale * 100);
2482
+ const scaleInputElement = document.getElementById(this.elements.scaleRate);
2483
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
1916
2484
  }
1917
2485
 
1918
2486
  /**
@@ -1921,45 +2489,47 @@ function ensureFabric() {
1921
2489
  * @private
1922
2490
  */
1923
2491
  _updateUI() {
1924
- const hasImg = !!this.originalImage;
1925
- const masks = hasImg ? this.canvas.getObjects().filter(o => o.maskId) : [];
2492
+ const hasImage = !!this.originalImage;
2493
+ const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
1926
2494
  const hasMasks = masks.length > 0;
1927
- const active = this.canvas.getActiveObject();
1928
- const hasSelectedMask = active && active.maskId;
1929
- const isDefault = this.currentScale === 1 && this.currentRotation === 0;
2495
+ const activeObject = this.canvas.getActiveObject();
2496
+ const hasSelectedMask = activeObject && activeObject.maskId;
2497
+ const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
1930
2498
  const canUndo = this.historyManager?.canUndo();
1931
2499
  const canRedo = this.historyManager?.canRedo();
1932
- const inCrop = !!this._cropMode;
2500
+ const isInCropMode = !!this._cropMode;
1933
2501
 
1934
- if (inCrop) {
2502
+ if (isInCropMode) {
1935
2503
  // iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
1936
- for (const k of Object.keys(this.elements || {})) {
1937
- const el = document.getElementById(this.elements[k]);
1938
- if (!el) continue;
1939
- if (k === 'applyCropBtn' || k === 'cancelCropBtn') {
1940
- el.disabled = false;
2504
+ for (const key of Object.keys(this.elements || {})) {
2505
+ const element = document.getElementById(this.elements[key]);
2506
+ if (!element) continue;
2507
+ if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
2508
+ this._setDisabled(key, false);
1941
2509
  } else {
1942
- el.disabled = true;
2510
+ this._setDisabled(key, true);
1943
2511
  }
1944
2512
  }
1945
2513
  return;
1946
2514
  }
1947
2515
 
1948
- this._setDisabled('zoomInBtn', !hasImg || this.isAnimating || this.currentScale >= this.options.maxScale);
1949
- this._setDisabled('zoomOutBtn', !hasImg || this.isAnimating || this.currentScale <= this.options.minScale);
1950
- this._setDisabled('rotateLeftBtn', !hasImg || this.isAnimating);
1951
- this._setDisabled('rotateRightBtn', !hasImg || this.isAnimating);
1952
- this._setDisabled('addMaskBtn', !hasImg || this.isAnimating);
2516
+ this._setDisabled('zoomInBtn', !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2517
+ this._setDisabled('zoomOutBtn', !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2518
+ this._setDisabled('rotateLeftBtn', !hasImage || this.isAnimating);
2519
+ this._setDisabled('rotateRightBtn', !hasImage || this.isAnimating);
2520
+ this._setDisabled('addMaskBtn', !hasImage || this.isAnimating);
1953
2521
  this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
1954
2522
  this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
1955
- this._setDisabled('mergeBtn', !hasImg || !hasMasks || this.isAnimating);
1956
- this._setDisabled('downloadBtn', !hasImg || this.isAnimating);
1957
- this._setDisabled('resetBtn', !hasImg || isDefault || this.isAnimating);
1958
- this._setDisabled('undoBtn', !hasImg || this.isAnimating || !canUndo);
1959
- this._setDisabled('redoBtn', !hasImg || this.isAnimating || !canRedo);
1960
- this._setDisabled('cropBtn', !hasImg || this.isAnimating);
2523
+ this._setDisabled('mergeBtn', !hasImage || !hasMasks || this.isAnimating);
2524
+ this._setDisabled('downloadBtn', !hasImage || this.isAnimating);
2525
+ this._setDisabled('resetBtn', !hasImage || isDefaultTransform || this.isAnimating);
2526
+ this._setDisabled('undoBtn', !hasImage || this.isAnimating || !canUndo);
2527
+ this._setDisabled('redoBtn', !hasImage || this.isAnimating || !canRedo);
2528
+ this._setDisabled('cropBtn', !hasImage || this.isAnimating);
1961
2529
  this._setDisabled('applyCropBtn', true);
1962
2530
  this._setDisabled('cancelCropBtn', true);
2531
+ this._setDisabled('imageInput', this.isAnimating);
2532
+ this._setDisabled('uploadArea', this.isAnimating);
1963
2533
  }
1964
2534
 
1965
2535
  /**
@@ -1970,8 +2540,26 @@ function ensureFabric() {
1970
2540
  * @private
1971
2541
  */
1972
2542
  _setDisabled(key, disabled) {
1973
- const el = document.getElementById(this.elements[key]);
1974
- if (el) el.disabled = !!disabled;
2543
+ const element = document.getElementById(this.elements[key]);
2544
+ if (!element) return;
2545
+ if ('disabled' in element) {
2546
+ element.disabled = !!disabled;
2547
+ return;
2548
+ }
2549
+
2550
+ if (disabled) {
2551
+ element.setAttribute('aria-disabled', 'true');
2552
+ element.style.pointerEvents = 'none';
2553
+ } else {
2554
+ element.removeAttribute('aria-disabled');
2555
+ element.style.pointerEvents = '';
2556
+ }
2557
+ }
2558
+
2559
+ _isElementDisabled(element) {
2560
+ if (!element) return false;
2561
+ if ('disabled' in element) return !!element.disabled;
2562
+ return element.getAttribute('aria-disabled') === 'true';
1975
2563
  }
1976
2564
 
1977
2565
  /**
@@ -1989,15 +2577,15 @@ function ensureFabric() {
1989
2577
  * @private
1990
2578
  */
1991
2579
  _setPlaceholderVisible(show) {
1992
- if (!this.placeholderEl) return;
2580
+ if (!this.placeholderElement) return;
1993
2581
  if (show) {
1994
- this.placeholderEl.classList.remove('d-none');
1995
- this.placeholderEl.classList.add('d-flex');
1996
- this.containerEl.classList.add('d-none');
2582
+ this.placeholderElement.classList.remove('d-none');
2583
+ this.placeholderElement.classList.add('d-flex');
2584
+ this.containerElement.classList.add('d-none');
1997
2585
  } else {
1998
- this.placeholderEl.classList.remove('d-flex');
1999
- this.placeholderEl.classList.add('d-none');
2000
- this.containerEl.classList.remove('d-none');
2586
+ this.placeholderElement.classList.remove('d-flex');
2587
+ this.placeholderElement.classList.add('d-none');
2588
+ this.containerElement.classList.remove('d-none');
2001
2589
  }
2002
2590
  }
2003
2591
 
@@ -2009,28 +2597,32 @@ function ensureFabric() {
2009
2597
  dispose() {
2010
2598
  // Remove bound DOM event listeners
2011
2599
  try {
2012
- for (const key in (this._boundHandlers || {})) {
2013
- const handlers = this._boundHandlers[key] || [];
2014
- const el = document.getElementById(this.elements[key]);
2015
- if (!el) continue;
2016
- handlers.forEach(h => {
2017
- try { el.removeEventListener(h.event, h.handler); } catch (e) { void e; }
2600
+ for (const key in (this._handlersByElementKey || {})) {
2601
+ const handlers = this._handlersByElementKey[key] || [];
2602
+ const element = document.getElementById(this.elements[key]);
2603
+ if (!element) continue;
2604
+ handlers.forEach(handlerRecord => {
2605
+ try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
2018
2606
  });
2019
2607
  }
2020
- } catch (e) { void e; }
2608
+ } catch (error) { void error; }
2021
2609
 
2022
2610
  if (this._cropRect) {
2023
2611
  try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
2024
2612
  this._cropRect = null;
2025
2613
  }
2026
2614
 
2615
+ if (this.containerElement && this._containerOriginalOverflow !== undefined) {
2616
+ try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (e) { void e; }
2617
+ }
2618
+
2027
2619
  if (this.canvas) {
2028
2620
  try { this.canvas.dispose(); } catch (e) { void e; }
2029
2621
  this.canvas = null;
2030
- this.canvasEl = null;
2622
+ this.canvasElement = null;
2031
2623
  this.isImageLoadedToCanvas = false;
2032
2624
  }
2033
- this._boundHandlers = {};
2625
+ this._handlersByElementKey = {};
2034
2626
  }
2035
2627
  }
2036
2628
 
@@ -2135,6 +2727,13 @@ function ensureFabric() {
2135
2727
  this.history = [];
2136
2728
  this.currentIndex = -1;
2137
2729
  this.maxSize = maxSize;
2730
+ this.pending = Promise.resolve();
2731
+ }
2732
+
2733
+ enqueue(task) {
2734
+ const run = this.pending.then(task, task);
2735
+ this.pending = run.catch(() => {});
2736
+ return run;
2138
2737
  }
2139
2738
 
2140
2739
  /**
@@ -2147,7 +2746,17 @@ function ensureFabric() {
2147
2746
  execute(command) {
2148
2747
  // Perform the command.
2149
2748
  command.execute();
2749
+ this.push(command);
2750
+ }
2150
2751
 
2752
+ /**
2753
+ * Pushes an already-applied command onto the history stack.
2754
+ * Truncates any "future" history when branching.
2755
+ *
2756
+ * @param {Command} command The command to push.
2757
+ * @returns {void}
2758
+ */
2759
+ push(command) {
2151
2760
  // Remove any commands that are ahead of the current index.
2152
2761
  if (this.currentIndex < this.history.length - 1) {
2153
2762
  this.history = this.history.slice(0, this.currentIndex + 1);
@@ -2188,10 +2797,13 @@ function ensureFabric() {
2188
2797
  * @returns {void}
2189
2798
  */
2190
2799
  undo() {
2191
- if (this.currentIndex >= 0) {
2192
- this.history[this.currentIndex].undo();
2193
- this.currentIndex--;
2194
- }
2800
+ return this.enqueue(async () => {
2801
+ if (this.currentIndex >= 0) {
2802
+ const index = this.currentIndex;
2803
+ await this.history[index].undo();
2804
+ this.currentIndex = index - 1;
2805
+ }
2806
+ });
2195
2807
  }
2196
2808
 
2197
2809
  /**
@@ -2200,10 +2812,13 @@ function ensureFabric() {
2200
2812
  * @returns {void}
2201
2813
  */
2202
2814
  redo() {
2203
- if (this.currentIndex < this.history.length - 1) {
2204
- this.currentIndex++;
2205
- this.history[this.currentIndex].execute();
2206
- }
2815
+ return this.enqueue(async () => {
2816
+ if (this.currentIndex < this.history.length - 1) {
2817
+ const index = this.currentIndex + 1;
2818
+ await this.history[index].execute();
2819
+ this.currentIndex = index;
2820
+ }
2821
+ });
2207
2822
  }
2208
2823
  }
2209
2824