@bensitu/image-editor 1.2.0 → 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.
@@ -1,30 +1,36 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.2.0
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
- (function (root, factory) {
16
- if (typeof define === 'function' && define.amd) {
17
- // AMD / RequireJS
18
- define([], factory)
19
- } else if (typeof module === 'object' && module.exports) {
20
- // CommonJS / Node / webpack (target=commonjs)
21
- module.exports = factory()
22
- } else {
23
- // Browser normal <script> method, hanging to the global
24
- root.ImageEditor = factory()
25
- }
26
- })(typeof self !== 'undefined' ? self : this, function () {
27
- 'use strict'
10
+ let fabric = null;
11
+
12
+ function getGlobalScope() {
13
+ if (typeof globalThis !== 'undefined') return globalThis;
14
+ if (typeof self !== 'undefined') return self;
15
+ if (typeof window !== 'undefined') return window;
16
+ return null;
17
+ }
18
+
19
+ function getGlobalFabric() {
20
+ const scope = getGlobalScope();
21
+ return scope && scope.fabric ? scope.fabric : null;
22
+ }
23
+
24
+ export function setFabric(fabricInstance) {
25
+ fabric = fabricInstance || getGlobalFabric();
26
+ return fabric;
27
+ }
28
+
29
+ function ensureFabric() {
30
+ if (!fabric) setFabric();
31
+ return fabric;
32
+ }
33
+
28
34
  /**
29
35
  * ImageEditor
30
36
  *
@@ -53,7 +59,7 @@
53
59
  * @param {number} [options.rotationStep=90] - Rotation step in degrees.
54
60
  * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image/mask.
55
61
  * @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
56
- * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover canvas (at least one side fits, allowing overflow).
62
+ * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
57
63
  * @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
58
64
  * @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
59
65
  * @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
@@ -70,17 +76,39 @@
70
76
  * @param {string|null} [options.initialImageBase64=null] - Base64 string to auto-load as initial image, if any.
71
77
  * @param {string} [options.defaultDownloadFileName='edited_image.jpg'] - Default file name for downloads.
72
78
  * @param {function} [options.onImageLoaded] - Optional callback to invoke after an image loads.
79
+ * @param {function} [options.onError] - Optional callback for recoverable internal errors.
80
+ * @param {function} [options.onWarning] - Optional callback for recoverable internal warnings.
73
81
  *
74
82
  * @constructor
75
83
  */
76
84
  class ImageEditor {
77
85
  constructor(options = {}) {
78
- // Verify that fabric.js is present
79
- this._fabricLoaded = typeof fabric !== 'undefined';
80
- if (!this._fabricLoaded) {
81
- console.error('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
82
- }
83
86
  // Default options (can be overridden via ctor param)
87
+ const defaultLabel = {
88
+ getText: (mask) => mask.maskName,
89
+ textOptions: {
90
+ fontSize: 12,
91
+ fill: '#fff',
92
+ backgroundColor: 'rgba(0,0,0,0.7)',
93
+ padding: 2,
94
+ fontFamily: 'monospace',
95
+ fontWeight: 'bold',
96
+ selectable: false,
97
+ evented: false,
98
+ originX: 'left',
99
+ originY: 'top'
100
+ }
101
+ };
102
+ const defaultCrop = {
103
+ minWidth: 100,
104
+ minHeight: 100,
105
+ padding: 10,
106
+ hideMasksDuringCrop: true,
107
+ preserveMasksAfterCrop: false,
108
+ allowRotationOfCropRect: false
109
+ };
110
+ const userLabel = options.label || {};
111
+ const userCrop = options.crop || {};
84
112
  this.options = {
85
113
  canvasWidth: 800,
86
114
  canvasHeight: 600,
@@ -117,38 +145,35 @@
117
145
  initialImageBase64: null, // Provide a base64 'data:image/...' string here if you want auto-load
118
146
 
119
147
  defaultDownloadFileName: 'edited_image.jpg',
120
-
121
- ...options
122
- };
123
- this.options.label = {
124
- getText: (mask, maskIndex) => mask.maskName,
125
- textOptions: {
126
- fontSize: 12,
127
- fill: '#fff',
128
- backgroundColor: 'rgba(0,0,0,0.7)',
129
- padding: 2,
130
- fontFamily: "monospace",
131
- fontWeight: "bold",
132
- selectable: false,
133
- evented: false,
134
- originX: 'left',
135
- originY: 'top',
148
+ onError: null,
149
+ onWarning: null,
150
+
151
+ ...options,
152
+ label: {
153
+ ...defaultLabel,
154
+ ...userLabel,
155
+ textOptions: {
156
+ ...defaultLabel.textOptions,
157
+ ...(userLabel.textOptions || {})
158
+ }
159
+ },
160
+ crop: {
161
+ ...defaultCrop,
162
+ ...userCrop
136
163
  }
137
164
  };
138
- this.options.crop = {
139
- minWidth: 100,
140
- minHeight: 100,
141
- padding: 10,
142
- hideMasksDuringCrop: true,
143
- preserveMasksAfterCrop: true,
144
- allowRotationOfCropRect: false
145
- };
165
+
166
+ // Verify that fabric.js is present
167
+ this._fabricLoaded = !!ensureFabric();
168
+ if (!this._fabricLoaded) {
169
+ this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
170
+ }
146
171
 
147
172
  // Runtime state
148
173
  this.canvas = null;
149
- this.canvasEl = null;
150
- this.containerEl = null;
151
- this.placeholderEl = null;
174
+ this.canvasElement = null;
175
+ this.containerElement = null;
176
+ this.placeholderElement = null;
152
177
 
153
178
  this.originalImage = null; // fabric.Image
154
179
  this.baseImageScale = 1;
@@ -160,15 +185,20 @@
160
185
  this.isImageLoadedToCanvas = false;
161
186
  this.maxHistorySize = 50;
162
187
 
163
- this._boundHandlers = {};
188
+ this._handlersByElementKey = {};
164
189
 
190
+ this._lastMask = null;
165
191
  this._lastMaskInitialLeft = null;
166
192
  this._lastMaskInitialTop = null;
167
193
  this._lastMaskInitialWidth = null;
194
+ this._lastSnapshot = null;
168
195
 
169
196
  this._cropMode = false;
170
197
  this._cropRect = null;
171
198
  this._cropHandlers = [];
199
+ this._cropPrevEvented = null;
200
+ this._prevSelectionSetting = undefined;
201
+ this._containerOriginalOverflow = undefined;
172
202
 
173
203
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
174
204
 
@@ -176,6 +206,39 @@
176
206
  this.historyManager = new HistoryManager(this.maxHistorySize);
177
207
  }
178
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
+
179
242
  /**
180
243
  * Initializes the editor, binds to DOM elements, sets up event handlers,
181
244
  * and (optionally) loads an initial image.
@@ -241,53 +304,120 @@
241
304
  }
242
305
  }
243
306
 
307
+ _reportError(message, error = null) {
308
+ const handler = this.options && this.options.onError;
309
+ if (typeof handler !== 'function') return;
310
+
311
+ try {
312
+ handler(error, message);
313
+ } catch {
314
+ // Ignore observer failures so editor recovery paths remain stable.
315
+ }
316
+ }
317
+
318
+ _reportWarning(message, error = null) {
319
+ const handler = this.options && this.options.onWarning;
320
+ if (typeof handler !== 'function') return;
321
+
322
+ try {
323
+ handler(error, message);
324
+ } catch {
325
+ // Ignore observer failures so editor recovery paths remain stable.
326
+ }
327
+ }
328
+
244
329
  /**
245
330
  * Canvas setup helpers
246
331
  * @private
247
332
  */
248
333
  _initCanvas() {
249
- const canvasEl = document.getElementById(this.elements.canvas);
250
- if (!canvasEl) throw new Error('Canvas is not found: ' + this.elements.canvas);
251
- this.canvasEl = canvasEl;
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;
252
337
 
253
338
  // Decide which element acts as "viewport" (for width/height fallback)
254
339
  if (this.elements.canvasContainer) {
255
- const ce = document.getElementById(this.elements.canvasContainer);
256
- this.containerEl = ce || canvasEl.parentElement;
340
+ const containerElement = document.getElementById(this.elements.canvasContainer);
341
+ this.containerElement = containerElement || canvasElement.parentElement;
257
342
  } else {
258
- this.containerEl = canvasEl.parentElement;
343
+ this.containerElement = canvasElement.parentElement;
259
344
  }
260
345
 
261
- this.placeholderEl = document.getElementById(this.elements.imgPlaceholder) || null;
346
+ this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
262
347
 
263
348
  // Initial size — take container size if available
264
- let initialW = this.options.canvasWidth;
265
- let initialH = this.options.canvasHeight;
266
- if (this.containerEl) {
267
- const cw = Math.floor(this.containerEl.clientWidth);
268
- const ch = Math.floor(this.containerEl.clientHeight);
269
- if (cw > 0 && ch > 0) { initialW = cw; initialH = ch; }
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
+ }
270
358
  }
271
359
 
272
- this.canvas = new fabric.Canvas(canvasEl, {
273
- width: initialW,
274
- height: initialH,
360
+ this.canvas = new fabric.Canvas(canvasElement, {
361
+ width: initialWidth,
362
+ height: initialHeight,
275
363
  backgroundColor: this.options.backgroundColor,
276
364
  selection: this.options.groupSelection,
277
365
  preserveObjectStacking: true
278
366
  });
279
367
 
280
368
  // Fabric event wiring
281
- this.canvas.on('selection:created', (e) => this._onSelectionChanged(e.selected));
282
- this.canvas.on('selection:updated', (e) => this._onSelectionChanged(e.selected));
283
- this.canvas.on('selection:cleared', () => this._onSelectionChanged([]));
284
- this.canvas.on('object:moving', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
285
- this.canvas.on('object:scaling', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
286
- this.canvas.on('object:rotating', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
287
- this.canvas.on('object:modified', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
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));
288
376
 
289
377
  // Avoid inline-element whitespace artefacts
290
- this.canvasEl.style.display = 'block';
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
+ }
291
421
  }
292
422
 
293
423
  /**
@@ -296,67 +426,72 @@
296
426
  */
297
427
  _bindEvents() {
298
428
  // Click anywhere on the upload area opens the native file dialog
299
- this._bindIfExists('uploadArea', 'click', () => document.getElementById(this.elements.imageInput)?.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
+ });
300
434
  // File-input change
301
- const inputEl = document.getElementById(this.elements.imageInput);
302
- if (inputEl) {
303
- inputEl.addEventListener('change', (e) => {
304
- const f = e.target.files && e.target.files[0];
305
- if (f) this._loadImageFile(f);
306
- });
307
- }
435
+ this._bindIfExists('imageInput', 'change', (event) => {
436
+ const file = event.target.files && event.target.files[0];
437
+ if (file) this._loadImageFile(file);
438
+ });
308
439
  // Zoom & reset
309
440
  this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
310
441
  this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
311
- this._bindIfExists('resetBtn', 'click', () => { this.reset(); });
442
+ this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
312
443
  // Mask management
313
- this._bindIfExists('addMaskBtn', 'click', () => this.addMask());
444
+ this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
314
445
  this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
315
446
  this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
316
447
  // Merge + download
317
- this._bindIfExists('mergeBtn', 'click', () => this.merge());
448
+ this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
318
449
  this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
319
450
  // Undo + Redo
320
451
  this._bindIfExists('undoBtn', 'click', () => this.undo());
321
452
  this._bindIfExists('redoBtn', 'click', () => this.redo());
322
453
 
323
454
  // Rotation buttons (step can be overridden by two input fields)
324
- const rotLeftBtn = document.getElementById(this.elements.rotateLeftBtn);
325
- const rotRightBtn = document.getElementById(this.elements.rotateRightBtn);
326
- if (rotLeftBtn) rotLeftBtn.addEventListener('click', () => {
327
- const el = document.getElementById(this.elements.rotationLeftInput);
455
+ this._bindIfExists('rotateLeftBtn', 'click', () => {
456
+ const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
328
457
  let step = this.options.rotationStep;
329
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
458
+ if (rotationInputElement) {
459
+ const parsedStep = parseFloat(rotationInputElement.value);
460
+ if (!isNaN(parsedStep)) step = parsedStep;
461
+ }
330
462
  this.rotateImage(this.currentRotation - step);
331
463
  });
332
- if (rotRightBtn) rotRightBtn.addEventListener('click', () => {
333
- const el = document.getElementById(this.elements.rotationRightInput);
464
+ this._bindIfExists('rotateRightBtn', 'click', () => {
465
+ const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
334
466
  let step = this.options.rotationStep;
335
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
467
+ if (rotationInputElement) {
468
+ const parsedStep = parseFloat(rotationInputElement.value);
469
+ if (!isNaN(parsedStep)) step = parsedStep;
470
+ }
336
471
  this.rotateImage(this.currentRotation + step);
337
472
  });
338
473
 
339
474
  // Crop bindings (optional: bound only if element IDs exist in elements)
340
475
  this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
341
- this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(e => console.error('applyCrop failed', e)); });
476
+ this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
342
477
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
343
478
  }
344
479
 
345
480
  /**
346
481
  * Event binding element check
347
482
  *
348
- * @param {*} event
483
+ * @param {*} eventName
349
484
  * @param {*} handler
350
485
  * @param {*} key
351
486
  * @private
352
487
  */
353
- _bindIfExists(key, event, handler) {
354
- const el = document.getElementById(this.elements[key]);
355
- if (el) {
356
- el.addEventListener(event, handler);
357
- this._boundHandlers = this._boundHandlers || {};
358
- if (!this._boundHandlers[key]) this._boundHandlers[key] = [];
359
- this._boundHandlers[key].push({ event, handler });
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 });
360
495
  }
361
496
  }
362
497
 
@@ -369,115 +504,131 @@
369
504
  _loadImageFile(file) {
370
505
  if (!file || !file.type.startsWith('image/')) return;
371
506
  const reader = new FileReader();
372
- reader.onload = (e) => this.loadImage(e.target.result);
373
- reader.onerror = (e) => { console.error(`[ImageEditor: fileReadError]`, e); }
507
+ reader.onload = (event) => this.loadImage(event.target.result);
508
+ reader.onerror = (event) => { this._reportError('Image file could not be read', event); };
374
509
  reader.readAsDataURL(file);
375
510
  }
376
511
 
377
512
  /**
378
513
  * Load a base64 encoded image string into fabric.
379
514
  * @async
380
- * @param {String} base64
381
- */
382
- async loadImage(base64) {
515
+ * @param {String} imageBase64
516
+ */
517
+ async loadImage(imageBase64) {
383
518
  if (!this._fabricLoaded) return;
384
- if (!base64 || typeof base64 !== 'string' || !base64.startsWith('data:image/')) return;
519
+ if (!this.canvas) return;
520
+ if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
385
521
 
386
522
  this._setPlaceholderVisible(false);
523
+ this._syncContainerOverflow();
387
524
 
388
- const imgEl = await this._createImageElement(base64);
525
+ const imageElement = await this._createImageElement(imageBase64);
389
526
 
390
- let loadSrc = base64;
527
+ let loadSource = imageBase64;
391
528
  if (this.options.downsampleOnLoad) {
392
- const needResize =
393
- imgEl.naturalWidth > this.options.downsampleMaxWidth ||
394
- imgEl.naturalHeight > this.options.downsampleMaxHeight;
395
- if (needResize) {
529
+ const shouldResize =
530
+ imageElement.naturalWidth > this.options.downsampleMaxWidth ||
531
+ imageElement.naturalHeight > this.options.downsampleMaxHeight;
532
+ if (shouldResize) {
396
533
  const ratio = Math.min(
397
- this.options.downsampleMaxWidth / imgEl.naturalWidth,
398
- this.options.downsampleMaxHeight / imgEl.naturalHeight
534
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
535
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
399
536
  );
400
- const tw = Math.round(imgEl.naturalWidth * ratio);
401
- const th = Math.round(imgEl.naturalHeight * ratio);
402
- loadSrc = this._resampleImageToDataURL(imgEl, tw, th, this.options.downsampleQuality);
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);
403
540
  }
404
541
  }
405
542
 
406
543
  // Create fabric.Image from URL
407
- fabric.Image.fromURL(loadSrc, (fimg) => {
408
- this.canvas.discardActiveObject();
409
- this._hideAllMaskLabels();
410
- this.canvas.clear();
411
- this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
412
-
413
- fimg.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
414
-
415
- const imgW = fimg.width;
416
- const imgH = fimg.height;
417
-
418
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || this.options.canvasWidth) : this.options.canvasWidth;
419
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || this.options.canvasHeight) : this.options.canvasHeight;
420
-
421
- if (this.options.fitImageToCanvas) {
422
- // Fit into current canvas (shrink only) and ensure canvas does not exceed container
423
- const cw = Math.max(1, Math.min(this.options.canvasWidth, minW) - 1)
424
- const ch = Math.max(1, Math.min(this.options.canvasHeight, minH) - 1);
425
- this._setCanvasSizeInt(cw, ch);
426
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
427
- fimg.set({ left: 0, top: 0 });
428
- fimg.scale(fitScale);
429
- this.baseImageScale = fimg.scaleX || 1;
430
- } else if (this.options.coverImageToCanvas) {
431
- // Cover canvas: scale to cover, allowing overflow (at least one side fits)
432
- const cw = Math.max(this.options.canvasWidth, minW);
433
- const ch = Math.max(this.options.canvasHeight, minH);
434
- this._setCanvasSizeInt(cw, ch);
435
- const coverScale = Math.min(1, Math.max(cw / imgW, ch / imgH));
436
- fimg.set({ left: 0, top: 0 });
437
- fimg.scale(coverScale);
438
- this.baseImageScale = fimg.scaleX || 1;
439
- } else if (this.options.expandCanvasToImage) {
440
- // Expand canvas so that it fully contains the image
441
- const cw = Math.max(minW, Math.floor(imgW));
442
- const ch = Math.max(minH, Math.floor(imgH));
443
- this._setCanvasSizeInt(cw, ch);
444
- fimg.set({ left: 0, top: 0 });
445
- fimg.scale(1);
446
- this.baseImageScale = 1;
447
- } else {
448
- // Keep existing canvas size and center the image
449
- const cw = Math.max(this.options.canvasWidth, minW);
450
- const ch = Math.max(this.options.canvasHeight, minH);
451
- this._setCanvasSizeInt(cw, ch);
452
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
453
- fimg.set({ left: 0, top: 0 });
454
- fimg.scale(fitScale);
455
- this.baseImageScale = fimg.scaleX || 1;
456
- }
457
- // Put the image onto the canvas
458
- this.originalImage = fimg;
459
- this.canvas.add(fimg);
460
- this.canvas.sendToBack(fimg);
461
-
462
- // Reset mask placement memory
463
- this._lastMaskInitialLeft = null;
464
- this._lastMaskInitialTop = null;
465
- this._lastMaskInitialWidth = null;
466
-
467
- this.maskCounter = 0;
468
- this.currentScale = 1;
469
- this.currentRotation = 0;
544
+ return new Promise((resolve, reject) => {
545
+ fabric.Image.fromURL(loadSource, (fabricImage) => {
546
+ try {
547
+ if (!fabricImage) throw new Error('Image could not be loaded');
548
+
549
+ this.canvas.discardActiveObject();
550
+ this._hideAllMaskLabels();
551
+ this.canvas.clear();
552
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
553
+
554
+ fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
555
+
556
+ const imageWidth = fabricImage.width;
557
+ const imageHeight = fabricImage.height;
558
+
559
+ const viewport = this._getContainerViewportSize();
560
+ const minWidth = viewport.width;
561
+ const minHeight = viewport.height;
562
+
563
+ if (this.options.fitImageToCanvas) {
564
+ // Fit into current canvas (shrink only) and ensure canvas does not exceed container
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;
572
+ } else if (this.options.coverImageToCanvas) {
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;
578
+ } else if (this.options.expandCanvasToImage) {
579
+ // Expand canvas so that it fully contains the image
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);
585
+ this.baseImageScale = 1;
586
+ } else {
587
+ // Keep existing canvas size and center the image
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;
595
+ }
596
+ // Put the image onto the canvas
597
+ this.originalImage = fabricImage;
598
+ this.canvas.add(fabricImage);
599
+ this.canvas.sendToBack(fabricImage);
600
+
601
+ // Reset mask placement memory
602
+ this._lastMask = null;
603
+ this._lastMaskInitialLeft = null;
604
+ this._lastMaskInitialTop = null;
605
+ this._lastMaskInitialWidth = null;
606
+
607
+ this.maskCounter = 0;
608
+ this.currentScale = 1;
609
+ this.currentRotation = 0;
610
+
611
+ this._updateInputs();
612
+ this._updateMaskList();
613
+ this.isImageLoadedToCanvas = true;
614
+ this._updateUI();
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
+ }
470
621
 
471
- this._updateInputs();
472
- this._updateMaskList();
473
- this._updateUI();
474
- this.canvas.renderAll();
475
- this.isImageLoadedToCanvas = true;
622
+ if (typeof this.onImageLoaded === 'function') {
623
+ this.onImageLoaded();
624
+ }
476
625
 
477
- if (typeof this.onImageLoaded === 'function') {
478
- this.onImageLoaded();
479
- }
480
- }, { crossOrigin: 'anonymous' });
626
+ resolve();
627
+ } catch (error) {
628
+ reject(error);
629
+ }
630
+ }, { crossOrigin: 'anonymous' });
631
+ });
481
632
  }
482
633
 
483
634
  /**
@@ -485,9 +636,11 @@
485
636
  * @returns {boolean} true if loaded, false if not
486
637
  */
487
638
  isImageLoaded() {
639
+ const fabricInstance = ensureFabric();
488
640
  return !!(
489
641
  this.originalImage &&
490
- this.originalImage instanceof fabric.Image &&
642
+ fabricInstance &&
643
+ this.originalImage instanceof fabricInstance.Image &&
491
644
  this.originalImage.width > 0 &&
492
645
  this.originalImage.height > 0
493
646
  );
@@ -496,44 +649,44 @@
496
649
  /**
497
650
  * Creates an HTMLImageElement from a given data URL.
498
651
  *
499
- * @param {string} dataURL - A data URL representing the image (e.g., "data:image/png;base64,...").
652
+ * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
500
653
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
501
654
  * @private
502
655
  */
503
- _createImageElement(dataURL) {
504
- return new Promise((res, rej) => {
505
- const img = new Image();
506
- img.onload = () => {
507
- img.onload = null;
508
- img.onerror = null;
509
- res(img);
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);
510
663
  };
511
- img.onerror = (e) => {
512
- img.onload = null;
513
- img.onerror = null;
514
- rej(e);
664
+ imageElement.onerror = (error) => {
665
+ imageElement.onload = null;
666
+ imageElement.onerror = null;
667
+ reject(error);
515
668
  };
516
- img.src = dataURL;
669
+ imageElement.src = dataUrl;
517
670
  });
518
671
  }
519
672
 
520
673
  /**
521
674
  * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
522
675
  *
523
- * @param {HTMLImageElement} imgEl - The image element to resample.
524
- * @param {number} w - Target width (in pixels) for the resampled image.
525
- * @param {number} h - Target height (in pixels) for the resampled image.
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.
526
679
  * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
527
680
  * @returns {string} A data URL representing the resampled image as JPEG.
528
681
  * @private
529
682
  */
530
- _resampleImageToDataURL(imgEl, w, h, quality = 0.92) {
531
- const oc = document.createElement('canvas');
532
- oc.width = w;
533
- oc.height = h;
534
- const ctx = oc.getContext('2d');
535
- ctx.drawImage(imgEl, 0, 0, imgEl.naturalWidth, imgEl.naturalHeight, 0, 0, w, h);
536
- return oc.toDataURL('image/jpeg', quality);
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);
537
690
  }
538
691
 
539
692
  /**
@@ -552,60 +705,401 @@
552
705
  this.canvas.setHeight(ih);
553
706
  if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
554
707
  // Keep DOM element in sync (avoid fractional CSS pixels)
555
- if (this.canvasEl) {
556
- this.canvasEl.style.width = iw + 'px';
557
- this.canvasEl.style.height = ih + 'px';
558
- this.canvasEl.style.maxWidth = 'none';
708
+ if (this.canvasElement) {
709
+ this.canvasElement.style.width = iw + 'px';
710
+ this.canvasElement.style.height = ih + 'px';
711
+ this.canvasElement.style.maxWidth = 'none';
712
+ }
713
+ }
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
+ });
559
946
  }
560
947
  }
561
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
+
562
1056
  /**
563
1057
  * Gets the top-left corner coordinates of the given object.
564
1058
  * Used for geometry calculations (e.g., scale, rotate).
565
1059
  *
566
- * @param {Object} obj - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
1060
+ * @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
567
1061
  * @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
568
1062
  * @private
569
1063
  */
570
- _getObjectTopLeftPoint(obj) {
571
- if (!obj) return { x: 0, y: 0 };
572
- obj.setCoords();
573
- const coords = typeof obj.getCoords === 'function' ? obj.getCoords() : null;
1064
+ _getObjectTopLeftPoint(fabricObject) {
1065
+ if (!fabricObject) return { x: 0, y: 0 };
1066
+ fabricObject.setCoords();
1067
+ const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
574
1068
  if (coords && coords.length) return coords[0];
575
- const br = obj.getBoundingRect(true, true);
576
- return { x: br.left, y: br.top };
1069
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1070
+ return { x: boundingRect.left, y: boundingRect.top };
577
1071
  }
578
1072
 
579
1073
  /**
580
1074
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
581
1075
  *
582
- * @param {Object} obj - The object to modify. Should support set, setPositionByOrigin, and setCoords.
1076
+ * @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
583
1077
  * @param {string} originX - The new originX ("left", "center", "right", etc.).
584
1078
  * @param {string} originY - The new originY ("top", "center", "bottom", etc.).
585
1079
  * @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
586
1080
  * @private
587
1081
  */
588
- _setObjectOriginKeepingPosition(obj, originX, originY, refPoint) {
589
- if (!obj || !refPoint || !obj.setPositionByOrigin) return;
590
- obj.set({ originX, originY });
591
- obj.setPositionByOrigin(refPoint, originX, originY);
592
- obj.setCoords();
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();
593
1087
  }
594
1088
 
595
1089
  /**
596
1090
  * Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
597
1091
  *
598
- * @param {Object} obj - The object to align.
1092
+ * @param {Object} fabricObject - The object to align.
599
1093
  * @private
600
1094
  */
601
- _alignObjectBoundingBoxToCanvasTopLeft(obj) {
602
- if (!obj) return;
603
- obj.setCoords();
604
- const br = obj.getBoundingRect(true, true);
605
- const dx = br.left;
606
- const dy = br.top;
607
- obj.set({ left: (obj.left || 0) - dx, top: (obj.top || 0) - dy });
608
- obj.setCoords();
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();
609
1103
  this.canvas.renderAll();
610
1104
  }
611
1105
 
@@ -617,22 +1111,27 @@
617
1111
  _updateCanvasSizeToImageBounds() {
618
1112
  if (!this.originalImage) return;
619
1113
  this.originalImage.setCoords();
620
- const br = this.originalImage.getBoundingRect(true, true);
1114
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
621
1115
 
622
- // Container integer sizes
623
- const containerW = this.containerEl ? Math.ceil(this.containerEl.clientWidth || 0) : 0;
624
- const containerH = this.containerEl ? Math.ceil(this.containerEl.clientHeight || 0) : 0;
1116
+ const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
1117
+ this._setCanvasSizeInt(size.width, size.height);
1118
+ }
625
1119
 
626
- // If image smaller or equal than container in BOTH dims => keep canvas equal to container
627
- if (containerW > 0 && containerH > 0 && br.width <= containerW && br.height <= containerH) {
628
- this._setCanvasSizeInt(containerW, containerH);
629
- return;
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);
630
1134
  }
631
-
632
- // Else canvas follows image bounding box but not smaller than container dims individually
633
- const newW = Math.max(containerW || 0, Math.floor(br.width));
634
- const newH = Math.max(containerH || 0, Math.floor(br.height));
635
- this._setCanvasSizeInt(newW, newH);
636
1135
  }
637
1136
 
638
1137
  /**
@@ -642,8 +1141,8 @@
642
1141
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
643
1142
  * @public
644
1143
  */
645
- scaleImage(factor) {
646
- return this.animQueue.add(() => this._scaleImageImpl(factor));
1144
+ scaleImage(factor, options = {}) {
1145
+ return this.animQueue.add(() => this._scaleImageImpl(factor, options));
647
1146
  }
648
1147
 
649
1148
  /**
@@ -653,50 +1152,53 @@
653
1152
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
654
1153
  * @private
655
1154
  */
656
- _scaleImageImpl(factor) {
1155
+ _scaleImageImpl(factor, options = {}) {
657
1156
  if (!this.originalImage) return Promise.resolve();
658
1157
  if (this.isAnimating) return Promise.resolve();
1158
+ const saveHistory = options.saveHistory !== false;
659
1159
  factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
660
1160
  this.currentScale = factor;
661
1161
  this.isAnimating = true;
662
1162
  this._updateUI();
663
1163
 
664
- const targetAbs = this.baseImageScale * factor;
1164
+ const targetScale = this.baseImageScale * factor;
665
1165
 
666
1166
  // Scale around current top-left (recompute)
667
1167
  const topLeft = this._getObjectTopLeftPoint(this.originalImage);
668
1168
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
669
1169
 
670
- const p1 = new Promise((res) => {
671
- this.originalImage.animate('scaleX', targetAbs, {
1170
+ const scaleXAnimation = new Promise((resolve) => {
1171
+ this.originalImage.animate('scaleX', targetScale, {
672
1172
  duration: this.options.animationDuration,
673
1173
  onChange: this.canvas.renderAll.bind(this.canvas),
674
- onComplete: res
1174
+ onComplete: resolve
675
1175
  });
676
1176
  });
677
- const p2 = new Promise((res) => {
678
- this.originalImage.animate('scaleY', targetAbs, {
1177
+ const scaleYAnimation = new Promise((resolve) => {
1178
+ this.originalImage.animate('scaleY', targetScale, {
679
1179
  duration: this.options.animationDuration,
680
1180
  onChange: this.canvas.renderAll.bind(this.canvas),
681
- onComplete: res
1181
+ onComplete: resolve
682
1182
  });
683
1183
  });
684
1184
 
685
- return Promise.all([p1, p2]).then(() => {
686
- this.originalImage.set({ scaleX: targetAbs, scaleY: targetAbs });
1185
+ return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1186
+ this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
687
1187
  this.originalImage.setCoords();
688
1188
 
689
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1189
+ if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1190
+ this._updateCanvasSizeToImageBounds();
1191
+ }
690
1192
 
691
1193
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
692
1194
 
693
1195
  // Sync mask labels
694
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1196
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
695
1197
 
696
1198
  this.isAnimating = false;
697
1199
  this._updateInputs();
698
1200
  this._updateUI();
699
- this.saveState();
1201
+ if (saveHistory) this.saveState();
700
1202
  }).catch(() => {
701
1203
  this.isAnimating = false;
702
1204
  this._updateUI();
@@ -710,8 +1212,8 @@
710
1212
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
711
1213
  * @public
712
1214
  */
713
- rotateImage(deg) {
714
- return this.animQueue.add(() => this._rotateImageImpl(deg));
1215
+ rotateImage(degrees, options = {}) {
1216
+ return this.animQueue.add(() => this._rotateImageImpl(degrees, options));
715
1217
  }
716
1218
 
717
1219
  /**
@@ -721,10 +1223,11 @@
721
1223
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
722
1224
  * @private
723
1225
  */
724
- _rotateImageImpl(degrees) {
1226
+ _rotateImageImpl(degrees, options = {}) {
725
1227
  if (!this.originalImage) return Promise.resolve();
726
1228
  if (this.isAnimating) return Promise.resolve();
727
1229
  if (isNaN(degrees)) return Promise.resolve();
1230
+ const saveHistory = options.saveHistory !== false;
728
1231
  this.currentRotation = degrees;
729
1232
  this.isAnimating = true;
730
1233
  this._updateUI();
@@ -732,19 +1235,21 @@
732
1235
  const center = this.originalImage.getCenterPoint();
733
1236
  this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
734
1237
 
735
- const p = new Promise((res) => {
1238
+ const rotationAnimation = new Promise((resolve) => {
736
1239
  this.originalImage.animate('angle', degrees, {
737
1240
  duration: this.options.animationDuration,
738
1241
  onChange: this.canvas.renderAll.bind(this.canvas),
739
- onComplete: res
1242
+ onComplete: resolve
740
1243
  });
741
1244
  });
742
1245
 
743
- return p.then(() => {
1246
+ return rotationAnimation.then(() => {
744
1247
  this.originalImage.set('angle', degrees);
745
1248
  this.originalImage.setCoords();
746
1249
 
747
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1250
+ if (this.options.expandCanvasToImage || this.options.coverImageToCanvas) {
1251
+ this._updateCanvasSizeToImageBounds();
1252
+ }
748
1253
 
749
1254
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
750
1255
 
@@ -752,12 +1257,12 @@
752
1257
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
753
1258
 
754
1259
  // Sync mask labels
755
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1260
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
756
1261
 
757
1262
  this.isAnimating = false;
758
1263
  this._updateInputs();
759
1264
  this._updateUI();
760
- this.saveState();
1265
+ if (saveHistory) this.saveState();
761
1266
  }).catch(() => {
762
1267
  this.isAnimating = false;
763
1268
  this._updateUI();
@@ -765,20 +1270,28 @@
765
1270
  }
766
1271
 
767
1272
  /**
768
- * Resets the image: scales to 1 and rotates to 0 degrees.
1273
+ * Resets the image transform: scales to 1 and rotates to 0 degrees.
769
1274
  * @returns {Promise<void>} Promise that resolves when reset is complete.
770
1275
  */
771
- reset() {
1276
+ resetImageTransform() {
772
1277
  if (!this.originalImage) return Promise.resolve();
773
1278
 
774
- return this.scaleImage(1)
775
- .then(() => this.rotateImage(0))
776
- .then(() => {
777
- this.saveState();
778
- })
779
- .catch(err => {
780
- console.error('reset() failed', err);
781
- });
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();
782
1295
  }
783
1296
 
784
1297
  /**
@@ -786,33 +1299,66 @@
786
1299
  * @param {string} jsonString - the JSON string returned by fabric.toJSON().
787
1300
  */
788
1301
  loadFromState(jsonString) {
789
- if (!jsonString || !this.canvas) return;
1302
+ if (!jsonString || !this.canvas) return Promise.resolve();
790
1303
 
791
- try {
792
- const json = (typeof jsonString === 'string')
793
- ? JSON.parse(jsonString)
794
- : jsonString;
795
-
796
- this.canvas.loadFromJSON(json, () => {
797
- this._hideAllMaskLabels();
798
- const objs = this.canvas.getObjects();
799
- this.originalImage = objs.find(o => o.type === 'image' && !o.maskId) || null;
800
-
801
- this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
802
- this.canvas.sendToBack(this.originalImage);
803
-
804
- const masks = objs.filter(o => o.maskId);
805
- this.maskCounter = masks.reduce((max, m) =>
806
- Math.max(max, m.maskId), 0);
1304
+ return new Promise((resolve) => {
1305
+ try {
1306
+ const json = (typeof jsonString === 'string')
1307
+ ? JSON.parse(jsonString)
1308
+ : jsonString;
807
1309
 
808
- this.canvas.renderAll();
809
- this._updateMaskList();
810
- this._updateUI();
811
- });
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();
1354
+ }
1355
+ });
812
1356
 
813
- } catch (e) {
814
- console.error('loadFromState() failed', e);
815
- }
1357
+ } catch (error) {
1358
+ this._reportError('loadFromState() failed', error);
1359
+ resolve();
1360
+ }
1361
+ });
816
1362
  }
817
1363
 
818
1364
  /**
@@ -820,59 +1366,116 @@
820
1366
  */
821
1367
  saveState() {
822
1368
  if (!this.canvas) return;
823
- const activeObj = this.canvas.getActiveObject();
1369
+ const activeObject = this.canvas.getActiveObject();
824
1370
  this._hideAllMaskLabels();
825
1371
 
826
1372
  try {
827
- // request JSON including the custom flag 'isCropRect' so we can filter it out
828
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
829
- if (Array.isArray(jsonObj.objects)) {
830
- // filter out crop-rect objects before stringifying
831
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
832
- }
833
- const after = JSON.stringify(jsonObj);
1373
+ const after = this._serializeCanvasState();
834
1374
  const before = this._lastSnapshot || after;
1375
+ if (after === before) return;
835
1376
  let executedOnce = false;
836
1377
 
837
- const cmd = new Command(
1378
+ const command = new Command(
838
1379
  () => {
839
1380
  if (executedOnce) {
840
- this.loadFromState(after);
1381
+ return this.loadFromState(after);
841
1382
  }
842
1383
  executedOnce = true;
1384
+ return undefined;
843
1385
  },
844
- () => {
845
- this.loadFromState(before);
846
- }
1386
+ () => this.loadFromState(before)
847
1387
  );
848
1388
 
849
- this.historyManager.execute(cmd);
1389
+ this.historyManager.execute(command);
850
1390
  this._lastSnapshot = after;
851
- if (activeObj && activeObj.maskId) {
852
- this._showLabelForMask(activeObj);
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]);
853
1396
  }
854
1397
  this._updateUI();
855
- } catch (err) {
856
- console.warn('saveState: failed to save canvas snapshot', err);
857
1398
  }
858
1399
  }
859
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
+
860
1415
  /**
861
1416
  * Undo the last state change, if possible.
862
1417
  */
863
1418
  undo() {
864
- this.historyManager.undo();
1419
+ return this.historyManager.undo()
1420
+ .then(() => { this._updateUI(); })
1421
+ .catch(error => { this._reportError('undo failed', error); });
865
1422
  }
866
1423
 
867
1424
  /**
868
1425
  * Redo the next state change, if possible.
869
1426
  */
870
1427
  redo() {
871
- 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 };
872
1475
  }
873
1476
 
874
1477
  /**
875
- * Adds a rectangular mask to the canvas.
1478
+ * Creates a mask and adds it to the canvas.
876
1479
  * Mask placement and properties are determined by the provided config and instance options.
877
1480
  * Canvas and list UI are updated accordingly.
878
1481
  * @param {Object} [config={}] - Optional mask configuration overrides:
@@ -886,15 +1489,15 @@
886
1489
  * @param {boolean} [config.selectable=true]
887
1490
  * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
888
1491
  * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
889
- * @param {function} [config.fabricGenerator] - (cfg) => new FabricObj
1492
+ * @param {function} [config.fabricGenerator] - (maskConfig) => new FabricObj
890
1493
  * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
891
1494
  * @public
892
1495
  */
893
- addMask(config = {}) {
1496
+ createMask(config = {}) {
894
1497
  if (!this.canvas) return null;
895
1498
  const shapeType = config.shape || 'rect';
896
1499
  // Default config
897
- const cfg = {
1500
+ const maskConfig = {
898
1501
  shape: shapeType,
899
1502
  width: this.options.defaultMaskWidth,
900
1503
  height: this.options.defaultMaskHeight,
@@ -913,160 +1516,172 @@
913
1516
  let left = firstOffset;
914
1517
  let top = firstOffset;
915
1518
 
916
- const resolveValue = (val, fallback) => {
917
- if (typeof val === 'function')
918
- return val(this.canvas, this.options); // This context is this of addMask
919
- if (typeof val === 'string' && val.endsWith('%')) {
920
- const percent = parseFloat(val) / 100;
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;
921
1524
  return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
922
1525
  }
923
- return val != null ? val : fallback;
1526
+ return value != null ? value : fallback;
924
1527
  }
925
1528
 
926
- if (cfg.left === undefined && this._lastMask) {
927
- const prev = this._lastMask;
928
- let prevRight = prev.left;
1529
+ if (maskConfig.left === undefined && this._lastMask) {
1530
+ const previousMask = this._lastMask;
1531
+ let previousMaskRight = previousMask.left;
929
1532
 
930
- if (prev.getScaledWidth) {
931
- prevRight += prev.getScaledWidth();
932
- } else if (prev.width) {
933
- prevRight += prev.width * (prev.scaleX ?? 1);
1533
+ if (previousMask.getScaledWidth) {
1534
+ previousMaskRight += previousMask.getScaledWidth();
1535
+ } else if (previousMask.width) {
1536
+ previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
934
1537
  }
935
- left = Math.round(prevRight + cfg.gap);
936
- top = prev.top ?? firstOffset;
1538
+ left = Math.round(previousMaskRight + maskConfig.gap);
1539
+ top = previousMask.top ?? firstOffset;
937
1540
  } else {
938
- left = resolveValue(cfg.left, firstOffset);
939
- top = resolveValue(cfg.top, firstOffset);
1541
+ left = resolveValue(maskConfig.left, firstOffset);
1542
+ top = resolveValue(maskConfig.top, firstOffset);
940
1543
  }
941
1544
 
942
- cfg.width = resolveValue(cfg.width, this.options.defaultMaskWidth);
943
- cfg.height = resolveValue(cfg.height, this.options.defaultMaskHeight);
944
-
945
- // If expandCanvasToImage mode, ensure canvas large enough to hold mask initial placement
946
- if (this.options.expandCanvasToImage && shapeType === 'rect') {
947
- const requiredW = Math.ceil(left + cfg.width + 10);
948
- const requiredH = Math.ceil(top + cfg.height + 10);
949
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
950
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
951
- const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
952
- const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
953
- this._setCanvasSizeInt(newW, newH);
954
- }
1545
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1546
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
955
1547
 
956
1548
  let mask;
957
- if (typeof cfg.fabricGenerator === 'function') {
958
- mask = cfg.fabricGenerator(cfg, this.canvas, this.options);
1549
+ if (typeof maskConfig.fabricGenerator === 'function') {
1550
+ mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
959
1551
  } else {
960
1552
  switch (shapeType) {
961
1553
  case 'circle':
962
1554
  mask = new fabric.Circle({
963
1555
  left, top,
964
- radius: resolveValue(cfg.radius, Math.min(cfg.width, cfg.height) / 2),
965
- fill: cfg.color,
966
- opacity: cfg.alpha,
967
- angle: cfg.angle,
968
- ...cfg.styles
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
969
1561
  });
970
1562
  break;
971
1563
  case 'ellipse':
972
1564
  mask = new fabric.Ellipse({
973
1565
  left, top,
974
- rx: resolveValue(cfg.rx, cfg.width / 2),
975
- ry: resolveValue(cfg.ry, cfg.height / 2),
976
- fill: cfg.color,
977
- opacity: cfg.alpha,
978
- angle: cfg.angle,
979
- ...cfg.styles
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
980
1572
  });
981
1573
  break;
982
- case 'polygon':
983
- let polyPoints = cfg.points || [];
984
- if (Array.isArray(polyPoints) && polyPoints.length && typeof polyPoints[0] === 'object') {
1574
+ case 'polygon': {
1575
+ let polygonPoints = maskConfig.points || [];
1576
+ if (Array.isArray(polygonPoints) && polygonPoints.length && typeof polygonPoints[0] === 'object') {
985
1577
  // Ensure numeric {x,y} objects for fabric.Polygon
986
- polyPoints = polyPoints.map(pt => ({ x: Number(pt.x), y: Number(pt.y) }));
1578
+ polygonPoints = polygonPoints.map(point => ({ x: Number(point.x), y: Number(point.y) }));
987
1579
  }
988
- mask = new fabric.Polygon(polyPoints, {
1580
+ mask = new fabric.Polygon(polygonPoints, {
989
1581
  left, top,
990
- fill: cfg.color,
991
- opacity: cfg.alpha,
992
- angle: cfg.angle,
993
- ...cfg.styles
1582
+ fill: maskConfig.color,
1583
+ opacity: maskConfig.alpha,
1584
+ angle: maskConfig.angle,
1585
+ ...maskConfig.styles
994
1586
  });
995
1587
  break;
1588
+ }
996
1589
  case 'rect':
997
1590
  default:
998
1591
  mask = new fabric.Rect({
999
1592
  left, top,
1000
- width: resolveValue(cfg.width, this.options.defaultMaskWidth),
1001
- height: resolveValue(cfg.height, this.options.defaultMaskHeight),
1002
- fill: cfg.color,
1003
- opacity: cfg.alpha,
1004
- angle: cfg.angle,
1005
- rx: cfg.rx, // Rounded Corners
1006
- ry: cfg.ry,
1007
- ...cfg.styles
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
1008
1601
  });
1009
1602
  }
1010
1603
  }
1011
1604
 
1012
- mask.selectable = cfg.selectable !== false;
1013
- mask.hasControls = ('hasControls' in cfg) ? cfg.hasControls : true;
1014
- mask.lockRotation = !this.options.maskRotatable;
1015
- mask.borderColor = cfg.borderColor || 'red';
1016
- mask.cornerColor = cfg.cornerColor || 'black';
1017
- mask.cornerSize = cfg.cornerSize || 8;
1018
- mask.transparentCorners = ('transparentCorners' in cfg) ? cfg.transparentCorners : false;
1019
- mask.stroke = (cfg.styles && cfg.styles.stroke) || '#ccc';
1020
- mask.strokeWidth = (cfg.styles && cfg.styles.strokeWidth) || 1;
1021
- mask.strokeUniform = ('strokeUniform' in cfg) ? cfg.strokeUniform : true;
1022
- if (cfg.styles && cfg.styles.strokeDashArray) mask.strokeDashArray = cfg.styles.strokeDashArray;
1023
-
1024
- mask.originalAlpha = cfg.alpha;
1025
- const normalStyle = { stroke: mask.stroke, strokeWidth: mask.strokeWidth, opacity: mask.originalAlpha };
1026
- const hoverStyle = { stroke: '#ff5500', strokeWidth: 2, opacity: Math.min(mask.originalAlpha + 0.2, 1) };
1027
-
1028
- mask.on('mouseover', () => {
1029
- mask.set(hoverStyle);
1030
- mask.canvas.requestRenderAll();
1031
- });
1032
-
1033
- mask.on('mouseout', () => {
1034
- mask.set(normalStyle);
1035
- 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
1036
1627
  });
1628
+ this._rebindMaskEvents(mask);
1629
+ this._expandCanvasToFitObject(mask);
1037
1630
 
1038
1631
  // Remember initial for next one
1039
1632
  this._lastMaskInitialLeft = left;
1040
1633
  this._lastMaskInitialTop = top;
1041
- this._lastMaskInitialWidth = resolveValue(cfg.width, this.options.defaultMaskWidth);
1634
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1042
1635
 
1043
- mask.maskId = ++this.maskCounter;
1044
- mask.maskName = `${this.options.maskName}${mask.maskId}`;
1636
+ const maskId = ++this.maskCounter;
1637
+ mask.set({
1638
+ maskId,
1639
+ maskName: `${this.options.maskName}${maskId}`
1640
+ });
1045
1641
  this._lastMask = mask;
1046
1642
 
1047
1643
  this.canvas.add(mask);
1048
1644
  this.canvas.bringToFront(mask);
1049
- if (cfg.selectable) this.canvas.setActiveObject(mask);
1050
- this._onSelectionChanged([mask]);
1645
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1646
+ this._handleSelectionChanged([mask]);
1051
1647
  this._updateMaskList();
1052
1648
  this._updateUI();
1053
1649
  this.canvas.renderAll();
1054
1650
  this.saveState();
1055
1651
 
1056
- if (typeof cfg.onCreate === 'function') cfg.onCreate(mask, this.canvas);
1652
+ if (typeof maskConfig.onCreate === 'function') maskConfig.onCreate(mask, this.canvas);
1057
1653
  return mask;
1058
1654
  }
1059
1655
 
1656
+ /**
1657
+ * @deprecated Use createMask() instead.
1658
+ */
1659
+ addMask(config = {}) {
1660
+ return this.createMask(config);
1661
+ }
1662
+
1060
1663
  /**
1061
1664
  * Removes the currently selected mask from the canvas, if any.
1062
1665
  * The associated label is also removed. UI and mask list are updated.
1063
1666
  */
1064
1667
  removeSelectedMask() {
1065
- const active = this.canvas.getActiveObject();
1066
- if (!active || !active.maskId) return;
1067
- this._removeLabelForMask(active);
1068
- this.canvas.remove(active);
1668
+ const activeObject = this.canvas.getActiveObject();
1669
+ const selectedMasks = this._getModifiedMasks(activeObject);
1670
+ if (!selectedMasks.length) return;
1671
+
1069
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
+ }
1070
1685
  this._updateMaskList();
1071
1686
  this._updateUI();
1072
1687
  this.canvas.renderAll();
@@ -1077,18 +1692,20 @@
1077
1692
  * Removes all masks from the canvas, including their labels.
1078
1693
  * UI and internal mask placement memory are reset.
1079
1694
  */
1080
- removeAllMasks() {
1081
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1082
- masks.forEach(m => this._removeLabelForMask(m));
1083
- masks.forEach(m => this.canvas.remove(m));
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));
1084
1700
  this.canvas.discardActiveObject();
1701
+ this._lastMask = null;
1085
1702
  this._lastMaskInitialLeft = null;
1086
1703
  this._lastMaskInitialTop = null;
1087
1704
  this._lastMaskInitialWidth = null;
1088
1705
  this._updateMaskList();
1089
1706
  this._updateUI();
1090
1707
  this.canvas.renderAll();
1091
- this.saveState();
1708
+ if (saveHistory) this.saveState();
1092
1709
  }
1093
1710
 
1094
1711
  /**
@@ -1101,12 +1718,12 @@
1101
1718
  if (!mask || !this.canvas) return;
1102
1719
  if (mask.__label) {
1103
1720
  try {
1104
- const objs = this.canvas.getObjects();
1105
- if (objs.includes(mask.__label)) {
1721
+ const canvasObjects = this.canvas.getObjects();
1722
+ if (canvasObjects.includes(mask.__label)) {
1106
1723
  this.canvas.remove(mask.__label);
1107
1724
  }
1108
- } catch (e) { /* ignore */ }
1109
- try { delete mask.__label; } catch (e) { }
1725
+ } catch (error) { void error; }
1726
+ try { delete mask.__label; } catch (error) { void error; }
1110
1727
  }
1111
1728
  }
1112
1729
 
@@ -1120,12 +1737,12 @@
1120
1737
  _createLabelForMask(mask) {
1121
1738
  if (!mask || !this.options.maskLabelOnSelect) return;
1122
1739
  this._removeLabelForMask(mask);
1123
- let textObj = null;
1740
+ let textObject = null;
1124
1741
  if (this.options.label && typeof this.options.label.create === 'function') {
1125
- textObj = this.options.label.create(mask, fabric);
1742
+ textObject = this.options.label.create(mask, fabric);
1126
1743
  }
1127
- if (!textObj) {
1128
- let txt = mask.maskName; // Default
1744
+ if (!textObject) {
1745
+ let labelText = mask.maskName;
1129
1746
  let textOptions = {
1130
1747
  left: 0,
1131
1748
  top: 0,
@@ -1140,20 +1757,22 @@
1140
1757
  };
1141
1758
  if (this.options.label) {
1142
1759
  if (typeof this.options.label.getText === 'function') {
1143
- txt = this.options.label.getText(mask, this.maskCounter);
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);
1144
1763
  }
1145
1764
  // Merge external styles
1146
1765
  if (this.options.label.textOptions) {
1147
1766
  Object.assign(textOptions, this.options.label.textOptions);
1148
1767
  }
1149
1768
  }
1150
- textObj = new fabric.Text(txt, textOptions);
1769
+ textObject = new fabric.Text(labelText, textOptions);
1151
1770
  }
1152
1771
 
1153
- textObj.maskLabel = true;
1154
- mask.__label = textObj;
1155
- this.canvas.add(textObj);
1156
- this.canvas.bringToFront(textObj);
1772
+ textObject.maskLabel = true;
1773
+ mask.__label = textObject;
1774
+ this.canvas.add(textObject);
1775
+ this.canvas.bringToFront(textObject);
1157
1776
  this._syncMaskLabel(mask);
1158
1777
  }
1159
1778
 
@@ -1164,14 +1783,18 @@
1164
1783
  */
1165
1784
  _hideAllMaskLabels() {
1166
1785
  if (!this.canvas) return;
1167
- const objs = this.canvas.getObjects();
1168
- const labels = objs.filter(o => o.maskLabel);
1169
- labels.forEach(l => {
1786
+ const canvasObjects = this.canvas.getObjects();
1787
+ const labels = canvasObjects.filter(object => object.maskLabel);
1788
+ labels.forEach(label => {
1170
1789
  try {
1171
- if (objs.includes(l)) this.canvas.remove(l);
1172
- } catch (e) { }
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
+ }
1173
1797
  });
1174
- objs.forEach(o => { if (o.maskId && o.__label) { try { delete o.__label; } catch (e) { } } });
1175
1798
  }
1176
1799
 
1177
1800
  /**
@@ -1211,7 +1834,11 @@
1211
1834
  visible: true
1212
1835
  });
1213
1836
  mask.__label.setCoords();
1214
- this.canvas.renderAll();
1837
+ if (typeof this.canvas.requestRenderAll === 'function') {
1838
+ this.canvas.requestRenderAll();
1839
+ } else {
1840
+ this.canvas.renderAll();
1841
+ }
1215
1842
  }
1216
1843
 
1217
1844
  /**
@@ -1224,7 +1851,7 @@
1224
1851
  if (!mask) return;
1225
1852
  if (!this.options.maskLabelOnSelect) return;
1226
1853
  if (!mask.__label) this._createLabelForMask(mask);
1227
- mask.__label.visible = true;
1854
+ mask.__label.set({ visible: true });
1228
1855
  this._syncMaskLabel(mask);
1229
1856
  }
1230
1857
 
@@ -1235,18 +1862,22 @@
1235
1862
  * @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
1236
1863
  * @private
1237
1864
  */
1238
- _onSelectionChanged(selected) {
1239
- const selectedMask = (selected || []).find(o => o.maskId);
1240
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1241
- masks.forEach(m => {
1242
- if (m !== selectedMask) {
1243
- if (m.__label) {
1244
- try { this.canvas.remove(m.__label); } catch (e) { }
1245
- delete m.__label;
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;
1246
1873
  }
1247
- m.set({ stroke: '#ccc', strokeWidth: 1 });
1874
+ const originalStrokeWidth = Number(mask.originalStrokeWidth);
1875
+ mask.set({
1876
+ stroke: mask.originalStroke || '#ccc',
1877
+ strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
1878
+ });
1248
1879
  } else {
1249
- m.set({ stroke: '#ff0000', strokeWidth: 1 });
1880
+ mask.set({ stroke: '#ff0000', strokeWidth: 1 });
1250
1881
  }
1251
1882
  });
1252
1883
 
@@ -1263,16 +1894,16 @@
1263
1894
  * @private
1264
1895
  */
1265
1896
  _updateMaskList() {
1266
- const listEl = document.getElementById(this.elements.maskList);
1267
- if (!listEl) return;
1268
- listEl.innerHTML = '';
1269
- const masks = this.canvas.getObjects().filter(o => o.maskId);
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);
1270
1901
  masks.forEach(mask => {
1271
- const li = document.createElement('li');
1272
- li.className = 'list-group-item mask-item';
1273
- li.textContent = mask.maskName;
1274
- li.onclick = () => { this.canvas.setActiveObject(mask); this._onSelectionChanged([mask]); };
1275
- listEl.appendChild(li);
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);
1276
1907
  });
1277
1908
  }
1278
1909
 
@@ -1283,10 +1914,10 @@
1283
1914
  * @private
1284
1915
  */
1285
1916
  _updateMaskListSelection(selectedMask) {
1286
- const listEl = document.getElementById(this.elements.maskList);
1287
- if (!listEl) return;
1288
- const items = listEl.querySelectorAll('.mask-item');
1289
- items.forEach(item => {
1917
+ const maskListElement = document.getElementById(this.elements.maskList);
1918
+ if (!maskListElement) return;
1919
+ const maskItems = maskListElement.querySelectorAll('.mask-item');
1920
+ maskItems.forEach(item => {
1290
1921
  const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
1291
1922
  item.classList.toggle('active', isSelected);
1292
1923
  });
@@ -1298,25 +1929,33 @@
1298
1929
  * @async
1299
1930
  * @returns {Promise<void>} Resolves when merge and load are complete.
1300
1931
  */
1301
- async merge() {
1932
+ async mergeMasks() {
1302
1933
  if (!this.originalImage) return;
1303
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1934
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1304
1935
  if (!masks.length) return;
1305
1936
 
1306
1937
  this.canvas.discardActiveObject();
1307
1938
  this.canvas.renderAll();
1308
1939
 
1309
1940
  try {
1310
- const merged = await this.getImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1311
- this.removeAllMasks();
1941
+ const beforeJson = this._serializeCanvasState();
1942
+ const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1943
+ this.removeAllMasks({ saveHistory: false });
1312
1944
  await this.loadImage(merged);
1313
- this.saveState();
1945
+ const afterJson = this._serializeCanvasState();
1946
+ this._pushStateTransition(beforeJson, afterJson);
1314
1947
  } catch (err) {
1315
- console.error('merge error', err);
1316
- if (this.canvasEl) this.canvasEl.style.visibility = '';
1948
+ this._reportError('merge error', err);
1317
1949
  }
1318
1950
  }
1319
1951
 
1952
+ /**
1953
+ * @deprecated Use mergeMasks() instead.
1954
+ */
1955
+ async merge() {
1956
+ return this.mergeMasks();
1957
+ }
1958
+
1320
1959
  /**
1321
1960
  * Triggers a JPEG image download of the current canvas (image plus masks if configured).
1322
1961
  * The image area and multiplier are controlled by options.
@@ -1325,7 +1964,7 @@
1325
1964
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1326
1965
  if (!this.originalImage) return;
1327
1966
  const exportImageArea = this.options.exportImageAreaByDefault;
1328
- this.getImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1967
+ this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1329
1968
  .then(base64 => {
1330
1969
  const link = document.createElement('a');
1331
1970
  link.download = fileName;
@@ -1334,139 +1973,144 @@
1334
1973
  link.click();
1335
1974
  document.body.removeChild(link);
1336
1975
  })
1337
- .catch(err => console.error('download error', err));
1976
+ .catch(err => this._reportError('download error', err));
1338
1977
  }
1339
1978
 
1340
1979
  /**
1341
- * Exports the image as a Base64-encoded JPEG.
1980
+ * Exports the image as a Base64-encoded image data URL.
1342
1981
  * Can export either the original, or the current view including masks (clipped/cropped).
1343
1982
  * Will restore masks' state after temporary modifications for export.
1344
1983
  * @async
1345
- * @param {Object} [opts={}] - Export options.
1346
- * @param {boolean} [opts.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1347
- * @param {number} [opts.multiplier=1] - Scaling multiplier for output (resolution).
1348
- * @returns {Promise<string>} Promise resolving to a JPEG image data URL.
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.
1349
1990
  * @throws {Error} If there is no image loaded.
1350
1991
  */
1351
- async getImageBase64(opts = {}) {
1992
+ async exportImageBase64(options = {}) {
1352
1993
  if (!this.originalImage) throw new Error('No image loaded');
1353
- const exportImageArea = typeof opts.exportImageArea === 'boolean' ? opts.exportImageArea : this.options.exportImageAreaByDefault;
1354
- const multiplier = opts.multiplier || this.options.exportMultiplier || 1;
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);
1355
1998
 
1356
1999
  if (!exportImageArea) {
1357
- // Export original image pixels
1358
- const imgEl = this.originalImage.getElement ? this.originalImage.getElement() : (this.originalImage._element || null);
1359
- if (!imgEl) return this.canvas.toDataURL({ format: 'jpeg', quality: this.options.downsampleQuality, multiplier });
1360
- const w = this.originalImage.width;
1361
- const h = this.originalImage.height;
1362
- const oc = document.createElement('canvas');
1363
- oc.width = w;
1364
- oc.height = h;
1365
- const ctx = oc.getContext('2d');
1366
- ctx.drawImage(imgEl, 0, 0, w, h);
1367
- return oc.toDataURL('image/jpeg', this.options.downsampleQuality);
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
+ }
1368
2026
  }
1369
2027
 
1370
2028
  // Export current scaled image area (masks clipped)
1371
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1372
- const masksBackup = masks.map(m => ({
1373
- obj: m,
1374
- opacity: m.opacity,
1375
- fill: m.fill,
1376
- strokeWidth: m.strokeWidth,
1377
- stroke: m.stroke,
1378
- selectable: m.selectable,
1379
- lockRotation: m.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
1380
2038
  }));
1381
2039
 
1382
- // Remove labels, deselect
1383
- masks.forEach(m => this._removeLabelForMask(m));
1384
- this.canvas.discardActiveObject();
1385
- this.canvas.renderAll();
1386
-
1387
- // Set masks to opaque black no border
1388
- masks.forEach(m => {
1389
- m.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
1390
- m.setCoords();
1391
- });
1392
- this.canvas.renderAll();
2040
+ let finalBase64;
2041
+ try {
2042
+ // Remove labels, deselect
2043
+ masks.forEach(mask => this._removeLabelForMask(mask));
2044
+ this.canvas.discardActiveObject();
2045
+ this.canvas.renderAll();
1393
2046
 
1394
- // Compute integer bounding box for image
1395
- this.originalImage.setCoords();
1396
- const imgBr = this.originalImage.getBoundingRect(true, true);
1397
- const sx = Math.max(0, Math.round(imgBr.left));
1398
- const sy = Math.max(0, Math.round(imgBr.top));
1399
- const sw = Math.max(1, Math.round(imgBr.width));
1400
- const sh = Math.max(1, Math.round(imgBr.height));
1401
-
1402
- // Crop precisely in offscreen canvas
1403
- const finalBase64 = await new Promise((resolve, reject) => {
1404
- try {
1405
- const fullDataUrl = this.canvas.toDataURL({
1406
- format: 'jpeg',
1407
- quality: this.options.downsampleQuality,
1408
- multiplier: multiplier
1409
- });
2047
+ // Set masks to opaque black no border
2048
+ masks.forEach(mask => {
2049
+ mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
2050
+ mask.setCoords();
2051
+ });
2052
+ this.canvas.renderAll();
1410
2053
 
1411
- const img = new Image();
1412
- img.onload = () => {
1413
- try {
1414
- const sxM = Math.round(sx * multiplier);
1415
- const syM = Math.round(sy * multiplier);
1416
- const swM = Math.round(sw * multiplier);
1417
- const shM = Math.round(sh * multiplier);
1418
-
1419
- const oc = document.createElement('canvas');
1420
- oc.width = swM;
1421
- oc.height = shM;
1422
- const ctx = oc.getContext('2d');
1423
-
1424
- ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
1425
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality);
1426
- resolve(out);
1427
- } catch (e) { reject(e); }
1428
- };
1429
- img.onerror = reject;
1430
- img.src = fullDataUrl;
1431
- } catch (e) { reject(e); }
1432
- });
2054
+ // Compute integer bounding box for image
2055
+ this.originalImage.setCoords();
2056
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2057
+ const { sx, sy, sw, sh } = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2058
+
2059
+ // Crop precisely in offscreen canvas
2060
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2061
+ sx,
2062
+ sy,
2063
+ sw,
2064
+ sh,
2065
+ multiplier,
2066
+ quality,
2067
+ format
2068
+ });
2069
+ } finally {
2070
+ maskStyleBackups.forEach(backup => {
2071
+ try {
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
2079
+ });
2080
+ backup.object.setCoords();
2081
+ } catch (error) { void error; }
2082
+ });
1433
2083
 
1434
- // Restore masks
1435
- masksBackup.forEach(b => {
1436
- try {
1437
- b.obj.set({
1438
- opacity: b.opacity,
1439
- fill: b.fill,
1440
- strokeWidth: b.strokeWidth,
1441
- stroke: b.stroke,
1442
- selectable: b.selectable,
1443
- lockRotation: b.lockRotation
1444
- });
1445
- b.obj.setCoords();
1446
- } catch (e) { }
1447
- });
2084
+ this.canvas.renderAll();
2085
+ }
1448
2086
 
1449
- this.canvas.renderAll();
1450
2087
  return finalBase64;
1451
2088
  }
1452
2089
 
2090
+ /**
2091
+ * @deprecated Use exportImageBase64() instead.
2092
+ */
2093
+ async getImageBase64(options = {}) {
2094
+ return this.exportImageBase64(options);
2095
+ }
2096
+
1453
2097
  /**
1454
2098
  * Exports the current canvas (with or without masks) as a File object.
1455
2099
  * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
1456
2100
  *
1457
2101
  * @async
1458
- * @param {Object} [opts={}] - Export options.
1459
- * @param {boolean} [opts.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
1460
- * @param {string} [opts.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
1461
- * @param {number} [opts.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
1462
- * @param {number} [opts.multiplier=1] - Output resolution multiplier.
1463
- * @param {string} [opts.fileName] - Optional file name (only used for download).
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).
1464
2108
  * @returns {Promise<File>} Resolves with the exported image as a File object.
1465
2109
  *
1466
2110
  * @example
1467
2111
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
1468
2112
  */
1469
- async exportImageFile(opts = {}) {
2113
+ async exportImageFile(options = {}) {
1470
2114
  if (!this.originalImage) throw new Error('No image loaded');
1471
2115
  const {
1472
2116
  mergeMask = true,
@@ -1474,30 +2118,25 @@
1474
2118
  quality = this.options.downsampleQuality ?? 0.92,
1475
2119
  multiplier = this.options.exportMultiplier ?? 1,
1476
2120
  fileName = this.options.defaultDownloadFileName ?? 'exported_image.jpg'
1477
- } = opts;
2121
+ } = options;
1478
2122
 
1479
- const typeMapping = {
1480
- 'jpeg': 'jpeg',
1481
- 'jpg': 'jpeg',
1482
- 'image/jpeg': 'jpeg',
1483
- 'png': 'png',
1484
- 'image/png': 'png',
1485
- 'webp': 'webp',
1486
- 'image/webp': 'webp'
1487
- };
1488
- const safeFileType = typeMapping[String(fileType).toLowerCase()] || 'jpeg';
2123
+ const safeFileType = this._normalizeImageFormat(fileType);
1489
2124
 
1490
2125
  // Get Base64
1491
2126
  let base64;
1492
2127
  if (mergeMask) {
1493
- base64 = await this.getImageBase64({
2128
+ base64 = await this.exportImageBase64({
1494
2129
  exportImageArea: true,
1495
2130
  multiplier,
2131
+ quality,
2132
+ fileType: safeFileType
1496
2133
  });
1497
2134
  } else {
1498
- base64 = await this.getImageBase64({
2135
+ base64 = await this.exportImageBase64({
1499
2136
  exportImageArea: false,
1500
2137
  multiplier,
2138
+ quality,
2139
+ fileType: safeFileType
1501
2140
  });
1502
2141
  }
1503
2142
 
@@ -1506,34 +2145,95 @@
1506
2145
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1507
2146
  // Redraw if not required format
1508
2147
  imageDataUrl = await new Promise((resolve, reject) => {
1509
- const img = new window.Image();
1510
- img.crossOrigin = "Anonymous";
1511
- img.onload = () => {
2148
+ const imageElement = new window.Image();
2149
+ imageElement.crossOrigin = "Anonymous";
2150
+ imageElement.onload = () => {
1512
2151
  try {
1513
- const oc = document.createElement('canvas');
1514
- oc.width = img.width;
1515
- oc.height = img.height;
1516
- const ctx = oc.getContext('2d');
1517
- ctx.drawImage(img, 0, 0);
1518
- const durl = oc.toDataURL(`image/${safeFileType}`, quality);
1519
- resolve(durl);
1520
- } catch (e) { reject(e); }
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); }
1521
2160
  };
1522
- img.onerror = reject;
1523
- img.src = base64;
2161
+ imageElement.onerror = reject;
2162
+ imageElement.src = base64;
1524
2163
  });
1525
2164
  }
1526
2165
 
1527
2166
  // Convert DataURL to Blob and then to File
1528
- const bstr = atob(imageDataUrl.split(',')[1]);
2167
+ const binaryString = atob(imageDataUrl.split(',')[1]);
1529
2168
  const mime = `image/${safeFileType}`;
1530
- let n = bstr.length;
1531
- const u8arr = new Uint8Array(n);
1532
- while (n--) {
1533
- u8arr[n] = bstr.charCodeAt(n);
2169
+ let byteIndex = binaryString.length;
2170
+ const bytes = new Uint8Array(byteIndex);
2171
+ while (byteIndex--) {
2172
+ bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
2173
+ }
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;
1534
2192
  }
1535
- const file = new File([u8arr], fileName, { type: mime });
1536
- return file;
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 = [];
1537
2237
  }
1538
2238
 
1539
2239
  /**
@@ -1554,13 +2254,13 @@
1554
2254
 
1555
2255
  // Create initial crop rect centered on the image bounding box
1556
2256
  this.originalImage.setCoords();
1557
- const imgBr = this.originalImage.getBoundingRect(true, true);
2257
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
1558
2258
  // Provide small inset so user can see a margin
1559
2259
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
1560
- const left = Math.max(0, Math.floor(imgBr.left + padding));
1561
- const top = Math.max(0, Math.floor(imgBr.top + padding));
1562
- const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imgBr.width - padding * 2));
1563
- const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imgBr.height - padding * 2));
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));
1564
2264
 
1565
2265
  // Visual style: translucent fill + dashed stroke
1566
2266
  const cropRect = new fabric.Rect({
@@ -1592,21 +2292,36 @@
1592
2292
  // While in crop mode: we want only the cropRect to be interactive
1593
2293
  // but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
1594
2294
  this._cropPrevEvented = [];
1595
- this.canvas.getObjects().forEach(o => {
1596
- if (o !== cropRect) {
1597
- this._cropPrevEvented.push({ obj: o, evented: o.evented, selectable: o.selectable });
1598
- try { o.evented = false; o.selectable = false; } catch (e) { /* ignore */ }
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; }
1599
2307
  }
1600
2308
  });
1601
2309
 
1602
2310
  // When the crop rect changes, re-render
1603
- const onModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (e) { } };
1604
- cropRect.on('modified', onModified);
1605
- cropRect.on('moving', onModified);
1606
- cropRect.on('scaling', onModified);
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);
1607
2315
 
1608
2316
  // Keep handlers to remove later
1609
- this._cropHandlers.push({ target: cropRect, handlers: [{ evt: 'modified', fn: onModified }, { evt: 'moving', fn: onModified }, { evt: 'scaling', fn: onModified }] });
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
+ });
1610
2325
 
1611
2326
  this._updateUI();
1612
2327
  this.canvas.renderAll();
@@ -1618,27 +2333,8 @@
1618
2333
  */
1619
2334
  cancelCrop() {
1620
2335
  if (!this.canvas || !this._cropMode) return;
1621
- // Remove handlers if any and remove object
1622
- if (this._cropRect) {
1623
- try {
1624
- if (this._cropHandlers && this._cropHandlers.length) {
1625
- this._cropHandlers.forEach(h => {
1626
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1627
- });
1628
- }
1629
- } catch (e) { /* ignore */ }
1630
-
1631
- try { this.canvas.remove(this._cropRect); } catch (e) { }
1632
- this._cropRect = null;
1633
- }
1634
- // restore evented/selectable flags
1635
- if (Array.isArray(this._cropPrevEvented)) {
1636
- this._cropPrevEvented.forEach(i => {
1637
- try { i.obj.evented = i.evented; i.obj.selectable = i.selectable; } catch (e) { }
1638
- });
1639
- }
1640
- this._cropPrevEvented = null;
1641
- this._cropHandlers = [];
2336
+ this._removeCropRect();
2337
+ this._restoreCropObjectState();
1642
2338
  this._cropMode = false;
1643
2339
  // restore selection setting
1644
2340
  this.canvas.selection = !!this._prevSelectionSetting;
@@ -1661,58 +2357,57 @@
1661
2357
  this._cropRect.setCoords();
1662
2358
  const rectBounds = this._cropRect.getBoundingRect(true, true);
1663
2359
 
1664
- // Compute integer crop region clamped to canvas
1665
- const sx = Math.max(0, Math.round(rectBounds.left));
1666
- const sy = Math.max(0, Math.round(rectBounds.top));
1667
- const sw = Math.max(1, Math.round(Math.min(rectBounds.width, this.canvas.getWidth() - sx)));
1668
- 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();
1669
2364
 
1670
- // Include isCropRect in toJSON whitelist so we can detect and filter them out.
1671
2365
  let beforeJson = null;
1672
2366
  try {
1673
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1674
- if (Array.isArray(jsonObj.objects)) {
1675
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
1676
- }
1677
- beforeJson = JSON.stringify(jsonObj);
1678
- } catch (e) {
1679
- console.warn('applyCrop: could not serialize before state', e);
2367
+ beforeJson = this._serializeCanvasState();
2368
+ } catch (error) {
2369
+ this._reportWarning('applyCrop: could not serialize before state', error);
1680
2370
  beforeJson = null;
1681
2371
  }
1682
2372
 
2373
+ const preservedMasks = [];
1683
2374
 
1684
- // Remove ALL un-merged masks so they won't be baked into exported pixels
1685
2375
  try {
1686
- const masks = this.canvas.getObjects().filter(o => o.maskId);
2376
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1687
2377
  if (masks && masks.length) {
1688
- masks.forEach(m => {
2378
+ masks.forEach(mask => {
1689
2379
  try {
1690
- this._removeLabelForMask(m);
1691
- this.canvas.remove(m);
1692
- } catch (err) {
1693
- console.warn('applyCrop: failed to remove mask', err);
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);
1694
2400
  }
1695
2401
  });
2402
+ this._clearMaskPlacementMemory();
1696
2403
  this.canvas.discardActiveObject();
1697
2404
  this.canvas.renderAll();
1698
2405
  }
1699
- } catch (e) {
1700
- console.warn('applyCrop: error while removing masks', e);
2406
+ } catch (error) {
2407
+ this._reportWarning('applyCrop: error while removing masks', error);
1701
2408
  }
1702
2409
 
1703
- try {
1704
- if (this._cropRect) {
1705
- try {
1706
- if (this._cropHandlers && this._cropHandlers.length) {
1707
- this._cropHandlers.forEach(h => {
1708
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1709
- });
1710
- }
1711
- } catch (e) { /* ignore */ }
1712
- try { this.canvas.remove(this._cropRect); } catch (e) { /* ignore */ }
1713
- this._cropRect = null;
1714
- }
1715
- } catch (e) { /* ignore */ }
2410
+ this._removeCropRect();
1716
2411
 
1717
2412
  // End crop mode
1718
2413
  this._cropMode = false;
@@ -1722,80 +2417,52 @@
1722
2417
  // Export full canvas and crop on offscreen canvas
1723
2418
  let croppedBase64;
1724
2419
  try {
1725
- const fullDataUrl = this.canvas.toDataURL({
1726
- format: 'jpeg',
1727
- quality: this.options.downsampleQuality || 0.92,
1728
- multiplier: 1
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'
1729
2428
  });
1730
-
1731
- croppedBase64 = await new Promise((resolve, reject) => {
1732
- const img = new Image();
1733
- img.onload = () => {
1734
- try {
1735
- const oc = document.createElement('canvas');
1736
- oc.width = sw;
1737
- oc.height = sh;
1738
- const ctx = oc.getContext('2d');
1739
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
1740
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality || 0.92);
1741
- resolve(out);
1742
- } catch (err) {
1743
- reject(err);
1744
- }
1745
- };
1746
- img.onerror = (e) => reject(e);
1747
- img.src = fullDataUrl;
1748
- });
1749
- } catch (e) {
1750
- console.error('applyCrop: failed to create cropped image', e);
1751
- this._updateUI();
2429
+ } catch (error) {
2430
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
1752
2431
  return;
1753
2432
  }
1754
2433
 
1755
2434
  // Load the cropped image as the new base image
1756
2435
  try {
1757
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
+ }
1758
2448
  } catch (e) {
1759
- console.error('applyCrop: loadImage(croppedBase64) failed', e);
1760
- this._updateUI();
2449
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', e);
1761
2450
  return;
1762
2451
  }
1763
2452
 
1764
2453
  // Create "after" snapshot (also exclude crop rect if any) and push history command
1765
2454
  let afterJson = null;
1766
2455
  try {
1767
- const jsonObj2 = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1768
- if (Array.isArray(jsonObj2.objects)) {
1769
- jsonObj2.objects = jsonObj2.objects.filter(o => !o.isCropRect);
1770
- }
1771
- afterJson = JSON.stringify(jsonObj2);
2456
+ afterJson = this._serializeCanvasState();
1772
2457
  } catch (e) {
1773
- console.warn('applyCrop: failed to serialize after state', e);
2458
+ this._reportWarning('applyCrop: failed to serialize after state', e);
1774
2459
  afterJson = null;
1775
2460
  }
1776
2461
 
1777
2462
  try {
1778
- const self = this;
1779
- const cmd = new Command(
1780
- () => { if (afterJson) self.loadFromState(afterJson); },
1781
- () => { if (beforeJson) self.loadFromState(beforeJson); }
1782
- );
1783
-
1784
- if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1785
-
1786
- // trim future redo history
1787
- if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
1788
- this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
1789
- }
1790
-
1791
- this.historyManager.history.push(cmd);
1792
- if (this.historyManager.history.length > this.historyManager.maxSize) {
1793
- this.historyManager.history.shift();
1794
- } else {
1795
- this.historyManager.currentIndex++;
1796
- }
2463
+ this._pushStateTransition(beforeJson, afterJson);
1797
2464
  } catch (e) {
1798
- console.warn('applyCrop: failed to push history command', e);
2465
+ this._reportWarning('applyCrop: failed to push history command', e);
1799
2466
  }
1800
2467
 
1801
2468
  // Final UI update
@@ -1812,8 +2479,8 @@
1812
2479
  * @private
1813
2480
  */
1814
2481
  _updateInputs() {
1815
- const scaleEl = document.getElementById(this.elements.scaleRate);
1816
- if (scaleEl) scaleEl.value = Math.round(this.currentScale * 100);
2482
+ const scaleInputElement = document.getElementById(this.elements.scaleRate);
2483
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
1817
2484
  }
1818
2485
 
1819
2486
  /**
@@ -1822,45 +2489,47 @@
1822
2489
  * @private
1823
2490
  */
1824
2491
  _updateUI() {
1825
- const hasImg = !!this.originalImage;
1826
- const masks = hasImg ? this.canvas.getObjects().filter(o => o.maskId) : [];
2492
+ const hasImage = !!this.originalImage;
2493
+ const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
1827
2494
  const hasMasks = masks.length > 0;
1828
- const active = this.canvas.getActiveObject();
1829
- const hasSelectedMask = active && active.maskId;
1830
- const isDefault = this.currentScale === 1 && this.currentRotation === 0;
2495
+ const activeObject = this.canvas.getActiveObject();
2496
+ const hasSelectedMask = activeObject && activeObject.maskId;
2497
+ const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
1831
2498
  const canUndo = this.historyManager?.canUndo();
1832
2499
  const canRedo = this.historyManager?.canRedo();
1833
- const inCrop = !!this._cropMode;
2500
+ const isInCropMode = !!this._cropMode;
1834
2501
 
1835
- if (inCrop) {
2502
+ if (isInCropMode) {
1836
2503
  // iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
1837
- for (const k of Object.keys(this.elements || {})) {
1838
- const el = document.getElementById(this.elements[k]);
1839
- if (!el) continue;
1840
- if (k === 'applyCropBtn' || k === 'cancelCropBtn') {
1841
- el.disabled = false;
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);
1842
2509
  } else {
1843
- el.disabled = true;
2510
+ this._setDisabled(key, true);
1844
2511
  }
1845
2512
  }
1846
2513
  return;
1847
2514
  }
1848
2515
 
1849
- this._setDisabled('zoomInBtn', !hasImg || this.isAnimating || this.currentScale >= this.options.maxScale);
1850
- this._setDisabled('zoomOutBtn', !hasImg || this.isAnimating || this.currentScale <= this.options.minScale);
1851
- this._setDisabled('rotateLeftBtn', !hasImg || this.isAnimating);
1852
- this._setDisabled('rotateRightBtn', !hasImg || this.isAnimating);
1853
- this._setDisabled('addMaskBtn', !hasImg || this.isAnimating);
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);
1854
2521
  this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
1855
2522
  this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
1856
- this._setDisabled('mergeBtn', !hasImg || !hasMasks || this.isAnimating);
1857
- this._setDisabled('downloadBtn', !hasImg || this.isAnimating);
1858
- this._setDisabled('resetBtn', !hasImg || isDefault || this.isAnimating);
1859
- this._setDisabled('undoBtn', !hasImg || this.isAnimating || !canUndo);
1860
- this._setDisabled('redoBtn', !hasImg || this.isAnimating || !canRedo);
1861
- this._setDisabled('cropBtn', !hasImg || this.isAnimating);
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);
1862
2529
  this._setDisabled('applyCropBtn', true);
1863
2530
  this._setDisabled('cancelCropBtn', true);
2531
+ this._setDisabled('imageInput', this.isAnimating);
2532
+ this._setDisabled('uploadArea', this.isAnimating);
1864
2533
  }
1865
2534
 
1866
2535
  /**
@@ -1871,8 +2540,26 @@
1871
2540
  * @private
1872
2541
  */
1873
2542
  _setDisabled(key, disabled) {
1874
- const el = document.getElementById(this.elements[key]);
1875
- if (el) el.disabled = !!disabled;
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';
1876
2563
  }
1877
2564
 
1878
2565
  /**
@@ -1890,15 +2577,15 @@
1890
2577
  * @private
1891
2578
  */
1892
2579
  _setPlaceholderVisible(show) {
1893
- if (!this.placeholderEl) return;
2580
+ if (!this.placeholderElement) return;
1894
2581
  if (show) {
1895
- this.placeholderEl.classList.remove('d-none');
1896
- this.placeholderEl.classList.add('d-flex');
1897
- this.containerEl.classList.add('d-none');
2582
+ this.placeholderElement.classList.remove('d-none');
2583
+ this.placeholderElement.classList.add('d-flex');
2584
+ this.containerElement.classList.add('d-none');
1898
2585
  } else {
1899
- this.placeholderEl.classList.remove('d-flex');
1900
- this.placeholderEl.classList.add('d-none');
1901
- this.containerEl.classList.remove('d-none');
2586
+ this.placeholderElement.classList.remove('d-flex');
2587
+ this.placeholderElement.classList.add('d-none');
2588
+ this.containerElement.classList.remove('d-none');
1902
2589
  }
1903
2590
  }
1904
2591
 
@@ -1910,28 +2597,32 @@
1910
2597
  dispose() {
1911
2598
  // Remove bound DOM event listeners
1912
2599
  try {
1913
- for (const key in (this._boundHandlers || {})) {
1914
- const handlers = this._boundHandlers[key] || [];
1915
- const el = document.getElementById(this.elements[key]);
1916
- if (!el) continue;
1917
- handlers.forEach(h => {
1918
- try { el.removeEventListener(h.event, h.handler); } catch (e) { }
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; }
1919
2606
  });
1920
2607
  }
1921
- } catch (e) { }
2608
+ } catch (error) { void error; }
1922
2609
 
1923
2610
  if (this._cropRect) {
1924
- try { this.canvas.remove(this._cropRect); } catch (e) { }
2611
+ try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
1925
2612
  this._cropRect = null;
1926
2613
  }
1927
2614
 
2615
+ if (this.containerElement && this._containerOriginalOverflow !== undefined) {
2616
+ try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (e) { void e; }
2617
+ }
2618
+
1928
2619
  if (this.canvas) {
1929
- try { this.canvas.dispose(); } catch (e) { }
2620
+ try { this.canvas.dispose(); } catch (e) { void e; }
1930
2621
  this.canvas = null;
1931
- this.canvasEl = null;
2622
+ this.canvasElement = null;
1932
2623
  this.isImageLoadedToCanvas = false;
1933
2624
  }
1934
- this._boundHandlers = {};
2625
+ this._handlersByElementKey = {};
1935
2626
  }
1936
2627
  }
1937
2628
 
@@ -2036,6 +2727,13 @@
2036
2727
  this.history = [];
2037
2728
  this.currentIndex = -1;
2038
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;
2039
2737
  }
2040
2738
 
2041
2739
  /**
@@ -2048,7 +2746,17 @@
2048
2746
  execute(command) {
2049
2747
  // Perform the command.
2050
2748
  command.execute();
2749
+ this.push(command);
2750
+ }
2051
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) {
2052
2760
  // Remove any commands that are ahead of the current index.
2053
2761
  if (this.currentIndex < this.history.length - 1) {
2054
2762
  this.history = this.history.slice(0, this.currentIndex + 1);
@@ -2089,10 +2797,13 @@
2089
2797
  * @returns {void}
2090
2798
  */
2091
2799
  undo() {
2092
- if (this.currentIndex >= 0) {
2093
- this.history[this.currentIndex].undo();
2094
- this.currentIndex--;
2095
- }
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
+ });
2096
2807
  }
2097
2808
 
2098
2809
  /**
@@ -2101,12 +2812,15 @@
2101
2812
  * @returns {void}
2102
2813
  */
2103
2814
  redo() {
2104
- if (this.currentIndex < this.history.length - 1) {
2105
- this.currentIndex++;
2106
- this.history[this.currentIndex].execute();
2107
- }
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
+ });
2108
2822
  }
2109
2823
  }
2110
2824
 
2111
- return ImageEditor
2112
- })
2825
+ export { ImageEditor };
2826
+ export default ImageEditor;