@bensitu/image-editor 1.3.1 → 1.4.0

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,7 +3,7 @@
3
3
  /**
4
4
  * @file image-editor.js
5
5
  * @module image-editor
6
- * @version 1.3.1
6
+ * @version 1.4.0
7
7
  * @author Ben Situ
8
8
  * @license MIT
9
9
  * @description Lightweight canvas-based image editor with masking/transform/export support.
@@ -70,6 +70,8 @@
70
70
  downsampleMaxWidth: 4e3,
71
71
  downsampleMaxHeight: 3e3,
72
72
  downsampleQuality: 0.92,
73
+ preserveSourceFormat: true,
74
+ downsampleMimeType: null,
73
75
  imageLoadTimeoutMs: 3e4,
74
76
  exportMultiplier: 1,
75
77
  exportImageAreaByDefault: true,
@@ -118,6 +120,7 @@
118
120
  this.isImageLoadedToCanvas = false;
119
121
  this.maxHistorySize = 50;
120
122
  this._handlersByElementKey = {};
123
+ this._elementCache = {};
121
124
  this._lastMask = null;
122
125
  this._lastMaskInitialLeft = null;
123
126
  this._lastMaskInitialTop = null;
@@ -128,8 +131,14 @@
128
131
  this._cropHandlers = [];
129
132
  this._cropPrevEvented = null;
130
133
  this._prevSelectionSetting = void 0;
131
- this._containerOriginalOverflow = void 0;
134
+ this._containerOriginalOverflow = null;
135
+ this._lastContainerViewportSize = null;
136
+ this._canvasElementOriginalStyle = null;
137
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
132
138
  this._scrollbarSizeCache = null;
139
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
140
+ this._disposed = false;
141
+ this._initialized = false;
133
142
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
134
143
  this.animationQueue = new AnimationQueue();
135
144
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -193,6 +202,16 @@
193
202
  */
194
203
  init(idMap = {}) {
195
204
  if (!this._fabricLoaded) return;
205
+ if (this._initialized || this.canvas) this.dispose();
206
+ this._disposed = false;
207
+ this._initialized = true;
208
+ this.animationQueue = new AnimationQueue();
209
+ this.historyManager = new HistoryManager(this.maxHistorySize);
210
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
211
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
212
+ this._containerOriginalOverflow = null;
213
+ this._lastContainerViewportSize = null;
214
+ this._canvasElementOriginalStyle = null;
196
215
  const defaults = {
197
216
  canvas: "fabricCanvas",
198
217
  canvasContainer: null,
@@ -220,6 +239,7 @@
220
239
  cancelCropBtn: "cancelCropBtn"
221
240
  };
222
241
  this.elements = { ...defaults, ...idMap };
242
+ this._elementCache = {};
223
243
  this._initCanvas();
224
244
  this._bindEvents();
225
245
  this._updateInputs();
@@ -254,16 +274,22 @@
254
274
  * @private
255
275
  */
256
276
  _initCanvas() {
257
- const canvasElement = document.getElementById(this.elements.canvas);
277
+ const canvasElement = this._getElement("canvas");
258
278
  if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
259
279
  this.canvasElement = canvasElement;
280
+ this._canvasElementOriginalStyle = {
281
+ display: canvasElement.style.display || "",
282
+ width: canvasElement.style.width || "",
283
+ height: canvasElement.style.height || "",
284
+ maxWidth: canvasElement.style.maxWidth || ""
285
+ };
260
286
  if (this.elements.canvasContainer) {
261
- const containerElement = document.getElementById(this.elements.canvasContainer);
287
+ const containerElement = this._getElement("canvasContainer");
262
288
  this.containerElement = containerElement || canvasElement.parentElement;
263
289
  } else {
264
290
  this.containerElement = canvasElement.parentElement;
265
291
  }
266
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
292
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
267
293
  let initialWidth = this.options.canvasWidth;
268
294
  let initialHeight = this.options.canvasHeight;
269
295
  if (this.containerElement) {
@@ -272,6 +298,10 @@
272
298
  if (containerWidth > 0 && containerHeight > 0) {
273
299
  initialWidth = containerWidth;
274
300
  initialHeight = containerHeight;
301
+ this._lastContainerViewportSize = {
302
+ width: containerWidth,
303
+ height: containerHeight
304
+ };
275
305
  }
276
306
  }
277
307
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -296,6 +326,23 @@
296
326
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
297
327
  this.canvasElement.style.display = "block";
298
328
  }
329
+ /**
330
+ * Returns a configured DOM element and caches lookups for hot UI paths.
331
+ *
332
+ * @param {string} key - Key in the configured element map.
333
+ * @returns {HTMLElement|null} The configured element, or null when missing.
334
+ * @private
335
+ */
336
+ _getElement(key) {
337
+ const id = this.elements && this.elements[key];
338
+ if (!id) return null;
339
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
340
+ return this._elementCache[key];
341
+ }
342
+ const element = document.getElementById(id);
343
+ if (this._elementCache) this._elementCache[key] = element || null;
344
+ return element || null;
345
+ }
299
346
  /**
300
347
  * Records a history entry after Fabric finishes modifying one or more masks.
301
348
  *
@@ -336,9 +383,7 @@
336
383
  */
337
384
  _syncContainerOverflow(options = {}) {
338
385
  if (!this.containerElement || !this.containerElement.style) return;
339
- if (this._containerOriginalOverflow === void 0) {
340
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
341
- }
386
+ this._captureContainerOverflowState();
342
387
  const shouldPreserveScroll = options.preserveScroll === true;
343
388
  if (this.options.coverImageToCanvas) {
344
389
  this.containerElement.style.overflow = "scroll";
@@ -353,58 +398,77 @@
353
398
  this.containerElement.scrollTop = 0;
354
399
  }
355
400
  } else {
356
- this.containerElement.style.overflow = this._containerOriginalOverflow;
401
+ this._restoreContainerOverflowState();
357
402
  }
358
403
  }
404
+ _captureContainerOverflowState() {
405
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
406
+ this._containerOriginalOverflow = {
407
+ overflow: this.containerElement.style.overflow || "",
408
+ overflowX: this.containerElement.style.overflowX || "",
409
+ overflowY: this.containerElement.style.overflowY || ""
410
+ };
411
+ }
412
+ _restoreContainerOverflowState() {
413
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
414
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
415
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
416
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
417
+ }
359
418
  /**
360
419
  * DOM / UI bindings
361
420
  * @private
362
421
  */
363
422
  _bindEvents() {
364
423
  this._bindIfExists("uploadArea", "click", () => {
365
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
424
+ const uploadAreaElement = this._getElement("uploadArea");
366
425
  if (this._isElementDisabled(uploadAreaElement)) return;
367
- document.getElementById(this.elements.imageInput)?.click();
426
+ this._getElement("imageInput")?.click();
368
427
  });
369
428
  this._bindIfExists("imageInput", "change", (event) => {
370
429
  const file = event.target.files && event.target.files[0];
371
- if (file) this._loadImageFile(file);
430
+ if (file) {
431
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
432
+ event.target.value = "";
433
+ });
434
+ }
372
435
  });
