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