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