@bensitu/image-editor 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,13 @@
3
3
  /**
4
4
  * @file image-editor.js
5
5
  * @module image-editor
6
- * @version 1.3.1
6
+ * @version 1.4.1
7
7
  * @author Ben Situ
8
8
  * @license MIT
9
9
  * @description Lightweight canvas-based image editor with masking/transform/export support.
10
10
  */
11
11
  var fabric = null;
12
+ var INTERNAL_OPERATION_TOKEN = /* @__PURE__ */ Symbol("ImageEditorInternalOperation");
12
13
  function getGlobalScope() {
13
14
  if (typeof globalThis !== "undefined") return globalThis;
14
15
  if (typeof self !== "undefined") return self;
@@ -70,6 +71,8 @@
70
71
  downsampleMaxWidth: 4e3,
71
72
  downsampleMaxHeight: 3e3,
72
73
  downsampleQuality: 0.92,
74
+ preserveSourceFormat: true,
75
+ downsampleMimeType: null,
73
76
  imageLoadTimeoutMs: 3e4,
74
77
  exportMultiplier: 1,
75
78
  exportImageAreaByDefault: true,
@@ -114,10 +117,15 @@
114
117
  this.currentRotation = 0;
115
118
  this.maskCounter = 0;
116
119
  this.isAnimating = false;
120
+ this._isLoading = false;
121
+ this._activeOperationName = null;
122
+ this._activeOperationToken = null;
117
123
  this.elements = {};
118
124
  this.isImageLoadedToCanvas = false;
119
125
  this.maxHistorySize = 50;
120
126
  this._handlersByElementKey = {};
127
+ this._elementCache = {};
128
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
121
129
  this._lastMask = null;
122
130
  this._lastMaskInitialLeft = null;
123
131
  this._lastMaskInitialTop = null;
@@ -128,8 +136,14 @@
128
136
  this._cropHandlers = [];
129
137
  this._cropPrevEvented = null;
130
138
  this._prevSelectionSetting = void 0;
131
- this._containerOriginalOverflow = void 0;
139
+ this._containerOriginalOverflow = null;
140
+ this._lastContainerViewportSize = null;
141
+ this._canvasElementOriginalStyle = null;
142
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
132
143
  this._scrollbarSizeCache = null;
144
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
145
+ this._disposed = false;
146
+ this._initialized = false;
133
147
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
134
148
  this.animationQueue = new AnimationQueue();
135
149
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -193,6 +207,20 @@
193
207
  */
194
208
  init(idMap = {}) {
195
209
  if (!this._fabricLoaded) return;
210
+ if (this._initialized || this.canvas) this.dispose();
211
+ this._disposed = false;
212
+ this._initialized = true;
213
+ this.animationQueue = new AnimationQueue();
214
+ this.historyManager = new HistoryManager(this.maxHistorySize);
215
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
216
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
217
+ this._isLoading = false;
218
+ this._activeOperationName = null;
219
+ this._activeOperationToken = null;
220
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
221
+ this._containerOriginalOverflow = null;
222
+ this._lastContainerViewportSize = null;
223
+ this._canvasElementOriginalStyle = null;
196
224
  const defaults = {
197
225
  canvas: "fabricCanvas",
198
226
  canvasContainer: null,
@@ -220,6 +248,7 @@
220
248
  cancelCropBtn: "cancelCropBtn"
221
249
  };
222
250
  this.elements = { ...defaults, ...idMap };
251
+ this._elementCache = {};
223
252
  this._initCanvas();
224
253
  this._bindEvents();
225
254
  this._updateInputs();
@@ -254,16 +283,22 @@
254
283
  * @private
255
284
  */
256
285
  _initCanvas() {
257
- const canvasElement = document.getElementById(this.elements.canvas);
286
+ const canvasElement = this._getElement("canvas");
258
287
  if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
259
288
  this.canvasElement = canvasElement;
289
+ this._canvasElementOriginalStyle = {
290
+ display: canvasElement.style.display || "",
291
+ width: canvasElement.style.width || "",
292
+ height: canvasElement.style.height || "",
293
+ maxWidth: canvasElement.style.maxWidth || ""
294
+ };
260
295
  if (this.elements.canvasContainer) {
261
- const containerElement = document.getElementById(this.elements.canvasContainer);
296
+ const containerElement = this._getElement("canvasContainer");
262
297
  this.containerElement = containerElement || canvasElement.parentElement;
263
298
  } else {
264
299
  this.containerElement = canvasElement.parentElement;
265
300
  }
266
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
301
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
267
302
  let initialWidth = this.options.canvasWidth;
268
303
  let initialHeight = this.options.canvasHeight;
269
304
  if (this.containerElement) {
@@ -272,6 +307,10 @@
272
307
  if (containerWidth > 0 && containerHeight > 0) {
273
308
  initialWidth = containerWidth;
274
309
  initialHeight = containerHeight;
310
+ this._lastContainerViewportSize = {
311
+ width: containerWidth,
312
+ height: containerHeight
313
+ };
275
314
  }
276
315
  }
277
316
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -296,6 +335,23 @@
296
335
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
297
336
  this.canvasElement.style.display = "block";
298
337
  }
338
+ /**
339
+ * Returns a configured DOM element and caches lookups for hot UI paths.
340
+ *
341
+ * @param {string} key - Key in the configured element map.
342
+ * @returns {HTMLElement|null} The configured element, or null when missing.
343
+ * @private
344
+ */
345
+ _getElement(key) {
346
+ const id = this.elements && this.elements[key];
347
+ if (!id) return null;
348
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
349
+ return this._elementCache[key];
350
+ }
351
+ const element = document.getElementById(id);
352
+ if (this._elementCache) this._elementCache[key] = element || null;
353
+ return element || null;
354
+ }
299
355
  /**
300
356
  * Records a history entry after Fabric finishes modifying one or more masks.
301
357
  *
@@ -336,9 +392,7 @@
336
392
  */
337
393
  _syncContainerOverflow(options = {}) {
338
394
  if (!this.containerElement || !this.containerElement.style) return;
339
- if (this._containerOriginalOverflow === void 0) {
340
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
341
- }
395
+ this._captureContainerOverflowState();
342
396
  const shouldPreserveScroll = options.preserveScroll === true;
343
397
  if (this.options.coverImageToCanvas) {
344
398
  this.containerElement.style.overflow = "scroll";
@@ -353,58 +407,83 @@
353
407
  this.containerElement.scrollTop = 0;
354
408
  }
355
409
  } else {
356
- this.containerElement.style.overflow = this._containerOriginalOverflow;
410
+ this._restoreContainerOverflowState();
357
411
  }
358
412
  }
413
+ _captureContainerOverflowState() {
414
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
415
+ this._containerOriginalOverflow = {
416
+ overflow: this.containerElement.style.overflow || "",
417
+ overflowX: this.containerElement.style.overflowX || "",
418
+ overflowY: this.containerElement.style.overflowY || ""
419
+ };
420
+ }
421
+ _restoreContainerOverflowState() {
422
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
423
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
424
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
425
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
426
+ }
427
+ _restoreContainerOverflowSnapshot(snapshot) {
428
+ if (!this.containerElement || !this.containerElement.style || !snapshot) return;
429
+ this.containerElement.style.overflow = snapshot.overflow || "";
430
+ this.containerElement.style.overflowX = snapshot.overflowX || "";
431
+ this.containerElement.style.overflowY = snapshot.overflowY || "";
432
+ }
359
433
  /**
360
434
  * DOM / UI bindings
361
435
  * @private
362
436
  */
363
437
  _bindEvents() {
364
438
  this._bindIfExists("uploadArea", "click", () => {
365
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
439
+ const uploadAreaElement = this._getElement("uploadArea");
366
440
  if (this._isElementDisabled(uploadAreaElement)) return;
367
- document.getElementById(this.elements.imageInput)?.click();
441
+ this._getElement("imageInput")?.click();
368
442
  });
369
443
  this._bindIfExists("imageInput", "change", (event) => {
370
444
  const file = event.target.files && event.target.files[0];
371
- if (file) this._loadImageFile(file);
445
+ if (file) {
446
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
447
+ event.target.value = "";
448
+ });
449
+ }
372
450
  });
373
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
374
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
451
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
452
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
375
453
  this._bindIfExists("resetBtn", "click", () => {
376
- this.resetImageTransform();
454
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
377
455
  });
378
456
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
379
457
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
380
458
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
381
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
459
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
382
460
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
383
- this._bindIfExists("undoBtn", "click", () => this.undo());
384
- this._bindIfExists("redoBtn", "click", () => this.redo());
461
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
462
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
385
463
  this._bindIfExists("rotateLeftBtn", "click", () => {
386
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
464
+ const rotationInputElement = this._getElement("rotationLeftInput");
387
465
  let step = this.options.rotationStep;
388
466
  if (rotationInputElement) {
389
467
  const parsedStep = parseFloat(rotationInputElement.value);
390
468
  if (!isNaN(parsedStep)) step = parsedStep;
391
469
  }
392
- this.rotateImage(this.currentRotation - step);
470
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
393
471
  });
394
472
  this._bindIfExists("rotateRightBtn", "click", () => {
395
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
473
+ const rotationInputElement = this._getElement("rotationRightInput");
396
474
  let step = this.options.rotationStep;
397
475
  if (rotationInputElement) {
398
476
  const parsedStep = parseFloat(rotationInputElement.value);
399
477
  if (!isNaN(parsedStep)) step = parsedStep;
400
478
  }
401
- this.rotateImage(this.currentRotation + step);
479
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
402
480
  });
403
481
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
404
482
  this._bindIfExists("applyCropBtn", "click", () => {
405
483
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
406
484
  });
407
485
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
486
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
408
487
  }
409
488
  /**
410
489
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -415,7 +494,7 @@
415
494
  * @private
416
495
  */