373
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
374
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
436
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
437
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
375
438
  this._bindIfExists("resetBtn", "click", () => {
376
- this.resetImageTransform();
439
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
377
440
  });
378
441
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
379
442
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
380
443
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
381
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
444
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
382
445
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
383
- this._bindIfExists("undoBtn", "click", () => this.undo());
384
- this._bindIfExists("redoBtn", "click", () => this.redo());
446
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
447
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
385
448
  this._bindIfExists("rotateLeftBtn", "click", () => {
386
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
449
+ const rotationInputElement = this._getElement("rotationLeftInput");
387
450
  let step = this.options.rotationStep;
388
451
  if (rotationInputElement) {
389
452
  const parsedStep = parseFloat(rotationInputElement.value);
390
453
  if (!isNaN(parsedStep)) step = parsedStep;
391
454
  }
392
- this.rotateImage(this.currentRotation - step);
455
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
393
456
  });
394
457
  this._bindIfExists("rotateRightBtn", "click", () => {
395
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
458
+ const rotationInputElement = this._getElement("rotationRightInput");
396
459
  let step = this.options.rotationStep;
397
460
  if (rotationInputElement) {
398
461
  const parsedStep = parseFloat(rotationInputElement.value);
399
462
  if (!isNaN(parsedStep)) step = parsedStep;
400
463
  }
401
- this.rotateImage(this.currentRotation + step);
464
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
402
465
  });
403
466
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
404
467
  this._bindIfExists("applyCropBtn", "click", () => {
405
468
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
406
469
  });
407
470
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
471
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
408
472
  }
409
473
  /**
410
474
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -415,7 +479,7 @@
415
479
  * @private
416
480
  */
417
481
  _bindIfExists(key, eventName, handler) {
418
- const element = document.getElementById(this.elements[key]);
482
+ const element = this._getElement(key);
419
483
  if (element) {
420
484
  element.addEventListener(eventName, handler);
421
485
  this._handlersByElementKey = this._handlersByElementKey || {};
@@ -427,16 +491,33 @@
427
491
  * Reads an image File as a data URL and loads it into the Fabric canvas.
428
492
  *
429
493
  * @param {File} file - Image file selected by the user.
494
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
430
495
  * @private
431
496
  */
432
497
  _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);
498
+ if (!this._isSupportedImageFile(file)) {
499
+ const error = new Error("Selected file is not a supported image");
500
+ this._reportError("Selected file is not a supported image", error);
501
+ return Promise.reject(error);
502
+ }
503
+ return new Promise((resolve, reject) => {
504
+ const reader = new FileReader();
505
+ reader.onload = (event) => {
506
+ this.loadImage(event.target.result).then(resolve).catch(reject);
507
+ };
508
+ reader.onerror = (event) => {
509
+ const error = new Error("Image file could not be read");
510
+ this._reportError("Image file could not be read", event);
511
+ reject(error);
512
+ };
513
+ reader.readAsDataURL(file);
514
+ });
515
+ }
516
+ _isSupportedImageFile(file) {
517
+ if (!file) return false;
518
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
519
+ const fileName = String(file.name || "");
520
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
440
521
  }
441
522
  /**
442
523
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -466,98 +547,97 @@
466
547
  */
467
548
  async loadImage(imageBase64, options = {}) {
468
549
  if (!this._fabricLoaded) return;
469
- if (!this.canvas) return;
550
+ if (!this.canvas || this._disposed) return;
470
551
  if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
552
+ this._assertIdleForOperation("loadImage");
471
553
  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);
554
+ const transaction = this._captureLoadImageTransaction();
555
+ try {
556
+ const imageElement = await this._createImageElement(imageBase64);
557
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
558
+ let loadSource = imageBase64;
559
+ if (this.options.downsampleOnLoad) {
560
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
561
+ if (shouldResize) {
562
+ const ratio = Math.min(
563
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
564
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
565
+ );
566
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
567
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
568
+ loadSource = this._resampleImageToDataURL(
569
+ imageElement,
570
+ targetWidth,
571
+ targetHeight,
572
+ this.options.downsampleQuality,
573
+ imageBase64
574
+ );
575
+ }
576
+ }
577
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
578
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
579
+ this.canvas.discardActiveObject();
580
+ this._hideAllMaskLabels();
581
+ this.canvas.clear();
582
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
583
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
584
+ this._setPlaceholderVisible(false);
585
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
586
+ const imageWidth = fabricImage.width;
587
+ const imageHeight = fabricImage.height;
588
+ const viewport = this._getContainerViewportSize();
589
+ const minWidth = viewport.width;
590
+ const minHeight = viewport.height;
591
+ if (this.options.fitImageToCanvas) {
592
+ const canvasWidth = Math.max(1, minWidth - 1);
593
+ const canvasHeight = Math.max(1, minHeight - 1);
594
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
595
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
596
+ fabricImage.set({ left: 0, top: 0 });
597
+ fabricImage.scale(fitScale);
598
+ this.baseImageScale = fabricImage.scaleX || 1;
599
+ } else if (this.options.coverImageToCanvas) {
600
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
601
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
602
+ fabricImage.set({ left: 0, top: 0 });
603
+ fabricImage.scale(layout.scale);
604
+ this.baseImageScale = fabricImage.scaleX || 1;
605
+ } else if (this.options.expandCanvasToImage) {
606
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
607
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
608
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
609
+ fabricImage.set({ left: 0, top: 0 });
610
+ fabricImage.scale(1);
611
+ this.baseImageScale = 1;
612
+ } else {
613
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
614
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
615
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
616
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
617
+ fabricImage.set({ left: 0, top: 0 });
618
+ fabricImage.scale(fitScale);
619
+ this.baseImageScale = fabricImage.scaleX || 1;
486
620
  }
621
+ this.originalImage = fabricImage;
622
+ this.canvas.add(fabricImage);
623
+ this.canvas.sendToBack(fabricImage);
624
+ this._clearMaskPlacementMemory();
625
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
626
+ this.currentScale = 1;
627
+ this.currentRotation = 0;
628
+ this._updateInputs();
629
+ this._updateMaskList();
630
+ this.isImageLoadedToCanvas = true;
631
+ this._updateUI();
632
+ this.canvas.renderAll();
633
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
634
+ if (typeof this.onImageLoaded === "function") {
635
+ this.onImageLoaded();
636
+ }
637
+ } catch (error) {
638
+ await this._rollbackLoadImageTransaction(transaction);
639
+ throw error;
487
640
  }
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
641
  }
