@bensitu/image-editor 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -27
- package/dist/image-editor.esm.js +1753 -864
- package/dist/image-editor.esm.js.map +3 -3
- package/dist/image-editor.esm.min.js +3 -8
- package/dist/image-editor.esm.min.js.map +3 -3
- package/dist/image-editor.esm.min.mjs +3 -8
- package/dist/image-editor.esm.min.mjs.map +3 -3
- package/dist/image-editor.esm.mjs +1753 -864
- package/dist/image-editor.esm.mjs.map +3 -3
- package/dist/image-editor.js +1753 -864
- package/dist/image-editor.js.map +3 -3
- package/dist/image-editor.min.js +2 -7
- package/dist/image-editor.min.js.map +3 -3
- package/image-editor.d.ts +41 -9
- package/package.json +1 -1
- package/src/image-editor.js +1955 -903
|
@@ -5,15 +5,10 @@ import fabricModule from "fabric";
|
|
|
5
5
|
/**
|
|
6
6
|
* @file image-editor.js
|
|
7
7
|
* @module image-editor
|
|
8
|
-
* @version 1.
|
|
8
|
+
* @version 1.3.0
|
|
9
9
|
* @author Ben Situ
|
|
10
10
|
* @license MIT
|
|
11
11
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
12
|
-
*
|
|
13
|
-
* This source file is free software, available under the MIT license.
|
|
14
|
-
* It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
|
15
|
-
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
16
|
-
* See the license files for details.
|
|
17
12
|
*/
|
|
18
13
|
var fabric = null;
|
|
19
14
|
function getGlobalScope() {
|
|
@@ -81,6 +76,7 @@ var ImageEditor = class {
|
|
|
81
76
|
downsampleMaxWidth: 4e3,
|
|
82
77
|
downsampleMaxHeight: 3e3,
|
|
83
78
|
downsampleQuality: 0.92,
|
|
79
|
+
imageLoadTimeoutMs: 3e4,
|
|
84
80
|
exportMultiplier: 1,
|
|
85
81
|
exportImageAreaByDefault: true,
|
|
86
82
|
defaultMaskWidth: 50,
|
|
@@ -115,9 +111,9 @@ var ImageEditor = class {
|
|
|
115
111
|
this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
|
|
116
112
|
}
|
|
117
113
|
this.canvas = null;
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
114
|
+
this.canvasElement = null;
|
|
115
|
+
this.containerElement = null;
|
|
116
|
+
this.placeholderElement = null;
|
|
121
117
|
this.originalImage = null;
|
|
122
118
|
this.baseImageScale = 1;
|
|
123
119
|
this.currentScale = 1;
|
|
@@ -127,27 +123,69 @@ var ImageEditor = class {
|
|
|
127
123
|
this.elements = {};
|
|
128
124
|
this.isImageLoadedToCanvas = false;
|
|
129
125
|
this.maxHistorySize = 50;
|
|
130
|
-
this.
|
|
126
|
+
this._handlersByElementKey = {};
|
|
131
127
|
this._lastMask = null;
|
|
132
128
|
this._lastMaskInitialLeft = null;
|
|
133
129
|
this._lastMaskInitialTop = null;
|
|
134
130
|
this._lastMaskInitialWidth = null;
|
|
131
|
+
this._lastSnapshot = null;
|
|
135
132
|
this._cropMode = false;
|
|
136
133
|
this._cropRect = null;
|
|
137
134
|
this._cropHandlers = [];
|
|
135
|
+
this._cropPrevEvented = null;
|
|
136
|
+
this._prevSelectionSetting = void 0;
|
|
137
|
+
this._containerOriginalOverflow = void 0;
|
|
138
|
+
this._scrollbarSizeCache = null;
|
|
138
139
|
this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
|
|
139
|
-
this.
|
|
140
|
+
this.animationQueue = new AnimationQueue();
|
|
140
141
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
141
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Backward-compatible alias for {@link ImageEditor#canvasElement}.
|
|
145
|
+
*
|
|
146
|
+
* @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
|
|
147
|
+
* @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
|
|
148
|
+
*/
|
|
149
|
+
get canvasEl() {
|
|
150
|
+
return this.canvasElement;
|
|
151
|
+
}
|
|
152
|
+
set canvasEl(value) {
|
|
153
|
+
this.canvasElement = value;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Backward-compatible alias for {@link ImageEditor#containerElement}.
|
|
157
|
+
*
|
|
158
|
+
* @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
|
|
159
|
+
* @returns {HTMLElement|null} The canvas viewport/container element.
|
|
160
|
+
*/
|
|
161
|
+
get containerEl() {
|
|
162
|
+
return this.containerElement;
|
|
163
|
+
}
|
|
164
|
+
set containerEl(value) {
|
|
165
|
+
this.containerElement = value;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Backward-compatible alias for {@link ImageEditor#placeholderElement}.
|
|
169
|
+
*
|
|
170
|
+
* @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
|
|
171
|
+
* @returns {HTMLElement|null} The placeholder element shown before an image loads.
|
|
172
|
+
*/
|
|
173
|
+
get placeholderEl() {
|
|
174
|
+
return this.placeholderElement;
|
|
175
|
+
}
|
|
176
|
+
set placeholderEl(value) {
|
|
177
|
+
this.placeholderElement = value;
|
|
178
|
+
}
|
|
142
179
|
/**
|
|
143
180
|
* Initializes the editor, binds to DOM elements, sets up event handlers,
|
|
144
181
|
* and (optionally) loads an initial image.
|
|
145
182
|
* Use this method to set up the editor UI before interacting with it.
|
|
146
183
|
*
|
|
147
184
|
* @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
|
|
148
|
-
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
149
|
-
* rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
150
|
-
* zoomInBtn, zoomOutBtn, resetBtn,
|
|
185
|
+
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
186
|
+
* rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
187
|
+
* mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
|
|
188
|
+
* uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
|
|
151
189
|
*
|
|
152
190
|
* @returns {void}
|
|
153
191
|
*
|
|
@@ -219,225 +257,310 @@ var ImageEditor = class {
|
|
|
219
257
|
}
|
|
220
258
|
}
|
|
221
259
|
/**
|
|
222
|
-
*
|
|
260
|
+
* Initializes the Fabric canvas, viewport elements, and selection event handlers.
|
|
261
|
+
*
|
|
262
|
+
* @returns {void}
|
|
223
263
|
* @private
|
|
224
264
|
*/
|
|
225
265
|
_initCanvas() {
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
266
|
+
const canvasElement = document.getElementById(this.elements.canvas);
|
|
267
|
+
if (!canvasElement)
|
|
228
268
|
throw new Error("Canvas is not found: " + this.elements.canvas);
|
|
229
|
-
this.
|
|
269
|
+
this.canvasElement = canvasElement;
|
|
230
270
|
if (this.elements.canvasContainer) {
|
|
231
|
-
const
|
|
232
|
-
this.
|
|
271
|
+
const containerElement = document.getElementById(this.elements.canvasContainer);
|
|
272
|
+
this.containerElement = containerElement || canvasElement.parentElement;
|
|
233
273
|
} else {
|
|
234
|
-
this.
|
|
274
|
+
this.containerElement = canvasElement.parentElement;
|
|
235
275
|
}
|
|
236
|
-
this.
|
|
237
|
-
let
|
|
238
|
-
let
|
|
239
|
-
if (this.
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
|
|
277
|
+
let initialWidth = this.options.canvasWidth;
|
|
278
|
+
let initialHeight = this.options.canvasHeight;
|
|
279
|
+
if (this.containerElement) {
|
|
280
|
+
const containerWidth = Math.floor(this.containerElement.clientWidth);
|
|
281
|
+
const containerHeight = Math.floor(this.containerElement.clientHeight);
|
|
282
|
+
if (containerWidth > 0 && containerHeight > 0) {
|
|
283
|
+
initialWidth = containerWidth;
|
|
284
|
+
initialHeight = containerHeight;
|
|
245
285
|
}
|
|
246
286
|
}
|
|
247
|
-
this.canvas = new fabric.Canvas(
|
|
248
|
-
width:
|
|
249
|
-
height:
|
|
287
|
+
this.canvas = new fabric.Canvas(canvasElement, {
|
|
288
|
+
width: initialWidth,
|
|
289
|
+
height: initialHeight,
|
|
250
290
|
backgroundColor: this.options.backgroundColor,
|
|
251
291
|
selection: this.options.groupSelection,
|
|
252
292
|
preserveObjectStacking: true
|
|
253
293
|
});
|
|
254
|
-
this.canvas.on("selection:created", (
|
|
255
|
-
this.canvas.on("selection:updated", (
|
|
256
|
-
this.canvas.on("selection:cleared", () => this.
|
|
257
|
-
this.canvas.on("object:moving", (
|
|
258
|
-
if (
|
|
259
|
-
this._syncMaskLabel(
|
|
294
|
+
this.canvas.on("selection:created", (event) => this._handleSelectionChanged(event.selected));
|
|
295
|
+
this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
|
|
296
|
+
this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
|
|
297
|
+
this.canvas.on("object:moving", (event) => {
|
|
298
|
+
if (event.target && event.target.maskId)
|
|
299
|
+
this._syncMaskLabel(event.target);
|
|
260
300
|
});
|
|
261
|
-
this.canvas.on("object:scaling", (
|
|
262
|
-
if (
|
|
263
|
-
this._syncMaskLabel(
|
|
301
|
+
this.canvas.on("object:scaling", (event) => {
|
|
302
|
+
if (event.target && event.target.maskId)
|
|
303
|
+
this._syncMaskLabel(event.target);
|
|
264
304
|
});
|
|
265
|
-
this.canvas.on("object:rotating", (
|
|
266
|
-
if (
|
|
267
|
-
this._syncMaskLabel(
|
|
305
|
+
this.canvas.on("object:rotating", (event) => {
|
|
306
|
+
if (event.target && event.target.maskId)
|
|
307
|
+
this._syncMaskLabel(event.target);
|
|
268
308
|
});
|
|
269
|
-
this.canvas.on("object:modified", (
|
|
270
|
-
|
|
271
|
-
|
|
309
|
+
this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
|
|
310
|
+
this.canvasElement.style.display = "block";
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Records a history entry after Fabric finishes modifying one or more masks.
|
|
314
|
+
*
|
|
315
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
|
|
316
|
+
* @returns {void}
|
|
317
|
+
* @private
|
|
318
|
+
*/
|
|
319
|
+
_handleObjectModified(target) {
|
|
320
|
+
const masks = this._getModifiedMasks(target);
|
|
321
|
+
if (!masks.length)
|
|
322
|
+
return;
|
|
323
|
+
masks.forEach((mask) => {
|
|
324
|
+
if (typeof mask.setCoords === "function")
|
|
325
|
+
mask.setCoords();
|
|
326
|
+
this._syncMaskLabel(mask);
|
|
272
327
|
});
|
|
273
|
-
this.
|
|
328
|
+
this._expandCanvasToFitObjects(masks);
|
|
329
|
+
this.saveState();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Extracts editable mask objects from a Fabric modification target.
|
|
333
|
+
*
|
|
334
|
+
* @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
|
|
335
|
+
* @returns {Array<fabric.Object>} Modified mask objects.
|
|
336
|
+
* @private
|
|
337
|
+
*/
|
|
338
|
+
_getModifiedMasks(target) {
|
|
339
|
+
if (!target)
|
|
340
|
+
return [];
|
|
341
|
+
if (target.maskId)
|
|
342
|
+
return [target];
|
|
343
|
+
const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
|
|
344
|
+
return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Updates container overflow behavior for fit and cover image modes.
|
|
348
|
+
*
|
|
349
|
+
* @param {Object} [options={}] - Overflow update options.
|
|
350
|
+
* @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
|
|
351
|
+
* @returns {void}
|
|
352
|
+
* @private
|
|
353
|
+
*/
|
|
354
|
+
_syncContainerOverflow(options = {}) {
|
|
355
|
+
if (!this.containerElement || !this.containerElement.style)
|
|
356
|
+
return;
|
|
357
|
+
if (this._containerOriginalOverflow === void 0) {
|
|
358
|
+
this._containerOriginalOverflow = this.containerElement.style.overflow || "";
|
|
359
|
+
}
|
|
360
|
+
const shouldPreserveScroll = options.preserveScroll === true;
|
|
361
|
+
if (this.options.coverImageToCanvas) {
|
|
362
|
+
this.containerElement.style.overflow = "scroll";
|
|
363
|
+
if (!shouldPreserveScroll) {
|
|
364
|
+
this.containerElement.scrollLeft = 0;
|
|
365
|
+
this.containerElement.scrollTop = 0;
|
|
366
|
+
}
|
|
367
|
+
} else if (this.options.fitImageToCanvas) {
|
|
368
|
+
this.containerElement.style.overflow = "auto";
|
|
369
|
+
if (!shouldPreserveScroll) {
|
|
370
|
+
this.containerElement.scrollLeft = 0;
|
|
371
|
+
this.containerElement.scrollTop = 0;
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
375
|
+
}
|
|
274
376
|
}
|
|
275
377
|
/**
|
|
276
378
|
* DOM / UI bindings
|
|
277
379
|
* @private
|
|
278
380
|
*/
|
|
279
381
|
_bindEvents() {
|
|
280
|
-
this._bindIfExists("uploadArea", "click", () =>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
382
|
+
this._bindIfExists("uploadArea", "click", () => {
|
|
383
|
+
const uploadAreaElement = document.getElementById(this.elements.uploadArea);
|
|
384
|
+
if (this._isElementDisabled(uploadAreaElement))
|
|
385
|
+
return;
|
|
386
|
+
document.getElementById(this.elements.imageInput)?.click();
|
|
387
|
+
});
|
|
388
|
+
this._bindIfExists("imageInput", "change", (event) => {
|
|
389
|
+
const file = event.target.files && event.target.files[0];
|
|
390
|
+
if (file)
|
|
391
|
+
this._loadImageFile(file);
|
|
392
|
+
});
|
|
289
393
|
this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
|
|
290
394
|
this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
|
|
291
395
|
this._bindIfExists("resetBtn", "click", () => {
|
|
292
|
-
this.
|
|
396
|
+
this.resetImageTransform();
|
|
293
397
|
});
|
|
294
|
-
this._bindIfExists("addMaskBtn", "click", () => this.
|
|
398
|
+
this._bindIfExists("addMaskBtn", "click", () => this.createMask());
|
|
295
399
|
this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
|
|
296
400
|
this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
|
|
297
|
-
this._bindIfExists("mergeBtn", "click", () => this.
|
|
401
|
+
this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
|
|
298
402
|
this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
|
|
299
403
|
this._bindIfExists("undoBtn", "click", () => this.undo());
|
|
300
404
|
this._bindIfExists("redoBtn", "click", () => this.redo());
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
step = p;
|
|
322
|
-
}
|
|
323
|
-
this.rotateImage(this.currentRotation + step);
|
|
324
|
-
});
|
|
405
|
+
this._bindIfExists("rotateLeftBtn", "click", () => {
|
|
406
|
+
const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
|
|
407
|
+
let step = this.options.rotationStep;
|
|
408
|
+
if (rotationInputElement) {
|
|
409
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
410
|
+
if (!isNaN(parsedStep))
|
|
411
|
+
step = parsedStep;
|
|
412
|
+
}
|
|
413
|
+
this.rotateImage(this.currentRotation - step);
|
|
414
|
+
});
|
|
415
|
+
this._bindIfExists("rotateRightBtn", "click", () => {
|
|
416
|
+
const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
|
|
417
|
+
let step = this.options.rotationStep;
|
|
418
|
+
if (rotationInputElement) {
|
|
419
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
420
|
+
if (!isNaN(parsedStep))
|
|
421
|
+
step = parsedStep;
|
|
422
|
+
}
|
|
423
|
+
this.rotateImage(this.currentRotation + step);
|
|
424
|
+
});
|
|
325
425
|
this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
|
|
326
426
|
this._bindIfExists("applyCropBtn", "click", () => {
|
|
327
|
-
this.applyCrop().catch((
|
|
427
|
+
this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
|
|
328
428
|
});
|
|
329
429
|
this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
|
|
330
430
|
}
|
|
331
|
-
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* @param {
|
|
335
|
-
* @param {
|
|
336
|
-
* @param {
|
|
431
|
+
/**
|
|
432
|
+
* Binds a DOM event listener when the configured element exists and records it for disposal.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} key - Key in this.elements for the target DOM element.
|
|
435
|
+
* @param {string} eventName - DOM event name to listen for.
|
|
436
|
+
* @param {EventListener} handler - Event listener callback.
|
|
337
437
|
* @private
|
|
338
438
|
*/
|
|
339
|
-
_bindIfExists(key,
|
|
340
|
-
const
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
this.
|
|
344
|
-
if (!this.
|
|
345
|
-
this.
|
|
346
|
-
this.
|
|
439
|
+
_bindIfExists(key, eventName, handler) {
|
|
440
|
+
const element = document.getElementById(this.elements[key]);
|
|
441
|
+
if (element) {
|
|
442
|
+
element.addEventListener(eventName, handler);
|
|
443
|
+
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
444
|
+
if (!this._handlersByElementKey[key])
|
|
445
|
+
this._handlersByElementKey[key] = [];
|
|
446
|
+
this._handlersByElementKey[key].push({ eventName, handler });
|
|
347
447
|
}
|
|
348
448
|
}
|
|
349
|
-
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* @param {File} file
|
|
449
|
+
/**
|
|
450
|
+
* Reads an image File as a data URL and loads it into the Fabric canvas.
|
|
451
|
+
*
|
|
452
|
+
* @param {File} file - Image file selected by the user.
|
|
353
453
|
* @private
|
|
354
454
|
*/
|
|
355
455
|
_loadImageFile(file) {
|
|
356
456
|
if (!file || !file.type.startsWith("image/"))
|
|
357
457
|
return;
|
|
358
458
|
const reader = new FileReader();
|
|
359
|
-
reader.onload = (
|
|
360
|
-
reader.onerror = (
|
|
361
|
-
this._reportError("Image file could not be read",
|
|
459
|
+
reader.onload = (event) => this.loadImage(event.target.result);
|
|
460
|
+
reader.onerror = (event) => {
|
|
461
|
+
this._reportError("Image file could not be read", event);
|
|
362
462
|
};
|
|
363
463
|
reader.readAsDataURL(file);
|
|
364
464
|
}
|
|
365
465
|
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
466
|
+
* Warns when more than one mutually exclusive image layout mode is enabled.
|
|
467
|
+
*
|
|
468
|
+
* @returns {void}
|
|
469
|
+
* @private
|
|
470
|
+
*/
|
|
471
|
+
_warnOnImageLayoutOptionConflict() {
|
|
472
|
+
const activeModes = [
|
|
473
|
+
["fitImageToCanvas", this.options.fitImageToCanvas],
|
|
474
|
+
["coverImageToCanvas", this.options.coverImageToCanvas],
|
|
475
|
+
["expandCanvasToImage", this.options.expandCanvasToImage]
|
|
476
|
+
].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
|
|
477
|
+
if (activeModes.length <= 1)
|
|
478
|
+
return;
|
|
479
|
+
this._reportWarning(
|
|
480
|
+
`Only one image layout mode should be enabled. Active modes: ${activeModes.join(", ")}.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Loads a base64 data URL into the Fabric canvas as the base image.
|
|
485
|
+
*
|
|
486
|
+
* @async
|
|
487
|
+
* @param {string} imageBase64 - Image data URL beginning with `data:image/`.
|
|
488
|
+
* @param {LoadImageOptions} [options={}] - Optional load behavior.
|
|
489
|
+
* @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
|
|
490
|
+
* @public
|
|
491
|
+
*/
|
|
492
|
+
async loadImage(imageBase64, options = {}) {
|
|
371
493
|
if (!this._fabricLoaded)
|
|
372
494
|
return;
|
|
373
495
|
if (!this.canvas)
|
|
374
496
|
return;
|
|
375
|
-
if (!
|
|
497
|
+
if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
|
|
376
498
|
return;
|
|
499
|
+
this._warnOnImageLayoutOptionConflict();
|
|
377
500
|
this._setPlaceholderVisible(false);
|
|
378
|
-
|
|
379
|
-
|
|
501
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
502
|
+
const imageElement = await this._createImageElement(imageBase64);
|
|
503
|
+
let loadSource = imageBase64;
|
|
380
504
|
if (this.options.downsampleOnLoad) {
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
505
|
+
const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
|
|
506
|
+
if (shouldResize) {
|
|
383
507
|
const ratio = Math.min(
|
|
384
|
-
this.options.downsampleMaxWidth /
|
|
385
|
-
this.options.downsampleMaxHeight /
|
|
508
|
+
this.options.downsampleMaxWidth / imageElement.naturalWidth,
|
|
509
|
+
this.options.downsampleMaxHeight / imageElement.naturalHeight
|
|
386
510
|
);
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
|
|
511
|
+
const targetWidth = Math.round(imageElement.naturalWidth * ratio);
|
|
512
|
+
const targetHeight = Math.round(imageElement.naturalHeight * ratio);
|
|
513
|
+
loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
|
|
390
514
|
}
|
|
391
515
|
}
|
|
392
516
|
return new Promise((resolve, reject) => {
|
|
393
|
-
fabric.Image.fromURL(
|
|
517
|
+
fabric.Image.fromURL(loadSource, (fabricImage) => {
|
|
394
518
|
try {
|
|
395
|
-
if (!
|
|
519
|
+
if (!fabricImage)
|
|
396
520
|
throw new Error("Image could not be loaded");
|
|
397
521
|
this.canvas.discardActiveObject();
|
|
398
522
|
this._hideAllMaskLabels();
|
|
399
523
|
this.canvas.clear();
|
|
400
524
|
this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
const
|
|
525
|
+
fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
|
|
526
|
+
const imageWidth = fabricImage.width;
|
|
527
|
+
const imageHeight = fabricImage.height;
|
|
528
|
+
const viewport = this._getContainerViewportSize();
|
|
529
|
+
const minWidth = viewport.width;
|
|
530
|
+
const minHeight = viewport.height;
|
|
406
531
|
if (this.options.fitImageToCanvas) {
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
this._setCanvasSizeInt(
|
|
410
|
-
const fitScale = Math.min(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
this.baseImageScale =
|
|
532
|
+
const canvasWidth = Math.max(1, minWidth - 1);
|
|
533
|
+
const canvasHeight = Math.max(1, minHeight - 1);
|
|
534
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
535
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
536
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
537
|
+
fabricImage.scale(fitScale);
|
|
538
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
414
539
|
} else if (this.options.coverImageToCanvas) {
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
fimg.scale(coverScale);
|
|
421
|
-
this.baseImageScale = fimg.scaleX || 1;
|
|
540
|
+
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
541
|
+
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
542
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
543
|
+
fabricImage.scale(layout.scale);
|
|
544
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
422
545
|
} else if (this.options.expandCanvasToImage) {
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
this._setCanvasSizeInt(
|
|
426
|
-
|
|
427
|
-
|
|
546
|
+
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
547
|
+
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
548
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
549
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
550
|
+
fabricImage.scale(1);
|
|
428
551
|
this.baseImageScale = 1;
|
|
429
552
|
} else {
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
this._setCanvasSizeInt(
|
|
433
|
-
const fitScale = Math.min(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
this.baseImageScale =
|
|
553
|
+
const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
|
|
554
|
+
const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
|
|
555
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
556
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
557
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
558
|
+
fabricImage.scale(fitScale);
|
|
559
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
437
560
|
}
|
|
438
|
-
this.originalImage =
|
|
439
|
-
this.canvas.add(
|
|
440
|
-
this.canvas.sendToBack(
|
|
561
|
+
this.originalImage = fabricImage;
|
|
562
|
+
this.canvas.add(fabricImage);
|
|
563
|
+
this.canvas.sendToBack(fabricImage);
|
|
441
564
|
this._lastMask = null;
|
|
442
565
|
this._lastMaskInitialLeft = null;
|
|
443
566
|
this._lastMaskInitialTop = null;
|
|
@@ -450,12 +573,17 @@ var ImageEditor = class {
|
|
|
450
573
|
this.isImageLoadedToCanvas = true;
|
|
451
574
|
this._updateUI();
|
|
452
575
|
this.canvas.renderAll();
|
|
576
|
+
try {
|
|
577
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
578
|
+
} catch (error) {
|
|
579
|
+
this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
|
|
580
|
+
}
|
|
453
581
|
if (typeof this.onImageLoaded === "function") {
|
|
454
582
|
this.onImageLoaded();
|
|
455
583
|
}
|
|
456
584
|
resolve();
|
|
457
|
-
} catch (
|
|
458
|
-
reject(
|
|
585
|
+
} catch (error) {
|
|
586
|
+
reject(error);
|
|
459
587
|
}
|
|
460
588
|
}, { crossOrigin: "anonymous" });
|
|
461
589
|
});
|
|
@@ -471,114 +599,530 @@ var ImageEditor = class {
|
|
|
471
599
|
/**
|
|
472
600
|
* Creates an HTMLImageElement from a given data URL.
|
|
473
601
|
*
|
|
474
|
-
* @param {string}
|
|
602
|
+
* @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
|
|
603
|
+
* @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
|
|
475
604
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
476
605
|
* @private
|
|
477
606
|
*/
|
|
478
|
-
_createImageElement(
|
|
479
|
-
return new Promise((
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
607
|
+
_createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
608
|
+
return new Promise((resolve, reject) => {
|
|
609
|
+
const imageElement = new Image();
|
|
610
|
+
let isSettled = false;
|
|
611
|
+
const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
612
|
+
let timerId;
|
|
613
|
+
const settle = (callback) => {
|
|
614
|
+
if (isSettled)
|
|
615
|
+
return;
|
|
616
|
+
isSettled = true;
|
|
617
|
+
clearTimeout(timerId);
|
|
618
|
+
imageElement.onload = null;
|
|
619
|
+
imageElement.onerror = null;
|
|
620
|
+
callback();
|
|
490
621
|
};
|
|
491
|
-
|
|
622
|
+
timerId = setTimeout(() => {
|
|
623
|
+
settle(() => reject(new Error("Image load timed out")));
|
|
624
|
+
try {
|
|
625
|
+
imageElement.src = "";
|
|
626
|
+
} catch (error) {
|
|
627
|
+
}
|
|
628
|
+
}, safeTimeoutMs);
|
|
629
|
+
imageElement.onload = () => settle(() => resolve(imageElement));
|
|
630
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
631
|
+
imageElement.src = dataUrl;
|
|
492
632
|
});
|
|
493
633
|
}
|
|
494
634
|
/**
|
|
495
635
|
* Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
|
|
496
636
|
*
|
|
497
|
-
* @param {HTMLImageElement}
|
|
498
|
-
* @param {number}
|
|
499
|
-
* @param {number}
|
|
637
|
+
* @param {HTMLImageElement} imageElement - The image element to resample.
|
|
638
|
+
* @param {number} targetWidth - Target width (in pixels) for the resampled image.
|
|
639
|
+
* @param {number} targetHeight - Target height (in pixels) for the resampled image.
|
|
500
640
|
* @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
|
|
501
641
|
* @returns {string} A data URL representing the resampled image as JPEG.
|
|
502
642
|
* @private
|
|
503
643
|
*/
|
|
504
|
-
_resampleImageToDataURL(
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
644
|
+
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
|
|
645
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
646
|
+
offscreenCanvas.width = targetWidth;
|
|
647
|
+
offscreenCanvas.height = targetHeight;
|
|
648
|
+
const context = offscreenCanvas.getContext("2d");
|
|
649
|
+
if (!context)
|
|
650
|
+
throw new Error("2D canvas context is unavailable");
|
|
651
|
+
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
652
|
+
return offscreenCanvas.toDataURL("image/jpeg", quality);
|
|
511
653
|
}
|
|
512
654
|
/**
|
|
513
655
|
* Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
|
|
514
656
|
* Also updates the corresponding style attributes.
|
|
515
657
|
*
|
|
516
|
-
* @param {number}
|
|
517
|
-
* @param {number}
|
|
658
|
+
* @param {number} width - Canvas width in pixels.
|
|
659
|
+
* @param {number} height - Canvas height in pixels.
|
|
518
660
|
* @private
|
|
519
661
|
*/
|
|
520
|
-
_setCanvasSizeInt(
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
this.canvas.setWidth(
|
|
524
|
-
this.canvas.setHeight(
|
|
662
|
+
_setCanvasSizeInt(width, height) {
|
|
663
|
+
const integerWidth = Math.max(1, Math.round(Number(width) || 1));
|
|
664
|
+
const integerHeight = Math.max(1, Math.round(Number(height) || 1));
|
|
665
|
+
this.canvas.setWidth(integerWidth);
|
|
666
|
+
this.canvas.setHeight(integerHeight);
|
|
525
667
|
if (typeof this.canvas.calcOffset === "function")
|
|
526
668
|
this.canvas.calcOffset();
|
|
527
|
-
if (this.
|
|
528
|
-
this.
|
|
529
|
-
this.
|
|
530
|
-
this.
|
|
669
|
+
if (this.canvasElement) {
|
|
670
|
+
this.canvasElement.style.width = integerWidth + "px";
|
|
671
|
+
this.canvasElement.style.height = integerHeight + "px";
|
|
672
|
+
this.canvasElement.style.maxWidth = "none";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
_ceilCanvasDimension(value) {
|
|
676
|
+
const numericValue = Number(value) || 0;
|
|
677
|
+
const roundedValue = Math.round(numericValue);
|
|
678
|
+
if (Math.abs(numericValue - roundedValue) < 0.01)
|
|
679
|
+
return roundedValue;
|
|
680
|
+
return Math.ceil(numericValue);
|
|
681
|
+
}
|
|
682
|
+
_getContainerViewportSize() {
|
|
683
|
+
if (!this.containerElement) {
|
|
684
|
+
return {
|
|
685
|
+
width: Math.max(1, Math.floor(this.options.canvasWidth || 1)),
|
|
686
|
+
height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
690
|
+
return {
|
|
691
|
+
width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
|
|
692
|
+
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
696
|
+
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
697
|
+
return { width, height };
|
|
698
|
+
}
|
|
699
|
+
_hasFixedContainerScrollbars() {
|
|
700
|
+
if (!this.containerElement)
|
|
701
|
+
return false;
|
|
702
|
+
const inlineOverflow = this.containerElement.style.overflow;
|
|
703
|
+
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
704
|
+
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
705
|
+
let computedOverflow = "";
|
|
706
|
+
let computedOverflowX = "";
|
|
707
|
+
let computedOverflowY = "";
|
|
708
|
+
if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
|
|
709
|
+
const style = window.getComputedStyle(this.containerElement);
|
|
710
|
+
computedOverflow = style.overflow;
|
|
711
|
+
computedOverflowX = style.overflowX;
|
|
712
|
+
computedOverflowY = style.overflowY;
|
|
713
|
+
}
|
|
714
|
+
return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
|
|
715
|
+
}
|
|
716
|
+
_getScrollbarSize() {
|
|
717
|
+
if (this._scrollbarSizeCache) {
|
|
718
|
+
return { ...this._scrollbarSizeCache };
|
|
531
719
|
}
|
|
720
|
+
if (typeof document === "undefined" || !document.createElement || !document.body) {
|
|
721
|
+
return { width: 0, height: 0 };
|
|
722
|
+
}
|
|
723
|
+
const probe = document.createElement("div");
|
|
724
|
+
probe.style.position = "absolute";
|
|
725
|
+
probe.style.visibility = "hidden";
|
|
726
|
+
probe.style.overflow = "scroll";
|
|
727
|
+
probe.style.width = "100px";
|
|
728
|
+
probe.style.height = "100px";
|
|
729
|
+
probe.style.top = "-9999px";
|
|
730
|
+
document.body.appendChild(probe);
|
|
731
|
+
const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
|
732
|
+
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
733
|
+
document.body.removeChild(probe);
|
|
734
|
+
this._scrollbarSizeCache = { width, height };
|
|
735
|
+
return { ...this._scrollbarSizeCache };
|
|
736
|
+
}
|
|
737
|
+
_getScrollSafetyMargin() {
|
|
738
|
+
return 2;
|
|
739
|
+
}
|
|
740
|
+
_getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
|
|
741
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
742
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
743
|
+
const safeWidth = Math.max(1, viewport.width - safetyMargin);
|
|
744
|
+
const safeHeight = Math.max(1, viewport.height - safetyMargin);
|
|
745
|
+
return {
|
|
746
|
+
width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
|
|
747
|
+
height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
|
|
748
|
+
viewportWidth: viewport.width,
|
|
749
|
+
viewportHeight: viewport.height,
|
|
750
|
+
hasHorizontal: true,
|
|
751
|
+
hasVertical: true
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const scrollbar = this._getScrollbarSize();
|
|
755
|
+
let hasVertical = false;
|
|
756
|
+
let hasHorizontal = false;
|
|
757
|
+
let effectiveWidth = viewport.width;
|
|
758
|
+
let effectiveHeight = viewport.height;
|
|
759
|
+
for (let i = 0; i < 4; i += 1) {
|
|
760
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
761
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
762
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
763
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
764
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
765
|
+
break;
|
|
766
|
+
hasVertical = nextHasVertical;
|
|
767
|
+
hasHorizontal = nextHasHorizontal;
|
|
768
|
+
}
|
|
769
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
770
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
771
|
+
return {
|
|
772
|
+
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
|
|
773
|
+
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
|
|
774
|
+
viewportWidth: effectiveWidth,
|
|
775
|
+
viewportHeight: effectiveHeight,
|
|
776
|
+
hasHorizontal,
|
|
777
|
+
hasVertical
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
_calculateCoverCanvasLayout(imageWidth, imageHeight) {
|
|
781
|
+
const viewport = this._getContainerViewportSize();
|
|
782
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
783
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
784
|
+
const targetWidth = Math.max(1, viewport.width - safetyMargin);
|
|
785
|
+
const targetHeight = Math.max(1, viewport.height - safetyMargin);
|
|
786
|
+
const scale2 = Math.min(1, Math.max(targetWidth / imageWidth, targetHeight / imageHeight));
|
|
787
|
+
const contentWidth2 = imageWidth * scale2;
|
|
788
|
+
const contentHeight2 = imageHeight * scale2;
|
|
789
|
+
const canvasSize2 = this._getScrollableCanvasSize(contentWidth2, contentHeight2, viewport);
|
|
790
|
+
return {
|
|
791
|
+
scale: scale2,
|
|
792
|
+
canvasWidth: canvasSize2.width,
|
|
793
|
+
canvasHeight: canvasSize2.height
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const scrollbar = this._getScrollbarSize();
|
|
797
|
+
let hasVertical = false;
|
|
798
|
+
let hasHorizontal = false;
|
|
799
|
+
let scale = 1;
|
|
800
|
+
let contentWidth = imageWidth;
|
|
801
|
+
let contentHeight = imageHeight;
|
|
802
|
+
let effectiveWidth = viewport.width;
|
|
803
|
+
let effectiveHeight = viewport.height;
|
|
804
|
+
for (let i = 0; i < 4; i += 1) {
|
|
805
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
806
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
807
|
+
scale = Math.min(1, Math.max(effectiveWidth / imageWidth, effectiveHeight / imageHeight));
|
|
808
|
+
contentWidth = imageWidth * scale;
|
|
809
|
+
contentHeight = imageHeight * scale;
|
|
810
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
811
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
812
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
813
|
+
break;
|
|
814
|
+
hasVertical = nextHasVertical;
|
|
815
|
+
hasHorizontal = nextHasHorizontal;
|
|
816
|
+
}
|
|
817
|
+
const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
|
|
818
|
+
return {
|
|
819
|
+
scale,
|
|
820
|
+
canvasWidth: canvasSize.width,
|
|
821
|
+
canvasHeight: canvasSize.height
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
_getStateProperties() {
|
|
825
|
+
return [
|
|
826
|
+
"maskId",
|
|
827
|
+
"maskName",
|
|
828
|
+
"maskLabel",
|
|
829
|
+
"isCropRect",
|
|
830
|
+
"originalAlpha",
|
|
831
|
+
"originalStroke",
|
|
832
|
+
"originalStrokeWidth",
|
|
833
|
+
"selectable",
|
|
834
|
+
"evented",
|
|
835
|
+
"hasControls",
|
|
836
|
+
"lockRotation",
|
|
837
|
+
"borderColor",
|
|
838
|
+
"cornerColor",
|
|
839
|
+
"cornerSize",
|
|
840
|
+
"transparentCorners",
|
|
841
|
+
"strokeUniform",
|
|
842
|
+
"strokeDashArray"
|
|
843
|
+
];
|
|
844
|
+
}
|
|
845
|
+
_getMaskNormalStyle(mask) {
|
|
846
|
+
const strokeWidth = Number(mask && mask.originalStrokeWidth);
|
|
847
|
+
const opacity = Number(mask && mask.originalAlpha);
|
|
848
|
+
const style = {
|
|
849
|
+
stroke: mask && mask.originalStroke || "#ccc",
|
|
850
|
+
strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
|
|
851
|
+
};
|
|
852
|
+
if (Number.isFinite(opacity))
|
|
853
|
+
style.opacity = opacity;
|
|
854
|
+
return style;
|
|
855
|
+
}
|
|
856
|
+
_withNormalizedMaskStyles(callback) {
|
|
857
|
+
if (!this.canvas)
|
|
858
|
+
return callback();
|
|
859
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
860
|
+
const maskStyleBackups = masks.map((mask) => ({
|
|
861
|
+
object: mask,
|
|
862
|
+
stroke: mask.stroke,
|
|
863
|
+
strokeWidth: mask.strokeWidth,
|
|
864
|
+
opacity: mask.opacity
|
|
865
|
+
}));
|
|
866
|
+
try {
|
|
867
|
+
masks.forEach((mask) => {
|
|
868
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
869
|
+
});
|
|
870
|
+
return callback();
|
|
871
|
+
} finally {
|
|
872
|
+
maskStyleBackups.forEach((backup) => {
|
|
873
|
+
try {
|
|
874
|
+
backup.object.set({
|
|
875
|
+
stroke: backup.stroke,
|
|
876
|
+
strokeWidth: backup.strokeWidth,
|
|
877
|
+
opacity: backup.opacity
|
|
878
|
+
});
|
|
879
|
+
} catch (error) {
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
_restoreMaskControls(mask) {
|
|
885
|
+
if (!mask)
|
|
886
|
+
return;
|
|
887
|
+
const cornerSize = Number(mask.cornerSize);
|
|
888
|
+
mask.set({
|
|
889
|
+
selectable: mask.selectable !== false,
|
|
890
|
+
evented: mask.evented !== false,
|
|
891
|
+
hasControls: mask.hasControls !== false,
|
|
892
|
+
lockRotation: typeof mask.lockRotation === "boolean" ? mask.lockRotation : !this.options.maskRotatable,
|
|
893
|
+
borderColor: mask.borderColor || "red",
|
|
894
|
+
cornerColor: mask.cornerColor || "black",
|
|
895
|
+
cornerSize: Number.isFinite(cornerSize) ? cornerSize : 8,
|
|
896
|
+
transparentCorners: mask.transparentCorners === true,
|
|
897
|
+
strokeUniform: mask.strokeUniform !== false
|
|
898
|
+
});
|
|
899
|
+
if (typeof mask.setCoords === "function")
|
|
900
|
+
mask.setCoords();
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Captures editor-owned runtime state that Fabric does not include in canvas JSON.
|
|
904
|
+
*
|
|
905
|
+
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
|
|
906
|
+
* @private
|
|
907
|
+
*/
|
|
908
|
+
_serializeEditorMetadata() {
|
|
909
|
+
const baseImageScale = Number(this.baseImageScale);
|
|
910
|
+
const currentScale = Number(this.currentScale);
|
|
911
|
+
const currentRotation = Number(this.currentRotation);
|
|
912
|
+
const maskCounter = Number(this.maskCounter);
|
|
913
|
+
return {
|
|
914
|
+
version: 1,
|
|
915
|
+
baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
|
|
916
|
+
currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
|
|
917
|
+
currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
|
|
918
|
+
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
_serializeCanvasState() {
|
|
922
|
+
if (!this.canvas)
|
|
923
|
+
return null;
|
|
924
|
+
return this._withNormalizedMaskStyles(() => {
|
|
925
|
+
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
926
|
+
if (Array.isArray(jsonObject.objects)) {
|
|
927
|
+
jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
|
|
928
|
+
}
|
|
929
|
+
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
930
|
+
return JSON.stringify(jsonObject);
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
|
|
935
|
+
*
|
|
936
|
+
* @param {number} quality - Requested image quality.
|
|
937
|
+
* @returns {number} A finite quality value between 0 and 1.
|
|
938
|
+
* @private
|
|
939
|
+
*/
|
|
940
|
+
_normalizeQuality(quality) {
|
|
941
|
+
const numericQuality = Number(quality);
|
|
942
|
+
if (!Number.isFinite(numericQuality))
|
|
943
|
+
return this.options.downsampleQuality ?? 0.92;
|
|
944
|
+
return Math.max(0, Math.min(1, numericQuality));
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Normalizes public image format aliases to canvas export format names.
|
|
948
|
+
*
|
|
949
|
+
* @param {string} format - Requested image format or MIME type.
|
|
950
|
+
* @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
|
|
951
|
+
* @private
|
|
952
|
+
*/
|
|
953
|
+
_normalizeImageFormat(format) {
|
|
954
|
+
const typeMapping = {
|
|
955
|
+
"jpeg": "jpeg",
|
|
956
|
+
"jpg": "jpeg",
|
|
957
|
+
"image/jpeg": "jpeg",
|
|
958
|
+
"png": "png",
|
|
959
|
+
"image/png": "png",
|
|
960
|
+
"webp": "webp",
|
|
961
|
+
"image/webp": "webp"
|
|
962
|
+
};
|
|
963
|
+
return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Converts a bounding rectangle into a canvas-safe integer source region.
|
|
967
|
+
*
|
|
968
|
+
* @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
|
|
969
|
+
* @param {Object} [options={}] - Region rounding options.
|
|
970
|
+
* @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
|
|
971
|
+
* @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
|
|
972
|
+
* @private
|
|
973
|
+
*/
|
|
974
|
+
_getClampedCanvasRegion(bounds, options = {}) {
|
|
975
|
+
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
976
|
+
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
977
|
+
const left = Number(bounds.left) || 0;
|
|
978
|
+
const top = Number(bounds.top) || 0;
|
|
979
|
+
const width = Math.max(0, Number(bounds.width) || 0);
|
|
980
|
+
const height = Math.max(0, Number(bounds.height) || 0);
|
|
981
|
+
const includePartialPixels = options.includePartialPixels !== false;
|
|
982
|
+
const roundEnd = includePartialPixels ? Math.ceil : Math.floor;
|
|
983
|
+
const sourceX = Math.min(canvasWidth - 1, Math.max(0, Math.floor(left)));
|
|
984
|
+
const sourceY = Math.min(canvasHeight - 1, Math.max(0, Math.floor(top)));
|
|
985
|
+
const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
|
|
986
|
+
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
987
|
+
return {
|
|
988
|
+
sourceX,
|
|
989
|
+
sourceY,
|
|
990
|
+
sourceWidth: Math.max(1, endX - sourceX),
|
|
991
|
+
sourceHeight: Math.max(1, endY - sourceY)
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Crops an image data URL to a source region using an offscreen canvas.
|
|
996
|
+
*
|
|
997
|
+
* @param {string} dataUrl - Source image data URL.
|
|
998
|
+
* @param {number} sourceX - Source region x coordinate.
|
|
999
|
+
* @param {number} sourceY - Source region y coordinate.
|
|
1000
|
+
* @param {number} sourceWidth - Source region width.
|
|
1001
|
+
* @param {number} sourceHeight - Source region height.
|
|
1002
|
+
* @param {number} multiplier - Export multiplier already applied to the source data URL.
|
|
1003
|
+
* @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
|
|
1004
|
+
* @param {number} [quality=0.92] - Output image quality for lossy formats.
|
|
1005
|
+
* @returns {Promise<string>} Resolves with the cropped image data URL.
|
|
1006
|
+
* @private
|
|
1007
|
+
*/
|
|
1008
|
+
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
|
|
1009
|
+
return new Promise((resolve, reject) => {
|
|
1010
|
+
const imageElement = new Image();
|
|
1011
|
+
let isSettled = false;
|
|
1012
|
+
const timeoutMs = Number(this.options.imageLoadTimeoutMs);
|
|
1013
|
+
const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3e4;
|
|
1014
|
+
let timerId;
|
|
1015
|
+
const settle = (callback) => {
|
|
1016
|
+
if (isSettled)
|
|
1017
|
+
return;
|
|
1018
|
+
isSettled = true;
|
|
1019
|
+
clearTimeout(timerId);
|
|
1020
|
+
imageElement.onload = null;
|
|
1021
|
+
imageElement.onerror = null;
|
|
1022
|
+
callback();
|
|
1023
|
+
};
|
|
1024
|
+
timerId = setTimeout(() => {
|
|
1025
|
+
settle(() => reject(new Error("Image crop load timed out")));
|
|
1026
|
+
try {
|
|
1027
|
+
imageElement.src = "";
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
}
|
|
1030
|
+
}, safeTimeoutMs);
|
|
1031
|
+
imageElement.onload = () => {
|
|
1032
|
+
try {
|
|
1033
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1034
|
+
const scaledSourceX = Math.round(sourceX * safeMultiplier);
|
|
1035
|
+
const scaledSourceY = Math.round(sourceY * safeMultiplier);
|
|
1036
|
+
const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
|
|
1037
|
+
const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
|
|
1038
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
1039
|
+
offscreenCanvas.width = scaledSourceWidth;
|
|
1040
|
+
offscreenCanvas.height = scaledSourceHeight;
|
|
1041
|
+
const context = offscreenCanvas.getContext("2d");
|
|
1042
|
+
if (!context)
|
|
1043
|
+
throw new Error("2D canvas context is unavailable");
|
|
1044
|
+
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
1045
|
+
settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
settle(() => reject(error));
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
1051
|
+
imageElement.src = dataUrl;
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Exports the whole Fabric canvas, then crops the requested source region from that export.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {Object} region - Canvas source region and export options.
|
|
1058
|
+
* @param {number} region.sourceX - Source region x coordinate.
|
|
1059
|
+
* @param {number} region.sourceY - Source region y coordinate.
|
|
1060
|
+
* @param {number} region.sourceWidth - Source region width.
|
|
1061
|
+
* @param {number} region.sourceHeight - Source region height.
|
|
1062
|
+
* @param {number} [region.multiplier=1] - Export multiplier.
|
|
1063
|
+
* @param {number} [region.quality=0.92] - Output image quality for lossy formats.
|
|
1064
|
+
* @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
|
|
1065
|
+
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1066
|
+
* @private
|
|
1067
|
+
*/
|
|
1068
|
+
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = "jpeg" }) {
|
|
1069
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1070
|
+
const fullDataUrl = this.canvas.toDataURL({
|
|
1071
|
+
format,
|
|
1072
|
+
quality,
|
|
1073
|
+
multiplier: safeMultiplier
|
|
1074
|
+
});
|
|
1075
|
+
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
532
1076
|
}
|
|
533
1077
|
/**
|
|
534
1078
|
* Gets the top-left corner coordinates of the given object.
|
|
535
1079
|
* Used for geometry calculations (e.g., scale, rotate).
|
|
536
1080
|
*
|
|
537
|
-
* @param {Object}
|
|
1081
|
+
* @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
|
|
538
1082
|
* @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
|
|
539
1083
|
* @private
|
|
540
1084
|
*/
|
|
541
|
-
_getObjectTopLeftPoint(
|
|
542
|
-
if (!
|
|
1085
|
+
_getObjectTopLeftPoint(fabricObject) {
|
|
1086
|
+
if (!fabricObject)
|
|
543
1087
|
return { x: 0, y: 0 };
|
|
544
|
-
|
|
545
|
-
const coords = typeof
|
|
1088
|
+
fabricObject.setCoords();
|
|
1089
|
+
const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
|
|
546
1090
|
if (coords && coords.length)
|
|
547
1091
|
return coords[0];
|
|
548
|
-
const
|
|
549
|
-
return { x:
|
|
1092
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1093
|
+
return { x: boundingRect.left, y: boundingRect.top };
|
|
550
1094
|
}
|
|
551
1095
|
/**
|
|
552
1096
|
* Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
|
|
553
1097
|
*
|
|
554
|
-
* @param {Object}
|
|
1098
|
+
* @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
|
|
555
1099
|
* @param {string} originX - The new originX ("left", "center", "right", etc.).
|
|
556
1100
|
* @param {string} originY - The new originY ("top", "center", "bottom", etc.).
|
|
557
1101
|
* @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
|
|
558
1102
|
* @private
|
|
559
1103
|
*/
|
|
560
|
-
_setObjectOriginKeepingPosition(
|
|
561
|
-
if (!
|
|
1104
|
+
_setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
|
|
1105
|
+
if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
|
|
562
1106
|
return;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1107
|
+
fabricObject.set({ originX, originY });
|
|
1108
|
+
fabricObject.setPositionByOrigin(refPoint, originX, originY);
|
|
1109
|
+
fabricObject.setCoords();
|
|
566
1110
|
}
|
|
567
1111
|
/**
|
|
568
1112
|
* Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
|
|
569
1113
|
*
|
|
570
|
-
* @param {Object}
|
|
1114
|
+
* @param {Object} fabricObject - The object to align.
|
|
571
1115
|
* @private
|
|
572
1116
|
*/
|
|
573
|
-
_alignObjectBoundingBoxToCanvasTopLeft(
|
|
574
|
-
if (!
|
|
1117
|
+
_alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
|
|
1118
|
+
if (!fabricObject)
|
|
575
1119
|
return;
|
|
576
|
-
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
1120
|
+
fabricObject.setCoords();
|
|
1121
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1122
|
+
const deltaX = boundingRect.left;
|
|
1123
|
+
const deltaY = boundingRect.top;
|
|
1124
|
+
fabricObject.set({ left: (fabricObject.left || 0) - deltaX, top: (fabricObject.top || 0) - deltaY });
|
|
1125
|
+
fabricObject.setCoords();
|
|
582
1126
|
this.canvas.renderAll();
|
|
583
1127
|
}
|
|
584
1128
|
/**
|
|
@@ -590,16 +1134,63 @@ var ImageEditor = class {
|
|
|
590
1134
|
if (!this.originalImage)
|
|
591
1135
|
return;
|
|
592
1136
|
this.originalImage.setCoords();
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1137
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1138
|
+
const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
|
|
1139
|
+
this._setCanvasSizeInt(size.width, size.height);
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Whether post-load edits should resize the canvas to keep transformed content visible.
|
|
1143
|
+
*
|
|
1144
|
+
* @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
|
|
1145
|
+
* @private
|
|
1146
|
+
*/
|
|
1147
|
+
_shouldResizeCanvasToContentBounds() {
|
|
1148
|
+
return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Expands the canvas once so all provided objects remain visible after an edit.
|
|
1152
|
+
*
|
|
1153
|
+
* @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
|
|
1154
|
+
* @param {number} [padding=10] - Extra canvas space after the farthest object edge.
|
|
1155
|
+
* @returns {void}
|
|
1156
|
+
* @private
|
|
1157
|
+
*/
|
|
1158
|
+
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1159
|
+
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds())
|
|
598
1160
|
return;
|
|
1161
|
+
try {
|
|
1162
|
+
let requiredWidth = this.canvas.getWidth();
|
|
1163
|
+
let requiredHeight = this.canvas.getHeight();
|
|
1164
|
+
fabricObjects.forEach((fabricObject) => {
|
|
1165
|
+
if (!fabricObject)
|
|
1166
|
+
return;
|
|
1167
|
+
if (typeof fabricObject.setCoords === "function")
|
|
1168
|
+
fabricObject.setCoords();
|
|
1169
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1170
|
+
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1171
|
+
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1172
|
+
});
|
|
1173
|
+
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
1174
|
+
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
1175
|
+
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
1176
|
+
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
1177
|
+
if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
|
|
1178
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1179
|
+
}
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
this._reportWarning("expandCanvasToFitObjects: failed to expand canvas", error);
|
|
599
1182
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Expands the canvas so one object remains visible after an edit.
|
|
1186
|
+
*
|
|
1187
|
+
* @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
|
|
1188
|
+
* @param {number} [padding=10] - Extra canvas space after the object edge.
|
|
1189
|
+
* @returns {void}
|
|
1190
|
+
* @private
|
|
1191
|
+
*/
|
|
1192
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
1193
|
+
this._expandCanvasToFitObjects([fabricObject], padding);
|
|
603
1194
|
}
|
|
604
1195
|
/**
|
|
605
1196
|
* Scales the original image by a given factor, with animation.
|
|
@@ -608,8 +1199,8 @@ var ImageEditor = class {
|
|
|
608
1199
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
609
1200
|
* @public
|
|
610
1201
|
*/
|
|
611
|
-
scaleImage(factor) {
|
|
612
|
-
return this.
|
|
1202
|
+
scaleImage(factor, options = {}) {
|
|
1203
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
613
1204
|
}
|
|
614
1205
|
/**
|
|
615
1206
|
* Scales the original image by a given factor, with animation.
|
|
@@ -618,46 +1209,49 @@ var ImageEditor = class {
|
|
|
618
1209
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
619
1210
|
* @private
|
|
620
1211
|
*/
|
|
621
|
-
_scaleImageImpl(factor) {
|
|
1212
|
+
_scaleImageImpl(factor, options = {}) {
|
|
622
1213
|
if (!this.originalImage)
|
|
623
1214
|
return Promise.resolve();
|
|
624
1215
|
if (this.isAnimating)
|
|
625
1216
|
return Promise.resolve();
|
|
1217
|
+
const saveHistory = options.saveHistory !== false;
|
|
626
1218
|
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
627
1219
|
this.currentScale = factor;
|
|
628
1220
|
this.isAnimating = true;
|
|
629
1221
|
this._updateUI();
|
|
630
|
-
const
|
|
1222
|
+
const targetScale = this.baseImageScale * factor;
|
|
631
1223
|
const topLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
632
1224
|
this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
|
|
633
|
-
const
|
|
634
|
-
this.originalImage.animate("scaleX",
|
|
1225
|
+
const scaleXAnimation = new Promise((resolve) => {
|
|
1226
|
+
this.originalImage.animate("scaleX", targetScale, {
|
|
635
1227
|
duration: this.options.animationDuration,
|
|
636
1228
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
637
|
-
onComplete:
|
|
1229
|
+
onComplete: resolve
|
|
638
1230
|
});
|
|
639
1231
|
});
|
|
640
|
-
const
|
|
641
|
-
this.originalImage.animate("scaleY",
|
|
1232
|
+
const scaleYAnimation = new Promise((resolve) => {
|
|
1233
|
+
this.originalImage.animate("scaleY", targetScale, {
|
|
642
1234
|
duration: this.options.animationDuration,
|
|
643
1235
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
644
|
-
onComplete:
|
|
1236
|
+
onComplete: resolve
|
|
645
1237
|
});
|
|
646
1238
|
});
|
|
647
|
-
return Promise.all([
|
|
648
|
-
this.originalImage.set({ scaleX:
|
|
1239
|
+
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1240
|
+
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
649
1241
|
this.originalImage.setCoords();
|
|
650
|
-
if (this.
|
|
1242
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
651
1243
|
this._updateCanvasSizeToImageBounds();
|
|
1244
|
+
}
|
|
652
1245
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
653
|
-
this.canvas.getObjects().forEach((
|
|
654
|
-
if (
|
|
655
|
-
this._syncMaskLabel(
|
|
1246
|
+
this.canvas.getObjects().forEach((object) => {
|
|
1247
|
+
if (object.maskId)
|
|
1248
|
+
this._syncMaskLabel(object);
|
|
656
1249
|
});
|
|
657
1250
|
this.isAnimating = false;
|
|
658
1251
|
this._updateInputs();
|
|
659
1252
|
this._updateUI();
|
|
660
|
-
|
|
1253
|
+
if (saveHistory)
|
|
1254
|
+
this.saveState();
|
|
661
1255
|
}).catch(() => {
|
|
662
1256
|
this.isAnimating = false;
|
|
663
1257
|
this._updateUI();
|
|
@@ -670,8 +1264,8 @@ var ImageEditor = class {
|
|
|
670
1264
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
671
1265
|
* @public
|
|
672
1266
|
*/
|
|
673
|
-
rotateImage(
|
|
674
|
-
return this.
|
|
1267
|
+
rotateImage(degrees, options = {}) {
|
|
1268
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
675
1269
|
}
|
|
676
1270
|
/**
|
|
677
1271
|
* Rotates the original image by a given number of degrees, with animation.
|
|
@@ -680,171 +1274,322 @@ var ImageEditor = class {
|
|
|
680
1274
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
681
1275
|
* @private
|
|
682
1276
|
*/
|
|
683
|
-
_rotateImageImpl(degrees) {
|
|
1277
|
+
_rotateImageImpl(degrees, options = {}) {
|
|
684
1278
|
if (!this.originalImage)
|
|
685
1279
|
return Promise.resolve();
|
|
686
1280
|
if (this.isAnimating)
|
|
687
1281
|
return Promise.resolve();
|
|
688
1282
|
if (isNaN(degrees))
|
|
689
1283
|
return Promise.resolve();
|
|
1284
|
+
const saveHistory = options.saveHistory !== false;
|
|
690
1285
|
this.currentRotation = degrees;
|
|
691
1286
|
this.isAnimating = true;
|
|
692
1287
|
this._updateUI();
|
|
693
1288
|
const center = this.originalImage.getCenterPoint();
|
|
694
1289
|
this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
|
|
695
|
-
const
|
|
1290
|
+
const rotationAnimation = new Promise((resolve) => {
|
|
696
1291
|
this.originalImage.animate("angle", degrees, {
|
|
697
1292
|
duration: this.options.animationDuration,
|
|
698
1293
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
699
|
-
onComplete:
|
|
1294
|
+
onComplete: resolve
|
|
700
1295
|
});
|
|
701
1296
|
});
|
|
702
|
-
return
|
|
1297
|
+
return rotationAnimation.then(() => {
|
|
703
1298
|
this.originalImage.set("angle", degrees);
|
|
704
1299
|
this.originalImage.setCoords();
|
|
705
|
-
if (this.
|
|
1300
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
706
1301
|
this._updateCanvasSizeToImageBounds();
|
|
1302
|
+
}
|
|
707
1303
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
708
1304
|
const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
709
1305
|
this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
|
|
710
|
-
this.canvas.getObjects().forEach((
|
|
711
|
-
if (
|
|
712
|
-
this._syncMaskLabel(
|
|
1306
|
+
this.canvas.getObjects().forEach((object) => {
|
|
1307
|
+
if (object.maskId)
|
|
1308
|
+
this._syncMaskLabel(object);
|
|
713
1309
|
});
|
|
714
1310
|
this.isAnimating = false;
|
|
715
1311
|
this._updateInputs();
|
|
716
1312
|
this._updateUI();
|
|
717
|
-
|
|
1313
|
+
if (saveHistory)
|
|
1314
|
+
this.saveState();
|
|
718
1315
|
}).catch(() => {
|
|
719
1316
|
this.isAnimating = false;
|
|
720
1317
|
this._updateUI();
|
|
721
1318
|
});
|
|
722
1319
|
}
|
|
723
1320
|
/**
|
|
724
|
-
* Resets the image: scales to 1 and rotates to 0 degrees.
|
|
725
|
-
*
|
|
1321
|
+
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
1322
|
+
*
|
|
1323
|
+
* @returns {Promise<void>} Resolves when the reset history transition has been recorded.
|
|
1324
|
+
* @public
|
|
726
1325
|
*/
|
|
727
|
-
|
|
1326
|
+
resetImageTransform() {
|
|
728
1327
|
if (!this.originalImage)
|
|
729
1328
|
return Promise.resolve();
|
|
730
|
-
return this.
|
|
731
|
-
this.
|
|
732
|
-
|
|
733
|
-
this.
|
|
1329
|
+
return this.animationQueue.add(async () => {
|
|
1330
|
+
const before = this._lastSnapshot || this._serializeCanvasState();
|
|
1331
|
+
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1332
|
+
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1333
|
+
const after = this._serializeCanvasState();
|
|
1334
|
+
this._pushStateTransition(before, after);
|
|
1335
|
+
}).catch((error) => {
|
|
1336
|
+
this._reportError("resetImageTransform() failed", error);
|
|
734
1337
|
});
|
|
735
1338
|
}
|
|
736
1339
|
/**
|
|
737
|
-
*
|
|
738
|
-
*
|
|
1340
|
+
* Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
|
|
1341
|
+
*
|
|
1342
|
+
* @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
|
|
1343
|
+
* @returns {Promise<void>} Resolves when the image transform reset is complete.
|
|
739
1344
|
*/
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
this.
|
|
760
|
-
|
|
1345
|
+
reset() {
|
|
1346
|
+
return this.resetImageTransform();
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
|
|
1350
|
+
*
|
|
1351
|
+
* @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
|
|
1352
|
+
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
1353
|
+
* @public
|
|
1354
|
+
*/
|
|
1355
|
+
loadFromState(serializedState) {
|
|
1356
|
+
if (!serializedState || !this.canvas)
|
|
1357
|
+
return Promise.resolve();
|
|
1358
|
+
return new Promise((resolve) => {
|
|
1359
|
+
try {
|
|
1360
|
+
const state = typeof serializedState === "string" ? JSON.parse(serializedState) : serializedState;
|
|
1361
|
+
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1362
|
+
this.canvas.loadFromJSON(state, () => {
|
|
1363
|
+
try {
|
|
1364
|
+
this._hideAllMaskLabels();
|
|
1365
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1366
|
+
this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
|
|
1367
|
+
if (this.originalImage) {
|
|
1368
|
+
this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
|
|
1369
|
+
this.canvas.sendToBack(this.originalImage);
|
|
1370
|
+
const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
|
|
1371
|
+
const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
|
|
1372
|
+
const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
|
|
1373
|
+
if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
|
|
1374
|
+
this.baseImageScale = restoredBaseScale;
|
|
1375
|
+
}
|
|
1376
|
+
if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
|
|
1377
|
+
this.currentScale = restoredCurrentScale;
|
|
1378
|
+
} else {
|
|
1379
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1380
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1381
|
+
this.currentScale = imageScale / baseScale;
|
|
1382
|
+
}
|
|
1383
|
+
this.currentRotation = Number.isFinite(restoredCurrentRotation) ? restoredCurrentRotation : Number(this.originalImage.angle) || 0;
|
|
1384
|
+
} else {
|
|
1385
|
+
this.baseImageScale = 1;
|
|
1386
|
+
this.currentScale = 1;
|
|
1387
|
+
this.currentRotation = 0;
|
|
1388
|
+
}
|
|
1389
|
+
const masks = canvasObjects.filter((object) => object.maskId);
|
|
1390
|
+
masks.forEach((mask) => {
|
|
1391
|
+
this._restoreMaskControls(mask);
|
|
1392
|
+
this._rebindMaskEvents(mask);
|
|
1393
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
1394
|
+
});
|
|
1395
|
+
const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
|
|
1396
|
+
const maxMaskId = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
|
|
1397
|
+
this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId ? Math.floor(restoredMaskCounter) : maxMaskId;
|
|
1398
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1399
|
+
if (!this._lastMask) {
|
|
1400
|
+
this._lastMaskInitialLeft = null;
|
|
1401
|
+
this._lastMaskInitialTop = null;
|
|
1402
|
+
this._lastMaskInitialWidth = null;
|
|
1403
|
+
}
|
|
1404
|
+
this.isImageLoadedToCanvas = !!this.originalImage;
|
|
1405
|
+
this.canvas.renderAll();
|
|
1406
|
+
this._updateInputs();
|
|
1407
|
+
this._updateMaskList();
|
|
1408
|
+
this._updatePlaceholderStatus();
|
|
1409
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
1410
|
+
this._updateUI();
|
|
1411
|
+
} catch (callbackError) {
|
|
1412
|
+
this._reportError("loadFromState() failed", callbackError);
|
|
1413
|
+
} finally {
|
|
1414
|
+
resolve();
|
|
761
1415
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
this._reportError("loadFromState() failed", callbackError);
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
} catch (e) {
|
|
772
|
-
this._reportError("loadFromState() failed", e);
|
|
773
|
-
}
|
|
1416
|
+
});
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
this._reportError("loadFromState() failed", error);
|
|
1419
|
+
resolve();
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
774
1422
|
}
|
|
775
1423
|
/**
|
|
776
|
-
* Saves the current
|
|
1424
|
+
* Saves the current editable canvas state as an undoable history transition.
|
|
1425
|
+
*
|
|
1426
|
+
* Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
|
|
1427
|
+
* mask objects and restored by `loadFromState()`.
|
|
1428
|
+
*
|
|
1429
|
+
* @returns {void}
|
|
1430
|
+
* @public
|
|
777
1431
|
*/
|
|
778
1432
|
saveState() {
|
|
779
1433
|
if (!this.canvas)
|
|
780
1434
|
return;
|
|
781
|
-
const
|
|
782
|
-
this._hideAllMaskLabels();
|
|
1435
|
+
const activeObject = this.canvas.getActiveObject();
|
|
783
1436
|
try {
|
|
784
|
-
const
|
|
785
|
-
if (Array.isArray(jsonObj.objects)) {
|
|
786
|
-
jsonObj.objects = jsonObj.objects.filter((o) => !o.isCropRect);
|
|
787
|
-
}
|
|
788
|
-
const after = JSON.stringify(jsonObj);
|
|
1437
|
+
const after = this._serializeCanvasState();
|
|
789
1438
|
const before = this._lastSnapshot || after;
|
|
1439
|
+
if (after === before)
|
|
1440
|
+
return;
|
|
790
1441
|
let executedOnce = false;
|
|
791
|
-
const
|
|
1442
|
+
const command = new Command(
|
|
792
1443
|
() => {
|
|
793
1444
|
if (executedOnce) {
|
|
794
|
-
this.loadFromState(after);
|
|
1445
|
+
return this.loadFromState(after);
|
|
795
1446
|
}
|
|
796
1447
|
executedOnce = true;
|
|
1448
|
+
return void 0;
|
|
797
1449
|
},
|
|
798
|
-
() =>
|
|
799
|
-
this.loadFromState(before);
|
|
800
|
-
}
|
|
1450
|
+
() => this.loadFromState(before)
|
|
801
1451
|
);
|
|
802
|
-
this.historyManager.execute(
|
|
1452
|
+
this.historyManager.execute(command);
|
|
803
1453
|
this._lastSnapshot = after;
|
|
804
|
-
|
|
805
|
-
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
this._reportWarning("saveState: failed to save canvas snapshot", error);
|
|
1456
|
+
} finally {
|
|
1457
|
+
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1458
|
+
this._handleSelectionChanged([activeObject]);
|
|
806
1459
|
}
|
|
807
1460
|
this._updateUI();
|
|
808
|
-
} catch (err) {
|
|
809
|
-
this._reportWarning("saveState: failed to save canvas snapshot", err);
|
|
810
1461
|
}
|
|
811
1462
|
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Pushes a precomputed before/after state transition into history.
|
|
1465
|
+
*
|
|
1466
|
+
* Use this for operations such as crop and merge that build their snapshots around asynchronous image
|
|
1467
|
+
* loading, where the "after" state is already applied before the history command is recorded.
|
|
1468
|
+
*
|
|
1469
|
+
* @param {string} before - Serialized state before the operation.
|
|
1470
|
+
* @param {string} after - Serialized state after the operation.
|
|
1471
|
+
* @returns {void}
|
|
1472
|
+
* @private
|
|
1473
|
+
*/
|
|
1474
|
+
_pushStateTransition(before, after) {
|
|
1475
|
+
if (!before || !after)
|
|
1476
|
+
return;
|
|
1477
|
+
if (before === after)
|
|
1478
|
+
return;
|
|
1479
|
+
if (!this.historyManager)
|
|
1480
|
+
this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1481
|
+
const command = new Command(
|
|
1482
|
+
() => this.loadFromState(after),
|
|
1483
|
+
() => this.loadFromState(before)
|
|
1484
|
+
);
|
|
1485
|
+
this.historyManager.push(command);
|
|
1486
|
+
this._lastSnapshot = after;
|
|
1487
|
+
this._updateUI();
|
|
1488
|
+
}
|
|
812
1489
|
/**
|
|
813
1490
|
* Undo the last state change, if possible.
|
|
1491
|
+
*
|
|
1492
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
|
|
1493
|
+
* @public
|
|
814
1494
|
*/
|
|
815
1495
|
undo() {
|
|
816
|
-
this.historyManager.undo()
|
|
1496
|
+
return this.historyManager.undo().then(() => {
|
|
1497
|
+
this._updateUI();
|
|
1498
|
+
}).catch((error) => {
|
|
1499
|
+
this._reportError("undo failed", error);
|
|
1500
|
+
});
|
|
817
1501
|
}
|
|
818
1502
|
/**
|
|
819
1503
|
* Redo the next state change, if possible.
|
|
1504
|
+
*
|
|
1505
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
|
|
1506
|
+
* @public
|
|
820
1507
|
*/
|
|
821
1508
|
redo() {
|
|
822
|
-
this.historyManager.redo()
|
|
1509
|
+
return this.historyManager.redo().then(() => {
|
|
1510
|
+
this._updateUI();
|
|
1511
|
+
}).catch((error) => {
|
|
1512
|
+
this._reportError("redo failed", error);
|
|
1513
|
+
});
|
|
823
1514
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1515
|
+
_rebindMaskEvents(mask) {
|
|
1516
|
+
if (!mask)
|
|
1517
|
+
return;
|
|
1518
|
+
if (mask.__imageEditorMaskHandlers) {
|
|
1519
|
+
try {
|
|
1520
|
+
mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
|
|
1521
|
+
mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const metadata = {};
|
|
1526
|
+
if (!Number.isFinite(Number(mask.originalAlpha))) {
|
|
1527
|
+
metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
|
|
1528
|
+
}
|
|
1529
|
+
if (!mask.originalStroke)
|
|
1530
|
+
metadata.originalStroke = mask.stroke || "#ccc";
|
|
1531
|
+
if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
|
|
1532
|
+
metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
|
|
1533
|
+
}
|
|
1534
|
+
if (Object.keys(metadata).length)
|
|
1535
|
+
mask.set(metadata);
|
|
1536
|
+
const normalStyle = {
|
|
1537
|
+
stroke: mask.originalStroke || "#ccc",
|
|
1538
|
+
strokeWidth: mask.originalStrokeWidth,
|
|
1539
|
+
opacity: mask.originalAlpha
|
|
1540
|
+
};
|
|
1541
|
+
const hoverStyle = {
|
|
1542
|
+
stroke: "#ff5500",
|
|
1543
|
+
strokeWidth: 2,
|
|
1544
|
+
opacity: Math.min(mask.originalAlpha + 0.2, 1)
|
|
1545
|
+
};
|
|
1546
|
+
const mouseover = () => {
|
|
1547
|
+
mask.set(hoverStyle);
|
|
1548
|
+
if (mask.canvas)
|
|
1549
|
+
mask.canvas.requestRenderAll();
|
|
1550
|
+
};
|
|
1551
|
+
const mouseout = () => {
|
|
1552
|
+
mask.set(normalStyle);
|
|
1553
|
+
if (mask.canvas)
|
|
1554
|
+
mask.canvas.requestRenderAll();
|
|
1555
|
+
};
|
|
1556
|
+
mask.on("mouseover", mouseover);
|
|
1557
|
+
mask.on("mouseout", mouseout);
|
|
1558
|
+
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Creates a mask and adds it to the canvas.
|
|
1562
|
+
*
|
|
1563
|
+
* Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
|
|
1564
|
+
* after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
|
|
1565
|
+
* so controls and hit testing stay in sync with Fabric 5.x behavior.
|
|
1566
|
+
*
|
|
1567
|
+
* @param {Object} [config={}] - Optional mask configuration overrides.
|
|
1568
|
+
* @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
|
|
1569
|
+
* @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
|
|
1570
|
+
* @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
|
|
1571
|
+
* @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
|
|
1572
|
+
* @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
|
|
1573
|
+
* @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
|
|
1574
|
+
* @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
|
|
1575
|
+
* @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
|
|
1576
|
+
* @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
|
|
1577
|
+
* @param {number} [config.angle=0] - Rotation angle in degrees.
|
|
1578
|
+
* @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
|
|
1579
|
+
* @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
|
|
1580
|
+
* @param {boolean} [config.selectable=true] - Whether the mask can be selected.
|
|
1581
|
+
* @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
|
|
1582
|
+
* @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
|
|
1583
|
+
* @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
|
|
1584
|
+
* @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
|
|
1585
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
841
1586
|
* @public
|
|
842
1587
|
*/
|
|
843
|
-
|
|
1588
|
+
createMask(config = {}) {
|
|
844
1589
|
if (!this.canvas)
|
|
845
1590
|
return null;
|
|
846
1591
|
const shapeType = config.shape || "rect";
|
|
847
|
-
const
|
|
1592
|
+
const maskConfig = {
|
|
848
1593
|
shape: shapeType,
|
|
849
1594
|
width: this.options.defaultMaskWidth,
|
|
850
1595
|
height: this.options.defaultMaskHeight,
|
|
@@ -860,80 +1605,73 @@ var ImageEditor = class {
|
|
|
860
1605
|
const firstOffset = 10;
|
|
861
1606
|
let left = firstOffset;
|
|
862
1607
|
let top = firstOffset;
|
|
863
|
-
const resolveValue = (
|
|
864
|
-
if (typeof
|
|
865
|
-
return
|
|
866
|
-
if (typeof
|
|
867
|
-
const percent = parseFloat(
|
|
1608
|
+
const resolveValue = (value, fallback) => {
|
|
1609
|
+
if (typeof value === "function")
|
|
1610
|
+
return value(this.canvas, this.options);
|
|
1611
|
+
if (typeof value === "string" && value.endsWith("%")) {
|
|
1612
|
+
const percent = parseFloat(value) / 100;
|
|
868
1613
|
return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
|
|
869
1614
|
}
|
|
870
|
-
return
|
|
1615
|
+
return value != null ? value : fallback;
|
|
871
1616
|
};
|
|
872
|
-
if (
|
|
873
|
-
const
|
|
874
|
-
let
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
} else if (
|
|
878
|
-
|
|
1617
|
+
if (maskConfig.left === void 0 && this._lastMask) {
|
|
1618
|
+
const previousMask = this._lastMask;
|
|
1619
|
+
let previousMaskRight = previousMask.left;
|
|
1620
|
+
if (previousMask.getScaledWidth) {
|
|
1621
|
+
previousMaskRight += previousMask.getScaledWidth();
|
|
1622
|
+
} else if (previousMask.width) {
|
|
1623
|
+
previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
|
|
879
1624
|
}
|
|
880
|
-
left = Math.round(
|
|
881
|
-
top =
|
|
1625
|
+
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1626
|
+
top = previousMask.top ?? firstOffset;
|
|
882
1627
|
} else {
|
|
883
|
-
left = resolveValue(
|
|
884
|
-
top = resolveValue(
|
|
885
|
-
}
|
|
886
|
-
cfg.width = resolveValue(cfg.width, this.options.defaultMaskWidth);
|
|
887
|
-
cfg.height = resolveValue(cfg.height, this.options.defaultMaskHeight);
|
|
888
|
-
if (this.options.expandCanvasToImage && shapeType === "rect") {
|
|
889
|
-
const requiredW = Math.ceil(left + cfg.width + 10);
|
|
890
|
-
const requiredH = Math.ceil(top + cfg.height + 10);
|
|
891
|
-
const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
|
|
892
|
-
const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
|
|
893
|
-
const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
|
|
894
|
-
const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
|
|
895
|
-
this._setCanvasSizeInt(newW, newH);
|
|
1628
|
+
left = resolveValue(maskConfig.left, firstOffset);
|
|
1629
|
+
top = resolveValue(maskConfig.top, firstOffset);
|
|
896
1630
|
}
|
|
1631
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1632
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1633
|
+
maskConfig.left = left;
|
|
1634
|
+
maskConfig.top = top;
|
|
897
1635
|
let mask;
|
|
898
|
-
if (typeof
|
|
899
|
-
mask =
|
|
1636
|
+
if (typeof maskConfig.fabricGenerator === "function") {
|
|
1637
|
+
mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
|
|
900
1638
|
} else {
|
|
901
1639
|
switch (shapeType) {
|
|
902
1640
|
case "circle":
|
|
903
1641
|
mask = new fabric.Circle({
|
|
904
1642
|
left,
|
|
905
1643
|
top,
|
|
906
|
-
radius: resolveValue(
|
|
907
|
-
fill:
|
|
908
|
-
opacity:
|
|
909
|
-
angle:
|
|
910
|
-
...
|
|
1644
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
1645
|
+
fill: maskConfig.color,
|
|
1646
|
+
opacity: maskConfig.alpha,
|
|
1647
|
+
angle: maskConfig.angle,
|
|
1648
|
+
...maskConfig.styles
|
|
911
1649
|
});
|
|
912
1650
|
break;
|
|
913
1651
|
case "ellipse":
|
|
914
1652
|
mask = new fabric.Ellipse({
|
|
915
1653
|
left,
|
|
916
1654
|
top,
|
|
917
|
-
rx: resolveValue(
|
|
918
|
-
ry: resolveValue(
|
|
919
|
-
fill:
|
|
920
|
-
opacity:
|
|
921
|
-
angle:
|
|
922
|
-
...
|
|
1655
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1656
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
1657
|
+
fill: maskConfig.color,
|
|
1658
|
+
opacity: maskConfig.alpha,
|
|
1659
|
+
angle: maskConfig.angle,
|
|
1660
|
+
...maskConfig.styles
|
|
923
1661
|
});
|
|
924
1662
|
break;
|
|
925
1663
|
case "polygon": {
|
|
926
|
-
let
|
|
927
|
-
if (Array.isArray(
|
|
928
|
-
|
|
1664
|
+
let polygonPoints = maskConfig.points || [];
|
|
1665
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length) {
|
|
1666
|
+
polygonPoints = polygonPoints.map((point) => Array.isArray(point) ? { x: Number(point[0]), y: Number(point[1]) } : { x: Number(point.x), y: Number(point.y) });
|
|
929
1667
|
}
|
|
930
|
-
mask = new fabric.Polygon(
|
|
1668
|
+
mask = new fabric.Polygon(polygonPoints, {
|
|
931
1669
|
left,
|
|
932
1670
|
top,
|
|
933
|
-
fill:
|
|
934
|
-
opacity:
|
|
935
|
-
angle:
|
|
936
|
-
...
|
|
1671
|
+
fill: maskConfig.color,
|
|
1672
|
+
opacity: maskConfig.alpha,
|
|
1673
|
+
angle: maskConfig.angle,
|
|
1674
|
+
...maskConfig.styles
|
|
937
1675
|
});
|
|
938
1676
|
break;
|
|
939
1677
|
}
|
|
@@ -942,80 +1680,96 @@ var ImageEditor = class {
|
|
|
942
1680
|
mask = new fabric.Rect({
|
|
943
1681
|
left,
|
|
944
1682
|
top,
|
|
945
|
-
width: resolveValue(
|
|
946
|
-
height: resolveValue(
|
|
947
|
-
fill:
|
|
948
|
-
opacity:
|
|
949
|
-
angle:
|
|
950
|
-
rx:
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
...cfg.styles
|
|
1683
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1684
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
1685
|
+
fill: maskConfig.color,
|
|
1686
|
+
opacity: maskConfig.alpha,
|
|
1687
|
+
angle: maskConfig.angle,
|
|
1688
|
+
rx: maskConfig.rx,
|
|
1689
|
+
ry: maskConfig.ry,
|
|
1690
|
+
...maskConfig.styles
|
|
954
1691
|
});
|
|
955
1692
|
}
|
|
956
1693
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
mask.
|
|
977
|
-
mask.
|
|
978
|
-
mask.
|
|
1694
|
+
const styles = maskConfig.styles || {};
|
|
1695
|
+
const hasStyle = (property) => Object.prototype.hasOwnProperty.call(styles, property);
|
|
1696
|
+
const maskSettings = {
|
|
1697
|
+
selectable: maskConfig.selectable !== false,
|
|
1698
|
+
hasControls: "hasControls" in maskConfig ? maskConfig.hasControls : true,
|
|
1699
|
+
lockRotation: !this.options.maskRotatable,
|
|
1700
|
+
borderColor: "borderColor" in maskConfig ? maskConfig.borderColor : "red",
|
|
1701
|
+
cornerColor: "cornerColor" in maskConfig ? maskConfig.cornerColor : "black",
|
|
1702
|
+
cornerSize: "cornerSize" in maskConfig ? maskConfig.cornerSize : 8,
|
|
1703
|
+
transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
|
|
1704
|
+
stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
|
|
1705
|
+
strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
|
|
1706
|
+
opacity: hasStyle("opacity") ? styles.opacity : maskConfig.alpha,
|
|
1707
|
+
strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
|
|
1708
|
+
};
|
|
1709
|
+
if (hasStyle("strokeDashArray"))
|
|
1710
|
+
maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
1711
|
+
mask.set(maskSettings);
|
|
1712
|
+
mask.setCoords();
|
|
1713
|
+
mask.set({
|
|
1714
|
+
originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
|
|
1715
|
+
originalStroke: mask.stroke || "#ccc",
|
|
1716
|
+
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
979
1717
|
});
|
|
1718
|
+
this._rebindMaskEvents(mask);
|
|
1719
|
+
this._expandCanvasToFitObject(mask);
|
|
980
1720
|
this._lastMaskInitialLeft = left;
|
|
981
1721
|
this._lastMaskInitialTop = top;
|
|
982
|
-
this._lastMaskInitialWidth = resolveValue(
|
|
983
|
-
|
|
984
|
-
mask.
|
|
1722
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1723
|
+
const maskId = ++this.maskCounter;
|
|
1724
|
+
mask.set({
|
|
1725
|
+
maskId,
|
|
1726
|
+
maskName: `${this.options.maskName}${maskId}`
|
|
1727
|
+
});
|
|
985
1728
|
this._lastMask = mask;
|
|
986
1729
|
this.canvas.add(mask);
|
|
987
1730
|
this.canvas.bringToFront(mask);
|
|
988
|
-
if (
|
|
1731
|
+
if (maskConfig.selectable)
|
|
989
1732
|
this.canvas.setActiveObject(mask);
|
|
990
|
-
this.
|
|
1733
|
+
this._handleSelectionChanged([mask]);
|
|
991
1734
|
this._updateMaskList();
|
|
992
1735
|
this._updateUI();
|
|
993
1736
|
this.canvas.renderAll();
|
|
994
1737
|
this.saveState();
|
|
995
|
-
if (typeof
|
|
996
|
-
|
|
1738
|
+
if (typeof maskConfig.onCreate === "function")
|
|
1739
|
+
maskConfig.onCreate(mask, this.canvas);
|
|
997
1740
|
return mask;
|
|
998
1741
|
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Backward-compatible alias for {@link ImageEditor#createMask}.
|
|
1744
|
+
*
|
|
1745
|
+
* @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
|
|
1746
|
+
* @param {Object} [config={}] - Mask configuration passed to createMask().
|
|
1747
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1748
|
+
*/
|
|
1749
|
+
addMask(config = {}) {
|
|
1750
|
+
return this.createMask(config);
|
|
1751
|
+
}
|
|
999
1752
|
/**
|
|
1000
1753
|
* Removes the currently selected mask from the canvas, if any.
|
|
1001
1754
|
* The associated label is also removed. UI and mask list are updated.
|
|
1002
1755
|
*/
|
|
1003
1756
|
removeSelectedMask() {
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1757
|
+
const activeObject = this.canvas.getActiveObject();
|
|
1758
|
+
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
1759
|
+
if (!selectedMasks.length)
|
|
1006
1760
|
return;
|
|
1007
|
-
this._removeLabelForMask(active);
|
|
1008
|
-
this.canvas.remove(active);
|
|
1009
|
-
if (this._lastMask === active) {
|
|
1010
|
-
const masks = this.canvas.getObjects().filter((o) => o.maskId);
|
|
1011
|
-
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1012
|
-
if (!this._lastMask) {
|
|
1013
|
-
this._lastMaskInitialLeft = null;
|
|
1014
|
-
this._lastMaskInitialTop = null;
|
|
1015
|
-
this._lastMaskInitialWidth = null;
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
1761
|
this.canvas.discardActiveObject();
|
|
1762
|
+
selectedMasks.forEach((mask) => {
|
|
1763
|
+
this._removeLabelForMask(mask);
|
|
1764
|
+
this.canvas.remove(mask);
|
|
1765
|
+
});
|
|
1766
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1767
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1768
|
+
if (!this._lastMask) {
|
|
1769
|
+
this._lastMaskInitialLeft = null;
|
|
1770
|
+
this._lastMaskInitialTop = null;
|
|
1771
|
+
this._lastMaskInitialWidth = null;
|
|
1772
|
+
}
|
|
1019
1773
|
this._updateMaskList();
|
|
1020
1774
|
this._updateUI();
|
|
1021
1775
|
this.canvas.renderAll();
|
|
@@ -1025,10 +1779,11 @@ var ImageEditor = class {
|
|
|
1025
1779
|
* Removes all masks from the canvas, including their labels.
|
|
1026
1780
|
* UI and internal mask placement memory are reset.
|
|
1027
1781
|
*/
|
|
1028
|
-
removeAllMasks() {
|
|
1029
|
-
const
|
|
1030
|
-
masks.
|
|
1031
|
-
masks.forEach((
|
|
1782
|
+
removeAllMasks(options = {}) {
|
|
1783
|
+
const saveHistory = options.saveHistory !== false;
|
|
1784
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1785
|
+
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
1786
|
+
masks.forEach((mask) => this.canvas.remove(mask));
|
|
1032
1787
|
this.canvas.discardActiveObject();
|
|
1033
1788
|
this._lastMask = null;
|
|
1034
1789
|
this._lastMaskInitialLeft = null;
|
|
@@ -1037,7 +1792,8 @@ var ImageEditor = class {
|
|
|
1037
1792
|
this._updateMaskList();
|
|
1038
1793
|
this._updateUI();
|
|
1039
1794
|
this.canvas.renderAll();
|
|
1040
|
-
|
|
1795
|
+
if (saveHistory)
|
|
1796
|
+
this.saveState();
|
|
1041
1797
|
}
|
|
1042
1798
|
/**
|
|
1043
1799
|
* Removes the label associated with the specified mask object, if it exists.
|
|
@@ -1050,18 +1806,35 @@ var ImageEditor = class {
|
|
|
1050
1806
|
return;
|
|
1051
1807
|
if (mask.__label) {
|
|
1052
1808
|
try {
|
|
1053
|
-
const
|
|
1054
|
-
if (
|
|
1809
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1810
|
+
if (canvasObjects.includes(mask.__label)) {
|
|
1055
1811
|
this.canvas.remove(mask.__label);
|
|
1056
1812
|
}
|
|
1057
|
-
} catch (
|
|
1813
|
+
} catch (error) {
|
|
1058
1814
|
}
|
|
1059
1815
|
try {
|
|
1060
1816
|
delete mask.__label;
|
|
1061
|
-
} catch (
|
|
1817
|
+
} catch (error) {
|
|
1062
1818
|
}
|
|
1063
1819
|
}
|
|
1064
1820
|
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Returns a stable zero-based creation index for label callbacks.
|
|
1823
|
+
*
|
|
1824
|
+
* Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
|
|
1825
|
+
* lifetime of a mask.
|
|
1826
|
+
*
|
|
1827
|
+
* @param {fabric.Object} mask - Mask object.
|
|
1828
|
+
* @returns {number} Stable zero-based creation index.
|
|
1829
|
+
* @private
|
|
1830
|
+
*/
|
|
1831
|
+
_getMaskCreationIndex(mask) {
|
|
1832
|
+
const maskId = Number(mask && mask.maskId);
|
|
1833
|
+
if (Number.isFinite(maskId) && maskId > 0)
|
|
1834
|
+
return Math.floor(maskId) - 1;
|
|
1835
|
+
const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1836
|
+
return Math.max(0, masks.indexOf(mask));
|
|
1837
|
+
}
|
|
1065
1838
|
/**
|
|
1066
1839
|
* Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
|
|
1067
1840
|
* The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
|
|
@@ -1073,12 +1846,12 @@ var ImageEditor = class {
|
|
|
1073
1846
|
if (!mask || !this.options.maskLabelOnSelect)
|
|
1074
1847
|
return;
|
|
1075
1848
|
this._removeLabelForMask(mask);
|
|
1076
|
-
let
|
|
1849
|
+
let textObject = null;
|
|
1077
1850
|
if (this.options.label && typeof this.options.label.create === "function") {
|
|
1078
|
-
|
|
1851
|
+
textObject = this.options.label.create(mask, fabric);
|
|
1079
1852
|
}
|
|
1080
|
-
if (!
|
|
1081
|
-
let
|
|
1853
|
+
if (!textObject) {
|
|
1854
|
+
let labelText = mask.maskName;
|
|
1082
1855
|
let textOptions = {
|
|
1083
1856
|
left: 0,
|
|
1084
1857
|
top: 0,
|
|
@@ -1093,18 +1866,18 @@ var ImageEditor = class {
|
|
|
1093
1866
|
};
|
|
1094
1867
|
if (this.options.label) {
|
|
1095
1868
|
if (typeof this.options.label.getText === "function") {
|
|
1096
|
-
|
|
1869
|
+
labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
|
|
1097
1870
|
}
|
|
1098
1871
|
if (this.options.label.textOptions) {
|
|
1099
1872
|
Object.assign(textOptions, this.options.label.textOptions);
|
|
1100
1873
|
}
|
|
1101
1874
|
}
|
|
1102
|
-
|
|
1875
|
+
textObject = new fabric.Text(labelText, textOptions);
|
|
1103
1876
|
}
|
|
1104
|
-
|
|
1105
|
-
mask.__label =
|
|
1106
|
-
this.canvas.add(
|
|
1107
|
-
this.canvas.bringToFront(
|
|
1877
|
+
textObject.maskLabel = true;
|
|
1878
|
+
mask.__label = textObject;
|
|
1879
|
+
this.canvas.add(textObject);
|
|
1880
|
+
this.canvas.bringToFront(textObject);
|
|
1108
1881
|
this._syncMaskLabel(mask);
|
|
1109
1882
|
}
|
|
1110
1883
|
/**
|
|
@@ -1115,20 +1888,20 @@ var ImageEditor = class {
|
|
|
1115
1888
|
_hideAllMaskLabels() {
|
|
1116
1889
|
if (!this.canvas)
|
|
1117
1890
|
return;
|
|
1118
|
-
const
|
|
1119
|
-
const labels =
|
|
1120
|
-
labels.forEach((
|
|
1891
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1892
|
+
const labels = canvasObjects.filter((object) => object.maskLabel);
|
|
1893
|
+
labels.forEach((label) => {
|
|
1121
1894
|
try {
|
|
1122
|
-
if (
|
|
1123
|
-
this.canvas.remove(
|
|
1124
|
-
} catch (
|
|
1895
|
+
if (canvasObjects.includes(label))
|
|
1896
|
+
this.canvas.remove(label);
|
|
1897
|
+
} catch (error) {
|
|
1125
1898
|
}
|
|
1126
1899
|
});
|
|
1127
|
-
|
|
1128
|
-
if (
|
|
1900
|
+
canvasObjects.forEach((object) => {
|
|
1901
|
+
if (object.maskId && object.__label) {
|
|
1129
1902
|
try {
|
|
1130
|
-
delete
|
|
1131
|
-
} catch (
|
|
1903
|
+
delete object.__label;
|
|
1904
|
+
} catch (error) {
|
|
1132
1905
|
}
|
|
1133
1906
|
}
|
|
1134
1907
|
});
|
|
@@ -1168,7 +1941,11 @@ var ImageEditor = class {
|
|
|
1168
1941
|
visible: true
|
|
1169
1942
|
});
|
|
1170
1943
|
mask.__label.setCoords();
|
|
1171
|
-
this.canvas.
|
|
1944
|
+
if (typeof this.canvas.requestRenderAll === "function") {
|
|
1945
|
+
this.canvas.requestRenderAll();
|
|
1946
|
+
} else {
|
|
1947
|
+
this.canvas.renderAll();
|
|
1948
|
+
}
|
|
1172
1949
|
}
|
|
1173
1950
|
/**
|
|
1174
1951
|
* Shows the label for the given mask, creating it if necessary and synchronizing its position.
|
|
@@ -1183,7 +1960,7 @@ var ImageEditor = class {
|
|
|
1183
1960
|
return;
|
|
1184
1961
|
if (!mask.__label)
|
|
1185
1962
|
this._createLabelForMask(mask);
|
|
1186
|
-
mask.__label.visible
|
|
1963
|
+
mask.__label.set({ visible: true });
|
|
1187
1964
|
this._syncMaskLabel(mask);
|
|
1188
1965
|
}
|
|
1189
1966
|
/**
|
|
@@ -1193,21 +1970,25 @@ var ImageEditor = class {
|
|
|
1193
1970
|
* @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
|
|
1194
1971
|
* @private
|
|
1195
1972
|
*/
|
|
1196
|
-
|
|
1197
|
-
const selectedMask = (selected || []).find((
|
|
1198
|
-
const masks = this.canvas.getObjects().filter((
|
|
1199
|
-
masks.forEach((
|
|
1200
|
-
if (
|
|
1201
|
-
if (
|
|
1973
|
+
_handleSelectionChanged(selected) {
|
|
1974
|
+
const selectedMask = (selected || []).find((object) => object.maskId);
|
|
1975
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1976
|
+
masks.forEach((mask) => {
|
|
1977
|
+
if (mask !== selectedMask) {
|
|
1978
|
+
if (mask.__label) {
|
|
1202
1979
|
try {
|
|
1203
|
-
this.canvas.remove(
|
|
1204
|
-
} catch (
|
|
1980
|
+
this.canvas.remove(mask.__label);
|
|
1981
|
+
} catch (error) {
|
|
1205
1982
|
}
|
|
1206
|
-
delete
|
|
1983
|
+
delete mask.__label;
|
|
1207
1984
|
}
|
|
1208
|
-
|
|
1985
|
+
const originalStrokeWidth = Number(mask.originalStrokeWidth);
|
|
1986
|
+
mask.set({
|
|
1987
|
+
stroke: mask.originalStroke || "#ccc",
|
|
1988
|
+
strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
|
|
1989
|
+
});
|
|
1209
1990
|
} else {
|
|
1210
|
-
|
|
1991
|
+
mask.set({ stroke: "#ff0000", strokeWidth: 1 });
|
|
1211
1992
|
}
|
|
1212
1993
|
});
|
|
1213
1994
|
if (selectedMask)
|
|
@@ -1222,20 +2003,20 @@ var ImageEditor = class {
|
|
|
1222
2003
|
* @private
|
|
1223
2004
|
*/
|
|
1224
2005
|
_updateMaskList() {
|
|
1225
|
-
const
|
|
1226
|
-
if (!
|
|
2006
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
2007
|
+
if (!maskListElement)
|
|
1227
2008
|
return;
|
|
1228
|
-
|
|
1229
|
-
const masks = this.canvas.getObjects().filter((
|
|
2009
|
+
maskListElement.innerHTML = "";
|
|
2010
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1230
2011
|
masks.forEach((mask) => {
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
2012
|
+
const listItemElement = document.createElement("li");
|
|
2013
|
+
listItemElement.className = "list-group-item mask-item";
|
|
2014
|
+
listItemElement.textContent = mask.maskName;
|
|
2015
|
+
listItemElement.onclick = () => {
|
|
1235
2016
|
this.canvas.setActiveObject(mask);
|
|
1236
|
-
this.
|
|
2017
|
+
this._handleSelectionChanged([mask]);
|
|
1237
2018
|
};
|
|
1238
|
-
|
|
2019
|
+
maskListElement.appendChild(listItemElement);
|
|
1239
2020
|
});
|
|
1240
2021
|
}
|
|
1241
2022
|
/**
|
|
@@ -1245,157 +2026,167 @@ var ImageEditor = class {
|
|
|
1245
2026
|
* @private
|
|
1246
2027
|
*/
|
|
1247
2028
|
_updateMaskListSelection(selectedMask) {
|
|
1248
|
-
const
|
|
1249
|
-
if (!
|
|
2029
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
2030
|
+
if (!maskListElement)
|
|
1250
2031
|
return;
|
|
1251
|
-
const
|
|
1252
|
-
|
|
2032
|
+
const maskItems = maskListElement.querySelectorAll(".mask-item");
|
|
2033
|
+
maskItems.forEach((item) => {
|
|
1253
2034
|
const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
|
|
1254
2035
|
item.classList.toggle("active", isSelected);
|
|
1255
2036
|
});
|
|
1256
2037
|
}
|
|
1257
2038
|
/**
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
2039
|
+
* Flattens the current masks into the base image and reloads the flattened image.
|
|
2040
|
+
*
|
|
2041
|
+
* This removes editable mask objects after export and records the operation as one undoable history transition.
|
|
2042
|
+
* It does nothing when no base image or no masks exist.
|
|
2043
|
+
*
|
|
1260
2044
|
* @async
|
|
1261
|
-
* @returns {Promise<void>} Resolves when
|
|
2045
|
+
* @returns {Promise<void>} Resolves when the flattened image has been loaded.
|
|
2046
|
+
* @public
|
|
1262
2047
|
*/
|
|
1263
|
-
async
|
|
2048
|
+
async mergeMasks() {
|
|
1264
2049
|
if (!this.originalImage)
|
|
1265
2050
|
return;
|
|
1266
|
-
const masks = this.canvas.getObjects().filter((
|
|
2051
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1267
2052
|
if (!masks.length)
|
|
1268
2053
|
return;
|
|
1269
2054
|
this.canvas.discardActiveObject();
|
|
1270
2055
|
this.canvas.renderAll();
|
|
1271
2056
|
try {
|
|
1272
|
-
const
|
|
1273
|
-
this.
|
|
1274
|
-
|
|
1275
|
-
this.
|
|
1276
|
-
|
|
1277
|
-
this.
|
|
1278
|
-
|
|
1279
|
-
|
|
2057
|
+
const beforeJson = this._serializeCanvasState();
|
|
2058
|
+
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
2059
|
+
this.removeAllMasks({ saveHistory: false });
|
|
2060
|
+
await this.loadImage(merged, { preserveScroll: true });
|
|
2061
|
+
const afterJson = this._serializeCanvasState();
|
|
2062
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
this._reportError("merge error", error);
|
|
1280
2065
|
}
|
|
1281
2066
|
}
|
|
1282
2067
|
/**
|
|
1283
|
-
*
|
|
2068
|
+
* Backward-compatible alias for {@link ImageEditor#mergeMasks}.
|
|
2069
|
+
*
|
|
2070
|
+
* @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
|
|
2071
|
+
* @returns {Promise<void>} Resolves when mask flattening is complete.
|
|
2072
|
+
*/
|
|
2073
|
+
async merge() {
|
|
2074
|
+
return this.mergeMasks();
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Triggers a JPEG image download of the current canvas.
|
|
2078
|
+
*
|
|
1284
2079
|
* The image area and multiplier are controlled by options.
|
|
1285
2080
|
* @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
|
|
2081
|
+
* @returns {void}
|
|
2082
|
+
* @public
|
|
1286
2083
|
*/
|
|
1287
2084
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1288
2085
|
if (!this.originalImage)
|
|
1289
2086
|
return;
|
|
1290
2087
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1291
|
-
this.
|
|
2088
|
+
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((imageBase64) => {
|
|
1292
2089
|
const link = document.createElement("a");
|
|
1293
2090
|
link.download = fileName;
|
|
1294
|
-
link.href =
|
|
2091
|
+
link.href = imageBase64;
|
|
1295
2092
|
document.body.appendChild(link);
|
|
1296
2093
|
link.click();
|
|
1297
2094
|
document.body.removeChild(link);
|
|
1298
|
-
}).catch((
|
|
2095
|
+
}).catch((error) => this._reportError("download error", error));
|
|
1299
2096
|
}
|
|
1300
2097
|
/**
|
|
1301
|
-
* Exports the image as a Base64-encoded
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
2098
|
+
* Exports the current image as a Base64-encoded data URL.
|
|
2099
|
+
*
|
|
2100
|
+
* When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
|
|
2101
|
+
* temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
|
|
2102
|
+
*
|
|
1304
2103
|
* @async
|
|
1305
|
-
* @param {Object} [
|
|
1306
|
-
* @param {boolean} [
|
|
1307
|
-
* @param {number} [
|
|
1308
|
-
* @
|
|
2104
|
+
* @param {Object} [options={}] - Export options.
|
|
2105
|
+
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
2106
|
+
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
2107
|
+
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
2108
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
2109
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1309
2110
|
* @throws {Error} If there is no image loaded.
|
|
2111
|
+
* @public
|
|
1310
2112
|
*/
|
|
1311
|
-
async
|
|
2113
|
+
async exportImageBase64(options = {}) {
|
|
1312
2114
|
if (!this.originalImage)
|
|
1313
2115
|
throw new Error("No image loaded");
|
|
1314
|
-
const exportImageArea = typeof
|
|
1315
|
-
const multiplier =
|
|
2116
|
+
const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
2117
|
+
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
2118
|
+
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
2119
|
+
const format = this._normalizeImageFormat(options.fileType || options.format);
|
|
1316
2120
|
if (!exportImageArea) {
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
2121
|
+
const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
|
|
2122
|
+
const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
|
|
2123
|
+
try {
|
|
2124
|
+
masks2.forEach((mask) => {
|
|
2125
|
+
mask.set({ visible: false });
|
|
2126
|
+
});
|
|
2127
|
+
this.canvas.discardActiveObject();
|
|
2128
|
+
this.canvas.renderAll();
|
|
2129
|
+
this.originalImage.setCoords();
|
|
2130
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2131
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2132
|
+
return await this._exportCanvasRegionToDataURL({
|
|
2133
|
+
...exportRegion,
|
|
2134
|
+
multiplier,
|
|
2135
|
+
quality,
|
|
2136
|
+
format
|
|
2137
|
+
});
|
|
2138
|
+
} finally {
|
|
2139
|
+
maskVisibilityBackups.forEach((backup) => {
|
|
2140
|
+
try {
|
|
2141
|
+
backup.object.set({ visible: backup.visible });
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
this.canvas.renderAll();
|
|
2146
|
+
}
|
|
1328
2147
|
}
|
|
1329
|
-
const masks = this.canvas.getObjects().filter((
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1332
|
-
opacity:
|
|
1333
|
-
fill:
|
|
1334
|
-
strokeWidth:
|
|
1335
|
-
stroke:
|
|
1336
|
-
selectable:
|
|
1337
|
-
lockRotation:
|
|
2148
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
2149
|
+
const maskStyleBackups = masks.map((mask) => ({
|
|
2150
|
+
object: mask,
|
|
2151
|
+
opacity: mask.opacity,
|
|
2152
|
+
fill: mask.fill,
|
|
2153
|
+
strokeWidth: mask.strokeWidth,
|
|
2154
|
+
stroke: mask.stroke,
|
|
2155
|
+
selectable: mask.selectable,
|
|
2156
|
+
lockRotation: mask.lockRotation
|
|
1338
2157
|
}));
|
|
1339
2158
|
let finalBase64;
|
|
1340
2159
|
try {
|
|
1341
|
-
masks.forEach((
|
|
2160
|
+
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
1342
2161
|
this.canvas.discardActiveObject();
|
|
1343
2162
|
this.canvas.renderAll();
|
|
1344
|
-
masks.forEach((
|
|
1345
|
-
|
|
1346
|
-
|
|
2163
|
+
masks.forEach((mask) => {
|
|
2164
|
+
mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
|
|
2165
|
+
mask.setCoords();
|
|
1347
2166
|
});
|
|
1348
2167
|
this.canvas.renderAll();
|
|
1349
2168
|
this.originalImage.setCoords();
|
|
1350
|
-
const
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
const fullDataUrl = this.canvas.toDataURL({
|
|
1358
|
-
format: "jpeg",
|
|
1359
|
-
quality: this.options.downsampleQuality,
|
|
1360
|
-
multiplier
|
|
1361
|
-
});
|
|
1362
|
-
const img = new Image();
|
|
1363
|
-
img.onload = () => {
|
|
1364
|
-
try {
|
|
1365
|
-
const sxM = Math.round(sx * multiplier);
|
|
1366
|
-
const syM = Math.round(sy * multiplier);
|
|
1367
|
-
const swM = Math.round(sw * multiplier);
|
|
1368
|
-
const shM = Math.round(sh * multiplier);
|
|
1369
|
-
const oc = document.createElement("canvas");
|
|
1370
|
-
oc.width = swM;
|
|
1371
|
-
oc.height = shM;
|
|
1372
|
-
const ctx = oc.getContext("2d");
|
|
1373
|
-
ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
|
|
1374
|
-
const out = oc.toDataURL("image/jpeg", this.options.downsampleQuality);
|
|
1375
|
-
resolve(out);
|
|
1376
|
-
} catch (e) {
|
|
1377
|
-
reject(e);
|
|
1378
|
-
}
|
|
1379
|
-
};
|
|
1380
|
-
img.onerror = reject;
|
|
1381
|
-
img.src = fullDataUrl;
|
|
1382
|
-
} catch (e) {
|
|
1383
|
-
reject(e);
|
|
1384
|
-
}
|
|
2169
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2170
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2171
|
+
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
2172
|
+
...exportRegion,
|
|
2173
|
+
multiplier,
|
|
2174
|
+
quality,
|
|
2175
|
+
format
|
|
1385
2176
|
});
|
|
1386
2177
|
} finally {
|
|
1387
|
-
|
|
2178
|
+
maskStyleBackups.forEach((backup) => {
|
|
1388
2179
|
try {
|
|
1389
|
-
|
|
1390
|
-
opacity:
|
|
1391
|
-
fill:
|
|
1392
|
-
strokeWidth:
|
|
1393
|
-
stroke:
|
|
1394
|
-
selectable:
|
|
1395
|
-
lockRotation:
|
|
2180
|
+
backup.object.set({
|
|
2181
|
+
opacity: backup.opacity,
|
|
2182
|
+
fill: backup.fill,
|
|
2183
|
+
strokeWidth: backup.strokeWidth,
|
|
2184
|
+
stroke: backup.stroke,
|
|
2185
|
+
selectable: backup.selectable,
|
|
2186
|
+
lockRotation: backup.lockRotation
|
|
1396
2187
|
});
|
|
1397
|
-
|
|
1398
|
-
} catch (
|
|
2188
|
+
backup.object.setCoords();
|
|
2189
|
+
} catch (error) {
|
|
1399
2190
|
}
|
|
1400
2191
|
});
|
|
1401
2192
|
this.canvas.renderAll();
|
|
@@ -1403,22 +2194,34 @@ var ImageEditor = class {
|
|
|
1403
2194
|
return finalBase64;
|
|
1404
2195
|
}
|
|
1405
2196
|
/**
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
2197
|
+
* Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
|
|
2198
|
+
*
|
|
2199
|
+
* @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
|
|
2200
|
+
* @param {Object} [options={}] - Export options passed to exportImageBase64().
|
|
2201
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
2202
|
+
*/
|
|
2203
|
+
async getImageBase64(options = {}) {
|
|
2204
|
+
return this.exportImageBase64(options);
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Exports the current image as a File object.
|
|
2208
|
+
*
|
|
2209
|
+
* The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
|
|
2210
|
+
* Supported output formats are JPEG, PNG, and WebP.
|
|
1408
2211
|
*
|
|
1409
2212
|
* @async
|
|
1410
|
-
* @param {Object} [
|
|
1411
|
-
* @param {boolean} [
|
|
1412
|
-
* @param {string} [
|
|
1413
|
-
* @param {number} [
|
|
1414
|
-
* @param {number} [
|
|
1415
|
-
* @param {string} [
|
|
2213
|
+
* @param {Object} [options={}] - Export options.
|
|
2214
|
+
* @param {boolean} [options.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
|
|
2215
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
|
|
2216
|
+
* @param {number} [options.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
|
|
2217
|
+
* @param {number} [options.multiplier=1] - Output resolution multiplier.
|
|
2218
|
+
* @param {string} [options.fileName] - Optional file name (only used for download).
|
|
1416
2219
|
* @returns {Promise<File>} Resolves with the exported image as a File object.
|
|
1417
2220
|
*
|
|
1418
2221
|
* @example
|
|
1419
2222
|
* const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
|
|
1420
2223
|
*/
|
|
1421
|
-
async exportImageFile(
|
|
2224
|
+
async exportImageFile(options = {}) {
|
|
1422
2225
|
if (!this.originalImage)
|
|
1423
2226
|
throw new Error("No image loaded");
|
|
1424
2227
|
const {
|
|
@@ -1427,63 +2230,124 @@ var ImageEditor = class {
|
|
|
1427
2230
|
quality = this.options.downsampleQuality ?? 0.92,
|
|
1428
2231
|
multiplier = this.options.exportMultiplier ?? 1,
|
|
1429
2232
|
fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
|
|
1430
|
-
} =
|
|
1431
|
-
const
|
|
1432
|
-
|
|
1433
|
-
"jpg": "jpeg",
|
|
1434
|
-
"image/jpeg": "jpeg",
|
|
1435
|
-
"png": "png",
|
|
1436
|
-
"image/png": "png",
|
|
1437
|
-
"webp": "webp",
|
|
1438
|
-
"image/webp": "webp"
|
|
1439
|
-
};
|
|
1440
|
-
const safeFileType = typeMapping[String(fileType).toLowerCase()] || "jpeg";
|
|
1441
|
-
let base64;
|
|
2233
|
+
} = options;
|
|
2234
|
+
const safeFileType = this._normalizeImageFormat(fileType);
|
|
2235
|
+
let imageBase64;
|
|
1442
2236
|
if (mergeMask) {
|
|
1443
|
-
|
|
2237
|
+
imageBase64 = await this.exportImageBase64({
|
|
1444
2238
|
exportImageArea: true,
|
|
1445
|
-
multiplier
|
|
2239
|
+
multiplier,
|
|
2240
|
+
quality,
|
|
2241
|
+
fileType: safeFileType
|
|
1446
2242
|
});
|
|
1447
2243
|
} else {
|
|
1448
|
-
|
|
2244
|
+
imageBase64 = await this.exportImageBase64({
|
|
1449
2245
|
exportImageArea: false,
|
|
1450
|
-
multiplier
|
|
2246
|
+
multiplier,
|
|
2247
|
+
quality,
|
|
2248
|
+
fileType: safeFileType
|
|
1451
2249
|
});
|
|
1452
2250
|
}
|
|
1453
|
-
let imageDataUrl =
|
|
2251
|
+
let imageDataUrl = imageBase64;
|
|
1454
2252
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
1455
2253
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
|
|
2254
|
+
const imageElement = new window.Image();
|
|
2255
|
+
imageElement.crossOrigin = "Anonymous";
|
|
2256
|
+
imageElement.onload = () => {
|
|
1459
2257
|
try {
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
const
|
|
1466
|
-
resolve(
|
|
1467
|
-
} catch (
|
|
1468
|
-
reject(
|
|
2258
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
2259
|
+
offscreenCanvas.width = imageElement.width;
|
|
2260
|
+
offscreenCanvas.height = imageElement.height;
|
|
2261
|
+
const context = offscreenCanvas.getContext("2d");
|
|
2262
|
+
context.drawImage(imageElement, 0, 0);
|
|
2263
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
|
|
2264
|
+
resolve(convertedDataUrl);
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
reject(error);
|
|
1469
2267
|
}
|
|
1470
2268
|
};
|
|
1471
|
-
|
|
1472
|
-
|
|
2269
|
+
imageElement.onerror = reject;
|
|
2270
|
+
imageElement.src = imageBase64;
|
|
1473
2271
|
});
|
|
1474
2272
|
}
|
|
1475
|
-
const
|
|
2273
|
+
const binaryString = atob(imageDataUrl.split(",")[1]);
|
|
1476
2274
|
const mime = `image/${safeFileType}`;
|
|
1477
|
-
let
|
|
1478
|
-
const
|
|
1479
|
-
while (
|
|
1480
|
-
|
|
2275
|
+
let byteIndex = binaryString.length;
|
|
2276
|
+
const bytes = new Uint8Array(byteIndex);
|
|
2277
|
+
while (byteIndex--) {
|
|
2278
|
+
bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
|
|
2279
|
+
}
|
|
2280
|
+
return new File([bytes], fileName, { type: mime });
|
|
2281
|
+
}
|
|
2282
|
+
_clearMaskPlacementMemory() {
|
|
2283
|
+
this._lastMask = null;
|
|
2284
|
+
this._lastMaskInitialLeft = null;
|
|
2285
|
+
this._lastMaskInitialTop = null;
|
|
2286
|
+
this._lastMaskInitialWidth = null;
|
|
2287
|
+
}
|
|
2288
|
+
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
2289
|
+
this._reportError(message, error);
|
|
2290
|
+
if (this._cropRect && this.canvas)
|
|
2291
|
+
this._removeCropRect();
|
|
2292
|
+
this._cropRect = null;
|
|
2293
|
+
this._cropMode = false;
|
|
2294
|
+
if (this.canvas && this._prevSelectionSetting !== void 0) {
|
|
2295
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2296
|
+
}
|
|
2297
|
+
this._prevSelectionSetting = void 0;
|
|
2298
|
+
if (beforeJson) {
|
|
2299
|
+
try {
|
|
2300
|
+
await this.loadFromState(beforeJson);
|
|
2301
|
+
} catch (restoreError) {
|
|
2302
|
+
this._reportError("applyCrop: rollback failed", restoreError);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
this._updateUI();
|
|
2306
|
+
if (this.canvas)
|
|
2307
|
+
this.canvas.renderAll();
|
|
2308
|
+
}
|
|
2309
|
+
_restoreCropObjectState() {
|
|
2310
|
+
if (Array.isArray(this._cropPrevEvented)) {
|
|
2311
|
+
this._cropPrevEvented.forEach((state) => {
|
|
2312
|
+
try {
|
|
2313
|
+
state.object.set({
|
|
2314
|
+
evented: state.evented,
|
|
2315
|
+
selectable: state.selectable,
|
|
2316
|
+
visible: state.visible
|
|
2317
|
+
});
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
1481
2321
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
2322
|
+
this._cropPrevEvented = null;
|
|
2323
|
+
}
|
|
2324
|
+
_removeCropRect() {
|
|
2325
|
+
if (!this._cropRect)
|
|
2326
|
+
return;
|
|
2327
|
+
try {
|
|
2328
|
+
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2329
|
+
this._cropHandlers.forEach((targetHandlers) => {
|
|
2330
|
+
targetHandlers.handlers.forEach((handlerRecord) => {
|
|
2331
|
+
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
2332
|
+
});
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
}
|
|
2337
|
+
try {
|
|
2338
|
+
this.canvas.remove(this._cropRect);
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
}
|
|
2341
|
+
this._cropRect = null;
|
|
2342
|
+
this._cropHandlers = [];
|
|
1484
2343
|
}
|
|
1485
2344
|
/**
|
|
1486
|
-
*
|
|
2345
|
+
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
2346
|
+
*
|
|
2347
|
+
* Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
|
|
2348
|
+
* cropping when `crop.hideMasksDuringCrop` is enabled.
|
|
2349
|
+
*
|
|
2350
|
+
* @returns {void}
|
|
1487
2351
|
* @public
|
|
1488
2352
|
*/
|
|
1489
2353
|
enterCropMode() {
|
|
@@ -1496,12 +2360,18 @@ var ImageEditor = class {
|
|
|
1496
2360
|
this.canvas.selection = false;
|
|
1497
2361
|
this.canvas.discardActiveObject();
|
|
1498
2362
|
this.originalImage.setCoords();
|
|
1499
|
-
const
|
|
2363
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1500
2364
|
const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
|
|
1501
|
-
const left = Math.max(0, Math.floor(
|
|
1502
|
-
const top = Math.max(0, Math.floor(
|
|
1503
|
-
const
|
|
1504
|
-
const
|
|
2365
|
+
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2366
|
+
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2367
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
|
|
2368
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
|
|
2369
|
+
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
2370
|
+
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
2371
|
+
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
2372
|
+
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
2373
|
+
const width = minCropWidth;
|
|
2374
|
+
const height = minCropHeight;
|
|
1505
2375
|
const cropRect = new fabric.Rect({
|
|
1506
2376
|
left,
|
|
1507
2377
|
top,
|
|
@@ -1518,7 +2388,8 @@ var ImageEditor = class {
|
|
|
1518
2388
|
cornerSize: 8,
|
|
1519
2389
|
objectCaching: false,
|
|
1520
2390
|
originX: "left",
|
|
1521
|
-
originY: "top"
|
|
2391
|
+
originY: "top",
|
|
2392
|
+
lockScalingFlip: true
|
|
1522
2393
|
});
|
|
1523
2394
|
this.canvas.add(cropRect);
|
|
1524
2395
|
cropRect.isCropRect = true;
|
|
@@ -1526,63 +2397,59 @@ var ImageEditor = class {
|
|
|
1526
2397
|
this.canvas.setActiveObject(cropRect);
|
|
1527
2398
|
this._cropRect = cropRect;
|
|
1528
2399
|
this._cropPrevEvented = [];
|
|
1529
|
-
this.
|
|
1530
|
-
|
|
1531
|
-
|
|
2400
|
+
const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
|
|
2401
|
+
this.canvas.getObjects().forEach((object) => {
|
|
2402
|
+
if (object !== cropRect) {
|
|
2403
|
+
this._cropPrevEvented.push({ object, evented: object.evented, selectable: object.selectable, visible: object.visible });
|
|
1532
2404
|
try {
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2405
|
+
const updates = {
|
|
2406
|
+
evented: false,
|
|
2407
|
+
selectable: false
|
|
2408
|
+
};
|
|
2409
|
+
if (shouldHideMasks && (object.maskId || object.maskLabel))
|
|
2410
|
+
updates.visible = false;
|
|
2411
|
+
object.set(updates);
|
|
2412
|
+
} catch (error) {
|
|
1536
2413
|
}
|
|
1537
2414
|
}
|
|
1538
2415
|
});
|
|
1539
|
-
const
|
|
2416
|
+
const handleCropRectModified = () => {
|
|
1540
2417
|
try {
|
|
2418
|
+
const cropWidth = Math.max(1, Number(cropRect.width) || 1);
|
|
2419
|
+
const cropHeight = Math.max(1, Number(cropRect.height) || 1);
|
|
2420
|
+
const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
|
|
2421
|
+
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
2422
|
+
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
1541
2423
|
cropRect.setCoords();
|
|
1542
2424
|
this.canvas.requestRenderAll();
|
|
1543
|
-
} catch (
|
|
2425
|
+
} catch (error) {
|
|
1544
2426
|
}
|
|
1545
2427
|
};
|
|
1546
|
-
cropRect.on("modified",
|
|
1547
|
-
cropRect.on("moving",
|
|
1548
|
-
cropRect.on("scaling",
|
|
1549
|
-
this._cropHandlers.push({
|
|
2428
|
+
cropRect.on("modified", handleCropRectModified);
|
|
2429
|
+
cropRect.on("moving", handleCropRectModified);
|
|
2430
|
+
cropRect.on("scaling", handleCropRectModified);
|
|
2431
|
+
this._cropHandlers.push({
|
|
2432
|
+
target: cropRect,
|
|
2433
|
+
handlers: [
|
|
2434
|
+
{ eventName: "modified", handler: handleCropRectModified },
|
|
2435
|
+
{ eventName: "moving", handler: handleCropRectModified },
|
|
2436
|
+
{ eventName: "scaling", handler: handleCropRectModified }
|
|
2437
|
+
]
|
|
2438
|
+
});
|
|
1550
2439
|
this._updateUI();
|
|
1551
2440
|
this.canvas.renderAll();
|
|
1552
2441
|
}
|
|
1553
2442
|
/**
|
|
1554
|
-
*
|
|
2443
|
+
* Cancels crop mode and removes the temporary crop rectangle.
|
|
2444
|
+
*
|
|
2445
|
+
* @returns {void}
|
|
1555
2446
|
* @public
|
|
1556
2447
|
*/
|
|
1557
2448
|
cancelCrop() {
|
|
1558
2449
|
if (!this.canvas || !this._cropMode)
|
|
1559
2450
|
return;
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
if (this._cropHandlers && this._cropHandlers.length) {
|
|
1563
|
-
this._cropHandlers.forEach((h) => {
|
|
1564
|
-
h.handlers.forEach((rec) => h.target.off(rec.evt, rec.fn));
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
} catch (e) {
|
|
1568
|
-
}
|
|
1569
|
-
try {
|
|
1570
|
-
this.canvas.remove(this._cropRect);
|
|
1571
|
-
} catch (e) {
|
|
1572
|
-
}
|
|
1573
|
-
this._cropRect = null;
|
|
1574
|
-
}
|
|
1575
|
-
if (Array.isArray(this._cropPrevEvented)) {
|
|
1576
|
-
this._cropPrevEvented.forEach((i) => {
|
|
1577
|
-
try {
|
|
1578
|
-
i.obj.evented = i.evented;
|
|
1579
|
-
i.obj.selectable = i.selectable;
|
|
1580
|
-
} catch (e) {
|
|
1581
|
-
}
|
|
1582
|
-
});
|
|
1583
|
-
}
|
|
1584
|
-
this._cropPrevEvented = null;
|
|
1585
|
-
this._cropHandlers = [];
|
|
2451
|
+
this._removeCropRect();
|
|
2452
|
+
this._restoreCropObjectState();
|
|
1586
2453
|
this._cropMode = false;
|
|
1587
2454
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
1588
2455
|
this._prevSelectionSetting = void 0;
|
|
@@ -1591,8 +2458,14 @@ var ImageEditor = class {
|
|
|
1591
2458
|
this.canvas.renderAll();
|
|
1592
2459
|
}
|
|
1593
2460
|
/**
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
2461
|
+
* Applies the current crop rectangle to the base image.
|
|
2462
|
+
*
|
|
2463
|
+
* Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
|
|
2464
|
+
* region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
|
|
2465
|
+
* single undoable history transition.
|
|
2466
|
+
*
|
|
2467
|
+
* @async
|
|
2468
|
+
* @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
|
|
1596
2469
|
* @public
|
|
1597
2470
|
*/
|
|
1598
2471
|
async applyCrop() {
|
|
@@ -1600,136 +2473,91 @@ var ImageEditor = class {
|
|
|
1600
2473
|
return;
|
|
1601
2474
|
this._cropRect.setCoords();
|
|
1602
2475
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
1603
|
-
const
|
|
1604
|
-
const
|
|
1605
|
-
|
|
1606
|
-
const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
|
|
2476
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds);
|
|
2477
|
+
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2478
|
+
this._restoreCropObjectState();
|
|
1607
2479
|
let beforeJson = null;
|
|
1608
2480
|
try {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
}
|
|
1613
|
-
beforeJson = JSON.stringify(jsonObj);
|
|
1614
|
-
} catch (e) {
|
|
1615
|
-
this._reportWarning("applyCrop: could not serialize before state", e);
|
|
2481
|
+
beforeJson = this._serializeCanvasState();
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
this._reportWarning("applyCrop: could not serialize before state", error);
|
|
1616
2484
|
beforeJson = null;
|
|
1617
2485
|
}
|
|
2486
|
+
const preservedMasks = [];
|
|
1618
2487
|
try {
|
|
1619
|
-
const masks = this.canvas.getObjects().filter((
|
|
2488
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1620
2489
|
if (masks && masks.length) {
|
|
1621
|
-
masks.forEach((
|
|
2490
|
+
masks.forEach((mask) => {
|
|
1622
2491
|
try {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
this.
|
|
2492
|
+
mask.setCoords();
|
|
2493
|
+
const maskBounds = mask.getBoundingRect(true, true);
|
|
2494
|
+
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;
|
|
2495
|
+
this._removeLabelForMask(mask);
|
|
2496
|
+
this.canvas.remove(mask);
|
|
2497
|
+
if (shouldPreserveMasks && intersectsCrop) {
|
|
2498
|
+
mask.set({
|
|
2499
|
+
left: (mask.left || 0) - cropRegion.sourceX,
|
|
2500
|
+
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2501
|
+
visible: true
|
|
2502
|
+
});
|
|
2503
|
+
mask.setCoords();
|
|
2504
|
+
preservedMasks.push(mask);
|
|
2505
|
+
}
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
this._reportWarning("applyCrop: failed to remove mask", error);
|
|
1627
2508
|
}
|
|
1628
2509
|
});
|
|
1629
|
-
this.
|
|
1630
|
-
this._lastMaskInitialLeft = null;
|
|
1631
|
-
this._lastMaskInitialTop = null;
|
|
1632
|
-
this._lastMaskInitialWidth = null;
|
|
2510
|
+
this._clearMaskPlacementMemory();
|
|
1633
2511
|
this.canvas.discardActiveObject();
|
|
1634
2512
|
this.canvas.renderAll();
|
|
1635
2513
|
}
|
|
1636
|
-
} catch (
|
|
1637
|
-
this._reportWarning("applyCrop: error while removing masks",
|
|
1638
|
-
}
|
|
1639
|
-
try {
|
|
1640
|
-
if (this._cropRect) {
|
|
1641
|
-
try {
|
|
1642
|
-
if (this._cropHandlers && this._cropHandlers.length) {
|
|
1643
|
-
this._cropHandlers.forEach((h) => {
|
|
1644
|
-
h.handlers.forEach((rec) => h.target.off(rec.evt, rec.fn));
|
|
1645
|
-
});
|
|
1646
|
-
}
|
|
1647
|
-
} catch (e) {
|
|
1648
|
-
}
|
|
1649
|
-
try {
|
|
1650
|
-
this.canvas.remove(this._cropRect);
|
|
1651
|
-
} catch (e) {
|
|
1652
|
-
}
|
|
1653
|
-
this._cropRect = null;
|
|
1654
|
-
}
|
|
1655
|
-
} catch (e) {
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
this._reportWarning("applyCrop: error while removing masks", error);
|
|
1656
2516
|
}
|
|
2517
|
+
this._removeCropRect();
|
|
1657
2518
|
this._cropMode = false;
|
|
1658
2519
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
1659
2520
|
this._prevSelectionSetting = void 0;
|
|
1660
2521
|
let croppedBase64;
|
|
1661
2522
|
try {
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
croppedBase64 = await new Promise((resolve, reject) => {
|
|
1668
|
-
const img = new Image();
|
|
1669
|
-
img.onload = () => {
|
|
1670
|
-
try {
|
|
1671
|
-
const oc = document.createElement("canvas");
|
|
1672
|
-
oc.width = sw;
|
|
1673
|
-
oc.height = sh;
|
|
1674
|
-
const ctx = oc.getContext("2d");
|
|
1675
|
-
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
1676
|
-
const out = oc.toDataURL("image/jpeg", this.options.downsampleQuality || 0.92);
|
|
1677
|
-
resolve(out);
|
|
1678
|
-
} catch (err) {
|
|
1679
|
-
reject(err);
|
|
1680
|
-
}
|
|
1681
|
-
};
|
|
1682
|
-
img.onerror = (e) => reject(e);
|
|
1683
|
-
img.src = fullDataUrl;
|
|
2523
|
+
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2524
|
+
...cropRegion,
|
|
2525
|
+
multiplier: 1,
|
|
2526
|
+
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2527
|
+
format: "jpeg"
|
|
1684
2528
|
});
|
|
1685
|
-
} catch (
|
|
1686
|
-
this.
|
|
1687
|
-
this._updateUI();
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error);
|
|
1688
2531
|
return;
|
|
1689
2532
|
}
|
|
1690
2533
|
try {
|
|
1691
2534
|
await this.loadImage(croppedBase64);
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2535
|
+
if (preservedMasks.length) {
|
|
2536
|
+
preservedMasks.forEach((mask) => {
|
|
2537
|
+
this._rebindMaskEvents(mask);
|
|
2538
|
+
this.canvas.add(mask);
|
|
2539
|
+
this.canvas.bringToFront(mask);
|
|
2540
|
+
});
|
|
2541
|
+
this._lastMask = preservedMasks[preservedMasks.length - 1];
|
|
2542
|
+
this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
|
|
2543
|
+
this._updateMaskList();
|
|
2544
|
+
this.canvas.renderAll();
|
|
2545
|
+
}
|
|
2546
|
+
} catch (error) {
|
|
2547
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", error);
|
|
1695
2548
|
return;
|
|
1696
2549
|
}
|
|
1697
2550
|
let afterJson = null;
|
|
1698
2551
|
try {
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
}
|
|
1703
|
-
afterJson = JSON.stringify(jsonObj2);
|
|
1704
|
-
} catch (e) {
|
|
1705
|
-
this._reportWarning("applyCrop: failed to serialize after state", e);
|
|
2552
|
+
afterJson = this._serializeCanvasState();
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
this._reportWarning("applyCrop: failed to serialize after state", error);
|
|
1706
2555
|
afterJson = null;
|
|
1707
2556
|
}
|
|
1708
2557
|
try {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
if (afterJson)
|
|
1713
|
-
self2.loadFromState(afterJson);
|
|
1714
|
-
},
|
|
1715
|
-
() => {
|
|
1716
|
-
if (beforeJson)
|
|
1717
|
-
self2.loadFromState(beforeJson);
|
|
1718
|
-
}
|
|
1719
|
-
);
|
|
1720
|
-
if (!this.historyManager)
|
|
1721
|
-
this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1722
|
-
if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
|
|
1723
|
-
this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
|
|
1724
|
-
}
|
|
1725
|
-
this.historyManager.history.push(cmd);
|
|
1726
|
-
if (this.historyManager.history.length > this.historyManager.maxSize) {
|
|
1727
|
-
this.historyManager.history.shift();
|
|
1728
|
-
} else {
|
|
1729
|
-
this.historyManager.currentIndex++;
|
|
1730
|
-
}
|
|
1731
|
-
} catch (e) {
|
|
1732
|
-
this._reportWarning("applyCrop: failed to push history command", e);
|
|
2558
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
this._reportWarning("applyCrop: failed to push history command", error);
|
|
1733
2561
|
}
|
|
1734
2562
|
this._updateUI();
|
|
1735
2563
|
this.canvas.renderAll();
|
|
@@ -1741,9 +2569,9 @@ var ImageEditor = class {
|
|
|
1741
2569
|
* @private
|
|
1742
2570
|
*/
|
|
1743
2571
|
_updateInputs() {
|
|
1744
|
-
const
|
|
1745
|
-
if (
|
|
1746
|
-
|
|
2572
|
+
const scaleInputElement = document.getElementById(this.elements.scaleRate);
|
|
2573
|
+
if (scaleInputElement)
|
|
2574
|
+
scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
1747
2575
|
}
|
|
1748
2576
|
/**
|
|
1749
2577
|
* Updates the enabled/disabled state of various UI controls (buttons)
|
|
@@ -1751,43 +2579,45 @@ var ImageEditor = class {
|
|
|
1751
2579
|
* @private
|
|
1752
2580
|
*/
|
|
1753
2581
|
_updateUI() {
|
|
1754
|
-
const
|
|
1755
|
-
const masks =
|
|
2582
|
+
const hasImage = !!this.originalImage;
|
|
2583
|
+
const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1756
2584
|
const hasMasks = masks.length > 0;
|
|
1757
|
-
const
|
|
1758
|
-
const hasSelectedMask =
|
|
1759
|
-
const
|
|
2585
|
+
const activeObject = this.canvas.getActiveObject();
|
|
2586
|
+
const hasSelectedMask = activeObject && activeObject.maskId;
|
|
2587
|
+
const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
|
|
1760
2588
|
const canUndo = this.historyManager?.canUndo();
|
|
1761
2589
|
const canRedo = this.historyManager?.canRedo();
|
|
1762
|
-
const
|
|
1763
|
-
if (
|
|
1764
|
-
for (const
|
|
1765
|
-
const
|
|
1766
|
-
if (!
|
|
2590
|
+
const isInCropMode = !!this._cropMode;
|
|
2591
|
+
if (isInCropMode) {
|
|
2592
|
+
for (const key of Object.keys(this.elements || {})) {
|
|
2593
|
+
const element = document.getElementById(this.elements[key]);
|
|
2594
|
+
if (!element)
|
|
1767
2595
|
continue;
|
|
1768
|
-
if (
|
|
1769
|
-
|
|
2596
|
+
if (key === "applyCropBtn" || key === "cancelCropBtn") {
|
|
2597
|
+
this._setDisabled(key, false);
|
|
1770
2598
|
} else {
|
|
1771
|
-
|
|
2599
|
+
this._setDisabled(key, true);
|
|
1772
2600
|
}
|
|
1773
2601
|
}
|
|
1774
2602
|
return;
|
|
1775
2603
|
}
|
|
1776
|
-
this._setDisabled("zoomInBtn", !
|
|
1777
|
-
this._setDisabled("zoomOutBtn", !
|
|
1778
|
-
this._setDisabled("rotateLeftBtn", !
|
|
1779
|
-
this._setDisabled("rotateRightBtn", !
|
|
1780
|
-
this._setDisabled("addMaskBtn", !
|
|
2604
|
+
this._setDisabled("zoomInBtn", !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
|
|
2605
|
+
this._setDisabled("zoomOutBtn", !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
|
|
2606
|
+
this._setDisabled("rotateLeftBtn", !hasImage || this.isAnimating);
|
|
2607
|
+
this._setDisabled("rotateRightBtn", !hasImage || this.isAnimating);
|
|
2608
|
+
this._setDisabled("addMaskBtn", !hasImage || this.isAnimating);
|
|
1781
2609
|
this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
|
|
1782
2610
|
this._setDisabled("removeAllMasksBtn", !hasMasks || this.isAnimating);
|
|
1783
|
-
this._setDisabled("mergeBtn", !
|
|
1784
|
-
this._setDisabled("downloadBtn", !
|
|
1785
|
-
this._setDisabled("resetBtn", !
|
|
1786
|
-
this._setDisabled("undoBtn", !
|
|
1787
|
-
this._setDisabled("redoBtn", !
|
|
1788
|
-
this._setDisabled("cropBtn", !
|
|
2611
|
+
this._setDisabled("mergeBtn", !hasImage || !hasMasks || this.isAnimating);
|
|
2612
|
+
this._setDisabled("downloadBtn", !hasImage || this.isAnimating);
|
|
2613
|
+
this._setDisabled("resetBtn", !hasImage || isDefaultTransform || this.isAnimating);
|
|
2614
|
+
this._setDisabled("undoBtn", !hasImage || this.isAnimating || !canUndo);
|
|
2615
|
+
this._setDisabled("redoBtn", !hasImage || this.isAnimating || !canRedo);
|
|
2616
|
+
this._setDisabled("cropBtn", !hasImage || this.isAnimating);
|
|
1789
2617
|
this._setDisabled("applyCropBtn", true);
|
|
1790
2618
|
this._setDisabled("cancelCropBtn", true);
|
|
2619
|
+
this._setDisabled("imageInput", this.isAnimating);
|
|
2620
|
+
this._setDisabled("uploadArea", this.isAnimating);
|
|
1791
2621
|
}
|
|
1792
2622
|
/**
|
|
1793
2623
|
* Enables or disables a specific UI element (typically a button) by its key.
|
|
@@ -1797,12 +2627,30 @@ var ImageEditor = class {
|
|
|
1797
2627
|
* @private
|
|
1798
2628
|
*/
|
|
1799
2629
|
_setDisabled(key, disabled) {
|
|
1800
|
-
const
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
2630
|
+
const element = document.getElementById(this.elements[key]);
|
|
2631
|
+
if (!element)
|
|
2632
|
+
return;
|
|
2633
|
+
if ("disabled" in element) {
|
|
2634
|
+
element.disabled = !!disabled;
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (disabled) {
|
|
2638
|
+
element.setAttribute("aria-disabled", "true");
|
|
2639
|
+
element.style.pointerEvents = "none";
|
|
2640
|
+
} else {
|
|
2641
|
+
element.removeAttribute("aria-disabled");
|
|
2642
|
+
element.style.pointerEvents = "";
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
_isElementDisabled(element) {
|
|
2646
|
+
if (!element)
|
|
2647
|
+
return false;
|
|
2648
|
+
if ("disabled" in element)
|
|
2649
|
+
return !!element.disabled;
|
|
2650
|
+
return element.getAttribute("aria-disabled") === "true";
|
|
1803
2651
|
}
|
|
1804
2652
|
/**
|
|
1805
|
-
*
|
|
2653
|
+
* Updates placeholder and canvas container visibility based on whether an image is loaded.
|
|
1806
2654
|
* @private
|
|
1807
2655
|
*/
|
|
1808
2656
|
_updatePlaceholderStatus() {
|
|
@@ -1811,21 +2659,22 @@ var ImageEditor = class {
|
|
|
1811
2659
|
this._setPlaceholderVisible(!this.originalImage);
|
|
1812
2660
|
}
|
|
1813
2661
|
/**
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
2662
|
+
* Shows or hides the placeholder and canvas container.
|
|
2663
|
+
*
|
|
2664
|
+
* @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
|
|
1816
2665
|
* @private
|
|
1817
2666
|
*/
|
|
1818
2667
|
_setPlaceholderVisible(show) {
|
|
1819
|
-
if (!this.
|
|
2668
|
+
if (!this.placeholderElement || !this.containerElement)
|
|
1820
2669
|
return;
|
|
1821
2670
|
if (show) {
|
|
1822
|
-
this.
|
|
1823
|
-
this.
|
|
1824
|
-
this.
|
|
2671
|
+
this.placeholderElement.classList.remove("d-none");
|
|
2672
|
+
this.placeholderElement.classList.add("d-flex");
|
|
2673
|
+
this.containerElement.classList.add("d-none");
|
|
1825
2674
|
} else {
|
|
1826
|
-
this.
|
|
1827
|
-
this.
|
|
1828
|
-
this.
|
|
2675
|
+
this.placeholderElement.classList.remove("d-flex");
|
|
2676
|
+
this.placeholderElement.classList.add("d-none");
|
|
2677
|
+
this.containerElement.classList.remove("d-none");
|
|
1829
2678
|
}
|
|
1830
2679
|
}
|
|
1831
2680
|
/**
|
|
@@ -1835,89 +2684,93 @@ var ImageEditor = class {
|
|
|
1835
2684
|
*/
|
|
1836
2685
|
dispose() {
|
|
1837
2686
|
try {
|
|
1838
|
-
for (const key in this.
|
|
1839
|
-
const handlers = this.
|
|
1840
|
-
const
|
|
1841
|
-
if (!
|
|
2687
|
+
for (const key in this._handlersByElementKey || {}) {
|
|
2688
|
+
const handlers = this._handlersByElementKey[key] || [];
|
|
2689
|
+
const element = document.getElementById(this.elements[key]);
|
|
2690
|
+
if (!element)
|
|
1842
2691
|
continue;
|
|
1843
|
-
handlers.forEach((
|
|
2692
|
+
handlers.forEach((handlerRecord) => {
|
|
1844
2693
|
try {
|
|
1845
|
-
|
|
1846
|
-
} catch (
|
|
2694
|
+
element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
|
|
2695
|
+
} catch (error) {
|
|
1847
2696
|
}
|
|
1848
2697
|
});
|
|
1849
2698
|
}
|
|
1850
|
-
} catch (
|
|
2699
|
+
} catch (error) {
|
|
1851
2700
|
}
|
|
1852
2701
|
if (this._cropRect) {
|
|
1853
2702
|
try {
|
|
1854
2703
|
this.canvas.remove(this._cropRect);
|
|
1855
|
-
} catch (
|
|
2704
|
+
} catch (error) {
|
|
1856
2705
|
}
|
|
1857
2706
|
this._cropRect = null;
|
|
1858
2707
|
}
|
|
2708
|
+
if (this.containerElement && this._containerOriginalOverflow !== void 0) {
|
|
2709
|
+
try {
|
|
2710
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
2711
|
+
} catch (error) {
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
1859
2714
|
if (this.canvas) {
|
|
1860
2715
|
try {
|
|
1861
2716
|
this.canvas.dispose();
|
|
1862
|
-
} catch (
|
|
2717
|
+
} catch (error) {
|
|
1863
2718
|
}
|
|
1864
2719
|
this.canvas = null;
|
|
1865
|
-
this.
|
|
2720
|
+
this.canvasElement = null;
|
|
1866
2721
|
this.isImageLoadedToCanvas = false;
|
|
1867
2722
|
}
|
|
1868
|
-
this.
|
|
2723
|
+
this._handlersByElementKey = {};
|
|
1869
2724
|
}
|
|
1870
2725
|
};
|
|
1871
2726
|
var AnimationQueue = class {
|
|
1872
2727
|
/**
|
|
1873
|
-
* Creates
|
|
1874
|
-
*
|
|
1875
|
-
* @constructor
|
|
2728
|
+
* Creates an empty animation queue.
|
|
1876
2729
|
*/
|
|
1877
2730
|
constructor() {
|
|
1878
|
-
this.
|
|
1879
|
-
this.
|
|
2731
|
+
this.animationTasks = [];
|
|
2732
|
+
this.isRunning = false;
|
|
1880
2733
|
}
|
|
1881
2734
|
/**
|
|
1882
2735
|
* Adds an animation function to the queue.
|
|
1883
2736
|
*
|
|
1884
|
-
* @param
|
|
1885
|
-
* @returns {Promise
|
|
2737
|
+
* @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
|
|
2738
|
+
* @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
|
|
1886
2739
|
*/
|
|
1887
2740
|
async add(animationFn) {
|
|
1888
2741
|
return new Promise((resolve, reject) => {
|
|
1889
|
-
this.
|
|
1890
|
-
if (!this.
|
|
1891
|
-
this.
|
|
2742
|
+
this.animationTasks.push({ animationFn, resolve, reject });
|
|
2743
|
+
if (!this.isRunning) {
|
|
2744
|
+
this._drainQueue();
|
|
1892
2745
|
}
|
|
1893
2746
|
});
|
|
1894
2747
|
}
|
|
1895
2748
|
/**
|
|
1896
|
-
*
|
|
2749
|
+
* Runs queued animation tasks sequentially until the queue is empty.
|
|
1897
2750
|
*
|
|
1898
2751
|
* @private
|
|
1899
2752
|
* @returns {Promise<void>}
|
|
1900
2753
|
*/
|
|
1901
|
-
async
|
|
1902
|
-
if (this.
|
|
1903
|
-
this.
|
|
2754
|
+
async _drainQueue() {
|
|
2755
|
+
if (this.animationTasks.length === 0) {
|
|
2756
|
+
this.isRunning = false;
|
|
1904
2757
|
return;
|
|
1905
2758
|
}
|
|
1906
|
-
this.
|
|
1907
|
-
const {
|
|
2759
|
+
this.isRunning = true;
|
|
2760
|
+
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
1908
2761
|
try {
|
|
1909
|
-
const result = await
|
|
2762
|
+
const result = await animationFn();
|
|
1910
2763
|
resolve(result);
|
|
1911
2764
|
} catch (error) {
|
|
1912
2765
|
reject(error);
|
|
1913
2766
|
}
|
|
1914
|
-
this.
|
|
2767
|
+
await this._drainQueue();
|
|
1915
2768
|
}
|
|
1916
2769
|
};
|
|
1917
2770
|
var Command = class {
|
|
1918
2771
|
/**
|
|
1919
|
-
* @param {
|
|
1920
|
-
* @param {
|
|
2772
|
+
* @param {HistoryTaskCallback} execute - Function that performs the action.
|
|
2773
|
+
* @param {HistoryTaskCallback} undo - Function that reverts the action.
|
|
1921
2774
|
*/
|
|
1922
2775
|
constructor(execute, undo) {
|
|
1923
2776
|
this.execute = execute;
|
|
@@ -1926,12 +2779,32 @@ var Command = class {
|
|
|
1926
2779
|
};
|
|
1927
2780
|
var HistoryManager = class {
|
|
1928
2781
|
/**
|
|
1929
|
-
* @param {number} [maxSize=50]
|
|
2782
|
+
* @param {number} [maxSize=50] - Maximum number of commands to keep in history.
|
|
1930
2783
|
*/
|
|
1931
2784
|
constructor(maxSize = 50) {
|
|
1932
2785
|
this.history = [];
|
|
1933
2786
|
this.currentIndex = -1;
|
|
1934
2787
|
this.maxSize = maxSize;
|
|
2788
|
+
this.pending = Promise.resolve();
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* Queues a history task after the previously queued undo/redo task completes.
|
|
2792
|
+
*
|
|
2793
|
+
* @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
|
|
2794
|
+
* @returns {Promise<void>} Resolves or rejects with the queued task result.
|
|
2795
|
+
* @private
|
|
2796
|
+
*/
|
|
2797
|
+
enqueue(task) {
|
|
2798
|
+
const nextTask = this.pending.then(task, task);
|
|
2799
|
+
let pendingAfterTask;
|
|
2800
|
+
const resetPending = () => {
|
|
2801
|
+
if (this.pending === pendingAfterTask) {
|
|
2802
|
+
this.pending = Promise.resolve();
|
|
2803
|
+
}
|
|
2804
|
+
};
|
|
2805
|
+
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
2806
|
+
this.pending = pendingAfterTask;
|
|
2807
|
+
return nextTask;
|
|
1935
2808
|
}
|
|
1936
2809
|
/**
|
|
1937
2810
|
* Executes a new command and pushes it onto the history stack.
|
|
@@ -1942,6 +2815,16 @@ var HistoryManager = class {
|
|
|
1942
2815
|
*/
|
|
1943
2816
|
execute(command) {
|
|
1944
2817
|
command.execute();
|
|
2818
|
+
this.push(command);
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Pushes an already-applied command onto the history stack.
|
|
2822
|
+
* Truncates any "future" history when branching.
|
|
2823
|
+
*
|
|
2824
|
+
* @param {Command} command The command to push.
|
|
2825
|
+
* @returns {void}
|
|
2826
|
+
*/
|
|
2827
|
+
push(command) {
|
|
1945
2828
|
if (this.currentIndex < this.history.length - 1) {
|
|
1946
2829
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
1947
2830
|
}
|
|
@@ -1971,24 +2854,30 @@ var HistoryManager = class {
|
|
|
1971
2854
|
/**
|
|
1972
2855
|
* Undoes the last executed command if possible.
|
|
1973
2856
|
*
|
|
1974
|
-
* @returns {void}
|
|
2857
|
+
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
1975
2858
|
*/
|
|
1976
2859
|
undo() {
|
|
1977
|
-
|
|
1978
|
-
this.
|
|
1979
|
-
|
|
1980
|
-
|
|
2860
|
+
return this.enqueue(async () => {
|
|
2861
|
+
if (this.currentIndex >= 0) {
|
|
2862
|
+
const index = this.currentIndex;
|
|
2863
|
+
await this.history[index].undo();
|
|
2864
|
+
this.currentIndex = index - 1;
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
1981
2867
|
}
|
|
1982
2868
|
/**
|
|
1983
2869
|
* Redoes the next command in history if possible.
|
|
1984
2870
|
*
|
|
1985
|
-
* @returns {void}
|
|
2871
|
+
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
1986
2872
|
*/
|
|
1987
2873
|
redo() {
|
|
1988
|
-
|
|
1989
|
-
this.currentIndex
|
|
1990
|
-
|
|
1991
|
-
|
|
2874
|
+
return this.enqueue(async () => {
|
|
2875
|
+
if (this.currentIndex < this.history.length - 1) {
|
|
2876
|
+
const index = this.currentIndex + 1;
|
|
2877
|
+
await this.history[index].execute();
|
|
2878
|
+
this.currentIndex = index;
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
1992
2881
|
}
|
|
1993
2882
|
};
|
|
1994
2883
|
var image_editor_default = ImageEditor;
|