417
496
  _bindIfExists(key, eventName, handler) {
418
- const element = document.getElementById(this.elements[key]);
497
+ const element = this._getElement(key);
419
498
  if (element) {
420
499
  element.addEventListener(eventName, handler);
421
500
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -427,16 +506,33 @@
427
506
  * Reads an image File as a data URL and loads it into the Fabric canvas.
428
507
  *
429
508
  * @param {File} file - Image file selected by the user.
509
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
430
510
  * @private
431
511
  */
432
512
  _loadImageFile(file) {
433
- if (!file || !file.type.startsWith("image/")) return;
434
- const reader = new FileReader();
435
- reader.onload = (event) => this.loadImage(event.target.result);
436
- reader.onerror = (event) => {
437
- this._reportError("Image file could not be read", event);
438
- };
439
- reader.readAsDataURL(file);
513
+ if (!this._isSupportedImageFile(file)) {
514
+ const error = new Error("Selected file is not a supported image");
515
+ this._reportError("Selected file is not a supported image", error);
516
+ return Promise.reject(error);
517
+ }
518
+ return new Promise((resolve, reject) => {
519
+ const reader = new FileReader();
520
+ reader.onload = (event) => {
521
+ this.loadImage(event.target.result).then(resolve).catch(reject);
522
+ };
523
+ reader.onerror = (event) => {
524
+ const error = new Error("Image file could not be read");
525
+ this._reportError("Image file could not be read", event);
526
+ reject(error);
527
+ };
528
+ reader.readAsDataURL(file);
529
+ });
530
+ }
531
+ _isSupportedImageFile(file) {
532
+ if (!file) return false;
533
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
534
+ const fileName = String(file.name || "");
535
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
440
536
  }
441
537
  /**
442
538
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -466,98 +562,102 @@
466
562
  */
467
563
  async loadImage(imageBase64, options = {}) {
468
564
  if (!this._fabricLoaded) return;
469
- if (!this.canvas) return;
565
+ if (!this.canvas || this._disposed) return;
470
566
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
567
+ this._assertIdleForOperation("loadImage", options);
568
+ this._isLoading = true;
569
+ this._updateUI();
471
570
  this._warnOnImageLayoutOptionConflict();
472
- this._setPlaceholderVisible(false);
473
- this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
474
- const imageElement = await this._createImageElement(imageBase64);
475
- let loadSource = imageBase64;
476
- if (this.options.downsampleOnLoad) {
477
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
478
- if (shouldResize) {
479
- const ratio = Math.min(
480
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
481
- this.options.downsampleMaxHeight / imageElement.naturalHeight
482
- );
483
- const targetWidth = Math.round(imageElement.naturalWidth * ratio);
484
- const targetHeight = Math.round(imageElement.naturalHeight * ratio);
485
- loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
571
+ const transaction = this._captureLoadImageTransaction();
572
+ try {
573
+ const imageElement = await this._createImageElement(imageBase64);
574
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
575
+ let loadSource = imageBase64;
576
+ if (this.options.downsampleOnLoad) {
577
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
578
+ if (shouldResize) {
579
+ const ratio = Math.min(
580
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
581
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
582
+ );
583
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
584
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
585
+ loadSource = this._resampleImageToDataURL(
586
+ imageElement,
587
+ targetWidth,
588
+ targetHeight,
589
+ this._normalizeQuality(this.options.downsampleQuality),
590
+ imageBase64
591
+ );
592
+ }
593
+ }
594
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
595
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
596
+ this.canvas.discardActiveObject();
597
+ this._hideAllMaskLabels();
598
+ this.canvas.clear();
599
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
600
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
601
+ this._setPlaceholderVisible(false);
602
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
603
+ const imageWidth = fabricImage.width;
604
+ const imageHeight = fabricImage.height;
605
+ const viewport = this._getContainerViewportSize();
606
+ const minWidth = viewport.width;
607
+ const minHeight = viewport.height;
608
+ if (this.options.fitImageToCanvas) {
609
+ const canvasWidth = Math.max(1, minWidth - 1);
610
+ const canvasHeight = Math.max(1, minHeight - 1);
611
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
612
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
613
+ fabricImage.set({ left: 0, top: 0 });
614
+ fabricImage.scale(fitScale);
615
+ this.baseImageScale = fabricImage.scaleX || 1;
616
+ } else if (this.options.coverImageToCanvas) {
617
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
618
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
619
+ fabricImage.set({ left: 0, top: 0 });
620
+ fabricImage.scale(layout.scale);
621
+ this.baseImageScale = fabricImage.scaleX || 1;
622
+ } else if (this.options.expandCanvasToImage) {
623
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
624
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
625
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
626
+ fabricImage.set({ left: 0, top: 0 });
627
+ fabricImage.scale(1);
628
+ this.baseImageScale = 1;
629
+ } else {
630
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
631
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
632
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
633
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
634
+ fabricImage.set({ left: 0, top: 0 });
635
+ fabricImage.scale(fitScale);
636
+ this.baseImageScale = fabricImage.scaleX || 1;
637
+ }
638
+ this.originalImage = fabricImage;
639
+ this.canvas.add(fabricImage);
640
+ this.canvas.sendToBack(fabricImage);
641
+ this._clearMaskPlacementMemory();
642
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
643
+ this.currentScale = 1;
644
+ this.currentRotation = 0;
645
+ this._updateInputs();
646
+ this._updateMaskList();
647
+ this.isImageLoadedToCanvas = true;
648
+ this._updateUI();
649
+ this.canvas.renderAll();
650
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
651
+ if (typeof this.onImageLoaded === "function") {
652
+ this.onImageLoaded();
486
653
  }
654
+ } catch (error) {
655
+ await this._rollbackLoadImageTransaction(transaction);
656
+ throw error;
657
+ } finally {
658
+ this._isLoading = false;
659
+ if (!this._disposed && this.canvas) this._updateUI();
487
660
  }
488
- return new Promise((resolve, reject) => {
489
- fabric.Image.fromURL(loadSource, (fabricImage) => {
490
- try {
491
- if (!fabricImage) throw new Error("Image could not be loaded");
492
- this.canvas.discardActiveObject();
493
- this._hideAllMaskLabels();
494
- this.canvas.clear();
495
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
496
- fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
497
- const imageWidth = fabricImage.width;
498
- const imageHeight = fabricImage.height;
499
- const viewport = this._getContainerViewportSize();
500
- const minWidth = viewport.width;
501
- const minHeight = viewport.height;
502
- if (this.options.fitImageToCanvas) {
503
- const canvasWidth = Math.max(1, minWidth - 1);
504
- const canvasHeight = Math.max(1, minHeight - 1);
505
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
506
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
507
- fabricImage.set({ left: 0, top: 0 });
508
- fabricImage.scale(fitScale);
509
- this.baseImageScale = fabricImage.scaleX || 1;
510
- } else if (this.options.coverImageToCanvas) {
511
- const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
512
- this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
513
- fabricImage.set({ left: 0, top: 0 });
514
- fabricImage.scale(layout.scale);
515
- this.baseImageScale = fabricImage.scaleX || 1;
516
- } else if (this.options.expandCanvasToImage) {
517
- const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
518
- const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
519
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
520
- fabricImage.set({ left: 0, top: 0 });
521
- fabricImage.scale(1);
522
- this.baseImageScale = 1;
523
- } else {
524
- const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
525
- const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
526
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
527
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
528
- fabricImage.set({ left: 0, top: 0 });
529
- fabricImage.scale(fitScale);
530
- this.baseImageScale = fabricImage.scaleX || 1;
531
- }
532
- this.originalImage = fabricImage;
533
- this.canvas.add(fabricImage);
534
- this.canvas.sendToBack(fabricImage);
535
- this._lastMask = null;
536
- this._lastMaskInitialLeft = null;
537
- this._lastMaskInitialTop = null;
538
- this._lastMaskInitialWidth = null;
539
- this.maskCounter = 0;
540
- this.currentScale = 1;
541
- this.currentRotation = 0;
542
- this._updateInputs();
543
- this._updateMaskList();
544
- this.isImageLoadedToCanvas = true;
545
- this._updateUI();
546
- this.canvas.renderAll();
547
- try {
548
- this._lastSnapshot = this._serializeCanvasState();
549
- } catch (error) {
550
- this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
551
- }
552
- if (typeof this.onImageLoaded === "function") {
553
- this.onImageLoaded();
554
- }
555
- resolve();
556
- } catch (error) {
557
- reject(error);
558
- }
559
- }, { crossOrigin: "anonymous" });
560
- });
561
661
  }
562
662
  /**
563
663
  * Checks whether there is a loaded image on the current canvas.
@@ -602,24 +702,155 @@
602
702
  imageElement.src = dataUrl;
603
703
  });
604
704
  }
705
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
706
+ return new Promise((resolve, reject) => {
707
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
708
+ let isSettled = false;
709
+ let timerId;
710
+ const settle = (callback) => {
711
+ if (isSettled) return;
712
+ isSettled = true;
713
+ clearTimeout(timerId);
714
+ callback();
715
+ };
716
+ timerId = setTimeout(() => {
717
+ settle(() => reject(new Error("Fabric image load timed out")));
718
+ }, safeTimeoutMs);
719
+ try {
720
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
721
+ settle(() => {
722
+ if (!fabricImage) {
723
+ reject(new Error("Image could not be loaded"));
724
+ return;
725
+ }
726
+ resolve(fabricImage);
727
+ });
728
+ }, { crossOrigin: "anonymous" });
729
+ } catch (error) {
730
+ settle(() => reject(error));
731
+ }
732
+ });
733
+ }
734
+ _getSafeTimeoutMs(timeoutMs) {
735
+ const safeTimeoutMs = Number(timeoutMs);
736
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
737
+ }
738
+ _captureLoadImageTransaction() {
739
+ return {
740
+ canvasState: this._serializeCanvasState(),
741
+ originalImage: this.originalImage,
742
+ baseImageScale: this.baseImageScale,
743
+ currentScale: this.currentScale,
744
+ currentRotation: this.currentRotation,
745
+ maskCounter: this.maskCounter,
746
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
747
+ lastSnapshot: this._lastSnapshot,
748
+ lastMask: this._lastMask,
749
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
750
+ lastMaskInitialTop: this._lastMaskInitialTop,
751
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
752
+ containerOverflow: this.containerElement && this.containerElement.style ? {
753
+ overflow: this.containerElement.style.overflow || "",
754
+ overflowX: this.containerElement.style.overflowX || "",
755
+ overflowY: this.containerElement.style.overflowY || ""
756
+ } : null,
757
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
758
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
759
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
760
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
761
+ };
762
+ }
763
+ async _rollbackLoadImageTransaction(transaction) {
764
+ if (!transaction || !this.canvas || this._disposed) return;
765
+ let didRestoreCanvasState = false;
766
+ try {
767
+ if (transaction.canvasState) {
768
+ await this.loadFromState(transaction.canvasState);
769
+ didRestoreCanvasState = true;
770
+ }
771
+ } catch (error) {
772
+ this._lastMask = null;
773
+ this._reportError("loadImage rollback failed", error);
774
+ }
775
+ this.baseImageScale = transaction.baseImageScale;
776
+ this.currentScale = transaction.currentScale;
777
+ this.currentRotation = transaction.currentRotation;
778
+ this.maskCounter = transaction.maskCounter;
779
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
780
+ this._lastSnapshot = transaction.lastSnapshot;
781
+ if (didRestoreCanvasState) {
782
+ this._restoreLastMaskReference(transaction.lastMask);
783
+ } else {
784
+ this._lastMask = null;
785
+ }
786
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
787
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
788
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
789
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
790
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
791
+ if (this.containerElement) {
792
+ this.containerElement.scrollLeft = transaction.scrollLeft;
793
+ this.containerElement.scrollTop = transaction.scrollTop;
794
+ this._restoreContainerOverflowSnapshot(transaction.containerOverflow);
795
+ }
796
+ this._updateInputs();
797
+ this._updateMaskList();
798
+ this._updateUI();
799
+ if (this.canvas) this.canvas.renderAll();
800
+ }
801
+ _restoreLastMaskReference(previousLastMask) {
802
+ if (!this.canvas) {
803
+ this._lastMask = null;
804
+ return;
805
+ }
806
+ const masks = this.canvas.getObjects().filter((object) => object.maskId);
807
+ const previousMaskId = previousLastMask && previousLastMask.maskId;
808
+ this._lastMask = masks.find((mask) => mask.maskId === previousMaskId) || masks[masks.length - 1] || null;
809
+ if (!this._lastMask) {
810
+ this._lastMaskInitialLeft = null;
811
+ this._lastMaskInitialTop = null;
812
+ this._lastMaskInitialWidth = null;
813
+ }
814
+ }
605
815
  /**
606
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
816
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
607
817
  *
608
818
  * @param {HTMLImageElement} imageElement - The image element to resample.
609
819
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
610
820
  * @param {number} targetHeight - Target height (in pixels) for the resampled image.
611
- * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
612
- * @returns {string} A data URL representing the resampled image as JPEG.
821
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
822
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
823
+ * @returns {string} A data URL representing the resampled image.
613
824
  * @private
614
825
  */
615
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
826
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
616
827
  const offscreenCanvas = document.createElement("canvas");
617
828
  offscreenCanvas.width = targetWidth;
618
829
  offscreenCanvas.height = targetHeight;
619
830
  const context = offscreenCanvas.getContext("2d");
620
831
  if (!context) throw new Error("2D canvas context is unavailable");
621
832
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
622
- return offscreenCanvas.toDataURL("image/jpeg", quality);
833
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
834
+ }
835
+ _getDataUrlMimeType(dataUrl) {
836
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
837
+ return match ? match[1].toLowerCase() : "";
838
+ }
839
+ _getDownsampleMimeType(sourceDataUrl) {
840
+ if (this.options.downsampleMimeType) {
841
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
842
+ return `image/${requestedFormat}`;
843
+ }
844
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
845
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
846
+ return sourceMimeType;
847
+ }
848
+ return "image/jpeg";
849
+ }
850
+ _captureCanvasStateOrThrow(context) {
851
+ const snapshot = this._serializeCanvasState();
852
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
853
+ return snapshot;
623
854
  }
624
855
  /**
625
856
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -638,7 +869,6 @@
638
869
  if (this.canvasElement) {
639
870
  this.canvasElement.style.width = integerWidth + "px";
640
871
  this.canvasElement.style.height = integerHeight + "px";
641
- this.canvasElement.style.maxWidth = "none";
642
872
  }
643
873
  }
644
874
  _ceilCanvasDimension(value) {
@@ -654,8 +884,13 @@
654
884
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
655
885
  };
656
886
  }
657
- let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
658
- let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
887
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
888
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
889
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
890
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
891
+ if (measuredWidth > 0 && measuredHeight > 0) {
892
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
893
+ }
659
894
  if (this._hasFixedContainerScrollbars()) {
660
895
  return { width, height };
661
896
  }
@@ -860,7 +1095,11 @@
860
1095
  maskStyleBackups.push(backup);
861
1096
  mask.set(stylePatch);
862
1097
  });
863
- return callback();
1098
+ const result = callback();
1099
+ if (result && typeof result.then === "function") {
1100
+ throw new Error("_withNormalizedMaskStyles callback must be synchronous");
1101
+ }
1102
+ return result;
864
1103
  } finally {
865
1104
  maskStyleBackups.forEach((backup) => {
866
1105
  try {
@@ -928,9 +1167,13 @@
928
1167
  * @returns {number} A finite quality value between 0 and 1.
929
1168
  * @private
930
1169
  */
931
- _normalizeQuality(quality) {
1170
+ _normalizeQuality(quality, fallback = void 0) {
1171
+ const fallbackQuality = fallback == null ? this.options.downsampleQuality : fallback;
1172
+ const numericFallback = fallbackQuality == null ? NaN : Number(fallbackQuality);
1173
+ const safeFallback = Number.isFinite(numericFallback) ? Math.max(0, Math.min(1, numericFallback)) : 0.92;
1174
+ if (quality == null) return safeFallback;
932
1175
  const numericQuality = Number(quality);
933
- if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1176
+ if (!Number.isFinite(numericQuality)) return safeFallback;
934
1177
  return Math.max(0, Math.min(1, numericQuality));
935
1178
  }
936
1179
  /**
@@ -981,67 +1224,66 @@
981
1224
  sourceHeight: Math.max(1, endY - sourceY)
982
1225
  };
983
1226
  }
984
- /**
985
- * Crops an image data URL to a source region using an offscreen canvas.
986
- *
987
- * @param {string} dataUrl - Source image data URL.
988
- * @param {number} sourceX - Source region x coordinate.
989
- * @param {number} sourceY - Source region y coordinate.
990
- * @param {number} sourceWidth - Source region width.
991
- * @param {number} sourceHeight - Source region height.
992
- * @param {number} multiplier - Export multiplier already applied to the source data URL.
993
- * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
994
- * @param {number} [quality=0.92] - Output image quality for lossy formats.
995
- * @returns {Promise<string>} Resolves with the cropped image data URL.
996
- * @private
997
- */
998
- async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
999
- return new Promise((resolve, reject) => {
1000
- const imageElement = new Image();
1001
- let isSettled = false;
1002
- const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1003
- const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1004
- let timerId;
1005
- const settle = (callback) => {
1006
- if (isSettled) return;
1007
- isSettled = true;
1008
- clearTimeout(timerId);
1009
- imageElement.onload = null;
1010
- imageElement.onerror = null;
1011
- callback();
1012
- };
1013
- timerId = setTimeout(() => {
1014
- settle(() => reject(new Error("Image crop load timed out")));
1015
- try {
1016
- imageElement.src = "";
1017
- } catch (error) {
1018
- void error;
1019
- }
1020
- }, safeTimeoutMs);
1021
- imageElement.onload = () => {
1022
- try {
1023
- const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1024
- const scaledSourceX = Math.round(sourceX * safeMultiplier);
1025
- const scaledSourceY = Math.round(sourceY * safeMultiplier);
1026
- const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1027
- const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1028
- const offscreenCanvas = document.createElement("canvas");
1029
- offscreenCanvas.width = scaledSourceWidth;
1030
- offscreenCanvas.height = scaledSourceHeight;
1031
- const context = offscreenCanvas.getContext("2d");
1032
- if (!context) throw new Error("2D canvas context is unavailable");
1033
- context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1034
- settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1035
- } catch (error) {
1036
- settle(() => reject(error));
1037
- }
1038
- };
1039
- imageElement.onerror = (error) => settle(() => reject(error));
1040
- imageElement.src = dataUrl;
1041
- });
1227
+ _hasFractionalCanvasEdge(value) {
1228
+ const numericValue = Number(value);
1229
+ if (!Number.isFinite(numericValue)) return false;
1230
+ return Math.abs(numericValue - Math.round(numericValue)) > 0.01;
1231
+ }
1232
+ _getPartialExportEdges(bounds) {
1233
+ if (!bounds) return null;
1234
+ const angle = Math.abs((Number(this.originalImage && this.originalImage.angle) || 0) % 90);
1235
+ const isAxisAligned = angle < 0.01 || Math.abs(angle - 90) < 0.01;
1236
+ if (!isAxisAligned) return null;
1237
+ return {
1238
+ left: this._hasFractionalCanvasEdge(bounds.left),
1239
+ top: this._hasFractionalCanvasEdge(bounds.top),
1240
+ right: this._hasFractionalCanvasEdge((Number(bounds.left) || 0) + (Number(bounds.width) || 0)),
1241
+ bottom: this._hasFractionalCanvasEdge((Number(bounds.top) || 0) + (Number(bounds.height) || 0))
1242
+ };
1243
+ }
1244
+ async _sealPartialTransparentEdges(dataUrl, edges) {
1245
+ if (!edges || !Object.values(edges).some(Boolean)) return dataUrl;
1246
+ const imageElement = await this._createImageElement(dataUrl);
1247
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1248
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1249
+ const offscreenCanvas = document.createElement("canvas");
1250
+ offscreenCanvas.width = width;
1251
+ offscreenCanvas.height = height;
1252
+ const context = offscreenCanvas.getContext("2d");
1253
+ if (!context) throw new Error("2D canvas context is unavailable");
1254
+ context.drawImage(imageElement, 0, 0, width, height);
1255
+ const imageData = context.getImageData(0, 0, width, height);
1256
+ const pixels = imageData.data;
1257
+ const sealPixel = (x, y, fallbackX, fallbackY) => {
1258
+ const index = (y * width + x) * 4;
1259
+ const fallbackIndex = (fallbackY * width + fallbackX) * 4;
1260
+ if (pixels[index + 3] === 0 && pixels[fallbackIndex + 3] > 0) {
1261
+ pixels[index] = pixels[fallbackIndex];
1262
+ pixels[index + 1] = pixels[fallbackIndex + 1];
1263
+ pixels[index + 2] = pixels[fallbackIndex + 2];
1264
+ pixels[index + 3] = pixels[fallbackIndex + 3];
1265
+ }
1266
+ if (pixels[index + 3] > 0 && pixels[index + 3] < 255) {
1267
+ pixels[index + 3] = 255;
1268
+ }
1269
+ };
1270
+ if (edges.left && width > 1) {
1271
+ for (let y = 0; y < height; y += 1) sealPixel(0, y, 1, y);
1272
+ }
1273
+ if (edges.right && width > 1) {
1274
+ for (let y = 0; y < height; y += 1) sealPixel(width - 1, y, width - 2, y);
1275
+ }
1276
+ if (edges.top && height > 1) {
1277
+ for (let x = 0; x < width; x += 1) sealPixel(x, 0, x, 1);
1278
+ }
1279
+ if (edges.bottom && height > 1) {
1280
+ for (let x = 0; x < width; x += 1) sealPixel(x, height - 1, x, height - 2);
1281
+ }
1282
+ context.putImageData(imageData, 0, 0);
1283
+ return offscreenCanvas.toDataURL("image/png");
1042
1284
  }
1043
1285
  /**
1044
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1286
+ * Exports a source region directly through Fabric's region export options.
1045
1287
  *
1046
1288
  * @param {Object} region - Canvas source region and export options.
1047
1289
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1051,17 +1293,46 @@
1051
1293
  * @param {number} [region.multiplier=1] - Export multiplier.
1052
1294
  * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1053
1295
  * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1296
+ * @param {Object|null} [region.sealPartialEdges=null] - Fractional canvas edges whose alpha should be sealed.
1054
1297
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1055
1298
  * @private
1056
1299
  */
1057
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1300
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg", sealPartialEdges = null }) {
1058
1301
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1059
- const fullDataUrl = this.canvas.toDataURL({
1060
- format,
1302
+ const safeFormat = this._normalizeImageFormat(format);
1303
+ const exportFormat = safeFormat === "jpeg" ? "png" : safeFormat;
1304
+ let regionDataUrl = this.canvas.toDataURL({
1305
+ format: exportFormat,
1061
1306
  quality,
1062
- multiplier: safeMultiplier
1307
+ multiplier: safeMultiplier,
1308
+ left: sourceX,
1309
+ top: sourceY,
1310
+ width: sourceWidth,
1311
+ height: sourceHeight
1063
1312
  });
1064
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1313
+ regionDataUrl = await this._sealPartialTransparentEdges(regionDataUrl, sealPartialEdges);
1314
+ if (safeFormat !== "jpeg") return regionDataUrl;
1315
+ return this._convertDataUrlToOpaqueJpeg(regionDataUrl, quality);
1316
+ }
1317
+ async _convertDataUrlToOpaqueJpeg(dataUrl, quality = 0.92) {
1318
+ const imageElement = await this._createImageElement(dataUrl);
1319
+ const width = Math.max(1, imageElement.naturalWidth || imageElement.width || 1);
1320
+ const height = Math.max(1, imageElement.naturalHeight || imageElement.height || 1);
1321
+ const offscreenCanvas = document.createElement("canvas");
1322
+ offscreenCanvas.width = width;
1323
+ offscreenCanvas.height = height;
1324
+ const context = offscreenCanvas.getContext("2d");
1325
+ if (!context) throw new Error("2D canvas context is unavailable");
1326
+ context.fillStyle = this._getJpegBackgroundColor();
1327
+ context.fillRect(0, 0, width, height);
1328
+ context.drawImage(imageElement, 0, 0, width, height);
1329
+ return offscreenCanvas.toDataURL("image/jpeg", this._normalizeQuality(quality));
1330
+ }
1331
+ _getJpegBackgroundColor() {
1332
+ const backgroundColor = String(this.options.backgroundColor || "").trim();
1333
+ if (!backgroundColor || backgroundColor === "transparent") return "#ffffff";
1334
+ if (/^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(backgroundColor)) return "#ffffff";
1335
+ return backgroundColor;
1065
1336
  }
1066
1337
  /**
1067
1338
  * Gets the top-left corner coordinates of the given object.
@@ -1074,11 +1345,37 @@
1074
1345
  _getObjectTopLeftPoint(fabricObject) {
1075
1346
  if (!fabricObject) return { x: 0, y: 0 };
1076
1347
  fabricObject.setCoords();
1077
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1078
- if (coords && coords.length) return coords[0];
1079
1348
  const boundingRect = fabricObject.getBoundingRect(true, true);
1080
1349
  return { x: boundingRect.left, y: boundingRect.top };
1081
1350
  }
1351
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1352
+ if (!fabricObject) return { x: 0, y: 0 };
1353
+ fabricObject.setCoords();
1354
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1355
+ if (coords && coords.length) return coords[0];
1356
+ return this._getObjectTopLeftPoint(fabricObject);
1357
+ }
1358
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1359
+ if (!fabricObject) return { x: 0, y: 0 };
1360
+ if (typeof fabricObject.getPointByOrigin === "function") {
1361
+ return fabricObject.getPointByOrigin(originX, originY);
1362
+ }
1363
+ return this._getObjectTopLeftPoint(fabricObject);
1364
+ }
1365
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1366
+ if (!fabricObject) return;
1367
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1368
+ const center = fabricObject.getCenterPoint();
1369
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1370
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1371
+ } else {
1372
+ fabricObject.set({
1373
+ left: (fabricObject.left || 0) + deltaX,
1374
+ top: (fabricObject.top || 0) + deltaY
1375
+ });
1376
+ }
1377
+ fabricObject.setCoords();
1378
+ }
1082
1379
  /**
1083
1380
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1084
1381
  *
@@ -1142,8 +1439,10 @@
1142
1439
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1143
1440
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1144
1441
  try {
1145
- let requiredWidth = this.canvas.getWidth();
1146
- let requiredHeight = this.canvas.getHeight();
1442
+ const currentWidth = this.canvas.getWidth();
1443
+ const currentHeight = this.canvas.getHeight();
1444
+ let requiredWidth = currentWidth;
1445
+ let requiredHeight = currentHeight;
1147
1446
  fabricObjects.forEach((fabricObject) => {
1148
1447
  if (!fabricObject) return;
1149
1448
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
@@ -1151,11 +1450,21 @@
1151
1450
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1152
1451
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1153
1452
  });
1154
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1155
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1156
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1157
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1158
- if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1453
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1454
+ let minWidth = 0;
1455
+ let minHeight = 0;
1456
+ if (shouldUseScrollSafeViewport) {
1457
+ const viewport = this._getContainerViewportSize();
1458
+ const safetyMargin = this._getScrollSafetyMargin();
1459
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1460
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1461
+ } else if (this.containerElement) {
1462
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1463
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1464
+ }
1465
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1466
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1467
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1159
1468
  this._setCanvasSizeInt(newWidth, newHeight);
1160
1469
  }
1161
1470
  } catch (error) {
@@ -1181,7 +1490,120 @@
1181
1490
  * @public
1182
1491
  */
1183
1492
  scaleImage(factor, options = {}) {
1184
- return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1493
+ try {
1494
+ this._assertCanQueueAnimation("scaleImage", options);
1495
+ } catch (error) {
1496
+ return Promise.reject(error);
1497
+ }
1498
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options)).finally(() => {
1499
+ if (!this._disposed && this.canvas) this._updateUI();
1500
+ });
1501
+ }
1502
+ _getInternalOperationToken(options) {
1503
+ return options && options[INTERNAL_OPERATION_TOKEN];
1504
+ }
1505
+ _isOwnInternalOperation(options) {
1506
+ const token = this._getInternalOperationToken(options);
1507
+ return !!token && token === this._activeOperationToken;
1508
+ }
1509
+ _beginBusyOperation(operationName) {
1510
+ const token = Symbol(operationName);
1511
+ this._activeOperationName = operationName;
1512
+ this._activeOperationToken = token;
1513
+ this._updateUI();
1514
+ return token;
1515
+ }
1516
+ _endBusyOperation(token) {
1517
+ if (token && token === this._activeOperationToken) {
1518
+ this._activeOperationName = null;
1519
+ this._activeOperationToken = null;
1520
+ this._updateUI();
1521
+ }
1522
+ }
1523
+ _withInternalOperationOptions(token, options = {}) {
1524
+ return {
1525
+ ...options,
1526
+ [INTERNAL_OPERATION_TOKEN]: token
1527
+ };
1528
+ }
1529
+ _assertEditorAvailable(operationName) {
1530
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1531
+ }
1532
+ _assertIdleForOperation(operationName, options = {}) {
1533
+ this._assertEditorAvailable(operationName);
1534
+ const isOwnInternalOperation = this._isOwnInternalOperation(options);
1535
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1536
+ throw new Error(`${operationName} cannot run while an animation is running`);
1537
+ }
1538
+ if (this._isLoading && !isOwnInternalOperation) {
1539
+ throw new Error(`${operationName} cannot run while an image is loading`);
1540
+ }
1541
+ if (this._activeOperationToken && !isOwnInternalOperation) {
1542
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1543
+ }
1544
+ }
1545
+ _assertCanQueueAnimation(operationName, options = {}) {
1546
+ this._assertEditorAvailable(operationName);
1547
+ if (this._isLoading && !this._isOwnInternalOperation(options)) {
1548
+ throw new Error(`${operationName} cannot run while an image is loading`);
1549
+ }
1550
+ if (this._activeOperationToken && !this._isOwnInternalOperation(options)) {
1551
+ throw new Error(`${operationName} cannot run while ${this._activeOperationName || "another operation"} is running`);
1552
+ }
1553
+ }
1554
+ _canMutateNow(operationName, options = {}) {
1555
+ try {
1556
+ this._assertIdleForOperation(operationName, options);
1557
+ return true;
1558
+ } catch (error) {
1559
+ this._reportError(`${operationName} blocked`, error);
1560
+ return false;
1561
+ }
1562
+ }
1563
+ _rejectActiveAnimations(reason) {
1564
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1565
+ this._activeAnimationRejectors.forEach((reject) => {
1566
+ try {
1567
+ reject(error);
1568
+ } catch (rejectError) {
1569
+ void rejectError;
1570
+ }
1571
+ });
1572
+ this._activeAnimationRejectors.clear();
1573
+ }
1574
+ _animateFabricProperty(fabricObject, property, value) {
1575
+ return new Promise((resolve, reject) => {
1576
+ if (this._disposed || !this.canvas || !fabricObject) {
1577
+ reject(new Error("Animation cannot start after editor disposal"));
1578
+ return;
1579
+ }
1580
+ let isSettled = false;
1581
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1582
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1583
+ let timerId;
1584
+ const settle = (callback) => {
1585
+ if (isSettled) return;
1586
+ isSettled = true;
1587
+ clearTimeout(timerId);
1588
+ this._activeAnimationRejectors.delete(reject);
1589
+ callback();
1590
+ };
1591
+ this._activeAnimationRejectors.add(reject);
1592
+ timerId = setTimeout(() => {
1593
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1594
+ }, timeoutMs);
1595
+ try {
1596
+ fabricObject.animate(property, value, {
1597
+ duration,
1598
+ onChange: () => {
1599
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1600
+ },
1601
+ onComplete: () => settle(resolve)
1602
+ });
1603
+ } catch (error) {
1604
+ settle(() => reject(error));
1605
+ }
1606
+ });
1185
1607
  }
1186
1608
  /**
1187
1609
  * Scales the original image by a given factor, with animation.
@@ -1190,32 +1612,25 @@
1190
1612
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1191
1613
  * @private
1192
1614
  */
1193
- _scaleImageImpl(factor, options = {}) {
1194
- if (!this.originalImage) return Promise.resolve();
1195
- if (this.isAnimating) return Promise.resolve();
1615
+ async _scaleImageImpl(factor, options = {}) {
1616
+ if (!this.originalImage || this._disposed) return;
1617
+ if (this.isAnimating) return;
1196
1618
  const saveHistory = options.saveHistory !== false;
1197
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1198
- this.currentScale = factor;
1199
- this.isAnimating = true;
1200
- this._updateUI();
1201
- const targetScale = this.baseImageScale * factor;
1202
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1203
- this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1204
- const scaleXAnimation = new Promise((resolve) => {
1205
- this.originalImage.animate("scaleX", targetScale, {
1206
- duration: this.options.animationDuration,
1207
- onChange: this.canvas.renderAll.bind(this.canvas),
1208
- onComplete: resolve
1209
- });
1210
- });
1211
- const scaleYAnimation = new Promise((resolve) => {
1212
- this.originalImage.animate("scaleY", targetScale, {
1213
- duration: this.options.animationDuration,
1214
- onChange: this.canvas.renderAll.bind(this.canvas),
1215
- onComplete: resolve
1216
- });
1217
- });
1218
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1619
+ let didStartAnimation = false;
1620
+ try {
1621
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1622
+ this.currentScale = factor;
1623
+ this.isAnimating = true;
1624
+ didStartAnimation = true;
1625
+ this._updateUI();
1626
+ const targetScale = this.baseImageScale * factor;
1627
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1628
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1629
+ await Promise.all([
1630
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1631
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1632
+ ]);
1633
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1219
1634
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1220
1635
  this.originalImage.setCoords();
1221
1636
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1225,14 +1640,15 @@
1225
1640
  this.canvas.getObjects().forEach((object) => {
1226
1641
  if (object.maskId) this._syncMaskLabel(object);
1227
1642
  });
1228
- this.isAnimating = false;
1229
1643
  this._updateInputs();
1230
- this._updateUI();
1231
1644
  if (saveHistory) this.saveState();
1232
- }).catch(() => {
1233
- this.isAnimating = false;
1234
- this._updateUI();
1235
- });
1645
+ } finally {
1646
+ if (didStartAnimation) {
1647
+ this.isAnimating = false;
1648
+ this._updateInputs();
1649
+ this._updateUI();
1650
+ }
1651
+ }
1236
1652
  }
