@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/src/image-editor.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file image-editor.js
|
|
3
3
|
* @module image-editor
|
|
4
|
-
* @version 1.2.
|
|
4
|
+
* @version 1.2.2
|
|
5
5
|
* @author Ben Situ
|
|
6
6
|
* @license MIT
|
|
7
7
|
* @description Lightweight canvas-based image editor with masking/transform/export support.
|
|
8
|
-
*
|
|
9
|
-
* This source file is free software, available under the MIT license.
|
|
10
|
-
* It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
|
11
|
-
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
12
|
-
* See the license files for details.
|
|
13
8
|
*/
|
|
14
9
|
|
|
15
10
|
let fabric = null;
|
|
@@ -64,7 +59,7 @@ function ensureFabric() {
|
|
|
64
59
|
* @param {number} [options.rotationStep=90] - Rotation step in degrees.
|
|
65
60
|
* @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image/mask.
|
|
66
61
|
* @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
|
|
67
|
-
* @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover
|
|
62
|
+
* @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
|
|
68
63
|
* @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
|
|
69
64
|
* @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
|
|
70
65
|
* @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
|
|
@@ -176,9 +171,9 @@ function ensureFabric() {
|
|
|
176
171
|
|
|
177
172
|
// Runtime state
|
|
178
173
|
this.canvas = null;
|
|
179
|
-
this.
|
|
180
|
-
this.
|
|
181
|
-
this.
|
|
174
|
+
this.canvasElement = null;
|
|
175
|
+
this.containerElement = null;
|
|
176
|
+
this.placeholderElement = null;
|
|
182
177
|
|
|
183
178
|
this.originalImage = null; // fabric.Image
|
|
184
179
|
this.baseImageScale = 1;
|
|
@@ -190,16 +185,20 @@ function ensureFabric() {
|
|
|
190
185
|
this.isImageLoadedToCanvas = false;
|
|
191
186
|
this.maxHistorySize = 50;
|
|
192
187
|
|
|
193
|
-
this.
|
|
188
|
+
this._handlersByElementKey = {};
|
|
194
189
|
|
|
195
190
|
this._lastMask = null;
|
|
196
191
|
this._lastMaskInitialLeft = null;
|
|
197
192
|
this._lastMaskInitialTop = null;
|
|
198
193
|
this._lastMaskInitialWidth = null;
|
|
194
|
+
this._lastSnapshot = null;
|
|
199
195
|
|
|
200
196
|
this._cropMode = false;
|
|
201
197
|
this._cropRect = null;
|
|
202
198
|
this._cropHandlers = [];
|
|
199
|
+
this._cropPrevEvented = null;
|
|
200
|
+
this._prevSelectionSetting = undefined;
|
|
201
|
+
this._containerOriginalOverflow = undefined;
|
|
203
202
|
|
|
204
203
|
this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
|
|
205
204
|
|
|
@@ -207,6 +206,39 @@ function ensureFabric() {
|
|
|
207
206
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
208
207
|
}
|
|
209
208
|
|
|
209
|
+
/**
|
|
210
|
+
* @deprecated Use canvasElement instead.
|
|
211
|
+
*/
|
|
212
|
+
get canvasEl() {
|
|
213
|
+
return this.canvasElement;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
set canvasEl(value) {
|
|
217
|
+
this.canvasElement = value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @deprecated Use containerElement instead.
|
|
222
|
+
*/
|
|
223
|
+
get containerEl() {
|
|
224
|
+
return this.containerElement;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
set containerEl(value) {
|
|
228
|
+
this.containerElement = value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @deprecated Use placeholderElement instead.
|
|
233
|
+
*/
|
|
234
|
+
get placeholderEl() {
|
|
235
|
+
return this.placeholderElement;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
set placeholderEl(value) {
|
|
239
|
+
this.placeholderElement = value;
|
|
240
|
+
}
|
|
241
|
+
|
|
210
242
|
/**
|
|
211
243
|
* Initializes the editor, binds to DOM elements, sets up event handlers,
|
|
212
244
|
* and (optionally) loads an initial image.
|
|
@@ -299,48 +331,93 @@ function ensureFabric() {
|
|
|
299
331
|
* @private
|
|
300
332
|
*/
|
|
301
333
|
_initCanvas() {
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
304
|
-
this.
|
|
334
|
+
const canvasElement = document.getElementById(this.elements.canvas);
|
|
335
|
+
if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
|
|
336
|
+
this.canvasElement = canvasElement;
|
|
305
337
|
|
|
306
338
|
// Decide which element acts as "viewport" (for width/height fallback)
|
|
307
339
|
if (this.elements.canvasContainer) {
|
|
308
|
-
const
|
|
309
|
-
this.
|
|
340
|
+
const containerElement = document.getElementById(this.elements.canvasContainer);
|
|
341
|
+
this.containerElement = containerElement || canvasElement.parentElement;
|
|
310
342
|
} else {
|
|
311
|
-
this.
|
|
343
|
+
this.containerElement = canvasElement.parentElement;
|
|
312
344
|
}
|
|
313
345
|
|
|
314
|
-
this.
|
|
346
|
+
this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
|
|
315
347
|
|
|
316
348
|
// Initial size — take container size if available
|
|
317
|
-
let
|
|
318
|
-
let
|
|
319
|
-
if (this.
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
349
|
+
let initialWidth = this.options.canvasWidth;
|
|
350
|
+
let initialHeight = this.options.canvasHeight;
|
|
351
|
+
if (this.containerElement) {
|
|
352
|
+
const containerWidth = Math.floor(this.containerElement.clientWidth);
|
|
353
|
+
const containerHeight = Math.floor(this.containerElement.clientHeight);
|
|
354
|
+
if (containerWidth > 0 && containerHeight > 0) {
|
|
355
|
+
initialWidth = containerWidth;
|
|
356
|
+
initialHeight = containerHeight;
|
|
357
|
+
}
|
|
323
358
|
}
|
|
324
359
|
|
|
325
|
-
this.canvas = new fabric.Canvas(
|
|
326
|
-
width:
|
|
327
|
-
height:
|
|
360
|
+
this.canvas = new fabric.Canvas(canvasElement, {
|
|
361
|
+
width: initialWidth,
|
|
362
|
+
height: initialHeight,
|
|
328
363
|
backgroundColor: this.options.backgroundColor,
|
|
329
364
|
selection: this.options.groupSelection,
|
|
330
365
|
preserveObjectStacking: true
|
|
331
366
|
});
|
|
332
367
|
|
|
333
368
|
// Fabric event wiring
|
|
334
|
-
this.canvas.on('selection:created', (
|
|
335
|
-
this.canvas.on('selection:updated', (
|
|
336
|
-
this.canvas.on('selection:cleared', () => this.
|
|
337
|
-
this.canvas.on('object:moving', (
|
|
338
|
-
this.canvas.on('object:scaling', (
|
|
339
|
-
this.canvas.on('object:rotating', (
|
|
340
|
-
this.canvas.on('object:modified', (
|
|
369
|
+
this.canvas.on('selection:created', (event) => this._handleSelectionChanged(event.selected));
|
|
370
|
+
this.canvas.on('selection:updated', (event) => this._handleSelectionChanged(event.selected));
|
|
371
|
+
this.canvas.on('selection:cleared', () => this._handleSelectionChanged([]));
|
|
372
|
+
this.canvas.on('object:moving', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
|
|
373
|
+
this.canvas.on('object:scaling', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
|
|
374
|
+
this.canvas.on('object:rotating', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
|
|
375
|
+
this.canvas.on('object:modified', (event) => this._handleObjectModified(event.target));
|
|
341
376
|
|
|
342
377
|
// Avoid inline-element whitespace artefacts
|
|
343
|
-
this.
|
|
378
|
+
this.canvasElement.style.display = 'block';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_handleObjectModified(target) {
|
|
382
|
+
const masks = this._getModifiedMasks(target);
|
|
383
|
+
if (!masks.length) return;
|
|
384
|
+
masks.forEach(mask => {
|
|
385
|
+
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
386
|
+
this._syncMaskLabel(mask);
|
|
387
|
+
this._expandCanvasToFitObject(mask);
|
|
388
|
+
});
|
|
389
|
+
this.saveState();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_getModifiedMasks(target) {
|
|
393
|
+
if (!target) return [];
|
|
394
|
+
if (target.maskId) return [target];
|
|
395
|
+
|
|
396
|
+
const objects = typeof target.getObjects === 'function' ? target.getObjects() : [];
|
|
397
|
+
|
|
398
|
+
return Array.isArray(objects) ? objects.filter(object => object && object.maskId) : [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_syncContainerOverflow() {
|
|
402
|
+
if (!this.containerElement || !this.containerElement.style) return;
|
|
403
|
+
if (this._containerOriginalOverflow === undefined) {
|
|
404
|
+
this._containerOriginalOverflow = this.containerElement.style.overflow || '';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (this.options.coverImageToCanvas) {
|
|
408
|
+
const shouldResetScroll = !this.isImageLoadedToCanvas;
|
|
409
|
+
this.containerElement.style.overflow = 'scroll';
|
|
410
|
+
if (shouldResetScroll) {
|
|
411
|
+
this.containerElement.scrollLeft = 0;
|
|
412
|
+
this.containerElement.scrollTop = 0;
|
|
413
|
+
}
|
|
414
|
+
} else if (this.options.fitImageToCanvas) {
|
|
415
|
+
this.containerElement.style.overflow = 'auto';
|
|
416
|
+
this.containerElement.scrollLeft = 0;
|
|
417
|
+
this.containerElement.scrollTop = 0;
|
|
418
|
+
} else {
|
|
419
|
+
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
420
|
+
}
|
|
344
421
|
}
|
|
345
422
|
|
|
346
423
|
/**
|
|
@@ -349,67 +426,72 @@ function ensureFabric() {
|
|
|
349
426
|
*/
|
|
350
427
|
_bindEvents() {
|
|
351
428
|
// Click anywhere on the upload area opens the native file dialog
|
|
352
|
-
this._bindIfExists('uploadArea', 'click', () =>
|
|
429
|
+
this._bindIfExists('uploadArea', 'click', () => {
|
|
430
|
+
const uploadAreaElement = document.getElementById(this.elements.uploadArea);
|
|
431
|
+
if (this._isElementDisabled(uploadAreaElement)) return;
|
|
432
|
+
document.getElementById(this.elements.imageInput)?.click();
|
|
433
|
+
});
|
|
353
434
|
// File-input change
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (f) this._loadImageFile(f);
|
|
359
|
-
});
|
|
360
|
-
}
|
|
435
|
+
this._bindIfExists('imageInput', 'change', (event) => {
|
|
436
|
+
const file = event.target.files && event.target.files[0];
|
|
437
|
+
if (file) this._loadImageFile(file);
|
|
438
|
+
});
|
|
361
439
|
// Zoom & reset
|
|
362
440
|
this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
|
|
363
441
|
this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
|
|
364
|
-
this._bindIfExists('resetBtn', 'click', () => { this.
|
|
442
|
+
this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
|
|
365
443
|
// Mask management
|
|
366
|
-
this._bindIfExists('addMaskBtn', 'click', () => this.
|
|
444
|
+
this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
|
|
367
445
|
this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
|
|
368
446
|
this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
|
|
369
447
|
// Merge + download
|
|
370
|
-
this._bindIfExists('mergeBtn', 'click', () => this.
|
|
448
|
+
this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
|
|
371
449
|
this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
|
|
372
450
|
// Undo + Redo
|
|
373
451
|
this._bindIfExists('undoBtn', 'click', () => this.undo());
|
|
374
452
|
this._bindIfExists('redoBtn', 'click', () => this.redo());
|
|
375
453
|
|
|
376
454
|
// Rotation buttons (step can be overridden by two input fields)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (rotLeftBtn) rotLeftBtn.addEventListener('click', () => {
|
|
380
|
-
const el = document.getElementById(this.elements.rotationLeftInput);
|
|
455
|
+
this._bindIfExists('rotateLeftBtn', 'click', () => {
|
|
456
|
+
const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
|
|
381
457
|
let step = this.options.rotationStep;
|
|
382
|
-
if (
|
|
458
|
+
if (rotationInputElement) {
|
|
459
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
460
|
+
if (!isNaN(parsedStep)) step = parsedStep;
|
|
461
|
+
}
|
|
383
462
|
this.rotateImage(this.currentRotation - step);
|
|
384
463
|
});
|
|
385
|
-
|
|
386
|
-
const
|
|
464
|
+
this._bindIfExists('rotateRightBtn', 'click', () => {
|
|
465
|
+
const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
|
|
387
466
|
let step = this.options.rotationStep;
|
|
388
|
-
if (
|
|
467
|
+
if (rotationInputElement) {
|
|
468
|
+
const parsedStep = parseFloat(rotationInputElement.value);
|
|
469
|
+
if (!isNaN(parsedStep)) step = parsedStep;
|
|
470
|
+
}
|
|
389
471
|
this.rotateImage(this.currentRotation + step);
|
|
390
472
|
});
|
|
391
473
|
|
|
392
474
|
// Crop bindings (optional: bound only if element IDs exist in elements)
|
|
393
475
|
this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
|
|
394
|
-
this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(
|
|
476
|
+
this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
|
|
395
477
|
this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
|
|
396
478
|
}
|
|
397
479
|
|
|
398
480
|
/**
|
|
399
481
|
* Event binding element check
|
|
400
482
|
*
|
|
401
|
-
* @param {*}
|
|
483
|
+
* @param {*} eventName
|
|
402
484
|
* @param {*} handler
|
|
403
485
|
* @param {*} key
|
|
404
486
|
* @private
|
|
405
487
|
*/
|
|
406
|
-
_bindIfExists(key,
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
this.
|
|
411
|
-
if (!this.
|
|
412
|
-
this.
|
|
488
|
+
_bindIfExists(key, eventName, handler) {
|
|
489
|
+
const element = document.getElementById(this.elements[key]);
|
|
490
|
+
if (element) {
|
|
491
|
+
element.addEventListener(eventName, handler);
|
|
492
|
+
this._handlersByElementKey = this._handlersByElementKey || {};
|
|
493
|
+
if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
|
|
494
|
+
this._handlersByElementKey[key].push({ eventName, handler });
|
|
413
495
|
}
|
|
414
496
|
}
|
|
415
497
|
|
|
@@ -422,100 +504,99 @@ function ensureFabric() {
|
|
|
422
504
|
_loadImageFile(file) {
|
|
423
505
|
if (!file || !file.type.startsWith('image/')) return;
|
|
424
506
|
const reader = new FileReader();
|
|
425
|
-
reader.onload = (
|
|
426
|
-
reader.onerror = (
|
|
507
|
+
reader.onload = (event) => this.loadImage(event.target.result);
|
|
508
|
+
reader.onerror = (event) => { this._reportError('Image file could not be read', event); };
|
|
427
509
|
reader.readAsDataURL(file);
|
|
428
510
|
}
|
|
429
511
|
|
|
430
512
|
/**
|
|
431
513
|
* Load a base64 encoded image string into fabric.
|
|
432
514
|
* @async
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
async loadImage(
|
|
515
|
+
* @param {String} imageBase64
|
|
516
|
+
*/
|
|
517
|
+
async loadImage(imageBase64) {
|
|
436
518
|
if (!this._fabricLoaded) return;
|
|
437
519
|
if (!this.canvas) return;
|
|
438
|
-
if (!
|
|
520
|
+
if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
|
|
439
521
|
|
|
440
522
|
this._setPlaceholderVisible(false);
|
|
523
|
+
this._syncContainerOverflow();
|
|
441
524
|
|
|
442
|
-
const
|
|
525
|
+
const imageElement = await this._createImageElement(imageBase64);
|
|
443
526
|
|
|
444
|
-
let
|
|
527
|
+
let loadSource = imageBase64;
|
|
445
528
|
if (this.options.downsampleOnLoad) {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (
|
|
529
|
+
const shouldResize =
|
|
530
|
+
imageElement.naturalWidth > this.options.downsampleMaxWidth ||
|
|
531
|
+
imageElement.naturalHeight > this.options.downsampleMaxHeight;
|
|
532
|
+
if (shouldResize) {
|
|
450
533
|
const ratio = Math.min(
|
|
451
|
-
this.options.downsampleMaxWidth /
|
|
452
|
-
this.options.downsampleMaxHeight /
|
|
534
|
+
this.options.downsampleMaxWidth / imageElement.naturalWidth,
|
|
535
|
+
this.options.downsampleMaxHeight / imageElement.naturalHeight
|
|
453
536
|
);
|
|
454
|
-
const
|
|
455
|
-
const
|
|
456
|
-
|
|
537
|
+
const targetWidth = Math.round(imageElement.naturalWidth * ratio);
|
|
538
|
+
const targetHeight = Math.round(imageElement.naturalHeight * ratio);
|
|
539
|
+
loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
|
|
457
540
|
}
|
|
458
541
|
}
|
|
459
542
|
|
|
460
543
|
// Create fabric.Image from URL
|
|
461
544
|
return new Promise((resolve, reject) => {
|
|
462
|
-
fabric.Image.fromURL(
|
|
545
|
+
fabric.Image.fromURL(loadSource, (fabricImage) => {
|
|
463
546
|
try {
|
|
464
|
-
if (!
|
|
547
|
+
if (!fabricImage) throw new Error('Image could not be loaded');
|
|
465
548
|
|
|
466
549
|
this.canvas.discardActiveObject();
|
|
467
550
|
this._hideAllMaskLabels();
|
|
468
551
|
this.canvas.clear();
|
|
469
552
|
this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
|
|
470
553
|
|
|
471
|
-
|
|
554
|
+
fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
|
|
472
555
|
|
|
473
|
-
const
|
|
474
|
-
const
|
|
556
|
+
const imageWidth = fabricImage.width;
|
|
557
|
+
const imageHeight = fabricImage.height;
|
|
475
558
|
|
|
476
|
-
const
|
|
477
|
-
const
|
|
559
|
+
const viewport = this._getContainerViewportSize();
|
|
560
|
+
const minWidth = viewport.width;
|
|
561
|
+
const minHeight = viewport.height;
|
|
478
562
|
|
|
479
563
|
if (this.options.fitImageToCanvas) {
|
|
480
564
|
// Fit into current canvas (shrink only) and ensure canvas does not exceed container
|
|
481
|
-
const
|
|
482
|
-
const
|
|
483
|
-
this._setCanvasSizeInt(
|
|
484
|
-
const fitScale = Math.min(
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
this.baseImageScale =
|
|
565
|
+
const canvasWidth = Math.max(1, Math.min(this.options.canvasWidth, minWidth) - 1)
|
|
566
|
+
const canvasHeight = Math.max(1, Math.min(this.options.canvasHeight, minHeight) - 1);
|
|
567
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
568
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
569
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
570
|
+
fabricImage.scale(fitScale);
|
|
571
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
488
572
|
} else if (this.options.coverImageToCanvas) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
fimg.set({ left: 0, top: 0 });
|
|
495
|
-
fimg.scale(coverScale);
|
|
496
|
-
this.baseImageScale = fimg.scaleX || 1;
|
|
573
|
+
const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
|
|
574
|
+
this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
|
|
575
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
576
|
+
fabricImage.scale(layout.scale);
|
|
577
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
497
578
|
} else if (this.options.expandCanvasToImage) {
|
|
498
579
|
// Expand canvas so that it fully contains the image
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
this._setCanvasSizeInt(
|
|
502
|
-
|
|
503
|
-
|
|
580
|
+
const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
|
|
581
|
+
const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
|
|
582
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
583
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
584
|
+
fabricImage.scale(1);
|
|
504
585
|
this.baseImageScale = 1;
|
|
505
586
|
} else {
|
|
506
587
|
// Keep existing canvas size and center the image
|
|
507
|
-
const
|
|
508
|
-
const
|
|
509
|
-
this._setCanvasSizeInt(
|
|
510
|
-
const fitScale = Math.min(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
this.baseImageScale =
|
|
588
|
+
const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
|
|
589
|
+
const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
|
|
590
|
+
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
591
|
+
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
592
|
+
fabricImage.set({ left: 0, top: 0 });
|
|
593
|
+
fabricImage.scale(fitScale);
|
|
594
|
+
this.baseImageScale = fabricImage.scaleX || 1;
|
|
514
595
|
}
|
|
515
596
|
// Put the image onto the canvas
|
|
516
|
-
this.originalImage =
|
|
517
|
-
this.canvas.add(
|
|
518
|
-
this.canvas.sendToBack(
|
|
597
|
+
this.originalImage = fabricImage;
|
|
598
|
+
this.canvas.add(fabricImage);
|
|
599
|
+
this.canvas.sendToBack(fabricImage);
|
|
519
600
|
|
|
520
601
|
// Reset mask placement memory
|
|
521
602
|
this._lastMask = null;
|
|
@@ -532,14 +613,19 @@ function ensureFabric() {
|
|
|
532
613
|
this.isImageLoadedToCanvas = true;
|
|
533
614
|
this._updateUI();
|
|
534
615
|
this.canvas.renderAll();
|
|
616
|
+
try {
|
|
617
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this._reportWarning('loadImage: failed to capture initial canvas snapshot', error);
|
|
620
|
+
}
|
|
535
621
|
|
|
536
622
|
if (typeof this.onImageLoaded === 'function') {
|
|
537
623
|
this.onImageLoaded();
|
|
538
624
|
}
|
|
539
625
|
|
|
540
626
|
resolve();
|
|
541
|
-
} catch (
|
|
542
|
-
reject(
|
|
627
|
+
} catch (error) {
|
|
628
|
+
reject(error);
|
|
543
629
|
}
|
|
544
630
|
}, { crossOrigin: 'anonymous' });
|
|
545
631
|
});
|
|
@@ -563,44 +649,44 @@ function ensureFabric() {
|
|
|
563
649
|
/**
|
|
564
650
|
* Creates an HTMLImageElement from a given data URL.
|
|
565
651
|
*
|
|
566
|
-
* @param {string}
|
|
652
|
+
* @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
|
|
567
653
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
568
654
|
* @private
|
|
569
655
|
*/
|
|
570
|
-
_createImageElement(
|
|
571
|
-
return new Promise((
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
656
|
+
_createImageElement(dataUrl) {
|
|
657
|
+
return new Promise((resolve, reject) => {
|
|
658
|
+
const imageElement = new Image();
|
|
659
|
+
imageElement.onload = () => {
|
|
660
|
+
imageElement.onload = null;
|
|
661
|
+
imageElement.onerror = null;
|
|
662
|
+
resolve(imageElement);
|
|
577
663
|
};
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
664
|
+
imageElement.onerror = (error) => {
|
|
665
|
+
imageElement.onload = null;
|
|
666
|
+
imageElement.onerror = null;
|
|
667
|
+
reject(error);
|
|
582
668
|
};
|
|
583
|
-
|
|
669
|
+
imageElement.src = dataUrl;
|
|
584
670
|
});
|
|
585
671
|
}
|
|
586
672
|
|
|
587
673
|
/**
|
|
588
674
|
* Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
|
|
589
675
|
*
|
|
590
|
-
* @param {HTMLImageElement}
|
|
591
|
-
* @param {number}
|
|
592
|
-
* @param {number}
|
|
676
|
+
* @param {HTMLImageElement} imageElement - The image element to resample.
|
|
677
|
+
* @param {number} targetWidth - Target width (in pixels) for the resampled image.
|
|
678
|
+
* @param {number} targetHeight - Target height (in pixels) for the resampled image.
|
|
593
679
|
* @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
|
|
594
680
|
* @returns {string} A data URL representing the resampled image as JPEG.
|
|
595
681
|
* @private
|
|
596
682
|
*/
|
|
597
|
-
_resampleImageToDataURL(
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
return
|
|
683
|
+
_resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
|
|
684
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
685
|
+
offscreenCanvas.width = targetWidth;
|
|
686
|
+
offscreenCanvas.height = targetHeight;
|
|
687
|
+
const context = offscreenCanvas.getContext('2d');
|
|
688
|
+
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
689
|
+
return offscreenCanvas.toDataURL('image/jpeg', quality);
|
|
604
690
|
}
|
|
605
691
|
|
|
606
692
|
/**
|
|
@@ -619,60 +705,401 @@ function ensureFabric() {
|
|
|
619
705
|
this.canvas.setHeight(ih);
|
|
620
706
|
if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
|
|
621
707
|
// Keep DOM element in sync (avoid fractional CSS pixels)
|
|
622
|
-
if (this.
|
|
623
|
-
this.
|
|
624
|
-
this.
|
|
625
|
-
this.
|
|
708
|
+
if (this.canvasElement) {
|
|
709
|
+
this.canvasElement.style.width = iw + 'px';
|
|
710
|
+
this.canvasElement.style.height = ih + 'px';
|
|
711
|
+
this.canvasElement.style.maxWidth = 'none';
|
|
626
712
|
}
|
|
627
713
|
}
|
|
628
714
|
|
|
715
|
+
_ceilCanvasDimension(value) {
|
|
716
|
+
const numericValue = Number(value) || 0;
|
|
717
|
+
const roundedValue = Math.round(numericValue);
|
|
718
|
+
if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
|
|
719
|
+
return Math.ceil(numericValue);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_getContainerViewportSize() {
|
|
723
|
+
if (!this.containerElement) {
|
|
724
|
+
return {
|
|
725
|
+
width: Math.max(1, Math.floor(this.options.canvasWidth || 1)),
|
|
726
|
+
height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
731
|
+
return {
|
|
732
|
+
width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
|
|
733
|
+
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const previousOverflow = this.containerElement.style.overflow;
|
|
738
|
+
this.containerElement.style.overflow = 'hidden';
|
|
739
|
+
|
|
740
|
+
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
741
|
+
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
742
|
+
|
|
743
|
+
this.containerElement.style.overflow = previousOverflow;
|
|
744
|
+
return { width, height };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
_hasFixedContainerScrollbars() {
|
|
748
|
+
if (!this.containerElement) return false;
|
|
749
|
+
const inlineOverflow = this.containerElement.style.overflow;
|
|
750
|
+
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
751
|
+
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
752
|
+
let computedOverflow = '';
|
|
753
|
+
let computedOverflowX = '';
|
|
754
|
+
let computedOverflowY = '';
|
|
755
|
+
|
|
756
|
+
if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
|
|
757
|
+
const style = window.getComputedStyle(this.containerElement);
|
|
758
|
+
computedOverflow = style.overflow;
|
|
759
|
+
computedOverflowX = style.overflowX;
|
|
760
|
+
computedOverflowY = style.overflowY;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY]
|
|
764
|
+
.some(value => value === 'scroll');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_getScrollbarSize() {
|
|
768
|
+
if (typeof document === 'undefined' || !document.createElement || !document.body) {
|
|
769
|
+
return { width: 0, height: 0 };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const probe = document.createElement('div');
|
|
773
|
+
probe.style.position = 'absolute';
|
|
774
|
+
probe.style.visibility = 'hidden';
|
|
775
|
+
probe.style.overflow = 'scroll';
|
|
776
|
+
probe.style.width = '100px';
|
|
777
|
+
probe.style.height = '100px';
|
|
778
|
+
probe.style.top = '-9999px';
|
|
779
|
+
document.body.appendChild(probe);
|
|
780
|
+
|
|
781
|
+
const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
|
782
|
+
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
783
|
+
document.body.removeChild(probe);
|
|
784
|
+
|
|
785
|
+
return { width, height };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
_getScrollSafetyMargin() {
|
|
789
|
+
return 2;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
_getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
|
|
793
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
794
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
795
|
+
const safeWidth = Math.max(1, viewport.width - safetyMargin);
|
|
796
|
+
const safeHeight = Math.max(1, viewport.height - safetyMargin);
|
|
797
|
+
return {
|
|
798
|
+
width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
|
|
799
|
+
height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
|
|
800
|
+
viewportWidth: viewport.width,
|
|
801
|
+
viewportHeight: viewport.height,
|
|
802
|
+
hasHorizontal: true,
|
|
803
|
+
hasVertical: true
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const scrollbar = this._getScrollbarSize();
|
|
808
|
+
let hasVertical = false;
|
|
809
|
+
let hasHorizontal = false;
|
|
810
|
+
let effectiveWidth = viewport.width;
|
|
811
|
+
let effectiveHeight = viewport.height;
|
|
812
|
+
|
|
813
|
+
for (let i = 0; i < 4; i += 1) {
|
|
814
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
815
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
816
|
+
|
|
817
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
818
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
819
|
+
|
|
820
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
|
|
821
|
+
hasVertical = nextHasVertical;
|
|
822
|
+
hasHorizontal = nextHasHorizontal;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
826
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
|
|
830
|
+
height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
|
|
831
|
+
viewportWidth: effectiveWidth,
|
|
832
|
+
viewportHeight: effectiveHeight,
|
|
833
|
+
hasHorizontal,
|
|
834
|
+
hasVertical
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
_calculateCoverCanvasLayout(imageWidth, imageHeight) {
|
|
839
|
+
const viewport = this._getContainerViewportSize();
|
|
840
|
+
|
|
841
|
+
if (this._hasFixedContainerScrollbars()) {
|
|
842
|
+
const safetyMargin = this._getScrollSafetyMargin();
|
|
843
|
+
const targetWidth = Math.max(1, viewport.width - safetyMargin);
|
|
844
|
+
const targetHeight = Math.max(1, viewport.height - safetyMargin);
|
|
845
|
+
const scale = Math.min(1, Math.max(targetWidth / imageWidth, targetHeight / imageHeight));
|
|
846
|
+
const contentWidth = imageWidth * scale;
|
|
847
|
+
const contentHeight = imageHeight * scale;
|
|
848
|
+
const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
|
|
849
|
+
return {
|
|
850
|
+
scale,
|
|
851
|
+
canvasWidth: canvasSize.width,
|
|
852
|
+
canvasHeight: canvasSize.height
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const scrollbar = this._getScrollbarSize();
|
|
857
|
+
let hasVertical = false;
|
|
858
|
+
let hasHorizontal = false;
|
|
859
|
+
let scale = 1;
|
|
860
|
+
let contentWidth = imageWidth;
|
|
861
|
+
let contentHeight = imageHeight;
|
|
862
|
+
let effectiveWidth = viewport.width;
|
|
863
|
+
let effectiveHeight = viewport.height;
|
|
864
|
+
|
|
865
|
+
for (let i = 0; i < 4; i += 1) {
|
|
866
|
+
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
867
|
+
effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
|
|
868
|
+
scale = Math.min(1, Math.max(effectiveWidth / imageWidth, effectiveHeight / imageHeight));
|
|
869
|
+
contentWidth = imageWidth * scale;
|
|
870
|
+
contentHeight = imageHeight * scale;
|
|
871
|
+
|
|
872
|
+
const nextHasVertical = contentHeight > effectiveHeight + 0.5;
|
|
873
|
+
const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
|
|
874
|
+
|
|
875
|
+
if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
|
|
876
|
+
hasVertical = nextHasVertical;
|
|
877
|
+
hasHorizontal = nextHasHorizontal;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
|
|
881
|
+
return {
|
|
882
|
+
scale,
|
|
883
|
+
canvasWidth: canvasSize.width,
|
|
884
|
+
canvasHeight: canvasSize.height
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
_getStateProperties() {
|
|
889
|
+
return [
|
|
890
|
+
'maskId',
|
|
891
|
+
'maskName',
|
|
892
|
+
'maskLabel',
|
|
893
|
+
'isCropRect',
|
|
894
|
+
'originalAlpha',
|
|
895
|
+
'originalStroke',
|
|
896
|
+
'originalStrokeWidth',
|
|
897
|
+
'selectable',
|
|
898
|
+
'evented',
|
|
899
|
+
'hasControls',
|
|
900
|
+
'lockRotation',
|
|
901
|
+
'borderColor',
|
|
902
|
+
'cornerColor',
|
|
903
|
+
'cornerSize',
|
|
904
|
+
'transparentCorners',
|
|
905
|
+
'strokeUniform',
|
|
906
|
+
'strokeDashArray'
|
|
907
|
+
];
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
_getMaskNormalStyle(mask) {
|
|
911
|
+
const strokeWidth = Number(mask && mask.originalStrokeWidth);
|
|
912
|
+
const opacity = Number(mask && mask.originalAlpha);
|
|
913
|
+
const style = {
|
|
914
|
+
stroke: (mask && mask.originalStroke) || '#ccc',
|
|
915
|
+
strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
|
|
916
|
+
};
|
|
917
|
+
if (Number.isFinite(opacity)) style.opacity = opacity;
|
|
918
|
+
return style;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
_withNormalizedMaskStyles(callback) {
|
|
922
|
+
if (!this.canvas) return callback();
|
|
923
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
924
|
+
const maskStyleBackups = masks.map(mask => ({
|
|
925
|
+
object: mask,
|
|
926
|
+
stroke: mask.stroke,
|
|
927
|
+
strokeWidth: mask.strokeWidth,
|
|
928
|
+
opacity: mask.opacity
|
|
929
|
+
}));
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
masks.forEach(mask => {
|
|
933
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
934
|
+
});
|
|
935
|
+
return callback();
|
|
936
|
+
} finally {
|
|
937
|
+
maskStyleBackups.forEach(backup => {
|
|
938
|
+
try {
|
|
939
|
+
backup.object.set({
|
|
940
|
+
stroke: backup.stroke,
|
|
941
|
+
strokeWidth: backup.strokeWidth,
|
|
942
|
+
opacity: backup.opacity
|
|
943
|
+
});
|
|
944
|
+
} catch (error) { void error; }
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
_restoreMaskControls(mask) {
|
|
950
|
+
if (!mask) return;
|
|
951
|
+
|
|
952
|
+
const cornerSize = Number(mask.cornerSize);
|
|
953
|
+
mask.set({
|
|
954
|
+
selectable: mask.selectable !== false,
|
|
955
|
+
evented: mask.evented !== false,
|
|
956
|
+
hasControls: mask.hasControls !== false,
|
|
957
|
+
lockRotation: typeof mask.lockRotation === 'boolean' ? mask.lockRotation : !this.options.maskRotatable,
|
|
958
|
+
borderColor: mask.borderColor || 'red',
|
|
959
|
+
cornerColor: mask.cornerColor || 'black',
|
|
960
|
+
cornerSize: Number.isFinite(cornerSize) ? cornerSize : 8,
|
|
961
|
+
transparentCorners: mask.transparentCorners === true,
|
|
962
|
+
strokeUniform: mask.strokeUniform !== false
|
|
963
|
+
});
|
|
964
|
+
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
_serializeCanvasState() {
|
|
968
|
+
if (!this.canvas) return null;
|
|
969
|
+
return this._withNormalizedMaskStyles(() => {
|
|
970
|
+
const jsonObject = this.canvas.toJSON(this._getStateProperties());
|
|
971
|
+
if (Array.isArray(jsonObject.objects)) {
|
|
972
|
+
jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
|
|
973
|
+
}
|
|
974
|
+
return JSON.stringify(jsonObject);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
_normalizeQuality(quality) {
|
|
979
|
+
const numericQuality = Number(quality);
|
|
980
|
+
if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
|
|
981
|
+
return Math.max(0, Math.min(1, numericQuality));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
_normalizeImageFormat(format) {
|
|
985
|
+
const typeMapping = {
|
|
986
|
+
'jpeg': 'jpeg',
|
|
987
|
+
'jpg': 'jpeg',
|
|
988
|
+
'image/jpeg': 'jpeg',
|
|
989
|
+
'png': 'png',
|
|
990
|
+
'image/png': 'png',
|
|
991
|
+
'webp': 'webp',
|
|
992
|
+
'image/webp': 'webp'
|
|
993
|
+
};
|
|
994
|
+
return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
_getClampedCanvasRegion(bounds, options = {}) {
|
|
998
|
+
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
999
|
+
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
1000
|
+
const left = Number(bounds.left) || 0;
|
|
1001
|
+
const top = Number(bounds.top) || 0;
|
|
1002
|
+
const width = Math.max(0, Number(bounds.width) || 0);
|
|
1003
|
+
const height = Math.max(0, Number(bounds.height) || 0);
|
|
1004
|
+
const includePartialPixels = options.includePartialPixels !== false;
|
|
1005
|
+
const roundEnd = includePartialPixels ? Math.ceil : Math.floor;
|
|
1006
|
+
const sourceX = Math.min(canvasWidth - 1, Math.max(0, Math.floor(left)));
|
|
1007
|
+
const sourceY = Math.min(canvasHeight - 1, Math.max(0, Math.floor(top)));
|
|
1008
|
+
const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
|
|
1009
|
+
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
sx: sourceX,
|
|
1013
|
+
sy: sourceY,
|
|
1014
|
+
sw: Math.max(1, endX - sourceX),
|
|
1015
|
+
sh: Math.max(1, endY - sourceY)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
|
|
1020
|
+
return new Promise((resolve, reject) => {
|
|
1021
|
+
const imageElement = new Image();
|
|
1022
|
+
imageElement.onload = () => {
|
|
1023
|
+
try {
|
|
1024
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1025
|
+
const scaledSourceX = Math.round(sourceX * safeMultiplier);
|
|
1026
|
+
const scaledSourceY = Math.round(sourceY * safeMultiplier);
|
|
1027
|
+
const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
|
|
1028
|
+
const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
|
|
1029
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
1030
|
+
offscreenCanvas.width = scaledSourceWidth;
|
|
1031
|
+
offscreenCanvas.height = scaledSourceHeight;
|
|
1032
|
+
const context = offscreenCanvas.getContext('2d');
|
|
1033
|
+
|
|
1034
|
+
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
1035
|
+
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
reject(error);
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
imageElement.onerror = reject;
|
|
1041
|
+
imageElement.src = dataUrl;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async _exportCanvasRegionToDataURL({ sx, sy, sw, sh, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
|
|
1046
|
+
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1047
|
+
const fullDataUrl = this.canvas.toDataURL({
|
|
1048
|
+
format,
|
|
1049
|
+
quality,
|
|
1050
|
+
multiplier: safeMultiplier
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
return this._cropDataUrl(fullDataUrl, sx, sy, sw, sh, safeMultiplier, format, quality);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
629
1056
|
/**
|
|
630
1057
|
* Gets the top-left corner coordinates of the given object.
|
|
631
1058
|
* Used for geometry calculations (e.g., scale, rotate).
|
|
632
1059
|
*
|
|
633
|
-
* @param {Object}
|
|
1060
|
+
* @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
|
|
634
1061
|
* @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
|
|
635
1062
|
* @private
|
|
636
1063
|
*/
|
|
637
|
-
_getObjectTopLeftPoint(
|
|
638
|
-
if (!
|
|
639
|
-
|
|
640
|
-
const coords = typeof
|
|
1064
|
+
_getObjectTopLeftPoint(fabricObject) {
|
|
1065
|
+
if (!fabricObject) return { x: 0, y: 0 };
|
|
1066
|
+
fabricObject.setCoords();
|
|
1067
|
+
const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
|
|
641
1068
|
if (coords && coords.length) return coords[0];
|
|
642
|
-
const
|
|
643
|
-
return { x:
|
|
1069
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1070
|
+
return { x: boundingRect.left, y: boundingRect.top };
|
|
644
1071
|
}
|
|
645
1072
|
|
|
646
1073
|
/**
|
|
647
1074
|
* Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
|
|
648
1075
|
*
|
|
649
|
-
* @param {Object}
|
|
1076
|
+
* @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
|
|
650
1077
|
* @param {string} originX - The new originX ("left", "center", "right", etc.).
|
|
651
1078
|
* @param {string} originY - The new originY ("top", "center", "bottom", etc.).
|
|
652
1079
|
* @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
|
|
653
1080
|
* @private
|
|
654
1081
|
*/
|
|
655
|
-
_setObjectOriginKeepingPosition(
|
|
656
|
-
if (!
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1082
|
+
_setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
|
|
1083
|
+
if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
|
|
1084
|
+
fabricObject.set({ originX, originY });
|
|
1085
|
+
fabricObject.setPositionByOrigin(refPoint, originX, originY);
|
|
1086
|
+
fabricObject.setCoords();
|
|
660
1087
|
}
|
|
661
1088
|
|
|
662
1089
|
/**
|
|
663
1090
|
* Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
|
|
664
1091
|
*
|
|
665
|
-
* @param {Object}
|
|
1092
|
+
* @param {Object} fabricObject - The object to align.
|
|
666
1093
|
* @private
|
|
667
1094
|
*/
|
|
668
|
-
_alignObjectBoundingBoxToCanvasTopLeft(
|
|
669
|
-
if (!
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
const
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
1095
|
+
_alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
|
|
1096
|
+
if (!fabricObject) return;
|
|
1097
|
+
fabricObject.setCoords();
|
|
1098
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1099
|
+
const deltaX = boundingRect.left;
|
|
1100
|
+
const deltaY = boundingRect.top;
|
|
1101
|
+
fabricObject.set({ left: (fabricObject.left || 0) - deltaX, top: (fabricObject.top || 0) - deltaY });
|
|
1102
|
+
fabricObject.setCoords();
|
|
676
1103
|
this.canvas.renderAll();
|
|
677
1104
|
}
|
|
678
1105
|
|
|
@@ -684,22 +1111,27 @@ function ensureFabric() {
|
|
|
684
1111
|
_updateCanvasSizeToImageBounds() {
|
|
685
1112
|
if (!this.originalImage) return;
|
|
686
1113
|
this.originalImage.setCoords();
|
|
687
|
-
const
|
|
1114
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
688
1115
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1116
|
+
const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
|
|
1117
|
+
this._setCanvasSizeInt(size.width, size.height);
|
|
1118
|
+
}
|
|
692
1119
|
|
|
693
|
-
|
|
694
|
-
if (
|
|
695
|
-
|
|
696
|
-
|
|
1120
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
1121
|
+
if (!this.canvas || !fabricObject || !this.options.expandCanvasToImage) return;
|
|
1122
|
+
try {
|
|
1123
|
+
fabricObject.setCoords();
|
|
1124
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1125
|
+
const requiredWidth = Math.ceil(boundingRect.left + boundingRect.width + padding);
|
|
1126
|
+
const requiredHeight = Math.ceil(boundingRect.top + boundingRect.height + padding);
|
|
1127
|
+
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
1128
|
+
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
1129
|
+
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
1130
|
+
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
1131
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
this._reportWarning('expandCanvasToFitObject: failed to expand canvas', error);
|
|
697
1134
|
}
|
|
698
|
-
|
|
699
|
-
// Else canvas follows image bounding box but not smaller than container dims individually
|
|
700
|
-
const newW = Math.max(containerW || 0, Math.floor(br.width));
|
|
701
|
-
const newH = Math.max(containerH || 0, Math.floor(br.height));
|
|
702
|
-
this._setCanvasSizeInt(newW, newH);
|
|
703
1135
|
}
|
|
704
1136
|
|
|
705
1137
|
/**
|
|
@@ -709,8 +1141,8 @@ function ensureFabric() {
|
|
|
709
1141
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
710
1142
|
* @public
|
|
711
1143
|
*/
|
|
712
|
-
scaleImage(factor) {
|
|
713
|
-
return this.animQueue.add(() => this._scaleImageImpl(factor));
|
|
1144
|
+
scaleImage(factor, options = {}) {
|
|
1145
|
+
return this.animQueue.add(() => this._scaleImageImpl(factor, options));
|
|
714
1146
|
}
|
|
715
1147
|
|
|
716
1148
|
/**
|
|
@@ -720,50 +1152,53 @@ function ensureFabric() {
|
|
|
720
1152
|
* @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
|
|
721
1153
|
* @private
|
|
722
1154
|
*/
|
|
723
|
-
_scaleImageImpl(factor) {
|
|
1155
|
+
_scaleImageImpl(factor, options = {}) {
|
|
724
1156
|
if (!this.originalImage) return Promise.resolve();
|
|
725
1157
|
if (this.isAnimating) return Promise.resolve();
|
|
1158
|
+
const saveHistory = options.saveHistory !== false;
|
|
726
1159
|
factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
|
|
727
1160
|
this.currentScale = factor;
|
|
728
1161
|
this.isAnimating = true;
|
|
729
1162
|
this._updateUI();
|
|
730
1163
|
|
|
731
|
-
const
|
|
1164
|
+
const targetScale = this.baseImageScale * factor;
|
|
732
1165
|
|
|
733
1166
|
// Scale around current top-left (recompute)
|
|
734
1167
|
const topLeft = this._getObjectTopLeftPoint(this.originalImage);
|
|
735
1168
|
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
|
|
736
1169
|
|
|
737
|
-
const
|
|
738
|
-
this.originalImage.animate('scaleX',
|
|
1170
|
+
const scaleXAnimation = new Promise((resolve) => {
|
|
1171
|
+
this.originalImage.animate('scaleX', targetScale, {
|
|
739
1172
|
duration: this.options.animationDuration,
|
|
740
1173
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
741
|
-
onComplete:
|
|
1174
|
+
onComplete: resolve
|
|
742
1175
|
});
|
|
743
1176
|
});
|
|
744
|
-
const
|
|
745
|
-
this.originalImage.animate('scaleY',
|
|
1177
|
+
const scaleYAnimation = new Promise((resolve) => {
|
|
1178
|
+
this.originalImage.animate('scaleY', targetScale, {
|
|
746
1179
|
duration: this.options.animationDuration,
|
|
747
1180
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
748
|
-
onComplete:
|
|
1181
|
+
onComplete: resolve
|
|
749
1182
|
});
|
|
750
1183
|
});
|
|
751
1184
|
|
|
752
|
-
return Promise.all([
|
|
753
|
-
this.originalImage.set({ scaleX:
|
|
1185
|
+
return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
|
|
1186
|
+
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
754
1187
|
this.originalImage.setCoords();
|
|
755
1188
|
|
|
756
|
-
if (this.options.expandCanvasToImage
|
|
1189
|
+
if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
|
|
1190
|
+
this._updateCanvasSizeToImageBounds();
|
|
1191
|
+
}
|
|
757
1192
|
|
|
758
1193
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
759
1194
|
|
|
760
1195
|
// Sync mask labels
|
|
761
|
-
this.canvas.getObjects().forEach(
|
|
1196
|
+
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
762
1197
|
|
|
763
1198
|
this.isAnimating = false;
|
|
764
1199
|
this._updateInputs();
|
|
765
1200
|
this._updateUI();
|
|
766
|
-
this.saveState();
|
|
1201
|
+
if (saveHistory) this.saveState();
|
|
767
1202
|
}).catch(() => {
|
|
768
1203
|
this.isAnimating = false;
|
|
769
1204
|
this._updateUI();
|
|
@@ -777,8 +1212,8 @@ function ensureFabric() {
|
|
|
777
1212
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
778
1213
|
* @public
|
|
779
1214
|
*/
|
|
780
|
-
rotateImage(
|
|
781
|
-
return this.animQueue.add(() => this._rotateImageImpl(
|
|
1215
|
+
rotateImage(degrees, options = {}) {
|
|
1216
|
+
return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
782
1217
|
}
|
|
783
1218
|
|
|
784
1219
|
/**
|
|
@@ -788,10 +1223,11 @@ function ensureFabric() {
|
|
|
788
1223
|
* @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
|
|
789
1224
|
* @private
|
|
790
1225
|
*/
|
|
791
|
-
_rotateImageImpl(degrees) {
|
|
1226
|
+
_rotateImageImpl(degrees, options = {}) {
|
|
792
1227
|
if (!this.originalImage) return Promise.resolve();
|
|
793
1228
|
if (this.isAnimating) return Promise.resolve();
|
|
794
1229
|
if (isNaN(degrees)) return Promise.resolve();
|
|
1230
|
+
const saveHistory = options.saveHistory !== false;
|
|
795
1231
|
this.currentRotation = degrees;
|
|
796
1232
|
this.isAnimating = true;
|
|
797
1233
|
this._updateUI();
|
|
@@ -799,19 +1235,21 @@ function ensureFabric() {
|
|
|
799
1235
|
const center = this.originalImage.getCenterPoint();
|
|
800
1236
|
this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
|
|
801
1237
|
|
|
802
|
-
const
|
|
1238
|
+
const rotationAnimation = new Promise((resolve) => {
|
|
803
1239
|
this.originalImage.animate('angle', degrees, {
|
|
804
1240
|
duration: this.options.animationDuration,
|
|
805
1241
|
onChange: this.canvas.renderAll.bind(this.canvas),
|
|
806
|
-
onComplete:
|
|
1242
|
+
onComplete: resolve
|
|
807
1243
|
});
|
|
808
1244
|
});
|
|
809
1245
|
|
|
810
|
-
return
|
|
1246
|
+
return rotationAnimation.then(() => {
|
|
811
1247
|
this.originalImage.set('angle', degrees);
|
|
812
1248
|
this.originalImage.setCoords();
|
|
813
1249
|
|
|
814
|
-
if (this.options.expandCanvasToImage
|
|
1250
|
+
if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
|
|
1251
|
+
this._updateCanvasSizeToImageBounds();
|
|
1252
|
+
}
|
|
815
1253
|
|
|
816
1254
|
this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
|
|
817
1255
|
|
|
@@ -819,12 +1257,12 @@ function ensureFabric() {
|
|
|
819
1257
|
this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
|
|
820
1258
|
|
|
821
1259
|
// Sync mask labels
|
|
822
|
-
this.canvas.getObjects().forEach(
|
|
1260
|
+
this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
|
|
823
1261
|
|
|
824
1262
|
this.isAnimating = false;
|
|
825
1263
|
this._updateInputs();
|
|
826
1264
|
this._updateUI();
|
|
827
|
-
this.saveState();
|
|
1265
|
+
if (saveHistory) this.saveState();
|
|
828
1266
|
}).catch(() => {
|
|
829
1267
|
this.isAnimating = false;
|
|
830
1268
|
this._updateUI();
|
|
@@ -832,20 +1270,28 @@ function ensureFabric() {
|
|
|
832
1270
|
}
|
|
833
1271
|
|
|
834
1272
|
/**
|
|
835
|
-
* Resets the image: scales to 1 and rotates to 0 degrees.
|
|
1273
|
+
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
836
1274
|
* @returns {Promise<void>} Promise that resolves when reset is complete.
|
|
837
1275
|
*/
|
|
838
|
-
|
|
1276
|
+
resetImageTransform() {
|
|
839
1277
|
if (!this.originalImage) return Promise.resolve();
|
|
840
1278
|
|
|
841
|
-
return this.
|
|
842
|
-
|
|
843
|
-
.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
.
|
|
847
|
-
|
|
848
|
-
|
|
1279
|
+
return this.animQueue.add(async () => {
|
|
1280
|
+
const before = this._serializeCanvasState();
|
|
1281
|
+
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1282
|
+
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1283
|
+
const after = this._serializeCanvasState();
|
|
1284
|
+
this._pushStateTransition(before, after);
|
|
1285
|
+
}).catch(err => {
|
|
1286
|
+
this._reportError('resetImageTransform() failed', err);
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* @deprecated Use resetImageTransform() instead.
|
|
1292
|
+
*/
|
|
1293
|
+
reset() {
|
|
1294
|
+
return this.resetImageTransform();
|
|
849
1295
|
}
|
|
850
1296
|
|
|
851
1297
|
/**
|
|
@@ -853,47 +1299,66 @@ function ensureFabric() {
|
|
|
853
1299
|
* @param {string} jsonString - the JSON string returned by fabric.toJSON().
|
|
854
1300
|
*/
|
|
855
1301
|
loadFromState(jsonString) {
|
|
856
|
-
if (!jsonString || !this.canvas) return;
|
|
857
|
-
|
|
858
|
-
try {
|
|
859
|
-
const json = (typeof jsonString === 'string')
|
|
860
|
-
? JSON.parse(jsonString)
|
|
861
|
-
: jsonString;
|
|
862
|
-
|
|
863
|
-
this.canvas.loadFromJSON(json, () => {
|
|
864
|
-
try {
|
|
865
|
-
this._hideAllMaskLabels();
|
|
866
|
-
const objs = this.canvas.getObjects();
|
|
867
|
-
this.originalImage = objs.find(o => o.type === 'image' && !o.maskId) || null;
|
|
1302
|
+
if (!jsonString || !this.canvas) return Promise.resolve();
|
|
868
1303
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1304
|
+
return new Promise((resolve) => {
|
|
1305
|
+
try {
|
|
1306
|
+
const json = (typeof jsonString === 'string')
|
|
1307
|
+
? JSON.parse(jsonString)
|
|
1308
|
+
: jsonString;
|
|
873
1309
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
this.
|
|
881
|
-
|
|
1310
|
+
this.canvas.loadFromJSON(json, () => {
|
|
1311
|
+
try {
|
|
1312
|
+
this._hideAllMaskLabels();
|
|
1313
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1314
|
+
this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
|
|
1315
|
+
|
|
1316
|
+
if (this.originalImage) {
|
|
1317
|
+
this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
|
|
1318
|
+
this.canvas.sendToBack(this.originalImage);
|
|
1319
|
+
this.currentRotation = Number(this.originalImage.angle) || 0;
|
|
1320
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1321
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1322
|
+
this.currentScale = imageScale / baseScale;
|
|
1323
|
+
} else {
|
|
1324
|
+
this.currentScale = 1;
|
|
1325
|
+
this.currentRotation = 0;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const masks = canvasObjects.filter(object => object.maskId);
|
|
1329
|
+
masks.forEach(mask => {
|
|
1330
|
+
this._restoreMaskControls(mask);
|
|
1331
|
+
this._rebindMaskEvents(mask);
|
|
1332
|
+
mask.set(this._getMaskNormalStyle(mask));
|
|
1333
|
+
});
|
|
1334
|
+
this.maskCounter = masks.reduce((max, mask) =>
|
|
1335
|
+
Math.max(max, mask.maskId), 0);
|
|
1336
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1337
|
+
if (!this._lastMask) {
|
|
1338
|
+
this._lastMaskInitialLeft = null;
|
|
1339
|
+
this._lastMaskInitialTop = null;
|
|
1340
|
+
this._lastMaskInitialWidth = null;
|
|
1341
|
+
}
|
|
1342
|
+
this.isImageLoadedToCanvas = !!this.originalImage;
|
|
1343
|
+
|
|
1344
|
+
this.canvas.renderAll();
|
|
1345
|
+
this._updateInputs();
|
|
1346
|
+
this._updateMaskList();
|
|
1347
|
+
this._updatePlaceholderStatus();
|
|
1348
|
+
this._lastSnapshot = this._serializeCanvasState();
|
|
1349
|
+
this._updateUI();
|
|
1350
|
+
} catch (callbackError) {
|
|
1351
|
+
this._reportError('loadFromState() failed', callbackError);
|
|
1352
|
+
} finally {
|
|
1353
|
+
resolve();
|
|
882
1354
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
this.canvas.renderAll();
|
|
886
|
-
this._updateMaskList();
|
|
887
|
-
this._updatePlaceholderStatus();
|
|
888
|
-
this._updateUI();
|
|
889
|
-
} catch (callbackError) {
|
|
890
|
-
this._reportError('loadFromState() failed', callbackError);
|
|
891
|
-
}
|
|
892
|
-
});
|
|
1355
|
+
});
|
|
893
1356
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
this._reportError('loadFromState() failed', error);
|
|
1359
|
+
resolve();
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
897
1362
|
}
|
|
898
1363
|
|
|
899
1364
|
/**
|
|
@@ -901,59 +1366,116 @@ function ensureFabric() {
|
|
|
901
1366
|
*/
|
|
902
1367
|
saveState() {
|
|
903
1368
|
if (!this.canvas) return;
|
|
904
|
-
const
|
|
1369
|
+
const activeObject = this.canvas.getActiveObject();
|
|
905
1370
|
this._hideAllMaskLabels();
|
|
906
1371
|
|
|
907
1372
|
try {
|
|
908
|
-
|
|
909
|
-
const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
|
|
910
|
-
if (Array.isArray(jsonObj.objects)) {
|
|
911
|
-
// filter out crop-rect objects before stringifying
|
|
912
|
-
jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
|
|
913
|
-
}
|
|
914
|
-
const after = JSON.stringify(jsonObj);
|
|
1373
|
+
const after = this._serializeCanvasState();
|
|
915
1374
|
const before = this._lastSnapshot || after;
|
|
1375
|
+
if (after === before) return;
|
|
916
1376
|
let executedOnce = false;
|
|
917
1377
|
|
|
918
|
-
const
|
|
1378
|
+
const command = new Command(
|
|
919
1379
|
() => {
|
|
920
1380
|
if (executedOnce) {
|
|
921
|
-
this.loadFromState(after);
|
|
1381
|
+
return this.loadFromState(after);
|
|
922
1382
|
}
|
|
923
1383
|
executedOnce = true;
|
|
1384
|
+
return undefined;
|
|
924
1385
|
},
|
|
925
|
-
() =>
|
|
926
|
-
this.loadFromState(before);
|
|
927
|
-
}
|
|
1386
|
+
() => this.loadFromState(before)
|
|
928
1387
|
);
|
|
929
1388
|
|
|
930
|
-
this.historyManager.execute(
|
|
1389
|
+
this.historyManager.execute(command);
|
|
931
1390
|
this._lastSnapshot = after;
|
|
932
|
-
|
|
933
|
-
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
this._reportWarning('saveState: failed to save canvas snapshot', error);
|
|
1393
|
+
} finally {
|
|
1394
|
+
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1395
|
+
this._handleSelectionChanged([activeObject]);
|
|
934
1396
|
}
|
|
935
1397
|
this._updateUI();
|
|
936
|
-
} catch (err) {
|
|
937
|
-
this._reportWarning('saveState: failed to save canvas snapshot', err);
|
|
938
1398
|
}
|
|
939
1399
|
}
|
|
940
1400
|
|
|
1401
|
+
_pushStateTransition(before, after) {
|
|
1402
|
+
if (!before || !after) return;
|
|
1403
|
+
if (before === after) return;
|
|
1404
|
+
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1405
|
+
|
|
1406
|
+
const command = new Command(
|
|
1407
|
+
() => this.loadFromState(after),
|
|
1408
|
+
() => this.loadFromState(before)
|
|
1409
|
+
);
|
|
1410
|
+
this.historyManager.push(command);
|
|
1411
|
+
this._lastSnapshot = after;
|
|
1412
|
+
this._updateUI();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
941
1415
|
/**
|
|
942
1416
|
* Undo the last state change, if possible.
|
|
943
1417
|
*/
|
|
944
1418
|
undo() {
|
|
945
|
-
this.historyManager.undo()
|
|
1419
|
+
return this.historyManager.undo()
|
|
1420
|
+
.then(() => { this._updateUI(); })
|
|
1421
|
+
.catch(error => { this._reportError('undo failed', error); });
|
|
946
1422
|
}
|
|
947
1423
|
|
|
948
1424
|
/**
|
|
949
1425
|
* Redo the next state change, if possible.
|
|
950
1426
|
*/
|
|
951
1427
|
redo() {
|
|
952
|
-
this.historyManager.redo()
|
|
1428
|
+
return this.historyManager.redo()
|
|
1429
|
+
.then(() => { this._updateUI(); })
|
|
1430
|
+
.catch(error => { this._reportError('redo failed', error); });
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
_rebindMaskEvents(mask) {
|
|
1434
|
+
if (!mask) return;
|
|
1435
|
+
if (mask.__imageEditorMaskHandlers) {
|
|
1436
|
+
try {
|
|
1437
|
+
mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
|
|
1438
|
+
mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
|
|
1439
|
+
} catch (e) { void e; }
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const metadata = {};
|
|
1443
|
+
if (!Number.isFinite(Number(mask.originalAlpha))) {
|
|
1444
|
+
metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
|
|
1445
|
+
}
|
|
1446
|
+
if (!mask.originalStroke) metadata.originalStroke = mask.stroke || '#ccc';
|
|
1447
|
+
if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
|
|
1448
|
+
metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
|
|
1449
|
+
}
|
|
1450
|
+
if (Object.keys(metadata).length) mask.set(metadata);
|
|
1451
|
+
|
|
1452
|
+
const normalStyle = {
|
|
1453
|
+
stroke: mask.originalStroke || '#ccc',
|
|
1454
|
+
strokeWidth: mask.originalStrokeWidth,
|
|
1455
|
+
opacity: mask.originalAlpha
|
|
1456
|
+
};
|
|
1457
|
+
const hoverStyle = {
|
|
1458
|
+
stroke: '#ff5500',
|
|
1459
|
+
strokeWidth: 2,
|
|
1460
|
+
opacity: Math.min(mask.originalAlpha + 0.2, 1)
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
const mouseover = () => {
|
|
1464
|
+
mask.set(hoverStyle);
|
|
1465
|
+
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1466
|
+
};
|
|
1467
|
+
const mouseout = () => {
|
|
1468
|
+
mask.set(normalStyle);
|
|
1469
|
+
if (mask.canvas) mask.canvas.requestRenderAll();
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
mask.on('mouseover', mouseover);
|
|
1473
|
+
mask.on('mouseout', mouseout);
|
|
1474
|
+
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
953
1475
|
}
|
|
954
1476
|
|
|
955
1477
|
/**
|
|
956
|
-
*
|
|
1478
|
+
* Creates a mask and adds it to the canvas.
|
|
957
1479
|
* Mask placement and properties are determined by the provided config and instance options.
|
|
958
1480
|
* Canvas and list UI are updated accordingly.
|
|
959
1481
|
* @param {Object} [config={}] - Optional mask configuration overrides:
|
|
@@ -967,15 +1489,15 @@ function ensureFabric() {
|
|
|
967
1489
|
* @param {boolean} [config.selectable=true]
|
|
968
1490
|
* @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
|
|
969
1491
|
* @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
|
|
970
|
-
* @param {function} [config.fabricGenerator] - (
|
|
1492
|
+
* @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
|
|
971
1493
|
* @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
|
|
972
1494
|
* @public
|
|
973
1495
|
*/
|
|
974
|
-
|
|
1496
|
+
createMask(config = {}) {
|
|
975
1497
|
if (!this.canvas) return null;
|
|
976
1498
|
const shapeType = config.shape || 'rect';
|
|
977
1499
|
// Default config
|
|
978
|
-
const
|
|
1500
|
+
const maskConfig = {
|
|
979
1501
|
shape: shapeType,
|
|
980
1502
|
width: this.options.defaultMaskWidth,
|
|
981
1503
|
height: this.options.defaultMaskHeight,
|
|
@@ -994,84 +1516,73 @@ function ensureFabric() {
|
|
|
994
1516
|
let left = firstOffset;
|
|
995
1517
|
let top = firstOffset;
|
|
996
1518
|
|
|
997
|
-
const resolveValue = (
|
|
998
|
-
if (typeof
|
|
999
|
-
return
|
|
1000
|
-
if (typeof
|
|
1001
|
-
const percent = parseFloat(
|
|
1519
|
+
const resolveValue = (value, fallback) => {
|
|
1520
|
+
if (typeof value === 'function')
|
|
1521
|
+
return value(this.canvas, this.options);
|
|
1522
|
+
if (typeof value === 'string' && value.endsWith('%')) {
|
|
1523
|
+
const percent = parseFloat(value) / 100;
|
|
1002
1524
|
return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
|
|
1003
1525
|
}
|
|
1004
|
-
return
|
|
1526
|
+
return value != null ? value : fallback;
|
|
1005
1527
|
}
|
|
1006
1528
|
|
|
1007
|
-
if (
|
|
1008
|
-
const
|
|
1009
|
-
let
|
|
1529
|
+
if (maskConfig.left === undefined && this._lastMask) {
|
|
1530
|
+
const previousMask = this._lastMask;
|
|
1531
|
+
let previousMaskRight = previousMask.left;
|
|
1010
1532
|
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
} else if (
|
|
1014
|
-
|
|
1533
|
+
if (previousMask.getScaledWidth) {
|
|
1534
|
+
previousMaskRight += previousMask.getScaledWidth();
|
|
1535
|
+
} else if (previousMask.width) {
|
|
1536
|
+
previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
|
|
1015
1537
|
}
|
|
1016
|
-
left = Math.round(
|
|
1017
|
-
top =
|
|
1538
|
+
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1539
|
+
top = previousMask.top ?? firstOffset;
|
|
1018
1540
|
} else {
|
|
1019
|
-
left = resolveValue(
|
|
1020
|
-
top = resolveValue(
|
|
1541
|
+
left = resolveValue(maskConfig.left, firstOffset);
|
|
1542
|
+
top = resolveValue(maskConfig.top, firstOffset);
|
|
1021
1543
|
}
|
|
1022
1544
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// If expandCanvasToImage mode, ensure canvas large enough to hold mask initial placement
|
|
1027
|
-
if (this.options.expandCanvasToImage && shapeType === 'rect') {
|
|
1028
|
-
const requiredW = Math.ceil(left + cfg.width + 10);
|
|
1029
|
-
const requiredH = Math.ceil(top + cfg.height + 10);
|
|
1030
|
-
const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
|
|
1031
|
-
const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
|
|
1032
|
-
const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
|
|
1033
|
-
const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
|
|
1034
|
-
this._setCanvasSizeInt(newW, newH);
|
|
1035
|
-
}
|
|
1545
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1546
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1036
1547
|
|
|
1037
1548
|
let mask;
|
|
1038
|
-
if (typeof
|
|
1039
|
-
mask =
|
|
1549
|
+
if (typeof maskConfig.fabricGenerator === 'function') {
|
|
1550
|
+
mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
|
|
1040
1551
|
} else {
|
|
1041
1552
|
switch (shapeType) {
|
|
1042
1553
|
case 'circle':
|
|
1043
1554
|
mask = new fabric.Circle({
|
|
1044
1555
|
left, top,
|
|
1045
|
-
radius: resolveValue(
|
|
1046
|
-
fill:
|
|
1047
|
-
opacity:
|
|
1048
|
-
angle:
|
|
1049
|
-
...
|
|
1556
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
1557
|
+
fill: maskConfig.color,
|
|
1558
|
+
opacity: maskConfig.alpha,
|
|
1559
|
+
angle: maskConfig.angle,
|
|
1560
|
+
...maskConfig.styles
|
|
1050
1561
|
});
|
|
1051
1562
|
break;
|
|
1052
1563
|
case 'ellipse':
|
|
1053
1564
|
mask = new fabric.Ellipse({
|
|
1054
1565
|
left, top,
|
|
1055
|
-
rx: resolveValue(
|
|
1056
|
-
ry: resolveValue(
|
|
1057
|
-
fill:
|
|
1058
|
-
opacity:
|
|
1059
|
-
angle:
|
|
1060
|
-
...
|
|
1566
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1567
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
1568
|
+
fill: maskConfig.color,
|
|
1569
|
+
opacity: maskConfig.alpha,
|
|
1570
|
+
angle: maskConfig.angle,
|
|
1571
|
+
...maskConfig.styles
|
|
1061
1572
|
});
|
|
1062
1573
|
break;
|
|
1063
1574
|
case 'polygon': {
|
|
1064
|
-
let
|
|
1065
|
-
if (Array.isArray(
|
|
1575
|
+
let polygonPoints = maskConfig.points || [];
|
|
1576
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === 'object') {
|
|
1066
1577
|
// Ensure numeric {x,y} objects for fabric.Polygon
|
|
1067
|
-
|
|
1578
|
+
polygonPoints = polygonPoints.map(point => ({ x: Number(point.x), y: Number(point.y) }));
|
|
1068
1579
|
}
|
|
1069
|
-
mask = new fabric.Polygon(
|
|
1580
|
+
mask = new fabric.Polygon(polygonPoints, {
|
|
1070
1581
|
left, top,
|
|
1071
|
-
fill:
|
|
1072
|
-
opacity:
|
|
1073
|
-
angle:
|
|
1074
|
-
...
|
|
1582
|
+
fill: maskConfig.color,
|
|
1583
|
+
opacity: maskConfig.alpha,
|
|
1584
|
+
angle: maskConfig.angle,
|
|
1585
|
+
...maskConfig.styles
|
|
1075
1586
|
});
|
|
1076
1587
|
break;
|
|
1077
1588
|
}
|
|
@@ -1079,85 +1590,98 @@ function ensureFabric() {
|
|
|
1079
1590
|
default:
|
|
1080
1591
|
mask = new fabric.Rect({
|
|
1081
1592
|
left, top,
|
|
1082
|
-
width: resolveValue(
|
|
1083
|
-
height: resolveValue(
|
|
1084
|
-
fill:
|
|
1085
|
-
opacity:
|
|
1086
|
-
angle:
|
|
1087
|
-
rx:
|
|
1088
|
-
ry:
|
|
1089
|
-
...
|
|
1593
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1594
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
1595
|
+
fill: maskConfig.color,
|
|
1596
|
+
opacity: maskConfig.alpha,
|
|
1597
|
+
angle: maskConfig.angle,
|
|
1598
|
+
rx: maskConfig.rx, // Rounded Corners
|
|
1599
|
+
ry: maskConfig.ry,
|
|
1600
|
+
...maskConfig.styles
|
|
1090
1601
|
});
|
|
1091
1602
|
}
|
|
1092
1603
|
}
|
|
1093
1604
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
mask.
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
mask.set(normalStyle);
|
|
1117
|
-
mask.canvas.requestRenderAll();
|
|
1605
|
+
const styles = maskConfig.styles || {};
|
|
1606
|
+
const hasStyle = property => Object.prototype.hasOwnProperty.call(styles, property);
|
|
1607
|
+
const maskSettings = {
|
|
1608
|
+
selectable: maskConfig.selectable !== false,
|
|
1609
|
+
hasControls: ('hasControls' in maskConfig) ? maskConfig.hasControls : true,
|
|
1610
|
+
lockRotation: !this.options.maskRotatable,
|
|
1611
|
+
borderColor: ('borderColor' in maskConfig) ? maskConfig.borderColor : 'red',
|
|
1612
|
+
cornerColor: ('cornerColor' in maskConfig) ? maskConfig.cornerColor : 'black',
|
|
1613
|
+
cornerSize: ('cornerSize' in maskConfig) ? maskConfig.cornerSize : 8,
|
|
1614
|
+
transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
|
|
1615
|
+
stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
|
|
1616
|
+
strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
|
|
1617
|
+
strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
|
|
1618
|
+
};
|
|
1619
|
+
if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
1620
|
+
mask.set(maskSettings);
|
|
1621
|
+
mask.setCoords();
|
|
1622
|
+
|
|
1623
|
+
mask.set({
|
|
1624
|
+
originalAlpha: maskConfig.alpha,
|
|
1625
|
+
originalStroke: mask.stroke || '#ccc',
|
|
1626
|
+
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
1118
1627
|
});
|
|
1628
|
+
this._rebindMaskEvents(mask);
|
|
1629
|
+
this._expandCanvasToFitObject(mask);
|
|
1119
1630
|
|
|
1120
1631
|
// Remember initial for next one
|
|
1121
1632
|
this._lastMaskInitialLeft = left;
|
|
1122
1633
|
this._lastMaskInitialTop = top;
|
|
1123
|
-
this._lastMaskInitialWidth = resolveValue(
|
|
1634
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1124
1635
|
|
|
1125
|
-
|
|
1126
|
-
mask.
|
|
1636
|
+
const maskId = ++this.maskCounter;
|
|
1637
|
+
mask.set({
|
|
1638
|
+
maskId,
|
|
1639
|
+
maskName: `${this.options.maskName}${maskId}`
|
|
1640
|
+
});
|
|
1127
1641
|
this._lastMask = mask;
|
|
1128
1642
|
|
|
1129
1643
|
this.canvas.add(mask);
|
|
1130
1644
|
this.canvas.bringToFront(mask);
|
|
1131
|
-
if (
|
|
1132
|
-
this.
|
|
1645
|
+
if (maskConfig.selectable) this.canvas.setActiveObject(mask);
|
|
1646
|
+
this._handleSelectionChanged([mask]);
|
|
1133
1647
|
this._updateMaskList();
|
|
1134
1648
|
this._updateUI();
|
|
1135
1649
|
this.canvas.renderAll();
|
|
1136
1650
|
this.saveState();
|
|
1137
1651
|
|
|
1138
|
-
if (typeof
|
|
1652
|
+
if (typeof maskConfig.onCreate === 'function') maskConfig.onCreate(mask, this.canvas);
|
|
1139
1653
|
return mask;
|
|
1140
1654
|
}
|
|
1141
1655
|
|
|
1656
|
+
/**
|
|
1657
|
+
* @deprecated Use createMask() instead.
|
|
1658
|
+
*/
|
|
1659
|
+
addMask(config = {}) {
|
|
1660
|
+
return this.createMask(config);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1142
1663
|
/**
|
|
1143
1664
|
* Removes the currently selected mask from the canvas, if any.
|
|
1144
1665
|
* The associated label is also removed. UI and mask list are updated.
|
|
1145
1666
|
*/
|
|
1146
1667
|
removeSelectedMask() {
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (this._lastMask === active) {
|
|
1152
|
-
const masks = this.canvas.getObjects().filter(o => o.maskId);
|
|
1153
|
-
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1154
|
-
if (!this._lastMask) {
|
|
1155
|
-
this._lastMaskInitialLeft = null;
|
|
1156
|
-
this._lastMaskInitialTop = null;
|
|
1157
|
-
this._lastMaskInitialWidth = null;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1668
|
+
const activeObject = this.canvas.getActiveObject();
|
|
1669
|
+
const selectedMasks = this._getModifiedMasks(activeObject);
|
|
1670
|
+
if (!selectedMasks.length) return;
|
|
1671
|
+
|
|
1160
1672
|
this.canvas.discardActiveObject();
|
|
1673
|
+
selectedMasks.forEach(mask => {
|
|
1674
|
+
this._removeLabelForMask(mask);
|
|
1675
|
+
this.canvas.remove(mask);
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1679
|
+
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1680
|
+
if (!this._lastMask) {
|
|
1681
|
+
this._lastMaskInitialLeft = null;
|
|
1682
|
+
this._lastMaskInitialTop = null;
|
|
1683
|
+
this._lastMaskInitialWidth = null;
|
|
1684
|
+
}
|
|
1161
1685
|
this._updateMaskList();
|
|
1162
1686
|
this._updateUI();
|
|
1163
1687
|
this.canvas.renderAll();
|
|
@@ -1168,10 +1692,11 @@ function ensureFabric() {
|
|
|
1168
1692
|
* Removes all masks from the canvas, including their labels.
|
|
1169
1693
|
* UI and internal mask placement memory are reset.
|
|
1170
1694
|
*/
|
|
1171
|
-
removeAllMasks() {
|
|
1172
|
-
const
|
|
1173
|
-
masks.
|
|
1174
|
-
masks.forEach(
|
|
1695
|
+
removeAllMasks(options = {}) {
|
|
1696
|
+
const saveHistory = options.saveHistory !== false;
|
|
1697
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1698
|
+
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
1699
|
+
masks.forEach(mask => this.canvas.remove(mask));
|
|
1175
1700
|
this.canvas.discardActiveObject();
|
|
1176
1701
|
this._lastMask = null;
|
|
1177
1702
|
this._lastMaskInitialLeft = null;
|
|
@@ -1180,7 +1705,7 @@ function ensureFabric() {
|
|
|
1180
1705
|
this._updateMaskList();
|
|
1181
1706
|
this._updateUI();
|
|
1182
1707
|
this.canvas.renderAll();
|
|
1183
|
-
this.saveState();
|
|
1708
|
+
if (saveHistory) this.saveState();
|
|
1184
1709
|
}
|
|
1185
1710
|
|
|
1186
1711
|
/**
|
|
@@ -1193,12 +1718,12 @@ function ensureFabric() {
|
|
|
1193
1718
|
if (!mask || !this.canvas) return;
|
|
1194
1719
|
if (mask.__label) {
|
|
1195
1720
|
try {
|
|
1196
|
-
const
|
|
1197
|
-
if (
|
|
1721
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1722
|
+
if (canvasObjects.includes(mask.__label)) {
|
|
1198
1723
|
this.canvas.remove(mask.__label);
|
|
1199
1724
|
}
|
|
1200
|
-
} catch (
|
|
1201
|
-
try { delete mask.__label; } catch (
|
|
1725
|
+
} catch (error) { void error; }
|
|
1726
|
+
try { delete mask.__label; } catch (error) { void error; }
|
|
1202
1727
|
}
|
|
1203
1728
|
}
|
|
1204
1729
|
|
|
@@ -1212,12 +1737,12 @@ function ensureFabric() {
|
|
|
1212
1737
|
_createLabelForMask(mask) {
|
|
1213
1738
|
if (!mask || !this.options.maskLabelOnSelect) return;
|
|
1214
1739
|
this._removeLabelForMask(mask);
|
|
1215
|
-
let
|
|
1740
|
+
let textObject = null;
|
|
1216
1741
|
if (this.options.label && typeof this.options.label.create === 'function') {
|
|
1217
|
-
|
|
1742
|
+
textObject = this.options.label.create(mask, fabric);
|
|
1218
1743
|
}
|
|
1219
|
-
if (!
|
|
1220
|
-
let
|
|
1744
|
+
if (!textObject) {
|
|
1745
|
+
let labelText = mask.maskName;
|
|
1221
1746
|
let textOptions = {
|
|
1222
1747
|
left: 0,
|
|
1223
1748
|
top: 0,
|
|
@@ -1232,20 +1757,22 @@ function ensureFabric() {
|
|
|
1232
1757
|
};
|
|
1233
1758
|
if (this.options.label) {
|
|
1234
1759
|
if (typeof this.options.label.getText === 'function') {
|
|
1235
|
-
|
|
1760
|
+
const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
|
|
1761
|
+
const maskIndex = Math.max(0, masks.indexOf(mask));
|
|
1762
|
+
labelText = this.options.label.getText(mask, maskIndex);
|
|
1236
1763
|
}
|
|
1237
1764
|
// Merge external styles
|
|
1238
1765
|
if (this.options.label.textOptions) {
|
|
1239
1766
|
Object.assign(textOptions, this.options.label.textOptions);
|
|
1240
1767
|
}
|
|
1241
1768
|
}
|
|
1242
|
-
|
|
1769
|
+
textObject = new fabric.Text(labelText, textOptions);
|
|
1243
1770
|
}
|
|
1244
1771
|
|
|
1245
|
-
|
|
1246
|
-
mask.__label =
|
|
1247
|
-
this.canvas.add(
|
|
1248
|
-
this.canvas.bringToFront(
|
|
1772
|
+
textObject.maskLabel = true;
|
|
1773
|
+
mask.__label = textObject;
|
|
1774
|
+
this.canvas.add(textObject);
|
|
1775
|
+
this.canvas.bringToFront(textObject);
|
|
1249
1776
|
this._syncMaskLabel(mask);
|
|
1250
1777
|
}
|
|
1251
1778
|
|
|
@@ -1256,14 +1783,18 @@ function ensureFabric() {
|
|
|
1256
1783
|
*/
|
|
1257
1784
|
_hideAllMaskLabels() {
|
|
1258
1785
|
if (!this.canvas) return;
|
|
1259
|
-
const
|
|
1260
|
-
const labels =
|
|
1261
|
-
labels.forEach(
|
|
1786
|
+
const canvasObjects = this.canvas.getObjects();
|
|
1787
|
+
const labels = canvasObjects.filter(object => object.maskLabel);
|
|
1788
|
+
labels.forEach(label => {
|
|
1262
1789
|
try {
|
|
1263
|
-
if (
|
|
1264
|
-
} catch (
|
|
1790
|
+
if (canvasObjects.includes(label)) this.canvas.remove(label);
|
|
1791
|
+
} catch (error) { void error; }
|
|
1792
|
+
});
|
|
1793
|
+
canvasObjects.forEach(object => {
|
|
1794
|
+
if (object.maskId && object.__label) {
|
|
1795
|
+
try { delete object.__label; } catch (error) { void error; }
|
|
1796
|
+
}
|
|
1265
1797
|
});
|
|
1266
|
-
objs.forEach(o => { if (o.maskId && o.__label) { try { delete o.__label; } catch (e) { void e; } } });
|
|
1267
1798
|
}
|
|
1268
1799
|
|
|
1269
1800
|
/**
|
|
@@ -1303,7 +1834,11 @@ function ensureFabric() {
|
|
|
1303
1834
|
visible: true
|
|
1304
1835
|
});
|
|
1305
1836
|
mask.__label.setCoords();
|
|
1306
|
-
this.canvas.
|
|
1837
|
+
if (typeof this.canvas.requestRenderAll === 'function') {
|
|
1838
|
+
this.canvas.requestRenderAll();
|
|
1839
|
+
} else {
|
|
1840
|
+
this.canvas.renderAll();
|
|
1841
|
+
}
|
|
1307
1842
|
}
|
|
1308
1843
|
|
|
1309
1844
|
/**
|
|
@@ -1316,7 +1851,7 @@ function ensureFabric() {
|
|
|
1316
1851
|
if (!mask) return;
|
|
1317
1852
|
if (!this.options.maskLabelOnSelect) return;
|
|
1318
1853
|
if (!mask.__label) this._createLabelForMask(mask);
|
|
1319
|
-
mask.__label.visible
|
|
1854
|
+
mask.__label.set({ visible: true });
|
|
1320
1855
|
this._syncMaskLabel(mask);
|
|
1321
1856
|
}
|
|
1322
1857
|
|
|
@@ -1327,18 +1862,22 @@ function ensureFabric() {
|
|
|
1327
1862
|
* @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
|
|
1328
1863
|
* @private
|
|
1329
1864
|
*/
|
|
1330
|
-
|
|
1331
|
-
const selectedMask = (selected || []).find(
|
|
1332
|
-
const masks = this.canvas.getObjects().filter(
|
|
1333
|
-
masks.forEach(
|
|
1334
|
-
if (
|
|
1335
|
-
if (
|
|
1336
|
-
try { this.canvas.remove(
|
|
1337
|
-
delete
|
|
1865
|
+
_handleSelectionChanged(selected) {
|
|
1866
|
+
const selectedMask = (selected || []).find(object => object.maskId);
|
|
1867
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1868
|
+
masks.forEach(mask => {
|
|
1869
|
+
if (mask !== selectedMask) {
|
|
1870
|
+
if (mask.__label) {
|
|
1871
|
+
try { this.canvas.remove(mask.__label); } catch (error) { void error; }
|
|
1872
|
+
delete mask.__label;
|
|
1338
1873
|
}
|
|
1339
|
-
|
|
1874
|
+
const originalStrokeWidth = Number(mask.originalStrokeWidth);
|
|
1875
|
+
mask.set({
|
|
1876
|
+
stroke: mask.originalStroke || '#ccc',
|
|
1877
|
+
strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
|
|
1878
|
+
});
|
|
1340
1879
|
} else {
|
|
1341
|
-
|
|
1880
|
+
mask.set({ stroke: '#ff0000', strokeWidth: 1 });
|
|
1342
1881
|
}
|
|
1343
1882
|
});
|
|
1344
1883
|
|
|
@@ -1355,16 +1894,16 @@ function ensureFabric() {
|
|
|
1355
1894
|
* @private
|
|
1356
1895
|
*/
|
|
1357
1896
|
_updateMaskList() {
|
|
1358
|
-
const
|
|
1359
|
-
if (!
|
|
1360
|
-
|
|
1361
|
-
const masks = this.canvas.getObjects().filter(
|
|
1897
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1898
|
+
if (!maskListElement) return;
|
|
1899
|
+
maskListElement.innerHTML = '';
|
|
1900
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1362
1901
|
masks.forEach(mask => {
|
|
1363
|
-
const
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1902
|
+
const listItemElement = document.createElement('li');
|
|
1903
|
+
listItemElement.className = 'list-group-item mask-item';
|
|
1904
|
+
listItemElement.textContent = mask.maskName;
|
|
1905
|
+
listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
|
|
1906
|
+
maskListElement.appendChild(listItemElement);
|
|
1368
1907
|
});
|
|
1369
1908
|
}
|
|
1370
1909
|
|
|
@@ -1375,10 +1914,10 @@ function ensureFabric() {
|
|
|
1375
1914
|
* @private
|
|
1376
1915
|
*/
|
|
1377
1916
|
_updateMaskListSelection(selectedMask) {
|
|
1378
|
-
const
|
|
1379
|
-
if (!
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1917
|
+
const maskListElement = document.getElementById(this.elements.maskList);
|
|
1918
|
+
if (!maskListElement) return;
|
|
1919
|
+
const maskItems = maskListElement.querySelectorAll('.mask-item');
|
|
1920
|
+
maskItems.forEach(item => {
|
|
1382
1921
|
const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
|
|
1383
1922
|
item.classList.toggle('active', isSelected);
|
|
1384
1923
|
});
|
|
@@ -1390,25 +1929,33 @@ function ensureFabric() {
|
|
|
1390
1929
|
* @async
|
|
1391
1930
|
* @returns {Promise<void>} Resolves when merge and load are complete.
|
|
1392
1931
|
*/
|
|
1393
|
-
async
|
|
1932
|
+
async mergeMasks() {
|
|
1394
1933
|
if (!this.originalImage) return;
|
|
1395
|
-
const masks = this.canvas.getObjects().filter(
|
|
1934
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1396
1935
|
if (!masks.length) return;
|
|
1397
1936
|
|
|
1398
1937
|
this.canvas.discardActiveObject();
|
|
1399
1938
|
this.canvas.renderAll();
|
|
1400
1939
|
|
|
1401
1940
|
try {
|
|
1402
|
-
const
|
|
1403
|
-
this.
|
|
1941
|
+
const beforeJson = this._serializeCanvasState();
|
|
1942
|
+
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1943
|
+
this.removeAllMasks({ saveHistory: false });
|
|
1404
1944
|
await this.loadImage(merged);
|
|
1405
|
-
this.
|
|
1945
|
+
const afterJson = this._serializeCanvasState();
|
|
1946
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
1406
1947
|
} catch (err) {
|
|
1407
1948
|
this._reportError('merge error', err);
|
|
1408
|
-
if (this.canvasEl) this.canvasEl.style.visibility = '';
|
|
1409
1949
|
}
|
|
1410
1950
|
}
|
|
1411
1951
|
|
|
1952
|
+
/**
|
|
1953
|
+
* @deprecated Use mergeMasks() instead.
|
|
1954
|
+
*/
|
|
1955
|
+
async merge() {
|
|
1956
|
+
return this.mergeMasks();
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1412
1959
|
/**
|
|
1413
1960
|
* Triggers a JPEG image download of the current canvas (image plus masks if configured).
|
|
1414
1961
|
* The image area and multiplier are controlled by options.
|
|
@@ -1417,7 +1964,7 @@ function ensureFabric() {
|
|
|
1417
1964
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1418
1965
|
if (!this.originalImage) return;
|
|
1419
1966
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1420
|
-
this.
|
|
1967
|
+
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
|
|
1421
1968
|
.then(base64 => {
|
|
1422
1969
|
const link = document.createElement('a');
|
|
1423
1970
|
link.download = fileName;
|
|
@@ -1430,113 +1977,108 @@ function ensureFabric() {
|
|
|
1430
1977
|
}
|
|
1431
1978
|
|
|
1432
1979
|
/**
|
|
1433
|
-
* Exports the image as a Base64-encoded
|
|
1980
|
+
* Exports the image as a Base64-encoded image data URL.
|
|
1434
1981
|
* Can export either the original, or the current view including masks (clipped/cropped).
|
|
1435
1982
|
* Will restore masks' state after temporary modifications for export.
|
|
1436
1983
|
* @async
|
|
1437
|
-
* @param {Object} [
|
|
1438
|
-
* @param {boolean} [
|
|
1439
|
-
* @param {number} [
|
|
1440
|
-
* @
|
|
1984
|
+
* @param {Object} [options={}] - Export options.
|
|
1985
|
+
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1986
|
+
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1987
|
+
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1988
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1989
|
+
* @returns {Promise<string>} Promise resolving to an image data URL.
|
|
1441
1990
|
* @throws {Error} If there is no image loaded.
|
|
1442
1991
|
*/
|
|
1443
|
-
async
|
|
1992
|
+
async exportImageBase64(options = {}) {
|
|
1444
1993
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
1445
|
-
const exportImageArea = typeof
|
|
1446
|
-
const multiplier =
|
|
1994
|
+
const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
|
|
1995
|
+
const multiplier = options.multiplier || this.options.exportMultiplier || 1;
|
|
1996
|
+
const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
|
|
1997
|
+
const format = this._normalizeImageFormat(options.fileType || options.format);
|
|
1447
1998
|
|
|
1448
1999
|
if (!exportImageArea) {
|
|
1449
|
-
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2000
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
|
|
2001
|
+
const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
|
|
2002
|
+
|
|
2003
|
+
try {
|
|
2004
|
+
masks.forEach(mask => { mask.set({ visible: false }); });
|
|
2005
|
+
this.canvas.discardActiveObject();
|
|
2006
|
+
this.canvas.renderAll();
|
|
2007
|
+
|
|
2008
|
+
this.originalImage.setCoords();
|
|
2009
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2010
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2011
|
+
return await this._exportCanvasRegionToDataURL({
|
|
2012
|
+
sx,
|
|
2013
|
+
sy,
|
|
2014
|
+
sw,
|
|
2015
|
+
sh,
|
|
2016
|
+
multiplier,
|
|
2017
|
+
quality,
|
|
2018
|
+
format
|
|
2019
|
+
});
|
|
2020
|
+
} finally {
|
|
2021
|
+
maskVisibilityBackups.forEach(backup => {
|
|
2022
|
+
try { backup.object.set({ visible: backup.visible }); } catch (error) { void error; }
|
|
2023
|
+
});
|
|
2024
|
+
this.canvas.renderAll();
|
|
2025
|
+
}
|
|
1460
2026
|
}
|
|
1461
2027
|
|
|
1462
2028
|
// Export current scaled image area (masks clipped)
|
|
1463
|
-
const masks = this.canvas.getObjects().filter(
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
opacity:
|
|
1467
|
-
fill:
|
|
1468
|
-
strokeWidth:
|
|
1469
|
-
stroke:
|
|
1470
|
-
selectable:
|
|
1471
|
-
lockRotation:
|
|
2029
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2030
|
+
const maskStyleBackups = masks.map(mask => ({
|
|
2031
|
+
object: mask,
|
|
2032
|
+
opacity: mask.opacity,
|
|
2033
|
+
fill: mask.fill,
|
|
2034
|
+
strokeWidth: mask.strokeWidth,
|
|
2035
|
+
stroke: mask.stroke,
|
|
2036
|
+
selectable: mask.selectable,
|
|
2037
|
+
lockRotation: mask.lockRotation
|
|
1472
2038
|
}));
|
|
1473
2039
|
|
|
1474
2040
|
let finalBase64;
|
|
1475
2041
|
try {
|
|
1476
2042
|
// Remove labels, deselect
|
|
1477
|
-
masks.forEach(
|
|
2043
|
+
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
1478
2044
|
this.canvas.discardActiveObject();
|
|
1479
2045
|
this.canvas.renderAll();
|
|
1480
2046
|
|
|
1481
2047
|
// Set masks to opaque black no border
|
|
1482
|
-
masks.forEach(
|
|
1483
|
-
|
|
1484
|
-
|
|
2048
|
+
masks.forEach(mask => {
|
|
2049
|
+
mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
|
|
2050
|
+
mask.setCoords();
|
|
1485
2051
|
});
|
|
1486
2052
|
this.canvas.renderAll();
|
|
1487
2053
|
|
|
1488
2054
|
// Compute integer bounding box for image
|
|
1489
2055
|
this.originalImage.setCoords();
|
|
1490
|
-
const
|
|
1491
|
-
const sx =
|
|
1492
|
-
const sy = Math.max(0, Math.round(imgBr.top));
|
|
1493
|
-
const sw = Math.max(1, Math.round(imgBr.width));
|
|
1494
|
-
const sh = Math.max(1, Math.round(imgBr.height));
|
|
2056
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2057
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
1495
2058
|
|
|
1496
2059
|
// Crop precisely in offscreen canvas
|
|
1497
|
-
finalBase64 = await
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
const img = new Image();
|
|
1506
|
-
img.onload = () => {
|
|
1507
|
-
try {
|
|
1508
|
-
const sxM = Math.round(sx * multiplier);
|
|
1509
|
-
const syM = Math.round(sy * multiplier);
|
|
1510
|
-
const swM = Math.round(sw * multiplier);
|
|
1511
|
-
const shM = Math.round(sh * multiplier);
|
|
1512
|
-
|
|
1513
|
-
const oc = document.createElement('canvas');
|
|
1514
|
-
oc.width = swM;
|
|
1515
|
-
oc.height = shM;
|
|
1516
|
-
const ctx = oc.getContext('2d');
|
|
1517
|
-
|
|
1518
|
-
ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
|
|
1519
|
-
const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality);
|
|
1520
|
-
resolve(out);
|
|
1521
|
-
} catch (e) { reject(e); }
|
|
1522
|
-
};
|
|
1523
|
-
img.onerror = reject;
|
|
1524
|
-
img.src = fullDataUrl;
|
|
1525
|
-
} catch (e) { reject(e); }
|
|
2060
|
+
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
2061
|
+
sx,
|
|
2062
|
+
sy,
|
|
2063
|
+
sw,
|
|
2064
|
+
sh,
|
|
2065
|
+
multiplier,
|
|
2066
|
+
quality,
|
|
2067
|
+
format
|
|
1526
2068
|
});
|
|
1527
2069
|
} finally {
|
|
1528
|
-
|
|
2070
|
+
maskStyleBackups.forEach(backup => {
|
|
1529
2071
|
try {
|
|
1530
|
-
|
|
1531
|
-
opacity:
|
|
1532
|
-
fill:
|
|
1533
|
-
strokeWidth:
|
|
1534
|
-
stroke:
|
|
1535
|
-
selectable:
|
|
1536
|
-
lockRotation:
|
|
2072
|
+
backup.object.set({
|
|
2073
|
+
opacity: backup.opacity,
|
|
2074
|
+
fill: backup.fill,
|
|
2075
|
+
strokeWidth: backup.strokeWidth,
|
|
2076
|
+
stroke: backup.stroke,
|
|
2077
|
+
selectable: backup.selectable,
|
|
2078
|
+
lockRotation: backup.lockRotation
|
|
1537
2079
|
});
|
|
1538
|
-
|
|
1539
|
-
} catch (
|
|
2080
|
+
backup.object.setCoords();
|
|
2081
|
+
} catch (error) { void error; }
|
|
1540
2082
|
});
|
|
1541
2083
|
|
|
1542
2084
|
this.canvas.renderAll();
|
|
@@ -1545,23 +2087,30 @@ function ensureFabric() {
|
|
|
1545
2087
|
return finalBase64;
|
|
1546
2088
|
}
|
|
1547
2089
|
|
|
2090
|
+
/**
|
|
2091
|
+
* @deprecated Use exportImageBase64() instead.
|
|
2092
|
+
*/
|
|
2093
|
+
async getImageBase64(options = {}) {
|
|
2094
|
+
return this.exportImageBase64(options);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1548
2097
|
/**
|
|
1549
2098
|
* Exports the current canvas (with or without masks) as a File object.
|
|
1550
2099
|
* Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
|
|
1551
2100
|
*
|
|
1552
2101
|
* @async
|
|
1553
|
-
* @param {Object} [
|
|
1554
|
-
* @param {boolean} [
|
|
1555
|
-
* @param {string} [
|
|
1556
|
-
* @param {number} [
|
|
1557
|
-
* @param {number} [
|
|
1558
|
-
* @param {string} [
|
|
2102
|
+
* @param {Object} [options={}] - Export options.
|
|
2103
|
+
* @param {boolean} [options.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
|
|
2104
|
+
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
|
|
2105
|
+
* @param {number} [options.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
|
|
2106
|
+
* @param {number} [options.multiplier=1] - Output resolution multiplier.
|
|
2107
|
+
* @param {string} [options.fileName] - Optional file name (only used for download).
|
|
1559
2108
|
* @returns {Promise<File>} Resolves with the exported image as a File object.
|
|
1560
2109
|
*
|
|
1561
2110
|
* @example
|
|
1562
2111
|
* const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
|
|
1563
2112
|
*/
|
|
1564
|
-
async exportImageFile(
|
|
2113
|
+
async exportImageFile(options = {}) {
|
|
1565
2114
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
1566
2115
|
const {
|
|
1567
2116
|
mergeMask = true,
|
|
@@ -1569,30 +2118,25 @@ function ensureFabric() {
|
|
|
1569
2118
|
quality = this.options.downsampleQuality ?? 0.92,
|
|
1570
2119
|
multiplier = this.options.exportMultiplier ?? 1,
|
|
1571
2120
|
fileName = this.options.defaultDownloadFileName ?? 'exported_image.jpg'
|
|
1572
|
-
} =
|
|
2121
|
+
} = options;
|
|
1573
2122
|
|
|
1574
|
-
const
|
|
1575
|
-
'jpeg': 'jpeg',
|
|
1576
|
-
'jpg': 'jpeg',
|
|
1577
|
-
'image/jpeg': 'jpeg',
|
|
1578
|
-
'png': 'png',
|
|
1579
|
-
'image/png': 'png',
|
|
1580
|
-
'webp': 'webp',
|
|
1581
|
-
'image/webp': 'webp'
|
|
1582
|
-
};
|
|
1583
|
-
const safeFileType = typeMapping[String(fileType).toLowerCase()] || 'jpeg';
|
|
2123
|
+
const safeFileType = this._normalizeImageFormat(fileType);
|
|
1584
2124
|
|
|
1585
2125
|
// Get Base64
|
|
1586
2126
|
let base64;
|
|
1587
2127
|
if (mergeMask) {
|
|
1588
|
-
base64 = await this.
|
|
2128
|
+
base64 = await this.exportImageBase64({
|
|
1589
2129
|
exportImageArea: true,
|
|
1590
2130
|
multiplier,
|
|
2131
|
+
quality,
|
|
2132
|
+
fileType: safeFileType
|
|
1591
2133
|
});
|
|
1592
2134
|
} else {
|
|
1593
|
-
base64 = await this.
|
|
2135
|
+
base64 = await this.exportImageBase64({
|
|
1594
2136
|
exportImageArea: false,
|
|
1595
2137
|
multiplier,
|
|
2138
|
+
quality,
|
|
2139
|
+
fileType: safeFileType
|
|
1596
2140
|
});
|
|
1597
2141
|
}
|
|
1598
2142
|
|
|
@@ -1601,34 +2145,95 @@ function ensureFabric() {
|
|
|
1601
2145
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
1602
2146
|
// Redraw if not required format
|
|
1603
2147
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
1604
|
-
const
|
|
1605
|
-
|
|
1606
|
-
|
|
2148
|
+
const imageElement = new window.Image();
|
|
2149
|
+
imageElement.crossOrigin = "Anonymous";
|
|
2150
|
+
imageElement.onload = () => {
|
|
1607
2151
|
try {
|
|
1608
|
-
const
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
resolve(
|
|
1615
|
-
} catch (
|
|
2152
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
2153
|
+
offscreenCanvas.width = imageElement.width;
|
|
2154
|
+
offscreenCanvas.height = imageElement.height;
|
|
2155
|
+
const context = offscreenCanvas.getContext('2d');
|
|
2156
|
+
context.drawImage(imageElement, 0, 0);
|
|
2157
|
+
const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
|
|
2158
|
+
resolve(convertedDataUrl);
|
|
2159
|
+
} catch (error) { reject(error); }
|
|
1616
2160
|
};
|
|
1617
|
-
|
|
1618
|
-
|
|
2161
|
+
imageElement.onerror = reject;
|
|
2162
|
+
imageElement.src = base64;
|
|
1619
2163
|
});
|
|
1620
2164
|
}
|
|
1621
2165
|
|
|
1622
2166
|
// Convert DataURL to Blob and then to File
|
|
1623
|
-
const
|
|
2167
|
+
const binaryString = atob(imageDataUrl.split(',')[1]);
|
|
1624
2168
|
const mime = `image/${safeFileType}`;
|
|
1625
|
-
let
|
|
1626
|
-
const
|
|
1627
|
-
while (
|
|
1628
|
-
|
|
2169
|
+
let byteIndex = binaryString.length;
|
|
2170
|
+
const bytes = new Uint8Array(byteIndex);
|
|
2171
|
+
while (byteIndex--) {
|
|
2172
|
+
bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
|
|
1629
2173
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
2174
|
+
return new File([bytes], fileName, { type: mime });
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
_clearMaskPlacementMemory() {
|
|
2178
|
+
this._lastMask = null;
|
|
2179
|
+
this._lastMaskInitialLeft = null;
|
|
2180
|
+
this._lastMaskInitialTop = null;
|
|
2181
|
+
this._lastMaskInitialWidth = null;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
async _restoreStateAfterCropFailure(beforeJson, message, error) {
|
|
2185
|
+
this._reportError(message, error);
|
|
2186
|
+
|
|
2187
|
+
if (this._cropRect && this.canvas) this._removeCropRect();
|
|
2188
|
+
this._cropRect = null;
|
|
2189
|
+
this._cropMode = false;
|
|
2190
|
+
if (this.canvas && this._prevSelectionSetting !== undefined) {
|
|
2191
|
+
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2192
|
+
}
|
|
2193
|
+
this._prevSelectionSetting = undefined;
|
|
2194
|
+
|
|
2195
|
+
if (beforeJson) {
|
|
2196
|
+
try {
|
|
2197
|
+
await this.loadFromState(beforeJson);
|
|
2198
|
+
} catch (restoreError) {
|
|
2199
|
+
this._reportError('applyCrop: rollback failed', restoreError);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
this._updateUI();
|
|
2204
|
+
if (this.canvas) this.canvas.renderAll();
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
_restoreCropObjectState() {
|
|
2208
|
+
if (Array.isArray(this._cropPrevEvented)) {
|
|
2209
|
+
this._cropPrevEvented.forEach(state => {
|
|
2210
|
+
try {
|
|
2211
|
+
state.object.set({
|
|
2212
|
+
evented: state.evented,
|
|
2213
|
+
selectable: state.selectable,
|
|
2214
|
+
visible: state.visible
|
|
2215
|
+
});
|
|
2216
|
+
} catch (error) { void error; }
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
this._cropPrevEvented = null;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
_removeCropRect() {
|
|
2223
|
+
if (!this._cropRect) return;
|
|
2224
|
+
try {
|
|
2225
|
+
if (this._cropHandlers && this._cropHandlers.length) {
|
|
2226
|
+
this._cropHandlers.forEach(targetHandlers => {
|
|
2227
|
+
targetHandlers.handlers.forEach(handlerRecord => {
|
|
2228
|
+
targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
} catch (error) { void error; }
|
|
2233
|
+
|
|
2234
|
+
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
2235
|
+
this._cropRect = null;
|
|
2236
|
+
this._cropHandlers = [];
|
|
1632
2237
|
}
|
|
1633
2238
|
|
|
1634
2239
|
/**
|
|
@@ -1649,13 +2254,13 @@ function ensureFabric() {
|
|
|
1649
2254
|
|
|
1650
2255
|
// Create initial crop rect centered on the image bounding box
|
|
1651
2256
|
this.originalImage.setCoords();
|
|
1652
|
-
const
|
|
2257
|
+
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
1653
2258
|
// Provide small inset so user can see a margin
|
|
1654
2259
|
const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
|
|
1655
|
-
const left = Math.max(0, Math.floor(
|
|
1656
|
-
const top = Math.max(0, Math.floor(
|
|
1657
|
-
const width = Math.min(this.options.crop.minWidth || 50, Math.floor(
|
|
1658
|
-
const height = Math.min(this.options.crop.minHeight || 50, Math.floor(
|
|
2260
|
+
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2261
|
+
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2262
|
+
const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imageBounds.width - padding * 2));
|
|
2263
|
+
const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imageBounds.height - padding * 2));
|
|
1659
2264
|
|
|
1660
2265
|
// Visual style: translucent fill + dashed stroke
|
|
1661
2266
|
const cropRect = new fabric.Rect({
|
|
@@ -1687,21 +2292,36 @@ function ensureFabric() {
|
|
|
1687
2292
|
// While in crop mode: we want only the cropRect to be interactive
|
|
1688
2293
|
// but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
|
|
1689
2294
|
this._cropPrevEvented = [];
|
|
1690
|
-
this.
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
2295
|
+
const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
|
|
2296
|
+
this.canvas.getObjects().forEach(object => {
|
|
2297
|
+
if (object !== cropRect) {
|
|
2298
|
+
this._cropPrevEvented.push({ object, evented: object.evented, selectable: object.selectable, visible: object.visible });
|
|
2299
|
+
try {
|
|
2300
|
+
const updates = {
|
|
2301
|
+
evented: false,
|
|
2302
|
+
selectable: false
|
|
2303
|
+
};
|
|
2304
|
+
if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
|
|
2305
|
+
object.set(updates);
|
|
2306
|
+
} catch (error) { void error; }
|
|
1694
2307
|
}
|
|
1695
2308
|
});
|
|
1696
2309
|
|
|
1697
2310
|
// When the crop rect changes, re-render
|
|
1698
|
-
const
|
|
1699
|
-
cropRect.on('modified',
|
|
1700
|
-
cropRect.on('moving',
|
|
1701
|
-
cropRect.on('scaling',
|
|
2311
|
+
const handleCropRectModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (error) { void error; } };
|
|
2312
|
+
cropRect.on('modified', handleCropRectModified);
|
|
2313
|
+
cropRect.on('moving', handleCropRectModified);
|
|
2314
|
+
cropRect.on('scaling', handleCropRectModified);
|
|
1702
2315
|
|
|
1703
2316
|
// Keep handlers to remove later
|
|
1704
|
-
this._cropHandlers.push({
|
|
2317
|
+
this._cropHandlers.push({
|
|
2318
|
+
target: cropRect,
|
|
2319
|
+
handlers: [
|
|
2320
|
+
{ eventName: 'modified', handler: handleCropRectModified },
|
|
2321
|
+
{ eventName: 'moving', handler: handleCropRectModified },
|
|
2322
|
+
{ eventName: 'scaling', handler: handleCropRectModified }
|
|
2323
|
+
]
|
|
2324
|
+
});
|
|
1705
2325
|
|
|
1706
2326
|
this._updateUI();
|
|
1707
2327
|
this.canvas.renderAll();
|
|
@@ -1713,27 +2333,8 @@ function ensureFabric() {
|
|
|
1713
2333
|
*/
|
|
1714
2334
|
cancelCrop() {
|
|
1715
2335
|
if (!this.canvas || !this._cropMode) return;
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
try {
|
|
1719
|
-
if (this._cropHandlers && this._cropHandlers.length) {
|
|
1720
|
-
this._cropHandlers.forEach(h => {
|
|
1721
|
-
h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
|
|
1722
|
-
});
|
|
1723
|
-
}
|
|
1724
|
-
} catch (e) { void e; }
|
|
1725
|
-
|
|
1726
|
-
try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
|
|
1727
|
-
this._cropRect = null;
|
|
1728
|
-
}
|
|
1729
|
-
// restore evented/selectable flags
|
|
1730
|
-
if (Array.isArray(this._cropPrevEvented)) {
|
|
1731
|
-
this._cropPrevEvented.forEach(i => {
|
|
1732
|
-
try { i.obj.evented = i.evented; i.obj.selectable = i.selectable; } catch (e) { void e; }
|
|
1733
|
-
});
|
|
1734
|
-
}
|
|
1735
|
-
this._cropPrevEvented = null;
|
|
1736
|
-
this._cropHandlers = [];
|
|
2336
|
+
this._removeCropRect();
|
|
2337
|
+
this._restoreCropObjectState();
|
|
1737
2338
|
this._cropMode = false;
|
|
1738
2339
|
// restore selection setting
|
|
1739
2340
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
@@ -1756,62 +2357,57 @@ function ensureFabric() {
|
|
|
1756
2357
|
this._cropRect.setCoords();
|
|
1757
2358
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
1758
2359
|
|
|
1759
|
-
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
|
|
2360
|
+
const { sx, sy, sw, sh } = this._getClampedCanvasRegion(rectBounds);
|
|
2361
|
+
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2362
|
+
|
|
2363
|
+
this._restoreCropObjectState();
|
|
1764
2364
|
|
|
1765
|
-
// Include isCropRect in toJSON whitelist so we can detect and filter them out.
|
|
1766
2365
|
let beforeJson = null;
|
|
1767
2366
|
try {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
}
|
|
1772
|
-
beforeJson = JSON.stringify(jsonObj);
|
|
1773
|
-
} catch (e) {
|
|
1774
|
-
this._reportWarning('applyCrop: could not serialize before state', e);
|
|
2367
|
+
beforeJson = this._serializeCanvasState();
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
this._reportWarning('applyCrop: could not serialize before state', error);
|
|
1775
2370
|
beforeJson = null;
|
|
1776
2371
|
}
|
|
1777
2372
|
|
|
2373
|
+
const preservedMasks = [];
|
|
1778
2374
|
|
|
1779
|
-
// Remove ALL un-merged masks so they won't be baked into exported pixels
|
|
1780
2375
|
try {
|
|
1781
|
-
const masks = this.canvas.getObjects().filter(
|
|
2376
|
+
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
1782
2377
|
if (masks && masks.length) {
|
|
1783
|
-
masks.forEach(
|
|
2378
|
+
masks.forEach(mask => {
|
|
1784
2379
|
try {
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2380
|
+
mask.setCoords();
|
|
2381
|
+
const maskBounds = mask.getBoundingRect(true, true);
|
|
2382
|
+
const intersectsCrop =
|
|
2383
|
+
maskBounds.left < sx + sw &&
|
|
2384
|
+
maskBounds.left + maskBounds.width > sx &&
|
|
2385
|
+
maskBounds.top < sy + sh &&
|
|
2386
|
+
maskBounds.top + maskBounds.height > sy;
|
|
2387
|
+
this._removeLabelForMask(mask);
|
|
2388
|
+
this.canvas.remove(mask);
|
|
2389
|
+
if (shouldPreserveMasks && intersectsCrop) {
|
|
2390
|
+
mask.set({
|
|
2391
|
+
left: (mask.left || 0) - sx,
|
|
2392
|
+
top: (mask.top || 0) - sy,
|
|
2393
|
+
visible: true
|
|
2394
|
+
});
|
|
2395
|
+
mask.setCoords();
|
|
2396
|
+
preservedMasks.push(mask);
|
|
2397
|
+
}
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
this._reportWarning('applyCrop: failed to remove mask', error);
|
|
1789
2400
|
}
|
|
1790
2401
|
});
|
|
1791
|
-
this.
|
|
1792
|
-
this._lastMaskInitialLeft = null;
|
|
1793
|
-
this._lastMaskInitialTop = null;
|
|
1794
|
-
this._lastMaskInitialWidth = null;
|
|
2402
|
+
this._clearMaskPlacementMemory();
|
|
1795
2403
|
this.canvas.discardActiveObject();
|
|
1796
2404
|
this.canvas.renderAll();
|
|
1797
2405
|
}
|
|
1798
|
-
} catch (
|
|
1799
|
-
this._reportWarning('applyCrop: error while removing masks',
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
this._reportWarning('applyCrop: error while removing masks', error);
|
|
1800
2408
|
}
|
|
1801
2409
|
|
|
1802
|
-
|
|
1803
|
-
if (this._cropRect) {
|
|
1804
|
-
try {
|
|
1805
|
-
if (this._cropHandlers && this._cropHandlers.length) {
|
|
1806
|
-
this._cropHandlers.forEach(h => {
|
|
1807
|
-
h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
|
|
1808
|
-
});
|
|
1809
|
-
}
|
|
1810
|
-
} catch (e) { void e; }
|
|
1811
|
-
try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
|
|
1812
|
-
this._cropRect = null;
|
|
1813
|
-
}
|
|
1814
|
-
} catch (e) { void e; }
|
|
2410
|
+
this._removeCropRect();
|
|
1815
2411
|
|
|
1816
2412
|
// End crop mode
|
|
1817
2413
|
this._cropMode = false;
|
|
@@ -1821,78 +2417,50 @@ function ensureFabric() {
|
|
|
1821
2417
|
// Export full canvas and crop on offscreen canvas
|
|
1822
2418
|
let croppedBase64;
|
|
1823
2419
|
try {
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
img.onload = () => {
|
|
1833
|
-
try {
|
|
1834
|
-
const oc = document.createElement('canvas');
|
|
1835
|
-
oc.width = sw;
|
|
1836
|
-
oc.height = sh;
|
|
1837
|
-
const ctx = oc.getContext('2d');
|
|
1838
|
-
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
1839
|
-
const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality || 0.92);
|
|
1840
|
-
resolve(out);
|
|
1841
|
-
} catch (err) {
|
|
1842
|
-
reject(err);
|
|
1843
|
-
}
|
|
1844
|
-
};
|
|
1845
|
-
img.onerror = (e) => reject(e);
|
|
1846
|
-
img.src = fullDataUrl;
|
|
2420
|
+
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2421
|
+
sx,
|
|
2422
|
+
sy,
|
|
2423
|
+
sw,
|
|
2424
|
+
sh,
|
|
2425
|
+
multiplier: 1,
|
|
2426
|
+
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2427
|
+
format: 'jpeg'
|
|
1847
2428
|
});
|
|
1848
|
-
} catch (
|
|
1849
|
-
this.
|
|
1850
|
-
this._updateUI();
|
|
2429
|
+
} catch (error) {
|
|
2430
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
|
|
1851
2431
|
return;
|
|
1852
2432
|
}
|
|
1853
2433
|
|
|
1854
2434
|
// Load the cropped image as the new base image
|
|
1855
2435
|
try {
|
|
1856
2436
|
await this.loadImage(croppedBase64);
|
|
2437
|
+
if (preservedMasks.length) {
|
|
2438
|
+
preservedMasks.forEach(mask => {
|
|
2439
|
+
this._rebindMaskEvents(mask);
|
|
2440
|
+
this.canvas.add(mask);
|
|
2441
|
+
this.canvas.bringToFront(mask);
|
|
2442
|
+
});
|
|
2443
|
+
this._lastMask = preservedMasks[preservedMasks.length - 1];
|
|
2444
|
+
this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
|
|
2445
|
+
this._updateMaskList();
|
|
2446
|
+
this.canvas.renderAll();
|
|
2447
|
+
}
|
|
1857
2448
|
} catch (e) {
|
|
1858
|
-
this.
|
|
1859
|
-
this._updateUI();
|
|
2449
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', e);
|
|
1860
2450
|
return;
|
|
1861
2451
|
}
|
|
1862
2452
|
|
|
1863
2453
|
// Create "after" snapshot (also exclude crop rect if any) and push history command
|
|
1864
2454
|
let afterJson = null;
|
|
1865
2455
|
try {
|
|
1866
|
-
|
|
1867
|
-
if (Array.isArray(jsonObj2.objects)) {
|
|
1868
|
-
jsonObj2.objects = jsonObj2.objects.filter(o => !o.isCropRect);
|
|
1869
|
-
}
|
|
1870
|
-
afterJson = JSON.stringify(jsonObj2);
|
|
2456
|
+
afterJson = this._serializeCanvasState();
|
|
1871
2457
|
} catch (e) {
|
|
1872
2458
|
this._reportWarning('applyCrop: failed to serialize after state', e);
|
|
1873
2459
|
afterJson = null;
|
|
1874
2460
|
}
|
|
1875
2461
|
|
|
1876
2462
|
try {
|
|
1877
|
-
|
|
1878
|
-
const cmd = new Command(
|
|
1879
|
-
() => { if (afterJson) self.loadFromState(afterJson); },
|
|
1880
|
-
() => { if (beforeJson) self.loadFromState(beforeJson); }
|
|
1881
|
-
);
|
|
1882
|
-
|
|
1883
|
-
if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
|
|
1884
|
-
|
|
1885
|
-
// trim future redo history
|
|
1886
|
-
if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
|
|
1887
|
-
this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
this.historyManager.history.push(cmd);
|
|
1891
|
-
if (this.historyManager.history.length > this.historyManager.maxSize) {
|
|
1892
|
-
this.historyManager.history.shift();
|
|
1893
|
-
} else {
|
|
1894
|
-
this.historyManager.currentIndex++;
|
|
1895
|
-
}
|
|
2463
|
+
this._pushStateTransition(beforeJson, afterJson);
|
|
1896
2464
|
} catch (e) {
|
|
1897
2465
|
this._reportWarning('applyCrop: failed to push history command', e);
|
|
1898
2466
|
}
|
|
@@ -1911,8 +2479,8 @@ function ensureFabric() {
|
|
|
1911
2479
|
* @private
|
|
1912
2480
|
*/
|
|
1913
2481
|
_updateInputs() {
|
|
1914
|
-
const
|
|
1915
|
-
if (
|
|
2482
|
+
const scaleInputElement = document.getElementById(this.elements.scaleRate);
|
|
2483
|
+
if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
|
|
1916
2484
|
}
|
|
1917
2485
|
|
|
1918
2486
|
/**
|
|
@@ -1921,45 +2489,47 @@ function ensureFabric() {
|
|
|
1921
2489
|
* @private
|
|
1922
2490
|
*/
|
|
1923
2491
|
_updateUI() {
|
|
1924
|
-
const
|
|
1925
|
-
const masks =
|
|
2492
|
+
const hasImage = !!this.originalImage;
|
|
2493
|
+
const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
|
|
1926
2494
|
const hasMasks = masks.length > 0;
|
|
1927
|
-
const
|
|
1928
|
-
const hasSelectedMask =
|
|
1929
|
-
const
|
|
2495
|
+
const activeObject = this.canvas.getActiveObject();
|
|
2496
|
+
const hasSelectedMask = activeObject && activeObject.maskId;
|
|
2497
|
+
const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
|
|
1930
2498
|
const canUndo = this.historyManager?.canUndo();
|
|
1931
2499
|
const canRedo = this.historyManager?.canRedo();
|
|
1932
|
-
const
|
|
2500
|
+
const isInCropMode = !!this._cropMode;
|
|
1933
2501
|
|
|
1934
|
-
if (
|
|
2502
|
+
if (isInCropMode) {
|
|
1935
2503
|
// iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
|
|
1936
|
-
for (const
|
|
1937
|
-
const
|
|
1938
|
-
if (!
|
|
1939
|
-
if (
|
|
1940
|
-
|
|
2504
|
+
for (const key of Object.keys(this.elements || {})) {
|
|
2505
|
+
const element = document.getElementById(this.elements[key]);
|
|
2506
|
+
if (!element) continue;
|
|
2507
|
+
if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
|
|
2508
|
+
this._setDisabled(key, false);
|
|
1941
2509
|
} else {
|
|
1942
|
-
|
|
2510
|
+
this._setDisabled(key, true);
|
|
1943
2511
|
}
|
|
1944
2512
|
}
|
|
1945
2513
|
return;
|
|
1946
2514
|
}
|
|
1947
2515
|
|
|
1948
|
-
this._setDisabled('zoomInBtn', !
|
|
1949
|
-
this._setDisabled('zoomOutBtn', !
|
|
1950
|
-
this._setDisabled('rotateLeftBtn', !
|
|
1951
|
-
this._setDisabled('rotateRightBtn', !
|
|
1952
|
-
this._setDisabled('addMaskBtn', !
|
|
2516
|
+
this._setDisabled('zoomInBtn', !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
|
|
2517
|
+
this._setDisabled('zoomOutBtn', !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
|
|
2518
|
+
this._setDisabled('rotateLeftBtn', !hasImage || this.isAnimating);
|
|
2519
|
+
this._setDisabled('rotateRightBtn', !hasImage || this.isAnimating);
|
|
2520
|
+
this._setDisabled('addMaskBtn', !hasImage || this.isAnimating);
|
|
1953
2521
|
this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
|
|
1954
2522
|
this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
|
|
1955
|
-
this._setDisabled('mergeBtn', !
|
|
1956
|
-
this._setDisabled('downloadBtn', !
|
|
1957
|
-
this._setDisabled('resetBtn', !
|
|
1958
|
-
this._setDisabled('undoBtn', !
|
|
1959
|
-
this._setDisabled('redoBtn', !
|
|
1960
|
-
this._setDisabled('cropBtn', !
|
|
2523
|
+
this._setDisabled('mergeBtn', !hasImage || !hasMasks || this.isAnimating);
|
|
2524
|
+
this._setDisabled('downloadBtn', !hasImage || this.isAnimating);
|
|
2525
|
+
this._setDisabled('resetBtn', !hasImage || isDefaultTransform || this.isAnimating);
|
|
2526
|
+
this._setDisabled('undoBtn', !hasImage || this.isAnimating || !canUndo);
|
|
2527
|
+
this._setDisabled('redoBtn', !hasImage || this.isAnimating || !canRedo);
|
|
2528
|
+
this._setDisabled('cropBtn', !hasImage || this.isAnimating);
|
|
1961
2529
|
this._setDisabled('applyCropBtn', true);
|
|
1962
2530
|
this._setDisabled('cancelCropBtn', true);
|
|
2531
|
+
this._setDisabled('imageInput', this.isAnimating);
|
|
2532
|
+
this._setDisabled('uploadArea', this.isAnimating);
|
|
1963
2533
|
}
|
|
1964
2534
|
|
|
1965
2535
|
/**
|
|
@@ -1970,8 +2540,26 @@ function ensureFabric() {
|
|
|
1970
2540
|
* @private
|
|
1971
2541
|
*/
|
|
1972
2542
|
_setDisabled(key, disabled) {
|
|
1973
|
-
const
|
|
1974
|
-
if (
|
|
2543
|
+
const element = document.getElementById(this.elements[key]);
|
|
2544
|
+
if (!element) return;
|
|
2545
|
+
if ('disabled' in element) {
|
|
2546
|
+
element.disabled = !!disabled;
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
if (disabled) {
|
|
2551
|
+
element.setAttribute('aria-disabled', 'true');
|
|
2552
|
+
element.style.pointerEvents = 'none';
|
|
2553
|
+
} else {
|
|
2554
|
+
element.removeAttribute('aria-disabled');
|
|
2555
|
+
element.style.pointerEvents = '';
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
_isElementDisabled(element) {
|
|
2560
|
+
if (!element) return false;
|
|
2561
|
+
if ('disabled' in element) return !!element.disabled;
|
|
2562
|
+
return element.getAttribute('aria-disabled') === 'true';
|
|
1975
2563
|
}
|
|
1976
2564
|
|
|
1977
2565
|
/**
|
|
@@ -1989,15 +2577,15 @@ function ensureFabric() {
|
|
|
1989
2577
|
* @private
|
|
1990
2578
|
*/
|
|
1991
2579
|
_setPlaceholderVisible(show) {
|
|
1992
|
-
if (!this.
|
|
2580
|
+
if (!this.placeholderElement) return;
|
|
1993
2581
|
if (show) {
|
|
1994
|
-
this.
|
|
1995
|
-
this.
|
|
1996
|
-
this.
|
|
2582
|
+
this.placeholderElement.classList.remove('d-none');
|
|
2583
|
+
this.placeholderElement.classList.add('d-flex');
|
|
2584
|
+
this.containerElement.classList.add('d-none');
|
|
1997
2585
|
} else {
|
|
1998
|
-
this.
|
|
1999
|
-
this.
|
|
2000
|
-
this.
|
|
2586
|
+
this.placeholderElement.classList.remove('d-flex');
|
|
2587
|
+
this.placeholderElement.classList.add('d-none');
|
|
2588
|
+
this.containerElement.classList.remove('d-none');
|
|
2001
2589
|
}
|
|
2002
2590
|
}
|
|
2003
2591
|
|
|
@@ -2009,28 +2597,32 @@ function ensureFabric() {
|
|
|
2009
2597
|
dispose() {
|
|
2010
2598
|
// Remove bound DOM event listeners
|
|
2011
2599
|
try {
|
|
2012
|
-
for (const key in (this.
|
|
2013
|
-
const handlers = this.
|
|
2014
|
-
const
|
|
2015
|
-
if (!
|
|
2016
|
-
handlers.forEach(
|
|
2017
|
-
try {
|
|
2600
|
+
for (const key in (this._handlersByElementKey || {})) {
|
|
2601
|
+
const handlers = this._handlersByElementKey[key] || [];
|
|
2602
|
+
const element = document.getElementById(this.elements[key]);
|
|
2603
|
+
if (!element) continue;
|
|
2604
|
+
handlers.forEach(handlerRecord => {
|
|
2605
|
+
try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
|
|
2018
2606
|
});
|
|
2019
2607
|
}
|
|
2020
|
-
} catch (
|
|
2608
|
+
} catch (error) { void error; }
|
|
2021
2609
|
|
|
2022
2610
|
if (this._cropRect) {
|
|
2023
2611
|
try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
|
|
2024
2612
|
this._cropRect = null;
|
|
2025
2613
|
}
|
|
2026
2614
|
|
|
2615
|
+
if (this.containerElement && this._containerOriginalOverflow !== undefined) {
|
|
2616
|
+
try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (e) { void e; }
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2027
2619
|
if (this.canvas) {
|
|
2028
2620
|
try { this.canvas.dispose(); } catch (e) { void e; }
|
|
2029
2621
|
this.canvas = null;
|
|
2030
|
-
this.
|
|
2622
|
+
this.canvasElement = null;
|
|
2031
2623
|
this.isImageLoadedToCanvas = false;
|
|
2032
2624
|
}
|
|
2033
|
-
this.
|
|
2625
|
+
this._handlersByElementKey = {};
|
|
2034
2626
|
}
|
|
2035
2627
|
}
|
|
2036
2628
|
|
|
@@ -2135,6 +2727,13 @@ function ensureFabric() {
|
|
|
2135
2727
|
this.history = [];
|
|
2136
2728
|
this.currentIndex = -1;
|
|
2137
2729
|
this.maxSize = maxSize;
|
|
2730
|
+
this.pending = Promise.resolve();
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
enqueue(task) {
|
|
2734
|
+
const run = this.pending.then(task, task);
|
|
2735
|
+
this.pending = run.catch(() => {});
|
|
2736
|
+
return run;
|
|
2138
2737
|
}
|
|
2139
2738
|
|
|
2140
2739
|
/**
|
|
@@ -2147,7 +2746,17 @@ function ensureFabric() {
|
|
|
2147
2746
|
execute(command) {
|
|
2148
2747
|
// Perform the command.
|
|
2149
2748
|
command.execute();
|
|
2749
|
+
this.push(command);
|
|
2750
|
+
}
|
|
2150
2751
|
|
|
2752
|
+
/**
|
|
2753
|
+
* Pushes an already-applied command onto the history stack.
|
|
2754
|
+
* Truncates any "future" history when branching.
|
|
2755
|
+
*
|
|
2756
|
+
* @param {Command} command The command to push.
|
|
2757
|
+
* @returns {void}
|
|
2758
|
+
*/
|
|
2759
|
+
push(command) {
|
|
2151
2760
|
// Remove any commands that are ahead of the current index.
|
|
2152
2761
|
if (this.currentIndex < this.history.length - 1) {
|
|
2153
2762
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
@@ -2188,10 +2797,13 @@ function ensureFabric() {
|
|
|
2188
2797
|
* @returns {void}
|
|
2189
2798
|
*/
|
|
2190
2799
|
undo() {
|
|
2191
|
-
|
|
2192
|
-
this.
|
|
2193
|
-
|
|
2194
|
-
|
|
2800
|
+
return this.enqueue(async () => {
|
|
2801
|
+
if (this.currentIndex >= 0) {
|
|
2802
|
+
const index = this.currentIndex;
|
|
2803
|
+
await this.history[index].undo();
|
|
2804
|
+
this.currentIndex = index - 1;
|
|
2805
|
+
}
|
|
2806
|
+
});
|
|
2195
2807
|
}
|
|
2196
2808
|
|
|
2197
2809
|
/**
|
|
@@ -2200,10 +2812,13 @@ function ensureFabric() {
|
|
|
2200
2812
|
* @returns {void}
|
|
2201
2813
|
*/
|
|
2202
2814
|
redo() {
|
|
2203
|
-
|
|
2204
|
-
this.currentIndex
|
|
2205
|
-
|
|
2206
|
-
|
|
2815
|
+
return this.enqueue(async () => {
|
|
2816
|
+
if (this.currentIndex < this.history.length - 1) {
|
|
2817
|
+
const index = this.currentIndex + 1;
|
|
2818
|
+
await this.history[index].execute();
|
|
2819
|
+
this.currentIndex = index;
|
|
2820
|
+
}
|
|
2821
|
+
});
|
|
2207
2822
|
}
|
|
2208
2823
|
}
|
|
2209
2824
|
|