@bensitu/image-editor 1.3.0 → 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.
@@ -5,19 +5,16 @@ import fabricModule from "fabric";
5
5
  /**
6
6
  * @file image-editor.js
7
7
  * @module image-editor
8
- * @version 1.3.0
8
+ * @version 1.4.0
9
9
  * @author Ben Situ
10
10
  * @license MIT
11
11
  * @description Lightweight canvas-based image editor with masking/transform/export support.
12
12
  */
13
13
  var fabric = null;
14
14
  function getGlobalScope() {
15
- if (typeof globalThis !== "undefined")
16
- return globalThis;
17
- if (typeof self !== "undefined")
18
- return self;
19
- if (typeof window !== "undefined")
20
- return window;
15
+ if (typeof globalThis !== "undefined") return globalThis;
16
+ if (typeof self !== "undefined") return self;
17
+ if (typeof window !== "undefined") return window;
21
18
  return null;
22
19
  }
23
20
  function getGlobalFabric() {
@@ -29,8 +26,7 @@ function setFabric(fabricInstance2) {
29
26
  return fabric;
30
27
  }
31
28
  function ensureFabric() {
32
- if (!fabric)
33
- setFabric();
29
+ if (!fabric) setFabric();
34
30
  return fabric;
35
31
  }
36
32
  var ImageEditor = class {
@@ -76,6 +72,8 @@ var ImageEditor = class {
76
72
  downsampleMaxWidth: 4e3,
77
73
  downsampleMaxHeight: 3e3,
78
74
  downsampleQuality: 0.92,
75
+ preserveSourceFormat: true,
76
+ downsampleMimeType: null,
79
77
  imageLoadTimeoutMs: 3e4,
80
78
  exportMultiplier: 1,
81
79
  exportImageAreaByDefault: true,
@@ -124,6 +122,7 @@ var ImageEditor = class {
124
122
  this.isImageLoadedToCanvas = false;
125
123
  this.maxHistorySize = 50;
126
124
  this._handlersByElementKey = {};
125
+ this._elementCache = {};
127
126
  this._lastMask = null;
128
127
  this._lastMaskInitialLeft = null;
129
128
  this._lastMaskInitialTop = null;
@@ -134,8 +133,14 @@ var ImageEditor = class {
134
133
  this._cropHandlers = [];
135
134
  this._cropPrevEvented = null;
136
135
  this._prevSelectionSetting = void 0;
137
- this._containerOriginalOverflow = void 0;
136
+ this._containerOriginalOverflow = null;
137
+ this._lastContainerViewportSize = null;
138
+ this._canvasElementOriginalStyle = null;
139
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
138
140
  this._scrollbarSizeCache = null;
141
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
142
+ this._disposed = false;
143
+ this._initialized = false;
139
144
  this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
140
145
  this.animationQueue = new AnimationQueue();
141
146
  this.historyManager = new HistoryManager(this.maxHistorySize);
@@ -198,8 +203,17 @@ var ImageEditor = class {
198
203
  * });
199
204
  */
200
205
  init(idMap = {}) {
201
- if (!this._fabricLoaded)
202
- return;
206
+ if (!this._fabricLoaded) return;
207
+ if (this._initialized || this.canvas) this.dispose();
208
+ this._disposed = false;
209
+ this._initialized = true;
210
+ this.animationQueue = new AnimationQueue();
211
+ this.historyManager = new HistoryManager(this.maxHistorySize);
212
+ this._visibilityStateByElement = /* @__PURE__ */ new WeakMap();
213
+ this._activeAnimationRejectors = /* @__PURE__ */ new Set();
214
+ this._containerOriginalOverflow = null;
215
+ this._lastContainerViewportSize = null;
216
+ this._canvasElementOriginalStyle = null;
203
217
  const defaults = {
204
218
  canvas: "fabricCanvas",
205
219
  canvasContainer: null,
@@ -227,6 +241,7 @@ var ImageEditor = class {
227
241
  cancelCropBtn: "cancelCropBtn"
228
242
  };
229
243
  this.elements = { ...defaults, ...idMap };
244
+ this._elementCache = {};
230
245
  this._initCanvas();
231
246
  this._bindEvents();
232
247
  this._updateInputs();
@@ -240,8 +255,7 @@ var ImageEditor = class {
240
255
  }
241
256
  _reportError(message, error = null) {
242
257
  const handler = this.options && this.options.onError;
243
- if (typeof handler !== "function")
244
- return;
258
+ if (typeof handler !== "function") return;
245
259
  try {
246
260
  handler(error, message);
247
261
  } catch {
@@ -249,8 +263,7 @@ var ImageEditor = class {
249
263
  }
250
264
  _reportWarning(message, error = null) {
251
265
  const handler = this.options && this.options.onWarning;
252
- if (typeof handler !== "function")
253
- return;
266
+ if (typeof handler !== "function") return;
254
267
  try {
255
268
  handler(error, message);
256
269
  } catch {
@@ -263,17 +276,22 @@ var ImageEditor = class {
263
276
  * @private
264
277
  */
265
278
  _initCanvas() {
266
- const canvasElement = document.getElementById(this.elements.canvas);
267
- if (!canvasElement)
268
- throw new Error("Canvas is not found: " + this.elements.canvas);
279
+ const canvasElement = this._getElement("canvas");
280
+ if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
269
281
  this.canvasElement = canvasElement;
282
+ this._canvasElementOriginalStyle = {
283
+ display: canvasElement.style.display || "",
284
+ width: canvasElement.style.width || "",
285
+ height: canvasElement.style.height || "",
286
+ maxWidth: canvasElement.style.maxWidth || ""
287
+ };
270
288
  if (this.elements.canvasContainer) {
271
- const containerElement = document.getElementById(this.elements.canvasContainer);
289
+ const containerElement = this._getElement("canvasContainer");
272
290
  this.containerElement = containerElement || canvasElement.parentElement;
273
291
  } else {
274
292
  this.containerElement = canvasElement.parentElement;
275
293
  }
276
- this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
294
+ this.placeholderElement = this._getElement("imgPlaceholder") || null;
277
295
  let initialWidth = this.options.canvasWidth;
278
296
  let initialHeight = this.options.canvasHeight;
279
297
  if (this.containerElement) {
@@ -282,6 +300,10 @@ var ImageEditor = class {
282
300
  if (containerWidth > 0 && containerHeight > 0) {
283
301
  initialWidth = containerWidth;
284
302
  initialHeight = containerHeight;
303
+ this._lastContainerViewportSize = {
304
+ width: containerWidth,
305
+ height: containerHeight
306
+ };
285
307
  }
286
308
  }
287
309
  this.canvas = new fabric.Canvas(canvasElement, {
@@ -295,20 +317,34 @@ var ImageEditor = class {
295
317
  this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
296
318
  this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
297
319
  this.canvas.on("object:moving", (event) => {
298
- if (event.target && event.target.maskId)
299
- this._syncMaskLabel(event.target);
320
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
300
321
  });
301
322
  this.canvas.on("object:scaling", (event) => {
302
- if (event.target && event.target.maskId)
303
- this._syncMaskLabel(event.target);
323
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
304
324
  });
305
325
  this.canvas.on("object:rotating", (event) => {
306
- if (event.target && event.target.maskId)
307
- this._syncMaskLabel(event.target);
326
+ if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
308
327
  });
309
328
  this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
310
329
  this.canvasElement.style.display = "block";
311
330
  }
331
+ /**
332
+ * Returns a configured DOM element and caches lookups for hot UI paths.
333
+ *
334
+ * @param {string} key - Key in the configured element map.
335
+ * @returns {HTMLElement|null} The configured element, or null when missing.
336
+ * @private
337
+ */
338
+ _getElement(key) {
339
+ const id = this.elements && this.elements[key];
340
+ if (!id) return null;
341
+ if (this._elementCache && Object.prototype.hasOwnProperty.call(this._elementCache, key)) {
342
+ return this._elementCache[key];
343
+ }
344
+ const element = document.getElementById(id);
345
+ if (this._elementCache) this._elementCache[key] = element || null;
346
+ return element || null;
347
+ }
312
348
  /**
313
349
  * Records a history entry after Fabric finishes modifying one or more masks.
314
350
  *
@@ -318,11 +354,9 @@ var ImageEditor = class {
318
354
  */
319
355
  _handleObjectModified(target) {
320
356
  const masks = this._getModifiedMasks(target);
321
- if (!masks.length)
322
- return;
357
+ if (!masks.length) return;
323
358
  masks.forEach((mask) => {
324
- if (typeof mask.setCoords === "function")
325
- mask.setCoords();
359
+ if (typeof mask.setCoords === "function") mask.setCoords();
326
360
  this._syncMaskLabel(mask);
327
361
  });
328
362
  this._expandCanvasToFitObjects(masks);
@@ -336,10 +370,8 @@ var ImageEditor = class {
336
370
  * @private
337
371
  */
338
372
  _getModifiedMasks(target) {
339
- if (!target)
340
- return [];
341
- if (target.maskId)
342
- return [target];
373
+ if (!target) return [];
374
+ if (target.maskId) return [target];
343
375
  const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
344
376
  return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
345
377
  }
@@ -352,11 +384,8 @@ var ImageEditor = class {
352
384
  * @private
353
385
  */
354
386
  _syncContainerOverflow(options = {}) {
355
- if (!this.containerElement || !this.containerElement.style)
356
- return;
357
- if (this._containerOriginalOverflow === void 0) {
358
- this._containerOriginalOverflow = this.containerElement.style.overflow || "";
359
- }
387
+ if (!this.containerElement || !this.containerElement.style) return;
388
+ this._captureContainerOverflowState();
360
389
  const shouldPreserveScroll = options.preserveScroll === true;
361
390
  if (this.options.coverImageToCanvas) {
362
391
  this.containerElement.style.overflow = "scroll";
@@ -371,62 +400,77 @@ var ImageEditor = class {
371
400
  this.containerElement.scrollTop = 0;
372
401
  }
373
402
  } else {
374
- this.containerElement.style.overflow = this._containerOriginalOverflow;
403
+ this._restoreContainerOverflowState();
375
404
  }
376
405
  }
406
+ _captureContainerOverflowState() {
407
+ if (!this.containerElement || !this.containerElement.style || this._containerOriginalOverflow) return;
408
+ this._containerOriginalOverflow = {
409
+ overflow: this.containerElement.style.overflow || "",
410
+ overflowX: this.containerElement.style.overflowX || "",
411
+ overflowY: this.containerElement.style.overflowY || ""
412
+ };
413
+ }
414
+ _restoreContainerOverflowState() {
415
+ if (!this.containerElement || !this.containerElement.style || !this._containerOriginalOverflow) return;
416
+ this.containerElement.style.overflow = this._containerOriginalOverflow.overflow;
417
+ this.containerElement.style.overflowX = this._containerOriginalOverflow.overflowX;
418
+ this.containerElement.style.overflowY = this._containerOriginalOverflow.overflowY;
419
+ }
377
420
  /**
378
421
  * DOM / UI bindings
379
422
  * @private
380
423
  */
381
424
  _bindEvents() {
382
425
  this._bindIfExists("uploadArea", "click", () => {
383
- const uploadAreaElement = document.getElementById(this.elements.uploadArea);
384
- if (this._isElementDisabled(uploadAreaElement))
385
- return;
386
- document.getElementById(this.elements.imageInput)?.click();
426
+ const uploadAreaElement = this._getElement("uploadArea");
427
+ if (this._isElementDisabled(uploadAreaElement)) return;
428
+ this._getElement("imageInput")?.click();
387
429
  });
388
430
  this._bindIfExists("imageInput", "change", (event) => {
389
431
  const file = event.target.files && event.target.files[0];
390
- if (file)
391
- this._loadImageFile(file);
432
+ if (file) {
433
+ this._loadImageFile(file).catch((error) => this._reportError("Image file could not be loaded", error)).finally(() => {
434
+ event.target.value = "";
435
+ });
436
+ }
392
437
  });
393
- this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
394
- this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
438
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
439
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep).catch((error) => this._reportError("scaleImage failed", error)));
395
440
  this._bindIfExists("resetBtn", "click", () => {
396
- this.resetImageTransform();
441
+ this.resetImageTransform().catch((error) => this._reportError("resetImageTransform failed", error));
397
442
  });
398
443
  this._bindIfExists("addMaskBtn", "click", () => this.createMask());
399
444
  this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
400
445
  this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
401
- this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
446
+ this._bindIfExists("mergeBtn", "click", () => this.mergeMasks().catch((error) => this._reportError("merge error", error)));
402
447
  this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
403
- this._bindIfExists("undoBtn", "click", () => this.undo());
404
- this._bindIfExists("redoBtn", "click", () => this.redo());
448
+ this._bindIfExists("undoBtn", "click", () => this.undo().catch((error) => this._reportError("undo failed", error)));
449
+ this._bindIfExists("redoBtn", "click", () => this.redo().catch((error) => this._reportError("redo failed", error)));
405
450
  this._bindIfExists("rotateLeftBtn", "click", () => {
406
- const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
451
+ const rotationInputElement = this._getElement("rotationLeftInput");
407
452
  let step = this.options.rotationStep;
408
453
  if (rotationInputElement) {
409
454
  const parsedStep = parseFloat(rotationInputElement.value);
410
- if (!isNaN(parsedStep))
411
- step = parsedStep;
455
+ if (!isNaN(parsedStep)) step = parsedStep;
412
456
  }
413
- this.rotateImage(this.currentRotation - step);
457
+ this.rotateImage(this.currentRotation - step).catch((error) => this._reportError("rotateImage failed", error));
414
458
  });
415
459
  this._bindIfExists("rotateRightBtn", "click", () => {
416
- const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
460
+ const rotationInputElement = this._getElement("rotationRightInput");
417
461
  let step = this.options.rotationStep;
418
462
  if (rotationInputElement) {
419
463
  const parsedStep = parseFloat(rotationInputElement.value);
420
- if (!isNaN(parsedStep))
421
- step = parsedStep;
464
+ if (!isNaN(parsedStep)) step = parsedStep;
422
465
  }
423
- this.rotateImage(this.currentRotation + step);
466
+ this.rotateImage(this.currentRotation + step).catch((error) => this._reportError("rotateImage failed", error));
424
467
  });
425
468
  this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
426
469
  this._bindIfExists("applyCropBtn", "click", () => {
427
470
  this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
428
471
  });
429
472
  this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
473
+ this._bindIfExists("maskList", "click", (event) => this._handleMaskListClick(event));
430
474
  }
431
475
  /**
432
476
  * Binds a DOM event listener when the configured element exists and records it for disposal.
@@ -437,12 +481,11 @@ var ImageEditor = class {
437
481
  * @private
438
482
  */
439
483
  _bindIfExists(key, eventName, handler) {
440
- const element = document.getElementById(this.elements[key]);
484
+ const element = this._getElement(key);
441
485
  if (element) {
442
486
  element.addEventListener(eventName, handler);
443
487
  this._handlersByElementKey = this._handlersByElementKey || {};
444
- if (!this._handlersByElementKey[key])
445
- this._handlersByElementKey[key] = [];
488
+ if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
446
489
  this._handlersByElementKey[key].push({ eventName, handler });
447
490
  }
448
491
  }
@@ -450,17 +493,33 @@ var ImageEditor = class {
450
493
  * Reads an image File as a data URL and loads it into the Fabric canvas.
451
494
  *
452
495
  * @param {File} file - Image file selected by the user.
496
+ * @returns {Promise<void>} Resolves after the selected file is loaded.
453
497
  * @private
454
498
  */
455
499
  _loadImageFile(file) {
456
- if (!file || !file.type.startsWith("image/"))
457
- return;
458
- const reader = new FileReader();
459
- reader.onload = (event) => this.loadImage(event.target.result);
460
- reader.onerror = (event) => {
461
- this._reportError("Image file could not be read", event);
462
- };
463
- reader.readAsDataURL(file);
500
+ if (!this._isSupportedImageFile(file)) {
501
+ const error = new Error("Selected file is not a supported image");
502
+ this._reportError("Selected file is not a supported image", error);
503
+ return Promise.reject(error);
504
+ }
505
+ return new Promise((resolve, reject) => {
506
+ const reader = new FileReader();
507
+ reader.onload = (event) => {
508
+ this.loadImage(event.target.result).then(resolve).catch(reject);
509
+ };
510
+ reader.onerror = (event) => {
511
+ const error = new Error("Image file could not be read");
512
+ this._reportError("Image file could not be read", event);
513
+ reject(error);
514
+ };
515
+ reader.readAsDataURL(file);
516
+ });
517
+ }
518
+ _isSupportedImageFile(file) {
519
+ if (!file) return false;
520
+ if (typeof file.type === "string" && file.type.startsWith("image/")) return true;
521
+ const fileName = String(file.name || "");
522
+ return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(fileName);
464
523
  }
465
524
  /**
466
525
  * Warns when more than one mutually exclusive image layout mode is enabled.
@@ -474,8 +533,7 @@ var ImageEditor = class {
474
533
  ["coverImageToCanvas", this.options.coverImageToCanvas],
475
534
  ["expandCanvasToImage", this.options.expandCanvasToImage]
476
535
  ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
477
- if (activeModes.length <= 1)
478
- return;
536
+ if (activeModes.length <= 1) return;
479
537
  this._reportWarning(
480
538
  `Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
481
539
  );
@@ -490,103 +548,98 @@ var ImageEditor = class {
490
548
  * @public
491
549
  */
492
550
  async loadImage(imageBase64, options = {}) {
493
- if (!this._fabricLoaded)
494
- return;
495
- if (!this.canvas)
496
- return;
497
- if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
498
- return;
551
+ if (!this._fabricLoaded) return;
552
+ if (!this.canvas || this._disposed) return;
553
+ if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
554
+ this._assertIdleForOperation("loadImage");
499
555
  this._warnOnImageLayoutOptionConflict();
500
- this._setPlaceholderVisible(false);
501
- this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
502
- const imageElement = await this._createImageElement(imageBase64);
503
- let loadSource = imageBase64;
504
- if (this.options.downsampleOnLoad) {
505
- const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
506
- if (shouldResize) {
507
- const ratio = Math.min(
508
- this.options.downsampleMaxWidth / imageElement.naturalWidth,
509
- this.options.downsampleMaxHeight / imageElement.naturalHeight
510
- );
511
- const targetWidth = Math.round(imageElement.naturalWidth * ratio);
512
- const targetHeight = Math.round(imageElement.naturalHeight * ratio);
513
- loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
556
+ const transaction = this._captureLoadImageTransaction();
557
+ try {
558
+ const imageElement = await this._createImageElement(imageBase64);
559
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
560
+ let loadSource = imageBase64;
561
+ if (this.options.downsampleOnLoad) {
562
+ const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
563
+ if (shouldResize) {
564
+ const ratio = Math.min(
565
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
566
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
567
+ );
568
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
569
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
570
+ loadSource = this._resampleImageToDataURL(
571
+ imageElement,
572
+ targetWidth,
573
+ targetHeight,
574
+ this.options.downsampleQuality,
575
+ imageBase64
576
+ );
577
+ }
514
578
  }
579
+ const fabricImage = await this._createFabricImageFromURL(loadSource);
580
+ if (this._disposed || !this.canvas) throw new Error("Editor was disposed while loading image");
581
+ this.canvas.discardActiveObject();
582
+ this._hideAllMaskLabels();
583
+ this.canvas.clear();
584
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
585
+ fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
586
+ this._setPlaceholderVisible(false);
587
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
588
+ const imageWidth = fabricImage.width;
589
+ const imageHeight = fabricImage.height;
590
+ const viewport = this._getContainerViewportSize();
591
+ const minWidth = viewport.width;
592
+ const minHeight = viewport.height;
593
+ if (this.options.fitImageToCanvas) {
594
+ const canvasWidth = Math.max(1, minWidth - 1);
595
+ const canvasHeight = Math.max(1, minHeight - 1);
596
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
597
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
598
+ fabricImage.set({ left: 0, top: 0 });
599
+ fabricImage.scale(fitScale);
600
+ this.baseImageScale = fabricImage.scaleX || 1;
601
+ } else if (this.options.coverImageToCanvas) {
602
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
603
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
604
+ fabricImage.set({ left: 0, top: 0 });
605
+ fabricImage.scale(layout.scale);
606
+ this.baseImageScale = fabricImage.scaleX || 1;
607
+ } else if (this.options.expandCanvasToImage) {
608
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
609
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
610
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
611
+ fabricImage.set({ left: 0, top: 0 });
612
+ fabricImage.scale(1);
613
+ this.baseImageScale = 1;
614
+ } else {
615
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
616
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
617
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
618
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
619
+ fabricImage.set({ left: 0, top: 0 });
620
+ fabricImage.scale(fitScale);
621
+ this.baseImageScale = fabricImage.scaleX || 1;
622
+ }
623
+ this.originalImage = fabricImage;
624
+ this.canvas.add(fabricImage);
625
+ this.canvas.sendToBack(fabricImage);
626
+ this._clearMaskPlacementMemory();
627
+ if (options.resetMaskCounter !== false) this.maskCounter = 0;
628
+ this.currentScale = 1;
629
+ this.currentRotation = 0;
630
+ this._updateInputs();
631
+ this._updateMaskList();
632
+ this.isImageLoadedToCanvas = true;
633
+ this._updateUI();
634
+ this.canvas.renderAll();
635
+ this._lastSnapshot = this._captureCanvasStateOrThrow("loadImage");
636
+ if (typeof this.onImageLoaded === "function") {
637
+ this.onImageLoaded();
638
+ }
639
+ } catch (error) {
640
+ await this._rollbackLoadImageTransaction(transaction);
641
+ throw error;
515
642
  }
516
- return new Promise((resolve, reject) => {
517
- fabric.Image.fromURL(loadSource, (fabricImage) => {
518
- try {
519
- if (!fabricImage)
520
- throw new Error("Image could not be loaded");
521
- this.canvas.discardActiveObject();
522
- this._hideAllMaskLabels();
523
- this.canvas.clear();
524
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
525
- fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
526
- const imageWidth = fabricImage.width;
527
- const imageHeight = fabricImage.height;
528
- const viewport = this._getContainerViewportSize();
529
- const minWidth = viewport.width;
530
- const minHeight = viewport.height;
531
- if (this.options.fitImageToCanvas) {
532
- const canvasWidth = Math.max(1, minWidth - 1);
533
- const canvasHeight = Math.max(1, minHeight - 1);
534
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
535
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
536
- fabricImage.set({ left: 0, top: 0 });
537
- fabricImage.scale(fitScale);
538
- this.baseImageScale = fabricImage.scaleX || 1;
539
- } else if (this.options.coverImageToCanvas) {
540
- const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
541
- this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
542
- fabricImage.set({ left: 0, top: 0 });
543
- fabricImage.scale(layout.scale);
544
- this.baseImageScale = fabricImage.scaleX || 1;
545
- } else if (this.options.expandCanvasToImage) {
546
- const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
547
- const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
548
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
549
- fabricImage.set({ left: 0, top: 0 });
550
- fabricImage.scale(1);
551
- this.baseImageScale = 1;
552
- } else {
553
- const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
554
- const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
555
- this._setCanvasSizeInt(canvasWidth, canvasHeight);
556
- const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
557
- fabricImage.set({ left: 0, top: 0 });
558
- fabricImage.scale(fitScale);
559
- this.baseImageScale = fabricImage.scaleX || 1;
560
- }
561
- this.originalImage = fabricImage;
562
- this.canvas.add(fabricImage);
563
- this.canvas.sendToBack(fabricImage);
564
- this._lastMask = null;
565
- this._lastMaskInitialLeft = null;
566
- this._lastMaskInitialTop = null;
567
- this._lastMaskInitialWidth = null;
568
- this.maskCounter = 0;
569
- this.currentScale = 1;
570
- this.currentRotation = 0;
571
- this._updateInputs();
572
- this._updateMaskList();
573
- this.isImageLoadedToCanvas = true;
574
- this._updateUI();
575
- this.canvas.renderAll();
576
- try {
577
- this._lastSnapshot = this._serializeCanvasState();
578
- } catch (error) {
579
- this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
580
- }
581
- if (typeof this.onImageLoaded === "function") {
582
- this.onImageLoaded();
583
- }
584
- resolve();
585
- } catch (error) {
586
- reject(error);
587
- }
588
- }, { crossOrigin: "anonymous" });
589
- });
590
643
  }
591
644
  /**
592
645
  * Checks whether there is a loaded image on the current canvas.
@@ -611,8 +664,7 @@ var ImageEditor = class {
611
664
  const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
612
665
  let timerId;
613
666
  const settle = (callback) => {
614
- if (isSettled)
615
- return;
667
+ if (isSettled) return;
616
668
  isSettled = true;
617
669
  clearTimeout(timerId);
618
670
  imageElement.onload = null;
@@ -624,6 +676,7 @@ var ImageEditor = class {
624
676
  try {
625
677
  imageElement.src = "";
626
678
  } catch (error) {
679
+ void error;
627
680
  }
628
681
  }, safeTimeoutMs);
629
682
  imageElement.onload = () => settle(() => resolve(imageElement));
@@ -631,25 +684,132 @@ var ImageEditor = class {
631
684
  imageElement.src = dataUrl;
632
685
  });
633
686
  }
687
+ _createFabricImageFromURL(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
688
+ return new Promise((resolve, reject) => {
689
+ const safeTimeoutMs = this._getSafeTimeoutMs(timeoutMs);
690
+ let isSettled = false;
691
+ let timerId;
692
+ const settle = (callback) => {
693
+ if (isSettled) return;
694
+ isSettled = true;
695
+ clearTimeout(timerId);
696
+ callback();
697
+ };
698
+ timerId = setTimeout(() => {
699
+ settle(() => reject(new Error("Fabric image load timed out")));
700
+ }, safeTimeoutMs);
701
+ try {
702
+ fabric.Image.fromURL(dataUrl, (fabricImage) => {
703
+ settle(() => {
704
+ if (!fabricImage) {
705
+ reject(new Error("Image could not be loaded"));
706
+ return;
707
+ }
708
+ resolve(fabricImage);
709
+ });
710
+ }, { crossOrigin: "anonymous" });
711
+ } catch (error) {
712
+ settle(() => reject(error));
713
+ }
714
+ });
715
+ }
716
+ _getSafeTimeoutMs(timeoutMs) {
717
+ const safeTimeoutMs = Number(timeoutMs);
718
+ return Number.isFinite(safeTimeoutMs) && safeTimeoutMs > 0 ? safeTimeoutMs : 3e4;
719
+ }
720
+ _captureLoadImageTransaction() {
721
+ return {
722
+ canvasState: this._serializeCanvasState(),
723
+ originalImage: this.originalImage,
724
+ baseImageScale: this.baseImageScale,
725
+ currentScale: this.currentScale,
726
+ currentRotation: this.currentRotation,
727
+ maskCounter: this.maskCounter,
728
+ isImageLoadedToCanvas: this.isImageLoadedToCanvas,
729
+ lastSnapshot: this._lastSnapshot,
730
+ lastMask: this._lastMask,
731
+ lastMaskInitialLeft: this._lastMaskInitialLeft,
732
+ lastMaskInitialTop: this._lastMaskInitialTop,
733
+ lastMaskInitialWidth: this._lastMaskInitialWidth,
734
+ containerOverflow: this.containerElement && this.containerElement.style ? {
735
+ overflow: this.containerElement.style.overflow || "",
736
+ overflowX: this.containerElement.style.overflowX || "",
737
+ overflowY: this.containerElement.style.overflowY || ""
738
+ } : null,
739
+ scrollLeft: this.containerElement ? this.containerElement.scrollLeft : 0,
740
+ scrollTop: this.containerElement ? this.containerElement.scrollTop : 0,
741
+ placeholderVisibility: this._captureElementVisibility(this.placeholderElement),
742
+ canvasVisibility: this._captureElementVisibility(this._getCanvasVisibilityElement())
743
+ };
744
+ }
745
+ async _rollbackLoadImageTransaction(transaction) {
746
+ if (!transaction || !this.canvas || this._disposed) return;
747
+ try {
748
+ if (transaction.canvasState) await this.loadFromState(transaction.canvasState);
749
+ } catch (error) {
750
+ this._reportError("loadImage rollback failed", error);
751
+ }
752
+ this.baseImageScale = transaction.baseImageScale;
753
+ this.currentScale = transaction.currentScale;
754
+ this.currentRotation = transaction.currentRotation;
755
+ this.maskCounter = transaction.maskCounter;
756
+ this.isImageLoadedToCanvas = transaction.isImageLoadedToCanvas;
757
+ this._lastSnapshot = transaction.lastSnapshot;
758
+ this._lastMaskInitialLeft = transaction.lastMaskInitialLeft;
759
+ this._lastMaskInitialTop = transaction.lastMaskInitialTop;
760
+ this._lastMaskInitialWidth = transaction.lastMaskInitialWidth;
761
+ this._containerOriginalOverflow = transaction.containerOverflow;
762
+ this._restoreElementVisibility(this.placeholderElement, transaction.placeholderVisibility);
763
+ this._restoreElementVisibility(this._getCanvasVisibilityElement(), transaction.canvasVisibility);
764
+ if (this.containerElement) {
765
+ this.containerElement.scrollLeft = transaction.scrollLeft;
766
+ this.containerElement.scrollTop = transaction.scrollTop;
767
+ this._restoreContainerOverflowState();
768
+ }
769
+ this._updateInputs();
770
+ this._updateMaskList();
771
+ this._updateUI();
772
+ if (this.canvas) this.canvas.renderAll();
773
+ }
634
774
  /**
635
- * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
775
+ * Resamples the given image element to a new width and height and returns the result as a data URL.
636
776
  *
637
777
  * @param {HTMLImageElement} imageElement - The image element to resample.
638
778
  * @param {number} targetWidth - Target width (in pixels) for the resampled image.
639
779
  * @param {number} targetHeight - Target height (in pixels) for the resampled image.
640
- * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
641
- * @returns {string} A data URL representing the resampled image as JPEG.
780
+ * @param {number} [quality=0.92] - Image quality between 0 and 1 for lossy formats.
781
+ * @param {string|null} [sourceDataUrl=null] - Source data URL used to preserve alpha-capable formats.
782
+ * @returns {string} A data URL representing the resampled image.
642
783
  * @private
643
784
  */
644
- _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
785
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92, sourceDataUrl = null) {
645
786
  const offscreenCanvas = document.createElement("canvas");
646
787
  offscreenCanvas.width = targetWidth;
647
788
  offscreenCanvas.height = targetHeight;
648
789
  const context = offscreenCanvas.getContext("2d");
649
- if (!context)
650
- throw new Error("2D canvas context is unavailable");
790
+ if (!context) throw new Error("2D canvas context is unavailable");
651
791
  context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
652
- return offscreenCanvas.toDataURL("image/jpeg", quality);
792
+ return offscreenCanvas.toDataURL(this._getDownsampleMimeType(sourceDataUrl), quality);
793
+ }
794
+ _getDataUrlMimeType(dataUrl) {
795
+ const match = String(dataUrl || "").match(/^data:([^;,]+)[;,]/i);
796
+ return match ? match[1].toLowerCase() : "";
797
+ }
798
+ _getDownsampleMimeType(sourceDataUrl) {
799
+ if (this.options.downsampleMimeType) {
800
+ const requestedFormat = this._normalizeImageFormat(this.options.downsampleMimeType);
801
+ return `image/${requestedFormat}`;
802
+ }
803
+ const sourceMimeType = this._getDataUrlMimeType(sourceDataUrl);
804
+ if (this.options.preserveSourceFormat !== false && (sourceMimeType === "image/png" || sourceMimeType === "image/webp")) {
805
+ return sourceMimeType;
806
+ }
807
+ return "image/jpeg";
808
+ }
809
+ _captureCanvasStateOrThrow(context) {
810
+ const snapshot = this._serializeCanvasState();
811
+ if (!snapshot) throw new Error(`${context}: canvas state is unavailable`);
812
+ return snapshot;
653
813
  }
654
814
  /**
655
815
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
@@ -664,19 +824,16 @@ var ImageEditor = class {
664
824
  const integerHeight = Math.max(1, Math.round(Number(height) || 1));
665
825
  this.canvas.setWidth(integerWidth);
666
826
  this.canvas.setHeight(integerHeight);
667
- if (typeof this.canvas.calcOffset === "function")
668
- this.canvas.calcOffset();
827
+ if (typeof this.canvas.calcOffset === "function") this.canvas.calcOffset();
669
828
  if (this.canvasElement) {
670
829
  this.canvasElement.style.width = integerWidth + "px";
671
830
  this.canvasElement.style.height = integerHeight + "px";
672
- this.canvasElement.style.maxWidth = "none";
673
831
  }
674
832
  }
675
833
  _ceilCanvasDimension(value) {
676
834
  const numericValue = Number(value) || 0;
677
835
  const roundedValue = Math.round(numericValue);
678
- if (Math.abs(numericValue - roundedValue) < 0.01)
679
- return roundedValue;
836
+ if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
680
837
  return Math.ceil(numericValue);
681
838
  }
682
839
  _getContainerViewportSize() {
@@ -686,19 +843,36 @@ var ImageEditor = class {
686
843
  height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
687
844
  };
688
845
  }
846
+ const measuredWidth = Math.floor(this.containerElement.clientWidth || 0);
847
+ const measuredHeight = Math.floor(this.containerElement.clientHeight || 0);
848
+ let width = Math.max(1, measuredWidth || this._lastContainerViewportSize?.width || this.options.canvasWidth || 1);
849
+ let height = Math.max(1, measuredHeight || this._lastContainerViewportSize?.height || this.options.canvasHeight || 1);
850
+ if (measuredWidth > 0 && measuredHeight > 0) {
851
+ this._lastContainerViewportSize = { width: measuredWidth, height: measuredHeight };
852
+ }
689
853
  if (this._hasFixedContainerScrollbars()) {
690
- return {
691
- width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
692
- height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
693
- };
854
+ return { width, height };
855
+ }
856
+ const overflow = this._getContainerOverflowValues();
857
+ const canScrollX = overflow.x.some((value) => value === "auto" || value === "scroll");
858
+ const canScrollY = overflow.y.some((value) => value === "auto" || value === "scroll");
859
+ const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
860
+ const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
861
+ if (hasHorizontalScrollbar || hasVerticalScrollbar) {
862
+ const scrollbar = this._getScrollbarSize();
863
+ if (hasVerticalScrollbar) width += scrollbar.width;
864
+ if (hasHorizontalScrollbar) height += scrollbar.height;
694
865
  }
695
- const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
696
- const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
697
866
  return { width, height };
698
867
  }
699
- _hasFixedContainerScrollbars() {
700
- if (!this.containerElement)
701
- return false;
868
+ /**
869
+ * Reads inline and computed overflow values for both scroll axes.
870
+ *
871
+ * @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
872
+ * @private
873
+ */
874
+ _getContainerOverflowValues() {
875
+ if (!this.containerElement) return { x: [], y: [] };
702
876
  const inlineOverflow = this.containerElement.style.overflow;
703
877
  const inlineOverflowX = this.containerElement.style.overflowX;
704
878
  const inlineOverflowY = this.containerElement.style.overflowY;
@@ -711,7 +885,15 @@ var ImageEditor = class {
711
885
  computedOverflowX = style.overflowX;
712
886
  computedOverflowY = style.overflowY;
713
887
  }
714
- return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
888
+ return {
889
+ x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
890
+ y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
891
+ };
892
+ }
893
+ _hasFixedContainerScrollbars() {
894
+ if (!this.containerElement) return false;
895
+ const overflow = this._getContainerOverflowValues();
896
+ return [...overflow.x, ...overflow.y].some((value) => value === "scroll");
715
897
  }
716
898
  _getScrollbarSize() {
717
899
  if (this._scrollbarSizeCache) {
@@ -754,15 +936,14 @@ var ImageEditor = class {
754
936
  const scrollbar = this._getScrollbarSize();
755
937
  let hasVertical = false;
756
938
  let hasHorizontal = false;
757
- let effectiveWidth = viewport.width;
758
- let effectiveHeight = viewport.height;
939
+ let effectiveWidth;
940
+ let effectiveHeight;
759
941
  for (let i = 0; i < 4; i += 1) {
760
942
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
761
943
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
762
944
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
763
945
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
764
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
765
- break;
946
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
766
947
  hasVertical = nextHasVertical;
767
948
  hasHorizontal = nextHasHorizontal;
768
949
  }
@@ -799,8 +980,8 @@ var ImageEditor = class {
799
980
  let scale = 1;
800
981
  let contentWidth = imageWidth;
801
982
  let contentHeight = imageHeight;
802
- let effectiveWidth = viewport.width;
803
- let effectiveHeight = viewport.height;
983
+ let effectiveWidth;
984
+ let effectiveHeight;
804
985
  for (let i = 0; i < 4; i += 1) {
805
986
  effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
806
987
  effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
@@ -809,8 +990,7 @@ var ImageEditor = class {
809
990
  contentHeight = imageHeight * scale;
810
991
  const nextHasVertical = contentHeight > effectiveHeight + 0.5;
811
992
  const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
812
- if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
813
- break;
993
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
814
994
  hasVertical = nextHasVertical;
815
995
  hasHorizontal = nextHasHorizontal;
816
996
  }
@@ -849,41 +1029,48 @@ var ImageEditor = class {
849
1029
  stroke: mask && mask.originalStroke || "#ccc",
850
1030
  strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
851
1031
  };
852
- if (Number.isFinite(opacity))
853
- style.opacity = opacity;
1032
+ if (Number.isFinite(opacity)) style.opacity = opacity;
854
1033
  return style;
855
1034
  }
856
1035
  _withNormalizedMaskStyles(callback) {
857
- if (!this.canvas)
858
- return callback();
1036
+ if (!this.canvas) return callback();
859
1037
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
860
- const maskStyleBackups = masks.map((mask) => ({
861
- object: mask,
862
- stroke: mask.stroke,
863
- strokeWidth: mask.strokeWidth,
864
- opacity: mask.opacity
865
- }));
1038
+ const maskStyleBackups = [];
866
1039
  try {
867
1040
  masks.forEach((mask) => {
868
- mask.set(this._getMaskNormalStyle(mask));
1041
+ const normalStyle = this._getMaskNormalStyle(mask);
1042
+ const stylePatch = {};
1043
+ Object.keys(normalStyle).forEach((property) => {
1044
+ if (mask[property] !== normalStyle[property]) {
1045
+ stylePatch[property] = normalStyle[property];
1046
+ }
1047
+ });
1048
+ const changedProperties = Object.keys(stylePatch);
1049
+ if (!changedProperties.length) return;
1050
+ const backup = { object: mask };
1051
+ changedProperties.forEach((property) => {
1052
+ backup[property] = mask[property];
1053
+ });
1054
+ maskStyleBackups.push(backup);
1055
+ mask.set(stylePatch);
869
1056
  });
870
1057
  return callback();
871
1058
  } finally {
872
1059
  maskStyleBackups.forEach((backup) => {
873
1060
  try {
874
- backup.object.set({
875
- stroke: backup.stroke,
876
- strokeWidth: backup.strokeWidth,
877
- opacity: backup.opacity
1061
+ const restorePatch = {};
1062
+ Object.keys(backup).forEach((property) => {
1063
+ if (property !== "object") restorePatch[property] = backup[property];
878
1064
  });
1065
+ backup.object.set(restorePatch);
879
1066
  } catch (error) {
1067
+ void error;
880
1068
  }
881
1069
  });
882
1070
  }
883
1071
  }
884
1072
  _restoreMaskControls(mask) {
885
- if (!mask)
886
- return;
1073
+ if (!mask) return;
887
1074
  const cornerSize = Number(mask.cornerSize);
888
1075
  mask.set({
889
1076
  selectable: mask.selectable !== false,
@@ -896,8 +1083,7 @@ var ImageEditor = class {
896
1083
  transparentCorners: mask.transparentCorners === true,
897
1084
  strokeUniform: mask.strokeUniform !== false
898
1085
  });
899
- if (typeof mask.setCoords === "function")
900
- mask.setCoords();
1086
+ if (typeof mask.setCoords === "function") mask.setCoords();
901
1087
  }
902
1088
  /**
903
1089
  * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
@@ -919,8 +1105,7 @@ var ImageEditor = class {
919
1105
  };
920
1106
  }
921
1107
  _serializeCanvasState() {
922
- if (!this.canvas)
923
- return null;
1108
+ if (!this.canvas) return null;
924
1109
  return this._withNormalizedMaskStyles(() => {
925
1110
  const jsonObject = this.canvas.toJSON(this._getStateProperties());
926
1111
  if (Array.isArray(jsonObject.objects)) {
@@ -939,8 +1124,7 @@ var ImageEditor = class {
939
1124
  */
940
1125
  _normalizeQuality(quality) {
941
1126
  const numericQuality = Number(quality);
942
- if (!Number.isFinite(numericQuality))
943
- return this.options.downsampleQuality ?? 0.92;
1127
+ if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
944
1128
  return Math.max(0, Math.min(1, numericQuality));
945
1129
  }
946
1130
  /**
@@ -1013,8 +1197,7 @@ var ImageEditor = class {
1013
1197
  const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
1014
1198
  let timerId;
1015
1199
  const settle = (callback) => {
1016
- if (isSettled)
1017
- return;
1200
+ if (isSettled) return;
1018
1201
  isSettled = true;
1019
1202
  clearTimeout(timerId);
1020
1203
  imageElement.onload = null;
@@ -1026,6 +1209,7 @@ var ImageEditor = class {
1026
1209
  try {
1027
1210
  imageElement.src = "";
1028
1211
  } catch (error) {
1212
+ void error;
1029
1213
  }
1030
1214
  }, safeTimeoutMs);
1031
1215
  imageElement.onload = () => {
@@ -1039,8 +1223,7 @@ var ImageEditor = class {
1039
1223
  offscreenCanvas.width = scaledSourceWidth;
1040
1224
  offscreenCanvas.height = scaledSourceHeight;
1041
1225
  const context = offscreenCanvas.getContext("2d");
1042
- if (!context)
1043
- throw new Error("2D canvas context is unavailable");
1226
+ if (!context) throw new Error("2D canvas context is unavailable");
1044
1227
  context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1045
1228
  settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1046
1229
  } catch (error) {
@@ -1052,7 +1235,7 @@ var ImageEditor = class {
1052
1235
  });
1053
1236
  }
1054
1237
  /**
1055
- * Exports the whole Fabric canvas, then crops the requested source region from that export.
1238
+ * Exports a source region directly through Fabric's region export options.
1056
1239
  *
1057
1240
  * @param {Object} region - Canvas source region and export options.
1058
1241
  * @param {number} region.sourceX - Source region x coordinate.
@@ -1065,14 +1248,17 @@ var ImageEditor = class {
1065
1248
  * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1066
1249
  * @private
1067
1250
  */
1068
- async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1251
+ _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
1069
1252
  const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1070
- const fullDataUrl = this.canvas.toDataURL({
1253
+ return this.canvas.toDataURL({
1071
1254
  format,
1072
1255
  quality,
1073
- multiplier: safeMultiplier
1256
+ multiplier: safeMultiplier,
1257
+ left: sourceX,
1258
+ top: sourceY,
1259
+ width: sourceWidth,
1260
+ height: sourceHeight
1074
1261
  });
1075
- return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
1076
1262
  }
1077
1263
  /**
1078
1264
  * Gets the top-left corner coordinates of the given object.
@@ -1083,15 +1269,39 @@ var ImageEditor = class {
1083
1269
  * @private
1084
1270
  */
1085
1271
  _getObjectTopLeftPoint(fabricObject) {
1086
- if (!fabricObject)
1087
- return { x: 0, y: 0 };
1272
+ if (!fabricObject) return { x: 0, y: 0 };
1088
1273
  fabricObject.setCoords();
1089
- const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1090
- if (coords && coords.length)
1091
- return coords[0];
1092
1274
  const boundingRect = fabricObject.getBoundingRect(true, true);
1093
1275
  return { x: boundingRect.left, y: boundingRect.top };
1094
1276
  }
1277
+ _getObjectCoordinateTopLeftPoint(fabricObject) {
1278
+ if (!fabricObject) return { x: 0, y: 0 };
1279
+ fabricObject.setCoords();
1280
+ const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
1281
+ if (coords && coords.length) return coords[0];
1282
+ return this._getObjectTopLeftPoint(fabricObject);
1283
+ }
1284
+ _getObjectOriginPoint(fabricObject, originX, originY) {
1285
+ if (!fabricObject) return { x: 0, y: 0 };
1286
+ if (typeof fabricObject.getPointByOrigin === "function") {
1287
+ return fabricObject.getPointByOrigin(originX, originY);
1288
+ }
1289
+ return this._getObjectTopLeftPoint(fabricObject);
1290
+ }
1291
+ _translateObjectByCanvasOffset(fabricObject, deltaX, deltaY) {
1292
+ if (!fabricObject) return;
1293
+ if (typeof fabricObject.getCenterPoint === "function" && typeof fabricObject.setPositionByOrigin === "function") {
1294
+ const center = fabricObject.getCenterPoint();
1295
+ const nextCenter = new fabric.Point(center.x + deltaX, center.y + deltaY);
1296
+ fabricObject.setPositionByOrigin(nextCenter, "center", "center");
1297
+ } else {
1298
+ fabricObject.set({
1299
+ left: (fabricObject.left || 0) + deltaX,
1300
+ top: (fabricObject.top || 0) + deltaY
1301
+ });
1302
+ }
1303
+ fabricObject.setCoords();
1304
+ }
1095
1305
  /**
1096
1306
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
1097
1307
  *
@@ -1102,8 +1312,7 @@ var ImageEditor = class {
1102
1312
  * @private
1103
1313
  */
1104
1314
  _setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
1105
- if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
1106
- return;
1315
+ if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
1107
1316
  fabricObject.set({ originX, originY });
1108
1317
  fabricObject.setPositionByOrigin(refPoint, originX, originY);
1109
1318
  fabricObject.setCoords();
@@ -1115,8 +1324,7 @@ var ImageEditor = class {
1115
1324
  * @private
1116
1325
  */
1117
1326
  _alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
1118
- if (!fabricObject)
1119
- return;
1327
+ if (!fabricObject) return;
1120
1328
  fabricObject.setCoords();
1121
1329
  const boundingRect = fabricObject.getBoundingRect(true, true);
1122
1330
  const deltaX = boundingRect.left;
@@ -1131,8 +1339,7 @@ var ImageEditor = class {
1131
1339
  * @private
1132
1340
  */
1133
1341
  _updateCanvasSizeToImageBounds() {
1134
- if (!this.originalImage)
1135
- return;
1342
+ if (!this.originalImage) return;
1136
1343
  this.originalImage.setCoords();
1137
1344
  const imageBounds = this.originalImage.getBoundingRect(true, true);
1138
1345
  const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
@@ -1156,25 +1363,34 @@ var ImageEditor = class {
1156
1363
  * @private
1157
1364
  */
1158
1365
  _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1159
- if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds())
1160
- return;
1366
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1161
1367
  try {
1162
- let requiredWidth = this.canvas.getWidth();
1163
- let requiredHeight = this.canvas.getHeight();
1368
+ const currentWidth = this.canvas.getWidth();
1369
+ const currentHeight = this.canvas.getHeight();
1370
+ let requiredWidth = currentWidth;
1371
+ let requiredHeight = currentHeight;
1164
1372
  fabricObjects.forEach((fabricObject) => {
1165
- if (!fabricObject)
1166
- return;
1167
- if (typeof fabricObject.setCoords === "function")
1168
- fabricObject.setCoords();
1373
+ if (!fabricObject) return;
1374
+ if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
1169
1375
  const boundingRect = fabricObject.getBoundingRect(true, true);
1170
1376
  requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1171
1377
  requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1172
1378
  });
1173
- const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1174
- const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1175
- const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1176
- const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1177
- if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1379
+ const shouldUseScrollSafeViewport = this.options.fitImageToCanvas || this.options.coverImageToCanvas;
1380
+ let minWidth = 0;
1381
+ let minHeight = 0;
1382
+ if (shouldUseScrollSafeViewport) {
1383
+ const viewport = this._getContainerViewportSize();
1384
+ const safetyMargin = this._getScrollSafetyMargin();
1385
+ minWidth = Math.max(1, viewport.width - safetyMargin);
1386
+ minHeight = Math.max(1, viewport.height - safetyMargin);
1387
+ } else if (this.containerElement) {
1388
+ minWidth = Math.floor(this.containerElement.clientWidth || 0);
1389
+ minHeight = Math.floor(this.containerElement.clientHeight || 0);
1390
+ }
1391
+ const newWidth = Math.max(currentWidth, minWidth, requiredWidth);
1392
+ const newHeight = Math.max(currentHeight, minHeight, requiredHeight);
1393
+ if (newWidth !== currentWidth || newHeight !== currentHeight) {
1178
1394
  this._setCanvasSizeInt(newWidth, newHeight);
1179
1395
  }
1180
1396
  } catch (error) {
@@ -1202,6 +1418,66 @@ var ImageEditor = class {
1202
1418
  scaleImage(factor, options = {}) {
1203
1419
  return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
1204
1420
  }
1421
+ _assertIdleForOperation(operationName) {
1422
+ if (this._disposed || !this.canvas) throw new Error(`${operationName} cannot run after the editor has been disposed`);
1423
+ if (this.isAnimating || this.animationQueue && this.animationQueue.isBusy()) {
1424
+ throw new Error(`${operationName} cannot run while an animation is running`);
1425
+ }
1426
+ }
1427
+ _canMutateNow(operationName) {
1428
+ try {
1429
+ this._assertIdleForOperation(operationName);
1430
+ return true;
1431
+ } catch (error) {
1432
+ this._reportError(`${operationName} blocked`, error);
1433
+ return false;
1434
+ }
1435
+ }
1436
+ _rejectActiveAnimations(reason) {
1437
+ const error = reason instanceof Error ? reason : new Error(String(reason || "Animation cancelled"));
1438
+ this._activeAnimationRejectors.forEach((reject) => {
1439
+ try {
1440
+ reject(error);
1441
+ } catch (rejectError) {
1442
+ void rejectError;
1443
+ }
1444
+ });
1445
+ this._activeAnimationRejectors.clear();
1446
+ }
1447
+ _animateFabricProperty(fabricObject, property, value) {
1448
+ return new Promise((resolve, reject) => {
1449
+ if (this._disposed || !this.canvas || !fabricObject) {
1450
+ reject(new Error("Animation cannot start after editor disposal"));
1451
+ return;
1452
+ }
1453
+ let isSettled = false;
1454
+ const duration = Math.max(0, Number(this.options.animationDuration) || 0);
1455
+ const timeoutMs = Math.max(1e3, duration + 1e3);
1456
+ let timerId;
1457
+ const settle = (callback) => {
1458
+ if (isSettled) return;
1459
+ isSettled = true;
1460
+ clearTimeout(timerId);
1461
+ this._activeAnimationRejectors.delete(reject);
1462
+ callback();
1463
+ };
1464
+ this._activeAnimationRejectors.add(reject);
1465
+ timerId = setTimeout(() => {
1466
+ settle(() => reject(new Error(`Animation timed out while changing ${property}`)));
1467
+ }, timeoutMs);
1468
+ try {
1469
+ fabricObject.animate(property, value, {
1470
+ duration,
1471
+ onChange: () => {
1472
+ if (!this._disposed && this.canvas) this.canvas.renderAll();
1473
+ },
1474
+ onComplete: () => settle(resolve)
1475
+ });
1476
+ } catch (error) {
1477
+ settle(() => reject(error));
1478
+ }
1479
+ });
1480
+ }
1205
1481
  /**
1206
1482
  * Scales the original image by a given factor, with animation.
1207
1483
  * Returns a promise that resolves when the scale animation is complete.
@@ -1209,34 +1485,25 @@ var ImageEditor = class {
1209
1485
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
1210
1486
  * @private
1211
1487
  */
1212
- _scaleImageImpl(factor, options = {}) {
1213
- if (!this.originalImage)
1214
- return Promise.resolve();
1215
- if (this.isAnimating)
1216
- return Promise.resolve();
1488
+ async _scaleImageImpl(factor, options = {}) {
1489
+ if (!this.originalImage || this._disposed) return;
1490
+ if (this.isAnimating) return;
1217
1491
  const saveHistory = options.saveHistory !== false;
1218
- factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1219
- this.currentScale = factor;
1220
- this.isAnimating = true;
1221
- this._updateUI();
1222
- const targetScale = this.baseImageScale * factor;
1223
- const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1224
- this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1225
- const scaleXAnimation = new Promise((resolve) => {
1226
- this.originalImage.animate("scaleX", targetScale, {
1227
- duration: this.options.animationDuration,
1228
- onChange: this.canvas.renderAll.bind(this.canvas),
1229
- onComplete: resolve
1230
- });
1231
- });
1232
- const scaleYAnimation = new Promise((resolve) => {
1233
- this.originalImage.animate("scaleY", targetScale, {
1234
- duration: this.options.animationDuration,
1235
- onChange: this.canvas.renderAll.bind(this.canvas),
1236
- onComplete: resolve
1237
- });
1238
- });
1239
- return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1492
+ let didStartAnimation = false;
1493
+ try {
1494
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
1495
+ this.currentScale = factor;
1496
+ this.isAnimating = true;
1497
+ didStartAnimation = true;
1498
+ this._updateUI();
1499
+ const targetScale = this.baseImageScale * factor;
1500
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
1501
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
1502
+ await Promise.all([
1503
+ this._animateFabricProperty(this.originalImage, "scaleX", targetScale),
1504
+ this._animateFabricProperty(this.originalImage, "scaleY", targetScale)
1505
+ ]);
1506
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during scale animation");
1240
1507
  this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
1241
1508
  this.originalImage.setCoords();
1242
1509
  if (this._shouldResizeCanvasToContentBounds()) {
@@ -1244,18 +1511,17 @@ var ImageEditor = class {
1244
1511
  }
1245
1512
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1246
1513
  this.canvas.getObjects().forEach((object) => {
1247
- if (object.maskId)
1248
- this._syncMaskLabel(object);
1514
+ if (object.maskId) this._syncMaskLabel(object);
1249
1515
  });
1250
- this.isAnimating = false;
1251
1516
  this._updateInputs();
1252
- this._updateUI();
1253
- if (saveHistory)
1254
- this.saveState();
1255
- }).catch(() => {
1256
- this.isAnimating = false;
1257
- this._updateUI();
1258
- });
1517
+ if (saveHistory) this.saveState();
1518
+ } finally {
1519
+ if (didStartAnimation) {
1520
+ this.isAnimating = false;
1521
+ this._updateInputs();
1522
+ this._updateUI();
1523
+ }
1524
+ }
1259
1525
  }
1260
1526
  /**
1261
1527
  * Rotates the original image by a given number of degrees, with animation.
@@ -1274,48 +1540,50 @@ var ImageEditor = class {
1274
1540
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
1275
1541
  * @private
1276
1542
  */
1277
- _rotateImageImpl(degrees, options = {}) {
1278
- if (!this.originalImage)
1279
- return Promise.resolve();
1280
- if (this.isAnimating)
1281
- return Promise.resolve();
1282
- if (isNaN(degrees))
1283
- return Promise.resolve();
1543
+ async _rotateImageImpl(degrees, options = {}) {
1544
+ if (!this.originalImage || this._disposed) return;
1545
+ if (this.isAnimating) return;
1546
+ if (isNaN(degrees)) return;
1284
1547
  const saveHistory = options.saveHistory !== false;
1285
- this.currentRotation = degrees;
1286
- this.isAnimating = true;
1287
- this._updateUI();
1288
- const center = this.originalImage.getCenterPoint();
1289
- this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
1290
- const rotationAnimation = new Promise((resolve) => {
1291
- this.originalImage.animate("angle", degrees, {
1292
- duration: this.options.animationDuration,
1293
- onChange: this.canvas.renderAll.bind(this.canvas),
1294
- onComplete: resolve
1295
- });
1296
- });
1297
- return rotationAnimation.then(() => {
1548
+ const image = this.originalImage;
1549
+ const previousOriginX = image.originX || "left";
1550
+ const previousOriginY = image.originY || "top";
1551
+ const previousOriginPoint = this._getObjectOriginPoint(image, previousOriginX, previousOriginY);
1552
+ let didStartAnimation = false;
1553
+ let didCompleteRotation = false;
1554
+ try {
1555
+ this.currentRotation = degrees;
1556
+ this.isAnimating = true;
1557
+ didStartAnimation = true;
1558
+ this._updateUI();
1559
+ const center = image.getCenterPoint();
1560
+ this._setObjectOriginKeepingPosition(image, "center", "center", center);
1561
+ await this._animateFabricProperty(image, "angle", degrees);
1562
+ if (this._disposed || !this.canvas || !this.originalImage) throw new Error("Editor was disposed during rotation animation");
1298
1563
  this.originalImage.set("angle", degrees);
1299
1564
  this.originalImage.setCoords();
1300
1565
  if (this._shouldResizeCanvasToContentBounds()) {
1301
1566
  this._updateCanvasSizeToImageBounds();
1302
1567
  }
1303
1568
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
1304
- const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
1569
+ const newTopLeft = this._getObjectCoordinateTopLeftPoint(this.originalImage);
1305
1570
  this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
1306
1571
  this.canvas.getObjects().forEach((object) => {
1307
- if (object.maskId)
1308
- this._syncMaskLabel(object);
1572
+ if (object.maskId) this._syncMaskLabel(object);
1309
1573
  });
1310
- this.isAnimating = false;
1311
1574
  this._updateInputs();
1312
- this._updateUI();
1313
- if (saveHistory)
1314
- this.saveState();
1315
- }).catch(() => {
1316
- this.isAnimating = false;
1317
- this._updateUI();
1318
- });
1575
+ if (saveHistory) this.saveState();
1576
+ didCompleteRotation = true;
1577
+ } finally {
1578
+ if (!didCompleteRotation && !this._disposed && image) {
1579
+ this._setObjectOriginKeepingPosition(image, previousOriginX, previousOriginY, previousOriginPoint);
1580
+ }
1581
+ if (didStartAnimation) {
1582
+ this.isAnimating = false;
1583
+ this._updateInputs();
1584
+ this._updateUI();
1585
+ }
1586
+ }
1319
1587
  }
1320
1588
  /**
1321
1589
  * Resets the image transform: scales to 1 and rotates to 0 degrees.
@@ -1324,16 +1592,16 @@ var ImageEditor = class {
1324
1592
  * @public
1325
1593
  */
1326
1594
  resetImageTransform() {
1327
- if (!this.originalImage)
1328
- return Promise.resolve();
1595
+ if (!this.originalImage) return Promise.resolve();
1329
1596
  return this.animationQueue.add(async () => {
1330
- const before = this._lastSnapshot || this._serializeCanvasState();
1597
+ const before = this._lastSnapshot || this._captureCanvasStateOrThrow("resetImageTransform");
1331
1598
  await this._scaleImageImpl(1, { saveHistory: false });
1332
1599
  await this._rotateImageImpl(0, { saveHistory: false });
1333
- const after = this._serializeCanvasState();
1600
+ const after = this._captureCanvasStateOrThrow("resetImageTransform");
1334
1601
  this._pushStateTransition(before, after);
1335
1602
  }).catch((error) => {
1336
1603
  this._reportError("resetImageTransform() failed", error);
1604
+ throw error;
1337
1605
  });
1338
1606
  }
1339
1607
  /**
@@ -1353,14 +1621,31 @@ var ImageEditor = class {
1353
1621
  * @public
1354
1622
  */
1355
1623
  loadFromState(serializedState) {
1356
- if (!serializedState || !this.canvas)
1357
- return Promise.resolve();
1358
- return new Promise((resolve) => {
1624
+ if (!serializedState || !this.canvas || this._disposed) return Promise.resolve();
1625
+ if (this._cropMode || this._cropRect) {
1626
+ this._removeCropRect();
1627
+ this._restoreCropObjectState();
1628
+ this._cropMode = false;
1629
+ if (this._prevSelectionSetting !== void 0 && this.canvas) {
1630
+ this.canvas.selection = !!this._prevSelectionSetting;
1631
+ }
1632
+ this._prevSelectionSetting = void 0;
1633
+ }
1634
+ return new Promise((resolve, reject) => {
1359
1635
  try {
1360
1636
  const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
1361
1637
  const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
1362
- this.canvas.loadFromJSON(state, () => {
1638
+ this.canvas.loadFromJSON(state, async () => {
1363
1639
  try {
1640
+ if (this._disposed || !this.canvas) {
1641
+ reject(new Error("Editor was disposed while loading state"));
1642
+ return;
1643
+ }
1644
+ await this._waitForFabricImagesReady(this.canvas.getObjects());
1645
+ if (this._disposed || !this.canvas) {
1646
+ reject(new Error("Editor was disposed while loading state"));
1647
+ return;
1648
+ }
1364
1649
  this._hideAllMaskLabels();
1365
1650
  const canvasObjects = this.canvas.getObjects();
1366
1651
  this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
@@ -1408,18 +1693,44 @@ var ImageEditor = class {
1408
1693
  this._updatePlaceholderStatus();
1409
1694
  this._lastSnapshot = this._serializeCanvasState();
1410
1695
  this._updateUI();
1696
+ resolve();
1411
1697
  } catch (callbackError) {
1412
1698
  this._reportError("loadFromState() failed", callbackError);
1413
- } finally {
1414
- resolve();
1699
+ reject(callbackError);
1415
1700
  }
1416
1701
  });
1417
1702
  } catch (error) {
1418
1703
  this._reportError("loadFromState() failed", error);
1419
- resolve();
1704
+ reject(error);
1420
1705
  }
1421
1706
  });
1422
1707
  }
1708
+ async _waitForFabricImagesReady(canvasObjects) {
1709
+ const imageObjects = (canvasObjects || []).filter((object) => object && object.type === "image");
1710
+ await Promise.all(imageObjects.map((object) => this._waitForImageElementReady(
1711
+ typeof object.getElement === "function" ? object.getElement() : object._element
1712
+ )));
1713
+ }
1714
+ _waitForImageElementReady(imageElement) {
1715
+ if (!imageElement) return Promise.resolve();
1716
+ if (imageElement.complete || imageElement.naturalWidth > 0 || imageElement.width > 0) return Promise.resolve();
1717
+ return new Promise((resolve, reject) => {
1718
+ let isSettled = false;
1719
+ const timerId = setTimeout(() => {
1720
+ settle(() => reject(new Error("Image load timed out while restoring state")));
1721
+ }, this._getSafeTimeoutMs(this.options.imageLoadTimeoutMs));
1722
+ const settle = (callback) => {
1723
+ if (isSettled) return;
1724
+ isSettled = true;
1725
+ clearTimeout(timerId);
1726
+ imageElement.onload = null;
1727
+ imageElement.onerror = null;
1728
+ callback();
1729
+ };
1730
+ imageElement.onload = () => settle(resolve);
1731
+ imageElement.onerror = (error) => settle(() => reject(error));
1732
+ });
1733
+ }
1423
1734
  /**
1424
1735
  * Saves the current editable canvas state as an undoable history transition.
1425
1736
  *
@@ -1430,14 +1741,11 @@ var ImageEditor = class {
1430
1741
  * @public
1431
1742
  */
1432
1743
  saveState() {
1433
- if (!this.canvas)
1434
- return;
1435
- const activeObject = this.canvas.getActiveObject();
1744
+ if (!this.canvas) return;
1436
1745
  try {
1437
- const after = this._serializeCanvasState();
1746
+ const after = this._captureCanvasStateOrThrow("saveState");
1438
1747
  const before = this._lastSnapshot || after;
1439
- if (after === before)
1440
- return;
1748
+ if (after === before) return;
1441
1749
  let executedOnce = false;
1442
1750
  const command = new Command(
1443
1751
  () => {
@@ -1454,9 +1762,6 @@ var ImageEditor = class {
1454
1762
  } catch (error) {
1455
1763
  this._reportWarning("saveState: failed to save canvas snapshot", error);
1456
1764
  } finally {
1457
- if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1458
- this._handleSelectionChanged([activeObject]);
1459
- }
1460
1765
  this._updateUI();
1461
1766
  }
1462
1767
  }
@@ -1472,12 +1777,12 @@ var ImageEditor = class {
1472
1777
  * @private
1473
1778
  */
1474
1779
  _pushStateTransition(before, after) {
1475
- if (!before || !after)
1476
- return;
1477
- if (before === after)
1780
+ if (!before || !after) {
1781
+ this._reportWarning("History transition skipped because a canvas snapshot is unavailable");
1478
1782
  return;
1479
- if (!this.historyManager)
1480
- this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1783
+ }
1784
+ if (before === after) return;
1785
+ if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1481
1786
  const command = new Command(
1482
1787
  () => this.loadFromState(after),
1483
1788
  () => this.loadFromState(before)
@@ -1497,6 +1802,7 @@ var ImageEditor = class {
1497
1802
  this._updateUI();
1498
1803
  }).catch((error) => {
1499
1804
  this._reportError("undo failed", error);
1805
+ throw error;
1500
1806
  });
1501
1807
  }
1502
1808
  /**
@@ -1510,48 +1816,40 @@ var ImageEditor = class {
1510
1816
  this._updateUI();
1511
1817
  }).catch((error) => {
1512
1818
  this._reportError("redo failed", error);
1819
+ throw error;
1513
1820
  });
1514
1821
  }
1515
1822
  _rebindMaskEvents(mask) {
1516
- if (!mask)
1517
- return;
1823
+ if (!mask) return;
1518
1824
  if (mask.__imageEditorMaskHandlers) {
1519
1825
  try {
1520
1826
  mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
1521
1827
  mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
1522
1828
  } catch (error) {
1829
+ void error;
1523
1830
  }
1524
1831
  }
1525
1832
  const metadata = {};
1526
1833
  if (!Number.isFinite(Number(mask.originalAlpha))) {
1527
1834
  metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
1528
1835
  }
1529
- if (!mask.originalStroke)
1530
- metadata.originalStroke = mask.stroke || "#ccc";
1836
+ if (!mask.originalStroke) metadata.originalStroke = mask.stroke || "#ccc";
1531
1837
  if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
1532
1838
  metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1533
1839
  }
1534
- if (Object.keys(metadata).length)
1535
- mask.set(metadata);
1536
- const normalStyle = {
1537
- stroke: mask.originalStroke || "#ccc",
1538
- strokeWidth: mask.originalStrokeWidth,
1539
- opacity: mask.originalAlpha
1540
- };
1541
- const hoverStyle = {
1542
- stroke: "#ff5500",
1543
- strokeWidth: 2,
1544
- opacity: Math.min(mask.originalAlpha + 0.2, 1)
1545
- };
1840
+ if (Object.keys(metadata).length) mask.set(metadata);
1546
1841
  const mouseover = () => {
1547
- mask.set(hoverStyle);
1548
- if (mask.canvas)
1549
- mask.canvas.requestRenderAll();
1842
+ const opacity = Number(mask.originalAlpha);
1843
+ mask.set({
1844
+ stroke: "#ff5500",
1845
+ strokeWidth: 2,
1846
+ opacity: Math.min((Number.isFinite(opacity) ? opacity : 0.5) + 0.2, 1)
1847
+ });
1848
+ if (mask.canvas) mask.canvas.requestRenderAll();
1550
1849
  };
1551
1850
  const mouseout = () => {
1552
- mask.set(normalStyle);
1553
- if (mask.canvas)
1554
- mask.canvas.requestRenderAll();
1851
+ mask.set(this._getMaskNormalStyle(mask));
1852
+ if (mask.canvas) mask.canvas.requestRenderAll();
1555
1853
  };
1556
1854
  mask.on("mouseover", mouseover);
1557
1855
  mask.on("mouseout", mouseout);
@@ -1586,8 +1884,8 @@ var ImageEditor = class {
1586
1884
  * @public
1587
1885
  */
1588
1886
  createMask(config = {}) {
1589
- if (!this.canvas)
1590
- return null;
1887
+ if (!this.canvas) return null;
1888
+ if (!this._canMutateNow("createMask")) return null;
1591
1889
  const shapeType = config.shape || "rect";
1592
1890
  const maskConfig = {
1593
1891
  shape: shapeType,
@@ -1603,33 +1901,37 @@ var ImageEditor = class {
1603
1901
  ...config
1604
1902
  };
1605
1903
  const firstOffset = 10;
1606
- let left = firstOffset;
1607
- let top = firstOffset;
1608
- const resolveValue = (value, fallback) => {
1904
+ let left;
1905
+ let top;
1906
+ const getCanvasBasis = (axis) => {
1907
+ const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
1908
+ const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
1909
+ if (axis === "height") return canvasHeight;
1910
+ if (axis === "min") return Math.min(canvasWidth, canvasHeight);
1911
+ return canvasWidth;
1912
+ };
1913
+ const resolveValue = (value, fallback, axis = "width") => {
1609
1914
  if (typeof value === "function")
1610
1915
  return value(this.canvas, this.options);
1611
1916
  if (typeof value === "string" && value.endsWith("%")) {
1612
- const percent = parseFloat(value) / 100;
1613
- return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1917
+ const percent = Number.parseFloat(value) / 100;
1918
+ if (!Number.isFinite(percent)) return fallback;
1919
+ return Math.floor(getCanvasBasis(axis) * percent);
1614
1920
  }
1615
1921
  return value != null ? value : fallback;
1616
1922
  };
1617
1923
  if (maskConfig.left === void 0 && this._lastMask) {
1618
1924
  const previousMask = this._lastMask;
1619
- let previousMaskRight = previousMask.left;
1620
- if (previousMask.getScaledWidth) {
1621
- previousMaskRight += previousMask.getScaledWidth();
1622
- } else if (previousMask.width) {
1623
- previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1624
- }
1625
- left = Math.round(previousMaskRight + maskConfig.gap);
1626
- top = previousMask.top ?? firstOffset;
1925
+ if (typeof previousMask.setCoords === "function") previousMask.setCoords();
1926
+ const previousBounds = typeof previousMask.getBoundingRect === "function" ? previousMask.getBoundingRect(true, true) : { left: previousMask.left || firstOffset, top: previousMask.top || firstOffset, width: previousMask.width || 0 };
1927
+ left = Math.round(previousBounds.left + previousBounds.width + maskConfig.gap);
1928
+ top = Math.round(previousBounds.top ?? firstOffset);
1627
1929
  } else {
1628
- left = resolveValue(maskConfig.left, firstOffset);
1629
- top = resolveValue(maskConfig.top, firstOffset);
1930
+ left = resolveValue(maskConfig.left, firstOffset, "width");
1931
+ top = resolveValue(maskConfig.top, firstOffset, "height");
1630
1932
  }
1631
- maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1632
- maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1933
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1934
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
1633
1935
  maskConfig.left = left;
1634
1936
  maskConfig.top = top;
1635
1937
  let mask;
@@ -1641,7 +1943,7 @@ var ImageEditor = class {
1641
1943
  mask = new fabric.Circle({
1642
1944
  left,
1643
1945
  top,
1644
- radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1946
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
1645
1947
  fill: maskConfig.color,
1646
1948
  opacity: maskConfig.alpha,
1647
1949
  angle: maskConfig.angle,
@@ -1652,8 +1954,8 @@ var ImageEditor = class {
1652
1954
  mask = new fabric.Ellipse({
1653
1955
  left,
1654
1956
  top,
1655
- rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1656
- ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1957
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
1958
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
1657
1959
  fill: maskConfig.color,
1658
1960
  opacity: maskConfig.alpha,
1659
1961
  angle: maskConfig.angle,
@@ -1680,8 +1982,8 @@ var ImageEditor = class {
1680
1982
  mask = new fabric.Rect({
1681
1983
  left,
1682
1984
  top,
1683
- width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1684
- height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1985
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
1986
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
1685
1987
  fill: maskConfig.color,
1686
1988
  opacity: maskConfig.alpha,
1687
1989
  angle: maskConfig.angle,
@@ -1706,8 +2008,7 @@ var ImageEditor = class {
1706
2008
  opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
1707
2009
  strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
1708
2010
  };
1709
- if (hasStyle("strokeDashArray"))
1710
- maskSettings.strokeDashArray = styles.strokeDashArray;
2011
+ if (hasStyle("strokeDashArray")) maskSettings.strokeDashArray = styles.strokeDashArray;
1711
2012
  mask.set(maskSettings);
1712
2013
  mask.setCoords();
1713
2014
  mask.set({
@@ -1719,7 +2020,7 @@ var ImageEditor = class {
1719
2020
  this._expandCanvasToFitObject(mask);
1720
2021
  this._lastMaskInitialLeft = left;
1721
2022
  this._lastMaskInitialTop = top;
1722
- this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
2023
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
1723
2024
  const maskId = ++this.maskCounter;
1724
2025
  mask.set({
1725
2026
  maskId,
@@ -1728,15 +2029,13 @@ var ImageEditor = class {
1728
2029
  this._lastMask = mask;
1729
2030
  this.canvas.add(mask);
1730
2031
  this.canvas.bringToFront(mask);
1731
- if (maskConfig.selectable)
1732
- this.canvas.setActiveObject(mask);
2032
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1733
2033
  this._handleSelectionChanged([mask]);
1734
2034
  this._updateMaskList();
1735
2035
  this._updateUI();
1736
2036
  this.canvas.renderAll();
1737
2037
  this.saveState();
1738
- if (typeof maskConfig.onCreate === "function")
1739
- maskConfig.onCreate(mask, this.canvas);
2038
+ if (typeof maskConfig.onCreate === "function") maskConfig.onCreate(mask, this.canvas);
1740
2039
  return mask;
1741
2040
  }
1742
2041
  /**
@@ -1754,10 +2053,11 @@ var ImageEditor = class {
1754
2053
  * The associated label is also removed. UI and mask list are updated.
1755
2054
  */
1756
2055
  removeSelectedMask() {
2056
+ if (!this.canvas) return;
2057
+ if (!this._canMutateNow("removeSelectedMask")) return;
1757
2058
  const activeObject = this.canvas.getActiveObject();
1758
2059
  const selectedMasks = this._getModifiedMasks(activeObject);
1759
- if (!selectedMasks.length)
1760
- return;
2060
+ if (!selectedMasks.length) return;
1761
2061
  this.canvas.discardActiveObject();
1762
2062
  selectedMasks.forEach((mask) => {
1763
2063
  this._removeLabelForMask(mask);
@@ -1780,6 +2080,8 @@ var ImageEditor = class {
1780
2080
  * UI and internal mask placement memory are reset.
1781
2081
  */
1782
2082
  removeAllMasks(options = {}) {
2083
+ if (!this.canvas) return;
2084
+ if (!this._canMutateNow("removeAllMasks")) return;
1783
2085
  const saveHistory = options.saveHistory !== false;
1784
2086
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
1785
2087
  masks.forEach((mask) => this._removeLabelForMask(mask));
@@ -1792,8 +2094,7 @@ var ImageEditor = class {
1792
2094
  this._updateMaskList();
1793
2095
  this._updateUI();
1794
2096
  this.canvas.renderAll();
1795
- if (saveHistory)
1796
- this.saveState();
2097
+ if (saveHistory) this.saveState();
1797
2098
  }
1798
2099
  /**
1799
2100
  * Removes the label associated with the specified mask object, if it exists.
@@ -1802,8 +2103,7 @@ var ImageEditor = class {
1802
2103
  * @private
1803
2104
  */
1804
2105
  _removeLabelForMask(mask) {
1805
- if (!mask || !this.canvas)
1806
- return;
2106
+ if (!mask || !this.canvas) return;
1807
2107
  if (mask.__label) {
1808
2108
  try {
1809
2109
  const canvasObjects = this.canvas.getObjects();
@@ -1811,10 +2111,12 @@ var ImageEditor = class {
1811
2111
  this.canvas.remove(mask.__label);
1812
2112
  }
1813
2113
  } catch (error) {
2114
+ void error;
1814
2115
  }
1815
2116
  try {
1816
2117
  delete mask.__label;
1817
2118
  } catch (error) {
2119
+ void error;
1818
2120
  }
1819
2121
  }
1820
2122
  }
@@ -1830,8 +2132,7 @@ var ImageEditor = class {
1830
2132
  */
1831
2133
  _getMaskCreationIndex(mask) {
1832
2134
  const maskId = Number(mask && mask.maskId);
1833
- if (Number.isFinite(maskId) && maskId > 0)
1834
- return Math.floor(maskId) - 1;
2135
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
1835
2136
  const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
1836
2137
  return Math.max(0, masks.indexOf(mask));
1837
2138
  }
@@ -1843,12 +2144,15 @@ var ImageEditor = class {
1843
2144
  * @private
1844
2145
  */
1845
2146
  _createLabelForMask(mask) {
1846
- if (!mask || !this.options.maskLabelOnSelect)
1847
- return;
2147
+ if (!mask || !this.options.maskLabelOnSelect) return;
1848
2148
  this._removeLabelForMask(mask);
1849
2149
  let textObject = null;
1850
2150
  if (this.options.label && typeof this.options.label.create === "function") {
1851
2151
  textObject = this.options.label.create(mask, fabric);
2152
+ if (!textObject || typeof textObject.set !== "function") {
2153
+ this._reportWarning("label.create() returned an invalid Fabric object; using the default label");
2154
+ textObject = null;
2155
+ }
1852
2156
  }
1853
2157
  if (!textObject) {
1854
2158
  let labelText = mask.maskName;
@@ -1886,15 +2190,14 @@ var ImageEditor = class {
1886
2190
  * @private
1887
2191
  */
1888
2192
  _hideAllMaskLabels() {
1889
- if (!this.canvas)
1890
- return;
2193
+ if (!this.canvas) return;
1891
2194
  const canvasObjects = this.canvas.getObjects();
1892
2195
  const labels = canvasObjects.filter((object) => object.maskLabel);
1893
2196
  labels.forEach((label) => {
1894
2197
  try {
1895
- if (canvasObjects.includes(label))
1896
- this.canvas.remove(label);
2198
+ if (canvasObjects.includes(label)) this.canvas.remove(label);
1897
2199
  } catch (error) {
2200
+ void error;
1898
2201
  }
1899
2202
  });
1900
2203
  canvasObjects.forEach((object) => {
@@ -1902,6 +2205,7 @@ var ImageEditor = class {
1902
2205
  try {
1903
2206
  delete object.__label;
1904
2207
  } catch (error) {
2208
+ void error;
1905
2209
  }
1906
2210
  }
1907
2211
  });
@@ -1913,16 +2217,13 @@ var ImageEditor = class {
1913
2217
  * @private
1914
2218
  */
1915
2219
  _syncMaskLabel(mask) {
1916
- if (!mask)
1917
- return;
1918
- if (!this.options.maskLabelOnSelect)
1919
- return;
1920
- if (!mask.__label)
1921
- return;
1922
- const coords = mask.getCoords ? mask.getCoords() : null;
1923
- if (!coords || coords.length < 4)
1924
- return;
1925
- const tl = coords[0];
2220
+ if (!mask) return;
2221
+ if (!this.options.maskLabelOnSelect) return;
2222
+ if (!mask.__label) return;
2223
+ if (typeof mask.setCoords === "function") mask.setCoords();
2224
+ const bounds = mask.getBoundingRect ? mask.getBoundingRect(true, true) : null;
2225
+ if (!bounds) return;
2226
+ const tl = { x: bounds.left, y: bounds.top };
1926
2227
  const center = mask.getCenterPoint();
1927
2228
  const vx = center.x - tl.x;
1928
2229
  const vy = center.y - tl.y;
@@ -1954,12 +2255,9 @@ var ImageEditor = class {
1954
2255
  * @private
1955
2256
  */
1956
2257
  _showLabelForMask(mask) {
1957
- if (!mask)
1958
- return;
1959
- if (!this.options.maskLabelOnSelect)
1960
- return;
1961
- if (!mask.__label)
1962
- this._createLabelForMask(mask);
2258
+ if (!mask) return;
2259
+ if (!this.options.maskLabelOnSelect) return;
2260
+ if (!mask.__label) this._createLabelForMask(mask);
1963
2261
  mask.__label.set({ visible: true });
1964
2262
  this._syncMaskLabel(mask);
1965
2263
  }
@@ -1979,6 +2277,7 @@ var ImageEditor = class {
1979
2277
  try {
1980
2278
  this.canvas.remove(mask.__label);
1981
2279
  } catch (error) {
2280
+ void error;
1982
2281
  }
1983
2282
  delete mask.__label;
1984
2283
  }
@@ -1991,8 +2290,7 @@ var ImageEditor = class {
1991
2290
  mask.set({ stroke: "#ff0000", strokeWidth: 1 });
1992
2291
  }
1993
2292
  });
1994
- if (selectedMask)
1995
- this._showLabelForMask(selectedMask);
2293
+ if (selectedMask) this._showLabelForMask(selectedMask);
1996
2294
  this._updateMaskListSelection(selectedMask);
1997
2295
  this.canvas.renderAll();
1998
2296
  this._updateUI();
@@ -2003,22 +2301,28 @@ var ImageEditor = class {
2003
2301
  * @private
2004
2302
  */
2005
2303
  _updateMaskList() {
2006
- const maskListElement = document.getElementById(this.elements.maskList);
2007
- if (!maskListElement)
2008
- return;
2304
+ const maskListElement = this._getElement("maskList");
2305
+ if (!maskListElement) return;
2009
2306
  maskListElement.innerHTML = "";
2010
2307
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2011
2308
  masks.forEach((mask) => {
2012
2309
  const listItemElement = document.createElement("li");
2013
2310
  listItemElement.className = "list-group-item mask-item";
2014
2311
  listItemElement.textContent = mask.maskName;
2015
- listItemElement.onclick = () => {
2016
- this.canvas.setActiveObject(mask);
2017
- this._handleSelectionChanged([mask]);
2018
- };
2312
+ listItemElement.dataset.maskId = String(mask.maskId);
2019
2313
  maskListElement.appendChild(listItemElement);
2020
2314
  });
2021
2315
  }
2316
+ _handleMaskListClick(event) {
2317
+ if (!this.canvas) return;
2318
+ const itemElement = event.target && event.target.closest ? event.target.closest(".mask-item") : null;
2319
+ if (!itemElement || !itemElement.dataset) return;
2320
+ const maskId = Number(itemElement.dataset.maskId);
2321
+ const mask = this.canvas.getObjects().find((object) => Number(object.maskId) === maskId);
2322
+ if (!mask) return;
2323
+ this.canvas.setActiveObject(mask);
2324
+ this._handleSelectionChanged([mask]);
2325
+ }
2022
2326
  /**
2023
2327
  * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
2024
2328
  *
@@ -2026,13 +2330,13 @@ var ImageEditor = class {
2026
2330
  * @private
2027
2331
  */
2028
2332
  _updateMaskListSelection(selectedMask) {
2029
- const maskListElement = document.getElementById(this.elements.maskList);
2030
- if (!maskListElement)
2031
- return;
2333
+ const maskListElement = this._getElement("maskList");
2334
+ if (!maskListElement) return;
2032
2335
  const maskItems = maskListElement.querySelectorAll(".mask-item");
2033
2336
  maskItems.forEach((item) => {
2034
- const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
2337
+ const isSelected = !!selectedMask && Number(item.dataset.maskId) === Number(selectedMask.maskId);
2035
2338
  item.classList.toggle("active", isSelected);
2339
+ item.classList.toggle("selected", isSelected);
2036
2340
  });
2037
2341
  }
2038
2342
  /**
@@ -2046,22 +2350,22 @@ var ImageEditor = class {
2046
2350
  * @public
2047
2351
  */
2048
2352
  async mergeMasks() {
2049
- if (!this.originalImage)
2050
- return;
2353
+ if (!this.originalImage) return;
2354
+ this._assertIdleForOperation("mergeMasks");
2051
2355
  const masks = this.canvas.getObjects().filter((object) => object.maskId);
2052
- if (!masks.length)
2053
- return;
2356
+ if (!masks.length) return;
2054
2357
  this.canvas.discardActiveObject();
2055
2358
  this.canvas.renderAll();
2056
2359
  try {
2057
2360
  const beforeJson = this._serializeCanvasState();
2058
2361
  const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2059
2362
  this.removeAllMasks({ saveHistory: false });
2060
- await this.loadImage(merged, { preserveScroll: true });
2363
+ await this.loadImage(merged, { preserveScroll: true, resetMaskCounter: false });
2061
2364
  const afterJson = this._serializeCanvasState();
2062
2365
  this._pushStateTransition(beforeJson, afterJson);
2063
2366
  } catch (error) {
2064
2367
  this._reportError("merge error", error);
2368
+ throw error;
2065
2369
  }
2066
2370
  }
2067
2371
  /**
@@ -2082,8 +2386,8 @@ var ImageEditor = class {
2082
2386
  * @public
2083
2387
  */
2084
2388
  downloadImage(fileName = this.options.defaultDownloadFileName) {
2085
- if (!this.originalImage)
2086
- return;
2389
+ if (!this.originalImage) return;
2390
+ if (!this._canMutateNow("downloadImage")) return;
2087
2391
  const exportImageArea = this.options.exportImageAreaByDefault;
2088
2392
  this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
2089
2393
  const link = document.createElement("a");
@@ -2111,8 +2415,8 @@ var ImageEditor = class {
2111
2415
  * @public
2112
2416
  */
2113
2417
  async exportImageBase64(options = {}) {
2114
- if (!this.originalImage)
2115
- throw new Error("No image loaded");
2418
+ if (!this.originalImage) throw new Error("No image loaded");
2419
+ this._assertIdleForOperation("exportImageBase64");
2116
2420
  const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
2117
2421
  const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2118
2422
  const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
@@ -2129,7 +2433,7 @@ var ImageEditor = class {
2129
2433
  this.originalImage.setCoords();
2130
2434
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2131
2435
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2132
- return await this._exportCanvasRegionToDataURL({
2436
+ return this._exportCanvasRegionToDataURL({
2133
2437
  ...exportRegion,
2134
2438
  multiplier,
2135
2439
  quality,
@@ -2140,6 +2444,7 @@ var ImageEditor = class {
2140
2444
  try {
2141
2445
  backup.object.set({ visible: backup.visible });
2142
2446
  } catch (error) {
2447
+ void error;
2143
2448
  }
2144
2449
  });
2145
2450
  this.canvas.renderAll();
@@ -2168,7 +2473,7 @@ var ImageEditor = class {
2168
2473
  this.originalImage.setCoords();
2169
2474
  const imageBounds = this.originalImage.getBoundingRect(true, true);
2170
2475
  const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2171
- finalBase64 = await this._exportCanvasRegionToDataURL({
2476
+ finalBase64 = this._exportCanvasRegionToDataURL({
2172
2477
  ...exportRegion,
2173
2478
  multiplier,
2174
2479
  quality,
@@ -2187,6 +2492,7 @@ var ImageEditor = class {
2187
2492
  });
2188
2493
  backup.object.setCoords();
2189
2494
  } catch (error) {
2495
+ void error;
2190
2496
  }
2191
2497
  });
2192
2498
  this.canvas.renderAll();
@@ -2222,8 +2528,8 @@ var ImageEditor = class {
2222
2528
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
2223
2529
  */
2224
2530
  async exportImageFile(options = {}) {
2225
- if (!this.originalImage)
2226
- throw new Error("No image loaded");
2531
+ if (!this.originalImage) throw new Error("No image loaded");
2532
+ this._assertIdleForOperation("exportImageFile");
2227
2533
  const {
2228
2534
  mergeMask = true,
2229
2535
  fileType = "jpeg",
@@ -2259,6 +2565,7 @@ var ImageEditor = class {
2259
2565
  offscreenCanvas.width = imageElement.width;
2260
2566
  offscreenCanvas.height = imageElement.height;
2261
2567
  const context = offscreenCanvas.getContext("2d");
2568
+ if (!context) throw new Error("Unable to create 2D canvas context for export conversion");
2262
2569
  context.drawImage(imageElement, 0, 0);
2263
2570
  const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2264
2571
  resolve(convertedDataUrl);
@@ -2287,8 +2594,7 @@ var ImageEditor = class {
2287
2594
  }
2288
2595
  async _restoreStateAfterCropFailure(beforeJson, message, error) {
2289
2596
  this._reportError(message, error);
2290
- if (this._cropRect && this.canvas)
2291
- this._removeCropRect();
2597
+ if (this._cropRect && this.canvas) this._removeCropRect();
2292
2598
  this._cropRect = null;
2293
2599
  this._cropMode = false;
2294
2600
  if (this.canvas && this._prevSelectionSetting !== void 0) {
@@ -2303,8 +2609,7 @@ var ImageEditor = class {
2303
2609
  }
2304
2610
  }
2305
2611
  this._updateUI();
2306
- if (this.canvas)
2307
- this.canvas.renderAll();
2612
+ if (this.canvas) this.canvas.renderAll();
2308
2613
  }
2309
2614
  _restoreCropObjectState() {
2310
2615
  if (Array.isArray(this._cropPrevEvented)) {
@@ -2316,27 +2621,31 @@ var ImageEditor = class {
2316
2621
  visible: state.visible
2317
2622
  });
2318
2623
  } catch (error) {
2624
+ void error;
2319
2625
  }
2320
2626
  });
2321
2627
  }
2322
2628
  this._cropPrevEvented = null;
2323
2629
  }
2324
2630
  _removeCropRect() {
2325
- if (!this._cropRect)
2326
- return;
2631
+ if (!this._cropRect) return;
2327
2632
  try {
2328
2633
  if (this._cropHandlers && this._cropHandlers.length) {
2329
2634
  this._cropHandlers.forEach((targetHandlers) => {
2330
2635
  targetHandlers.handlers.forEach((handlerRecord) => {
2331
- targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2636
+ if (targetHandlers.target && typeof targetHandlers.target.off === "function") {
2637
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2638
+ }
2332
2639
  });
2333
2640
  });
2334
2641
  }
2335
2642
  } catch (error) {
2643
+ void error;
2336
2644
  }
2337
2645
  try {
2338
- this.canvas.remove(this._cropRect);
2646
+ if (this.canvas) this.canvas.remove(this._cropRect);
2339
2647
  } catch (error) {
2648
+ void error;
2340
2649
  }
2341
2650
  this._cropRect = null;
2342
2651
  this._cropHandlers = [];
@@ -2351,10 +2660,10 @@ var ImageEditor = class {
2351
2660
  * @public
2352
2661
  */
2353
2662
  enterCropMode() {
2354
- if (!this.canvas || !this.originalImage || this._cropMode)
2355
- return;
2356
- if (!this.isImageLoaded())
2357
- return;
2663
+ if (!this.canvas || !this.originalImage || this._cropMode) return;
2664
+ if (!this._canMutateNow("enterCropMode")) return;
2665
+ if (!this.isImageLoaded()) return;
2666
+ this._removeCropRect();
2358
2667
  this._cropMode = true;
2359
2668
  this._prevSelectionSetting = this.canvas.selection;
2360
2669
  this.canvas.selection = false;
@@ -2406,10 +2715,10 @@ var ImageEditor = class {
2406
2715
  evented: false,
2407
2716
  selectable: false
2408
2717
  };
2409
- if (shouldHideMasks && (object.maskId || object.maskLabel))
2410
- updates.visible = false;
2718
+ if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
2411
2719
  object.set(updates);
2412
2720
  } catch (error) {
2721
+ void error;
2413
2722
  }
2414
2723
  }
2415
2724
  });
@@ -2423,6 +2732,7 @@ var ImageEditor = class {
2423
2732
  cropRect.setCoords();
2424
2733
  this.canvas.requestRenderAll();
2425
2734
  } catch (error) {
2735
+ void error;
2426
2736
  }
2427
2737
  };
2428
2738
  cropRect.on("modified", handleCropRectModified);
@@ -2446,8 +2756,7 @@ var ImageEditor = class {
2446
2756
  * @public
2447
2757
  */
2448
2758
  cancelCrop() {
2449
- if (!this.canvas || !this._cropMode)
2450
- return;
2759
+ if (!this.canvas || !this._cropMode) return;
2451
2760
  this._removeCropRect();
2452
2761
  this._restoreCropObjectState();
2453
2762
  this._cropMode = false;
@@ -2469,14 +2778,14 @@ var ImageEditor = class {
2469
2778
  * @public
2470
2779
  */
2471
2780
  async applyCrop() {
2472
- if (!this.canvas || !this._cropMode || !this._cropRect)
2473
- return;
2781
+ if (!this.canvas || !this._cropMode || !this._cropRect) return;
2782
+ this._assertIdleForOperation("applyCrop");
2474
2783
  this._cropRect.setCoords();
2475
2784
  const rectBounds = this._cropRect.getBoundingRect(true, true);
2476
- const cropRegion = this._getClampedCanvasRegion(rectBounds);
2785
+ const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
2477
2786
  const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2478
2787
  this._restoreCropObjectState();
2479
- let beforeJson = null;
2788
+ let beforeJson;
2480
2789
  try {
2481
2790
  beforeJson = this._serializeCanvasState();
2482
2791
  } catch (error) {
@@ -2495,12 +2804,8 @@ var ImageEditor = class {
2495
2804
  this._removeLabelForMask(mask);
2496
2805
  this.canvas.remove(mask);
2497
2806
  if (shouldPreserveMasks && intersectsCrop) {
2498
- mask.set({
2499
- left: (mask.left || 0) - cropRegion.sourceX,
2500
- top: (mask.top || 0) - cropRegion.sourceY,
2501
- visible: true
2502
- });
2503
- mask.setCoords();
2807
+ this._translateObjectByCanvasOffset(mask, -cropRegion.sourceX, -cropRegion.sourceY);
2808
+ mask.set({ visible: true });
2504
2809
  preservedMasks.push(mask);
2505
2810
  }
2506
2811
  } catch (error) {
@@ -2531,7 +2836,7 @@ var ImageEditor = class {
2531
2836
  return;
2532
2837
  }
2533
2838
  try {
2534
- await this.loadImage(croppedBase64);
2839
+ await this.loadImage(croppedBase64, { resetMaskCounter: false });
2535
2840
  if (preservedMasks.length) {
2536
2841
  preservedMasks.forEach((mask) => {
2537
2842
  this._rebindMaskEvents(mask);
@@ -2547,9 +2852,9 @@ var ImageEditor = class {
2547
2852
  await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
2548
2853
  return;
2549
2854
  }
2550
- let afterJson = null;
2855
+ let afterJson;
2551
2856
  try {
2552
- afterJson = this._serializeCanvasState();
2857
+ afterJson = preservedMasks.length ? this._serializeCanvasState() : this._lastSnapshot;
2553
2858
  } catch (error) {
2554
2859
  this._reportWarning("applyCrop: failed to serialize after state", error);
2555
2860
  afterJson = null;
@@ -2569,9 +2874,8 @@ var ImageEditor = class {
2569
2874
  * @private
2570
2875
  */
2571
2876
  _updateInputs() {
2572
- const scaleInputElement = document.getElementById(this.elements.scaleRate);
2573
- if (scaleInputElement)
2574
- scaleInputElement.value = Math.round(this.currentScale * 100);
2877
+ const scaleInputElement = this._getElement("scaleRate");
2878
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
2575
2879
  }
2576
2880
  /**
2577
2881
  * Updates the enabled/disabled state of various UI controls (buttons)
@@ -2579,6 +2883,7 @@ var ImageEditor = class {
2579
2883
  * @private
2580
2884
  */
2581
2885
  _updateUI() {
2886
+ if (!this.canvas) return;
2582
2887
  const hasImage = !!this.originalImage;
2583
2888
  const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
2584
2889
  const hasMasks = masks.length > 0;
@@ -2590,9 +2895,8 @@ var ImageEditor = class {
2590
2895
  const isInCropMode = !!this._cropMode;
2591
2896
  if (isInCropMode) {
2592
2897
  for (const key of Object.keys(this.elements || {})) {
2593
- const element = document.getElementById(this.elements[key]);
2594
- if (!element)
2595
- continue;
2898
+ const element = this._getElement(key);
2899
+ if (!element) continue;
2596
2900
  if (key === "applyCropBtn" || key === "cancelCropBtn") {
2597
2901
  this._setDisabled(key, false);
2598
2902
  } else {
@@ -2627,9 +2931,8 @@ var ImageEditor = class {
2627
2931
  * @private
2628
2932
  */
2629
2933
  _setDisabled(key, disabled) {
2630
- const element = document.getElementById(this.elements[key]);
2631
- if (!element)
2632
- return;
2934
+ const element = this._getElement(key);
2935
+ if (!element) return;
2633
2936
  if ("disabled" in element) {
2634
2937
  element.disabled = !!disabled;
2635
2938
  return;
@@ -2643,10 +2946,8 @@ var ImageEditor = class {
2643
2946
  }
2644
2947
  }
2645
2948
  _isElementDisabled(element) {
2646
- if (!element)
2647
- return false;
2648
- if ("disabled" in element)
2649
- return !!element.disabled;
2949
+ if (!element) return false;
2950
+ if ("disabled" in element) return !!element.disabled;
2650
2951
  return element.getAttribute("aria-disabled") === "true";
2651
2952
  }
2652
2953
  /**
@@ -2654,8 +2955,7 @@ var ImageEditor = class {
2654
2955
  * @private
2655
2956
  */
2656
2957
  _updatePlaceholderStatus() {
2657
- if (!this.options.showPlaceholder)
2658
- return;
2958
+ if (!this.options.showPlaceholder) return;
2659
2959
  this._setPlaceholderVisible(!this.originalImage);
2660
2960
  }
2661
2961
  /**
@@ -2665,17 +2965,57 @@ var ImageEditor = class {
2665
2965
  * @private
2666
2966
  */
2667
2967
  _setPlaceholderVisible(show) {
2668
- if (!this.placeholderElement || !this.containerElement)
2669
- return;
2670
- if (show) {
2671
- this.placeholderElement.classList.remove("d-none");
2672
- this.placeholderElement.classList.add("d-flex");
2673
- this.containerElement.classList.add("d-none");
2968
+ if (this.placeholderElement) this._setElementVisible(this.placeholderElement, show);
2969
+ const canvasVisibilityElement = this._getCanvasVisibilityElement();
2970
+ if (canvasVisibilityElement && canvasVisibilityElement !== this.placeholderElement) {
2971
+ this._setElementVisible(canvasVisibilityElement, !show);
2972
+ }
2973
+ }
2974
+ _getCanvasVisibilityElement() {
2975
+ const wrapperElement = this.canvas && this.canvas.wrapperEl ? this.canvas.wrapperEl : null;
2976
+ if (this.containerElement && this.placeholderElement && (this.containerElement === this.placeholderElement || this.containerElement.contains(this.placeholderElement))) {
2977
+ return wrapperElement || this.canvasElement;
2978
+ }
2979
+ return this.containerElement || wrapperElement || this.canvasElement;
2980
+ }
2981
+ /**
2982
+ * Updates element visibility.
2983
+ *
2984
+ * @param {HTMLElement} element - Element whose visibility should be updated.
2985
+ * @param {boolean} isVisible - If true, removes the hidden state.
2986
+ * @returns {void}
2987
+ * @private
2988
+ */
2989
+ _setElementVisible(element, isVisible) {
2990
+ if (!element) return;
2991
+ this._rememberElementVisibility(element);
2992
+ element.hidden = !isVisible;
2993
+ element.setAttribute("aria-hidden", isVisible ? "false" : "true");
2994
+ if (element.classList) {
2995
+ element.classList.toggle("d-none", !isVisible);
2996
+ }
2997
+ }
2998
+ _rememberElementVisibility(element) {
2999
+ if (!element || this._visibilityStateByElement.has(element)) return;
3000
+ this._visibilityStateByElement.set(element, this._captureElementVisibility(element));
3001
+ }
3002
+ _captureElementVisibility(element) {
3003
+ if (!element) return null;
3004
+ return {
3005
+ hidden: element.hidden,
3006
+ ariaHidden: element.getAttribute("aria-hidden"),
3007
+ className: element.className
3008
+ };
3009
+ }
3010
+ _restoreElementVisibility(element, state) {
3011
+ if (!element || !state) return;
3012
+ element.hidden = !!state.hidden;
3013
+ if (state.ariaHidden === null) {
3014
+ element.removeAttribute("aria-hidden");
2674
3015
  } else {
2675
- this.placeholderElement.classList.remove("d-flex");
2676
- this.placeholderElement.classList.add("d-none");
2677
- this.containerElement.classList.remove("d-none");
3016
+ element.setAttribute("aria-hidden", state.ariaHidden);
2678
3017
  }
3018
+ element.className = state.className || "";
2679
3019
  }
2680
3020
  /**
2681
3021
  * Cleans up and disposes of the canvas and related references.
@@ -2683,44 +3023,84 @@ var ImageEditor = class {
2683
3023
  * @public
2684
3024
  */
2685
3025
  dispose() {
3026
+ this._disposed = true;
3027
+ this._rejectActiveAnimations(new Error("Editor disposed during animation"));
3028
+ if (this.animationQueue) {
3029
+ this.animationQueue.cancelAll(new Error("Editor disposed"));
3030
+ }
2686
3031
  try {
2687
- for (const key in this._handlersByElementKey || {}) {
2688
- const handlers = this._handlersByElementKey[key] || [];
2689
- const element = document.getElementById(this.elements[key]);
2690
- if (!element)
2691
- continue;
3032
+ for (const [key, handlers] of Object.entries(this._handlersByElementKey || {})) {
3033
+ const element = this._getElement(key);
3034
+ if (!element) continue;
2692
3035
  handlers.forEach((handlerRecord) => {
2693
3036
  try {
2694
3037
  element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
2695
3038
  } catch (error) {
3039
+ void error;
2696
3040
  }
2697
3041
  });
2698
3042
  }
2699
3043
  } catch (error) {
3044
+ void error;
2700
3045
  }
2701
3046
  if (this._cropRect) {
2702
3047
  try {
2703
3048
  this.canvas.remove(this._cropRect);
2704
3049
  } catch (error) {
3050
+ void error;
2705
3051
  }
2706
3052
  this._cropRect = null;
2707
3053
  }
2708
- if (this.containerElement && this._containerOriginalOverflow !== void 0) {
3054
+ if (this.containerElement && this._containerOriginalOverflow) {
3055
+ try {
3056
+ this._restoreContainerOverflowState();
3057
+ } catch (error) {
3058
+ void error;
3059
+ }
3060
+ }
3061
+ if (this._visibilityStateByElement) {
3062
+ try {
3063
+ [this.placeholderElement, this._getCanvasVisibilityElement()].forEach((element) => {
3064
+ const state = element ? this._visibilityStateByElement.get(element) : null;
3065
+ if (state) this._restoreElementVisibility(element, state);
3066
+ });
3067
+ } catch (error) {
3068
+ void error;
3069
+ }
3070
+ }
3071
+ if (this.canvasElement && this._canvasElementOriginalStyle) {
2709
3072
  try {
2710
- this.containerElement.style.overflow = this._containerOriginalOverflow;
3073
+ this.canvasElement.style.display = this._canvasElementOriginalStyle.display;
3074
+ this.canvasElement.style.width = this._canvasElementOriginalStyle.width;
3075
+ this.canvasElement.style.height = this._canvasElementOriginalStyle.height;
2711
3076
  } catch (error) {
3077
+ void error;
2712
3078
  }
2713
3079
  }
2714
3080
  if (this.canvas) {
2715
3081
  try {
2716
3082
  this.canvas.dispose();
2717
3083
  } catch (error) {
3084
+ void error;
2718
3085
  }
2719
3086
  this.canvas = null;
2720
3087
  this.canvasElement = null;
2721
3088
  this.isImageLoadedToCanvas = false;
2722
3089
  }
2723
3090
  this._handlersByElementKey = {};
3091
+ this._elementCache = {};
3092
+ this._clearMaskPlacementMemory();
3093
+ this.originalImage = null;
3094
+ this.baseImageScale = 1;
3095
+ this.currentScale = 1;
3096
+ this.currentRotation = 0;
3097
+ this.isAnimating = false;
3098
+ this._cropMode = false;
3099
+ this._cropRect = null;
3100
+ this._cropHandlers = [];
3101
+ this._cropPrevEvented = null;
3102
+ this._prevSelectionSetting = void 0;
3103
+ this._initialized = false;
2724
3104
  }
2725
3105
  };
2726
3106
  var AnimationQueue = class {
@@ -2730,6 +3110,7 @@ var AnimationQueue = class {
2730
3110
  constructor() {
2731
3111
  this.animationTasks = [];
2732
3112
  this.isRunning = false;
3113
+ this.currentTask = null;
2733
3114
  }
2734
3115
  /**
2735
3116
  * Adds an animation function to the queue.
@@ -2739,12 +3120,29 @@ var AnimationQueue = class {
2739
3120
  */
2740
3121
  async add(animationFn) {
2741
3122
  return new Promise((resolve, reject) => {
2742
- this.animationTasks.push({ animationFn, resolve, reject });
3123
+ this.animationTasks.push({ animationFn, resolve, reject, isSettled: false });
2743
3124
  if (!this.isRunning) {
2744
3125
  this._drainQueue();
2745
3126
  }
2746
3127
  });
2747
3128
  }
3129
+ isBusy() {
3130
+ return this.isRunning || this.animationTasks.length > 0;
3131
+ }
3132
+ cancelAll(reason = new Error("Animation queue cancelled")) {
3133
+ const cancellationError = reason instanceof Error ? reason : new Error(String(reason));
3134
+ const tasks = [
3135
+ ...this.currentTask ? [this.currentTask] : [],
3136
+ ...this.animationTasks.splice(0)
3137
+ ];
3138
+ tasks.forEach((task) => {
3139
+ if (!task || task.isSettled) return;
3140
+ task.isSettled = true;
3141
+ task.reject(cancellationError);
3142
+ });
3143
+ this.isRunning = false;
3144
+ this.currentTask = null;
3145
+ }
2748
3146
  /**
2749
3147
  * Runs queued animation tasks sequentially until the queue is empty.
2750
3148
  *
@@ -2752,19 +3150,27 @@ var AnimationQueue = class {
2752
3150
  * @returns {Promise<void>}
2753
3151
  */
2754
3152
  async _drainQueue() {
2755
- if (this.animationTasks.length === 0) {
2756
- this.isRunning = false;
2757
- return;
2758
- }
3153
+ if (this.isRunning) return;
2759
3154
  this.isRunning = true;
2760
- const { animationFn, resolve, reject } = this.animationTasks.shift();
2761
- try {
2762
- const result = await animationFn();
2763
- resolve(result);
2764
- } catch (error) {
2765
- reject(error);
3155
+ while (this.animationTasks.length > 0) {
3156
+ const task = this.animationTasks.shift();
3157
+ this.currentTask = task;
3158
+ try {
3159
+ const result = await task.animationFn();
3160
+ if (!task.isSettled) {
3161
+ task.isSettled = true;
3162
+ task.resolve(result);
3163
+ }
3164
+ } catch (error) {
3165
+ if (!task.isSettled) {
3166
+ task.isSettled = true;
3167
+ task.reject(error);
3168
+ }
3169
+ } finally {
3170
+ if (this.currentTask === task) this.currentTask = null;
3171
+ }
2766
3172
  }
2767
- await this._drainQueue();
3173
+ this.isRunning = false;
2768
3174
  }
2769
3175
  };
2770
3176
  var Command = class {
@@ -2795,15 +3201,8 @@ var HistoryManager = class {
2795
3201
  * @private
2796
3202
  */
2797
3203
  enqueue(task) {
2798
- const nextTask = this.pending.then(task, task);
2799
- let pendingAfterTask;
2800
- const resetPending = () => {
2801
- if (this.pending === pendingAfterTask) {
2802
- this.pending = Promise.resolve();
2803
- }
2804
- };
2805
- pendingAfterTask = nextTask.then(resetPending, resetPending);
2806
- this.pending = pendingAfterTask;
3204
+ const nextTask = this.pending.then(() => Promise.resolve().then(task));
3205
+ this.pending = nextTask.catch(() => void 0);
2807
3206
  return nextTask;
2808
3207
  }
2809
3208
  /**
@@ -2814,8 +3213,14 @@ var HistoryManager = class {
2814
3213
  * @returns {void}
2815
3214
  */
2816
3215
  execute(command) {
2817
- command.execute();
3216
+ const result = command.execute();
3217
+ if (result && typeof result.then === "function") {
3218
+ return Promise.resolve(result).then(() => {
3219
+ this.push(command);
3220
+ });
3221
+ }
2818
3222
  this.push(command);
3223
+ return result;
2819
3224
  }
2820
3225
  /**
2821
3226
  * Pushes an already-applied command onto the history stack.
@@ -2831,9 +3236,8 @@ var HistoryManager = class {
2831
3236
  this.history.push(command);
2832
3237
  if (this.history.length > this.maxSize) {
2833
3238
  this.history.shift();
2834
- } else {
2835
- this.currentIndex++;
2836
3239
  }
3240
+ this.currentIndex = this.history.length - 1;
2837
3241
  }
2838
3242
  /**
2839
3243
  * Checks whether an undo operation is possible.