1237
1653
  /**
1238
1654
  * Rotates the original image by a given number of degrees, with animation.
@@ -1242,7 +1658,14 @@
1242
1658
  * @public
1243
1659
  */
1244
1660
  rotateImage(degrees, options = {}) {
1245
- return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
1661
+ try {
1662
+ this._assertCanQueueAnimation("rotateImage", options);
1663
+ } catch (error) {
1664
+ return Promise.reject(error);
1665
+ }
1666
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options)).finally(() => {
1667
+ if (!this._disposed && this.canvas) this._updateUI();
1668
+ });
1246
1669
  }
1247
1670
  /**
1248
1671
  * Rotates the original image by a given number of degrees, with animation.
@@ -1251,43 +1674,50 @@
1251
1674
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1252
1675
  * @private
1253
1676
  */
1254
- _rotateImageImpl(degrees, options = {}) {
1255
- if (!this.originalImage) return Promise.resolve();
1256
- if (this.isAnimating) return Promise.resolve();
1257
- if (isNaN(degrees)) return Promise.resolve();
1677
+ async _rotateImageImpl(degrees, options = {}) {
1678
+ if (!this.originalImage || this._disposed) return;
1679
+ if (this.isAnimating) return;
1680
+ if (isNaN(degrees)) return;
1258
1681
  const saveHistory = options.saveHistory !== false;
1259
- this.currentRotation = degrees;
1260
- this.isAnimating = true;
1261
- this._updateUI();
1262
- const center = this.originalImage.getCenterPoint();
1263
- this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
1264
- const rotationAnimation = new Promise((resolve) => {
1265
- this.originalImage.animate("angle", degrees, {
1266
- duration: this.options.animationDuration,
1267
- onChange: this.canvas.renderAll.bind(this.canvas),
1268
- onComplete: resolve
1269
- });
1270
- });
1271
- return rotationAnimation.then(() => {
1682
+ const image = this.originalImage;
1683
+ const previousOriginX = image.originX || "left";
1684
+ const previousOriginY = image.originY || "top";
1685
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1686
+ let didStartAnimation = false;
1687
+ let didCompleteRotation = false;
1688
+ try {
1689
+ this.currentRotation = degrees;
1690
+ this.isAnimating = true;
1691
+ didStartAnimation = true;
1692
+ this._updateUI();
1693
+ const center = image.getCenterPoint();
1694
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1695
+ await this._animateFabricProperty(image, "angle", degrees);
1696
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1272
1697
  this.originalImage.set("angle", degrees);
1273
1698
  this.originalImage.setCoords();
1274
1699
  if (this._shouldResizeCanvasToContentBounds()) {
1275
1700
  this._updateCanvasSizeToImageBounds();
1276
1701
  }
1277
1702
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1278
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1703
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1279
1704
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1280
1705
  this.canvas.getObjects().forEach((object) => {
1281
1706
  if (object.maskId) this._syncMaskLabel(object);
1282
1707
  });
1283
- this.isAnimating = false;
1284
1708
  this._updateInputs();
1285
- this._updateUI();
1286
1709
  if (saveHistory) this.saveState();
1287
- }).catch(() => {
1288
- this.isAnimating = false;
1289
- this._updateUI();
1290
- });
1710
+ didCompleteRotation = true;
1711
+ } finally {
1712
+ if (!didCompleteRotation && !this._disposed && image) {
1713
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1714
+ }
1715
+ if (didStartAnimation) {
1716
+ this.isAnimating = false;
1717
+ this._updateInputs();
1718
+ this._updateUI();
1719
+ }
1720
+ }
1291
1721
  }
1292
1722
  /**
1293
1723
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1297,14 +1727,22 @@
1297
1727
  */
1298
1728
  resetImageTransform() {
1299
1729
  if (!this.originalImage) return Promise.resolve();
1730
+ try {
1731
+ this._assertCanQueueAnimation("resetImageTransform");
1732
+ } catch (error) {
1733
+ return Promise.reject(error);
1734
+ }
1300
1735
  return this.animationQueue.add(async () => {
1301
- const before = this._lastSnapshot || this._serializeCanvasState();
1736
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1302
1737
  await this._scaleImageImpl(1, { saveHistory: false });
1303
1738
  await this._rotateImageImpl(0, { saveHistory: false });
1304
- const after = this._serializeCanvasState();
1739
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1305
1740
  this._pushStateTransition(before, after);
1741
+ }).finally(() => {
1742
+ if (!this._disposed && this.canvas) this._updateUI();
1306
1743
  }).catch((error) => {
1307
1744
  this._reportError("resetImageTransform() failed", error);
1745
+ throw error;
1308
1746
  });
1309
1747
  }
1310
1748
  /**
@@ -1324,13 +1762,31 @@
1324
1762
  * @public
1325
1763
  */
1326
1764
  loadFromState(serializedState) {
1327
- if (!serializedState || !this.canvas) return Promise.resolve();
1328
- return new Promise((resolve) => {
1765
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1766
+ if (this._cropMode || this._cropRect) {
1767
+ this._removeCropRect();
1768
+ this._restoreCropObjectState();
1769
+ this._cropMode = false;
1770
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1771
+ this.canvas.selection = !!this._prevSelectionSetting;
1772
+ }
1773
+ this._prevSelectionSetting = void 0;
1774
+ }
1775
+ return new Promise((resolve, reject) => {
1329
1776
  try {
1330
1777
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1331
1778
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1332
- this.canvas.loadFromJSON(state, () => {
1779
+ this.canvas.loadFromJSON(state, async () => {
1333
1780
  try {
1781
+ if (this._disposed || !this.canvas) {
1782
+ reject(new Error("Editor was disposed while loading state"));
1783
+ return;
1784
+ }
1785
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1786
+ if (this._disposed || !this.canvas) {
1787
+ reject(new Error("Editor was disposed while loading state"));
1788
+ return;
1789
+ }
1334
1790
  this._hideAllMaskLabels();
1335
1791
  const canvasObjects = this.canvas.getObjects();
1336
1792
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1378,15 +1834,53 @@
1378
1834
  this._updatePlaceholderStatus();
1379
1835
  this._lastSnapshot = this._serializeCanvasState();
1380
1836
  this._updateUI();
1837
+ resolve();
1381
1838
  } catch (callbackError) {
1382
1839
  this._reportError("loadFromState() failed", callbackError);
1383
- } finally {
1384
- resolve();
1840
+ reject(callbackError);
1385
1841
  }
1386
1842
  });
1387
1843
  } catch (error) {
1388
1844
  this._reportError("loadFromState() failed", error);
1389
- resolve();
1845
+ reject(error);
1846
+ }
1847
+ });
1848
+ }
1849
+ async _waitForFabricImagesReady(canvasObjects) {
1850
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1851
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1852
+ typeof object.getElement === "function" ? object.getElement() : object._element
1853
+ )));
1854
+ }
1855
+ _waitForImageElementReady(imageElement) {
1856
+ if (!imageElement) return Promise.resolve();
1857
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1858
+ return new Promise((resolve, reject) => {
1859
+ let isSettled = false;
1860
+ const timerId = setTimeout(() => {
1861
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1862
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1863
+ const settle = (callback) => {
1864
+ if (isSettled) return;
1865
+ isSettled = true;
1866
+ clearTimeout(timerId);
1867
+ if (typeof imageElement.removeEventListener === "function") {
1868
+ imageElement.removeEventListener("load", handleLoad);
1869
+ imageElement.removeEventListener("error", handleError);
1870
+ } else {
1871
+ imageElement.onload = null;
1872
+ imageElement.onerror = null;
1873
+ }
1874
+ callback();
1875
+ };
1876
+ const handleLoad = () => settle(resolve);
1877
+ const handleError = (error) => settle(() => reject(error));
1878
+ if (typeof imageElement.addEventListener === "function") {
1879
+ imageElement.addEventListener("load", handleLoad, { once: true });
1880
+ imageElement.addEventListener("error", handleError, { once: true });
1881
+ } else {
1882
+ imageElement.onload = handleLoad;
1883
+ imageElement.onerror = handleError;
1390
1884
  }
1391
1885
  });
1392
1886
  }
@@ -1401,9 +1895,8 @@
1401
1895
  */
1402
1896
  saveState() {
1403
1897
  if (!this.canvas) return;
1404
- const activeObject = this.canvas.getActiveObject();
1405
1898
  try {
1406
- const after = this._serializeCanvasState();
1899
+ const after = this._captureCanvasStateOrThrow("saveState");
1407
1900
  const before = this._lastSnapshot || after;
1408
1901
  if (after === before) return;
1409
1902
  let executedOnce = false;
@@ -1422,9 +1915,6 @@
1422
1915
  } catch (error) {
1423
1916
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1424
1917
  } finally {
1425
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1426
- this._handleSelectionChanged([activeObject]);
1427
- }
1428
1918
  this._updateUI();
1429
1919
  }
1430
1920
  }
@@ -1440,7 +1930,10 @@
1440
1930
  * @private
1441
1931
  */
1442
1932
  _pushStateTransition(before, after) {
1443
- if (!before || !after) return;
1933
+ if (!before || !after) {
1934
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1935
+ return;
1936
+ }
1444
1937
  if (before === after) return;
1445
1938
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1446
1939
  const command = new Command(
@@ -1462,6 +1955,7 @@
1462
1955
  this._updateUI();
1463
1956
  }).catch((error) => {
1464
1957
  this._reportError("undo failed", error);
1958
+ throw error;
1465
1959
  });
1466
1960
  }
1467
1961
  /**
@@ -1475,6 +1969,7 @@
1475
1969
  this._updateUI();
1476
1970
  }).catch((error) => {
1477
1971
  this._reportError("redo failed", error);
1972
+ throw error;
1478
1973
  });
1479
1974
  }
1480
1975
  _rebindMaskEvents(mask) {
@@ -1496,22 +1991,17 @@
1496
1991
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1497
1992
  }
1498
1993
  if (Object.keys(metadata).length) mask.set(metadata);
1499
- const normalStyle = {
1500
- stroke: mask.originalStroke || "#ccc",
1501
- strokeWidth: mask.originalStrokeWidth,
1502
- opacity: mask.originalAlpha
1503
- };
1504
- const hoverStyle = {
1505
- stroke: "#ff5500",
1506
- strokeWidth: 2,
1507
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1508
- };
1509
1994
  const mouseover = () => {
1510
- mask.set(hoverStyle);
1995
+ const opacity = Number(mask.originalAlpha);
1996
+ mask.set({
1997
+ stroke: "#ff5500",
1998
+ strokeWidth: 2,
1999
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
2000
+ });
1511
2001
  if (mask.canvas) mask.canvas.requestRenderAll();
1512
2002
  };
1513
2003
  const mouseout = () => {
1514
- mask.set(normalStyle);
2004
+ mask.set(this._getMaskNormalStyle(mask));
1515
2005
  if (mask.canvas) mask.canvas.requestRenderAll();
1516
2006
  };
1517
2007
  mask.on("mouseover", mouseover);
@@ -1548,6 +2038,7 @@
1548
2038
  */
1549
2039
  createMask(config = {}) {
1550
2040
  if (!this.canvas) return null;
2041
+ if (!this._canMutateNow("createMask")) return null;
1551
2042
  const shapeType = config.shape || "rect";
1552
2043
  const maskConfig = {
1553
2044
  shape: shapeType,
@@ -1584,14 +2075,10 @@
1584
2075
  };
1585
2076
  if (maskConfig.left === void 0 && this._lastMask) {
1586
2077
  const previousMask = this._lastMask;
1587
- let previousMaskRight = previousMask.left;
1588
- if (previousMask.getScaledWidth) {
1589
- previousMaskRight += previousMask.getScaledWidth();
1590
- } else if (previousMask.width) {
1591
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1592
- }
1593
- left = Math.round(previousMaskRight + maskConfig.gap);
1594
- top = previousMask.top ?? firstOffset;
2078
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
2079
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
2080
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
2081
+ top = Math.round(previousBounds.top ?? firstOffset);
1595
2082
  } else {
1596
2083
  left = resolveValue(maskConfig.left, firstOffset, "width");
1597
2084
  top = resolveValue(maskConfig.top, firstOffset, "height");
@@ -1719,6 +2206,8 @@
1719
2206
  * The associated label is also removed. UI and mask list are updated.
1720
2207
  */
1721
2208
  removeSelectedMask() {
2209
+ if (!this.canvas) return;
2210
+ if (!this._canMutateNow("removeSelectedMask")) return;
1722
2211
  const activeObject = this.canvas.getActiveObject();
1723
2212
  const selectedMasks = this._getModifiedMasks(activeObject);
1724
2213
  if (!selectedMasks.length) return;
@@ -1744,6 +2233,8 @@
1744
2233
  * UI and internal mask placement memory are reset.
1745
2234
  */
1746
2235
  removeAllMasks(options = {}) {
2236
+ if (!this.canvas) return;
2237
+ if (!this._canMutateNow("removeAllMasks", options)) return;
1747
2238
  const saveHistory = options.saveHistory !== false;
1748
2239
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1749
2240
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1811,6 +2302,10 @@
1811
2302
  let textObject = null;
1812
2303
  if (this.options.label && typeof this.options.label.create === "function") {
1813
2304
  textObject = this.options.label.create(mask, fabric);
2305
+ if (!textObject || typeof textObject.set !== "function") {
2306
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2307
+ textObject = null;
2308
+ }
1814
2309
  }
1815
2310
  if (!textObject) {
1816
2311
  let labelText = mask.maskName;
@@ -1878,9 +2373,10 @@
1878
2373
  if (!mask) return;
1879
2374
  if (!this.options.maskLabelOnSelect) return;
1880
2375
  if (!mask.__label) return;
1881
- const coords = mask.getCoords ? mask.getCoords() : null;
1882
- if (!coords || coords.length < 4) return;
1883
- const tl = coords[0];
2376
+ if (typeof mask.setCoords === "function") mask.setCoords();
2377
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2378
+ if (!bounds) return;
2379
+ const tl = { x: bounds.left, y: bounds.top };
1884
2380
  const center = mask.getCenterPoint();
1885
2381
  const vx = center.x - tl.x;
1886
2382
  const vy = center.y - tl.y;
@@ -1958,7 +2454,7 @@
1958
2454
  * @private
1959
2455
  */
1960
2456
  _updateMaskList() {
1961
- const maskListElement = document.getElementById(this.elements.maskList);
2457
+ const maskListElement = this._getElement("maskList");
1962
2458
  if (!maskListElement) return;
1963
2459
  maskListElement.innerHTML = "";
1964
2460
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -1966,13 +2462,20 @@
1966
2462
  const listItemElement = document.createElement("li");
1967
2463
  listItemElement.className = "list-group-item mask-item";
1968
2464
  listItemElement.textContent = mask.maskName;
1969
- listItemElement.onclick = () => {
1970
- this.canvas.setActiveObject(mask);
1971
- this._handleSelectionChanged([mask]);
1972
- };
2465
+ listItemElement.dataset.maskId = String(mask.maskId);
1973
2466
  maskListElement.appendChild(listItemElement);
1974
2467
  });
1975
2468
  }
2469
+ _handleMaskListClick(event) {
2470
+ if (!this.canvas) return;
2471
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2472
+ if (!itemElement || !itemElement.dataset) return;
2473
+ const maskId = Number(itemElement.dataset.maskId);
2474
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2475
+ if (!mask) return;
2476
+ this.canvas.setActiveObject(mask);
2477
+ this._handleSelectionChanged([mask]);
2478
+ }
1976
2479
  /**
1977
2480
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
1978
2481
  *
@@ -1980,12 +2483,13 @@
1980
2483
  * @private
1981
2484
  */
1982
2485
  _updateMaskListSelection(selectedMask) {
1983
- const maskListElement = document.getElementById(this.elements.maskList);
2486
+ const maskListElement = this._getElement("maskList");
1984
2487
  if (!maskListElement) return;
1985
2488
  const maskItems = maskListElement.querySelectorAll(".mask-item");
1986
2489
  maskItems.forEach((item) => {
1987
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2490
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
1988
2491
  item.classList.toggle("active", isSelected);
2492
+ item.classList.toggle("selected", isSelected);
1989
2493
  });
1990
2494
  }
1991
2495
  /**
@@ -2000,19 +2504,36 @@
2000
2504
  */
2001
2505
  async mergeMasks() {
2002
2506
  if (!this.originalImage) return;
2507
+ this._assertIdleForOperation("mergeMasks");
2003
2508
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2004
2509
  if (!masks.length) return;
2510
+ const beforeJson = this._serializeCanvasState();
2511
+ const operationToken = this._beginBusyOperation("mergeMasks");
2005
2512
  this.canvas.discardActiveObject();
2006
2513
  this.canvas.renderAll();
2007
2514
  try {
2008
- const beforeJson = this._serializeCanvasState();
2009
- const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2010
- this.removeAllMasks({ saveHistory: false });
2011
- await this.loadImage(merged, { preserveScroll: true });
2515
+ const merged = await this.exportImageBase64(this._withInternalOperationOptions(operationToken, {
2516
+ exportImageArea: true,
2517
+ multiplier: this.options.exportMultiplier,
2518
+ fileType: "png"
2519
+ }));
2520
+ this.removeAllMasks(this._withInternalOperationOptions(operationToken, { saveHistory: false }));
2521
+ await this.loadImage(merged, this._withInternalOperationOptions(operationToken, {
2522
+ preserveScroll: true,
2523
+ resetMaskCounter: false
2524
+ }));
2012
2525
  const afterJson = this._serializeCanvasState();
2013
2526
  this._pushStateTransition(beforeJson, afterJson);
2014
2527
  } catch (error) {
2015
2528
  this._reportError("merge error", error);
2529
+ try {
2530
+ await this.loadFromState(beforeJson);
2531
+ } catch (restoreError) {
2532
+ this._reportError("mergeMasks rollback failed", restoreError);
2533
+ }
2534
+ throw error;
2535
+ } finally {
2536
+ this._endBusyOperation(operationToken);
2016
2537
  }
2017
2538
  }
2018
2539
  /**
@@ -2034,6 +2555,7 @@
2034
2555
  */
2035
2556
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2036
2557
  if (!this.originalImage) return;
2558
+ if (!this._canMutateNow("downloadImage")) return;
2037
2559
  const exportImageArea = this.options.exportImageAreaByDefault;
2038
2560
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2039
2561
  const link = document.createElement("a");
@@ -2062,6 +2584,7 @@
2062
2584
  */
2063
2585
  async exportImageBase64(options = {}) {
2064
2586
  if (!this.originalImage) throw new Error("No image loaded");
2587
+ this._assertIdleForOperation("exportImageBase64", options);
2065
2588
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2066
2589
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2067
2590
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2077,12 +2600,13 @@
2077
2600
  this.canvas.renderAll();
2078
2601
  this.originalImage.setCoords();
2079
2602
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2080
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2603
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2081
2604
  return await this._exportCanvasRegionToDataURL({
2082
2605
  ...exportRegion,
2083
2606
  multiplier,
2084
2607
  quality,
2085
- format
2608
+ format,
2609
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2086
2610
  });
2087
2611
  } finally {
2088
2612
  maskVisibilityBackups.forEach((backup) => {
@@ -2117,12 +2641,13 @@
2117
2641
  this.canvas.renderAll();
2118
2642
  this.originalImage.setCoords();
2119
2643
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2120
- const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2644
+ const exportRegion = this._getClampedCanvasRegion(imageBounds);
2121
2645
  finalBase64 = await this._exportCanvasRegionToDataURL({
2122
2646
  ...exportRegion,
2123
2647
  multiplier,
2124
2648
  quality,
2125
- format
2649
+ format,
2650
+ sealPartialEdges: this._getPartialExportEdges(imageBounds)
2126
2651
  });
2127
2652
  } finally {
2128
2653
  maskStyleBackups.forEach((backup) => {
@@ -2174,6 +2699,7 @@
2174
2699
  */
2175
2700
  async exportImageFile(options = {}) {
2176
2701
  if (!this.originalImage) throw new Error("No image loaded");
2702
+ this._assertIdleForOperation("exportImageFile");
2177
2703
  const {
2178
2704
  mergeMask = true,
2179
2705
  fileType = "jpeg",
@@ -2182,19 +2708,20 @@
2182
2708
  fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
2183
2709
  } = options;
2184
2710
  const safeFileType = this._normalizeImageFormat(fileType);
2711
+ const normalizedQuality = this._normalizeQuality(quality);
2185
2712
  let imageBase64;
2186
2713
  if (mergeMask) {
2187
2714
  imageBase64 = await this.exportImageBase64({
2188
2715
  exportImageArea: true,
2189
2716
  multiplier,
2190
- quality,
2717
+ quality: normalizedQuality,
2191
2718
  fileType: safeFileType
2192
2719
  });
2193
2720
  } else {
2194
2721
  imageBase64 = await this.exportImageBase64({
2195
2722
  exportImageArea: false,
2196
2723
  multiplier,
2197
- quality,
2724
+ quality: normalizedQuality,
2198
2725
  fileType: safeFileType
2199
2726
  });
2200
2727
  }
@@ -2209,8 +2736,9 @@
2209
2736
  offscreenCanvas.width = imageElement.width;
2210
2737
  offscreenCanvas.height = imageElement.height;
2211
2738
  const context = offscreenCanvas.getContext("2d");
2739
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2212
2740
  context.drawImage(imageElement, 0, 0);
2213
- const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2741
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, normalizedQuality);
2214
2742
  resolve(convertedDataUrl);
2215
2743
  } catch (error) {
2216
2744
  reject(error);
@@ -2276,7 +2804,9 @@
2276
2804
  if (this._cropHandlers && this._cropHandlers.length) {
2277
2805
  this._cropHandlers.forEach((targetHandlers) => {
2278
2806
  targetHandlers.handlers.forEach((handlerRecord) => {
2279
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2807
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2808
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2809
+ }
2280
2810
  });
2281
2811
  });
2282
2812
  }
@@ -2284,7 +2814,7 @@
2284
2814
  void error;
2285
2815
  }
2286
2816
  try {
2287
- this.canvas.remove(this._cropRect);
2817
+ if (this.canvas) this.canvas.remove(this._cropRect);
2288
2818
  } catch (error) {
2289
2819
  void error;
2290
2820
  }
@@ -2302,7 +2832,9 @@
2302
2832
  */
2303
2833
  enterCropMode() {
2304
2834
  if (!this.canvas || !this.originalImage || this._cropMode) return;
2835
+ if (!this._canMutateNow("enterCropMode")) return;
2305
2836
  if (!this.isImageLoaded()) return;
2837
+ this._removeCropRect();
2306
2838
  this._cropMode = true;
2307
2839
  this._prevSelectionSetting = this.canvas.selection;
2308
2840
  this.canvas.selection = false;
@@ -2418,6 +2950,7 @@
2418
2950
  */
2419
2951
  async applyCrop() {
2420
2952
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2953
+ this._assertIdleForOperation("applyCrop");
2421
2954
  this._cropRect.setCoords();
2422
2955
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2423
2956
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
@@ -2442,12 +2975,8 @@
2442
2975
  this._removeLabelForMask(mask);
2443
2976
  this.canvas.remove(mask);
2444
2977
  if (shouldPreserveMasks && intersectsCrop) {
2445
- mask.set({
2446
- left: (mask.left || 0) - cropRegion.sourceX,
2447
- top: (mask.top || 0) - cropRegion.sourceY,
2448
- visible: true
2449
- });
2450
- mask.setCoords();
2978
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2979
+ mask.set({ visible: true });
2451
2980
  preservedMasks.push(mask);
2452
2981
  }
2453
2982
  } catch (error) {
@@ -2478,7 +3007,7 @@
2478
3007
  return;
2479
3008
  }
2480
3009
  try {
2481
- await this.loadImage(croppedBase64);
3010
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2482
3011
  if (preservedMasks.length) {
2483
3012
  preservedMasks.forEach((mask) => {
2484
3013
  this._rebindMaskEvents(mask);
@@ -2496,7 +3025,7 @@
2496
3025
  }
2497
3026
  let afterJson;
2498
3027
  try {
2499
- afterJson = this._serializeCanvasState();
3028
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2500
3029
  } catch (error) {
2501
3030
  this._reportWarning("applyCrop: failed to serialize after state", error);
2502
3031
  afterJson = null;
@@ -2516,7 +3045,7 @@
2516
3045
  * @private
2517
3046
  */
2518
3047
  _updateInputs() {
2519
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
3048
+ const scaleInputElement = this._getElement("scaleRate");
2520
3049
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2521
3050
  }
2522
3051
  /**
@@ -2525,6 +3054,7 @@
2525
3054
  * @private
2526
3055
  */
2527
3056
  _updateUI() {
3057
+ if (!this.canvas) return;
2528
3058
  const hasImage = !!this.originalImage;
2529
3059
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2530
3060
  const hasMasks = masks.length > 0;
@@ -2534,9 +3064,10 @@
2534
3064
  const canUndo = this.historyManager?.canUndo();
2535
3065
  const canRedo = this.historyManager?.canRedo();
2536
3066
  const isInCropMode = !!this._cropMode;
3067
+ const isBusy = this.isAnimating || this._isLoading || !!this._activeOperationToken || !!(this.animationQueue && this.animationQueue.isBusy());
2537
3068
  if (isInCropMode) {
2538
3069
  for (const key of Object.keys(this.elements || {})) {
2539
- const element = document.getElementById(this.elements[key]);
3070
+ const element = this._getElement(key);
2540
3071
  if (!element) continue;
2541
3072
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2542
3073
  this._setDisabled(key, false);
@@ -2546,23 +3077,23 @@
2546
3077
  }
2547
3078
  return;
2548
3079
  }
2549
- this._setDisabled("zoomInBtn", !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2550
- this._setDisabled("zoomOutBtn", !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2551
- this._setDisabled("rotateLeftBtn", !hasImage || this.isAnimating);
2552
- this._setDisabled("rotateRightBtn", !hasImage || this.isAnimating);
2553
- this._setDisabled("addMaskBtn", !hasImage || this.isAnimating);
2554
- this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
2555
- this._setDisabled("removeAllMasksBtn", !hasMasks || this.isAnimating);
2556
- this._setDisabled("mergeBtn", !hasImage || !hasMasks || this.isAnimating);
2557
- this._setDisabled("downloadBtn", !hasImage || this.isAnimating);
2558
- this._setDisabled("resetBtn", !hasImage || isDefaultTransform || this.isAnimating);
2559
- this._setDisabled("undoBtn", !hasImage || this.isAnimating || !canUndo);
2560
- this._setDisabled("redoBtn", !hasImage || this.isAnimating || !canRedo);
2561
- this._setDisabled("cropBtn", !hasImage || this.isAnimating);
3080
+ this._setDisabled("zoomInBtn", !hasImage || isBusy || this.currentScale >= this.options.maxScale);
3081
+ this._setDisabled("zoomOutBtn", !hasImage || isBusy || this.currentScale <= this.options.minScale);
3082
+ this._setDisabled("rotateLeftBtn", !hasImage || isBusy);
3083
+ this._setDisabled("rotateRightBtn", !hasImage || isBusy);
3084
+ this._setDisabled("addMaskBtn", !hasImage || isBusy);
3085
+ this._setDisabled("removeMaskBtn", !hasSelectedMask || isBusy);
3086
+ this._setDisabled("removeAllMasksBtn", !hasMasks || isBusy);
3087
+ this._setDisabled("mergeBtn", !hasImage || !hasMasks || isBusy);
3088
+ this._setDisabled("downloadBtn", !hasImage || isBusy);
3089
+ this._setDisabled("resetBtn", !hasImage || isDefaultTransform || isBusy);
3090
+ this._setDisabled("undoBtn", !hasImage || isBusy || !canUndo);
3091
+ this._setDisabled("redoBtn", !hasImage || isBusy || !canRedo);
3092
+ this._setDisabled("cropBtn", !hasImage || isBusy);
2562
3093
  this._setDisabled("applyCropBtn", true);
2563
3094
  this._setDisabled("cancelCropBtn", true);
2564
- this._setDisabled("imageInput", this.isAnimating);
2565
- this._setDisabled("uploadArea", this.isAnimating);
3095
+ this._setDisabled("imageInput", isBusy);
3096
+ this._setDisabled("uploadArea", isBusy);
2566
3097
  }
2567
3098
  /**
2568
3099
  * Enables or disables a specific UI element (typically a button) by its key.
@@ -2572,18 +3103,22 @@
2572
3103
  * @private
2573
3104
  */
2574
3105
  _setDisabled(key, disabled) {
2575
- const element = document.getElementById(this.elements[key]);
3106
+ const element = this._getElement(key);
2576
3107
  if (!element) return;
2577
3108
  if ("disabled" in element) {
2578
3109
  element.disabled = !!disabled;
2579
3110
  return;
2580
3111
  }
3112
+ if (!this._elementOriginalPointerEvents) this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3113
+ if (!this._elementOriginalPointerEvents.has(key)) {
3114
+ this._elementOriginalPointerEvents.set(key, element.style.pointerEvents || "");
3115
+ }
2581
3116
  if (disabled) {
2582
3117
  element.setAttribute("aria-disabled", "true");
2583
3118
  element.style.pointerEvents = "none";
2584
3119
  } else {
2585
3120
  element.removeAttribute("aria-disabled");
2586
- element.style.pointerEvents = "";
3121
+ element.style.pointerEvents = this._elementOriginalPointerEvents.get(key) ?? "";
2587
3122
  }
2588
3123
  }
2589
3124
  _isElementDisabled(element) {
@@ -2606,9 +3141,18 @@
2606
3141
  * @private
2607
3142
  */
2608
3143
  _setPlaceholderVisible(show) {
2609
- if (!this.placeholderElement || !this.containerElement) return;
2610
- this._setElementVisible(this.placeholderElement, show);
2611
- this._setElementVisible(this.containerElement, !show);
3144
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
3145
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
3146
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
3147
+ this._setElementVisible(canvasVisibilityElement, !show);
3148
+ }
3149
+ }
3150
+ _getCanvasVisibilityElement() {
3151
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
3152
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
3153
+ return wrapperElement || this.canvasElement;
3154
+ }
3155
+ return this.containerElement || wrapperElement || this.canvasElement;
2612
3156
  }
2613
3157
  /**
2614
3158
  * Updates element visibility.
@@ -2620,9 +3164,34 @@
2620
3164
  */
2621
3165
  _setElementVisible(element, isVisible) {
2622
3166
  if (!element) return;
3167
+ this._rememberElementVisibility(element);
2623
3168
  element.hidden = !isVisible;
2624
3169
  element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2625
- if (isVisible && element.classList) element.classList.remove("d-none");
3170
+ if (element.classList) {
3171
+ element.classList.toggle("d-none", !isVisible);
3172
+ }
3173
+ }
3174
+ _rememberElementVisibility(element) {
3175
+ if (!element || this._visibilityStateByElement.has(element)) return;
3176
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3177
+ }
3178
+ _captureElementVisibility(element) {
3179
+ if (!element) return null;
3180
+ return {
3181
+ hidden: element.hidden,
3182
+ ariaHidden: element.getAttribute("aria-hidden"),
3183
+ className: element.className
3184
+ };
3185
+ }
3186
+ _restoreElementVisibility(element, state) {
3187
+ if (!element || !state) return;
3188
+ element.hidden = !!state.hidden;
3189
+ if (state.ariaHidden === null) {
3190
+ element.removeAttribute("aria-hidden");
3191
+ } else {
3192
+ element.setAttribute("aria-hidden", state.ariaHidden);
3193
+ }
3194
+ element.className = state.className || "";
2626
3195
  }
2627
3196
  /**
2628
3197
  * Cleans up and disposes of the canvas and related references.
@@ -2630,10 +3199,17 @@
2630
3199
  * @public
2631
3200
  */
2632
3201
  dispose() {
3202
+ this._disposed = true;
3203
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3204
+ if (this.animationQueue) {
3205
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3206
+ }
3207
+ this._isLoading = false;
3208
+ this._activeOperationName = null;
3209
+ this._activeOperationToken = null;
2633
3210
  try {
2634
- for (const key in this._handlersByElementKey || {}) {
2635
- const handlers = this._handlersByElementKey[key] || [];
2636
- const element = document.getElementById(this.elements[key]);
3211
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3212
+ const element = this._getElement(key);
2637
3213
  if (!element) continue;
2638
3214
  handlers.forEach((handlerRecord) => {
2639
3215
  try {
@@ -2654,9 +3230,28 @@
2654
3230
  }
2655
3231
  this._cropRect = null;
2656
3232
  }
2657
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3233
+ if (this.containerElement && this._containerOriginalOverflow) {
3234
+ try {
3235
+ this._restoreContainerOverflowState();
3236
+ } catch (error) {
3237
+ void error;
3238
+ }
3239
+ }
3240
+ if (this._visibilityStateByElement) {
3241
+ try {
3242
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3243
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3244
+ if (state) this._restoreElementVisibility(element, state);
3245
+ });
3246
+ } catch (error) {
3247
+ void error;
3248
+ }
3249
+ }
3250
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2658
3251
  try {
2659
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3252
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3253
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3254
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2660
3255
  } catch (error) {
2661
3256
  void error;
2662
3257
  }
@@ -2672,6 +3267,22 @@
2672
3267
  this.isImageLoadedToCanvas = false;
2673
3268
  }
2674
3269
  this._handlersByElementKey = {};
3270
+ this._elementCache = {};
3271
+ this._elementOriginalPointerEvents = /* @__PURE__ */ new Map();
3272
+ this._clearMaskPlacementMemory();
3273
+ this.originalImage = null;
3274
+ this.baseImageScale = 1;
3275
+ this.currentScale = 1;
3276
+ this.currentRotation = 0;
3277
+ this.isAnimating = false;
3278
+ this._isLoading = false;
3279
+ this._cropMode = false;
3280
+ this._cropRect = null;
3281
+ this._cropHandlers = [];
3282
+ this._cropPrevEvented = null;
3283
+ this._prevSelectionSetting = void 0;
3284
+ this._lastContainerViewportSize = null;
3285
+ this._initialized = false;
2675
3286
  }
2676
3287
  };
2677
3288
  var AnimationQueue = class {
@@ -2681,6 +3292,8 @@
2681
3292
  constructor() {
2682
3293
  this.animationTasks = [];
2683
3294
  this.isRunning = false;
3295
+ this.currentTask = null;
3296
+ this._generation = 0;
2684
3297
  }
2685
3298
  /**
2686
3299
  * Adds an animation function to the queue.
@@ -2690,12 +3303,30 @@
2690
3303
  */
2691
3304
  async add(animationFn) {
2692
3305
  return new Promise((resolve, reject) => {
2693
- this.animationTasks.push({ animationFn, resolve, reject });
3306
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2694
3307
  if (!this.isRunning) {
2695
3308
  this._drainQueue();
2696
3309
  }
2697
3310
  });
2698
3311
  }
3312
+ isBusy() {
3313
+ return this.isRunning || this.animationTasks.length > 0;
3314
+ }
3315
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3316
+ this._generation += 1;
3317
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3318
+ const tasks = [
3319
+ ...this.currentTask ? [this.currentTask] : [],
3320
+ ...this.animationTasks.splice(0)
3321
+ ];
3322
+ tasks.forEach((task) => {
3323
+ if (!task || task.isSettled) return;
3324
+ task.isSettled = true;
3325
+ task.reject(cancellationError);
3326
+ });
3327
+ this.isRunning = false;
3328
+ this.currentTask = null;
3329
+ }
2699
3330
  /**
2700
3331
  * Runs queued animation tasks sequentially until the queue is empty.
2701
3332
  *
@@ -2703,19 +3334,34 @@
2703
3334
  * @returns {Promise<void>}
2704
3335
  */
2705
3336
  async _drainQueue() {
2706
- if (this.animationTasks.length === 0) {
2707
- this.isRunning = false;
2708
- return;
2709
- }
3337
+ if (this.isRunning) return;
3338
+ const generation = this._generation;
2710
3339
  this.isRunning = true;
2711
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2712
3340
  try {
2713
- const result = await animationFn();
2714
- resolve(result);
2715
- } catch (error) {
2716
- reject(error);
3341
+ while (this.animationTasks.length > 0 && generation === this._generation) {
3342
+ const task = this.animationTasks.shift();
3343
+ this.currentTask = task;
3344
+ try {
3345
+ const result = await task.animationFn();
3346
+ if (generation === this._generation && !task.isSettled) {
3347
+ task.isSettled = true;
3348
+ task.resolve(result);
3349
+ }
3350
+ } catch (error) {
3351
+ if (generation === this._generation && !task.isSettled) {
3352
+ task.isSettled = true;
3353
+ task.reject(error);
3354
+ }
3355
+ } finally {
3356
+ if (generation === this._generation && this.currentTask === task) this.currentTask = null;
3357
+ }
3358
+ }
3359
+ } finally {
3360
+ if (generation === this._generation) {
3361
+ this.isRunning = false;
3362
+ this.currentTask = null;
3363
+ }
2717
3364
  }
2718
- await this._drainQueue();
2719
3365
  }
2720
3366
  };
2721
3367
  var Command = class {
@@ -2746,15 +3392,8 @@
2746
3392
  * @private
2747
3393
  */
2748
3394
  enqueue(task) {
2749
- const nextTask = this.pending.then(task, task);
2750
- let pendingAfterTask;
2751
- const resetPending = () => {
2752
- if (this.pending === pendingAfterTask) {
2753
- this.pending = Promise.resolve();
2754
- }
2755
- };
2756
- pendingAfterTask = nextTask.then(resetPending, resetPending);
2757
- this.pending = pendingAfterTask;
3395
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3396
+ this.pending = nextTask.catch(() => void 0);
2758
3397
  return nextTask;
2759
3398
  }
2760
3399
  /**
@@ -2765,8 +3404,14 @@
2765
3404
  * @returns {void}
2766
3405
  */
2767
3406
  execute(command) {
2768
- command.execute();
3407
+ const result = command.execute();
3408
+ if (result && typeof result.then === "function") {
3409
+ return Promise.resolve(result).then(() => {
3410
+ this.push(command);
3411
+ });
3412
+ }
2769
3413
  this.push(command);
3414
+ return result;
2770
3415
  }
2771
3416
  /**
2772
3417
  * Pushes an already-applied command onto the history stack.
@@ -2782,9 +3427,8 @@
2782
3427
  this.history.push(command);
2783
3428
  if (this.history.length > this.maxSize) {
2784
3429
  this.history.shift();
2785
- } else {
2786
- this.currentIndex++;
2787
3430
  }
3431
+ this.currentIndex = this.history.length - 1;
2788
3432
  }
2789
3433
  /**
2790
3434
  * Checks whether an undo operation is possible.