@bensitu/image-editor 1.2.1 → 1.2.2
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 +12 -26
- package/dist/image-editor.esm.js +1325 -763
- 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 +1325 -763
- package/dist/image-editor.esm.mjs.map +3 -3
- package/dist/image-editor.js +1325 -763
- 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 +29 -7
- package/package.json +1 -1
- package/src/image-editor.js +1344 -729
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.2.
|
|
6
|
+
* @version 1.2.2
|
|
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() {
|
|
@@ -113,9 +108,9 @@
|
|
|
113
108
|
this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
|
|
114
109
|
}
|
|
115
110
|
this.canvas = null;
|
|
116
|
-
this.
|
|
117
|
-
this.
|
|
118
|
-
this.
|
|
111
|
+
this.canvasElement = null;
|
|
112
|
+
this.containerElement = null;
|
|
113
|
+
this.placeholderElement = null;
|
|
119
114
|
this.originalImage = null;
|
|
120
115
|
this.baseImageScale = 1;
|
|
121
116
|
this.currentScale = 1;
|
|
@@ -125,18 +120,49 @@
|
|
|
125
120
|
this.elements = {};
|
|
126
121
|
this.isImageLoadedToCanvas = false;
|
|
127
122
|
this.maxHistorySize = 50;
|
|
128
|
-
this.
|
|
123
|
+
this._handlersByElementKey = {};
|
|
129
124
|
this._lastMask = null;
|
|
130
125
|
this._lastMaskInitialLeft = null;
|
|
131
126
|
this._lastMaskInitialTop = null;
|
|
132
127
|
this._lastMaskInitialWidth = null;
|
|
128
|
+
this._lastSnapshot = null;
|
|
133
129
|
this._cropMode = false;
|
|
134
130
|
this._cropRect = null;
|
|
135
131
|
this._cropHandlers = [];
|
|
132
|
+
this._cropPrevEvented = null;
|
|
133
|
+
this._prevSelectionSetting = void 0;
|
|
134
|
+
this._containerOriginalOverflow = void 0;
|
|
136
135
|
this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
|
|
137
136
|
this.animQueue = new AnimationQueue();
|
|
138
137
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
139
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* @deprecated Use canvasElement instead.
|
|
141
|
+
*/
|
|
142
|
+
get canvasEl() {
|
|
143
|
+
return this.canvasElement;
|
|
144
|
+
}
|
|
145
|
+
set canvasEl(value) {
|
|
146
|
+
this.canvasElement = value;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* @deprecated Use containerElement instead.
|
|
150
|
+
*/
|
|
151
|
+
get containerEl() {
|
|
152
|
+
return this.containerElement;
|
|
153
|
+
}
|
|
154
|
+
set containerEl(value) {
|
|
155
|
+
this.containerElement = value;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* @deprecated Use placeholderElement instead.
|
|
159
|
+
*/
|
|
160
|
+
get placeholderEl() {
|
|
161
|
+
return this.placeholderElement;
|
|
162
|
+
}
|
|
163
|
+
set placeholderEl(value) {
|
|
164
|
+
this.placeholderElement = value;
|
|
165
|
+
}
|
|
140
166
|
/**
|
|
141
167
|
* Initializes the editor, binds to DOM elements, sets up event handlers,
|
|
142
168
|
* and (optionally) loads an initial image.
|
|
@@ -221,127 +247,163 @@
|
|
|
221
247
|
* @private
|
|
222
248
|
*/
|
|
223
249
|
_initCanvas() {
|
|
224
|
-
const
|
|
225
|
-
if (!
|
|
250
|
+
const canvasElement = document.getElementById(this.elements.canvas);
|
|
251
|
+
if (!canvasElement)
|
|
226
252
|
throw new Error("Canvas is not found: " + this.elements.canvas);
|
|
227
|
-
this.
|
|
253
|
+
this.canvasElement = canvasElement;
|
|
228
254
|
if (this.elements.canvasContainer) {
|
|
229
|
-
const
|
|
230
|
-
this.
|
|
255
|
+
const containerElement = document.getElementById(this.elements.canvasContainer);
|
|
256
|
+
this.containerElement = containerElement || canvasElement.parentElement;
|
|
231
257
|
} else {
|
|
232
|
-
this.
|
|
258
|
+
this.containerElement = canvasElement.parentElement;
|
|
233
259
|
}
|
|
234
|
-
this.
|
|
235
|
-
let
|
|
236
|
-
let
|
|
237
|
-
if (this.
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
|
|
261
|
+
let initialWidth = this.options.canvasWidth;
|
|
262
|
+
let initialHeight = this.options.canvasHeight;
|
|
263
|
+
if (this.containerElement) {
|
|
264
|
+
const containerWidth = Math.floor(this.containerElement.clientWidth);
|
|
265
|
+
const containerHeight = Math.floor(this.containerElement.clientHeight);
|
|
266
|
+
if (containerWidth > 0 && containerHeight > 0) {
|
|
267
|
+
initialWidth = containerWidth;
|
|
268
|
+
initialHeight = containerHeight;
|
|
243
269
|
}
|
|
244
270
|
}
|
|
245
|
-
this.canvas = new fabric.Canvas(
|
|
246
|
-
width:
|
|
247
|
-
height:
|
|
271
|
+
this.canvas = new fabric.Canvas(canvasElement, {
|
|
272
|
+
width: initialWidth,
|
|
273
|
+
height: initialHeight,
|
|
248
274
|
backgroundColor: this.options.backgroundColor,
|
|
249
275
|
selection: this.options.groupSelection,
|
|
250
276
|
preserveObjectStacking: true
|
|
251
277
|
});
|
|
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(
|
|
278
|
+
this.canvas.on("selection:created", (event) => this._handleSelectionChanged(event.selected));
|
|
279
|
+
this.canvas.on("selection:updated", (event) => this._handleSelectionChanged(event.selected));
|
|
280
|
+
this.canvas.on("selection:cleared", () => this._handleSelectionChanged([]));
|
|
281
|
+
this.canvas.on("object:moving", (event) => {
|
|
282
|
+
if (event.target && event.target.maskId)
|
|
283
|
+
this._syncMaskLabel(event.target);
|
|
258
284
|
});
|
|
259
|
-
this.canvas.on("object:scaling", (
|
|
260
|
-
if (
|
|
261
|
-
this._syncMaskLabel(
|
|
285
|
+
this.canvas.on("object:scaling", (event) => {
|
|
286
|
+
if (event.target && event.target.maskId)
|
|
287
|
+
this._syncMaskLabel(event.target);
|
|
262
288
|
});
|
|
263
|
-
this.canvas.on("object:rotating", (
|
|
264
|
-
if (
|
|
265
|
-
this._syncMaskLabel(
|
|
289
|
+
this.canvas.on("object:rotating", (event) => {
|
|
290
|
+
if (event.target && event.target.maskId)
|
|
291
|
+
this._syncMaskLabel(event.target);
|
|
266
292
|
});
|
|
267
|
-
this.canvas.on("object:modified", (
|
|
268
|
-
|
|
269
|
-
|
|
293
|
+
this.canvas.on("object:modified", (event) => this._handleObjectModified(event.target));
|
|
294
|
+
this.canvasElement.style.display = "block";
|
|
295
|
+
}
|
|
296
|
+
_handleObjectModified(target) {
|
|
297
|
+
const masks = this._getModifiedMasks(target);
|
|
298
|
+
if (!masks.length)
|
|
299
|
+
return;
|
|
300
|
+
masks.forEach((mask) => {
|
|
301
|
+
if (typeof mask.setCoords === "function")
|
|
302
|
+
mask.setCoords();
|
|
303
|
+
this._syncMaskLabel(mask);
|
|
304
|
+
this._expandCanvasToFitObject(mask);
|
|
270
305
|
});
|
|
271
|
-
this.
|
|
306
|
+
this.saveState();
|
|
307
|
+
}
|
|
308
|
+
_getModifiedMasks(target) {
|
|
309
|
+
if (!target)
|
|
310
|
+
return [];
|
|
311
|
+
if (target.maskId)
|
|
312
|
+
return [target];
|
|
313
|
+
const objects = typeof target.getObjects === "function" ? target.getObjects() : [];
|
|
314
|
+
return Array.isArray(objects) ? objects.filter((object) => object && object.maskId) : [];
|
|
315
|
+
}
|
|
316
|
+
_syncContainerOverflow() {
|
|
317
|
+
if (!this.containerElement || !this.containerElement.style)
|
|
318
|
+
return;
|
|
319
|
+
if (this._containerOriginalOverflow === void 0) {
|
|
320
|
+
this._containerOriginalOverflow = this.containerElement.style.overflow || "";
|
|
321
|
+
}
|
|
322
|
+
if (this.options.coverImageToCanvas) {
|
|
323
|
+
const shouldResetScroll = !this.isImageLoadedToCanvas;
|
|
324
|
+
this.containerElement.style.overflow = "scroll";
|
|
325
|
+
if (shouldResetScroll) {
|
|
326
|
+
this.containerElement.scrollLeft = 0;
|
|
327
|
+
this.containerElement.scrollTop = 0;
|
|
328
|
+
}
|
|
329
|
+
} else if (this.options.fitImageToCanvas) {
|
|
330
|
+
this.containerElement.style.overflow = "auto";
|
|
331
|
+
this.containerElement.scrollLeft = 0;
|
|
332
|
+
this.containerElement.scrollTop = 0;
|
|
333
|
+
} else {
|
|
334
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
335
|
+
}
|
|
272
336
|
}
|
|
273
337
|
/**
|
|
274
338
|
* DOM / UI bindings
|
|
275
339
|
* @private
|
|
276
340
|
*/
|
|
277
341
|
_bindEvents() {
|
|
278
|
-
this._bindIfExists("uploadArea", "click", () =>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
342
|
+
this._bindIfExists("uploadArea", "click", () => {
|
|
343
|
+
const uploadAreaElement = document.getElementById(this.elements.uploadArea);
|
|
344
|
+
if (this._isElementDisabled(uploadAreaElement))
|
|
345
|
+
return;
|
|
346
|
+
document.getElementById(this.elements.imageInput)?.click();
|
|
347
|
+
});
|
|
348
|
+
this._bindIfExists("imageInput", "change", (event) => {
|
|
349
|
+
const file = event.target.files && event.target.files[0];
|
|
350
|
+
if (file)
|
|
351
|
+
this._loadImageFile(file);
|
|
352
|
+
});
|
|
287
353
|
this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
|
|
288
354
|
this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
|
|
289
355
|
this._bindIfExists("resetBtn", "click", () => {
|
|
290
|
-
this.
|
|
356
|
+
this.resetImageTransform();
|
|
291
357
|
});
|
|
292
|
-
this._bindIfExists("addMaskBtn", "click", () => this.
|
|
358
|
+
this._bindIfExists("addMaskBtn", "click", () => this.createMask());
|
|
293
359
|
this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
|
|
294
360
|
this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
|
|
295
|
-
this._bindIfExists("mergeBtn", "click", () => this.
|
|
361
|
+
this._bindIfExists("mergeBtn", "click", () => this.mergeMasks());
|
|
296
362
|
this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
|
|
297
363
|
this._bindIfExists("undoBtn", "click", () => this.undo());
|
|
298
364
|
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
|
-
});
|
|
365
|
+
this._bindIfExists("rotateLeftBtn", "click", () => {
|
|
366
|
+
const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
|
|
367
|
+
let step = this.options.rotationStep;
|
|
368
|
+
if (rotationInputElement) {
|
|
369
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
370
|
+
if (!isNaN(parsedStep))
|
|
371
|
+
step = parsedStep;
|
|
372
|
+
}
|
|
373
|
+
this.rotateImage(this.currentRotation - step);
|
|
374
|
+
});
|
|
375
|
+
this._bindIfExists("rotateRightBtn", "click", () => {
|
|
376
|
+
const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
|
|
377
|
+
let step = this.options.rotationStep;
|
|
378
|
+
if (rotationInputElement) {
|
|
379
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
380
|
+
if (!isNaN(parsedStep))
|
|
381
|
+
step = parsedStep;
|
|
382
|
+
}
|
|
383
|
+
this.rotateImage(this.currentRotation + step);
|
|
384
|
+
});
|
|
323
385
|
this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
|
|
324
386
|
this._bindIfExists("applyCropBtn", "click", () => {
|
|
325
|
-
this.applyCrop().catch((
|
|
387
|
+
this.applyCrop().catch((error) => this._reportError("applyCrop failed", error));
|
|
326
388
|
});
|
|
327
389
|
this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
|
|
328
390
|
}
|
|
329
391
|
/**
|
|
330
392
|
* Event binding element check
|
|
331
393
|
*
|
|
332
|
-
* @param {*}
|
|
394
|
+
* @param {*} eventName
|
|
333
395
|
* @param {*} handler
|
|
334
396
|
* @param {*} key
|
|
335
397
|
* @private
|
|
336
398
|
*/
|
|
337
|
-
_bindIfExists(key,
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
this.
|
|
342
|
-
if (!this.
|
|
343
|
-
this.
|
|
344
|
-
this.
|
|
399
|
+
_bindIfExists(key, eventName, handler) {
|
|
400
|
+
const element = document.getElementById(this.elements[key]);
|
|
401
|
+
if (element) {
|
|
402
|
+
element.addEventListener(eventName, handler);
|
|
403
|
+
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
404
|
+
if (!this._handlersByElementKey[key])
|
|
405
|
+
this._handlersByElementKey[key] = [];
|
|
406
|
+
this._handlersByElementKey[key].push({ eventName, handler });
|
|
345
407
|
}
|
|
346
408
|
}
|
|
347
409
|
/**
|
|
@@ -354,88 +416,88 @@
|
|
|
354
416
|
if (!file || !file.type.startsWith("image/"))
|
|
355
417
|
return;
|
|
356
418
|
const reader = new FileReader();
|
|
357
|
-
reader.onload = (
|
|
358
|
-
reader.onerror = (
|
|
359
|
-
this._reportError("Image file could not be read",
|
|
419
|
+
reader.onload = (event) => this.loadImage(event.target.result);
|
|
420
|
+
reader.onerror = (event) => {
|
|
421
|
+
this._reportError("Image file could not be read", event);
|
|
360
422
|
};
|
|
361
423
|
reader.readAsDataURL(file);
|
|
362
424
|
}
|
|
363
425
|
/**
|
|
364
426
|
* Load a base64 encoded image string into fabric.
|
|
365
427
|
* @async
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
async loadImage(
|
|
428
|
+
* @param {String} imageBase64
|
|
429
|
+
*/
|
|
430
|
+
async loadImage(imageBase64) {
|
|
369
431
|
if (!this._fabricLoaded)
|
|
370
432
|
return;
|
|
371
433
|
if (!this.canvas)
|
|
372
434
|
return;
|
|
373
|
-
if (!
|
|
435
|
+
if (!imageBase64 || typeof imageBase64 !== "string" || !imageBase64.startsWith("data:image/"))
|
|
374
436
|
return;
|
|
375
437
|
this._setPlaceholderVisible(false);
|
|
376
|
-
|
|
377
|
-
|
|
438
|
+
this._syncContainerOverflow();
|
|
439
|
+
const imageElement = await this._createImageElement(imageBase64);
|
|
440
|
+
let loadSource = imageBase64;
|
|
378
441
|
if (this.options.downsampleOnLoad) {
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
442
|
+
const shouldResize = imageElement.naturalWidth > this.options.downsampleMaxWidth || imageElement.naturalHeight > this.options.downsampleMaxHeight;
|
|
443
|
+
if (shouldResize) {
|
|
381
444
|
const ratio = Math.min(
|
|
382
|
-
this.options.downsampleMaxWidth /
|
|
383
|
-
this.options.downsampleMaxHeight /
|
|
445
|
+
this.options.downsampleMaxWidth / imageElement.naturalWidth,
|
|
446
|
+
this.options.downsampleMaxHeight / imageElement.naturalHeight
|
|
384
447
|
);
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
|
|
448
|
+
const targetWidth = Math.round(imageElement.naturalWidth * ratio);
|
|
449
|
+
const targetHeight = Math.round(imageElement.naturalHeight * ratio);
|
|
450
|
+
loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
|
|
388
451
|
}
|
|
389
452
|
}
|
|
390
453
|
return new Promise((resolve, reject) => {
|
|
391
|
-
fabric.Image.fromURL(
|
|
454
|
+
fabric.Image.fromURL(loadSource, (fabricImage) => {
|
|
392
455
|
try {
|
|
393
|
-
if (!
|
|
456
|
+
if (!fabricImage)
|
|
394
457
|
throw new Error("Image could not be loaded");
|
|
395
458
|
this.canvas.discardActiveObject();
|
|
396
459
|
this._hideAllMaskLabels();
|
|
397
460
|
this.canvas.clear();
|
|
398
461
|
this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
const
|
|
462
|
+
fabricImage.set({ originX: "left", originY: "top", selectable: false, evented: false });
|
|
463
|
+
const imageWidth = fabricImage.width;
|
|
464
|
+
const imageHeight = fabricImage.height;
|
|
465
|
+
const viewport = this._getContainerViewportSize();
|
|
466
|
+
const minWidth = viewport.width;
|
|
467
|
+
const minHeight = viewport.height;
|
|
404
468
|
if (this.options.fitImageToCanvas) {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
this._setCanvasSizeInt(
|
|
408
|
-
const fitScale = Math.min(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
this.baseImageScale =
|
|
469
|
+
const canvasWidth = Math.max(1, Math.min(this.options.canvasWidth, minWidth) - 1);
|
|
470
|
+
const canvasHeight = Math.max(1, Math.min(this.options.canvasHeight, minHeight) - 1);
|
|
471
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
472
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
473
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
474
|
+
fabricImage.scale(fitScale);
|
|
475
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
412
476
|
} else if (this.options.coverImageToCanvas) {
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
fimg.scale(coverScale);
|
|
419
|
-
this.baseImageScale = fimg.scaleX || 1;
|
|
477
|
+
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
478
|
+
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
479
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
480
|
+
fabricImage.scale(layout.scale);
|
|
481
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
420
482
|
} else if (this.options.expandCanvasToImage) {
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
this._setCanvasSizeInt(
|
|
424
|
-
|
|
425
|
-
|
|
483
|
+
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
484
|
+
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
485
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
486
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
487
|
+
fabricImage.scale(1);
|
|
426
488
|
this.baseImageScale = 1;
|
|
427
489
|
} else {
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
this._setCanvasSizeInt(
|
|
431
|
-
const fitScale = Math.min(
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
this.baseImageScale =
|
|
490
|
+
const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
|
|
491
|
+
const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
|
|
492
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
493
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
494
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
495
|
+
fabricImage.scale(fitScale);
|
|
496
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
435
497
|
}
|
|
436
|
-
this.originalImage =
|
|
437
|
-
this.canvas.add(
|
|
438
|
-
this.canvas.sendToBack(
|
|
498
|
+
this.originalImage = fabricImage;
|
|
499
|
+
this.canvas.add(fabricImage);
|
|
500
|
+
this.canvas.sendToBack(fabricImage);
|
|
439
501
|
this._lastMask = null;
|
|
440
502
|
this._lastMaskInitialLeft = null;
|
|
441
503
|
this._lastMaskInitialTop = null;
|
|
@@ -448,12 +510,17 @@
|
|
|
448
510
|
this.isImageLoadedToCanvas = true;
|
|
449
511
|
this._updateUI();
|
|
450
512
|
this.canvas.renderAll();
|
|
513
|
+
try {
|
|
514
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this._reportWarning("loadImage: failed to capture initial canvas snapshot", error);
|
|
517
|
+
}
|
|
451
518
|
if (typeof this.onImageLoaded === "function") {
|
|
452
519
|
this.onImageLoaded();
|
|
453
520
|
}
|
|
454
521
|
resolve();
|
|
455
|
-
} catch (
|
|
456
|
-
reject(
|
|
522
|
+
} catch (error) {
|
|
523
|
+
reject(error);
|
|
457
524
|
}
|
|
458
525
|
}, { crossOrigin: "anonymous" });
|
|
459
526
|
});
|
|
@@ -469,43 +536,43 @@
|
|
|
469
536
|
/**
|
|
470
537
|
* Creates an HTMLImageElement from a given data URL.
|
|
471
538
|
*
|
|
472
|
-
* @param {string}
|
|
539
|
+
* @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
|
|
473
540
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
474
541
|
* @private
|
|
475
542
|
*/
|
|
476
|
-
_createImageElement(
|
|
477
|
-
return new Promise((
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
543
|
+
_createImageElement(dataUrl) {
|
|
544
|
+
return new Promise((resolve, reject) => {
|
|
545
|
+
const imageElement = new Image();
|
|
546
|
+
imageElement.onload = () => {
|
|
547
|
+
imageElement.onload = null;
|
|
548
|
+
imageElement.onerror = null;
|
|
549
|
+
resolve(imageElement);
|
|
483
550
|
};
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
551
|
+
imageElement.onerror = (error) => {
|
|
552
|
+
imageElement.onload = null;
|
|
553
|
+
imageElement.onerror = null;
|
|
554
|
+
reject(error);
|
|
488
555
|
};
|
|
489
|
-
|
|
556
|
+
imageElement.src = dataUrl;
|
|
490
557
|
});
|
|
491
558
|
}
|
|
492
559
|
/**
|
|
493
560
|
* Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
|
|
494
561
|
*
|
|
495
|
-
* @param {HTMLImageElement}
|
|
496
|
-
* @param {number}
|
|
497
|
-
* @param {number}
|
|
562
|
+
* @param {HTMLImageElement} imageElement - The image element to resample.
|
|
563
|
+
* @param {number} targetWidth - Target width (in pixels) for the resampled image.
|
|
564
|
+
* @param {number} targetHeight - Target height (in pixels) for the resampled image.
|
|
498
565
|
* @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
|
|
499
566
|
* @returns {string} A data URL representing the resampled image as JPEG.
|
|
500
567
|
* @private
|
|
501
568
|
*/
|
|
502
|
-
_resampleImageToDataURL(
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
return
|
|
569
|
+
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
|
|
570
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
571
|
+
offscreenCanvas.width = targetWidth;
|
|
572
|
+
offscreenCanvas.height = targetHeight;
|
|
573
|
+
const context = offscreenCanvas.getContext("2d");
|
|
574
|
+
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
575
|
+
return offscreenCanvas.toDataURL("image/jpeg", quality);
|
|
509
576
|
}
|
|
510
577
|
/**
|
|
511
578
|
* Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
|
|
@@ -522,61 +589,369 @@
|
|
|
522
589
|
this.canvas.setHeight(ih);
|
|
523
590
|
if (typeof this.canvas.calcOffset === "function")
|
|
524
591
|
this.canvas.calcOffset();
|
|
525
|
-
if (this.
|
|
526
|
-
this.
|
|
527
|
-
this.
|
|
528
|
-
this.
|
|
592
|
+
if (this.canvasElement) {
|
|
593
|
+
this.canvasElement.style.width = iw + "px";
|
|
594
|
+
this.canvasElement.style.height = ih + "px";
|
|
595
|
+
this.canvasElement.style.maxWidth = "none";
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
_ceilCanvasDimension(value) {
|
|
599
|
+
const numericValue = Number(value) || 0;
|
|
600
|
+
const roundedValue = Math.round(numericValue);
|
|
601
|
+
if (Math.abs(numericValue - roundedValue) < 0.01)
|
|
602
|
+
return roundedValue;
|
|
603
|
+
return Math.ceil(numericValue);
|
|
604
|
+
}
|
|
605
|
+
_getContainerViewportSize() {
|
|
606
|
+
if (!this.containerElement) {
|
|
607
|
+
return {
|
|
608
|
+
width: Math.max(1, Math.floor(this.options.canvasWidth || 1)),
|
|
609
|
+
height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
613
|
+
return {
|
|
614
|
+
width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
|
|
615
|
+
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const previousOverflow = this.containerElement.style.overflow;
|
|
619
|
+
this.containerElement.style.overflow = "hidden";
|
|
620
|
+
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
621
|
+
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
622
|
+
this.containerElement.style.overflow = previousOverflow;
|
|
623
|
+
return { width, height };
|
|
624
|
+
}
|
|
625
|
+
_hasFixedContainerScrollbars() {
|
|
626
|
+
if (!this.containerElement)
|
|
627
|
+
return false;
|
|
628
|
+
const inlineOverflow = this.containerElement.style.overflow;
|
|
629
|
+
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
630
|
+
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
631
|
+
let computedOverflow = "";
|
|
632
|
+
let computedOverflowX = "";
|
|
633
|
+
let computedOverflowY = "";
|
|
634
|
+
if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
|
|
635
|
+
const style = window.getComputedStyle(this.containerElement);
|
|
636
|
+
computedOverflow = style.overflow;
|
|
637
|
+
computedOverflowX = style.overflowX;
|
|
638
|
+
computedOverflowY = style.overflowY;
|
|
639
|
+
}
|
|
640
|
+
return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY].some((value) => value === "scroll");
|
|
641
|
+
}
|
|
642
|
+
_getScrollbarSize() {
|
|
643
|
+
if (typeof document === "undefined" || !document.createElement || !document.body) {
|
|
644
|
+
return { width: 0, height: 0 };
|
|
645
|
+
}
|
|
646
|
+
const probe = document.createElement("div");
|
|
647
|
+
probe.style.position = "absolute";
|
|
648
|
+
probe.style.visibility = "hidden";
|
|
649
|
+
probe.style.overflow = "scroll";
|
|
650
|
+
probe.style.width = "100px";
|
|
651
|
+
probe.style.height = "100px";
|
|
652
|
+
probe.style.top = "-9999px";
|
|
653
|
+
document.body.appendChild(probe);
|
|
654
|
+
const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
|
655
|
+
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
656
|
+
document.body.removeChild(probe);
|
|
657
|
+
return { width, height };
|
|
658
|
+
}
|
|
659
|
+
_getScrollSafetyMargin() {
|
|
660
|
+
return 2;
|
|
661
|
+
}
|
|
662
|
+
_getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
|
|
663
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
664
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
665
|
+
const safeWidth = Math.max(1, viewport.width - safetyMargin);
|
|
666
|
+
const safeHeight = Math.max(1, viewport.height - safetyMargin);
|
|
667
|
+
return {
|
|
668
|
+
width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
|
|
669
|
+
height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
|
|
670
|
+
viewportWidth: viewport.width,
|
|
671
|
+
viewportHeight: viewport.height,
|
|
672
|
+
hasHorizontal: true,
|
|
673
|
+
hasVertical: true
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const scrollbar = this._getScrollbarSize();
|
|
677
|
+
let hasVertical = false;
|
|
678
|
+
let hasHorizontal = false;
|
|
679
|
+
let effectiveWidth = viewport.width;
|
|
680
|
+
let effectiveHeight = viewport.height;
|
|
681
|
+
for (let i = 0; i < 4; i += 1) {
|
|
682
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
683
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
684
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
685
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
686
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
687
|
+
break;
|
|
688
|
+
hasVertical = nextHasVertical;
|
|
689
|
+
hasHorizontal = nextHasHorizontal;
|
|
690
|
+
}
|
|
691
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
692
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
693
|
+
return {
|
|
694
|
+
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
|
|
695
|
+
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
|
|
696
|
+
viewportWidth: effectiveWidth,
|
|
697
|
+
viewportHeight: effectiveHeight,
|
|
698
|
+
hasHorizontal,
|
|
699
|
+
hasVertical
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
_calculateCoverCanvasLayout(imageWidth, imageHeight) {
|
|
703
|
+
const viewport = this._getContainerViewportSize();
|
|
704
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
705
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
706
|
+
const targetWidth = Math.max(1, viewport.width - safetyMargin);
|
|
707
|
+
const targetHeight = Math.max(1, viewport.height - safetyMargin);
|
|
708
|
+
const scale2 = Math.min(1, Math.max(targetWidth / imageWidth, targetHeight / imageHeight));
|
|
709
|
+
const contentWidth2 = imageWidth * scale2;
|
|
710
|
+
const contentHeight2 = imageHeight * scale2;
|
|
711
|
+
const canvasSize2 = this._getScrollableCanvasSize(contentWidth2, contentHeight2, viewport);
|
|
712
|
+
return {
|
|
713
|
+
scale: scale2,
|
|
714
|
+
canvasWidth: canvasSize2.width,
|
|
715
|
+
canvasHeight: canvasSize2.height
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
const scrollbar = this._getScrollbarSize();
|
|
719
|
+
let hasVertical = false;
|
|
720
|
+
let hasHorizontal = false;
|
|
721
|
+
let scale = 1;
|
|
722
|
+
let contentWidth = imageWidth;
|
|
723
|
+
let contentHeight = imageHeight;
|
|
724
|
+
let effectiveWidth = viewport.width;
|
|
725
|
+
let effectiveHeight = viewport.height;
|
|
726
|
+
for (let i = 0; i < 4; i += 1) {
|
|
727
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
728
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
729
|
+
scale = Math.min(1, Math.max(effectiveWidth / imageWidth, effectiveHeight / imageHeight));
|
|
730
|
+
contentWidth = imageWidth * scale;
|
|
731
|
+
contentHeight = imageHeight * scale;
|
|
732
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
733
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
734
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal)
|
|
735
|
+
break;
|
|
736
|
+
hasVertical = nextHasVertical;
|
|
737
|
+
hasHorizontal = nextHasHorizontal;
|
|
738
|
+
}
|
|
739
|
+
const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
|
|
740
|
+
return {
|
|
741
|
+
scale,
|
|
742
|
+
canvasWidth: canvasSize.width,
|
|
743
|
+
canvasHeight: canvasSize.height
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
_getStateProperties() {
|
|
747
|
+
return [
|
|
748
|
+
"maskId",
|
|
749
|
+
"maskName",
|
|
750
|
+
"maskLabel",
|
|
751
|
+
"isCropRect",
|
|
752
|
+
"originalAlpha",
|
|
753
|
+
"originalStroke",
|
|
754
|
+
"originalStrokeWidth",
|
|
755
|
+
"selectable",
|
|
756
|
+
"evented",
|
|
757
|
+
"hasControls",
|
|
758
|
+
"lockRotation",
|
|
759
|
+
"borderColor",
|
|
760
|
+
"cornerColor",
|
|
761
|
+
"cornerSize",
|
|
762
|
+
"transparentCorners",
|
|
763
|
+
"strokeUniform",
|
|
764
|
+
"strokeDashArray"
|
|
765
|
+
];
|
|
766
|
+
}
|
|
767
|
+
_getMaskNormalStyle(mask) {
|
|
768
|
+
const strokeWidth = Number(mask && mask.originalStrokeWidth);
|
|
769
|
+
const opacity = Number(mask && mask.originalAlpha);
|
|
770
|
+
const style = {
|
|
771
|
+
stroke: mask && mask.originalStroke || "#ccc",
|
|
772
|
+
strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
|
|
773
|
+
};
|
|
774
|
+
if (Number.isFinite(opacity))
|
|
775
|
+
style.opacity = opacity;
|
|
776
|
+
return style;
|
|
777
|
+
}
|
|
778
|
+
_withNormalizedMaskStyles(callback) {
|
|
779
|
+
if (!this.canvas)
|
|
780
|
+
return callback();
|
|
781
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
782
|
+
const maskStyleBackups = masks.map((mask) => ({
|
|
783
|
+
object: mask,
|
|
784
|
+
stroke: mask.stroke,
|
|
785
|
+
strokeWidth: mask.strokeWidth,
|
|
786
|
+
opacity: mask.opacity
|
|
787
|
+
}));
|
|
788
|
+
try {
|
|
789
|
+
masks.forEach((mask) => {
|
|
790
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
791
|
+
});
|
|
792
|
+
return callback();
|
|
793
|
+
} finally {
|
|
794
|
+
maskStyleBackups.forEach((backup) => {
|
|
795
|
+
try {
|
|
796
|
+
backup.object.set({
|
|
797
|
+
stroke: backup.stroke,
|
|
798
|
+
strokeWidth: backup.strokeWidth,
|
|
799
|
+
opacity: backup.opacity
|
|
800
|
+
});
|
|
801
|
+
} catch (error) {
|
|
802
|
+
}
|
|
803
|
+
});
|
|
529
804
|
}
|
|
530
805
|
}
|
|
806
|
+
_restoreMaskControls(mask) {
|
|
807
|
+
if (!mask)
|
|
808
|
+
return;
|
|
809
|
+
const cornerSize = Number(mask.cornerSize);
|
|
810
|
+
mask.set({
|
|
811
|
+
selectable: mask.selectable !== false,
|
|
812
|
+
evented: mask.evented !== false,
|
|
813
|
+
hasControls: mask.hasControls !== false,
|
|
814
|
+
lockRotation: typeof mask.lockRotation === "boolean" ? mask.lockRotation : !this.options.maskRotatable,
|
|
815
|
+
borderColor: mask.borderColor || "red",
|
|
816
|
+
cornerColor: mask.cornerColor || "black",
|
|
817
|
+
cornerSize: Number.isFinite(cornerSize) ? cornerSize : 8,
|
|
818
|
+
transparentCorners: mask.transparentCorners === true,
|
|
819
|
+
strokeUniform: mask.strokeUniform !== false
|
|
820
|
+
});
|
|
821
|
+
if (typeof mask.setCoords === "function")
|
|
822
|
+
mask.setCoords();
|
|
823
|
+
}
|
|
824
|
+
_serializeCanvasState() {
|
|
825
|
+
if (!this.canvas)
|
|
826
|
+
return null;
|
|
827
|
+
return this._withNormalizedMaskStyles(() => {
|
|
828
|
+
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
829
|
+
if (Array.isArray(jsonObject.objects)) {
|
|
830
|
+
jsonObject.objects = jsonObject.objects.filter((object) => !object.isCropRect && !object.maskLabel);
|
|
831
|
+
}
|
|
832
|
+
return JSON.stringify(jsonObject);
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
_normalizeQuality(quality) {
|
|
836
|
+
const numericQuality = Number(quality);
|
|
837
|
+
if (!Number.isFinite(numericQuality))
|
|
838
|
+
return this.options.downsampleQuality ?? 0.92;
|
|
839
|
+
return Math.max(0, Math.min(1, numericQuality));
|
|
840
|
+
}
|
|
841
|
+
_normalizeImageFormat(format) {
|
|
842
|
+
const typeMapping = {
|
|
843
|
+
"jpeg": "jpeg",
|
|
844
|
+
"jpg": "jpeg",
|
|
845
|
+
"image/jpeg": "jpeg",
|
|
846
|
+
"png": "png",
|
|
847
|
+
"image/png": "png",
|
|
848
|
+
"webp": "webp",
|
|
849
|
+
"image/webp": "webp"
|
|
850
|
+
};
|
|
851
|
+
return typeMapping[String(format || "jpeg").toLowerCase()] || "jpeg";
|
|
852
|
+
}
|
|
853
|
+
_getClampedCanvasRegion(bounds, options = {}) {
|
|
854
|
+
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
855
|
+
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
856
|
+
const left = Number(bounds.left) || 0;
|
|
857
|
+
const top = Number(bounds.top) || 0;
|
|
858
|
+
const width = Math.max(0, Number(bounds.width) || 0);
|
|
859
|
+
const height = Math.max(0, Number(bounds.height) || 0);
|
|
860
|
+
const includePartialPixels = options.includePartialPixels !== false;
|
|
861
|
+
const roundEnd = includePartialPixels ? Math.ceil : Math.floor;
|
|
862
|
+
const sourceX = Math.min(canvasWidth - 1, Math.max(0, Math.floor(left)));
|
|
863
|
+
const sourceY = Math.min(canvasHeight - 1, Math.max(0, Math.floor(top)));
|
|
864
|
+
const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
|
|
865
|
+
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
866
|
+
return {
|
|
867
|
+
sx: sourceX,
|
|
868
|
+
sy: sourceY,
|
|
869
|
+
sw: Math.max(1, endX - sourceX),
|
|
870
|
+
sh: Math.max(1, endY - sourceY)
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = "jpeg", quality = 0.92) {
|
|
874
|
+
return new Promise((resolve, reject) => {
|
|
875
|
+
const imageElement = new Image();
|
|
876
|
+
imageElement.onload = () => {
|
|
877
|
+
try {
|
|
878
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
879
|
+
const scaledSourceX = Math.round(sourceX * safeMultiplier);
|
|
880
|
+
const scaledSourceY = Math.round(sourceY * safeMultiplier);
|
|
881
|
+
const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
|
|
882
|
+
const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
|
|
883
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
884
|
+
offscreenCanvas.width = scaledSourceWidth;
|
|
885
|
+
offscreenCanvas.height = scaledSourceHeight;
|
|
886
|
+
const context = offscreenCanvas.getContext("2d");
|
|
887
|
+
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
888
|
+
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
889
|
+
} catch (error) {
|
|
890
|
+
reject(error);
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
imageElement.onerror = reject;
|
|
894
|
+
imageElement.src = dataUrl;
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = "jpeg" }) {
|
|
898
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
899
|
+
const fullDataUrl = this.canvas.toDataURL({
|
|
900
|
+
format,
|
|
901
|
+
quality,
|
|
902
|
+
multiplier: safeMultiplier
|
|
903
|
+
});
|
|
904
|
+
return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
|
|
905
|
+
}
|
|
531
906
|
/**
|
|
532
907
|
* Gets the top-left corner coordinates of the given object.
|
|
533
908
|
* Used for geometry calculations (e.g., scale, rotate).
|
|
534
909
|
*
|
|
535
|
-
* @param {Object}
|
|
910
|
+
* @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
|
|
536
911
|
* @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
|
|
537
912
|
* @private
|
|
538
913
|
*/
|
|
539
|
-
_getObjectTopLeftPoint(
|
|
540
|
-
if (!
|
|
914
|
+
_getObjectTopLeftPoint(fabricObject) {
|
|
915
|
+
if (!fabricObject)
|
|
541
916
|
return { x: 0, y: 0 };
|
|
542
|
-
|
|
543
|
-
const coords = typeof
|
|
917
|
+
fabricObject.setCoords();
|
|
918
|
+
const coords = typeof fabricObject.getCoords === "function" ? fabricObject.getCoords() : null;
|
|
544
919
|
if (coords && coords.length)
|
|
545
920
|
return coords[0];
|
|
546
|
-
const
|
|
547
|
-
return { x:
|
|
921
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
922
|
+
return { x: boundingRect.left, y: boundingRect.top };
|
|
548
923
|
}
|
|
549
924
|
/**
|
|
550
925
|
* Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
|
|
551
926
|
*
|
|
552
|
-
* @param {Object}
|
|
927
|
+
* @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
|
|
553
928
|
* @param {string} originX - The new originX ("left", "center", "right", etc.).
|
|
554
929
|
* @param {string} originY - The new originY ("top", "center", "bottom", etc.).
|
|
555
930
|
* @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
|
|
556
931
|
* @private
|
|
557
932
|
*/
|
|
558
|
-
_setObjectOriginKeepingPosition(
|
|
559
|
-
if (!
|
|
933
|
+
_setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
|
|
934
|
+
if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin)
|
|
560
935
|
return;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
936
|
+
fabricObject.set({ originX, originY });
|
|
937
|
+
fabricObject.setPositionByOrigin(refPoint, originX, originY);
|
|
938
|
+
fabricObject.setCoords();
|
|
564
939
|
}
|
|
565
940
|
/**
|
|
566
941
|
* Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
|
|
567
942
|
*
|
|
568
|
-
* @param {Object}
|
|
943
|
+
* @param {Object} fabricObject - The object to align.
|
|
569
944
|
* @private
|
|
570
945
|
*/
|
|
571
|
-
_alignObjectBoundingBoxToCanvasTopLeft(
|
|
572
|
-
if (!
|
|
946
|
+
_alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
|
|
947
|
+
if (!fabricObject)
|
|
573
948
|
return;
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
949
|
+
fabricObject.setCoords();
|
|
950
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
951
|
+
const deltaX = boundingRect.left;
|
|
952
|
+
const deltaY = boundingRect.top;
|
|
953
|
+
fabricObject.set({ left: (fabricObject.left || 0) - deltaX, top: (fabricObject.top || 0) - deltaY });
|
|
954
|
+
fabricObject.setCoords();
|
|
580
955
|
this.canvas.renderAll();
|
|
581
956
|
}
|
|
582
957
|
/**
|
|
@@ -588,16 +963,26 @@
|
|
|
588
963
|
if (!this.originalImage)
|
|
589
964
|
return;
|
|
590
965
|
this.originalImage.setCoords();
|
|
591
|
-
const
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
966
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
967
|
+
const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
|
|
968
|
+
this._setCanvasSizeInt(size.width, size.height);
|
|
969
|
+
}
|
|
970
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
971
|
+
if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage)
|
|
596
972
|
return;
|
|
973
|
+
try {
|
|
974
|
+
fabricObject.setCoords();
|
|
975
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
976
|
+
const requiredWidth = Math.ceil(boundingRect.left + boundingRect.width + padding);
|
|
977
|
+
const requiredHeight = Math.ceil(boundingRect.top + boundingRect.height + padding);
|
|
978
|
+
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
979
|
+
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
980
|
+
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
981
|
+
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
982
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
this._reportWarning("expandCanvasToFitObject: failed to expand canvas", error);
|
|
597
985
|
}
|
|
598
|
-
const newW = Math.max(containerW || 0, Math.floor(br.width));
|
|
599
|
-
const newH = Math.max(containerH || 0, Math.floor(br.height));
|
|
600
|
-
this._setCanvasSizeInt(newW, newH);
|
|
601
986
|
}
|
|
602
987
|
/**
|
|
603
988
|
* Scales the original image by a given factor, with animation.
|
|
@@ -606,8 +991,8 @@
|
|
|
606
991
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
607
992
|
* @public
|
|
608
993
|
*/
|
|
609
|
-
scaleImage(factor) {
|
|
610
|
-
return this.animQueue.add(() => this._scaleImageImpl(factor));
|
|
994
|
+
scaleImage(factor, options = {}) {
|
|
995
|
+
return this.animQueue.add(() => this._scaleImageImpl(factor, options));
|
|
611
996
|
}
|
|
612
997
|
/**
|
|
613
998
|
* Scales the original image by a given factor, with animation.
|
|
@@ -616,46 +1001,49 @@
|
|
|
616
1001
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
617
1002
|
* @private
|
|
618
1003
|
*/
|
|
619
|
-
_scaleImageImpl(factor) {
|
|
1004
|
+
_scaleImageImpl(factor, options = {}) {
|
|
620
1005
|
if (!this.originalImage)
|
|
621
1006
|
return Promise.resolve();
|
|
622
1007
|
if (this.isAnimating)
|
|
623
1008
|
return Promise.resolve();
|
|
1009
|
+
const saveHistory = options.saveHistory !== false;
|
|
624
1010
|
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
625
1011
|
this.currentScale = factor;
|
|
626
1012
|
this.isAnimating = true;
|
|
627
1013
|
this._updateUI();
|
|
628
|
-
const
|
|
1014
|
+
const targetScale = this.baseImageScale * factor;
|
|
629
1015
|
const topLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
630
1016
|
this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
|
|
631
|
-
const
|
|
632
|
-
this.originalImage.animate("scaleX",
|
|
1017
|
+
const scaleXAnimation = new Promise((resolve) => {
|
|
1018
|
+
this.originalImage.animate("scaleX", targetScale, {
|
|
633
1019
|
duration: this.options.animationDuration,
|
|
634
1020
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
635
|
-
onComplete:
|
|
1021
|
+
onComplete: resolve
|
|
636
1022
|
});
|
|
637
1023
|
});
|
|
638
|
-
const
|
|
639
|
-
this.originalImage.animate("scaleY",
|
|
1024
|
+
const scaleYAnimation = new Promise((resolve) => {
|
|
1025
|
+
this.originalImage.animate("scaleY", targetScale, {
|
|
640
1026
|
duration: this.options.animationDuration,
|
|
641
1027
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
642
|
-
onComplete:
|
|
1028
|
+
onComplete: resolve
|
|
643
1029
|
});
|
|
644
1030
|
});
|
|
645
|
-
return Promise.all([
|
|
646
|
-
this.originalImage.set({ scaleX:
|
|
1031
|
+
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1032
|
+
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
647
1033
|
this.originalImage.setCoords();
|
|
648
|
-
if (this.options.expandCanvasToImage)
|
|
1034
|
+
if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
|
|
649
1035
|
this._updateCanvasSizeToImageBounds();
|
|
1036
|
+
}
|
|
650
1037
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
651
|
-
this.canvas.getObjects().forEach((
|
|
652
|
-
if (
|
|
653
|
-
this._syncMaskLabel(
|
|
1038
|
+
this.canvas.getObjects().forEach((object) => {
|
|
1039
|
+
if (object.maskId)
|
|
1040
|
+
this._syncMaskLabel(object);
|
|
654
1041
|
});
|
|
655
1042
|
this.isAnimating = false;
|
|
656
1043
|
this._updateInputs();
|
|
657
1044
|
this._updateUI();
|
|
658
|
-
|
|
1045
|
+
if (saveHistory)
|
|
1046
|
+
this.saveState();
|
|
659
1047
|
}).catch(() => {
|
|
660
1048
|
this.isAnimating = false;
|
|
661
1049
|
this._updateUI();
|
|
@@ -668,8 +1056,8 @@
|
|
|
668
1056
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
669
1057
|
* @public
|
|
670
1058
|
*/
|
|
671
|
-
rotateImage(
|
|
672
|
-
return this.animQueue.add(() => this._rotateImageImpl(
|
|
1059
|
+
rotateImage(degrees, options = {}) {
|
|
1060
|
+
return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
673
1061
|
}
|
|
674
1062
|
/**
|
|
675
1063
|
* Rotates the original image by a given number of degrees, with animation.
|
|
@@ -678,97 +1066,129 @@
|
|
|
678
1066
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
679
1067
|
* @private
|
|
680
1068
|
*/
|
|
681
|
-
_rotateImageImpl(degrees) {
|
|
1069
|
+
_rotateImageImpl(degrees, options = {}) {
|
|
682
1070
|
if (!this.originalImage)
|
|
683
1071
|
return Promise.resolve();
|
|
684
1072
|
if (this.isAnimating)
|
|
685
1073
|
return Promise.resolve();
|
|
686
1074
|
if (isNaN(degrees))
|
|
687
1075
|
return Promise.resolve();
|
|
1076
|
+
const saveHistory = options.saveHistory !== false;
|
|
688
1077
|
this.currentRotation = degrees;
|
|
689
1078
|
this.isAnimating = true;
|
|
690
1079
|
this._updateUI();
|
|
691
1080
|
const center = this.originalImage.getCenterPoint();
|
|
692
1081
|
this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
|
|
693
|
-
const
|
|
1082
|
+
const rotationAnimation = new Promise((resolve) => {
|
|
694
1083
|
this.originalImage.animate("angle", degrees, {
|
|
695
1084
|
duration: this.options.animationDuration,
|
|
696
1085
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
697
|
-
onComplete:
|
|
1086
|
+
onComplete: resolve
|
|
698
1087
|
});
|
|
699
1088
|
});
|
|
700
|
-
return
|
|
1089
|
+
return rotationAnimation.then(() => {
|
|
701
1090
|
this.originalImage.set("angle", degrees);
|
|
702
1091
|
this.originalImage.setCoords();
|
|
703
|
-
if (this.options.expandCanvasToImage)
|
|
1092
|
+
if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
|
|
704
1093
|
this._updateCanvasSizeToImageBounds();
|
|
1094
|
+
}
|
|
705
1095
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
706
1096
|
const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
707
1097
|
this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
|
|
708
|
-
this.canvas.getObjects().forEach((
|
|
709
|
-
if (
|
|
710
|
-
this._syncMaskLabel(
|
|
1098
|
+
this.canvas.getObjects().forEach((object) => {
|
|
1099
|
+
if (object.maskId)
|
|
1100
|
+
this._syncMaskLabel(object);
|
|
711
1101
|
});
|
|
712
1102
|
this.isAnimating = false;
|
|
713
1103
|
this._updateInputs();
|
|
714
1104
|
this._updateUI();
|
|
715
|
-
|
|
1105
|
+
if (saveHistory)
|
|
1106
|
+
this.saveState();
|
|
716
1107
|
}).catch(() => {
|
|
717
1108
|
this.isAnimating = false;
|
|
718
1109
|
this._updateUI();
|
|
719
1110
|
});
|
|
720
1111
|
}
|
|
721
1112
|
/**
|
|
722
|
-
* Resets the image: scales to 1 and rotates to 0 degrees.
|
|
1113
|
+
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
723
1114
|
* @returns {Promise<void>} Promise that resolves when reset is complete.
|
|
724
1115
|
*/
|
|
725
|
-
|
|
1116
|
+
resetImageTransform() {
|
|
726
1117
|
if (!this.originalImage)
|
|
727
1118
|
return Promise.resolve();
|
|
728
|
-
return this.
|
|
729
|
-
this.
|
|
1119
|
+
return this.animQueue.add(async () => {
|
|
1120
|
+
const before = this._serializeCanvasState();
|
|
1121
|
+
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1122
|
+
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1123
|
+
const after = this._serializeCanvasState();
|
|
1124
|
+
this._pushStateTransition(before, after);
|
|
730
1125
|
}).catch((err) => {
|
|
731
|
-
this._reportError("
|
|
1126
|
+
this._reportError("resetImageTransform() failed", err);
|
|
732
1127
|
});
|
|
733
1128
|
}
|
|
1129
|
+
/**
|
|
1130
|
+
* @deprecated Use resetImageTransform() instead.
|
|
1131
|
+
*/
|
|
1132
|
+
reset() {
|
|
1133
|
+
return this.resetImageTransform();
|
|
1134
|
+
}
|
|
734
1135
|
/**
|
|
735
1136
|
* Restores a canvas state that was previously stored by saveState().
|
|
736
1137
|
* @param {string} jsonString - the JSON string returned by fabric.toJSON().
|
|
737
1138
|
*/
|
|
738
1139
|
loadFromState(jsonString) {
|
|
739
1140
|
if (!jsonString || !this.canvas)
|
|
740
|
-
return;
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
this.originalImage
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1141
|
+
return Promise.resolve();
|
|
1142
|
+
return new Promise((resolve) => {
|
|
1143
|
+
try {
|
|
1144
|
+
const json = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
|
|
1145
|
+
this.canvas.loadFromJSON(json, () => {
|
|
1146
|
+
try {
|
|
1147
|
+
this._hideAllMaskLabels();
|
|
1148
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1149
|
+
this.originalImage = canvasObjects.find((object) => object.type === "image" && !object.maskId) || null;
|
|
1150
|
+
if (this.originalImage) {
|
|
1151
|
+
this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
|
|
1152
|
+
this.canvas.sendToBack(this.originalImage);
|
|
1153
|
+
this.currentRotation = Number(this.originalImage.angle) || 0;
|
|
1154
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1155
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1156
|
+
this.currentScale = imageScale / baseScale;
|
|
1157
|
+
} else {
|
|
1158
|
+
this.currentScale = 1;
|
|
1159
|
+
this.currentRotation = 0;
|
|
1160
|
+
}
|
|
1161
|
+
const masks = canvasObjects.filter((object) => object.maskId);
|
|
1162
|
+
masks.forEach((mask) => {
|
|
1163
|
+
this._restoreMaskControls(mask);
|
|
1164
|
+
this._rebindMaskEvents(mask);
|
|
1165
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
1166
|
+
});
|
|
1167
|
+
this.maskCounter = masks.reduce((max, mask) => Math.max(max, mask.maskId), 0);
|
|
1168
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1169
|
+
if (!this._lastMask) {
|
|
1170
|
+
this._lastMaskInitialLeft = null;
|
|
1171
|
+
this._lastMaskInitialTop = null;
|
|
1172
|
+
this._lastMaskInitialWidth = null;
|
|
1173
|
+
}
|
|
1174
|
+
this.isImageLoadedToCanvas = !!this.originalImage;
|
|
1175
|
+
this.canvas.renderAll();
|
|
1176
|
+
this._updateInputs();
|
|
1177
|
+
this._updateMaskList();
|
|
1178
|
+
this._updatePlaceholderStatus();
|
|
1179
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
1180
|
+
this._updateUI();
|
|
1181
|
+
} catch (callbackError) {
|
|
1182
|
+
this._reportError("loadFromState() failed", callbackError);
|
|
1183
|
+
} finally {
|
|
1184
|
+
resolve();
|
|
759
1185
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
this._reportError("loadFromState() failed", callbackError);
|
|
767
|
-
}
|
|
768
|
-
});
|
|
769
|
-
} catch (e) {
|
|
770
|
-
this._reportError("loadFromState() failed", e);
|
|
771
|
-
}
|
|
1186
|
+
});
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
this._reportError("loadFromState() failed", error);
|
|
1189
|
+
resolve();
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
772
1192
|
}
|
|
773
1193
|
/**
|
|
774
1194
|
* Saves the current state of the canvas to history, storing any mask/raster label information.
|
|
@@ -776,51 +1196,117 @@
|
|
|
776
1196
|
saveState() {
|
|
777
1197
|
if (!this.canvas)
|
|
778
1198
|
return;
|
|
779
|
-
const
|
|
1199
|
+
const activeObject = this.canvas.getActiveObject();
|
|
780
1200
|
this._hideAllMaskLabels();
|
|
781
1201
|
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);
|
|
1202
|
+
const after = this._serializeCanvasState();
|
|
787
1203
|
const before = this._lastSnapshot || after;
|
|
1204
|
+
if (after === before)
|
|
1205
|
+
return;
|
|
788
1206
|
let executedOnce = false;
|
|
789
|
-
const
|
|
1207
|
+
const command = new Command(
|
|
790
1208
|
() => {
|
|
791
1209
|
if (executedOnce) {
|
|
792
|
-
this.loadFromState(after);
|
|
1210
|
+
return this.loadFromState(after);
|
|
793
1211
|
}
|
|
794
1212
|
executedOnce = true;
|
|
1213
|
+
return void 0;
|
|
795
1214
|
},
|
|
796
|
-
() =>
|
|
797
|
-
this.loadFromState(before);
|
|
798
|
-
}
|
|
1215
|
+
() => this.loadFromState(before)
|
|
799
1216
|
);
|
|
800
|
-
this.historyManager.execute(
|
|
1217
|
+
this.historyManager.execute(command);
|
|
801
1218
|
this._lastSnapshot = after;
|
|
802
|
-
|
|
803
|
-
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
this._reportWarning("saveState: failed to save canvas snapshot", error);
|
|
1221
|
+
} finally {
|
|
1222
|
+
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1223
|
+
this._handleSelectionChanged([activeObject]);
|
|
804
1224
|
}
|
|
805
1225
|
this._updateUI();
|
|
806
|
-
} catch (err) {
|
|
807
|
-
this._reportWarning("saveState: failed to save canvas snapshot", err);
|
|
808
1226
|
}
|
|
809
1227
|
}
|
|
1228
|
+
_pushStateTransition(before, after) {
|
|
1229
|
+
if (!before || !after)
|
|
1230
|
+
return;
|
|
1231
|
+
if (before === after)
|
|
1232
|
+
return;
|
|
1233
|
+
if (!this.historyManager)
|
|
1234
|
+
this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1235
|
+
const command = new Command(
|
|
1236
|
+
() => this.loadFromState(after),
|
|
1237
|
+
() => this.loadFromState(before)
|
|
1238
|
+
);
|
|
1239
|
+
this.historyManager.push(command);
|
|
1240
|
+
this._lastSnapshot = after;
|
|
1241
|
+
this._updateUI();
|
|
1242
|
+
}
|
|
810
1243
|
/**
|
|
811
1244
|
* Undo the last state change, if possible.
|
|
812
1245
|
*/
|
|
813
1246
|
undo() {
|
|
814
|
-
this.historyManager.undo()
|
|
1247
|
+
return this.historyManager.undo().then(() => {
|
|
1248
|
+
this._updateUI();
|
|
1249
|
+
}).catch((error) => {
|
|
1250
|
+
this._reportError("undo failed", error);
|
|
1251
|
+
});
|
|
815
1252
|
}
|
|
816
1253
|
/**
|
|
817
1254
|
* Redo the next state change, if possible.
|
|
818
1255
|
*/
|
|
819
1256
|
redo() {
|
|
820
|
-
this.historyManager.redo()
|
|
1257
|
+
return this.historyManager.redo().then(() => {
|
|
1258
|
+
this._updateUI();
|
|
1259
|
+
}).catch((error) => {
|
|
1260
|
+
this._reportError("redo failed", error);
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
_rebindMaskEvents(mask) {
|
|
1264
|
+
if (!mask)
|
|
1265
|
+
return;
|
|
1266
|
+
if (mask.__imageEditorMaskHandlers) {
|
|
1267
|
+
try {
|
|
1268
|
+
mask.off("mouseover", mask.__imageEditorMaskHandlers.mouseover);
|
|
1269
|
+
mask.off("mouseout", mask.__imageEditorMaskHandlers.mouseout);
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const metadata = {};
|
|
1274
|
+
if (!Number.isFinite(Number(mask.originalAlpha))) {
|
|
1275
|
+
metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
|
|
1276
|
+
}
|
|
1277
|
+
if (!mask.originalStroke)
|
|
1278
|
+
metadata.originalStroke = mask.stroke || "#ccc";
|
|
1279
|
+
if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
|
|
1280
|
+
metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
|
|
1281
|
+
}
|
|
1282
|
+
if (Object.keys(metadata).length)
|
|
1283
|
+
mask.set(metadata);
|
|
1284
|
+
const normalStyle = {
|
|
1285
|
+
stroke: mask.originalStroke || "#ccc",
|
|
1286
|
+
strokeWidth: mask.originalStrokeWidth,
|
|
1287
|
+
opacity: mask.originalAlpha
|
|
1288
|
+
};
|
|
1289
|
+
const hoverStyle = {
|
|
1290
|
+
stroke: "#ff5500",
|
|
1291
|
+
strokeWidth: 2,
|
|
1292
|
+
opacity: Math.min(mask.originalAlpha + 0.2, 1)
|
|
1293
|
+
};
|
|
1294
|
+
const mouseover = () => {
|
|
1295
|
+
mask.set(hoverStyle);
|
|
1296
|
+
if (mask.canvas)
|
|
1297
|
+
mask.canvas.requestRenderAll();
|
|
1298
|
+
};
|
|
1299
|
+
const mouseout = () => {
|
|
1300
|
+
mask.set(normalStyle);
|
|
1301
|
+
if (mask.canvas)
|
|
1302
|
+
mask.canvas.requestRenderAll();
|
|
1303
|
+
};
|
|
1304
|
+
mask.on("mouseover", mouseover);
|
|
1305
|
+
mask.on("mouseout", mouseout);
|
|
1306
|
+
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
821
1307
|
}
|
|
822
1308
|
/**
|
|
823
|
-
*
|
|
1309
|
+
* Creates a mask and adds it to the canvas.
|
|
824
1310
|
* Mask placement and properties are determined by the provided config and instance options.
|
|
825
1311
|
* Canvas and list UI are updated accordingly.
|
|
826
1312
|
* @param {Object} [config={}] - Optional mask configuration overrides:
|
|
@@ -834,15 +1320,15 @@
|
|
|
834
1320
|
* @param {boolean} [config.selectable=true]
|
|
835
1321
|
* @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
|
|
836
1322
|
* @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
|
|
837
|
-
* @param {function} [config.fabricGenerator] - (
|
|
1323
|
+
* @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
|
|
838
1324
|
* @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
|
|
839
1325
|
* @public
|
|
840
1326
|
*/
|
|
841
|
-
|
|
1327
|
+
createMask(config = {}) {
|
|
842
1328
|
if (!this.canvas)
|
|
843
1329
|
return null;
|
|
844
1330
|
const shapeType = config.shape || "rect";
|
|
845
|
-
const
|
|
1331
|
+
const maskConfig = {
|
|
846
1332
|
shape: shapeType,
|
|
847
1333
|
width: this.options.defaultMaskWidth,
|
|
848
1334
|
height: this.options.defaultMaskHeight,
|
|
@@ -858,80 +1344,71 @@
|
|
|
858
1344
|
const firstOffset = 10;
|
|
859
1345
|
let left = firstOffset;
|
|
860
1346
|
let top = firstOffset;
|
|
861
|
-
const resolveValue = (
|
|
862
|
-
if (typeof
|
|
863
|
-
return
|
|
864
|
-
if (typeof
|
|
865
|
-
const percent = parseFloat(
|
|
1347
|
+
const resolveValue = (value, fallback) => {
|
|
1348
|
+
if (typeof value === "function")
|
|
1349
|
+
return value(this.canvas, this.options);
|
|
1350
|
+
if (typeof value === "string" && value.endsWith("%")) {
|
|
1351
|
+
const percent = parseFloat(value) / 100;
|
|
866
1352
|
return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
|
|
867
1353
|
}
|
|
868
|
-
return
|
|
1354
|
+
return value != null ? value : fallback;
|
|
869
1355
|
};
|
|
870
|
-
if (
|
|
871
|
-
const
|
|
872
|
-
let
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
} else if (
|
|
876
|
-
|
|
1356
|
+
if (maskConfig.left === void 0 && this._lastMask) {
|
|
1357
|
+
const previousMask = this._lastMask;
|
|
1358
|
+
let previousMaskRight = previousMask.left;
|
|
1359
|
+
if (previousMask.getScaledWidth) {
|
|
1360
|
+
previousMaskRight += previousMask.getScaledWidth();
|
|
1361
|
+
} else if (previousMask.width) {
|
|
1362
|
+
previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
|
|
877
1363
|
}
|
|
878
|
-
left = Math.round(
|
|
879
|
-
top =
|
|
1364
|
+
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1365
|
+
top = previousMask.top ?? firstOffset;
|
|
880
1366
|
} 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);
|
|
1367
|
+
left = resolveValue(maskConfig.left, firstOffset);
|
|
1368
|
+
top = resolveValue(maskConfig.top, firstOffset);
|
|
894
1369
|
}
|
|
1370
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1371
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
895
1372
|
let mask;
|
|
896
|
-
if (typeof
|
|
897
|
-
mask =
|
|
1373
|
+
if (typeof maskConfig.fabricGenerator === "function") {
|
|
1374
|
+
mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
|
|
898
1375
|
} else {
|
|
899
1376
|
switch (shapeType) {
|
|
900
1377
|
case "circle":
|
|
901
1378
|
mask = new fabric.Circle({
|
|
902
1379
|
left,
|
|
903
1380
|
top,
|
|
904
|
-
radius: resolveValue(
|
|
905
|
-
fill:
|
|
906
|
-
opacity:
|
|
907
|
-
angle:
|
|
908
|
-
...
|
|
1381
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
1382
|
+
fill: maskConfig.color,
|
|
1383
|
+
opacity: maskConfig.alpha,
|
|
1384
|
+
angle: maskConfig.angle,
|
|
1385
|
+
...maskConfig.styles
|
|
909
1386
|
});
|
|
910
1387
|
break;
|
|
911
1388
|
case "ellipse":
|
|
912
1389
|
mask = new fabric.Ellipse({
|
|
913
1390
|
left,
|
|
914
1391
|
top,
|
|
915
|
-
rx: resolveValue(
|
|
916
|
-
ry: resolveValue(
|
|
917
|
-
fill:
|
|
918
|
-
opacity:
|
|
919
|
-
angle:
|
|
920
|
-
...
|
|
1392
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1393
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
1394
|
+
fill: maskConfig.color,
|
|
1395
|
+
opacity: maskConfig.alpha,
|
|
1396
|
+
angle: maskConfig.angle,
|
|
1397
|
+
...maskConfig.styles
|
|
921
1398
|
});
|
|
922
1399
|
break;
|
|
923
1400
|
case "polygon": {
|
|
924
|
-
let
|
|
925
|
-
if (Array.isArray(
|
|
926
|
-
|
|
1401
|
+
let polygonPoints = maskConfig.points || [];
|
|
1402
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === "object") {
|
|
1403
|
+
polygonPoints = polygonPoints.map((point) => ({ x: Number(point.x), y: Number(point.y) }));
|
|
927
1404
|
}
|
|
928
|
-
mask = new fabric.Polygon(
|
|
1405
|
+
mask = new fabric.Polygon(polygonPoints, {
|
|
929
1406
|
left,
|
|
930
1407
|
top,
|
|
931
|
-
fill:
|
|
932
|
-
opacity:
|
|
933
|
-
angle:
|
|
934
|
-
...
|
|
1408
|
+
fill: maskConfig.color,
|
|
1409
|
+
opacity: maskConfig.alpha,
|
|
1410
|
+
angle: maskConfig.angle,
|
|
1411
|
+
...maskConfig.styles
|
|
935
1412
|
});
|
|
936
1413
|
break;
|
|
937
1414
|
}
|
|
@@ -940,80 +1417,92 @@
|
|
|
940
1417
|
mask = new fabric.Rect({
|
|
941
1418
|
left,
|
|
942
1419
|
top,
|
|
943
|
-
width: resolveValue(
|
|
944
|
-
height: resolveValue(
|
|
945
|
-
fill:
|
|
946
|
-
opacity:
|
|
947
|
-
angle:
|
|
948
|
-
rx:
|
|
1420
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1421
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
1422
|
+
fill: maskConfig.color,
|
|
1423
|
+
opacity: maskConfig.alpha,
|
|
1424
|
+
angle: maskConfig.angle,
|
|
1425
|
+
rx: maskConfig.rx,
|
|
949
1426
|
// Rounded Corners
|
|
950
|
-
ry:
|
|
951
|
-
...
|
|
1427
|
+
ry: maskConfig.ry,
|
|
1428
|
+
...maskConfig.styles
|
|
952
1429
|
});
|
|
953
1430
|
}
|
|
954
1431
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
mask.
|
|
976
|
-
mask.
|
|
1432
|
+
const styles = maskConfig.styles || {};
|
|
1433
|
+
const hasStyle = (property) => Object.prototype.hasOwnProperty.call(styles, property);
|
|
1434
|
+
const maskSettings = {
|
|
1435
|
+
selectable: maskConfig.selectable !== false,
|
|
1436
|
+
hasControls: "hasControls" in maskConfig ? maskConfig.hasControls : true,
|
|
1437
|
+
lockRotation: !this.options.maskRotatable,
|
|
1438
|
+
borderColor: "borderColor" in maskConfig ? maskConfig.borderColor : "red",
|
|
1439
|
+
cornerColor: "cornerColor" in maskConfig ? maskConfig.cornerColor : "black",
|
|
1440
|
+
cornerSize: "cornerSize" in maskConfig ? maskConfig.cornerSize : 8,
|
|
1441
|
+
transparentCorners: "transparentCorners" in maskConfig ? maskConfig.transparentCorners : false,
|
|
1442
|
+
stroke: hasStyle("stroke") ? styles.stroke : "#ccc",
|
|
1443
|
+
strokeWidth: hasStyle("strokeWidth") ? styles.strokeWidth : 1,
|
|
1444
|
+
strokeUniform: "strokeUniform" in maskConfig ? maskConfig.strokeUniform : hasStyle("strokeUniform") ? styles.strokeUniform : true
|
|
1445
|
+
};
|
|
1446
|
+
if (hasStyle("strokeDashArray"))
|
|
1447
|
+
maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
1448
|
+
mask.set(maskSettings);
|
|
1449
|
+
mask.setCoords();
|
|
1450
|
+
mask.set({
|
|
1451
|
+
originalAlpha: maskConfig.alpha,
|
|
1452
|
+
originalStroke: mask.stroke || "#ccc",
|
|
1453
|
+
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
977
1454
|
});
|
|
1455
|
+
this._rebindMaskEvents(mask);
|
|
1456
|
+
this._expandCanvasToFitObject(mask);
|
|
978
1457
|
this._lastMaskInitialLeft = left;
|
|
979
1458
|
this._lastMaskInitialTop = top;
|
|
980
|
-
this._lastMaskInitialWidth = resolveValue(
|
|
981
|
-
|
|
982
|
-
mask.
|
|
1459
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1460
|
+
const maskId = ++this.maskCounter;
|
|
1461
|
+
mask.set({
|
|
1462
|
+
maskId,
|
|
1463
|
+
maskName: `${this.options.maskName}${maskId}`
|
|
1464
|
+
});
|
|
983
1465
|
this._lastMask = mask;
|
|
984
1466
|
this.canvas.add(mask);
|
|
985
1467
|
this.canvas.bringToFront(mask);
|
|
986
|
-
if (
|
|
1468
|
+
if (maskConfig.selectable)
|
|
987
1469
|
this.canvas.setActiveObject(mask);
|
|
988
|
-
this.
|
|
1470
|
+
this._handleSelectionChanged([mask]);
|
|
989
1471
|
this._updateMaskList();
|
|
990
1472
|
this._updateUI();
|
|
991
1473
|
this.canvas.renderAll();
|
|
992
1474
|
this.saveState();
|
|
993
|
-
if (typeof
|
|
994
|
-
|
|
1475
|
+
if (typeof maskConfig.onCreate === "function")
|
|
1476
|
+
maskConfig.onCreate(mask, this.canvas);
|
|
995
1477
|
return mask;
|
|
996
1478
|
}
|
|
1479
|
+
/**
|
|
1480
|
+
* @deprecated Use createMask() instead.
|
|
1481
|
+
*/
|
|
1482
|
+
addMask(config = {}) {
|
|
1483
|
+
return this.createMask(config);
|
|
1484
|
+
}
|
|
997
1485
|
/**
|
|
998
1486
|
* Removes the currently selected mask from the canvas, if any.
|
|
999
1487
|
* The associated label is also removed. UI and mask list are updated.
|
|
1000
1488
|
*/
|
|
1001
1489
|
removeSelectedMask() {
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1490
|
+
const activeObject = this.canvas.getActiveObject();
|
|
1491
|
+
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
1492
|
+
if (!selectedMasks.length)
|
|
1004
1493
|
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
1494
|
this.canvas.discardActiveObject();
|
|
1495
|
+
selectedMasks.forEach((mask) => {
|
|
1496
|
+
this._removeLabelForMask(mask);
|
|
1497
|
+
this.canvas.remove(mask);
|
|
1498
|
+
});
|
|
1499
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1500
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1501
|
+
if (!this._lastMask) {
|
|
1502
|
+
this._lastMaskInitialLeft = null;
|
|
1503
|
+
this._lastMaskInitialTop = null;
|
|
1504
|
+
this._lastMaskInitialWidth = null;
|
|
1505
|
+
}
|
|
1017
1506
|
this._updateMaskList();
|
|
1018
1507
|
this._updateUI();
|
|
1019
1508
|
this.canvas.renderAll();
|
|
@@ -1023,10 +1512,11 @@
|
|
|
1023
1512
|
* Removes all masks from the canvas, including their labels.
|
|
1024
1513
|
* UI and internal mask placement memory are reset.
|
|
1025
1514
|
*/
|
|
1026
|
-
removeAllMasks() {
|
|
1027
|
-
const
|
|
1028
|
-
masks.
|
|
1029
|
-
masks.forEach((
|
|
1515
|
+
removeAllMasks(options = {}) {
|
|
1516
|
+
const saveHistory = options.saveHistory !== false;
|
|
1517
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1518
|
+
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
1519
|
+
masks.forEach((mask) => this.canvas.remove(mask));
|
|
1030
1520
|
this.canvas.discardActiveObject();
|
|
1031
1521
|
this._lastMask = null;
|
|
1032
1522
|
this._lastMaskInitialLeft = null;
|
|
@@ -1035,7 +1525,8 @@
|
|
|
1035
1525
|
this._updateMaskList();
|
|
1036
1526
|
this._updateUI();
|
|
1037
1527
|
this.canvas.renderAll();
|
|
1038
|
-
|
|
1528
|
+
if (saveHistory)
|
|
1529
|
+
this.saveState();
|
|
1039
1530
|
}
|
|
1040
1531
|
/**
|
|
1041
1532
|
* Removes the label associated with the specified mask object, if it exists.
|
|
@@ -1048,15 +1539,15 @@
|
|
|
1048
1539
|
return;
|
|
1049
1540
|
if (mask.__label) {
|
|
1050
1541
|
try {
|
|
1051
|
-
const
|
|
1052
|
-
if (
|
|
1542
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1543
|
+
if (canvasObjects.includes(mask.__label)) {
|
|
1053
1544
|
this.canvas.remove(mask.__label);
|
|
1054
1545
|
}
|
|
1055
|
-
} catch (
|
|
1546
|
+
} catch (error) {
|
|
1056
1547
|
}
|
|
1057
1548
|
try {
|
|
1058
1549
|
delete mask.__label;
|
|
1059
|
-
} catch (
|
|
1550
|
+
} catch (error) {
|
|
1060
1551
|
}
|
|
1061
1552
|
}
|
|
1062
1553
|
}
|
|
@@ -1071,12 +1562,12 @@
|
|
|
1071
1562
|
if (!mask || !this.options.maskLabelOnSelect)
|
|
1072
1563
|
return;
|
|
1073
1564
|
this._removeLabelForMask(mask);
|
|
1074
|
-
let
|
|
1565
|
+
let textObject = null;
|
|
1075
1566
|
if (this.options.label && typeof this.options.label.create === "function") {
|
|
1076
|
-
|
|
1567
|
+
textObject = this.options.label.create(mask, fabric);
|
|
1077
1568
|
}
|
|
1078
|
-
if (!
|
|
1079
|
-
let
|
|
1569
|
+
if (!textObject) {
|
|
1570
|
+
let labelText = mask.maskName;
|
|
1080
1571
|
let textOptions = {
|
|
1081
1572
|
left: 0,
|
|
1082
1573
|
top: 0,
|
|
@@ -1091,18 +1582,20 @@
|
|
|
1091
1582
|
};
|
|
1092
1583
|
if (this.options.label) {
|
|
1093
1584
|
if (typeof this.options.label.getText === "function") {
|
|
1094
|
-
|
|
1585
|
+
const masks = this.canvas ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1586
|
+
const maskIndex = Math.max(0, masks.indexOf(mask));
|
|
1587
|
+
labelText = this.options.label.getText(mask, maskIndex);
|
|
1095
1588
|
}
|
|
1096
1589
|
if (this.options.label.textOptions) {
|
|
1097
1590
|
Object.assign(textOptions, this.options.label.textOptions);
|
|
1098
1591
|
}
|
|
1099
1592
|
}
|
|
1100
|
-
|
|
1593
|
+
textObject = new fabric.Text(labelText, textOptions);
|
|
1101
1594
|
}
|
|
1102
|
-
|
|
1103
|
-
mask.__label =
|
|
1104
|
-
this.canvas.add(
|
|
1105
|
-
this.canvas.bringToFront(
|
|
1595
|
+
textObject.maskLabel = true;
|
|
1596
|
+
mask.__label = textObject;
|
|
1597
|
+
this.canvas.add(textObject);
|
|
1598
|
+
this.canvas.bringToFront(textObject);
|
|
1106
1599
|
this._syncMaskLabel(mask);
|
|
1107
1600
|
}
|
|
1108
1601
|
/**
|
|
@@ -1113,20 +1606,20 @@
|
|
|
1113
1606
|
_hideAllMaskLabels() {
|
|
1114
1607
|
if (!this.canvas)
|
|
1115
1608
|
return;
|
|
1116
|
-
const
|
|
1117
|
-
const labels =
|
|
1118
|
-
labels.forEach((
|
|
1609
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1610
|
+
const labels = canvasObjects.filter((object) => object.maskLabel);
|
|
1611
|
+
labels.forEach((label) => {
|
|
1119
1612
|
try {
|
|
1120
|
-
if (
|
|
1121
|
-
this.canvas.remove(
|
|
1122
|
-
} catch (
|
|
1613
|
+
if (canvasObjects.includes(label))
|
|
1614
|
+
this.canvas.remove(label);
|
|
1615
|
+
} catch (error) {
|
|
1123
1616
|
}
|
|
1124
1617
|
});
|
|
1125
|
-
|
|
1126
|
-
if (
|
|
1618
|
+
canvasObjects.forEach((object) => {
|
|
1619
|
+
if (object.maskId && object.__label) {
|
|
1127
1620
|
try {
|
|
1128
|
-
delete
|
|
1129
|
-
} catch (
|
|
1621
|
+
delete object.__label;
|
|
1622
|
+
} catch (error) {
|
|
1130
1623
|
}
|
|
1131
1624
|
}
|
|
1132
1625
|
});
|
|
@@ -1166,7 +1659,11 @@
|
|
|
1166
1659
|
visible: true
|
|
1167
1660
|
});
|
|
1168
1661
|
mask.__label.setCoords();
|
|
1169
|
-
this.canvas.
|
|
1662
|
+
if (typeof this.canvas.requestRenderAll === "function") {
|
|
1663
|
+
this.canvas.requestRenderAll();
|
|
1664
|
+
} else {
|
|
1665
|
+
this.canvas.renderAll();
|
|
1666
|
+
}
|
|
1170
1667
|
}
|
|
1171
1668
|
/**
|
|
1172
1669
|
* Shows the label for the given mask, creating it if necessary and synchronizing its position.
|
|
@@ -1181,7 +1678,7 @@
|
|
|
1181
1678
|
return;
|
|
1182
1679
|
if (!mask.__label)
|
|
1183
1680
|
this._createLabelForMask(mask);
|
|
1184
|
-
mask.__label.visible
|
|
1681
|
+
mask.__label.set({ visible: true });
|
|
1185
1682
|
this._syncMaskLabel(mask);
|
|
1186
1683
|
}
|
|
1187
1684
|
/**
|
|
@@ -1191,21 +1688,25 @@
|
|
|
1191
1688
|
* @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
|
|
1192
1689
|
* @private
|
|
1193
1690
|
*/
|
|
1194
|
-
|
|
1195
|
-
const selectedMask = (selected || []).find((
|
|
1196
|
-
const masks = this.canvas.getObjects().filter((
|
|
1197
|
-
masks.forEach((
|
|
1198
|
-
if (
|
|
1199
|
-
if (
|
|
1691
|
+
_handleSelectionChanged(selected) {
|
|
1692
|
+
const selectedMask = (selected || []).find((object) => object.maskId);
|
|
1693
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1694
|
+
masks.forEach((mask) => {
|
|
1695
|
+
if (mask !== selectedMask) {
|
|
1696
|
+
if (mask.__label) {
|
|
1200
1697
|
try {
|
|
1201
|
-
this.canvas.remove(
|
|
1202
|
-
} catch (
|
|
1698
|
+
this.canvas.remove(mask.__label);
|
|
1699
|
+
} catch (error) {
|
|
1203
1700
|
}
|
|
1204
|
-
delete
|
|
1701
|
+
delete mask.__label;
|
|
1205
1702
|
}
|
|
1206
|
-
|
|
1703
|
+
const originalStrokeWidth = Number(mask.originalStrokeWidth);
|
|
1704
|
+
mask.set({
|
|
1705
|
+
stroke: mask.originalStroke || "#ccc",
|
|
1706
|
+
strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
|
|
1707
|
+
});
|
|
1207
1708
|
} else {
|
|
1208
|
-
|
|
1709
|
+
mask.set({ stroke: "#ff0000", strokeWidth: 1 });
|
|
1209
1710
|
}
|
|
1210
1711
|
});
|
|
1211
1712
|
if (selectedMask)
|
|
@@ -1220,20 +1721,20 @@
|
|
|
1220
1721
|
* @private
|
|
1221
1722
|
*/
|
|
1222
1723
|
_updateMaskList() {
|
|
1223
|
-
const
|
|
1224
|
-
if (!
|
|
1724
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1725
|
+
if (!maskListElement)
|
|
1225
1726
|
return;
|
|
1226
|
-
|
|
1227
|
-
const masks = this.canvas.getObjects().filter((
|
|
1727
|
+
maskListElement.innerHTML = "";
|
|
1728
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1228
1729
|
masks.forEach((mask) => {
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1730
|
+
const listItemElement = document.createElement("li");
|
|
1731
|
+
listItemElement.className = "list-group-item mask-item";
|
|
1732
|
+
listItemElement.textContent = mask.maskName;
|
|
1733
|
+
listItemElement.onclick = () => {
|
|
1233
1734
|
this.canvas.setActiveObject(mask);
|
|
1234
|
-
this.
|
|
1735
|
+
this._handleSelectionChanged([mask]);
|
|
1235
1736
|
};
|
|
1236
|
-
|
|
1737
|
+
maskListElement.appendChild(listItemElement);
|
|
1237
1738
|
});
|
|
1238
1739
|
}
|
|
1239
1740
|
/**
|
|
@@ -1243,11 +1744,11 @@
|
|
|
1243
1744
|
* @private
|
|
1244
1745
|
*/
|
|
1245
1746
|
_updateMaskListSelection(selectedMask) {
|
|
1246
|
-
const
|
|
1247
|
-
if (!
|
|
1747
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1748
|
+
if (!maskListElement)
|
|
1248
1749
|
return;
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1750
|
+
const maskItems = maskListElement.querySelectorAll(".mask-item");
|
|
1751
|
+
maskItems.forEach((item) => {
|
|
1251
1752
|
const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
|
|
1252
1753
|
item.classList.toggle("active", isSelected);
|
|
1253
1754
|
});
|
|
@@ -1258,25 +1759,31 @@
|
|
|
1258
1759
|
* @async
|
|
1259
1760
|
* @returns {Promise<void>} Resolves when merge and load are complete.
|
|
1260
1761
|
*/
|
|
1261
|
-
async
|
|
1762
|
+
async mergeMasks() {
|
|
1262
1763
|
if (!this.originalImage)
|
|
1263
1764
|
return;
|
|
1264
|
-
const masks = this.canvas.getObjects().filter((
|
|
1765
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1265
1766
|
if (!masks.length)
|
|
1266
1767
|
return;
|
|
1267
1768
|
this.canvas.discardActiveObject();
|
|
1268
1769
|
this.canvas.renderAll();
|
|
1269
1770
|
try {
|
|
1270
|
-
const
|
|
1271
|
-
this.
|
|
1771
|
+
const beforeJson = this._serializeCanvasState();
|
|
1772
|
+
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1773
|
+
this.removeAllMasks({ saveHistory: false });
|
|
1272
1774
|
await this.loadImage(merged);
|
|
1273
|
-
this.
|
|
1775
|
+
const afterJson = this._serializeCanvasState();
|
|
1776
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
1274
1777
|
} catch (err) {
|
|
1275
1778
|
this._reportError("merge error", err);
|
|
1276
|
-
if (this.canvasEl)
|
|
1277
|
-
this.canvasEl.style.visibility = "";
|
|
1278
1779
|
}
|
|
1279
1780
|
}
|
|
1781
|
+
/**
|
|
1782
|
+
* @deprecated Use mergeMasks() instead.
|
|
1783
|
+
*/
|
|
1784
|
+
async merge() {
|
|
1785
|
+
return this.mergeMasks();
|
|
1786
|
+
}
|
|
1280
1787
|
/**
|
|
1281
1788
|
* Triggers a JPEG image download of the current canvas (image plus masks if configured).
|
|
1282
1789
|
* The image area and multiplier are controlled by options.
|
|
@@ -1286,7 +1793,7 @@
|
|
|
1286
1793
|
if (!this.originalImage)
|
|
1287
1794
|
return;
|
|
1288
1795
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1289
|
-
this.
|
|
1796
|
+
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((base64) => {
|
|
1290
1797
|
const link = document.createElement("a");
|
|
1291
1798
|
link.download = fileName;
|
|
1292
1799
|
link.href = base64;
|
|
@@ -1296,127 +1803,130 @@
|
|
|
1296
1803
|
}).catch((err) => this._reportError("download error", err));
|
|
1297
1804
|
}
|
|
1298
1805
|
/**
|
|
1299
|
-
* Exports the image as a Base64-encoded
|
|
1806
|
+
* Exports the image as a Base64-encoded image data URL.
|
|
1300
1807
|
* Can export either the original, or the current view including masks (clipped/cropped).
|
|
1301
1808
|
* Will restore masks' state after temporary modifications for export.
|
|
1302
1809
|
* @async
|
|
1303
|
-
* @param {Object} [
|
|
1304
|
-
* @param {boolean} [
|
|
1305
|
-
* @param {number} [
|
|
1306
|
-
* @
|
|
1810
|
+
* @param {Object} [options={}] - Export options.
|
|
1811
|
+
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1812
|
+
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1813
|
+
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1814
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1815
|
+
* @returns {Promise<string>} Promise resolving to an image data URL.
|
|
1307
1816
|
* @throws {Error} If there is no image loaded.
|
|
1308
1817
|
*/
|
|
1309
|
-
async
|
|
1818
|
+
async exportImageBase64(options = {}) {
|
|
1310
1819
|
if (!this.originalImage)
|
|
1311
1820
|
throw new Error("No image loaded");
|
|
1312
|
-
const exportImageArea = typeof
|
|
1313
|
-
const multiplier =
|
|
1821
|
+
const exportImageArea = typeof options.exportImageArea === "boolean" ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
1822
|
+
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
1823
|
+
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
1824
|
+
const format = this._normalizeImageFormat(options.fileType || options.format);
|
|
1314
1825
|
if (!exportImageArea) {
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1826
|
+
const masks2 = this.canvas.getObjects().filter((object) => object.maskId || object.maskLabel);
|
|
1827
|
+
const maskVisibilityBackups = masks2.map((mask) => ({ object: mask, visible: mask.visible }));
|
|
1828
|
+
try {
|
|
1829
|
+
masks2.forEach((mask) => {
|
|
1830
|
+
mask.set({ visible: false });
|
|
1831
|
+
});
|
|
1832
|
+
this.canvas.discardActiveObject();
|
|
1833
|
+
this.canvas.renderAll();
|
|
1834
|
+
this.originalImage.setCoords();
|
|
1835
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1836
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1837
|
+
return await this._exportCanvasRegionToDataURL({
|
|
1838
|
+
sx,
|
|
1839
|
+
sy,
|
|
1840
|
+
sw,
|
|
1841
|
+
sh,
|
|
1842
|
+
multiplier,
|
|
1843
|
+
quality,
|
|
1844
|
+
format
|
|
1845
|
+
});
|
|
1846
|
+
} finally {
|
|
1847
|
+
maskVisibilityBackups.forEach((backup) => {
|
|
1848
|
+
try {
|
|
1849
|
+
backup.object.set({ visible: backup.visible });
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
this.canvas.renderAll();
|
|
1854
|
+
}
|
|
1326
1855
|
}
|
|
1327
|
-
const masks = this.canvas.getObjects().filter((
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
opacity:
|
|
1331
|
-
fill:
|
|
1332
|
-
strokeWidth:
|
|
1333
|
-
stroke:
|
|
1334
|
-
selectable:
|
|
1335
|
-
lockRotation:
|
|
1856
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1857
|
+
const maskStyleBackups = masks.map((mask) => ({
|
|
1858
|
+
object: mask,
|
|
1859
|
+
opacity: mask.opacity,
|
|
1860
|
+
fill: mask.fill,
|
|
1861
|
+
strokeWidth: mask.strokeWidth,
|
|
1862
|
+
stroke: mask.stroke,
|
|
1863
|
+
selectable: mask.selectable,
|
|
1864
|
+
lockRotation: mask.lockRotation
|
|
1336
1865
|
}));
|
|
1337
1866
|
let finalBase64;
|
|
1338
1867
|
try {
|
|
1339
|
-
masks.forEach((
|
|
1868
|
+
masks.forEach((mask) => this._removeLabelForMask(mask));
|
|
1340
1869
|
this.canvas.discardActiveObject();
|
|
1341
1870
|
this.canvas.renderAll();
|
|
1342
|
-
masks.forEach((
|
|
1343
|
-
|
|
1344
|
-
|
|
1871
|
+
masks.forEach((mask) => {
|
|
1872
|
+
mask.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
|
|
1873
|
+
mask.setCoords();
|
|
1345
1874
|
});
|
|
1346
1875
|
this.canvas.renderAll();
|
|
1347
1876
|
this.originalImage.setCoords();
|
|
1348
|
-
const
|
|
1349
|
-
const sx =
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
}
|
|
1877
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1878
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1879
|
+
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
1880
|
+
sx,
|
|
1881
|
+
sy,
|
|
1882
|
+
sw,
|
|
1883
|
+
sh,
|
|
1884
|
+
multiplier,
|
|
1885
|
+
quality,
|
|
1886
|
+
format
|
|
1383
1887
|
});
|
|
1384
1888
|
} finally {
|
|
1385
|
-
|
|
1889
|
+
maskStyleBackups.forEach((backup) => {
|
|
1386
1890
|
try {
|
|
1387
|
-
|
|
1388
|
-
opacity:
|
|
1389
|
-
fill:
|
|
1390
|
-
strokeWidth:
|
|
1391
|
-
stroke:
|
|
1392
|
-
selectable:
|
|
1393
|
-
lockRotation:
|
|
1891
|
+
backup.object.set({
|
|
1892
|
+
opacity: backup.opacity,
|
|
1893
|
+
fill: backup.fill,
|
|
1894
|
+
strokeWidth: backup.strokeWidth,
|
|
1895
|
+
stroke: backup.stroke,
|
|
1896
|
+
selectable: backup.selectable,
|
|
1897
|
+
lockRotation: backup.lockRotation
|
|
1394
1898
|
});
|
|
1395
|
-
|
|
1396
|
-
} catch (
|
|
1899
|
+
backup.object.setCoords();
|
|
1900
|
+
} catch (error) {
|
|
1397
1901
|
}
|
|
1398
1902
|
});
|
|
1399
1903
|
this.canvas.renderAll();
|
|
1400
1904
|
}
|
|
1401
1905
|
return finalBase64;
|
|
1402
1906
|
}
|
|
1907
|
+
/**
|
|
1908
|
+
* @deprecated Use exportImageBase64() instead.
|
|
1909
|
+
*/
|
|
1910
|
+
async getImageBase64(options = {}) {
|
|
1911
|
+
return this.exportImageBase64(options);
|
|
1912
|
+
}
|
|
1403
1913
|
/**
|
|
1404
1914
|
* Exports the current canvas (with or without masks) as a File object.
|
|
1405
1915
|
* Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
|
|
1406
1916
|
*
|
|
1407
1917
|
* @async
|
|
1408
|
-
* @param {Object} [
|
|
1409
|
-
* @param {boolean} [
|
|
1410
|
-
* @param {string} [
|
|
1411
|
-
* @param {number} [
|
|
1412
|
-
* @param {number} [
|
|
1413
|
-
* @param {string} [
|
|
1918
|
+
* @param {Object} [options={}] - Export options.
|
|
1919
|
+
* @param {boolean} [options.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
|
|
1920
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
|
|
1921
|
+
* @param {number} [options.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
|
|
1922
|
+
* @param {number} [options.multiplier=1] - Output resolution multiplier.
|
|
1923
|
+
* @param {string} [options.fileName] - Optional file name (only used for download).
|
|
1414
1924
|
* @returns {Promise<File>} Resolves with the exported image as a File object.
|
|
1415
1925
|
*
|
|
1416
1926
|
* @example
|
|
1417
1927
|
* const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
|
|
1418
1928
|
*/
|
|
1419
|
-
async exportImageFile(
|
|
1929
|
+
async exportImageFile(options = {}) {
|
|
1420
1930
|
if (!this.originalImage)
|
|
1421
1931
|
throw new Error("No image loaded");
|
|
1422
1932
|
const {
|
|
@@ -1425,60 +1935,116 @@
|
|
|
1425
1935
|
quality = this.options.downsampleQuality ?? 0.92,
|
|
1426
1936
|
multiplier = this.options.exportMultiplier ?? 1,
|
|
1427
1937
|
fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
|
|
1428
|
-
} =
|
|
1429
|
-
const
|
|
1430
|
-
"jpeg": "jpeg",
|
|
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";
|
|
1938
|
+
} = options;
|
|
1939
|
+
const safeFileType = this._normalizeImageFormat(fileType);
|
|
1439
1940
|
let base64;
|
|
1440
1941
|
if (mergeMask) {
|
|
1441
|
-
base64 = await this.
|
|
1942
|
+
base64 = await this.exportImageBase64({
|
|
1442
1943
|
exportImageArea: true,
|
|
1443
|
-
multiplier
|
|
1944
|
+
multiplier,
|
|
1945
|
+
quality,
|
|
1946
|
+
fileType: safeFileType
|
|
1444
1947
|
});
|
|
1445
1948
|
} else {
|
|
1446
|
-
base64 = await this.
|
|
1949
|
+
base64 = await this.exportImageBase64({
|
|
1447
1950
|
exportImageArea: false,
|
|
1448
|
-
multiplier
|
|
1951
|
+
multiplier,
|
|
1952
|
+
quality,
|
|
1953
|
+
fileType: safeFileType
|
|
1449
1954
|
});
|
|
1450
1955
|
}
|
|
1451
1956
|
let imageDataUrl = base64;
|
|
1452
1957
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
1453
1958
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1959
|
+
const imageElement = new window.Image();
|
|
1960
|
+
imageElement.crossOrigin = "Anonymous";
|
|
1961
|
+
imageElement.onload = () => {
|
|
1457
1962
|
try {
|
|
1458
|
-
const
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1463
|
-
const
|
|
1464
|
-
resolve(
|
|
1465
|
-
} catch (
|
|
1466
|
-
reject(
|
|
1963
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
1964
|
+
offscreenCanvas.width = imageElement.width;
|
|
1965
|
+
offscreenCanvas.height = imageElement.height;
|
|
1966
|
+
const context = offscreenCanvas.getContext("2d");
|
|
1967
|
+
context.drawImage(imageElement, 0, 0);
|
|
1968
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
|
|
1969
|
+
resolve(convertedDataUrl);
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
reject(error);
|
|
1467
1972
|
}
|
|
1468
1973
|
};
|
|
1469
|
-
|
|
1470
|
-
|
|
1974
|
+
imageElement.onerror = reject;
|
|
1975
|
+
imageElement.src = base64;
|
|
1471
1976
|
});
|
|
1472
1977
|
}
|
|
1473
|
-
const
|
|
1978
|
+
const binaryString = atob(imageDataUrl.split(",")[1]);
|
|
1474
1979
|
const mime = `image/${safeFileType}`;
|
|
1475
|
-
let
|
|
1476
|
-
const
|
|
1477
|
-
while (
|
|
1478
|
-
|
|
1980
|
+
let byteIndex = binaryString.length;
|
|
1981
|
+
const bytes = new Uint8Array(byteIndex);
|
|
1982
|
+
while (byteIndex--) {
|
|
1983
|
+
bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
|
|
1984
|
+
}
|
|
1985
|
+
return new File([bytes], fileName, { type: mime });
|
|
1986
|
+
}
|
|
1987
|
+
_clearMaskPlacementMemory() {
|
|
1988
|
+
this._lastMask = null;
|
|
1989
|
+
this._lastMaskInitialLeft = null;
|
|
1990
|
+
this._lastMaskInitialTop = null;
|
|
1991
|
+
this._lastMaskInitialWidth = null;
|
|
1992
|
+
}
|
|
1993
|
+
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
1994
|
+
this._reportError(message, error);
|
|
1995
|
+
if (this._cropRect && this.canvas)
|
|
1996
|
+
this._removeCropRect();
|
|
1997
|
+
this._cropRect = null;
|
|
1998
|
+
this._cropMode = false;
|
|
1999
|
+
if (this.canvas && this._prevSelectionSetting !== void 0) {
|
|
2000
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2001
|
+
}
|
|
2002
|
+
this._prevSelectionSetting = void 0;
|
|
2003
|
+
if (beforeJson) {
|
|
2004
|
+
try {
|
|
2005
|
+
await this.loadFromState(beforeJson);
|
|
2006
|
+
} catch (restoreError) {
|
|
2007
|
+
this._reportError("applyCrop: rollback failed", restoreError);
|
|
2008
|
+
}
|
|
1479
2009
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
2010
|
+
this._updateUI();
|
|
2011
|
+
if (this.canvas)
|
|
2012
|
+
this.canvas.renderAll();
|
|
2013
|
+
}
|
|
2014
|
+
_restoreCropObjectState() {
|
|
2015
|
+
if (Array.isArray(this._cropPrevEvented)) {
|
|
2016
|
+
this._cropPrevEvented.forEach((state) => {
|
|
2017
|
+
try {
|
|
2018
|
+
state.object.set({
|
|
2019
|
+
evented: state.evented,
|
|
2020
|
+
selectable: state.selectable,
|
|
2021
|
+
visible: state.visible
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
this._cropPrevEvented = null;
|
|
2028
|
+
}
|
|
2029
|
+
_removeCropRect() {
|
|
2030
|
+
if (!this._cropRect)
|
|
2031
|
+
return;
|
|
2032
|
+
try {
|
|
2033
|
+
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2034
|
+
this._cropHandlers.forEach((targetHandlers) => {
|
|
2035
|
+
targetHandlers.handlers.forEach((handlerRecord) => {
|
|
2036
|
+
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
2037
|
+
});
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
}
|
|
2042
|
+
try {
|
|
2043
|
+
this.canvas.remove(this._cropRect);
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
}
|
|
2046
|
+
this._cropRect = null;
|
|
2047
|
+
this._cropHandlers = [];
|
|
1482
2048
|
}
|
|
1483
2049
|
/**
|
|
1484
2050
|
* Enter crop mode: create a resizable/movable selection rect on top of the image.
|
|
@@ -1494,12 +2060,12 @@
|
|
|
1494
2060
|
this.canvas.selection = false;
|
|
1495
2061
|
this.canvas.discardActiveObject();
|
|
1496
2062
|
this.originalImage.setCoords();
|
|
1497
|
-
const
|
|
2063
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1498
2064
|
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 width = Math.min(this.options.crop.minWidth || 50, Math.floor(
|
|
1502
|
-
const height = Math.min(this.options.crop.minHeight || 50, Math.floor(
|
|
2065
|
+
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2066
|
+
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2067
|
+
const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imageBounds.width - padding * 2));
|
|
2068
|
+
const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imageBounds.height - padding * 2));
|
|
1503
2069
|
const cropRect = new fabric.Rect({
|
|
1504
2070
|
left,
|
|
1505
2071
|
top,
|
|
@@ -1524,27 +2090,40 @@
|
|
|
1524
2090
|
this.canvas.setActiveObject(cropRect);
|
|
1525
2091
|
this._cropRect = cropRect;
|
|
1526
2092
|
this._cropPrevEvented = [];
|
|
1527
|
-
this.
|
|
1528
|
-
|
|
1529
|
-
|
|
2093
|
+
const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
|
|
2094
|
+
this.canvas.getObjects().forEach((object) => {
|
|
2095
|
+
if (object !== cropRect) {
|
|
2096
|
+
this._cropPrevEvented.push({ object, evented: object.evented, selectable: object.selectable, visible: object.visible });
|
|
1530
2097
|
try {
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
2098
|
+
const updates = {
|
|
2099
|
+
evented: false,
|
|
2100
|
+
selectable: false
|
|
2101
|
+
};
|
|
2102
|
+
if (shouldHideMasks && (object.maskId || object.maskLabel))
|
|
2103
|
+
updates.visible = false;
|
|
2104
|
+
object.set(updates);
|
|
2105
|
+
} catch (error) {
|
|
1534
2106
|
}
|
|
1535
2107
|
}
|
|
1536
2108
|
});
|
|
1537
|
-
const
|
|
2109
|
+
const handleCropRectModified = () => {
|
|
1538
2110
|
try {
|
|
1539
2111
|
cropRect.setCoords();
|
|
1540
2112
|
this.canvas.requestRenderAll();
|
|
1541
|
-
} catch (
|
|
2113
|
+
} catch (error) {
|
|
1542
2114
|
}
|
|
1543
2115
|
};
|
|
1544
|
-
cropRect.on("modified",
|
|
1545
|
-
cropRect.on("moving",
|
|
1546
|
-
cropRect.on("scaling",
|
|
1547
|
-
this._cropHandlers.push({
|
|
2116
|
+
cropRect.on("modified", handleCropRectModified);
|
|
2117
|
+
cropRect.on("moving", handleCropRectModified);
|
|
2118
|
+
cropRect.on("scaling", handleCropRectModified);
|
|
2119
|
+
this._cropHandlers.push({
|
|
2120
|
+
target: cropRect,
|
|
2121
|
+
handlers: [
|
|
2122
|
+
{ eventName: "modified", handler: handleCropRectModified },
|
|
2123
|
+
{ eventName: "moving", handler: handleCropRectModified },
|
|
2124
|
+
{ eventName: "scaling", handler: handleCropRectModified }
|
|
2125
|
+
]
|
|
2126
|
+
});
|
|
1548
2127
|
this._updateUI();
|
|
1549
2128
|
this.canvas.renderAll();
|
|
1550
2129
|
}
|
|
@@ -1555,32 +2134,8 @@
|
|
|
1555
2134
|
cancelCrop() {
|
|
1556
2135
|
if (!this.canvas || !this._cropMode)
|
|
1557
2136
|
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 = [];
|
|
2137
|
+
this._removeCropRect();
|
|
2138
|
+
this._restoreCropObjectState();
|
|
1584
2139
|
this._cropMode = false;
|
|
1585
2140
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
1586
2141
|
this._prevSelectionSetting = void 0;
|
|
@@ -1598,134 +2153,92 @@
|
|
|
1598
2153
|
return;
|
|
1599
2154
|
this._cropRect.setCoords();
|
|
1600
2155
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
1601
|
-
const sx
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
|
|
2156
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
|
|
2157
|
+
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2158
|
+
this._restoreCropObjectState();
|
|
1605
2159
|
let beforeJson = null;
|
|
1606
2160
|
try {
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
}
|
|
1611
|
-
beforeJson = JSON.stringify(jsonObj);
|
|
1612
|
-
} catch (e) {
|
|
1613
|
-
this._reportWarning("applyCrop: could not serialize before state", e);
|
|
2161
|
+
beforeJson = this._serializeCanvasState();
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
this._reportWarning("applyCrop: could not serialize before state", error);
|
|
1614
2164
|
beforeJson = null;
|
|
1615
2165
|
}
|
|
2166
|
+
const preservedMasks = [];
|
|
1616
2167
|
try {
|
|
1617
|
-
const masks = this.canvas.getObjects().filter((
|
|
2168
|
+
const masks = this.canvas.getObjects().filter((object) => object.maskId);
|
|
1618
2169
|
if (masks && masks.length) {
|
|
1619
|
-
masks.forEach((
|
|
2170
|
+
masks.forEach((mask) => {
|
|
1620
2171
|
try {
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
this.
|
|
2172
|
+
mask.setCoords();
|
|
2173
|
+
const maskBounds = mask.getBoundingRect(true, true);
|
|
2174
|
+
const intersectsCrop = maskBounds.left < sx + sw && maskBounds.left + maskBounds.width > sx && maskBounds.top < sy + sh && maskBounds.top + maskBounds.height > sy;
|
|
2175
|
+
this._removeLabelForMask(mask);
|
|
2176
|
+
this.canvas.remove(mask);
|
|
2177
|
+
if (shouldPreserveMasks && intersectsCrop) {
|
|
2178
|
+
mask.set({
|
|
2179
|
+
left: (mask.left || 0) - sx,
|
|
2180
|
+
top: (mask.top || 0) - sy,
|
|
2181
|
+
visible: true
|
|
2182
|
+
});
|
|
2183
|
+
mask.setCoords();
|
|
2184
|
+
preservedMasks.push(mask);
|
|
2185
|
+
}
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
this._reportWarning("applyCrop: failed to remove mask", error);
|
|
1625
2188
|
}
|
|
1626
2189
|
});
|
|
1627
|
-
this.
|
|
1628
|
-
this._lastMaskInitialLeft = null;
|
|
1629
|
-
this._lastMaskInitialTop = null;
|
|
1630
|
-
this._lastMaskInitialWidth = null;
|
|
2190
|
+
this._clearMaskPlacementMemory();
|
|
1631
2191
|
this.canvas.discardActiveObject();
|
|
1632
2192
|
this.canvas.renderAll();
|
|
1633
2193
|
}
|
|
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) {
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
this._reportWarning("applyCrop: error while removing masks", error);
|
|
1654
2196
|
}
|
|
2197
|
+
this._removeCropRect();
|
|
1655
2198
|
this._cropMode = false;
|
|
1656
2199
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
1657
2200
|
this._prevSelectionSetting = void 0;
|
|
1658
2201
|
let croppedBase64;
|
|
1659
2202
|
try {
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
2203
|
+
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2204
|
+
sx,
|
|
2205
|
+
sy,
|
|
2206
|
+
sw,
|
|
2207
|
+
sh,
|
|
2208
|
+
multiplier: 1,
|
|
2209
|
+
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2210
|
+
format: "jpeg"
|
|
1664
2211
|
});
|
|
1665
|
-
|
|
1666
|
-
|
|
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;
|
|
1682
|
-
});
|
|
1683
|
-
} catch (e) {
|
|
1684
|
-
this._reportError("applyCrop: failed to create cropped image", e);
|
|
1685
|
-
this._updateUI();
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: failed to create cropped image", error);
|
|
1686
2214
|
return;
|
|
1687
2215
|
}
|
|
1688
2216
|
try {
|
|
1689
2217
|
await this.loadImage(croppedBase64);
|
|
2218
|
+
if (preservedMasks.length) {
|
|
2219
|
+
preservedMasks.forEach((mask) => {
|
|
2220
|
+
this._rebindMaskEvents(mask);
|
|
2221
|
+
this.canvas.add(mask);
|
|
2222
|
+
this.canvas.bringToFront(mask);
|
|
2223
|
+
});
|
|
2224
|
+
this._lastMask = preservedMasks[preservedMasks.length - 1];
|
|
2225
|
+
this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
|
|
2226
|
+
this._updateMaskList();
|
|
2227
|
+
this.canvas.renderAll();
|
|
2228
|
+
}
|
|
1690
2229
|
} catch (e) {
|
|
1691
|
-
this.
|
|
1692
|
-
this._updateUI();
|
|
2230
|
+
await this._restoreStateAfterCropFailure(beforeJson, "applyCrop: loadImage(croppedBase64) failed", e);
|
|
1693
2231
|
return;
|
|
1694
2232
|
}
|
|
1695
2233
|
let afterJson = null;
|
|
1696
2234
|
try {
|
|
1697
|
-
|
|
1698
|
-
if (Array.isArray(jsonObj2.objects)) {
|
|
1699
|
-
jsonObj2.objects = jsonObj2.objects.filter((o) => !o.isCropRect);
|
|
1700
|
-
}
|
|
1701
|
-
afterJson = JSON.stringify(jsonObj2);
|
|
2235
|
+
afterJson = this._serializeCanvasState();
|
|
1702
2236
|
} catch (e) {
|
|
1703
2237
|
this._reportWarning("applyCrop: failed to serialize after state", e);
|
|
1704
2238
|
afterJson = null;
|
|
1705
2239
|
}
|
|
1706
2240
|
try {
|
|
1707
|
-
|
|
1708
|
-
const cmd = new Command(
|
|
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
|
-
}
|
|
2241
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
1729
2242
|
} catch (e) {
|
|
1730
2243
|
this._reportWarning("applyCrop: failed to push history command", e);
|
|
1731
2244
|
}
|
|
@@ -1739,9 +2252,9 @@
|
|
|
1739
2252
|
* @private
|
|
1740
2253
|
*/
|
|
1741
2254
|
_updateInputs() {
|
|
1742
|
-
const
|
|
1743
|
-
if (
|
|
1744
|
-
|
|
2255
|
+
const scaleInputElement = document.getElementById(this.elements.scaleRate);
|
|
2256
|
+
if (scaleInputElement)
|
|
2257
|
+
scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
1745
2258
|
}
|
|
1746
2259
|
/**
|
|
1747
2260
|
* Updates the enabled/disabled state of various UI controls (buttons)
|
|
@@ -1749,43 +2262,45 @@
|
|
|
1749
2262
|
* @private
|
|
1750
2263
|
*/
|
|
1751
2264
|
_updateUI() {
|
|
1752
|
-
const
|
|
1753
|
-
const masks =
|
|
2265
|
+
const hasImage = !!this.originalImage;
|
|
2266
|
+
const masks = hasImage ? this.canvas.getObjects().filter((object) => object.maskId) : [];
|
|
1754
2267
|
const hasMasks = masks.length > 0;
|
|
1755
|
-
const
|
|
1756
|
-
const hasSelectedMask =
|
|
1757
|
-
const
|
|
2268
|
+
const activeObject = this.canvas.getActiveObject();
|
|
2269
|
+
const hasSelectedMask = activeObject && activeObject.maskId;
|
|
2270
|
+
const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
|
|
1758
2271
|
const canUndo = this.historyManager?.canUndo();
|
|
1759
2272
|
const canRedo = this.historyManager?.canRedo();
|
|
1760
|
-
const
|
|
1761
|
-
if (
|
|
1762
|
-
for (const
|
|
1763
|
-
const
|
|
1764
|
-
if (!
|
|
2273
|
+
const isInCropMode = !!this._cropMode;
|
|
2274
|
+
if (isInCropMode) {
|
|
2275
|
+
for (const key of Object.keys(this.elements || {})) {
|
|
2276
|
+
const element = document.getElementById(this.elements[key]);
|
|
2277
|
+
if (!element)
|
|
1765
2278
|
continue;
|
|
1766
|
-
if (
|
|
1767
|
-
|
|
2279
|
+
if (key === "applyCropBtn" || key === "cancelCropBtn") {
|
|
2280
|
+
this._setDisabled(key, false);
|
|
1768
2281
|
} else {
|
|
1769
|
-
|
|
2282
|
+
this._setDisabled(key, true);
|
|
1770
2283
|
}
|
|
1771
2284
|
}
|
|
1772
2285
|
return;
|
|
1773
2286
|
}
|
|
1774
|
-
this._setDisabled("zoomInBtn", !
|
|
1775
|
-
this._setDisabled("zoomOutBtn", !
|
|
1776
|
-
this._setDisabled("rotateLeftBtn", !
|
|
1777
|
-
this._setDisabled("rotateRightBtn", !
|
|
1778
|
-
this._setDisabled("addMaskBtn", !
|
|
2287
|
+
this._setDisabled("zoomInBtn", !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
|
|
2288
|
+
this._setDisabled("zoomOutBtn", !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
|
|
2289
|
+
this._setDisabled("rotateLeftBtn", !hasImage || this.isAnimating);
|
|
2290
|
+
this._setDisabled("rotateRightBtn", !hasImage || this.isAnimating);
|
|
2291
|
+
this._setDisabled("addMaskBtn", !hasImage || this.isAnimating);
|
|
1779
2292
|
this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
|
|
1780
2293
|
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", !
|
|
2294
|
+
this._setDisabled("mergeBtn", !hasImage || !hasMasks || this.isAnimating);
|
|
2295
|
+
this._setDisabled("downloadBtn", !hasImage || this.isAnimating);
|
|
2296
|
+
this._setDisabled("resetBtn", !hasImage || isDefaultTransform || this.isAnimating);
|
|
2297
|
+
this._setDisabled("undoBtn", !hasImage || this.isAnimating || !canUndo);
|
|
2298
|
+
this._setDisabled("redoBtn", !hasImage || this.isAnimating || !canRedo);
|
|
2299
|
+
this._setDisabled("cropBtn", !hasImage || this.isAnimating);
|
|
1787
2300
|
this._setDisabled("applyCropBtn", true);
|
|
1788
2301
|
this._setDisabled("cancelCropBtn", true);
|
|
2302
|
+
this._setDisabled("imageInput", this.isAnimating);
|
|
2303
|
+
this._setDisabled("uploadArea", this.isAnimating);
|
|
1789
2304
|
}
|
|
1790
2305
|
/**
|
|
1791
2306
|
* Enables or disables a specific UI element (typically a button) by its key.
|
|
@@ -1795,9 +2310,27 @@
|
|
|
1795
2310
|
* @private
|
|
1796
2311
|
*/
|
|
1797
2312
|
_setDisabled(key, disabled) {
|
|
1798
|
-
const
|
|
1799
|
-
if (
|
|
1800
|
-
|
|
2313
|
+
const element = document.getElementById(this.elements[key]);
|
|
2314
|
+
if (!element)
|
|
2315
|
+
return;
|
|
2316
|
+
if ("disabled" in element) {
|
|
2317
|
+
element.disabled = !!disabled;
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
if (disabled) {
|
|
2321
|
+
element.setAttribute("aria-disabled", "true");
|
|
2322
|
+
element.style.pointerEvents = "none";
|
|
2323
|
+
} else {
|
|
2324
|
+
element.removeAttribute("aria-disabled");
|
|
2325
|
+
element.style.pointerEvents = "";
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
_isElementDisabled(element) {
|
|
2329
|
+
if (!element)
|
|
2330
|
+
return false;
|
|
2331
|
+
if ("disabled" in element)
|
|
2332
|
+
return !!element.disabled;
|
|
2333
|
+
return element.getAttribute("aria-disabled") === "true";
|
|
1801
2334
|
}
|
|
1802
2335
|
/**
|
|
1803
2336
|
* Automatically display and hide placeholders and containers based on the current image content
|
|
@@ -1814,16 +2347,16 @@
|
|
|
1814
2347
|
* @private
|
|
1815
2348
|
*/
|
|
1816
2349
|
_setPlaceholderVisible(show) {
|
|
1817
|
-
if (!this.
|
|
2350
|
+
if (!this.placeholderElement)
|
|
1818
2351
|
return;
|
|
1819
2352
|
if (show) {
|
|
1820
|
-
this.
|
|
1821
|
-
this.
|
|
1822
|
-
this.
|
|
2353
|
+
this.placeholderElement.classList.remove("d-none");
|
|
2354
|
+
this.placeholderElement.classList.add("d-flex");
|
|
2355
|
+
this.containerElement.classList.add("d-none");
|
|
1823
2356
|
} else {
|
|
1824
|
-
this.
|
|
1825
|
-
this.
|
|
1826
|
-
this.
|
|
2357
|
+
this.placeholderElement.classList.remove("d-flex");
|
|
2358
|
+
this.placeholderElement.classList.add("d-none");
|
|
2359
|
+
this.containerElement.classList.remove("d-none");
|
|
1827
2360
|
}
|
|
1828
2361
|
}
|
|
1829
2362
|
/**
|
|
@@ -1833,19 +2366,19 @@
|
|
|
1833
2366
|
*/
|
|
1834
2367
|
dispose() {
|
|
1835
2368
|
try {
|
|
1836
|
-
for (const key in this.
|
|
1837
|
-
const handlers = this.
|
|
1838
|
-
const
|
|
1839
|
-
if (!
|
|
2369
|
+
for (const key in this._handlersByElementKey || {}) {
|
|
2370
|
+
const handlers = this._handlersByElementKey[key] || [];
|
|
2371
|
+
const element = document.getElementById(this.elements[key]);
|
|
2372
|
+
if (!element)
|
|
1840
2373
|
continue;
|
|
1841
|
-
handlers.forEach((
|
|
2374
|
+
handlers.forEach((handlerRecord) => {
|
|
1842
2375
|
try {
|
|
1843
|
-
|
|
1844
|
-
} catch (
|
|
2376
|
+
element.removeEventListener(handlerRecord.eventName, handlerRecord.handler);
|
|
2377
|
+
} catch (error) {
|
|
1845
2378
|
}
|
|
1846
2379
|
});
|
|
1847
2380
|
}
|
|
1848
|
-
} catch (
|
|
2381
|
+
} catch (error) {
|
|
1849
2382
|
}
|
|
1850
2383
|
if (this._cropRect) {
|
|
1851
2384
|
try {
|
|
@@ -1854,16 +2387,22 @@
|
|
|
1854
2387
|
}
|
|
1855
2388
|
this._cropRect = null;
|
|
1856
2389
|
}
|
|
2390
|
+
if (this.containerElement && this._containerOriginalOverflow !== void 0) {
|
|
2391
|
+
try {
|
|
2392
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
2393
|
+
} catch (e) {
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
1857
2396
|
if (this.canvas) {
|
|
1858
2397
|
try {
|
|
1859
2398
|
this.canvas.dispose();
|
|
1860
2399
|
} catch (e) {
|
|
1861
2400
|
}
|
|
1862
2401
|
this.canvas = null;
|
|
1863
|
-
this.
|
|
2402
|
+
this.canvasElement = null;
|
|
1864
2403
|
this.isImageLoadedToCanvas = false;
|
|
1865
2404
|
}
|
|
1866
|
-
this.
|
|
2405
|
+
this._handlersByElementKey = {};
|
|
1867
2406
|
}
|
|
1868
2407
|
};
|
|
1869
2408
|
var AnimationQueue = class {
|
|
@@ -1930,6 +2469,13 @@
|
|
|
1930
2469
|
this.history = [];
|
|
1931
2470
|
this.currentIndex = -1;
|
|
1932
2471
|
this.maxSize = maxSize;
|
|
2472
|
+
this.pending = Promise.resolve();
|
|
2473
|
+
}
|
|
2474
|
+
enqueue(task) {
|
|
2475
|
+
const run = this.pending.then(task, task);
|
|
2476
|
+
this.pending = run.catch(() => {
|
|
2477
|
+
});
|
|
2478
|
+
return run;
|
|
1933
2479
|
}
|
|
1934
2480
|
/**
|
|
1935
2481
|
* Executes a new command and pushes it onto the history stack.
|
|
@@ -1940,6 +2486,16 @@
|
|
|
1940
2486
|
*/
|
|
1941
2487
|
execute(command) {
|
|
1942
2488
|
command.execute();
|
|
2489
|
+
this.push(command);
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Pushes an already-applied command onto the history stack.
|
|
2493
|
+
* Truncates any "future" history when branching.
|
|
2494
|
+
*
|
|
2495
|
+
* @param {Command} command The command to push.
|
|
2496
|
+
* @returns {void}
|
|
2497
|
+
*/
|
|
2498
|
+
push(command) {
|
|
1943
2499
|
if (this.currentIndex < this.history.length - 1) {
|
|
1944
2500
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
1945
2501
|
}
|
|
@@ -1972,10 +2528,13 @@
|
|
|
1972
2528
|
* @returns {void}
|
|
1973
2529
|
*/
|
|
1974
2530
|
undo() {
|
|
1975
|
-
|
|
1976
|
-
this.
|
|
1977
|
-
|
|
1978
|
-
|
|
2531
|
+
return this.enqueue(async () => {
|
|
2532
|
+
if (this.currentIndex >= 0) {
|
|
2533
|
+
const index = this.currentIndex;
|
|
2534
|
+
await this.history[index].undo();
|
|
2535
|
+
this.currentIndex = index - 1;
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
1979
2538
|
}
|
|
1980
2539
|
/**
|
|
1981
2540
|
* Redoes the next command in history if possible.
|
|
@@ -1983,10 +2542,13 @@
|
|
|
1983
2542
|
* @returns {void}
|
|
1984
2543
|
*/
|
|
1985
2544
|
redo() {
|
|
1986
|
-
|
|
1987
|
-
this.currentIndex
|
|
1988
|
-
|
|
1989
|
-
|
|
2545
|
+
return this.enqueue(async () => {
|
|
2546
|
+
if (this.currentIndex < this.history.length - 1) {
|
|
2547
|
+
const index = this.currentIndex + 1;
|
|
2548
|
+
await this.history[index].execute();
|
|
2549
|
+
this.currentIndex = index;
|
|
2550
|
+
}
|
|
2551
|
+
});
|
|
1990
2552
|
}
|
|
1991
2553
|
};
|
|
1992
2554
|
var image_editor_default = ImageEditor;
|