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