562
642
  /**
563
643
  * Checks whether there is a loaded image on the current canvas.
@@ -602,24 +682,132 @@
602
682
  imageElement.src = dataUrl;
603
683
  });
604
684
  }
685
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
686
+ return new Promise((resolve, reject) => {
687
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
688
+ let isSettled = false;
689
+ let timerId;
690
+ const settle = (callback) => {
691
+ if (isSettled) return;
692
+ isSettled = true;
693
+ clearTimeout(timerId);
694
+ callback();
695
+ };
696
+ timerId = setTimeout(() => {
697
+ settle(() => reject(new Error("Fabric image load timed out")));
698
+ }, safeTimeoutMs);
699
+ try {
700
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
701
+ settle(() => {
702
+ if (!fabricImage) {
703
+ reject(new Error("Image could not be loaded"));
704
+ return;
705
+ }
706
+ resolve(fabricImage);
707
+ });
708
+ }, { crossOrigin: "anonymous" });
709
+ } catch (error) {
710
+ settle(() => reject(error));
711
+ }
712
+ });
713
+ }
714
+ _getSafeTimeoutMs(timeoutMs) {
715
+ const safeTimeoutMs = Number(timeoutMs);
716
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
717
+ }
718
+ _captureLoadImageTransaction() {
719
+ return {
720
+ canvasState: this._serializeCanvasState(),
721
+ originalImage: this.originalImage,
722
+ baseImageScale: this.baseImageScale,
723
+ currentScale: this.currentScale,
724
+ currentRotation: this.currentRotation,
725
+ maskCounter: this.maskCounter,
726
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
727
+ lastSnapshot: this._lastSnapshot,
728
+ lastMask: this._lastMask,
729
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
730
+ lastMaskInitialTop: this._lastMaskInitialTop,
731
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
732
+ containerOverflow: this.containerElement && this.containerElement.style ? {
733
+ overflow: this.containerElement.style.overflow || "",
734
+ overflowX: this.containerElement.style.overflowX || "",
735
+ overflowY: this.containerElement.style.overflowY || ""
736
+ } : null,
737
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
738
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
739
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
740
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
741
+ };
742
+ }
743
+ async _rollbackLoadImageTransaction(transaction) {
744
+ if (!transaction || !this.canvas || this._disposed) return;
745
+ try {
746
+ if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
747
+ } catch (error) {
748
+ this._reportError("loadImage rollback failed", error);
749
+ }
750
+ this.baseImageScale = transaction.baseImageScale;
751
+ this.currentScale = transaction.currentScale;
752
+ this.currentRotation = transaction.currentRotation;
753
+ this.maskCounter = transaction.maskCounter;
754
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
755
+ this._lastSnapshot = transaction.lastSnapshot;
756
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
757
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
758
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
759
+ this._containerOriginalOverflow = transaction.containerOverflow;
760
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
761
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
762
+ if (this.containerElement) {
763
+ this.containerElement.scrollLeft = transaction.scrollLeft;
764
+ this.containerElement.scrollTop = transaction.scrollTop;
765
+ this._restoreContainerOverflowState();
766
+ }
767
+ this._updateInputs();
768
+ this._updateMaskList();
769
+ this._updateUI();
770
+ if (this.canvas) this.canvas.renderAll();
771
+ }
605
772
  /**
606
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
773
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
607
774
  *
608
775
  * @param {HTMLImageElement} imageElement - The image element to resample.
609
776
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
610
777
  * @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.
778
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
779
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
780
+ * @returns {string} A data URL representing the resampled image.
613
781
  * @private
614
782
  */
615
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
783
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
616
784
  const offscreenCanvas = document.createElement("canvas");
617
785
  offscreenCanvas.width = targetWidth;
618
786
  offscreenCanvas.height = targetHeight;
619
787
  const context = offscreenCanvas.getContext("2d");
620
788
  if (!context) throw new Error("2D canvas context is unavailable");
621
789
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
622
- return offscreenCanvas.toDataURL("image/jpeg", quality);
790
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
791
+ }
792
+ _getDataUrlMimeType(dataUrl) {
793
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
794
+ return match ? match[1].toLowerCase() : "";
795
+ }
796
+ _getDownsampleMimeType(sourceDataUrl) {
797
+ if (this.options.downsampleMimeType) {
798
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
799
+ return `image/${requestedFormat}`;
800
+ }
801
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
802
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
803
+ return sourceMimeType;
804
+ }
805
+ return "image/jpeg";
806
+ }
807
+ _captureCanvasStateOrThrow(context) {
808
+ const snapshot = this._serializeCanvasState();
809
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
810
+ return snapshot;
623
811
  }
624
812
  /**
625
813
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -638,7 +826,6 @@
638
826
  if (this.canvasElement) {
639
827
  this.canvasElement.style.width = integerWidth + "px";
640
828
  this.canvasElement.style.height = integerHeight + "px";
641
- this.canvasElement.style.maxWidth = "none";
642
829
  }
643
830
  }
644
831
  _ceilCanvasDimension(value) {
@@ -654,8 +841,13 @@
654
841
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
655
842
  };
656
843
  }
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));
844
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
845
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
846
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
847
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
848
+ if (measuredWidth > 0 && measuredHeight > 0) {
849
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
850
+ }
659
851
  if (this._hasFixedContainerScrollbars()) {
660
852
  return { width, height };
661
853
  }
@@ -1041,7 +1233,7 @@
1041
1233
  });
1042
1234
  }
1043
1235
  /**
1044
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1236
+ * Exports a source region directly through Fabric's region export options.
1045
1237
  *
1046
1238
  * @param {Object} region - Canvas source region and export options.
1047
1239
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1054,14 +1246,17 @@
1054
1246
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1055
1247
  * @private
1056
1248
  */
