@bensitu/image-editor 1.2.2 → 1.3.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/README.md +7 -3
- package/dist/image-editor.esm.js +743 -463
- 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 +743 -463
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +743 -463
- 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 +21 -11
- package/package.json +9 -4
- package/src/image-editor.js +825 -338
|
@@ -5,19 +5,16 @@ import fabricModule from "fabric";
|
|
|
5
5
|
/**
|
|
6
6
|
* @file image-editor.js
|
|
7
7
|
* @module image-editor
|
|
8
|
-
* @version 1.
|
|
8
|
+
* @version 1.3.1
|
|
9
9
|
* @author Ben Situ
|
|
10
10
|
* @license MIT
|
|
11
11
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
12
12
|
*/
|
|
13
13
|
var fabric = null;
|
|
14
14
|
function getGlobalScope() {
|
|
15
|
-
if (typeof globalThis !== "undefined")
|
|
16
|
-
|
|
17
|
-
if (typeof
|
|
18
|
-
return self;
|
|
19
|
-
if (typeof window !== "undefined")
|
|
20
|
-
return window;
|
|
15
|
+
if (typeof globalThis !== "undefined") return globalThis;
|
|
16
|
+
if (typeof self !== "undefined") return self;
|
|
17
|
+
if (typeof window !== "undefined") return window;
|
|
21
18
|
return null;
|
|
22
19
|
}
|
|
23
20
|
function getGlobalFabric() {
|
|
@@ -29,8 +26,7 @@ function setFabric(fabricInstance2) {
|
|
|
29
26
|
return fabric;
|
|
30
27
|
}
|
|
31
28
|
function ensureFabric() {
|
|
32
|
-
if (!fabric)
|
|
33
|
-
setFabric();
|
|
29
|
+
if (!fabric) setFabric();
|
|
34
30
|
return fabric;
|
|
35
31
|
}
|
|
36
32
|
var ImageEditor = class {
|
|
@@ -76,6 +72,7 @@ var ImageEditor = class {
|
|
|
76
72
|
downsampleMaxWidth: 4e3,
|
|
77
73
|
downsampleMaxHeight: 3e3,
|
|
78
74
|
downsampleQuality: 0.92,
|
|
75
|
+
imageLoadTimeoutMs: 3e4,
|
|
79
76
|
exportMultiplier: 1,
|
|
80
77
|
exportImageAreaByDefault: true,
|
|
81
78
|
defaultMaskWidth: 50,
|
|
@@ -134,12 +131,16 @@ var ImageEditor = class {
|
|
|
134
131
|
this._cropPrevEvented = null;
|
|
135
132
|
this._prevSelectionSetting = void 0;
|
|
136
133
|
this._containerOriginalOverflow = void 0;
|
|
134
|
+
this._scrollbarSizeCache = null;
|
|
137
135
|
this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
|
|
138
|
-
this.
|
|
136
|
+
this.animationQueue = new AnimationQueue();
|
|
139
137
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
140
138
|
}
|
|
141
139
|
/**
|
|
142
|
-
* @
|
|
140
|
+
* Backward-compatible alias for {@link ImageEditor#canvasElement}.
|
|
141
|
+
*
|
|
142
|
+
* @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
|
|
143
|
+
* @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
|
|
143
144
|
*/
|
|
144
145
|
get canvasEl() {
|
|
145
146
|
return this.canvasElement;
|
|
@@ -148,7 +149,10 @@ var ImageEditor = class {
|
|
|
148
149
|
this.canvasElement = value;
|
|
149
150
|
}
|
|
150
151
|
/**
|
|
151
|
-
* @
|
|
152
|
+
* Backward-compatible alias for {@link ImageEditor#containerElement}.
|
|
153
|
+
*
|
|
154
|
+
* @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
|
|
155
|
+
* @returns {HTMLElement|null} The canvas viewport/container element.
|
|
152
156
|
*/
|
|
153
157
|
get containerEl() {
|
|
154
158
|
return this.containerElement;
|
|
@@ -157,7 +161,10 @@ var ImageEditor = class {
|
|
|
157
161
|
this.containerElement = value;
|
|
158
162
|
}
|
|
159
163
|
/**
|
|
160
|
-
* @
|
|
164
|
+
* Backward-compatible alias for {@link ImageEditor#placeholderElement}.
|
|
165
|
+
*
|
|
166
|
+
* @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
|
|
167
|
+
* @returns {HTMLElement|null} The placeholder element shown before an image loads.
|
|
161
168
|
*/
|
|
162
169
|
get placeholderEl() {
|
|
163
170
|
return this.placeholderElement;
|
|
@@ -171,9 +178,10 @@ var ImageEditor = class {
|
|
|
171
178
|
* Use this method to set up the editor UI before interacting with it.
|
|
172
179
|
*
|
|
173
180
|
* @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
|
|
174
|
-
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
175
|
-
* rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
176
|
-
* zoomInBtn, zoomOutBtn, resetBtn,
|
|
181
|
+
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
182
|
+
* rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
183
|
+
* mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
|
|
184
|
+
* uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
|
|
177
185
|
*
|
|
178
186
|
* @returns {void}
|
|
179
187
|
*
|
|
@@ -186,8 +194,7 @@ var ImageEditor = class {
|
|
|
186
194
|
* });
|
|
187
195
|
*/
|
|
188
196
|
init(idMap = {}) {
|
|
189
|
-
if (!this._fabricLoaded)
|
|
190
|
-
return;
|
|
197
|
+
if (!this._fabricLoaded) return;
|
|
191
198
|
const defaults = {
|
|
192
199
|
canvas: "fabricCanvas",
|
|
193
200
|
canvasContainer: null,
|
|
@@ -228,8 +235,7 @@ var ImageEditor = class {
|
|
|
228
235
|
}
|
|
229
236
|
_reportError(message, error = null) {
|
|
230
237
|
const handler = this.options && this.options.onError;
|
|
231
|
-
if (typeof handler !== "function")
|
|
232
|
-
return;
|
|
238
|
+
if (typeof handler !== "function") return;
|
|
233
239
|
try {
|
|
234
240
|
handler(error, message);
|
|
235
241
|
} catch {
|
|
@@ -237,21 +243,21 @@ var ImageEditor = class {
|
|
|
237
243
|
}
|
|
238
244
|
_reportWarning(message, error = null) {
|
|
239
245
|
const handler = this.options && this.options.onWarning;
|
|
240
|
-
if (typeof handler !== "function")
|
|
241
|
-
return;
|
|
246
|
+
if (typeof handler !== "function") return;
|
|
242
247
|
try {
|
|
243
248
|
handler(error, message);
|
|
244
249
|
} catch {
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
/**
|
|
248
|
-
*
|
|
253
|
+
* Initializes the Fabric canvas, viewport elements, and selection event handlers.
|
|
254
|
+
*
|
|
255
|
+
* @returns {void}
|
|
249
256
|
* @private
|
|
250
257
|
*/
|
|
251
258
|
_initCanvas() {
|
|
252
259
|
const canvasElement = document.getElementById(this.elements.canvas);
|
|
253
|
-
if (!canvasElement)
|
|
254
|
-
throw new Error("Canvas is not found: " + this.elements.canvas);
|
|
260
|
+
if (!canvasElement) throw new Error("Canvas is not found: " + this.elements.canvas);
|
|
255
261
|
this.canvasElement = canvasElement;
|
|
256
262
|
if (this.elements.canvasContainer) {
|
|
257
263
|
const containerElement = document.getElementById(this.elements.canvasContainer);
|
|
@@ -281,57 +287,73 @@ var ImageEditor = class {
|
|
|
281
287
|
this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
|
|
282
288
|
this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
|
|
283
289
|
this.canvas.on("object:moving", (event) => {
|
|
284
|
-
if (event.target && event.target.maskId)
|
|
285
|
-
this._syncMaskLabel(event.target);
|
|
290
|
+
if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
|
|
286
291
|
});
|
|
287
292
|
this.canvas.on("object:scaling", (event) => {
|
|
288
|
-
if (event.target && event.target.maskId)
|
|
289
|
-
this._syncMaskLabel(event.target);
|
|
293
|
+
if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
|
|
290
294
|
});
|
|
291
295
|
this.canvas.on("object:rotating", (event) => {
|
|
292
|
-
if (event.target && event.target.maskId)
|
|
293
|
-
this._syncMaskLabel(event.target);
|
|
296
|
+
if (event.target && event.target.maskId) this._syncMaskLabel(event.target);
|
|
294
297
|
});
|
|
295
298
|
this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
|
|
296
299
|
this.canvasElement.style.display = "block";
|
|
297
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Records a history entry after Fabric finishes modifying one or more masks.
|
|
303
|
+
*
|
|
304
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
|
|
305
|
+
* @returns {void}
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
298
308
|
_handleObjectModified(target) {
|
|
299
309
|
const masks = this._getModifiedMasks(target);
|
|
300
|
-
if (!masks.length)
|
|
301
|
-
return;
|
|
310
|
+
if (!masks.length) return;
|
|
302
311
|
masks.forEach((mask) => {
|
|
303
|
-
if (typeof mask.setCoords === "function")
|
|
304
|
-
mask.setCoords();
|
|
312
|
+
if (typeof mask.setCoords === "function") mask.setCoords();
|
|
305
313
|
this._syncMaskLabel(mask);
|
|
306
|
-
this._expandCanvasToFitObject(mask);
|
|
307
314
|
});
|
|
315
|
+
this._expandCanvasToFitObjects(masks);
|
|
308
316
|
this.saveState();
|
|
309
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Extracts editable mask objects from a Fabric modification target.
|
|
320
|
+
*
|
|
321
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
|
|
322
|
+
* @returns {Array<fabric.Object>} Modified mask objects.
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
310
325
|
_getModifiedMasks(target) {
|
|
311
|
-
if (!target)
|
|
312
|
-
|
|
313
|
-
if (target.maskId)
|
|
314
|
-
return [target];
|
|
326
|
+
if (!target) return [];
|
|
327
|
+
if (target.maskId) return [target];
|
|
315
328
|
const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
|
|
316
329
|
return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
|
|
317
330
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Updates container overflow behavior for fit and cover image modes.
|
|
333
|
+
*
|
|
334
|
+
* @param {Object} [options={}] - Overflow update options.
|
|
335
|
+
* @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
|
|
336
|
+
* @returns {void}
|
|
337
|
+
* @private
|
|
338
|
+
*/
|
|
339
|
+
_syncContainerOverflow(options = {}) {
|
|
340
|
+
if (!this.containerElement || !this.containerElement.style) return;
|
|
321
341
|
if (this._containerOriginalOverflow === void 0) {
|
|
322
342
|
this._containerOriginalOverflow = this.containerElement.style.overflow || "";
|
|
323
343
|
}
|
|
344
|
+
const shouldPreserveScroll = options.preserveScroll === true;
|
|
324
345
|
if (this.options.coverImageToCanvas) {
|
|
325
|
-
const shouldResetScroll = !this.isImageLoadedToCanvas;
|
|
326
346
|
this.containerElement.style.overflow = "scroll";
|
|
327
|
-
if (
|
|
347
|
+
if (!shouldPreserveScroll) {
|
|
328
348
|
this.containerElement.scrollLeft = 0;
|
|
329
349
|
this.containerElement.scrollTop = 0;
|
|
330
350
|
}
|
|
331
351
|
} else if (this.options.fitImageToCanvas) {
|
|
332
352
|
this.containerElement.style.overflow = "auto";
|
|
333
|
-
|
|
334
|
-
|
|
353
|
+
if (!shouldPreserveScroll) {
|
|
354
|
+
this.containerElement.scrollLeft = 0;
|
|
355
|
+
this.containerElement.scrollTop = 0;
|
|
356
|
+
}
|
|
335
357
|
} else {
|
|
336
358
|
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
337
359
|
}
|
|
@@ -343,14 +365,12 @@ var ImageEditor = class {
|
|
|
343
365
|
_bindEvents() {
|
|
344
366
|
this._bindIfExists("uploadArea", "click", () => {
|
|
345
367
|
const uploadAreaElement = document.getElementById(this.elements.uploadArea);
|
|
346
|
-
if (this._isElementDisabled(uploadAreaElement))
|
|
347
|
-
return;
|
|
368
|
+
if (this._isElementDisabled(uploadAreaElement)) return;
|
|
348
369
|
document.getElementById(this.elements.imageInput)?.click();
|
|
349
370
|
});
|
|
350
371
|
this._bindIfExists("imageInput", "change", (event) => {
|
|
351
372
|
const file = event.target.files && event.target.files[0];
|
|
352
|
-
if (file)
|
|
353
|
-
this._loadImageFile(file);
|
|
373
|
+
if (file) this._loadImageFile(file);
|
|
354
374
|
});
|
|
355
375
|
this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
|
|
356
376
|
this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
|
|
@@ -369,8 +389,7 @@ var ImageEditor = class {
|
|
|
369
389
|
let step = this.options.rotationStep;
|
|
370
390
|
if (rotationInputElement) {
|
|
371
391
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
372
|
-
if (!isNaN(parsedStep))
|
|
373
|
-
step = parsedStep;
|
|
392
|
+
if (!isNaN(parsedStep)) step = parsedStep;
|
|
374
393
|
}
|
|
375
394
|
this.rotateImage(this.currentRotation - step);
|
|
376
395
|
});
|
|
@@ -379,8 +398,7 @@ var ImageEditor = class {
|
|
|
379
398
|
let step = this.options.rotationStep;
|
|
380
399
|
if (rotationInputElement) {
|
|
381
400
|
const parsedStep = parseFloat(rotationInputElement.value);
|
|
382
|
-
if (!isNaN(parsedStep))
|
|
383
|
-
step = parsedStep;
|
|
401
|
+
if (!isNaN(parsedStep)) step = parsedStep;
|
|
384
402
|
}
|
|
385
403
|
this.rotateImage(this.currentRotation + step);
|
|
386
404
|
});
|
|
@@ -390,12 +408,12 @@ var ImageEditor = class {
|
|
|
390
408
|
});
|
|
391
409
|
this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
|
|
392
410
|
}
|
|
393
|
-
/**
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* @param {
|
|
397
|
-
* @param {
|
|
398
|
-
* @param {
|
|
411
|
+
/**
|
|
412
|
+
* Binds a DOM event listener when the configured element exists and records it for disposal.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} key - Key in this.elements for the target DOM element.
|
|
415
|
+
* @param {string} eventName - DOM event name to listen for.
|
|
416
|
+
* @param {EventListener} handler - Event listener callback.
|
|
399
417
|
* @private
|
|
400
418
|
*/
|
|
401
419
|
_bindIfExists(key, eventName, handler) {
|
|
@@ -403,20 +421,18 @@ var ImageEditor = class {
|
|
|
403
421
|
if (element) {
|
|
404
422
|
element.addEventListener(eventName, handler);
|
|
405
423
|
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
406
|
-
if (!this._handlersByElementKey[key])
|
|
407
|
-
this._handlersByElementKey[key] = [];
|
|
424
|
+
if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
|
|
408
425
|
this._handlersByElementKey[key].push({ eventName, handler });
|
|
409
426
|
}
|
|
410
427
|
}
|
|
411
|
-
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* @param {File} file
|
|
428
|
+
/**
|
|
429
|
+
* Reads an image File as a data URL and loads it into the Fabric canvas.
|
|
430
|
+
*
|
|
431
|
+
* @param {File} file - Image file selected by the user.
|
|
415
432
|
* @private
|
|
416
433
|
*/
|
|
417
434
|
_loadImageFile(file) {
|
|
418
|
-
if (!file || !file.type.startsWith("image/"))
|
|
419
|
-
return;
|
|
435
|
+
if (!file || !file.type.startsWith("image/")) return;
|
|
420
436
|
const reader = new FileReader();
|
|
421
437
|
reader.onload = (event) => this.loadImage(event.target.result);
|
|
422
438
|
reader.onerror = (event) => {
|
|
@@ -425,19 +441,38 @@ var ImageEditor = class {
|
|
|
425
441
|
reader.readAsDataURL(file);
|
|
426
442
|
}
|
|
427
443
|
/**
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
* @
|
|
444
|
+
* Warns when more than one mutually exclusive image layout mode is enabled.
|
|
445
|
+
*
|
|
446
|
+
* @returns {void}
|
|
447
|
+
* @private
|
|
431
448
|
*/
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
449
|
+
_warnOnImageLayoutOptionConflict() {
|
|
450
|
+
const activeModes = [
|
|
451
|
+
["fitImageToCanvas", this.options.fitImageToCanvas],
|
|
452
|
+
["coverImageToCanvas", this.options.coverImageToCanvas],
|
|
453
|
+
["expandCanvasToImage", this.options.expandCanvasToImage]
|
|
454
|
+
].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
|
|
455
|
+
if (activeModes.length <= 1) return;
|
|
456
|
+
this._reportWarning(
|
|
457
|
+
`Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Loads a base64 data URL into the Fabric canvas as the base image.
|
|
462
|
+
*
|
|
463
|
+
* @async
|
|
464
|
+
* @param {string} imageBase64 - Image data URL beginning with `data:image/`.
|
|
465
|
+
* @param {LoadImageOptions} [options={}] - Optional load behavior.
|
|
466
|
+
* @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
|
|
467
|
+
* @public
|
|
468
|
+
*/
|
|
469
|
+
async loadImage(imageBase64, options = {}) {
|
|
470
|
+
if (!this._fabricLoaded) return;
|
|
471
|
+
if (!this.canvas) return;
|
|
472
|
+
if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/")) return;
|
|
473
|
+
this._warnOnImageLayoutOptionConflict();
|
|
439
474
|
this._setPlaceholderVisible(false);
|
|
440
|
-
this._syncContainerOverflow();
|
|
475
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
441
476
|
const imageElement = await this._createImageElement(imageBase64);
|
|
442
477
|
let loadSource = imageBase64;
|
|
443
478
|
if (this.options.downsampleOnLoad) {
|
|
@@ -455,8 +490,7 @@ var ImageEditor = class {
|
|
|
455
490
|
return new Promise((resolve, reject) => {
|
|
456
491
|
fabric.Image.fromURL(loadSource, (fabricImage) => {
|
|
457
492
|
try {
|
|
458
|
-
if (!fabricImage)
|
|
459
|
-
throw new Error("Image could not be loaded");
|
|
493
|
+
if (!fabricImage) throw new Error("Image could not be loaded");
|
|
460
494
|
this.canvas.discardActiveObject();
|
|
461
495
|
this._hideAllMaskLabels();
|
|
462
496
|
this.canvas.clear();
|
|
@@ -468,8 +502,8 @@ var ImageEditor = class {
|
|
|
468
502
|
const minWidth = viewport.width;
|
|
469
503
|
const minHeight = viewport.height;
|
|
470
504
|
if (this.options.fitImageToCanvas) {
|
|
471
|
-
const canvasWidth = Math.max(1,
|
|
472
|
-
const canvasHeight = Math.max(1,
|
|
505
|
+
const canvasWidth = Math.max(1, minWidth - 1);
|
|
506
|
+
const canvasHeight = Math.max(1, minHeight - 1);
|
|
473
507
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
474
508
|
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
475
509
|
fabricImage.set({ left: 0, top: 0 });
|
|
@@ -539,22 +573,34 @@ var ImageEditor = class {
|
|
|
539
573
|
* Creates an HTMLImageElement from a given data URL.
|
|
540
574
|
*
|
|
541
575
|
* @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
|
|
576
|
+
* @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
|
|
542
577
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
543
578
|
* @private
|
|
544
579
|
*/
|
|
545
|
-
_createImageElement(dataUrl) {
|
|
580
|
+
_createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
546
581
|
return new Promise((resolve, reject) => {
|
|
547
582
|
const imageElement = new Image();
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
583
|
+
let isSettled = false;
|
|
584
|
+
const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
585
|
+
let timerId;
|
|
586
|
+
const settle = (callback) => {
|
|
587
|
+
if (isSettled) return;
|
|
588
|
+
isSettled = true;
|
|
589
|
+
clearTimeout(timerId);
|
|
554
590
|
imageElement.onload = null;
|
|
555
591
|
imageElement.onerror = null;
|
|
556
|
-
|
|
592
|
+
callback();
|
|
557
593
|
};
|
|
594
|
+
timerId = setTimeout(() => {
|
|
595
|
+
settle(() => reject(new Error("Image load timed out")));
|
|
596
|
+
try {
|
|
597
|
+
imageElement.src = "";
|
|
598
|
+
} catch (error) {
|
|
599
|
+
void error;
|
|
600
|
+
}
|
|
601
|
+
}, safeTimeoutMs);
|
|
602
|
+
imageElement.onload = () => settle(() => resolve(imageElement));
|
|
603
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
558
604
|
imageElement.src = dataUrl;
|
|
559
605
|
});
|
|
560
606
|
}
|
|
@@ -573,6 +619,7 @@ var ImageEditor = class {
|
|
|
573
619
|
offscreenCanvas.width = targetWidth;
|
|
574
620
|
offscreenCanvas.height = targetHeight;
|
|
575
621
|
const context = offscreenCanvas.getContext("2d");
|
|
622
|
+
if (!context) throw new Error("2D canvas context is unavailable");
|
|
576
623
|
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
577
624
|
return offscreenCanvas.toDataURL("image/jpeg", quality);
|
|
578
625
|
}
|
|
@@ -580,28 +627,26 @@ var ImageEditor = class {
|
|
|
580
627
|
* Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
|
|
581
628
|
* Also updates the corresponding style attributes.
|
|
582
629
|
*
|
|
583
|
-
* @param {number}
|
|
584
|
-
* @param {number}
|
|
630
|
+
* @param {number} width - Canvas width in pixels.
|
|
631
|
+
* @param {number} height - Canvas height in pixels.
|
|
585
632
|
* @private
|
|
586
633
|
*/
|
|
587
|
-
_setCanvasSizeInt(
|
|
588
|
-
const
|
|
589
|
-
const
|
|
590
|
-
this.canvas.setWidth(
|
|
591
|
-
this.canvas.setHeight(
|
|
592
|
-
if (typeof this.canvas.calcOffset === "function")
|
|
593
|
-
this.canvas.calcOffset();
|
|
634
|
+
_setCanvasSizeInt(width, height) {
|
|
635
|
+
const integerWidth = Math.max(1, Math.round(Number(width) || 1));
|
|
636
|
+
const integerHeight = Math.max(1, Math.round(Number(height) || 1));
|
|
637
|
+
this.canvas.setWidth(integerWidth);
|
|
638
|
+
this.canvas.setHeight(integerHeight);
|
|
639
|
+
if (typeof this.canvas.calcOffset === "function") this.canvas.calcOffset();
|
|
594
640
|
if (this.canvasElement) {
|
|
595
|
-
this.canvasElement.style.width =
|
|
596
|
-
this.canvasElement.style.height =
|
|
641
|
+
this.canvasElement.style.width = integerWidth + "px";
|
|
642
|
+
this.canvasElement.style.height = integerHeight + "px";
|
|
597
643
|
this.canvasElement.style.maxWidth = "none";
|
|
598
644
|
}
|
|
599
645
|
}
|
|
600
646
|
_ceilCanvasDimension(value) {
|
|
601
647
|
const numericValue = Number(value) || 0;
|
|
602
648
|
const roundedValue = Math.round(numericValue);
|
|
603
|
-
if (Math.abs(numericValue - roundedValue) < 0.01)
|
|
604
|
-
return roundedValue;
|
|
649
|
+
if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
|
|
605
650
|
return Math.ceil(numericValue);
|
|
606
651
|
}
|
|
607
652
|
_getContainerViewportSize() {
|
|
@@ -611,22 +656,31 @@ var ImageEditor = class {
|
|
|
611
656
|
height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
|
|
612
657
|
};
|
|
613
658
|
}
|
|
659
|
+
let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
660
|
+
let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
614
661
|
if (this._hasFixedContainerScrollbars()) {
|
|
615
|
-
return {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
662
|
+
return { width, height };
|
|
663
|
+
}
|
|
664
|
+
const overflow = this._getContainerOverflowValues();
|
|
665
|
+
const canScrollX = overflow.x.some((value) => value === "auto" || value === "scroll");
|
|
666
|
+
const canScrollY = overflow.y.some((value) => value === "auto" || value === "scroll");
|
|
667
|
+
const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
|
|
668
|
+
const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
|
|
669
|
+
if (hasHorizontalScrollbar || hasVerticalScrollbar) {
|
|
670
|
+
const scrollbar = this._getScrollbarSize();
|
|
671
|
+
if (hasVerticalScrollbar) width += scrollbar.width;
|
|
672
|
+
if (hasHorizontalScrollbar) height += scrollbar.height;
|
|
619
673
|
}
|
|
620
|
-
const previousOverflow = this.containerElement.style.overflow;
|
|
621
|
-
this.containerElement.style.overflow = "hidden";
|
|
622
|
-
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
623
|
-
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
624
|
-
this.containerElement.style.overflow = previousOverflow;
|
|
625
674
|
return { width, height };
|
|
626
675
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Reads inline and computed overflow values for both scroll axes.
|
|
678
|
+
*
|
|
679
|
+
* @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
|
|
680
|
+
* @private
|
|
681
|
+
*/
|
|
682
|
+
_getContainerOverflowValues() {
|
|
683
|
+
if (!this.containerElement) return { x: [], y: [] };
|
|
630
684
|
const inlineOverflow = this.containerElement.style.overflow;
|
|
631
685
|
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
632
686
|
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
@@ -639,9 +693,20 @@ var ImageEditor = class {
|
|
|
639
693
|
computedOverflowX = style.overflowX;
|
|
640
694
|
computedOverflowY = style.overflowY;
|
|
641
695
|
}
|
|
642
|
-
return
|
|
696
|
+
return {
|
|
697
|
+
x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
|
|
698
|
+
y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
_hasFixedContainerScrollbars() {
|
|
702
|
+
if (!this.containerElement) return false;
|
|
703
|
+
const overflow = this._getContainerOverflowValues();
|
|
704
|
+
return [...overflow.x, ...overflow.y].some((value) => value === "scroll");
|
|
643
705
|
}
|
|
644
706
|
_getScrollbarSize() {
|
|
707
|
+
if (this._scrollbarSizeCache) {
|
|
708
|
+
return { ...this._scrollbarSizeCache };
|
|
709
|
+
}
|
|
645
710
|
if (typeof document === "undefined" || !document.createElement || !document.body) {
|
|
646
711
|
return { width: 0, height: 0 };
|
|
647
712
|
}
|
|
@@ -656,7 +721,8 @@ var ImageEditor = class {
|
|
|
656
721
|
const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
|
657
722
|
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
658
723
|
document.body.removeChild(probe);
|
|
659
|
-
|
|
724
|
+
this._scrollbarSizeCache = { width, height };
|
|
725
|
+
return { ...this._scrollbarSizeCache };
|
|
660
726
|
}
|
|
661
727
|
_getScrollSafetyMargin() {
|
|
662
728
|
return 2;
|
|
@@ -678,15 +744,14 @@ var ImageEditor = class {
|
|
|
678
744
|
const scrollbar = this._getScrollbarSize();
|
|
679
745
|
let hasVertical = false;
|
|
680
746
|
let hasHorizontal = false;
|
|
681
|
-
let effectiveWidth
|
|
682
|
-
let effectiveHeight
|
|
747
|
+
let effectiveWidth;
|
|
748
|
+
let effectiveHeight;
|
|
683
749
|
for (let i = 0; i < 4; i += 1) {
|
|
684
750
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
685
751
|
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
686
752
|
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
687
753
|
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
688
|
-
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
689
|
-
break;
|
|
754
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
|
|
690
755
|
hasVertical = nextHasVertical;
|
|
691
756
|
hasHorizontal = nextHasHorizontal;
|
|
692
757
|
}
|
|
@@ -723,8 +788,8 @@ var ImageEditor = class {
|
|
|
723
788
|
let scale = 1;
|
|
724
789
|
let contentWidth = imageWidth;
|
|
725
790
|
let contentHeight = imageHeight;
|
|
726
|
-
let effectiveWidth
|
|
727
|
-
let effectiveHeight
|
|
791
|
+
let effectiveWidth;
|
|
792
|
+
let effectiveHeight;
|
|
728
793
|
for (let i = 0; i < 4; i += 1) {
|
|
729
794
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
730
795
|
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
@@ -733,8 +798,7 @@ var ImageEditor = class {
|
|
|
733
798
|
contentHeight = imageHeight * scale;
|
|
734
799
|
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
735
800
|
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
736
|
-
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
737
|
-
break;
|
|
801
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
|
|
738
802
|
hasVertical = nextHasVertical;
|
|
739
803
|
hasHorizontal = nextHasHorizontal;
|
|
740
804
|
}
|
|
@@ -773,41 +837,48 @@ var ImageEditor = class {
|
|
|
773
837
|
stroke: mask && mask.originalStroke || "#ccc",
|
|
774
838
|
strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
|
|
775
839
|
};
|
|
776
|
-
if (Number.isFinite(opacity))
|
|
777
|
-
style.opacity = opacity;
|
|
840
|
+
if (Number.isFinite(opacity)) style.opacity = opacity;
|
|
778
841
|
return style;
|
|
779
842
|
}
|
|
780
843
|
_withNormalizedMaskStyles(callback) {
|
|
781
|
-
if (!this.canvas)
|
|
782
|
-
return callback();
|
|
844
|
+
if (!this.canvas) return callback();
|
|
783
845
|
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
784
|
-
const maskStyleBackups =
|
|
785
|
-
object: mask,
|
|
786
|
-
stroke: mask.stroke,
|
|
787
|
-
strokeWidth: mask.strokeWidth,
|
|
788
|
-
opacity: mask.opacity
|
|
789
|
-
}));
|
|
846
|
+
const maskStyleBackups = [];
|
|
790
847
|
try {
|
|
791
848
|
masks.forEach((mask) => {
|
|
792
|
-
|
|
849
|
+
const normalStyle = this._getMaskNormalStyle(mask);
|
|
850
|
+
const stylePatch = {};
|
|
851
|
+
Object.keys(normalStyle).forEach((property) => {
|
|
852
|
+
if (mask[property] !== normalStyle[property]) {
|
|
853
|
+
stylePatch[property] = normalStyle[property];
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
const changedProperties = Object.keys(stylePatch);
|
|
857
|
+
if (!changedProperties.length) return;
|
|
858
|
+
const backup = { object: mask };
|
|
859
|
+
changedProperties.forEach((property) => {
|
|
860
|
+
backup[property] = mask[property];
|
|
861
|
+
});
|
|
862
|
+
maskStyleBackups.push(backup);
|
|
863
|
+
mask.set(stylePatch);
|
|
793
864
|
});
|
|
794
865
|
return callback();
|
|
795
866
|
} finally {
|
|
796
867
|
maskStyleBackups.forEach((backup) => {
|
|
797
868
|
try {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
opacity: backup.opacity
|
|
869
|
+
const restorePatch = {};
|
|
870
|
+
Object.keys(backup).forEach((property) => {
|
|
871
|
+
if (property !== "object") restorePatch[property] = backup[property];
|
|
802
872
|
});
|
|
873
|
+
backup.object.set(restorePatch);
|
|
803
874
|
} catch (error) {
|
|
875
|
+
void error;
|
|
804
876
|
}
|
|
805
877
|
});
|
|
806
878
|
}
|
|
807
879
|
}
|
|
808
880
|
_restoreMaskControls(mask) {
|
|
809
|
-
if (!mask)
|
|
810
|
-
return;
|
|
881
|
+
if (!mask) return;
|
|
811
882
|
const cornerSize = Number(mask.cornerSize);
|
|
812
883
|
mask.set({
|
|
813
884
|
selectable: mask.selectable !== false,
|
|
@@ -820,26 +891,57 @@ var ImageEditor = class {
|
|
|
820
891
|
transparentCorners: mask.transparentCorners === true,
|
|
821
892
|
strokeUniform: mask.strokeUniform !== false
|
|
822
893
|
});
|
|
823
|
-
if (typeof mask.setCoords === "function")
|
|
824
|
-
|
|
894
|
+
if (typeof mask.setCoords === "function") mask.setCoords();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Captures editor-owned runtime state that Fabric does not include in canvas JSON.
|
|
898
|
+
*
|
|
899
|
+
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
|
|
900
|
+
* @private
|
|
901
|
+
*/
|
|
902
|
+
_serializeEditorMetadata() {
|
|
903
|
+
const baseImageScale = Number(this.baseImageScale);
|
|
904
|
+
const currentScale = Number(this.currentScale);
|
|
905
|
+
const currentRotation = Number(this.currentRotation);
|
|
906
|
+
const maskCounter = Number(this.maskCounter);
|
|
907
|
+
return {
|
|
908
|
+
version: 1,
|
|
909
|
+
baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
|
|
910
|
+
currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
|
|
911
|
+
currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
|
|
912
|
+
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
|
|
913
|
+
};
|
|
825
914
|
}
|
|
826
915
|
_serializeCanvasState() {
|
|
827
|
-
if (!this.canvas)
|
|
828
|
-
return null;
|
|
916
|
+
if (!this.canvas) return null;
|
|
829
917
|
return this._withNormalizedMaskStyles(() => {
|
|
830
918
|
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
831
919
|
if (Array.isArray(jsonObject.objects)) {
|
|
832
920
|
jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
|
|
833
921
|
}
|
|
922
|
+
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
834
923
|
return JSON.stringify(jsonObject);
|
|
835
924
|
});
|
|
836
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
|
|
928
|
+
*
|
|
929
|
+
* @param {number} quality - Requested image quality.
|
|
930
|
+
* @returns {number} A finite quality value between 0 and 1.
|
|
931
|
+
* @private
|
|
932
|
+
*/
|
|
837
933
|
_normalizeQuality(quality) {
|
|
838
934
|
const numericQuality = Number(quality);
|
|
839
|
-
if (!Number.isFinite(numericQuality))
|
|
840
|
-
return this.options.downsampleQuality ?? 0.92;
|
|
935
|
+
if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
|
|
841
936
|
return Math.max(0, Math.min(1, numericQuality));
|
|
842
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* Normalizes public image format aliases to canvas export format names.
|
|
940
|
+
*
|
|
941
|
+
* @param {string} format - Requested image format or MIME type.
|
|
942
|
+
* @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
|
|
943
|
+
* @private
|
|
944
|
+
*/
|
|
843
945
|
_normalizeImageFormat(format) {
|
|
844
946
|
const typeMapping = {
|
|
845
947
|
"jpeg": "jpeg",
|
|
@@ -852,6 +954,15 @@ var ImageEditor = class {
|
|
|
852
954
|
};
|
|
853
955
|
return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
|
|
854
956
|
}
|
|
957
|
+
/**
|
|
958
|
+
* Converts a bounding rectangle into a canvas-safe integer source region.
|
|
959
|
+
*
|
|
960
|
+
* @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
|
|
961
|
+
* @param {Object} [options={}] - Region rounding options.
|
|
962
|
+
* @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
|
|
963
|
+
* @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
|
|
964
|
+
* @private
|
|
965
|
+
*/
|
|
855
966
|
_getClampedCanvasRegion(bounds, options = {}) {
|
|
856
967
|
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
857
968
|
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
@@ -866,15 +977,49 @@ var ImageEditor = class {
|
|
|
866
977
|
const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
|
|
867
978
|
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
868
979
|
return {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
980
|
+
sourceX,
|
|
981
|
+
sourceY,
|
|
982
|
+
sourceWidth: Math.max(1, endX - sourceX),
|
|
983
|
+
sourceHeight: Math.max(1, endY - sourceY)
|
|
873
984
|
};
|
|
874
985
|
}
|
|
986
|
+
/**
|
|
987
|
+
* Crops an image data URL to a source region using an offscreen canvas.
|
|
988
|
+
*
|
|
989
|
+
* @param {string} dataUrl - Source image data URL.
|
|
990
|
+
* @param {number} sourceX - Source region x coordinate.
|
|
991
|
+
* @param {number} sourceY - Source region y coordinate.
|
|
992
|
+
* @param {number} sourceWidth - Source region width.
|
|
993
|
+
* @param {number} sourceHeight - Source region height.
|
|
994
|
+
* @param {number} multiplier - Export multiplier already applied to the source data URL.
|
|
995
|
+
* @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
|
|
996
|
+
* @param {number} [quality=0.92] - Output image quality for lossy formats.
|
|
997
|
+
* @returns {Promise<string>} Resolves with the cropped image data URL.
|
|
998
|
+
* @private
|
|
999
|
+
*/
|
|
875
1000
|
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
|
|
876
1001
|
return new Promise((resolve, reject) => {
|
|
877
1002
|
const imageElement = new Image();
|
|
1003
|
+
let isSettled = false;
|
|
1004
|
+
const timeoutMs = Number(this.options.imageLoadTimeoutMs);
|
|
1005
|
+
const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
|
|
1006
|
+
let timerId;
|
|
1007
|
+
const settle = (callback) => {
|
|
1008
|
+
if (isSettled) return;
|
|
1009
|
+
isSettled = true;
|
|
1010
|
+
clearTimeout(timerId);
|
|
1011
|
+
imageElement.onload = null;
|
|
1012
|
+
imageElement.onerror = null;
|
|
1013
|
+
callback();
|
|
1014
|
+
};
|
|
1015
|
+
timerId = setTimeout(() => {
|
|
1016
|
+
settle(() => reject(new Error("Image crop load timed out")));
|
|
1017
|
+
try {
|
|
1018
|
+
imageElement.src = "";
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
void error;
|
|
1021
|
+
}
|
|
1022
|
+
}, safeTimeoutMs);
|
|
878
1023
|
imageElement.onload = () => {
|
|
879
1024
|
try {
|
|
880
1025
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
@@ -886,24 +1031,39 @@ var ImageEditor = class {
|
|
|
886
1031
|
offscreenCanvas.width = scaledSourceWidth;
|
|
887
1032
|
offscreenCanvas.height = scaledSourceHeight;
|
|
888
1033
|
const context = offscreenCanvas.getContext("2d");
|
|
1034
|
+
if (!context) throw new Error("2D canvas context is unavailable");
|
|
889
1035
|
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
890
|
-
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
1036
|
+
settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
|
|
891
1037
|
} catch (error) {
|
|
892
|
-
reject(error);
|
|
1038
|
+
settle(() => reject(error));
|
|
893
1039
|
}
|
|
894
1040
|
};
|
|
895
|
-
imageElement.onerror = reject;
|
|
1041
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
896
1042
|
imageElement.src = dataUrl;
|
|
897
1043
|
});
|
|
898
1044
|
}
|
|
899
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* Exports the whole Fabric canvas, then crops the requested source region from that export.
|
|
1047
|
+
*
|
|
1048
|
+
* @param {Object} region - Canvas source region and export options.
|
|
1049
|
+
* @param {number} region.sourceX - Source region x coordinate.
|
|
1050
|
+
* @param {number} region.sourceY - Source region y coordinate.
|
|
1051
|
+
* @param {number} region.sourceWidth - Source region width.
|
|
1052
|
+
* @param {number} region.sourceHeight - Source region height.
|
|
1053
|
+
* @param {number} [region.multiplier=1] - Export multiplier.
|
|
1054
|
+
* @param {number} [region.quality=0.92] - Output image quality for lossy formats.
|
|
1055
|
+
* @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
|
|
1056
|
+
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1057
|
+
* @private
|
|
1058
|
+
*/
|
|
1059
|
+
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
|
|
900
1060
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
901
1061
|
const fullDataUrl = this.canvas.toDataURL({
|
|
902
1062
|
format,
|
|
903
1063
|
quality,
|
|
904
1064
|
multiplier: safeMultiplier
|
|
905
1065
|
});
|
|
906
|
-
return this._cropDataUrl(fullDataUrl,
|
|
1066
|
+
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
907
1067
|
}
|
|
908
1068
|
/**
|
|
909
1069
|
* Gets the top-left corner coordinates of the given object.
|
|
@@ -914,12 +1074,10 @@ var ImageEditor = class {
|
|
|
914
1074
|
* @private
|
|
915
1075
|
*/
|
|
916
1076
|
_getObjectTopLeftPoint(fabricObject) {
|
|
917
|
-
if (!fabricObject)
|
|
918
|
-
return { x: 0, y: 0 };
|
|
1077
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
919
1078
|
fabricObject.setCoords();
|
|
920
1079
|
const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
|
|
921
|
-
if (coords && coords.length)
|
|
922
|
-
return coords[0];
|
|
1080
|
+
if (coords && coords.length) return coords[0];
|
|
923
1081
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
924
1082
|
return { x: boundingRect.left, y: boundingRect.top };
|
|
925
1083
|
}
|
|
@@ -933,8 +1091,7 @@ var ImageEditor = class {
|
|
|
933
1091
|
* @private
|
|
934
1092
|
*/
|
|
935
1093
|
_setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
|
|
936
|
-
if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
|
|
937
|
-
return;
|
|
1094
|
+
if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
|
|
938
1095
|
fabricObject.set({ originX, originY });
|
|
939
1096
|
fabricObject.setPositionByOrigin(refPoint, originX, originY);
|
|
940
1097
|
fabricObject.setCoords();
|
|
@@ -946,8 +1103,7 @@ var ImageEditor = class {
|
|
|
946
1103
|
* @private
|
|
947
1104
|
*/
|
|
948
1105
|
_alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
|
|
949
|
-
if (!fabricObject)
|
|
950
|
-
return;
|
|
1106
|
+
if (!fabricObject) return;
|
|
951
1107
|
fabricObject.setCoords();
|
|
952
1108
|
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
953
1109
|
const deltaX = boundingRect.left;
|
|
@@ -962,30 +1118,63 @@ var ImageEditor = class {
|
|
|
962
1118
|
* @private
|
|
963
1119
|
*/
|
|
964
1120
|
_updateCanvasSizeToImageBounds() {
|
|
965
|
-
if (!this.originalImage)
|
|
966
|
-
return;
|
|
1121
|
+
if (!this.originalImage) return;
|
|
967
1122
|
this.originalImage.setCoords();
|
|
968
1123
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
969
1124
|
const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
|
|
970
1125
|
this._setCanvasSizeInt(size.width, size.height);
|
|
971
1126
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1127
|
+
/**
|
|
1128
|
+
* Whether post-load edits should resize the canvas to keep transformed content visible.
|
|
1129
|
+
*
|
|
1130
|
+
* @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
|
|
1131
|
+
* @private
|
|
1132
|
+
*/
|
|
1133
|
+
_shouldResizeCanvasToContentBounds() {
|
|
1134
|
+
return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Expands the canvas once so all provided objects remain visible after an edit.
|
|
1138
|
+
*
|
|
1139
|
+
* @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
|
|
1140
|
+
* @param {number} [padding=10] - Extra canvas space after the farthest object edge.
|
|
1141
|
+
* @returns {void}
|
|
1142
|
+
* @private
|
|
1143
|
+
*/
|
|
1144
|
+
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1145
|
+
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
|
|
975
1146
|
try {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1147
|
+
let requiredWidth = this.canvas.getWidth();
|
|
1148
|
+
let requiredHeight = this.canvas.getHeight();
|
|
1149
|
+
fabricObjects.forEach((fabricObject) => {
|
|
1150
|
+
if (!fabricObject) return;
|
|
1151
|
+
if (typeof fabricObject.setCoords === "function") fabricObject.setCoords();
|
|
1152
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1153
|
+
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1154
|
+
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1155
|
+
});
|
|
980
1156
|
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
981
1157
|
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
982
1158
|
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
983
1159
|
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
984
|
-
this.
|
|
1160
|
+
if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
|
|
1161
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1162
|
+
}
|
|
985
1163
|
} catch (error) {
|
|
986
|
-
this._reportWarning("
|
|
1164
|
+
this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
|
|
987
1165
|
}
|
|
988
1166
|
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Expands the canvas so one object remains visible after an edit.
|
|
1169
|
+
*
|
|
1170
|
+
* @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
|
|
1171
|
+
* @param {number} [padding=10] - Extra canvas space after the object edge.
|
|
1172
|
+
* @returns {void}
|
|
1173
|
+
* @private
|
|
1174
|
+
*/
|
|
1175
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
1176
|
+
this._expandCanvasToFitObjects([fabricObject], padding);
|
|
1177
|
+
}
|
|
989
1178
|
/**
|
|
990
1179
|
* Scales the original image by a given factor, with animation.
|
|
991
1180
|
* Returns a promise that resolves when the scale animation is complete.
|
|
@@ -994,7 +1183,7 @@ var ImageEditor = class {
|
|
|
994
1183
|
* @public
|
|
995
1184
|
*/
|
|
996
1185
|
scaleImage(factor, options = {}) {
|
|
997
|
-
return this.
|
|
1186
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
998
1187
|
}
|
|
999
1188
|
/**
|
|
1000
1189
|
* Scales the original image by a given factor, with animation.
|
|
@@ -1004,10 +1193,8 @@ var ImageEditor = class {
|
|
|
1004
1193
|
* @private
|
|
1005
1194
|
*/
|
|
1006
1195
|
_scaleImageImpl(factor, options = {}) {
|
|
1007
|
-
if (!this.originalImage)
|
|
1008
|
-
|
|
1009
|
-
if (this.isAnimating)
|
|
1010
|
-
return Promise.resolve();
|
|
1196
|
+
if (!this.originalImage) return Promise.resolve();
|
|
1197
|
+
if (this.isAnimating) return Promise.resolve();
|
|
1011
1198
|
const saveHistory = options.saveHistory !== false;
|
|
1012
1199
|
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
1013
1200
|
this.currentScale = factor;
|
|
@@ -1033,19 +1220,17 @@ var ImageEditor = class {
|
|
|
1033
1220
|
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1034
1221
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1035
1222
|
this.originalImage.setCoords();
|
|
1036
|
-
if (this.
|
|
1223
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1037
1224
|
this._updateCanvasSizeToImageBounds();
|
|
1038
1225
|
}
|
|
1039
1226
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1040
1227
|
this.canvas.getObjects().forEach((object) => {
|
|
1041
|
-
if (object.maskId)
|
|
1042
|
-
this._syncMaskLabel(object);
|
|
1228
|
+
if (object.maskId) this._syncMaskLabel(object);
|
|
1043
1229
|
});
|
|
1044
1230
|
this.isAnimating = false;
|
|
1045
1231
|
this._updateInputs();
|
|
1046
1232
|
this._updateUI();
|
|
1047
|
-
if (saveHistory)
|
|
1048
|
-
this.saveState();
|
|
1233
|
+
if (saveHistory) this.saveState();
|
|
1049
1234
|
}).catch(() => {
|
|
1050
1235
|
this.isAnimating = false;
|
|
1051
1236
|
this._updateUI();
|
|
@@ -1059,7 +1244,7 @@ var ImageEditor = class {
|
|
|
1059
1244
|
* @public
|
|
1060
1245
|
*/
|
|
1061
1246
|
rotateImage(degrees, options = {}) {
|
|
1062
|
-
return this.
|
|
1247
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
1063
1248
|
}
|
|
1064
1249
|
/**
|
|
1065
1250
|
* Rotates the original image by a given number of degrees, with animation.
|
|
@@ -1069,12 +1254,9 @@ var ImageEditor = class {
|
|
|
1069
1254
|
* @private
|
|
1070
1255
|
*/
|
|
1071
1256
|
_rotateImageImpl(degrees, options = {}) {
|
|
1072
|
-
if (!this.originalImage)
|
|
1073
|
-
|
|
1074
|
-
if (
|
|
1075
|
-
return Promise.resolve();
|
|
1076
|
-
if (isNaN(degrees))
|
|
1077
|
-
return Promise.resolve();
|
|
1257
|
+
if (!this.originalImage) return Promise.resolve();
|
|
1258
|
+
if (this.isAnimating) return Promise.resolve();
|
|
1259
|
+
if (isNaN(degrees)) return Promise.resolve();
|
|
1078
1260
|
const saveHistory = options.saveHistory !== false;
|
|
1079
1261
|
this.currentRotation = degrees;
|
|
1080
1262
|
this.isAnimating = true;
|
|
@@ -1091,21 +1273,19 @@ var ImageEditor = class {
|
|
|
1091
1273
|
return rotationAnimation.then(() => {
|
|
1092
1274
|
this.originalImage.set("angle", degrees);
|
|
1093
1275
|
this.originalImage.setCoords();
|
|
1094
|
-
if (this.
|
|
1276
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1095
1277
|
this._updateCanvasSizeToImageBounds();
|
|
1096
1278
|
}
|
|
1097
1279
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
1098
1280
|
const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
1099
1281
|
this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
|
|
1100
1282
|
this.canvas.getObjects().forEach((object) => {
|
|
1101
|
-
if (object.maskId)
|
|
1102
|
-
this._syncMaskLabel(object);
|
|
1283
|
+
if (object.maskId) this._syncMaskLabel(object);
|
|
1103
1284
|
});
|
|
1104
1285
|
this.isAnimating = false;
|
|
1105
1286
|
this._updateInputs();
|
|
1106
1287
|
this._updateUI();
|
|
1107
|
-
if (saveHistory)
|
|
1108
|
-
this.saveState();
|
|
1288
|
+
if (saveHistory) this.saveState();
|
|
1109
1289
|
}).catch(() => {
|
|
1110
1290
|
this.isAnimating = false;
|
|
1111
1291
|
this._updateUI();
|
|
@@ -1113,38 +1293,45 @@ var ImageEditor = class {
|
|
|
1113
1293
|
}
|
|
1114
1294
|
/**
|
|
1115
1295
|
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
1116
|
-
*
|
|
1296
|
+
*
|
|
1297
|
+
* @returns {Promise<void>} Resolves when the reset history transition has been recorded.
|
|
1298
|
+
* @public
|
|
1117
1299
|
*/
|
|
1118
1300
|
resetImageTransform() {
|
|
1119
|
-
if (!this.originalImage)
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const before = this._serializeCanvasState();
|
|
1301
|
+
if (!this.originalImage) return Promise.resolve();
|
|
1302
|
+
return this.animationQueue.add(async () => {
|
|
1303
|
+
const before = this._lastSnapshot || this._serializeCanvasState();
|
|
1123
1304
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1124
1305
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1125
1306
|
const after = this._serializeCanvasState();
|
|
1126
1307
|
this._pushStateTransition(before, after);
|
|
1127
|
-
}).catch((
|
|
1128
|
-
this._reportError("resetImageTransform() failed",
|
|
1308
|
+
}).catch((error) => {
|
|
1309
|
+
this._reportError("resetImageTransform() failed", error);
|
|
1129
1310
|
});
|
|
1130
1311
|
}
|
|
1131
1312
|
/**
|
|
1132
|
-
* @
|
|
1313
|
+
* Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
|
|
1314
|
+
*
|
|
1315
|
+
* @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
|
|
1316
|
+
* @returns {Promise<void>} Resolves when the image transform reset is complete.
|
|
1133
1317
|
*/
|
|
1134
1318
|
reset() {
|
|
1135
1319
|
return this.resetImageTransform();
|
|
1136
1320
|
}
|
|
1137
1321
|
/**
|
|
1138
|
-
* Restores a canvas state
|
|
1139
|
-
*
|
|
1322
|
+
* Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
|
|
1323
|
+
*
|
|
1324
|
+
* @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
|
|
1325
|
+
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
1326
|
+
* @public
|
|
1140
1327
|
*/
|
|
1141
|
-
loadFromState(
|
|
1142
|
-
if (!
|
|
1143
|
-
return Promise.resolve();
|
|
1328
|
+
loadFromState(serializedState) {
|
|
1329
|
+
if (!serializedState || !this.canvas) return Promise.resolve();
|
|
1144
1330
|
return new Promise((resolve) => {
|
|
1145
1331
|
try {
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1332
|
+
const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
|
|
1333
|
+
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1334
|
+
this.canvas.loadFromJSON(state, () => {
|
|
1148
1335
|
try {
|
|
1149
1336
|
this._hideAllMaskLabels();
|
|
1150
1337
|
const canvasObjects = this.canvas.getObjects();
|
|
@@ -1152,11 +1339,22 @@ var ImageEditor = class {
|
|
|
1152
1339
|
if (this.originalImage) {
|
|
1153
1340
|
this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
|
|
1154
1341
|
this.canvas.sendToBack(this.originalImage);
|
|
1155
|
-
|
|
1156
|
-
const
|
|
1157
|
-
const
|
|
1158
|
-
|
|
1342
|
+
const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
|
|
1343
|
+
const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
|
|
1344
|
+
const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
|
|
1345
|
+
if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
|
|
1346
|
+
this.baseImageScale = restoredBaseScale;
|
|
1347
|
+
}
|
|
1348
|
+
if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
|
|
1349
|
+
this.currentScale = restoredCurrentScale;
|
|
1350
|
+
} else {
|
|
1351
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1352
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1353
|
+
this.currentScale = imageScale / baseScale;
|
|
1354
|
+
}
|
|
1355
|
+
this.currentRotation = Number.isFinite(restoredCurrentRotation) ? restoredCurrentRotation : Number(this.originalImage.angle) || 0;
|
|
1159
1356
|
} else {
|
|
1357
|
+
this.baseImageScale = 1;
|
|
1160
1358
|
this.currentScale = 1;
|
|
1161
1359
|
this.currentRotation = 0;
|
|
1162
1360
|
}
|
|
@@ -1166,7 +1364,9 @@ var ImageEditor = class {
|
|
|
1166
1364
|
this._rebindMaskEvents(mask);
|
|
1167
1365
|
mask.set(this._getMaskNormalStyle(mask));
|
|
1168
1366
|
});
|
|
1169
|
-
|
|
1367
|
+
const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
|
|
1368
|
+
const maxMaskId = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
|
|
1369
|
+
this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId ? Math.floor(restoredMaskCounter) : maxMaskId;
|
|
1170
1370
|
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1171
1371
|
if (!this._lastMask) {
|
|
1172
1372
|
this._lastMaskInitialLeft = null;
|
|
@@ -1193,18 +1393,21 @@ var ImageEditor = class {
|
|
|
1193
1393
|
});
|
|
1194
1394
|
}
|
|
1195
1395
|
/**
|
|
1196
|
-
* Saves the current
|
|
1396
|
+
* Saves the current editable canvas state as an undoable history transition.
|
|
1397
|
+
*
|
|
1398
|
+
* Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
|
|
1399
|
+
* mask objects and restored by `loadFromState()`.
|
|
1400
|
+
*
|
|
1401
|
+
* @returns {void}
|
|
1402
|
+
* @public
|
|
1197
1403
|
*/
|
|
1198
1404
|
saveState() {
|
|
1199
|
-
if (!this.canvas)
|
|
1200
|
-
return;
|
|
1405
|
+
if (!this.canvas) return;
|
|
1201
1406
|
const activeObject = this.canvas.getActiveObject();
|
|
1202
|
-
this._hideAllMaskLabels();
|
|
1203
1407
|
try {
|
|
1204
1408
|
const after = this._serializeCanvasState();
|
|
1205
1409
|
const before = this._lastSnapshot || after;
|
|
1206
|
-
if (after === before)
|
|
1207
|
-
return;
|
|
1410
|
+
if (after === before) return;
|
|
1208
1411
|
let executedOnce = false;
|
|
1209
1412
|
const command = new Command(
|
|
1210
1413
|
() => {
|
|
@@ -1221,19 +1424,27 @@ var ImageEditor = class {
|
|
|
1221
1424
|
} catch (error) {
|
|
1222
1425
|
this._reportWarning("saveState: failed to save canvas snapshot", error);
|
|
1223
1426
|
} finally {
|
|
1224
|
-
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1427
|
+
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1225
1428
|
this._handleSelectionChanged([activeObject]);
|
|
1226
1429
|
}
|
|
1227
1430
|
this._updateUI();
|
|
1228
1431
|
}
|
|
1229
1432
|
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Pushes a precomputed before/after state transition into history.
|
|
1435
|
+
*
|
|
1436
|
+
* Use this for operations such as crop and merge that build their snapshots around asynchronous image
|
|
1437
|
+
* loading, where the "after" state is already applied before the history command is recorded.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {string} before - Serialized state before the operation.
|
|
1440
|
+
* @param {string} after - Serialized state after the operation.
|
|
1441
|
+
* @returns {void}
|
|
1442
|
+
* @private
|
|
1443
|
+
*/
|
|
1230
1444
|
_pushStateTransition(before, after) {
|
|
1231
|
-
if (!before || !after)
|
|
1232
|
-
|
|
1233
|
-
if (
|
|
1234
|
-
return;
|
|
1235
|
-
if (!this.historyManager)
|
|
1236
|
-
this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1445
|
+
if (!before || !after) return;
|
|
1446
|
+
if (before === after) return;
|
|
1447
|
+
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1237
1448
|
const command = new Command(
|
|
1238
1449
|
() => this.loadFromState(after),
|
|
1239
1450
|
() => this.loadFromState(before)
|
|
@@ -1244,6 +1455,9 @@ var ImageEditor = class {
|
|
|
1244
1455
|
}
|
|
1245
1456
|
/**
|
|
1246
1457
|
* Undo the last state change, if possible.
|
|
1458
|
+
*
|
|
1459
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
|
|
1460
|
+
* @public
|
|
1247
1461
|
*/
|
|
1248
1462
|
undo() {
|
|
1249
1463
|
return this.historyManager.undo().then(() => {
|
|
@@ -1254,6 +1468,9 @@ var ImageEditor = class {
|
|
|
1254
1468
|
}
|
|
1255
1469
|
/**
|
|
1256
1470
|
* Redo the next state change, if possible.
|
|
1471
|
+
*
|
|
1472
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
|
|
1473
|
+
* @public
|
|
1257
1474
|
*/
|
|
1258
1475
|
redo() {
|
|
1259
1476
|
return this.historyManager.redo().then(() => {
|
|
@@ -1263,26 +1480,24 @@ var ImageEditor = class {
|
|
|
1263
1480
|
});
|
|
1264
1481
|
}
|
|
1265
1482
|
_rebindMaskEvents(mask) {
|
|
1266
|
-
if (!mask)
|
|
1267
|
-
return;
|
|
1483
|
+
if (!mask) return;
|
|
1268
1484
|
if (mask.__imageEditorMaskHandlers) {
|
|
1269
1485
|
try {
|
|
1270
1486
|
mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
|
|
1271
1487
|
mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
|
|
1272
|
-
} catch (
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
void error;
|
|
1273
1490
|
}
|
|
1274
1491
|
}
|
|
1275
1492
|
const metadata = {};
|
|
1276
1493
|
if (!Number.isFinite(Number(mask.originalAlpha))) {
|
|
1277
1494
|
metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
|
|
1278
1495
|
}
|
|
1279
|
-
if (!mask.originalStroke)
|
|
1280
|
-
metadata.originalStroke = mask.stroke || "#ccc";
|
|
1496
|
+
if (!mask.originalStroke) metadata.originalStroke = mask.stroke || "#ccc";
|
|
1281
1497
|
if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
|
|
1282
1498
|
metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
|
|
1283
1499
|
}
|
|
1284
|
-
if (Object.keys(metadata).length)
|
|
1285
|
-
mask.set(metadata);
|
|
1500
|
+
if (Object.keys(metadata).length) mask.set(metadata);
|
|
1286
1501
|
const normalStyle = {
|
|
1287
1502
|
stroke: mask.originalStroke || "#ccc",
|
|
1288
1503
|
strokeWidth: mask.originalStrokeWidth,
|
|
@@ -1295,40 +1510,46 @@ var ImageEditor = class {
|
|
|
1295
1510
|
};
|
|
1296
1511
|
const mouseover = () => {
|
|
1297
1512
|
mask.set(hoverStyle);
|
|
1298
|
-
if (mask.canvas)
|
|
1299
|
-
mask.canvas.requestRenderAll();
|
|
1513
|
+
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1300
1514
|
};
|
|
1301
1515
|
const mouseout = () => {
|
|
1302
1516
|
mask.set(normalStyle);
|
|
1303
|
-
if (mask.canvas)
|
|
1304
|
-
mask.canvas.requestRenderAll();
|
|
1517
|
+
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1305
1518
|
};
|
|
1306
1519
|
mask.on("mouseover", mouseover);
|
|
1307
1520
|
mask.on("mouseout", mouseout);
|
|
1308
1521
|
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
1309
1522
|
}
|
|
1310
|
-
/**
|
|
1523
|
+
/**
|
|
1311
1524
|
* Creates a mask and adds it to the canvas.
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
* @
|
|
1525
|
+
*
|
|
1526
|
+
* Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
|
|
1527
|
+
* after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
|
|
1528
|
+
* so controls and hit testing stay in sync with Fabric 5.x behavior.
|
|
1529
|
+
*
|
|
1530
|
+
* @param {Object} [config={}] - Optional mask configuration overrides.
|
|
1531
|
+
* @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
|
|
1532
|
+
* @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
|
|
1533
|
+
* @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
|
|
1534
|
+
* @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
|
|
1535
|
+
* @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
|
|
1536
|
+
* @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
|
|
1537
|
+
* @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
|
|
1538
|
+
* @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
|
|
1539
|
+
* @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
|
|
1540
|
+
* @param {number} [config.angle=0] - Rotation angle in degrees.
|
|
1541
|
+
* @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
|
|
1542
|
+
* @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
|
|
1543
|
+
* @param {boolean} [config.selectable=true] - Whether the mask can be selected.
|
|
1544
|
+
* @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
|
|
1545
|
+
* @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
|
|
1546
|
+
* @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
|
|
1547
|
+
* @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
|
|
1548
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1327
1549
|
* @public
|
|
1328
1550
|
*/
|
|
1329
1551
|
createMask(config = {}) {
|
|
1330
|
-
if (!this.canvas)
|
|
1331
|
-
return null;
|
|
1552
|
+
if (!this.canvas) return null;
|
|
1332
1553
|
const shapeType = config.shape || "rect";
|
|
1333
1554
|
const maskConfig = {
|
|
1334
1555
|
shape: shapeType,
|
|
@@ -1344,14 +1565,22 @@ var ImageEditor = class {
|
|
|
1344
1565
|
...config
|
|
1345
1566
|
};
|
|
1346
1567
|
const firstOffset = 10;
|
|
1347
|
-
let left
|
|
1348
|
-
let top
|
|
1349
|
-
const
|
|
1568
|
+
let left;
|
|
1569
|
+
let top;
|
|
1570
|
+
const getCanvasBasis = (axis) => {
|
|
1571
|
+
const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
|
|
1572
|
+
const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
|
|
1573
|
+
if (axis === "height") return canvasHeight;
|
|
1574
|
+
if (axis === "min") return Math.min(canvasWidth, canvasHeight);
|
|
1575
|
+
return canvasWidth;
|
|
1576
|
+
};
|
|
1577
|
+
const resolveValue = (value, fallback, axis = "width") => {
|
|
1350
1578
|
if (typeof value === "function")
|
|
1351
1579
|
return value(this.canvas, this.options);
|
|
1352
1580
|
if (typeof value === "string" && value.endsWith("%")) {
|
|
1353
|
-
const percent = parseFloat(value) / 100;
|
|
1354
|
-
|
|
1581
|
+
const percent = Number.parseFloat(value) / 100;
|
|
1582
|
+
if (!Number.isFinite(percent)) return fallback;
|
|
1583
|
+
return Math.floor(getCanvasBasis(axis) * percent);
|
|
1355
1584
|
}
|
|
1356
1585
|
return value != null ? value : fallback;
|
|
1357
1586
|
};
|
|
@@ -1366,11 +1595,13 @@ var ImageEditor = class {
|
|
|
1366
1595
|
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1367
1596
|
top = previousMask.top ?? firstOffset;
|
|
1368
1597
|
} else {
|
|
1369
|
-
left = resolveValue(maskConfig.left, firstOffset);
|
|
1370
|
-
top = resolveValue(maskConfig.top, firstOffset);
|
|
1598
|
+
left = resolveValue(maskConfig.left, firstOffset, "width");
|
|
1599
|
+
top = resolveValue(maskConfig.top, firstOffset, "height");
|
|
1371
1600
|
}
|
|
1372
|
-
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1373
|
-
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1601
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
|
|
1602
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height");
|
|
1603
|
+
maskConfig.left = left;
|
|
1604
|
+
maskConfig.top = top;
|
|
1374
1605
|
let mask;
|
|
1375
1606
|
if (typeof maskConfig.fabricGenerator === "function") {
|
|
1376
1607
|
mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
|
|
@@ -1380,7 +1611,7 @@ var ImageEditor = class {
|
|
|
1380
1611
|
mask = new fabric.Circle({
|
|
1381
1612
|
left,
|
|
1382
1613
|
top,
|
|
1383
|
-
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
1614
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, "min"),
|
|
1384
1615
|
fill: maskConfig.color,
|
|
1385
1616
|
opacity: maskConfig.alpha,
|
|
1386
1617
|
angle: maskConfig.angle,
|
|
@@ -1391,8 +1622,8 @@ var ImageEditor = class {
|
|
|
1391
1622
|
mask = new fabric.Ellipse({
|
|
1392
1623
|
left,
|
|
1393
1624
|
top,
|
|
1394
|
-
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1395
|
-
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
1625
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2, "width"),
|
|
1626
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2, "height"),
|
|
1396
1627
|
fill: maskConfig.color,
|
|
1397
1628
|
opacity: maskConfig.alpha,
|
|
1398
1629
|
angle: maskConfig.angle,
|
|
@@ -1401,8 +1632,8 @@ var ImageEditor = class {
|
|
|
1401
1632
|
break;
|
|
1402
1633
|
case "polygon": {
|
|
1403
1634
|
let polygonPoints = maskConfig.points || [];
|
|
1404
|
-
if (Array.isArray(polygonPoints) && polygonPoints.length
|
|
1405
|
-
polygonPoints = polygonPoints.map((point) => ({ x: Number(point.x), y: Number(point.y) })
|
|
1635
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length) {
|
|
1636
|
+
polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
|
|
1406
1637
|
}
|
|
1407
1638
|
mask = new fabric.Polygon(polygonPoints, {
|
|
1408
1639
|
left,
|
|
@@ -1419,13 +1650,12 @@ var ImageEditor = class {
|
|
|
1419
1650
|
mask = new fabric.Rect({
|
|
1420
1651
|
left,
|
|
1421
1652
|
top,
|
|
1422
|
-
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1423
|
-
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
1653
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width"),
|
|
1654
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, "height"),
|
|
1424
1655
|
fill: maskConfig.color,
|
|
1425
1656
|
opacity: maskConfig.alpha,
|
|
1426
1657
|
angle: maskConfig.angle,
|
|
1427
1658
|
rx: maskConfig.rx,
|
|
1428
|
-
// Rounded Corners
|
|
1429
1659
|
ry: maskConfig.ry,
|
|
1430
1660
|
...maskConfig.styles
|
|
1431
1661
|
});
|
|
@@ -1443,14 +1673,14 @@ var ImageEditor = class {
|
|
|
1443
1673
|
transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
|
|
1444
1674
|
stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
|
|
1445
1675
|
strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
|
|
1676
|
+
opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
|
|
1446
1677
|
strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
|
|
1447
1678
|
};
|
|
1448
|
-
if (hasStyle("strokeDashArray"))
|
|
1449
|
-
maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
1679
|
+
if (hasStyle("strokeDashArray")) maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
1450
1680
|
mask.set(maskSettings);
|
|
1451
1681
|
mask.setCoords();
|
|
1452
1682
|
mask.set({
|
|
1453
|
-
originalAlpha: maskConfig.alpha,
|
|
1683
|
+
originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
|
|
1454
1684
|
originalStroke: mask.stroke || "#ccc",
|
|
1455
1685
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
1456
1686
|
});
|
|
@@ -1458,7 +1688,7 @@ var ImageEditor = class {
|
|
|
1458
1688
|
this._expandCanvasToFitObject(mask);
|
|
1459
1689
|
this._lastMaskInitialLeft = left;
|
|
1460
1690
|
this._lastMaskInitialTop = top;
|
|
1461
|
-
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1691
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, "width");
|
|
1462
1692
|
const maskId = ++this.maskCounter;
|
|
1463
1693
|
mask.set({
|
|
1464
1694
|
maskId,
|
|
@@ -1467,19 +1697,21 @@ var ImageEditor = class {
|
|
|
1467
1697
|
this._lastMask = mask;
|
|
1468
1698
|
this.canvas.add(mask);
|
|
1469
1699
|
this.canvas.bringToFront(mask);
|
|
1470
|
-
if (maskConfig.selectable)
|
|
1471
|
-
this.canvas.setActiveObject(mask);
|
|
1700
|
+
if (maskConfig.selectable) this.canvas.setActiveObject(mask);
|
|
1472
1701
|
this._handleSelectionChanged([mask]);
|
|
1473
1702
|
this._updateMaskList();
|
|
1474
1703
|
this._updateUI();
|
|
1475
1704
|
this.canvas.renderAll();
|
|
1476
1705
|
this.saveState();
|
|
1477
|
-
if (typeof maskConfig.onCreate === "function")
|
|
1478
|
-
maskConfig.onCreate(mask, this.canvas);
|
|
1706
|
+
if (typeof maskConfig.onCreate === "function") maskConfig.onCreate(mask, this.canvas);
|
|
1479
1707
|
return mask;
|
|
1480
1708
|
}
|
|
1481
1709
|
/**
|
|
1482
|
-
* @
|
|
1710
|
+
* Backward-compatible alias for {@link ImageEditor#createMask}.
|
|
1711
|
+
*
|
|
1712
|
+
* @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
|
|
1713
|
+
* @param {Object} [config={}] - Mask configuration passed to createMask().
|
|
1714
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1483
1715
|
*/
|
|
1484
1716
|
addMask(config = {}) {
|
|
1485
1717
|
return this.createMask(config);
|
|
@@ -1491,8 +1723,7 @@ var ImageEditor = class {
|
|
|
1491
1723
|
removeSelectedMask() {
|
|
1492
1724
|
const activeObject = this.canvas.getActiveObject();
|
|
1493
1725
|
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
1494
|
-
if (!selectedMasks.length)
|
|
1495
|
-
return;
|
|
1726
|
+
if (!selectedMasks.length) return;
|
|
1496
1727
|
this.canvas.discardActiveObject();
|
|
1497
1728
|
selectedMasks.forEach((mask) => {
|
|
1498
1729
|
this._removeLabelForMask(mask);
|
|
@@ -1527,8 +1758,7 @@ var ImageEditor = class {
|
|
|
1527
1758
|
this._updateMaskList();
|
|
1528
1759
|
this._updateUI();
|
|
1529
1760
|
this.canvas.renderAll();
|
|
1530
|
-
if (saveHistory)
|
|
1531
|
-
this.saveState();
|
|
1761
|
+
if (saveHistory) this.saveState();
|
|
1532
1762
|
}
|
|
1533
1763
|
/**
|
|
1534
1764
|
* Removes the label associated with the specified mask object, if it exists.
|
|
@@ -1537,8 +1767,7 @@ var ImageEditor = class {
|
|
|
1537
1767
|
* @private
|
|
1538
1768
|
*/
|
|
1539
1769
|
_removeLabelForMask(mask) {
|
|
1540
|
-
if (!mask || !this.canvas)
|
|
1541
|
-
return;
|
|
1770
|
+
if (!mask || !this.canvas) return;
|
|
1542
1771
|
if (mask.__label) {
|
|
1543
1772
|
try {
|
|
1544
1773
|
const canvasObjects = this.canvas.getObjects();
|
|
@@ -1546,13 +1775,31 @@ var ImageEditor = class {
|
|
|
1546
1775
|
this.canvas.remove(mask.__label);
|
|
1547
1776
|
}
|
|
1548
1777
|
} catch (error) {
|
|
1778
|
+
void error;
|
|
1549
1779
|
}
|
|
1550
1780
|
try {
|
|
1551
1781
|
delete mask.__label;
|
|
1552
1782
|
} catch (error) {
|
|
1783
|
+
void error;
|
|
1553
1784
|
}
|
|
1554
1785
|
}
|
|
1555
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Returns a stable zero-based creation index for label callbacks.
|
|
1789
|
+
*
|
|
1790
|
+
* Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
|
|
1791
|
+
* lifetime of a mask.
|
|
1792
|
+
*
|
|
1793
|
+
* @param {fabric.Object} mask - Mask object.
|
|
1794
|
+
* @returns {number} Stable zero-based creation index.
|
|
1795
|
+
* @private
|
|
1796
|
+
*/
|
|
1797
|
+
_getMaskCreationIndex(mask) {
|
|
1798
|
+
const maskId = Number(mask && mask.maskId);
|
|
1799
|
+
if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
|
|
1800
|
+
const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1801
|
+
return Math.max(0, masks.indexOf(mask));
|
|
1802
|
+
}
|
|
1556
1803
|
/**
|
|
1557
1804
|
* Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
|
|
1558
1805
|
* The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
|
|
@@ -1561,8 +1808,7 @@ var ImageEditor = class {
|
|
|
1561
1808
|
* @private
|
|
1562
1809
|
*/
|
|
1563
1810
|
_createLabelForMask(mask) {
|
|
1564
|
-
if (!mask || !this.options.maskLabelOnSelect)
|
|
1565
|
-
return;
|
|
1811
|
+
if (!mask || !this.options.maskLabelOnSelect) return;
|
|
1566
1812
|
this._removeLabelForMask(mask);
|
|
1567
1813
|
let textObject = null;
|
|
1568
1814
|
if (this.options.label && typeof this.options.label.create === "function") {
|
|
@@ -1584,9 +1830,7 @@ var ImageEditor = class {
|
|
|
1584
1830
|
};
|
|
1585
1831
|
if (this.options.label) {
|
|
1586
1832
|
if (typeof this.options.label.getText === "function") {
|
|
1587
|
-
|
|
1588
|
-
const maskIndex = Math.max(0, masks.indexOf(mask));
|
|
1589
|
-
labelText = this.options.label.getText(mask, maskIndex);
|
|
1833
|
+
labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
|
|
1590
1834
|
}
|
|
1591
1835
|
if (this.options.label.textOptions) {
|
|
1592
1836
|
Object.assign(textOptions, this.options.label.textOptions);
|
|
@@ -1606,15 +1850,14 @@ var ImageEditor = class {
|
|
|
1606
1850
|
* @private
|
|
1607
1851
|
*/
|
|
1608
1852
|
_hideAllMaskLabels() {
|
|
1609
|
-
if (!this.canvas)
|
|
1610
|
-
return;
|
|
1853
|
+
if (!this.canvas) return;
|
|
1611
1854
|
const canvasObjects = this.canvas.getObjects();
|
|
1612
1855
|
const labels = canvasObjects.filter((object) => object.maskLabel);
|
|
1613
1856
|
labels.forEach((label) => {
|
|
1614
1857
|
try {
|
|
1615
|
-
if (canvasObjects.includes(label))
|
|
1616
|
-
this.canvas.remove(label);
|
|
1858
|
+
if (canvasObjects.includes(label)) this.canvas.remove(label);
|
|
1617
1859
|
} catch (error) {
|
|
1860
|
+
void error;
|
|
1618
1861
|
}
|
|
1619
1862
|
});
|
|
1620
1863
|
canvasObjects.forEach((object) => {
|
|
@@ -1622,6 +1865,7 @@ var ImageEditor = class {
|
|
|
1622
1865
|
try {
|
|
1623
1866
|
delete object.__label;
|
|
1624
1867
|
} catch (error) {
|
|
1868
|
+
void error;
|
|
1625
1869
|
}
|
|
1626
1870
|
}
|
|
1627
1871
|
});
|
|
@@ -1633,15 +1877,11 @@ var ImageEditor = class {
|
|
|
1633
1877
|
* @private
|
|
1634
1878
|
*/
|
|
1635
1879
|
_syncMaskLabel(mask) {
|
|
1636
|
-
if (!mask)
|
|
1637
|
-
|
|
1638
|
-
if (!
|
|
1639
|
-
return;
|
|
1640
|
-
if (!mask.__label)
|
|
1641
|
-
return;
|
|
1880
|
+
if (!mask) return;
|
|
1881
|
+
if (!this.options.maskLabelOnSelect) return;
|
|
1882
|
+
if (!mask.__label) return;
|
|
1642
1883
|
const coords = mask.getCoords ? mask.getCoords() : null;
|
|
1643
|
-
if (!coords || coords.length < 4)
|
|
1644
|
-
return;
|
|
1884
|
+
if (!coords || coords.length < 4) return;
|
|
1645
1885
|
const tl = coords[0];
|
|
1646
1886
|
const center = mask.getCenterPoint();
|
|
1647
1887
|
const vx = center.x - tl.x;
|
|
@@ -1674,12 +1914,9 @@ var ImageEditor = class {
|
|
|
1674
1914
|
* @private
|
|
1675
1915
|
*/
|
|
1676
1916
|
_showLabelForMask(mask) {
|
|
1677
|
-
if (!mask)
|
|
1678
|
-
|
|
1679
|
-
if (!this.
|
|
1680
|
-
return;
|
|
1681
|
-
if (!mask.__label)
|
|
1682
|
-
this._createLabelForMask(mask);
|
|
1917
|
+
if (!mask) return;
|
|
1918
|
+
if (!this.options.maskLabelOnSelect) return;
|
|
1919
|
+
if (!mask.__label) this._createLabelForMask(mask);
|
|
1683
1920
|
mask.__label.set({ visible: true });
|
|
1684
1921
|
this._syncMaskLabel(mask);
|
|
1685
1922
|
}
|
|
@@ -1699,6 +1936,7 @@ var ImageEditor = class {
|
|
|
1699
1936
|
try {
|
|
1700
1937
|
this.canvas.remove(mask.__label);
|
|
1701
1938
|
} catch (error) {
|
|
1939
|
+
void error;
|
|
1702
1940
|
}
|
|
1703
1941
|
delete mask.__label;
|
|
1704
1942
|
}
|
|
@@ -1711,8 +1949,7 @@ var ImageEditor = class {
|
|
|
1711
1949
|
mask.set({ stroke: "#ff0000", strokeWidth: 1 });
|
|
1712
1950
|
}
|
|
1713
1951
|
});
|
|
1714
|
-
if (selectedMask)
|
|
1715
|
-
this._showLabelForMask(selectedMask);
|
|
1952
|
+
if (selectedMask) this._showLabelForMask(selectedMask);
|
|
1716
1953
|
this._updateMaskListSelection(selectedMask);
|
|
1717
1954
|
this.canvas.renderAll();
|
|
1718
1955
|
this._updateUI();
|
|
@@ -1724,8 +1961,7 @@ var ImageEditor = class {
|
|
|
1724
1961
|
*/
|
|
1725
1962
|
_updateMaskList() {
|
|
1726
1963
|
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1727
|
-
if (!maskListElement)
|
|
1728
|
-
return;
|
|
1964
|
+
if (!maskListElement) return;
|
|
1729
1965
|
maskListElement.innerHTML = "";
|
|
1730
1966
|
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1731
1967
|
masks.forEach((mask) => {
|
|
@@ -1747,8 +1983,7 @@ var ImageEditor = class {
|
|
|
1747
1983
|
*/
|
|
1748
1984
|
_updateMaskListSelection(selectedMask) {
|
|
1749
1985
|
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1750
|
-
if (!maskListElement)
|
|
1751
|
-
return;
|
|
1986
|
+
if (!maskListElement) return;
|
|
1752
1987
|
const maskItems = maskListElement.querySelectorAll(".mask-item");
|
|
1753
1988
|
maskItems.forEach((item) => {
|
|
1754
1989
|
const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
|
|
@@ -1756,70 +1991,79 @@ var ImageEditor = class {
|
|
|
1756
1991
|
});
|
|
1757
1992
|
}
|
|
1758
1993
|
/**
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1994
|
+
* Flattens the current masks into the base image and reloads the flattened image.
|
|
1995
|
+
*
|
|
1996
|
+
* This removes editable mask objects after export and records the operation as one undoable history transition.
|
|
1997
|
+
* It does nothing when no base image or no masks exist.
|
|
1998
|
+
*
|
|
1761
1999
|
* @async
|
|
1762
|
-
* @returns {Promise<void>} Resolves when
|
|
2000
|
+
* @returns {Promise<void>} Resolves when the flattened image has been loaded.
|
|
2001
|
+
* @public
|
|
1763
2002
|
*/
|
|
1764
2003
|
async mergeMasks() {
|
|
1765
|
-
if (!this.originalImage)
|
|
1766
|
-
return;
|
|
2004
|
+
if (!this.originalImage) return;
|
|
1767
2005
|
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1768
|
-
if (!masks.length)
|
|
1769
|
-
return;
|
|
2006
|
+
if (!masks.length) return;
|
|
1770
2007
|
this.canvas.discardActiveObject();
|
|
1771
2008
|
this.canvas.renderAll();
|
|
1772
2009
|
try {
|
|
1773
2010
|
const beforeJson = this._serializeCanvasState();
|
|
1774
2011
|
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1775
2012
|
this.removeAllMasks({ saveHistory: false });
|
|
1776
|
-
await this.loadImage(merged);
|
|
2013
|
+
await this.loadImage(merged, { preserveScroll: true });
|
|
1777
2014
|
const afterJson = this._serializeCanvasState();
|
|
1778
2015
|
this._pushStateTransition(beforeJson, afterJson);
|
|
1779
|
-
} catch (
|
|
1780
|
-
this._reportError("merge error",
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
this._reportError("merge error", error);
|
|
1781
2018
|
}
|
|
1782
2019
|
}
|
|
1783
2020
|
/**
|
|
1784
|
-
* @
|
|
2021
|
+
* Backward-compatible alias for {@link ImageEditor#mergeMasks}.
|
|
2022
|
+
*
|
|
2023
|
+
* @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
|
|
2024
|
+
* @returns {Promise<void>} Resolves when mask flattening is complete.
|
|
1785
2025
|
*/
|
|
1786
2026
|
async merge() {
|
|
1787
2027
|
return this.mergeMasks();
|
|
1788
2028
|
}
|
|
1789
2029
|
/**
|
|
1790
|
-
* Triggers a JPEG image download of the current canvas
|
|
2030
|
+
* Triggers a JPEG image download of the current canvas.
|
|
2031
|
+
*
|
|
1791
2032
|
* The image area and multiplier are controlled by options.
|
|
1792
2033
|
* @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
|
|
2034
|
+
* @returns {void}
|
|
2035
|
+
* @public
|
|
1793
2036
|
*/
|
|
1794
2037
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1795
|
-
if (!this.originalImage)
|
|
1796
|
-
return;
|
|
2038
|
+
if (!this.originalImage) return;
|
|
1797
2039
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1798
|
-
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((
|
|
2040
|
+
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
|
|
1799
2041
|
const link = document.createElement("a");
|
|
1800
2042
|
link.download = fileName;
|
|
1801
|
-
link.href =
|
|
2043
|
+
link.href = imageBase64;
|
|
1802
2044
|
document.body.appendChild(link);
|
|
1803
2045
|
link.click();
|
|
1804
2046
|
document.body.removeChild(link);
|
|
1805
|
-
}).catch((
|
|
2047
|
+
}).catch((error) => this._reportError("download error", error));
|
|
1806
2048
|
}
|
|
1807
2049
|
/**
|
|
1808
|
-
* Exports the image as a Base64-encoded
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
2050
|
+
* Exports the current image as a Base64-encoded data URL.
|
|
2051
|
+
*
|
|
2052
|
+
* When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
|
|
2053
|
+
* temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
|
|
2054
|
+
*
|
|
1811
2055
|
* @async
|
|
1812
2056
|
* @param {Object} [options={}] - Export options.
|
|
1813
2057
|
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1814
2058
|
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1815
2059
|
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1816
2060
|
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1817
|
-
* @returns {Promise<string>}
|
|
2061
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1818
2062
|
* @throws {Error} If there is no image loaded.
|
|
2063
|
+
* @public
|
|
1819
2064
|
*/
|
|
1820
2065
|
async exportImageBase64(options = {}) {
|
|
1821
|
-
if (!this.originalImage)
|
|
1822
|
-
throw new Error("No image loaded");
|
|
2066
|
+
if (!this.originalImage) throw new Error("No image loaded");
|
|
1823
2067
|
const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
1824
2068
|
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
1825
2069
|
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
@@ -1835,12 +2079,9 @@ var ImageEditor = class {
|
|
|
1835
2079
|
this.canvas.renderAll();
|
|
1836
2080
|
this.originalImage.setCoords();
|
|
1837
2081
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1838
|
-
const
|
|
2082
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1839
2083
|
return await this._exportCanvasRegionToDataURL({
|
|
1840
|
-
|
|
1841
|
-
sy,
|
|
1842
|
-
sw,
|
|
1843
|
-
sh,
|
|
2084
|
+
...exportRegion,
|
|
1844
2085
|
multiplier,
|
|
1845
2086
|
quality,
|
|
1846
2087
|
format
|
|
@@ -1850,6 +2091,7 @@ var ImageEditor = class {
|
|
|
1850
2091
|
try {
|
|
1851
2092
|
backup.object.set({ visible: backup.visible });
|
|
1852
2093
|
} catch (error) {
|
|
2094
|
+
void error;
|
|
1853
2095
|
}
|
|
1854
2096
|
});
|
|
1855
2097
|
this.canvas.renderAll();
|
|
@@ -1877,12 +2119,9 @@ var ImageEditor = class {
|
|
|
1877
2119
|
this.canvas.renderAll();
|
|
1878
2120
|
this.originalImage.setCoords();
|
|
1879
2121
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1880
|
-
const
|
|
2122
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1881
2123
|
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
1882
|
-
|
|
1883
|
-
sy,
|
|
1884
|
-
sw,
|
|
1885
|
-
sh,
|
|
2124
|
+
...exportRegion,
|
|
1886
2125
|
multiplier,
|
|
1887
2126
|
quality,
|
|
1888
2127
|
format
|
|
@@ -1900,6 +2139,7 @@ var ImageEditor = class {
|
|
|
1900
2139
|
});
|
|
1901
2140
|
backup.object.setCoords();
|
|
1902
2141
|
} catch (error) {
|
|
2142
|
+
void error;
|
|
1903
2143
|
}
|
|
1904
2144
|
});
|
|
1905
2145
|
this.canvas.renderAll();
|
|
@@ -1907,14 +2147,20 @@ var ImageEditor = class {
|
|
|
1907
2147
|
return finalBase64;
|
|
1908
2148
|
}
|
|
1909
2149
|
/**
|
|
1910
|
-
* @
|
|
2150
|
+
* Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
|
|
2151
|
+
*
|
|
2152
|
+
* @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
|
|
2153
|
+
* @param {Object} [options={}] - Export options passed to exportImageBase64().
|
|
2154
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1911
2155
|
*/
|
|
1912
2156
|
async getImageBase64(options = {}) {
|
|
1913
2157
|
return this.exportImageBase64(options);
|
|
1914
2158
|
}
|
|
1915
2159
|
/**
|
|
1916
|
-
* Exports the current
|
|
1917
|
-
*
|
|
2160
|
+
* Exports the current image as a File object.
|
|
2161
|
+
*
|
|
2162
|
+
* The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
|
|
2163
|
+
* Supported output formats are JPEG, PNG, and WebP.
|
|
1918
2164
|
*
|
|
1919
2165
|
* @async
|
|
1920
2166
|
* @param {Object} [options={}] - Export options.
|
|
@@ -1929,8 +2175,7 @@ var ImageEditor = class {
|
|
|
1929
2175
|
* const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
|
|
1930
2176
|
*/
|
|
1931
2177
|
async exportImageFile(options = {}) {
|
|
1932
|
-
if (!this.originalImage)
|
|
1933
|
-
throw new Error("No image loaded");
|
|
2178
|
+
if (!this.originalImage) throw new Error("No image loaded");
|
|
1934
2179
|
const {
|
|
1935
2180
|
mergeMask = true,
|
|
1936
2181
|
fileType = "jpeg",
|
|
@@ -1939,23 +2184,23 @@ var ImageEditor = class {
|
|
|
1939
2184
|
fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
|
|
1940
2185
|
} = options;
|
|
1941
2186
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
1942
|
-
let
|
|
2187
|
+
let imageBase64;
|
|
1943
2188
|
if (mergeMask) {
|
|
1944
|
-
|
|
2189
|
+
imageBase64 = await this.exportImageBase64({
|
|
1945
2190
|
exportImageArea: true,
|
|
1946
2191
|
multiplier,
|
|
1947
2192
|
quality,
|
|
1948
2193
|
fileType: safeFileType
|
|
1949
2194
|
});
|
|
1950
2195
|
} else {
|
|
1951
|
-
|
|
2196
|
+
imageBase64 = await this.exportImageBase64({
|
|
1952
2197
|
exportImageArea: false,
|
|
1953
2198
|
multiplier,
|
|
1954
2199
|
quality,
|
|
1955
2200
|
fileType: safeFileType
|
|
1956
2201
|
});
|
|
1957
2202
|
}
|
|
1958
|
-
let imageDataUrl =
|
|
2203
|
+
let imageDataUrl = imageBase64;
|
|
1959
2204
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
1960
2205
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
1961
2206
|
const imageElement = new window.Image();
|
|
@@ -1974,7 +2219,7 @@ var ImageEditor = class {
|
|
|
1974
2219
|
}
|
|
1975
2220
|
};
|
|
1976
2221
|
imageElement.onerror = reject;
|
|
1977
|
-
imageElement.src =
|
|
2222
|
+
imageElement.src = imageBase64;
|
|
1978
2223
|
});
|
|
1979
2224
|
}
|
|
1980
2225
|
const binaryString = atob(imageDataUrl.split(",")[1]);
|
|
@@ -1994,8 +2239,7 @@ var ImageEditor = class {
|
|
|
1994
2239
|
}
|
|
1995
2240
|
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
1996
2241
|
this._reportError(message, error);
|
|
1997
|
-
if (this._cropRect && this.canvas)
|
|
1998
|
-
this._removeCropRect();
|
|
2242
|
+
if (this._cropRect && this.canvas) this._removeCropRect();
|
|
1999
2243
|
this._cropRect = null;
|
|
2000
2244
|
this._cropMode = false;
|
|
2001
2245
|
if (this.canvas && this._prevSelectionSetting !== void 0) {
|
|
@@ -2010,8 +2254,7 @@ var ImageEditor = class {
|
|
|
2010
2254
|
}
|
|
2011
2255
|
}
|
|
2012
2256
|
this._updateUI();
|
|
2013
|
-
if (this.canvas)
|
|
2014
|
-
this.canvas.renderAll();
|
|
2257
|
+
if (this.canvas) this.canvas.renderAll();
|
|
2015
2258
|
}
|
|
2016
2259
|
_restoreCropObjectState() {
|
|
2017
2260
|
if (Array.isArray(this._cropPrevEvented)) {
|
|
@@ -2023,14 +2266,14 @@ var ImageEditor = class {
|
|
|
2023
2266
|
visible: state.visible
|
|
2024
2267
|
});
|
|
2025
2268
|
} catch (error) {
|
|
2269
|
+
void error;
|
|
2026
2270
|
}
|
|
2027
2271
|
});
|
|
2028
2272
|
}
|
|
2029
2273
|
this._cropPrevEvented = null;
|
|
2030
2274
|
}
|
|
2031
2275
|
_removeCropRect() {
|
|
2032
|
-
if (!this._cropRect)
|
|
2033
|
-
return;
|
|
2276
|
+
if (!this._cropRect) return;
|
|
2034
2277
|
try {
|
|
2035
2278
|
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2036
2279
|
this._cropHandlers.forEach((targetHandlers) => {
|
|
@@ -2040,23 +2283,28 @@ var ImageEditor = class {
|
|
|
2040
2283
|
});
|
|
2041
2284
|
}
|
|
2042
2285
|
} catch (error) {
|
|
2286
|
+
void error;
|
|
2043
2287
|
}
|
|
2044
2288
|
try {
|
|
2045
2289
|
this.canvas.remove(this._cropRect);
|
|
2046
2290
|
} catch (error) {
|
|
2291
|
+
void error;
|
|
2047
2292
|
}
|
|
2048
2293
|
this._cropRect = null;
|
|
2049
2294
|
this._cropHandlers = [];
|
|
2050
2295
|
}
|
|
2051
2296
|
/**
|
|
2052
|
-
*
|
|
2297
|
+
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
2298
|
+
*
|
|
2299
|
+
* Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
|
|
2300
|
+
* cropping when `crop.hideMasksDuringCrop` is enabled.
|
|
2301
|
+
*
|
|
2302
|
+
* @returns {void}
|
|
2053
2303
|
* @public
|
|
2054
2304
|
*/
|
|
2055
2305
|
enterCropMode() {
|
|
2056
|
-
if (!this.canvas || !this.originalImage || this._cropMode)
|
|
2057
|
-
|
|
2058
|
-
if (!this.isImageLoaded())
|
|
2059
|
-
return;
|
|
2306
|
+
if (!this.canvas || !this.originalImage || this._cropMode) return;
|
|
2307
|
+
if (!this.isImageLoaded()) return;
|
|
2060
2308
|
this._cropMode = true;
|
|
2061
2309
|
this._prevSelectionSetting = this.canvas.selection;
|
|
2062
2310
|
this.canvas.selection = false;
|
|
@@ -2066,8 +2314,14 @@ var ImageEditor = class {
|
|
|
2066
2314
|
const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
|
|
2067
2315
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2068
2316
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2069
|
-
const
|
|
2070
|
-
const
|
|
2317
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
|
|
2318
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
|
|
2319
|
+
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
2320
|
+
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
2321
|
+
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
2322
|
+
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
2323
|
+
const width = minCropWidth;
|
|
2324
|
+
const height = minCropHeight;
|
|
2071
2325
|
const cropRect = new fabric.Rect({
|
|
2072
2326
|
left,
|
|
2073
2327
|
top,
|
|
@@ -2084,7 +2338,8 @@ var ImageEditor = class {
|
|
|
2084
2338
|
cornerSize: 8,
|
|
2085
2339
|
objectCaching: false,
|
|
2086
2340
|
originX: "left",
|
|
2087
|
-
originY: "top"
|
|
2341
|
+
originY: "top",
|
|
2342
|
+
lockScalingFlip: true
|
|
2088
2343
|
});
|
|
2089
2344
|
this.canvas.add(cropRect);
|
|
2090
2345
|
cropRect.isCropRect = true;
|
|
@@ -2101,18 +2356,24 @@ var ImageEditor = class {
|
|
|
2101
2356
|
evented: false,
|
|
2102
2357
|
selectable: false
|
|
2103
2358
|
};
|
|
2104
|
-
if (shouldHideMasks && (object.maskId || object.maskLabel))
|
|
2105
|
-
updates.visible = false;
|
|
2359
|
+
if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
|
|
2106
2360
|
object.set(updates);
|
|
2107
2361
|
} catch (error) {
|
|
2362
|
+
void error;
|
|
2108
2363
|
}
|
|
2109
2364
|
}
|
|
2110
2365
|
});
|
|
2111
2366
|
const handleCropRectModified = () => {
|
|
2112
2367
|
try {
|
|
2368
|
+
const cropWidth = Math.max(1, Number(cropRect.width) || 1);
|
|
2369
|
+
const cropHeight = Math.max(1, Number(cropRect.height) || 1);
|
|
2370
|
+
const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
|
|
2371
|
+
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
2372
|
+
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
2113
2373
|
cropRect.setCoords();
|
|
2114
2374
|
this.canvas.requestRenderAll();
|
|
2115
2375
|
} catch (error) {
|
|
2376
|
+
void error;
|
|
2116
2377
|
}
|
|
2117
2378
|
};
|
|
2118
2379
|
cropRect.on("modified", handleCropRectModified);
|
|
@@ -2130,12 +2391,13 @@ var ImageEditor = class {
|
|
|
2130
2391
|
this.canvas.renderAll();
|
|
2131
2392
|
}
|
|
2132
2393
|
/**
|
|
2133
|
-
*
|
|
2394
|
+
* Cancels crop mode and removes the temporary crop rectangle.
|
|
2395
|
+
*
|
|
2396
|
+
* @returns {void}
|
|
2134
2397
|
* @public
|
|
2135
2398
|
*/
|
|
2136
2399
|
cancelCrop() {
|
|
2137
|
-
if (!this.canvas || !this._cropMode)
|
|
2138
|
-
return;
|
|
2400
|
+
if (!this.canvas || !this._cropMode) return;
|
|
2139
2401
|
this._removeCropRect();
|
|
2140
2402
|
this._restoreCropObjectState();
|
|
2141
2403
|
this._cropMode = false;
|
|
@@ -2146,19 +2408,24 @@ var ImageEditor = class {
|
|
|
2146
2408
|
this.canvas.renderAll();
|
|
2147
2409
|
}
|
|
2148
2410
|
/**
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2411
|
+
* Applies the current crop rectangle to the base image.
|
|
2412
|
+
*
|
|
2413
|
+
* Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
|
|
2414
|
+
* region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
|
|
2415
|
+
* single undoable history transition.
|
|
2416
|
+
*
|
|
2417
|
+
* @async
|
|
2418
|
+
* @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
|
|
2151
2419
|
* @public
|
|
2152
2420
|
*/
|
|
2153
2421
|
async applyCrop() {
|
|
2154
|
-
if (!this.canvas || !this._cropMode || !this._cropRect)
|
|
2155
|
-
return;
|
|
2422
|
+
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
2156
2423
|
this._cropRect.setCoords();
|
|
2157
2424
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
2158
|
-
const
|
|
2425
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
2159
2426
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2160
2427
|
this._restoreCropObjectState();
|
|
2161
|
-
let beforeJson
|
|
2428
|
+
let beforeJson;
|
|
2162
2429
|
try {
|
|
2163
2430
|
beforeJson = this._serializeCanvasState();
|
|
2164
2431
|
} catch (error) {
|
|
@@ -2173,13 +2440,13 @@ var ImageEditor = class {
|
|
|
2173
2440
|
try {
|
|
2174
2441
|
mask.setCoords();
|
|
2175
2442
|
const maskBounds = mask.getBoundingRect(true, true);
|
|
2176
|
-
const intersectsCrop = maskBounds.left <
|
|
2443
|
+
const intersectsCrop = maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth && maskBounds.left + maskBounds.width > cropRegion.sourceX && maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight && maskBounds.top + maskBounds.height > cropRegion.sourceY;
|
|
2177
2444
|
this._removeLabelForMask(mask);
|
|
2178
2445
|
this.canvas.remove(mask);
|
|
2179
2446
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2180
2447
|
mask.set({
|
|
2181
|
-
left: (mask.left || 0) -
|
|
2182
|
-
top: (mask.top || 0) -
|
|
2448
|
+
left: (mask.left || 0) - cropRegion.sourceX,
|
|
2449
|
+
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2183
2450
|
visible: true
|
|
2184
2451
|
});
|
|
2185
2452
|
mask.setCoords();
|
|
@@ -2203,10 +2470,7 @@ var ImageEditor = class {
|
|
|
2203
2470
|
let croppedBase64;
|
|
2204
2471
|
try {
|
|
2205
2472
|
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2206
|
-
|
|
2207
|
-
sy,
|
|
2208
|
-
sw,
|
|
2209
|
-
sh,
|
|
2473
|
+
...cropRegion,
|
|
2210
2474
|
multiplier: 1,
|
|
2211
2475
|
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2212
2476
|
format: "jpeg"
|
|
@@ -2228,21 +2492,21 @@ var ImageEditor = class {
|
|
|
2228
2492
|
this._updateMaskList();
|
|
2229
2493
|
this.canvas.renderAll();
|
|
2230
2494
|
}
|
|
2231
|
-
} catch (
|
|
2232
|
-
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed",
|
|
2495
|
+
} catch (error) {
|
|
2496
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
|
|
2233
2497
|
return;
|
|
2234
2498
|
}
|
|
2235
|
-
let afterJson
|
|
2499
|
+
let afterJson;
|
|
2236
2500
|
try {
|
|
2237
2501
|
afterJson = this._serializeCanvasState();
|
|
2238
|
-
} catch (
|
|
2239
|
-
this._reportWarning("applyCrop: failed to serialize after state",
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
this._reportWarning("applyCrop: failed to serialize after state", error);
|
|
2240
2504
|
afterJson = null;
|
|
2241
2505
|
}
|
|
2242
2506
|
try {
|
|
2243
2507
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2244
|
-
} catch (
|
|
2245
|
-
this._reportWarning("applyCrop: failed to push history command",
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
this._reportWarning("applyCrop: failed to push history command", error);
|
|
2246
2510
|
}
|
|
2247
2511
|
this._updateUI();
|
|
2248
2512
|
this.canvas.renderAll();
|
|
@@ -2255,8 +2519,7 @@ var ImageEditor = class {
|
|
|
2255
2519
|
*/
|
|
2256
2520
|
_updateInputs() {
|
|
2257
2521
|
const scaleInputElement = document.getElementById(this.elements.scaleRate);
|
|
2258
|
-
if (scaleInputElement)
|
|
2259
|
-
scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
2522
|
+
if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
2260
2523
|
}
|
|
2261
2524
|
/**
|
|
2262
2525
|
* Updates the enabled/disabled state of various UI controls (buttons)
|
|
@@ -2276,8 +2539,7 @@ var ImageEditor = class {
|
|
|
2276
2539
|
if (isInCropMode) {
|
|
2277
2540
|
for (const key of Object.keys(this.elements || {})) {
|
|
2278
2541
|
const element = document.getElementById(this.elements[key]);
|
|
2279
|
-
if (!element)
|
|
2280
|
-
continue;
|
|
2542
|
+
if (!element) continue;
|
|
2281
2543
|
if (key === "applyCropBtn" || key === "cancelCropBtn") {
|
|
2282
2544
|
this._setDisabled(key, false);
|
|
2283
2545
|
} else {
|
|
@@ -2313,8 +2575,7 @@ var ImageEditor = class {
|
|
|
2313
2575
|
*/
|
|
2314
2576
|
_setDisabled(key, disabled) {
|
|
2315
2577
|
const element = document.getElementById(this.elements[key]);
|
|
2316
|
-
if (!element)
|
|
2317
|
-
return;
|
|
2578
|
+
if (!element) return;
|
|
2318
2579
|
if ("disabled" in element) {
|
|
2319
2580
|
element.disabled = !!disabled;
|
|
2320
2581
|
return;
|
|
@@ -2328,38 +2589,42 @@ var ImageEditor = class {
|
|
|
2328
2589
|
}
|
|
2329
2590
|
}
|
|
2330
2591
|
_isElementDisabled(element) {
|
|
2331
|
-
if (!element)
|
|
2332
|
-
|
|
2333
|
-
if ("disabled" in element)
|
|
2334
|
-
return !!element.disabled;
|
|
2592
|
+
if (!element) return false;
|
|
2593
|
+
if ("disabled" in element) return !!element.disabled;
|
|
2335
2594
|
return element.getAttribute("aria-disabled") === "true";
|
|
2336
2595
|
}
|
|
2337
2596
|
/**
|
|
2338
|
-
*
|
|
2597
|
+
* Updates placeholder and canvas container visibility based on whether an image is loaded.
|
|
2339
2598
|
* @private
|
|
2340
2599
|
*/
|
|
2341
2600
|
_updatePlaceholderStatus() {
|
|
2342
|
-
if (!this.options.showPlaceholder)
|
|
2343
|
-
return;
|
|
2601
|
+
if (!this.options.showPlaceholder) return;
|
|
2344
2602
|
this._setPlaceholderVisible(!this.originalImage);
|
|
2345
2603
|
}
|
|
2346
2604
|
/**
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2605
|
+
* Shows or hides the placeholder and canvas container.
|
|
2606
|
+
*
|
|
2607
|
+
* @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
|
|
2349
2608
|
* @private
|
|
2350
2609
|
*/
|
|
2351
2610
|
_setPlaceholderVisible(show) {
|
|
2352
|
-
if (!this.placeholderElement)
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2611
|
+
if (!this.placeholderElement || !this.containerElement) return;
|
|
2612
|
+
this._setElementVisible(this.placeholderElement, show);
|
|
2613
|
+
this._setElementVisible(this.containerElement, !show);
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Updates element visibility.
|
|
2617
|
+
*
|
|
2618
|
+
* @param {HTMLElement} element - Element whose visibility should be updated.
|
|
2619
|
+
* @param {boolean} isVisible - If true, removes the hidden state.
|
|
2620
|
+
* @returns {void}
|
|
2621
|
+
* @private
|
|
2622
|
+
*/
|
|
2623
|
+
_setElementVisible(element, isVisible) {
|
|
2624
|
+
if (!element) return;
|
|
2625
|
+
element.hidden = !isVisible;
|
|
2626
|
+
element.setAttribute("aria-hidden", isVisible ? "false" : "true");
|
|
2627
|
+
if (isVisible && element.classList) element.classList.remove("d-none");
|
|
2363
2628
|
}
|
|
2364
2629
|
/**
|
|
2365
2630
|
* Cleans up and disposes of the canvas and related references.
|
|
@@ -2371,34 +2636,38 @@ var ImageEditor = class {
|
|
|
2371
2636
|
for (const key in this._handlersByElementKey || {}) {
|
|
2372
2637
|
const handlers = this._handlersByElementKey[key] || [];
|
|
2373
2638
|
const element = document.getElementById(this.elements[key]);
|
|
2374
|
-
if (!element)
|
|
2375
|
-
continue;
|
|
2639
|
+
if (!element) continue;
|
|
2376
2640
|
handlers.forEach((handlerRecord) => {
|
|
2377
2641
|
try {
|
|
2378
2642
|
element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
|
|
2379
2643
|
} catch (error) {
|
|
2644
|
+
void error;
|
|
2380
2645
|
}
|
|
2381
2646
|
});
|
|
2382
2647
|
}
|
|
2383
2648
|
} catch (error) {
|
|
2649
|
+
void error;
|
|
2384
2650
|
}
|
|
2385
2651
|
if (this._cropRect) {
|
|
2386
2652
|
try {
|
|
2387
2653
|
this.canvas.remove(this._cropRect);
|
|
2388
|
-
} catch (
|
|
2654
|
+
} catch (error) {
|
|
2655
|
+
void error;
|
|
2389
2656
|
}
|
|
2390
2657
|
this._cropRect = null;
|
|
2391
2658
|
}
|
|
2392
2659
|
if (this.containerElement && this._containerOriginalOverflow !== void 0) {
|
|
2393
2660
|
try {
|
|
2394
2661
|
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
2395
|
-
} catch (
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
void error;
|
|
2396
2664
|
}
|
|
2397
2665
|
}
|
|
2398
2666
|
if (this.canvas) {
|
|
2399
2667
|
try {
|
|
2400
2668
|
this.canvas.dispose();
|
|
2401
|
-
} catch (
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
void error;
|
|
2402
2671
|
}
|
|
2403
2672
|
this.canvas = null;
|
|
2404
2673
|
this.canvasElement = null;
|
|
@@ -2409,54 +2678,52 @@ var ImageEditor = class {
|
|
|
2409
2678
|
};
|
|
2410
2679
|
var AnimationQueue = class {
|
|
2411
2680
|
/**
|
|
2412
|
-
* Creates
|
|
2413
|
-
*
|
|
2414
|
-
* @constructor
|
|
2681
|
+
* Creates an empty animation queue.
|
|
2415
2682
|
*/
|
|
2416
2683
|
constructor() {
|
|
2417
|
-
this.
|
|
2418
|
-
this.
|
|
2684
|
+
this.animationTasks = [];
|
|
2685
|
+
this.isRunning = false;
|
|
2419
2686
|
}
|
|
2420
2687
|
/**
|
|
2421
2688
|
* Adds an animation function to the queue.
|
|
2422
2689
|
*
|
|
2423
|
-
* @param
|
|
2424
|
-
* @returns {Promise
|
|
2690
|
+
* @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
|
|
2691
|
+
* @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
|
|
2425
2692
|
*/
|
|
2426
2693
|
async add(animationFn) {
|
|
2427
2694
|
return new Promise((resolve, reject) => {
|
|
2428
|
-
this.
|
|
2429
|
-
if (!this.
|
|
2430
|
-
this.
|
|
2695
|
+
this.animationTasks.push({ animationFn, resolve, reject });
|
|
2696
|
+
if (!this.isRunning) {
|
|
2697
|
+
this._drainQueue();
|
|
2431
2698
|
}
|
|
2432
2699
|
});
|
|
2433
2700
|
}
|
|
2434
2701
|
/**
|
|
2435
|
-
*
|
|
2702
|
+
* Runs queued animation tasks sequentially until the queue is empty.
|
|
2436
2703
|
*
|
|
2437
2704
|
* @private
|
|
2438
2705
|
* @returns {Promise<void>}
|
|
2439
2706
|
*/
|
|
2440
|
-
async
|
|
2441
|
-
if (this.
|
|
2442
|
-
this.
|
|
2707
|
+
async _drainQueue() {
|
|
2708
|
+
if (this.animationTasks.length === 0) {
|
|
2709
|
+
this.isRunning = false;
|
|
2443
2710
|
return;
|
|
2444
2711
|
}
|
|
2445
|
-
this.
|
|
2446
|
-
const {
|
|
2712
|
+
this.isRunning = true;
|
|
2713
|
+
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
2447
2714
|
try {
|
|
2448
|
-
const result = await
|
|
2715
|
+
const result = await animationFn();
|
|
2449
2716
|
resolve(result);
|
|
2450
2717
|
} catch (error) {
|
|
2451
2718
|
reject(error);
|
|
2452
2719
|
}
|
|
2453
|
-
this.
|
|
2720
|
+
await this._drainQueue();
|
|
2454
2721
|
}
|
|
2455
2722
|
};
|
|
2456
2723
|
var Command = class {
|
|
2457
2724
|
/**
|
|
2458
|
-
* @param {
|
|
2459
|
-
* @param {
|
|
2725
|
+
* @param {HistoryTaskCallback} execute - Function that performs the action.
|
|
2726
|
+
* @param {HistoryTaskCallback} undo - Function that reverts the action.
|
|
2460
2727
|
*/
|
|
2461
2728
|
constructor(execute, undo) {
|
|
2462
2729
|
this.execute = execute;
|
|
@@ -2465,7 +2732,7 @@ var Command = class {
|
|
|
2465
2732
|
};
|
|
2466
2733
|
var HistoryManager = class {
|
|
2467
2734
|
/**
|
|
2468
|
-
* @param {number} [maxSize=50]
|
|
2735
|
+
* @param {number} [maxSize=50] - Maximum number of commands to keep in history.
|
|
2469
2736
|
*/
|
|
2470
2737
|
constructor(maxSize = 50) {
|
|
2471
2738
|
this.history = [];
|
|
@@ -2473,11 +2740,24 @@ var HistoryManager = class {
|
|
|
2473
2740
|
this.maxSize = maxSize;
|
|
2474
2741
|
this.pending = Promise.resolve();
|
|
2475
2742
|
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Queues a history task after the previously queued undo/redo task completes.
|
|
2745
|
+
*
|
|
2746
|
+
* @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
|
|
2747
|
+
* @returns {Promise<void>} Resolves or rejects with the queued task result.
|
|
2748
|
+
* @private
|
|
2749
|
+
*/
|
|
2476
2750
|
enqueue(task) {
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2751
|
+
const nextTask = this.pending.then(task, task);
|
|
2752
|
+
let pendingAfterTask;
|
|
2753
|
+
const resetPending = () => {
|
|
2754
|
+
if (this.pending === pendingAfterTask) {
|
|
2755
|
+
this.pending = Promise.resolve();
|
|
2756
|
+
}
|
|
2757
|
+
};
|
|
2758
|
+
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
2759
|
+
this.pending = pendingAfterTask;
|
|
2760
|
+
return nextTask;
|
|
2481
2761
|
}
|
|
2482
2762
|
/**
|
|
2483
2763
|
* Executes a new command and pushes it onto the history stack.
|
|
@@ -2527,7 +2807,7 @@ var HistoryManager = class {
|
|
|
2527
2807
|
/**
|
|
2528
2808
|
* Undoes the last executed command if possible.
|
|
2529
2809
|
*
|
|
2530
|
-
* @returns {void}
|
|
2810
|
+
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
2531
2811
|
*/
|
|
2532
2812
|
undo() {
|
|
2533
2813
|
return this.enqueue(async () => {
|
|
@@ -2541,7 +2821,7 @@ var HistoryManager = class {
|
|
|
2541
2821
|
/**
|
|
2542
2822
|
* Redoes the next command in history if possible.
|
|
2543
2823
|
*
|
|
2544
|
-
* @returns {void}
|
|
2824
|
+
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
2545
2825
|
*/
|
|
2546
2826
|
redo() {
|
|
2547
2827
|
return this.enqueue(async () => {
|