1057
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1249
+ _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1058
1250
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1059
- const fullDataUrl = this.canvas.toDataURL({
1251
+ return this.canvas.toDataURL({
1060
1252
  format,
1061
1253
  quality,
1062
- multiplier: safeMultiplier
1254
+ multiplier: safeMultiplier,
1255
+ left: sourceX,
1256
+ top: sourceY,
1257
+ width: sourceWidth,
1258
+ height: sourceHeight
1063
1259
  });
1064
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1065
1260
  }
1066
1261
  /**
1067
1262
  * Gets the top-left corner coordinates of the given object.
@@ -1074,11 +1269,37 @@
1074
1269
  _getObjectTopLeftPoint(fabricObject) {
1075
1270
  if (!fabricObject) return { x: 0, y: 0 };
1076
1271
  fabricObject.setCoords();
1077
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1078
- if (coords && coords.length) return coords[0];
1079
1272
  const boundingRect = fabricObject.getBoundingRect(true, true);
1080
1273
  return { x: boundingRect.left, y: boundingRect.top };
1081
1274
  }
1275
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1276
+ if (!fabricObject) return { x: 0, y: 0 };
1277
+ fabricObject.setCoords();
1278
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1279
+ if (coords && coords.length) return coords[0];
1280
+ return this._getObjectTopLeftPoint(fabricObject);
1281
+ }
1282
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1283
+ if (!fabricObject) return { x: 0, y: 0 };
1284
+ if (typeof fabricObject.getPointByOrigin === "function") {
1285
+ return fabricObject.getPointByOrigin(originX, originY);
1286
+ }
1287
+ return this._getObjectTopLeftPoint(fabricObject);
1288
+ }
1289
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1290
+ if (!fabricObject) return;
1291
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1292
+ const center = fabricObject.getCenterPoint();
1293
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1294
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1295
+ } else {
1296
+ fabricObject.set({
1297
+ left: (fabricObject.left || 0) + deltaX,
1298
+ top: (fabricObject.top || 0) + deltaY
1299
+ });
1300
+ }
1301
+ fabricObject.setCoords();
1302
+ }
1082
1303
  /**
1083
1304
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1084
1305
  *
@@ -1142,8 +1363,10 @@
1142
1363
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1143
1364
  if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1144
1365
  try {
1145
- let requiredWidth = this.canvas.getWidth();
1146
- let requiredHeight = this.canvas.getHeight();
1366
+ const currentWidth = this.canvas.getWidth();
1367
+ const currentHeight = this.canvas.getHeight();
1368
+ let requiredWidth = currentWidth;
1369
+ let requiredHeight = currentHeight;
1147
1370
  fabricObjects.forEach((fabricObject) => {
1148
1371
  if (!fabricObject) return;
1149
1372
  if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
@@ -1151,11 +1374,21 @@
1151
1374
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1152
1375
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1153
1376
  });
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()) {
1377
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1378
+ let minWidth = 0;
1379
+ let minHeight = 0;
1380
+ if (shouldUseScrollSafeViewport) {
1381
+ const viewport = this._getContainerViewportSize();
1382
+ const safetyMargin = this._getScrollSafetyMargin();
1383
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1384
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1385
+ } else if (this.containerElement) {
1386
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1387
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1388
+ }
1389
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1390
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1391
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1159
1392
  this._setCanvasSizeInt(newWidth, newHeight);
1160
1393
  }
1161
1394
  } catch (error) {
@@ -1183,6 +1416,66 @@
1183
1416
  scaleImage(factor, options = {}) {
1184
1417
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1185
1418
  }
1419
+ _assertIdleForOperation(operationName) {
1420
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1421
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1422
+ throw new Error(`${operationName} cannot run while an animation is running`);
1423
+ }
1424
+ }
1425
+ _canMutateNow(operationName) {
1426
+ try {
1427
+ this._assertIdleForOperation(operationName);
1428
+ return true;
1429
+ } catch (error) {
1430
+ this._reportError(`${operationName} blocked`, error);
1431
+ return false;
1432
+ }
1433
+ }
1434
+ _rejectActiveAnimations(reason) {
1435
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1436
+ this._activeAnimationRejectors.forEach((reject) => {
1437
+ try {
1438
+ reject(error);
1439
+ } catch (rejectError) {
1440
+ void rejectError;
1441
+ }
1442
+ });
1443
+ this._activeAnimationRejectors.clear();
1444
+ }
1445
+ _animateFabricProperty(fabricObject, property, value) {
1446
+ return new Promise((resolve, reject) => {
1447
+ if (this._disposed || !this.canvas || !fabricObject) {
1448
+ reject(new Error("Animation cannot start after editor disposal"));
1449
+ return;
1450
+ }
1451
+ let isSettled = false;
1452
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1453
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1454
+ let timerId;
1455
+ const settle = (callback) => {
1456
+ if (isSettled) return;
1457
+ isSettled = true;
1458
+ clearTimeout(timerId);
1459
+ this._activeAnimationRejectors.delete(reject);
1460
+ callback();
1461
+ };
1462
+ this._activeAnimationRejectors.add(reject);
1463
+ timerId = setTimeout(() => {
1464
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1465
+ }, timeoutMs);
1466
+ try {
1467
+ fabricObject.animate(property, value, {
1468
+ duration,
1469
+ onChange: () => {
1470
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1471
+ },
1472
+ onComplete: () => settle(resolve)
1473
+ });
1474
+ } catch (error) {
1475
+ settle(() => reject(error));
1476
+ }
1477
+ });
1478
+ }
1186
1479
  /**
1187
1480
  * Scales the original image by a given factor, with animation.
1188
1481
  * Returns a promise that resolves when the scale animation is complete.
@@ -1190,32 +1483,25 @@
1190
1483
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1191
1484
  * @private
1192
1485
  */
1193
- _scaleImageImpl(factor, options = {}) {
1194
- if (!this.originalImage) return Promise.resolve();
1195
- if (this.isAnimating) return Promise.resolve();
1486
+ async _scaleImageImpl(factor, options = {}) {
1487
+ if (!this.originalImage || this._disposed) return;
1488
+ if (this.isAnimating) return;
1196
1489
  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(() => {
1490
+ let didStartAnimation = false;
1491
+ try {
1492
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1493
+ this.currentScale = factor;
1494
+ this.isAnimating = true;
1495
+ didStartAnimation = true;
1496
+ this._updateUI();
1497
+ const targetScale = this.baseImageScale * factor;
1498
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1499
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1500
+ await Promise.all([
1501
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1502
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1503
+ ]);
1504
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1219
1505
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1220
1506
  this.originalImage.setCoords();
1221
1507
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1225,14 +1511,15 @@
1225
1511
  this.canvas.getObjects().forEach((object) => {
1226
1512
  if (object.maskId) this._syncMaskLabel(object);
1227
1513
  });
1228
- this.isAnimating = false;
1229
1514
  this._updateInputs();
1230
- this._updateUI();
1231
1515
  if (saveHistory) this.saveState();
1232
- }).catch(() => {
1233
- this.isAnimating = false;
1234
- this._updateUI();
1235
- });
1516
+ } finally {
1517
+ if (didStartAnimation) {
1518
+ this.isAnimating = false;
1519
+ this._updateInputs();
1520
+ this._updateUI();
1521
+ }
1522
+ }
1236
1523
  }
1237
1524
  /**
1238
1525
  * Rotates the original image by a given number of degrees, with animation.
@@ -1251,43 +1538,50 @@
1251
1538
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1252
1539
  * @private
1253
1540
  */
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();
1541
+ async _rotateImageImpl(degrees, options = {}) {
1542
+ if (!this.originalImage || this._disposed) return;
1543
+ if (this.isAnimating) return;
1544
+ if (isNaN(degrees)) return;
1258
1545
  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(() => {
1546
+ const image = this.originalImage;
1547
+ const previousOriginX = image.originX || "left";
1548
+ const previousOriginY = image.originY || "top";
1549
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1550
+ let didStartAnimation = false;
1551
+ let didCompleteRotation = false;
1552
+ try {
1553
+ this.currentRotation = degrees;
1554
+ this.isAnimating = true;
1555
+ didStartAnimation = true;
1556
+ this._updateUI();
1557
+ const center = image.getCenterPoint();
1558
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1559
+ await this._animateFabricProperty(image, "angle", degrees);
1560
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1272
1561
  this.originalImage.set("angle", degrees);
1273
1562
  this.originalImage.setCoords();
1274
1563
  if (this._shouldResizeCanvasToContentBounds()) {
1275
1564
  this._updateCanvasSizeToImageBounds();
1276
1565
  }
1277
1566
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1278
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1567
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1279
1568
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1280
1569
  this.canvas.getObjects().forEach((object) => {
1281
1570
  if (object.maskId) this._syncMaskLabel(object);
1282
1571
  });
1283
- this.isAnimating = false;
1284
1572
  this._updateInputs();
1285
- this._updateUI();
1286
1573
  if (saveHistory) this.saveState();
1287
- }).catch(() => {
1288
- this.isAnimating = false;
1289
- this._updateUI();
1290
- });
1574
+ didCompleteRotation = true;
1575
+ } finally {
1576
+ if (!didCompleteRotation && !this._disposed && image) {
1577
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1578
+ }
1579
+ if (didStartAnimation) {
1580
+ this.isAnimating = false;
1581
+ this._updateInputs();
1582
+ this._updateUI();
1583
+ }
1584
+ }
1291
1585
  }
1292
1586
  /**
1293
1587
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1298,13 +1592,14 @@
1298
1592
  resetImageTransform() {
1299
1593
  if (!this.originalImage) return Promise.resolve();
1300
1594
  return this.animationQueue.add(async () => {
1301
- const before = this._lastSnapshot || this._serializeCanvasState();
1595
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1302
1596
  await this._scaleImageImpl(1, { saveHistory: false });
1303
1597
  await this._rotateImageImpl(0, { saveHistory: false });
1304
- const after = this._serializeCanvasState();
1598
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1305
1599
  this._pushStateTransition(before, after);
1306
1600
  }).catch((error) => {
1307
1601
  this._reportError("resetImageTransform() failed", error);
1602
+ throw error;
1308
1603
  });
1309
1604
  }
1310
1605
  /**
@@ -1324,13 +1619,31 @@
1324
1619
  * @public
1325
1620
  */
1326
1621
  loadFromState(serializedState) {
1327
- if (!serializedState || !this.canvas) return Promise.resolve();
1328
- return new Promise((resolve) => {
1622
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1623
+ if (this._cropMode || this._cropRect) {
1624
+ this._removeCropRect();
1625
+ this._restoreCropObjectState();
1626
+ this._cropMode = false;
1627
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1628
+ this.canvas.selection = !!this._prevSelectionSetting;
1629
+ }
1630
+ this._prevSelectionSetting = void 0;
1631
+ }
1632
+ return new Promise((resolve, reject) => {
1329
1633
  try {
1330
1634
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1331
1635
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1332
- this.canvas.loadFromJSON(state, () => {
1636
+ this.canvas.loadFromJSON(state, async () => {
1333
1637
  try {
1638
+ if (this._disposed || !this.canvas) {
1639
+ reject(new Error("Editor was disposed while loading state"));
1640
+ return;
1641
+ }
1642
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1643
+ if (this._disposed || !this.canvas) {
1644
+ reject(new Error("Editor was disposed while loading state"));
1645
+ return;
1646
+ }
1334
1647
  this._hideAllMaskLabels();
1335
1648
  const canvasObjects = this.canvas.getObjects();
1336
1649
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1378,18 +1691,44 @@
1378
1691
  this._updatePlaceholderStatus();
1379
1692
  this._lastSnapshot = this._serializeCanvasState();
1380
1693
  this._updateUI();
1694
+ resolve();
1381
1695
  } catch (callbackError) {
1382
1696
  this._reportError("loadFromState() failed", callbackError);
1383
- } finally {
1384
- resolve();
1697
+ reject(callbackError);
1385
1698
  }
1386
1699
  });
1387
1700
  } catch (error) {
1388
1701
  this._reportError("loadFromState() failed", error);
1389
- resolve();
1702
+ reject(error);
1390
1703
  }
1391
1704
  });
1392
1705
  }
1706
+ async _waitForFabricImagesReady(canvasObjects) {
1707
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1708
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1709
+ typeof object.getElement === "function" ? object.getElement() : object._element
1710
+ )));
1711
+ }
1712
+ _waitForImageElementReady(imageElement) {
1713
+ if (!imageElement) return Promise.resolve();
1714
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1715
+ return new Promise((resolve, reject) => {
1716
+ let isSettled = false;
1717
+ const timerId = setTimeout(() => {
1718
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1719
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1720
+ const settle = (callback) => {
1721
+ if (isSettled) return;
1722
+ isSettled = true;
1723
+ clearTimeout(timerId);
1724
+ imageElement.onload = null;
1725
+ imageElement.onerror = null;
1726
+ callback();
1727
+ };
1728
+ imageElement.onload = () => settle(resolve);
1729
+ imageElement.onerror = (error) => settle(() => reject(error));
1730
+ });
1731
+ }
1393
1732
  /**
1394
1733
  * Saves the current editable canvas state as an undoable history transition.
1395
1734
  *
@@ -1401,9 +1740,8 @@
1401
1740
  */
1402
1741
  saveState() {
1403
1742
  if (!this.canvas) return;
1404
- const activeObject = this.canvas.getActiveObject();
1405
1743
  try {
1406
- const after = this._serializeCanvasState();
1744
+ const after = this._captureCanvasStateOrThrow("saveState");
1407
1745
  const before = this._lastSnapshot || after;
1408
1746
  if (after === before) return;
1409
1747
  let executedOnce = false;
@@ -1422,9 +1760,6 @@
1422
1760
  } catch (error) {
1423
1761
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1424
1762
  } finally {
1425
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1426
- this._handleSelectionChanged([activeObject]);
1427
- }
1428
1763
  this._updateUI();
1429
1764
  }
1430
1765
  }
@@ -1440,7 +1775,10 @@
1440
1775
  * @private
1441
1776
  */
1442
1777
  _pushStateTransition(before, after) {
1443
- if (!before || !after) return;
1778
+ if (!before || !after) {
1779
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1780
+ return;
1781
+ }
1444
1782
  if (before === after) return;
1445
1783
  if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1446
1784
  const command = new Command(
@@ -1462,6 +1800,7 @@
1462
1800
  this._updateUI();
1463
1801
  }).catch((error) => {
1464
1802
  this._reportError("undo failed", error);
1803
+ throw error;
1465
1804
  });
1466
1805
  }
1467
1806
  /**
@@ -1475,6 +1814,7 @@
1475
1814
  this._updateUI();
1476
1815
  }).catch((error) => {
1477
1816
  this._reportError("redo failed", error);
1817
+ throw error;
1478
1818
  });
1479
1819
  }
1480
1820
  _rebindMaskEvents(mask) {
@@ -1496,22 +1836,17 @@
1496
1836
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1497
1837
  }
1498
1838
  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
1839
  const mouseover = () => {
1510
- mask.set(hoverStyle);
1840
+ const opacity = Number(mask.originalAlpha);
1841
+ mask.set({
1842
+ stroke: "#ff5500",
1843
+ strokeWidth: 2,
1844
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
1845
+ });
1511
1846
  if (mask.canvas) mask.canvas.requestRenderAll();
1512
1847
  };
1513
1848
  const mouseout = () => {
1514
- mask.set(normalStyle);
1849
+ mask.set(this._getMaskNormalStyle(mask));
1515
1850
  if (mask.canvas) mask.canvas.requestRenderAll();
1516
1851
  };
1517
1852
  mask.on("mouseover", mouseover);
@@ -1548,6 +1883,7 @@
1548
1883
  */
1549
1884
  createMask(config = {}) {
1550
1885
  if (!this.canvas) return null;
1886
+ if (!this._canMutateNow("createMask")) return null;
1551
1887
  const shapeType = config.shape || "rect";
1552
1888
  const maskConfig = {
1553
1889
  shape: shapeType,
@@ -1584,14 +1920,10 @@
1584
1920
  };
1585
1921
  if (maskConfig.left === void 0 && this._lastMask) {
1586
1922
  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;
1923
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
1924
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
1925
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
1926
+ top = Math.round(previousBounds.top ?? firstOffset);
1595
1927
  } else {
1596
1928
  left = resolveValue(maskConfig.left, firstOffset, "width");
1597
1929
  top = resolveValue(maskConfig.top, firstOffset, "height");
@@ -1719,6 +2051,8 @@
1719
2051
  * The associated label is also removed. UI and mask list are updated.
1720
2052
  */
1721
2053
  removeSelectedMask() {
2054
+ if (!this.canvas) return;
2055
+ if (!this._canMutateNow("removeSelectedMask")) return;
1722
2056
  const activeObject = this.canvas.getActiveObject();
1723
2057
  const selectedMasks = this._getModifiedMasks(activeObject);
1724
2058
  if (!selectedMasks.length) return;
@@ -1744,6 +2078,8 @@
1744
2078
  * UI and internal mask placement memory are reset.
1745
2079
  */
1746
2080
  removeAllMasks(options = {}) {
2081
+ if (!this.canvas) return;
2082
+ if (!this._canMutateNow("removeAllMasks")) return;
1747
2083
  const saveHistory = options.saveHistory !== false;
1748
2084
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1749
2085
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1811,6 +2147,10 @@
1811
2147
  let textObject = null;
1812
2148
  if (this.options.label && typeof this.options.label.create === "function") {
1813
2149
  textObject = this.options.label.create(mask, fabric);
2150
+ if (!textObject || typeof textObject.set !== "function") {
2151
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2152
+ textObject = null;
2153
+ }
1814
2154
  }
1815
2155
  if (!textObject) {
1816
2156
  let labelText = mask.maskName;
@@ -1878,9 +2218,10 @@
1878
2218
  if (!mask) return;
1879
2219
  if (!this.options.maskLabelOnSelect) return;
1880
2220
  if (!mask.__label) return;
1881
- const coords = mask.getCoords ? mask.getCoords() : null;
1882
- if (!coords || coords.length < 4) return;
1883
- const tl = coords[0];
2221
+ if (typeof mask.setCoords === "function") mask.setCoords();
2222
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2223
+ if (!bounds) return;
2224
+ const tl = { x: bounds.left, y: bounds.top };
1884
2225
  const center = mask.getCenterPoint();
1885
2226
  const vx = center.x - tl.x;
1886
2227
  const vy = center.y - tl.y;
@@ -1958,7 +2299,7 @@
1958
2299
  * @private
1959
2300
  */
1960
2301
  _updateMaskList() {
1961
- const maskListElement = document.getElementById(this.elements.maskList);
2302
+ const maskListElement = this._getElement("maskList");
1962
2303
  if (!maskListElement) return;
1963
2304
  maskListElement.innerHTML = "";
1964
2305
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
@@ -1966,13 +2307,20 @@
1966
2307
  const listItemElement = document.createElement("li");
1967
2308
  listItemElement.className = "list-group-item mask-item";
1968
2309
  listItemElement.textContent = mask.maskName;
1969
- listItemElement.onclick = () => {
1970
- this.canvas.setActiveObject(mask);
1971
- this._handleSelectionChanged([mask]);
1972
- };
2310
+ listItemElement.dataset.maskId = String(mask.maskId);
1973
2311
  maskListElement.appendChild(listItemElement);
1974
2312
  });
1975
2313
  }
2314
+ _handleMaskListClick(event) {
2315
+ if (!this.canvas) return;
2316
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2317
+ if (!itemElement || !itemElement.dataset) return;
2318
+ const maskId = Number(itemElement.dataset.maskId);
2319
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2320
+ if (!mask) return;
2321
+ this.canvas.setActiveObject(mask);
2322
+ this._handleSelectionChanged([mask]);
2323
+ }
1976
2324
  /**
1977
2325
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
1978
2326
  *
@@ -1980,12 +2328,13 @@
1980
2328
  * @private
1981
2329
  */
1982
2330
  _updateMaskListSelection(selectedMask) {
1983
- const maskListElement = document.getElementById(this.elements.maskList);
2331
+ const maskListElement = this._getElement("maskList");
1984
2332
  if (!maskListElement) return;
1985
2333
  const maskItems = maskListElement.querySelectorAll(".mask-item");
1986
2334
  maskItems.forEach((item) => {
1987
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2335
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
1988
2336
  item.classList.toggle("active", isSelected);
2337
+ item.classList.toggle("selected", isSelected);
1989
2338
  });
1990
2339
  }
1991
2340
  /**
@@ -2000,6 +2349,7 @@
2000
2349
  */
2001
2350
  async mergeMasks() {
2002
2351
  if (!this.originalImage) return;
2352
+ this._assertIdleForOperation("mergeMasks");
2003
2353
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2004
2354
  if (!masks.length) return;
2005
2355
  this.canvas.discardActiveObject();
@@ -2008,11 +2358,12 @@
2008
2358
  const beforeJson = this._serializeCanvasState();
2009
2359
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2010
2360
  this.removeAllMasks({ saveHistory: false });
2011
- await this.loadImage(merged, { preserveScroll: true });
2361
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2012
2362
  const afterJson = this._serializeCanvasState();
2013
2363
  this._pushStateTransition(beforeJson, afterJson);
2014
2364
  } catch (error) {
2015
2365
  this._reportError("merge error", error);
2366
+ throw error;
2016
2367
  }
2017
2368
  }
2018
2369
  /**
@@ -2034,6 +2385,7 @@
2034
2385
  */
2035
2386
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2036
2387
  if (!this.originalImage) return;
2388
+ if (!this._canMutateNow("downloadImage")) return;
2037
2389
  const exportImageArea = this.options.exportImageAreaByDefault;
2038
2390
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2039
2391
  const link = document.createElement("a");
@@ -2062,6 +2414,7 @@
2062
2414
  */
2063
2415
  async exportImageBase64(options = {}) {
2064
2416
  if (!this.originalImage) throw new Error("No image loaded");
2417
+ this._assertIdleForOperation("exportImageBase64");
2065
2418
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2066
2419
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2067
2420
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2078,7 +2431,7 @@
2078
2431
  this.originalImage.setCoords();
2079
2432
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2080
2433
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2081
- return await this._exportCanvasRegionToDataURL({
2434
+ return this._exportCanvasRegionToDataURL({
2082
2435
  ...exportRegion,
2083
2436
  multiplier,
2084
2437
  quality,
@@ -2118,7 +2471,7 @@
2118
2471
  this.originalImage.setCoords();
2119
2472
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2120
2473
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2121
- finalBase64 = await this._exportCanvasRegionToDataURL({
2474
+ finalBase64 = this._exportCanvasRegionToDataURL({
2122
2475
  ...exportRegion,
2123
2476
  multiplier,
2124
2477
  quality,
@@ -2174,6 +2527,7 @@
2174
2527
  */
2175
2528
  async exportImageFile(options = {}) {
2176
2529
  if (!this.originalImage) throw new Error("No image loaded");
2530
+ this._assertIdleForOperation("exportImageFile");
2177
2531
  const {
2178
2532
  mergeMask = true,
2179
2533
  fileType = "jpeg",
@@ -2209,6 +2563,7 @@
2209
2563
  offscreenCanvas.width = imageElement.width;
2210
2564
  offscreenCanvas.height = imageElement.height;
2211
2565
  const context = offscreenCanvas.getContext("2d");
2566
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2212
2567
  context.drawImage(imageElement, 0, 0);
2213
2568
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2214
2569
  resolve(convertedDataUrl);
@@ -2276,7 +2631,9 @@
2276
2631
  if (this._cropHandlers && this._cropHandlers.length) {
2277
2632
  this._cropHandlers.forEach((targetHandlers) => {
2278
2633
  targetHandlers.handlers.forEach((handlerRecord) => {
2279
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2634
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2635
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2636
+ }
2280
2637
  });
2281
2638
  });
2282
2639
  }
@@ -2284,7 +2641,7 @@
2284
2641
  void error;
2285
2642
  }
2286
2643
  try {
2287
- this.canvas.remove(this._cropRect);
2644
+ if (this.canvas) this.canvas.remove(this._cropRect);
2288
2645
  } catch (error) {
2289
2646
  void error;
2290
2647
  }
@@ -2302,7 +2659,9 @@
2302
2659
  */
2303
2660
  enterCropMode() {
2304
2661
  if (!this.canvas || !this.originalImage || this._cropMode) return;
2662
+ if (!this._canMutateNow("enterCropMode")) return;
2305
2663
  if (!this.isImageLoaded()) return;
2664
+ this._removeCropRect();
2306
2665
  this._cropMode = true;
2307
2666
  this._prevSelectionSetting = this.canvas.selection;
2308
2667
  this.canvas.selection = false;
@@ -2418,6 +2777,7 @@
2418
2777
  */
2419
2778
  async applyCrop() {
2420
2779
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
2780
+ this._assertIdleForOperation("applyCrop");
2421
2781
  this._cropRect.setCoords();
2422
2782
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2423
2783
  const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
@@ -2442,12 +2802,8 @@
2442
2802
  this._removeLabelForMask(mask);
2443
2803
  this.canvas.remove(mask);
2444
2804
  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();
2805
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2806
+ mask.set({ visible: true });
2451
2807
  preservedMasks.push(mask);
2452
2808
  }
2453
2809
  } catch (error) {
@@ -2478,7 +2834,7 @@
2478
2834
  return;
2479
2835
  }
2480
2836
  try {
2481
- await this.loadImage(croppedBase64);
2837
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2482
2838
  if (preservedMasks.length) {
2483
2839
  preservedMasks.forEach((mask) => {
2484
2840
  this._rebindMaskEvents(mask);
@@ -2496,7 +2852,7 @@
2496
2852
  }
2497
2853
  let afterJson;
2498
2854
  try {
2499
- afterJson = this._serializeCanvasState();
2855
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2500
2856
  } catch (error) {
2501
2857
  this._reportWarning("applyCrop: failed to serialize after state", error);
2502
2858
  afterJson = null;
@@ -2516,7 +2872,7 @@
2516
2872
  * @private
2517
2873
  */
2518
2874
  _updateInputs() {
2519
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
2875
+ const scaleInputElement = this._getElement("scaleRate");
2520
2876
  if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2521
2877
  }
2522
2878
  /**
@@ -2525,6 +2881,7 @@
2525
2881
  * @private
2526
2882
  */
2527
2883
  _updateUI() {
2884
+ if (!this.canvas) return;
2528
2885
  const hasImage = !!this.originalImage;
2529
2886
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2530
2887
  const hasMasks = masks.length > 0;
@@ -2536,7 +2893,7 @@
2536
2893
  const isInCropMode = !!this._cropMode;
2537
2894
  if (isInCropMode) {
2538
2895
  for (const key of Object.keys(this.elements || {})) {
2539
- const element = document.getElementById(this.elements[key]);
2896
+ const element = this._getElement(key);
2540
2897
  if (!element) continue;
2541
2898
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2542
2899
  this._setDisabled(key, false);
@@ -2572,7 +2929,7 @@
2572
2929
  * @private
2573
2930
  */
2574
2931
  _setDisabled(key, disabled) {
2575
- const element = document.getElementById(this.elements[key]);
2932
+ const element = this._getElement(key);
2576
2933
  if (!element) return;
2577
2934
  if ("disabled" in element) {
2578
2935
  element.disabled = !!disabled;
@@ -2606,9 +2963,18 @@
2606
2963
  * @private
2607
2964
  */
2608
2965
  _setPlaceholderVisible(show) {
2609
- if (!this.placeholderElement || !this.containerElement) return;
2610
- this._setElementVisible(this.placeholderElement, show);
2611
- this._setElementVisible(this.containerElement, !show);
2966
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
2967
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
2968
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
2969
+ this._setElementVisible(canvasVisibilityElement, !show);
2970
+ }
2971
+ }
2972
+ _getCanvasVisibilityElement() {
2973
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
2974
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
2975
+ return wrapperElement || this.canvasElement;
2976
+ }
2977
+ return this.containerElement || wrapperElement || this.canvasElement;
2612
2978
  }
2613
2979
  /**
2614
2980
  * Updates element visibility.
@@ -2620,9 +2986,34 @@
2620
2986
  */
2621
2987
  _setElementVisible(element, isVisible) {
2622
2988
  if (!element) return;
2989
+ this._rememberElementVisibility(element);
2623
2990
  element.hidden = !isVisible;
2624
2991
  element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2625
- if (isVisible && element.classList) element.classList.remove("d-none");
2992
+ if (element.classList) {
2993
+ element.classList.toggle("d-none", !isVisible);
2994
+ }
2995
+ }
2996
+ _rememberElementVisibility(element) {
2997
+ if (!element || this._visibilityStateByElement.has(element)) return;
2998
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
2999
+ }
3000
+ _captureElementVisibility(element) {
3001
+ if (!element) return null;
3002
+ return {
3003
+ hidden: element.hidden,
3004
+ ariaHidden: element.getAttribute("aria-hidden"),
3005
+ className: element.className
3006
+ };
3007
+ }
3008
+ _restoreElementVisibility(element, state) {
3009
+ if (!element || !state) return;
3010
+ element.hidden = !!state.hidden;
3011
+ if (state.ariaHidden === null) {
3012
+ element.removeAttribute("aria-hidden");
3013
+ } else {
3014
+ element.setAttribute("aria-hidden", state.ariaHidden);
3015
+ }
3016
+ element.className = state.className || "";
2626
3017
  }
2627
3018
  /**
2628
3019
  * Cleans up and disposes of the canvas and related references.
@@ -2630,10 +3021,14 @@
2630
3021
  * @public
2631
3022
  */
2632
3023
  dispose() {
3024
+ this._disposed = true;
3025
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3026
+ if (this.animationQueue) {
3027
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3028
+ }
2633
3029
  try {
2634
- for (const key in this._handlersByElementKey || {}) {
2635
- const handlers = this._handlersByElementKey[key] || [];
2636
- const element = document.getElementById(this.elements[key]);
3030
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3031
+ const element = this._getElement(key);
2637
3032
  if (!element) continue;
2638
3033
  handlers.forEach((handlerRecord) => {
2639
3034
  try {
@@ -2654,9 +3049,28 @@
2654
3049
  }
2655
3050
  this._cropRect = null;
2656
3051
  }
2657
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3052
+ if (this.containerElement && this._containerOriginalOverflow) {
3053
+ try {
3054
+ this._restoreContainerOverflowState();
3055
+ } catch (error) {
3056
+ void error;
3057
+ }
3058
+ }
3059
+ if (this._visibilityStateByElement) {
3060
+ try {
3061
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3062
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3063
+ if (state) this._restoreElementVisibility(element, state);
3064
+ });
3065
+ } catch (error) {
3066
+ void error;
3067
+ }
3068
+ }
3069
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2658
3070
  try {
2659
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3071
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3072
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3073
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2660
3074
  } catch (error) {
2661
3075
  void error;
2662
3076
  }
@@ -2672,6 +3086,19 @@
2672
3086
  this.isImageLoadedToCanvas = false;
2673
3087
  }
2674
3088
  this._handlersByElementKey = {};
3089
+ this._elementCache = {};
3090
+ this._clearMaskPlacementMemory();
3091
+ this.originalImage = null;
3092
+ this.baseImageScale = 1;
3093
+ this.currentScale = 1;
3094
+ this.currentRotation = 0;
3095
+ this.isAnimating = false;
3096
+ this._cropMode = false;
3097
+ this._cropRect = null;
3098
+ this._cropHandlers = [];
3099
+ this._cropPrevEvented = null;
3100
+ this._prevSelectionSetting = void 0;
3101
+ this._initialized = false;
2675
3102
  }
2676
3103
  };
2677
3104
  var AnimationQueue = class {
@@ -2681,6 +3108,7 @@
2681
3108
  constructor() {
2682
3109
  this.animationTasks = [];
2683
3110
  this.isRunning = false;
3111
+ this.currentTask = null;
2684
3112
  }
2685
3113
  /**
2686
3114
  * Adds an animation function to the queue.
@@ -2690,12 +3118,29 @@
2690
3118
  */
2691
3119
  async add(animationFn) {
2692
3120
  return new Promise((resolve, reject) => {
2693
- this.animationTasks.push({ animationFn, resolve, reject });
3121
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2694
3122
  if (!this.isRunning) {
2695
3123
  this._drainQueue();
2696
3124
  }
2697
3125
  });
2698
3126
  }
3127
+ isBusy() {
3128
+ return this.isRunning || this.animationTasks.length > 0;
3129
+ }
3130
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3131
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3132
+ const tasks = [
3133
+ ...this.currentTask ? [this.currentTask] : [],
3134
+ ...this.animationTasks.splice(0)
3135
+ ];
3136
+ tasks.forEach((task) => {
3137
+ if (!task || task.isSettled) return;
3138
+ task.isSettled = true;
3139
+ task.reject(cancellationError);
3140
+ });
3141
+ this.isRunning = false;
3142
+ this.currentTask = null;
3143
+ }
2699
3144
  /**
2700
3145
  * Runs queued animation tasks sequentially until the queue is empty.
2701
3146
  *
@@ -2703,19 +3148,27 @@
2703
3148
  * @returns {Promise<void>}
2704
3149
  */
2705
3150
  async _drainQueue() {
2706
- if (this.animationTasks.length === 0) {
2707
- this.isRunning = false;
2708
- return;
2709
- }
3151
+ if (this.isRunning) return;
2710
3152
  this.isRunning = true;
2711
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2712
- try {
2713
- const result = await animationFn();
2714
- resolve(result);
2715
- } catch (error) {
2716
- reject(error);
3153
+ while (this.animationTasks.length > 0) {
3154
+ const task = this.animationTasks.shift();
3155
+ this.currentTask = task;
3156
+ try {
3157
+ const result = await task.animationFn();
3158
+ if (!task.isSettled) {
3159
+ task.isSettled = true;
3160
+ task.resolve(result);
3161
+ }
3162
+ } catch (error) {
3163
+ if (!task.isSettled) {
3164
+ task.isSettled = true;
3165
+ task.reject(error);
3166
+ }
3167
+ } finally {
3168
+ if (this.currentTask === task) this.currentTask = null;
3169
+ }
2717
3170
  }
2718
- await this._drainQueue();
3171
+ this.isRunning = false;
2719
3172
  }
2720
3173
  };
2721
3174
  var Command = class {
@@ -2746,15 +3199,8 @@
2746
3199
  * @private
2747
3200
  */
2748
3201
  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;
3202
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3203
+ this.pending = nextTask.catch(() => void 0);
2758
3204
  return nextTask;
2759
3205
  }
2760
3206
  /**
@@ -2765,8 +3211,14 @@
2765
3211
  * @returns {void}
2766
3212
  */
2767
3213
  execute(command) {
2768
- command.execute();
3214
+ const result = command.execute();
3215
+ if (result && typeof result.then === "function") {
3216
+ return Promise.resolve(result).then(() => {
3217
+ this.push(command);
3218
+ });
3219
+ }
2769
3220
  this.push(command);
3221
+ return result;
2770
3222
  }
2771
3223
  /**
2772
3224
  * Pushes an already-applied command onto the history stack.
@@ -2782,9 +3234,8 @@
2782
3234
  this.history.push(command);
2783
3235
  if (this.history.length > this.maxSize) {
2784
3236
  this.history.shift();
2785
- } else {
2786
- this.currentIndex++;
2787
3237
  }
3238
+ this.currentIndex = this.history.length - 1;
2788
3239
  }
2789
3240
  /**
2790
3241
  * Checks whether an undo operation is possible.