@bensitu/image-editor 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * @file image-editor.js
3
3
  * @module image-editor
4
- * @version 1.2.1
4
+ * @version 1.3.0
5
5
  * @author Ben Situ
6
6
  * @license MIT
7
7
  * @description Lightweight canvas-based image editor with masking/transform/export support.
8
- *
9
- * This source file is free software, available under the MIT license.
10
- * It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
11
- * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12
- * See the license files for details.
13
8
  */
14
9
 
15
10
  let fabric = null;
16
11
 
12
+ /**
13
+ * Returns the ambient global scope used to discover a globally loaded Fabric.js namespace.
14
+ *
15
+ * @returns {typeof globalThis|null} The global scope, or null when no standard scope is available.
16
+ * @private
17
+ */
17
18
  function getGlobalScope() {
18
19
  if (typeof globalThis !== 'undefined') return globalThis;
19
20
  if (typeof self !== 'undefined') return self;
@@ -21,37 +22,96 @@ function getGlobalScope() {
21
22
  return null;
22
23
  }
23
24
 
25
+ /**
26
+ * Returns the globally registered Fabric.js namespace when one is available.
27
+ *
28
+ * @returns {Object|null} The Fabric.js namespace, or null when Fabric is not registered globally.
29
+ * @private
30
+ */
24
31
  function getGlobalFabric() {
25
32
  const scope = getGlobalScope();
26
33
  return scope && scope.fabric ? scope.fabric : null;
27
34
  }
28
35
 
36
+ /**
37
+ * Registers the Fabric.js namespace used by ImageEditor instances.
38
+ *
39
+ * This helper is exported for the package entry wrappers, not as part of the documented package API.
40
+ *
41
+ * @param {Object} [fabricInstance] - Fabric.js namespace object. When omitted, the global `fabric` namespace is used.
42
+ * @returns {Object|null} The active Fabric.js namespace.
43
+ * @private
44
+ */
29
45
  export function setFabric(fabricInstance) {
30
46
  fabric = fabricInstance || getGlobalFabric();
31
47
  return fabric;
32
48
  }
33
49
 
50
+ /**
51
+ * Resolves the active Fabric.js namespace, trying the global namespace as a fallback.
52
+ *
53
+ * @returns {Object|null} The active Fabric.js namespace.
54
+ * @private
55
+ */
34
56
  function ensureFabric() {
35
57
  if (!fabric) setFabric();
36
58
  return fabric;
37
59
  }
38
60
 
61
+ /**
62
+ * @callback ImageLoadedCallback
63
+ * @returns {void}
64
+ */
65
+
66
+ /**
67
+ * @callback EditorErrorCallback
68
+ * @param {*} error - Recoverable error or warning value, when available.
69
+ * @param {string} message - Human-readable context for the error or warning.
70
+ * @returns {void}
71
+ */
72
+
73
+ /**
74
+ * @callback MaskValueResolver
75
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
76
+ * @param {Object} options - Editor options.
77
+ * @returns {number} Resolved numeric mask value.
78
+ */
79
+
80
+ /**
81
+ * @callback MaskFabricGenerator
82
+ * @param {Object} config - Normalized mask configuration.
83
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
84
+ * @param {Object} options - Editor options.
85
+ * @returns {fabric.Object} Custom Fabric object to use as the mask.
86
+ */
87
+
88
+ /**
89
+ * @callback MaskCreateCallback
90
+ * @param {fabric.Object} mask - Created mask object.
91
+ * @param {fabric.Canvas} canvas - Active Fabric canvas.
92
+ * @returns {void}
93
+ */
94
+
95
+ /**
96
+ * @callback MaskLabelTextCallback
97
+ * @param {fabric.Object} mask - Mask object whose label is being created.
98
+ * @param {number} creationIndex - Stable zero-based creation index derived from the mask id.
99
+ * @returns {string} Label text.
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} LoadImageOptions
104
+ * @property {boolean} [preserveScroll=false] - If true, keeps the current scroll position while reloading.
105
+ */
106
+
39
107
  /**
40
- * ImageEditor
41
- *
42
- * A lightweight wrapper around fabric.js providing masking, scaling, rotation,
43
- * merging/export helpers, and UI integrations for image editing.
108
+ * Fabric.js-based image editor with masking, transform, crop, history, and export helpers.
44
109
  *
45
- * <b>Note:</b> Requires fabric.js (v5.x) to be loaded on the page before use.
110
+ * Requires Fabric.js v5.x through the ESM package entry or a globally available `fabric` namespace.
46
111
  *
47
- * <pre>
48
- * Example usage:
112
+ * @example
49
113
  * const editor = new ImageEditor({ canvasWidth: 1024, canvasHeight: 768 });
50
114
  * editor.init();
51
- * </pre>
52
- *
53
- * @class ImageEditor
54
- * @classdesc Fabric.js-based image editor with simple mask, transform, export and UI features.
55
115
  *
56
116
  * @param {Object} [options={}] - Customization options to override defaults.
57
117
  * @param {number} [options.canvasWidth=800] - The initial canvas width in pixels.
@@ -62,33 +122,36 @@ function ensureFabric() {
62
122
  * @param {number} [options.maxScale=5.0] - Maximum image scaling factor.
63
123
  * @param {number} [options.scaleStep=0.05] - Scale increment/decrement per step.
64
124
  * @param {number} [options.rotationStep=90] - Rotation step in degrees.
65
- * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image/mask.
125
+ * @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit the loaded image.
66
126
  * @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
67
- * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover canvas (at least one side fits, allowing overflow).
127
+ * @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
68
128
  * @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
69
129
  * @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
70
130
  * @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
71
131
  * @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
132
+ * @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
72
133
  * @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
73
134
  * @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
74
- * @param {number} [options.defaultMaskWidth=50] - Default width of new mask rectangles.
135
+ * @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
75
136
  * @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
76
137
  * @param {boolean} [options.maskRotatable=false] - If true, masks can be rotated.
77
138
  * @param {boolean} [options.maskLabelOnSelect=true] - Show label on selected mask.
78
139
  * @param {number} [options.maskLabelOffset=3] - Offset for mask labels from top-left corner.
79
140
  * @param {string} [options.maskName='mask'] - Prefix for mask names/labels.
141
+ * @param {boolean} [options.groupSelection=false] - If true, Fabric can select multiple masks as an ActiveSelection.
80
142
  * @param {boolean} [options.showPlaceholder=true] - If true, shows placeholder when no image is loaded.
81
143
  * @param {string|null} [options.initialImageBase64=null] - Base64 string to auto-load as initial image, if any.
82
144
  * @param {string} [options.defaultDownloadFileName='edited_image.jpg'] - Default file name for downloads.
83
- * @param {function} [options.onImageLoaded] - Optional callback to invoke after an image loads.
84
- * @param {function} [options.onError] - Optional callback for recoverable internal errors.
85
- * @param {function} [options.onWarning] - Optional callback for recoverable internal warnings.
86
- *
87
- * @constructor
145
+ * @param {Object} [options.label] - Mask label customization options.
146
+ * @param {MaskLabelTextCallback} [options.label.getText] - Callback for label text; receives a stable zero-based creation index.
147
+ * @param {Object} [options.crop] - Crop mode customization options.
148
+ * @param {ImageLoadedCallback} [options.onImageLoaded] - Callback invoked after an image load completes.
149
+ * @param {EditorErrorCallback} [options.onError] - Callback invoked for recoverable internal errors.
150
+ * @param {EditorErrorCallback} [options.onWarning] - Callback invoked for recoverable internal warnings.
88
151
  */
89
152
  class ImageEditor {
90
153
  constructor(options = {}) {
91
- // Default options (can be overridden via ctor param)
154
+ // Default options that callers can override with constructor options.
92
155
  const defaultLabel = {
93
156
  getText: (mask) => mask.maskName,
94
157
  textOptions: {
@@ -133,6 +196,7 @@ function ensureFabric() {
133
196
  downsampleMaxWidth: 4000,
134
197
  downsampleMaxHeight: 3000,
135
198
  downsampleQuality: 0.92,
199
+ imageLoadTimeoutMs: 30000,
136
200
 
137
201
  exportMultiplier: 1,
138
202
  exportImageAreaByDefault: true,
@@ -168,19 +232,19 @@ function ensureFabric() {
168
232
  }
169
233
  };
170
234
 
171
- // Verify that fabric.js is present
235
+ // Verify that Fabric.js is present before any canvas work starts.
172
236
  this._fabricLoaded = !!ensureFabric();
173
237
  if (!this._fabricLoaded) {
174
238
  this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
175
239
  }
176
240
 
177
- // Runtime state
241
+ // Runtime state owned by this editor instance.
178
242
  this.canvas = null;
179
- this.canvasEl = null;
180
- this.containerEl = null;
181
- this.placeholderEl = null;
243
+ this.canvasElement = null;
244
+ this.containerElement = null;
245
+ this.placeholderElement = null;
182
246
 
183
- this.originalImage = null; // fabric.Image
247
+ this.originalImage = null;
184
248
  this.baseImageScale = 1;
185
249
  this.currentScale = 1;
186
250
  this.currentRotation = 0;
@@ -190,32 +254,80 @@ function ensureFabric() {
190
254
  this.isImageLoadedToCanvas = false;
191
255
  this.maxHistorySize = 50;
192
256
 
193
- this._boundHandlers = {};
257
+ this._handlersByElementKey = {};
194
258
 
195
259
  this._lastMask = null;
196
260
  this._lastMaskInitialLeft = null;
197
261
  this._lastMaskInitialTop = null;
198
262
  this._lastMaskInitialWidth = null;
263
+ this._lastSnapshot = null;
199
264
 
200
265
  this._cropMode = false;
201
266
  this._cropRect = null;
202
267
  this._cropHandlers = [];
268
+ this._cropPrevEvented = null;
269
+ this._prevSelectionSetting = undefined;
270
+ this._containerOriginalOverflow = undefined;
271
+ this._scrollbarSizeCache = null;
203
272
 
204
273
  this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
205
274
 
206
- this.animQueue = new AnimationQueue();
275
+ this.animationQueue = new AnimationQueue();
207
276
  this.historyManager = new HistoryManager(this.maxHistorySize);
208
277
  }
209
278
 
279
+ /**
280
+ * Backward-compatible alias for {@link ImageEditor#canvasElement}.
281
+ *
282
+ * @deprecated Use canvasElement instead. This alias will be removed in v2.0.0.
283
+ * @returns {HTMLCanvasElement|null} The canvas element currently owned by the editor.
284
+ */
285
+ get canvasEl() {
286
+ return this.canvasElement;
287
+ }
288
+
289
+ set canvasEl(value) {
290
+ this.canvasElement = value;
291
+ }
292
+
293
+ /**
294
+ * Backward-compatible alias for {@link ImageEditor#containerElement}.
295
+ *
296
+ * @deprecated Use containerElement instead. This alias will be removed in v2.0.0.
297
+ * @returns {HTMLElement|null} The canvas viewport/container element.
298
+ */
299
+ get containerEl() {
300
+ return this.containerElement;
301
+ }
302
+
303
+ set containerEl(value) {
304
+ this.containerElement = value;
305
+ }
306
+
307
+ /**
308
+ * Backward-compatible alias for {@link ImageEditor#placeholderElement}.
309
+ *
310
+ * @deprecated Use placeholderElement instead. This alias will be removed in v2.0.0.
311
+ * @returns {HTMLElement|null} The placeholder element shown before an image loads.
312
+ */
313
+ get placeholderEl() {
314
+ return this.placeholderElement;
315
+ }
316
+
317
+ set placeholderEl(value) {
318
+ this.placeholderElement = value;
319
+ }
320
+
210
321
  /**
211
322
  * Initializes the editor, binds to DOM elements, sets up event handlers,
212
323
  * and (optionally) loads an initial image.
213
324
  * Use this method to set up the editor UI before interacting with it.
214
325
  *
215
326
  * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
216
- * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput, rotationRightInput,
217
- * rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn, mergeBtn, downloadBtn, maskList,
218
- * zoomInBtn, zoomOutBtn, resetBtn, imageInput. Unknown keys are ignored.
327
+ * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
328
+ * rotationRightInput, rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
329
+ * mergeBtn, downloadBtn, maskList, zoomInBtn, zoomOutBtn, resetBtn, undoBtn, redoBtn, imageInput,
330
+ * uploadArea, cropBtn, applyCropBtn, and cancelCropBtn. Unknown keys are ignored.
219
331
  *
220
332
  * @returns {void}
221
333
  *
@@ -295,52 +407,123 @@ function ensureFabric() {
295
407
  }
296
408
 
297
409
  /**
298
- * Canvas setup helpers
410
+ * Initializes the Fabric canvas, viewport elements, and selection event handlers.
411
+ *
412
+ * @returns {void}
299
413
  * @private
300
414
  */
301
415
  _initCanvas() {
302
- const canvasEl = document.getElementById(this.elements.canvas);
303
- if (!canvasEl) throw new Error('Canvas is not found: ' + this.elements.canvas);
304
- this.canvasEl = canvasEl;
416
+ const canvasElement = document.getElementById(this.elements.canvas);
417
+ if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
418
+ this.canvasElement = canvasElement;
305
419
 
306
- // Decide which element acts as "viewport" (for width/height fallback)
420
+ // Decide which element acts as the viewport for size fallback and scrolling.
307
421
  if (this.elements.canvasContainer) {
308
- const ce = document.getElementById(this.elements.canvasContainer);
309
- this.containerEl = ce || canvasEl.parentElement;
422
+ const containerElement = document.getElementById(this.elements.canvasContainer);
423
+ this.containerElement = containerElement || canvasElement.parentElement;
310
424
  } else {
311
- this.containerEl = canvasEl.parentElement;
425
+ this.containerElement = canvasElement.parentElement;
312
426
  }
313
427
 
314
- this.placeholderEl = document.getElementById(this.elements.imgPlaceholder) || null;
315
-
316
- // Initial size take container size if available
317
- let initialW = this.options.canvasWidth;
318
- let initialH = this.options.canvasHeight;
319
- if (this.containerEl) {
320
- const cw = Math.floor(this.containerEl.clientWidth);
321
- const ch = Math.floor(this.containerEl.clientHeight);
322
- if (cw > 0 && ch > 0) { initialW = cw; initialH = ch; }
428
+ this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
429
+
430
+ // Prefer a measured container size when it is available.
431
+ let initialWidth = this.options.canvasWidth;
432
+ let initialHeight = this.options.canvasHeight;
433
+ if (this.containerElement) {
434
+ const containerWidth = Math.floor(this.containerElement.clientWidth);
435
+ const containerHeight = Math.floor(this.containerElement.clientHeight);
436
+ if (containerWidth > 0 && containerHeight > 0) {
437
+ initialWidth = containerWidth;
438
+ initialHeight = containerHeight;
439
+ }
323
440
  }
324
441
 
325
- this.canvas = new fabric.Canvas(canvasEl, {
326
- width: initialW,
327
- height: initialH,
442
+ this.canvas = new fabric.Canvas(canvasElement, {
443
+ width: initialWidth,
444
+ height: initialHeight,
328
445
  backgroundColor: this.options.backgroundColor,
329
446
  selection: this.options.groupSelection,
330
447
  preserveObjectStacking: true
331
448
  });
332
449
 
333
- // Fabric event wiring
334
- this.canvas.on('selection:created', (e) => this._onSelectionChanged(e.selected));
335
- this.canvas.on('selection:updated', (e) => this._onSelectionChanged(e.selected));
336
- this.canvas.on('selection:cleared', () => this._onSelectionChanged([]));
337
- this.canvas.on('object:moving', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
338
- this.canvas.on('object:scaling', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
339
- this.canvas.on('object:rotating', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
340
- this.canvas.on('object:modified', (e) => { if (e.target && e.target.maskId) this._syncMaskLabel(e.target); });
450
+ // Fabric event wiring keeps selection, mask labels, and history in sync.
451
+ this.canvas.on('selection:created', (event) => this._handleSelectionChanged(event.selected));
452
+ this.canvas.on('selection:updated', (event) => this._handleSelectionChanged(event.selected));
453
+ this.canvas.on('selection:cleared', () => this._handleSelectionChanged([]));
454
+ this.canvas.on('object:moving', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
455
+ this.canvas.on('object:scaling', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
456
+ this.canvas.on('object:rotating', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
457
+ this.canvas.on('object:modified', (event) => this._handleObjectModified(event.target));
458
+
459
+ // Avoid inline-element whitespace artifacts around the canvas.
460
+ this.canvasElement.style.display = 'block';
461
+ }
462
+
463
+ /**
464
+ * Records a history entry after Fabric finishes modifying one or more masks.
465
+ *
466
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Modified Fabric object or selection.
467
+ * @returns {void}
468
+ * @private
469
+ */
470
+ _handleObjectModified(target) {
471
+ const masks = this._getModifiedMasks(target);
472
+ if (!masks.length) return;
473
+ masks.forEach(mask => {
474
+ if (typeof mask.setCoords === 'function') mask.setCoords();
475
+ this._syncMaskLabel(mask);
476
+ });
477
+ this._expandCanvasToFitObjects(masks);
478
+ this.saveState();
479
+ }
480
+
481
+ /**
482
+ * Extracts editable mask objects from a Fabric modification target.
483
+ *
484
+ * @param {fabric.Object|fabric.ActiveSelection|null} target - Fabric object or active selection.
485
+ * @returns {Array<fabric.Object>} Modified mask objects.
486
+ * @private
487
+ */
488
+ _getModifiedMasks(target) {
489
+ if (!target) return [];
490
+ if (target.maskId) return [target];
491
+
492
+ const objects = typeof target.getObjects === 'function' ? target.getObjects() : [];
341
493
 
342
- // Avoid inline-element whitespace artefacts
343
- this.canvasEl.style.display = 'block';
494
+ return Array.isArray(objects) ? objects.filter(object => object && object.maskId) : [];
495
+ }
496
+
497
+ /**
498
+ * Updates container overflow behavior for fit and cover image modes.
499
+ *
500
+ * @param {Object} [options={}] - Overflow update options.
501
+ * @param {boolean} [options.preserveScroll=false] - If true, keeps the current scroll offsets.
502
+ * @returns {void}
503
+ * @private
504
+ */
505
+ _syncContainerOverflow(options = {}) {
506
+ if (!this.containerElement || !this.containerElement.style) return;
507
+ if (this._containerOriginalOverflow === undefined) {
508
+ this._containerOriginalOverflow = this.containerElement.style.overflow || '';
509
+ }
510
+
511
+ const shouldPreserveScroll = options.preserveScroll === true;
512
+ if (this.options.coverImageToCanvas) {
513
+ this.containerElement.style.overflow = 'scroll';
514
+ if (!shouldPreserveScroll) {
515
+ this.containerElement.scrollLeft = 0;
516
+ this.containerElement.scrollTop = 0;
517
+ }
518
+ } else if (this.options.fitImageToCanvas) {
519
+ this.containerElement.style.overflow = 'auto';
520
+ if (!shouldPreserveScroll) {
521
+ this.containerElement.scrollLeft = 0;
522
+ this.containerElement.scrollTop = 0;
523
+ }
524
+ } else {
525
+ this.containerElement.style.overflow = this._containerOriginalOverflow;
526
+ }
344
527
  }
345
528
 
346
529
  /**
@@ -349,173 +532,201 @@ function ensureFabric() {
349
532
  */
350
533
  _bindEvents() {
351
534
  // Click anywhere on the upload area opens the native file dialog
352
- this._bindIfExists('uploadArea', 'click', () => document.getElementById(this.elements.imageInput)?.click());
535
+ this._bindIfExists('uploadArea', 'click', () => {
536
+ const uploadAreaElement = document.getElementById(this.elements.uploadArea);
537
+ if (this._isElementDisabled(uploadAreaElement)) return;
538
+ document.getElementById(this.elements.imageInput)?.click();
539
+ });
353
540
  // File-input change
354
- const inputEl = document.getElementById(this.elements.imageInput);
355
- if (inputEl) {
356
- inputEl.addEventListener('change', (e) => {
357
- const f = e.target.files && e.target.files[0];
358
- if (f) this._loadImageFile(f);
359
- });
360
- }
541
+ this._bindIfExists('imageInput', 'change', (event) => {
542
+ const file = event.target.files && event.target.files[0];
543
+ if (file) this._loadImageFile(file);
544
+ });
361
545
  // Zoom & reset
362
546
  this._bindIfExists('zoomInBtn', 'click', () => this.scaleImage(this.currentScale + this.options.scaleStep));
363
547
  this._bindIfExists('zoomOutBtn', 'click', () => this.scaleImage(this.currentScale - this.options.scaleStep));
364
- this._bindIfExists('resetBtn', 'click', () => { this.reset(); });
548
+ this._bindIfExists('resetBtn', 'click', () => { this.resetImageTransform(); });
365
549
  // Mask management
366
- this._bindIfExists('addMaskBtn', 'click', () => this.addMask());
550
+ this._bindIfExists('addMaskBtn', 'click', () => this.createMask());
367
551
  this._bindIfExists('removeMaskBtn', 'click', () => this.removeSelectedMask());
368
552
  this._bindIfExists('removeAllMasksBtn', 'click', () => this.removeAllMasks());
369
553
  // Merge + download
370
- this._bindIfExists('mergeBtn', 'click', () => this.merge());
554
+ this._bindIfExists('mergeBtn', 'click', () => this.mergeMasks());
371
555
  this._bindIfExists('downloadBtn', 'click', () => this.downloadImage());
372
556
  // Undo + Redo
373
557
  this._bindIfExists('undoBtn', 'click', () => this.undo());
374
558
  this._bindIfExists('redoBtn', 'click', () => this.redo());
375
559
 
376
560
  // Rotation buttons (step can be overridden by two input fields)
377
- const rotLeftBtn = document.getElementById(this.elements.rotateLeftBtn);
378
- const rotRightBtn = document.getElementById(this.elements.rotateRightBtn);
379
- if (rotLeftBtn) rotLeftBtn.addEventListener('click', () => {
380
- const el = document.getElementById(this.elements.rotationLeftInput);
561
+ this._bindIfExists('rotateLeftBtn', 'click', () => {
562
+ const rotationInputElement = document.getElementById(this.elements.rotationLeftInput);
381
563
  let step = this.options.rotationStep;
382
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
564
+ if (rotationInputElement) {
565
+ const parsedStep = parseFloat(rotationInputElement.value);
566
+ if (!isNaN(parsedStep)) step = parsedStep;
567
+ }
383
568
  this.rotateImage(this.currentRotation - step);
384
569
  });
385
- if (rotRightBtn) rotRightBtn.addEventListener('click', () => {
386
- const el = document.getElementById(this.elements.rotationRightInput);
570
+ this._bindIfExists('rotateRightBtn', 'click', () => {
571
+ const rotationInputElement = document.getElementById(this.elements.rotationRightInput);
387
572
  let step = this.options.rotationStep;
388
- if (el) { const p = parseFloat(el.value); if (!isNaN(p)) step = p; }
573
+ if (rotationInputElement) {
574
+ const parsedStep = parseFloat(rotationInputElement.value);
575
+ if (!isNaN(parsedStep)) step = parsedStep;
576
+ }
389
577
  this.rotateImage(this.currentRotation + step);
390
578
  });
391
579
 
392
580
  // Crop bindings (optional: bound only if element IDs exist in elements)
393
581
  this._bindIfExists('cropBtn', 'click', () => this.enterCropMode());
394
- this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(e => this._reportError('applyCrop failed', e)); });
582
+ this._bindIfExists('applyCropBtn', 'click', () => { this.applyCrop().catch(error => this._reportError('applyCrop failed', error)); });
395
583
  this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
396
584
  }
397
585
 
398
- /**
399
- * Event binding element check
400
- *
401
- * @param {*} event
402
- * @param {*} handler
403
- * @param {*} key
586
+ /**
587
+ * Binds a DOM event listener when the configured element exists and records it for disposal.
588
+ *
589
+ * @param {string} key - Key in this.elements for the target DOM element.
590
+ * @param {string} eventName - DOM event name to listen for.
591
+ * @param {EventListener} handler - Event listener callback.
404
592
  * @private
405
593
  */
406
- _bindIfExists(key, event, handler) {
407
- const el = document.getElementById(this.elements[key]);
408
- if (el) {
409
- el.addEventListener(event, handler);
410
- this._boundHandlers = this._boundHandlers || {};
411
- if (!this._boundHandlers[key]) this._boundHandlers[key] = [];
412
- this._boundHandlers[key].push({ event, handler });
594
+ _bindIfExists(key, eventName, handler) {
595
+ const element = document.getElementById(this.elements[key]);
596
+ if (element) {
597
+ element.addEventListener(eventName, handler);
598
+ this._handlersByElementKey = this._handlersByElementKey || {};
599
+ if (!this._handlersByElementKey[key]) this._handlersByElementKey[key] = [];
600
+ this._handlersByElementKey[key].push({ eventName, handler });
413
601
  }
414
602
  }
415
603
 
416
- /**
417
- * Image loading helpers
418
- *
419
- * @param {File} file
604
+ /**
605
+ * Reads an image File as a data URL and loads it into the Fabric canvas.
606
+ *
607
+ * @param {File} file - Image file selected by the user.
420
608
  * @private
421
609
  */
422
610
  _loadImageFile(file) {
423
611
  if (!file || !file.type.startsWith('image/')) return;
424
612
  const reader = new FileReader();
425
- reader.onload = (e) => this.loadImage(e.target.result);
426
- reader.onerror = (e) => { this._reportError('Image file could not be read', e); };
613
+ reader.onload = (event) => this.loadImage(event.target.result);
614
+ reader.onerror = (event) => { this._reportError('Image file could not be read', event); };
427
615
  reader.readAsDataURL(file);
428
616
  }
429
617
 
430
618
  /**
431
- * Load a base64 encoded image string into fabric.
432
- * @async
433
- * @param {String} base64
434
- */
435
- async loadImage(base64) {
619
+ * Warns when more than one mutually exclusive image layout mode is enabled.
620
+ *
621
+ * @returns {void}
622
+ * @private
623
+ */
624
+ _warnOnImageLayoutOptionConflict() {
625
+ const activeModes = [
626
+ ['fitImageToCanvas', this.options.fitImageToCanvas],
627
+ ['coverImageToCanvas', this.options.coverImageToCanvas],
628
+ ['expandCanvasToImage', this.options.expandCanvasToImage]
629
+ ].filter(([, isEnabled]) => !!isEnabled).map(([name]) => name);
630
+
631
+ if (activeModes.length <= 1) return;
632
+ this._reportWarning(
633
+ `Only one image layout mode should be enabled. Active modes: ${activeModes.join(', ')}.`
634
+ );
635
+ }
636
+
637
+ /**
638
+ * Loads a base64 data URL into the Fabric canvas as the base image.
639
+ *
640
+ * @async
641
+ * @param {string} imageBase64 - Image data URL beginning with `data:image/`.
642
+ * @param {LoadImageOptions} [options={}] - Optional load behavior.
643
+ * @returns {Promise<void>} Resolves after the Fabric image is added to the canvas.
644
+ * @public
645
+ */
646
+ async loadImage(imageBase64, options = {}) {
436
647
  if (!this._fabricLoaded) return;
437
648
  if (!this.canvas) return;
438
- if (!base64 || typeof base64 !== 'string' || !base64.startsWith('data:image/')) return;
649
+ if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
439
650
 
651
+ this._warnOnImageLayoutOptionConflict();
440
652
  this._setPlaceholderVisible(false);
653
+ this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
441
654
 
442
- const imgEl = await this._createImageElement(base64);
655
+ const imageElement = await this._createImageElement(imageBase64);
443
656
 
444
- let loadSrc = base64;
657
+ let loadSource = imageBase64;
445
658
  if (this.options.downsampleOnLoad) {
446
- const needResize =
447
- imgEl.naturalWidth > this.options.downsampleMaxWidth ||
448
- imgEl.naturalHeight > this.options.downsampleMaxHeight;
449
- if (needResize) {
659
+ const shouldResize =
660
+ imageElement.naturalWidth > this.options.downsampleMaxWidth ||
661
+ imageElement.naturalHeight > this.options.downsampleMaxHeight;
662
+ if (shouldResize) {
450
663
  const ratio = Math.min(
451
- this.options.downsampleMaxWidth / imgEl.naturalWidth,
452
- this.options.downsampleMaxHeight / imgEl.naturalHeight
664
+ this.options.downsampleMaxWidth / imageElement.naturalWidth,
665
+ this.options.downsampleMaxHeight / imageElement.naturalHeight
453
666
  );
454
- const tw = Math.round(imgEl.naturalWidth * ratio);
455
- const th = Math.round(imgEl.naturalHeight * ratio);
456
- loadSrc = this._resampleImageToDataURL(imgEl, tw, th, this.options.downsampleQuality);
667
+ const targetWidth = Math.round(imageElement.naturalWidth * ratio);
668
+ const targetHeight = Math.round(imageElement.naturalHeight * ratio);
669
+ loadSource = this._resampleImageToDataURL(imageElement, targetWidth, targetHeight, this.options.downsampleQuality);
457
670
  }
458
671
  }
459
672
 
460
673
  // Create fabric.Image from URL
461
674
  return new Promise((resolve, reject) => {
462
- fabric.Image.fromURL(loadSrc, (fimg) => {
675
+ fabric.Image.fromURL(loadSource, (fabricImage) => {
463
676
  try {
464
- if (!fimg) throw new Error('Image could not be loaded');
677
+ if (!fabricImage) throw new Error('Image could not be loaded');
465
678
 
466
679
  this.canvas.discardActiveObject();
467
680
  this._hideAllMaskLabels();
468
681
  this.canvas.clear();
469
682
  this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
470
683
 
471
- fimg.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
684
+ fabricImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false });
472
685
 
473
- const imgW = fimg.width;
474
- const imgH = fimg.height;
686
+ const imageWidth = fabricImage.width;
687
+ const imageHeight = fabricImage.height;
475
688
 
476
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || this.options.canvasWidth) : this.options.canvasWidth;
477
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || this.options.canvasHeight) : this.options.canvasHeight;
689
+ const viewport = this._getContainerViewportSize();
690
+ const minWidth = viewport.width;
691
+ const minHeight = viewport.height;
478
692
 
479
693
  if (this.options.fitImageToCanvas) {
480
- // Fit into current canvas (shrink only) and ensure canvas does not exceed container
481
- const cw = Math.max(1, Math.min(this.options.canvasWidth, minW) - 1)
482
- const ch = Math.max(1, Math.min(this.options.canvasHeight, minH) - 1);
483
- this._setCanvasSizeInt(cw, ch);
484
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
485
- fimg.set({ left: 0, top: 0 });
486
- fimg.scale(fitScale);
487
- this.baseImageScale = fimg.scaleX || 1;
694
+ // Fit into the visible viewport, shrinking only when the image is larger.
695
+ const canvasWidth = Math.max(1, minWidth - 1);
696
+ const canvasHeight = Math.max(1, minHeight - 1);
697
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
698
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
699
+ fabricImage.set({ left: 0, top: 0 });
700
+ fabricImage.scale(fitScale);
701
+ this.baseImageScale = fabricImage.scaleX || 1;
488
702
  } else if (this.options.coverImageToCanvas) {
489
- // Cover canvas: scale to cover, allowing overflow (at least one side fits)
490
- const cw = Math.max(this.options.canvasWidth, minW);
491
- const ch = Math.max(this.options.canvasHeight, minH);
492
- this._setCanvasSizeInt(cw, ch);
493
- const coverScale = Math.min(1, Math.max(cw / imgW, ch / imgH));
494
- fimg.set({ left: 0, top: 0 });
495
- fimg.scale(coverScale);
496
- this.baseImageScale = fimg.scaleX || 1;
703
+ const layout = this._calculateCoverCanvasLayout(imageWidth, imageHeight);
704
+ this._setCanvasSizeInt(layout.canvasWidth, layout.canvasHeight);
705
+ fabricImage.set({ left: 0, top: 0 });
706
+ fabricImage.scale(layout.scale);
707
+ this.baseImageScale = fabricImage.scaleX || 1;
497
708
  } else if (this.options.expandCanvasToImage) {
498
709
  // Expand canvas so that it fully contains the image
499
- const cw = Math.max(minW, Math.floor(imgW));
500
- const ch = Math.max(minH, Math.floor(imgH));
501
- this._setCanvasSizeInt(cw, ch);
502
- fimg.set({ left: 0, top: 0 });
503
- fimg.scale(1);
710
+ const canvasWidth = Math.max(minWidth, Math.floor(imageWidth));
711
+ const canvasHeight = Math.max(minHeight, Math.floor(imageHeight));
712
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
713
+ fabricImage.set({ left: 0, top: 0 });
714
+ fabricImage.scale(1);
504
715
  this.baseImageScale = 1;
505
716
  } else {
506
717
  // Keep existing canvas size and center the image
507
- const cw = Math.max(this.options.canvasWidth, minW);
508
- const ch = Math.max(this.options.canvasHeight, minH);
509
- this._setCanvasSizeInt(cw, ch);
510
- const fitScale = Math.min(cw / imgW, ch / imgH, 1);
511
- fimg.set({ left: 0, top: 0 });
512
- fimg.scale(fitScale);
513
- this.baseImageScale = fimg.scaleX || 1;
718
+ const canvasWidth = Math.max(this.options.canvasWidth, minWidth);
719
+ const canvasHeight = Math.max(this.options.canvasHeight, minHeight);
720
+ this._setCanvasSizeInt(canvasWidth, canvasHeight);
721
+ const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
722
+ fabricImage.set({ left: 0, top: 0 });
723
+ fabricImage.scale(fitScale);
724
+ this.baseImageScale = fabricImage.scaleX || 1;
514
725
  }
515
726
  // Put the image onto the canvas
516
- this.originalImage = fimg;
517
- this.canvas.add(fimg);
518
- this.canvas.sendToBack(fimg);
727
+ this.originalImage = fabricImage;
728
+ this.canvas.add(fabricImage);
729
+ this.canvas.sendToBack(fabricImage);
519
730
 
520
731
  // Reset mask placement memory
521
732
  this._lastMask = null;
@@ -532,14 +743,19 @@ function ensureFabric() {
532
743
  this.isImageLoadedToCanvas = true;
533
744
  this._updateUI();
534
745
  this.canvas.renderAll();
746
+ try {
747
+ this._lastSnapshot = this._serializeCanvasState();
748
+ } catch (error) {
749
+ this._reportWarning('loadImage: failed to capture initial canvas snapshot', error);
750
+ }
535
751
 
536
752
  if (typeof this.onImageLoaded === 'function') {
537
753
  this.onImageLoaded();
538
754
  }
539
755
 
540
756
  resolve();
541
- } catch (err) {
542
- reject(err);
757
+ } catch (error) {
758
+ reject(error);
543
759
  }
544
760
  }, { crossOrigin: 'anonymous' });
545
761
  });
@@ -563,116 +779,558 @@ function ensureFabric() {
563
779
  /**
564
780
  * Creates an HTMLImageElement from a given data URL.
565
781
  *
566
- * @param {string} dataURL - A data URL representing the image (e.g., "data:image/png;base64,...").
782
+ * @param {string} dataUrl - A data URL representing the image (e.g., "data:image/png;base64,...").
783
+ * @param {number} [timeoutMs=this.options.imageLoadTimeoutMs] - Maximum decode time before rejecting.
567
784
  * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
568
785
  * @private
569
786
  */
570
- _createImageElement(dataURL) {
571
- return new Promise((res, rej) => {
572
- const img = new Image();
573
- img.onload = () => {
574
- img.onload = null;
575
- img.onerror = null;
576
- res(img);
577
- };
578
- img.onerror = (e) => {
579
- img.onload = null;
580
- img.onerror = null;
581
- rej(e);
787
+ _createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
788
+ return new Promise((resolve, reject) => {
789
+ const imageElement = new Image();
790
+ let isSettled = false;
791
+ const safeTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
792
+ ? Number(timeoutMs)
793
+ : 30000;
794
+ let timerId;
795
+ const settle = (callback) => {
796
+ if (isSettled) return;
797
+ isSettled = true;
798
+ clearTimeout(timerId);
799
+ imageElement.onload = null;
800
+ imageElement.onerror = null;
801
+ callback();
582
802
  };
583
- img.src = dataURL;
803
+ timerId = setTimeout(() => {
804
+ settle(() => reject(new Error('Image load timed out')));
805
+ try { imageElement.src = ''; } catch (error) { void error; }
806
+ }, safeTimeoutMs);
807
+ imageElement.onload = () => settle(() => resolve(imageElement));
808
+ imageElement.onerror = (error) => settle(() => reject(error));
809
+ imageElement.src = dataUrl;
584
810
  });
585
811
  }
586
812
 
587
813
  /**
588
814
  * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
589
815
  *
590
- * @param {HTMLImageElement} imgEl - The image element to resample.
591
- * @param {number} w - Target width (in pixels) for the resampled image.
592
- * @param {number} h - Target height (in pixels) for the resampled image.
816
+ * @param {HTMLImageElement} imageElement - The image element to resample.
817
+ * @param {number} targetWidth - Target width (in pixels) for the resampled image.
818
+ * @param {number} targetHeight - Target height (in pixels) for the resampled image.
593
819
  * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
594
820
  * @returns {string} A data URL representing the resampled image as JPEG.
595
821
  * @private
596
822
  */
597
- _resampleImageToDataURL(imgEl, w, h, quality = 0.92) {
598
- const oc = document.createElement('canvas');
599
- oc.width = w;
600
- oc.height = h;
601
- const ctx = oc.getContext('2d');
602
- ctx.drawImage(imgEl, 0, 0, imgEl.naturalWidth, imgEl.naturalHeight, 0, 0, w, h);
603
- return oc.toDataURL('image/jpeg', quality);
823
+ _resampleImageToDataURL(imageElement, targetWidth, targetHeight, quality = 0.92) {
824
+ const offscreenCanvas = document.createElement('canvas');
825
+ offscreenCanvas.width = targetWidth;
826
+ offscreenCanvas.height = targetHeight;
827
+ const context = offscreenCanvas.getContext('2d');
828
+ if (!context) throw new Error('2D canvas context is unavailable');
829
+ context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
830
+ return offscreenCanvas.toDataURL('image/jpeg', quality);
604
831
  }
605
832
 
606
833
  /**
607
834
  * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
608
835
  * Also updates the corresponding style attributes.
609
836
  *
610
- * @param {number} w - Canvas width (in pixels).
611
- * @param {number} h - Canvas height (in pixels).
837
+ * @param {number} width - Canvas width in pixels.
838
+ * @param {number} height - Canvas height in pixels.
612
839
  * @private
613
840
  */
614
- _setCanvasSizeInt(w, h) {
615
- const iw = Math.max(1, Math.round(Number(w) || 1));
616
- const ih = Math.max(1, Math.round(Number(h) || 1));
841
+ _setCanvasSizeInt(width, height) {
842
+ const integerWidth = Math.max(1, Math.round(Number(width) || 1));
843
+ const integerHeight = Math.max(1, Math.round(Number(height) || 1));
617
844
  // Set fabric internal and also style attributes to keep DOM consistent
618
- this.canvas.setWidth(iw);
619
- this.canvas.setHeight(ih);
845
+ this.canvas.setWidth(integerWidth);
846
+ this.canvas.setHeight(integerHeight);
620
847
  if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
621
848
  // Keep DOM element in sync (avoid fractional CSS pixels)
622
- if (this.canvasEl) {
623
- this.canvasEl.style.width = iw + 'px';
624
- this.canvasEl.style.height = ih + 'px';
625
- this.canvasEl.style.maxWidth = 'none';
849
+ if (this.canvasElement) {
850
+ this.canvasElement.style.width = integerWidth + 'px';
851
+ this.canvasElement.style.height = integerHeight + 'px';
852
+ this.canvasElement.style.maxWidth = 'none';
853
+ }
854
+ }
855
+
856
+ _ceilCanvasDimension(value) {
857
+ const numericValue = Number(value) || 0;
858
+ const roundedValue = Math.round(numericValue);
859
+ if (Math.abs(numericValue - roundedValue) < 0.01) return roundedValue;
860
+ return Math.ceil(numericValue);
861
+ }
862
+
863
+ _getContainerViewportSize() {
864
+ if (!this.containerElement) {
865
+ return {
866
+ width: Math.max(1, Math.floor(this.options.canvasWidth || 1)),
867
+ height: Math.max(1, Math.floor(this.options.canvasHeight || 1))
868
+ };
869
+ }
870
+
871
+ if (this._hasFixedContainerScrollbars()) {
872
+ return {
873
+ width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
874
+ height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
875
+ };
876
+ }
877
+
878
+ const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
879
+ const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
880
+
881
+ return { width, height };
882
+ }
883
+
884
+ _hasFixedContainerScrollbars() {
885
+ if (!this.containerElement) return false;
886
+ const inlineOverflow = this.containerElement.style.overflow;
887
+ const inlineOverflowX = this.containerElement.style.overflowX;
888
+ const inlineOverflowY = this.containerElement.style.overflowY;
889
+ let computedOverflow = '';
890
+ let computedOverflowX = '';
891
+ let computedOverflowY = '';
892
+
893
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
894
+ const style = window.getComputedStyle(this.containerElement);
895
+ computedOverflow = style.overflow;
896
+ computedOverflowX = style.overflowX;
897
+ computedOverflowY = style.overflowY;
898
+ }
899
+
900
+ return [inlineOverflow, inlineOverflowX, inlineOverflowY, computedOverflow, computedOverflowX, computedOverflowY]
901
+ .some(value => value === 'scroll');
902
+ }
903
+
904
+ _getScrollbarSize() {
905
+ if (this._scrollbarSizeCache) {
906
+ return { ...this._scrollbarSizeCache };
626
907
  }
908
+ if (typeof document === 'undefined' || !document.createElement || !document.body) {
909
+ return { width: 0, height: 0 };
910
+ }
911
+
912
+ const probe = document.createElement('div');
913
+ probe.style.position = 'absolute';
914
+ probe.style.visibility = 'hidden';
915
+ probe.style.overflow = 'scroll';
916
+ probe.style.width = '100px';
917
+ probe.style.height = '100px';
918
+ probe.style.top = '-9999px';
919
+ document.body.appendChild(probe);
920
+
921
+ const width = Math.max(0, probe.offsetWidth - probe.clientWidth);
922
+ const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
923
+ document.body.removeChild(probe);
924
+
925
+ this._scrollbarSizeCache = { width, height };
926
+ return { ...this._scrollbarSizeCache };
927
+ }
928
+
929
+ _getScrollSafetyMargin() {
930
+ return 2;
931
+ }
932
+
933
+ _getScrollableCanvasSize(contentWidth, contentHeight, viewport = this._getContainerViewportSize()) {
934
+ if (this._hasFixedContainerScrollbars()) {
935
+ const safetyMargin = this._getScrollSafetyMargin();
936
+ const safeWidth = Math.max(1, viewport.width - safetyMargin);
937
+ const safeHeight = Math.max(1, viewport.height - safetyMargin);
938
+ return {
939
+ width: contentWidth > viewport.width + 0.5 ? this._ceilCanvasDimension(contentWidth) : safeWidth,
940
+ height: contentHeight > viewport.height + 0.5 ? this._ceilCanvasDimension(contentHeight) : safeHeight,
941
+ viewportWidth: viewport.width,
942
+ viewportHeight: viewport.height,
943
+ hasHorizontal: true,
944
+ hasVertical: true
945
+ };
946
+ }
947
+
948
+ const scrollbar = this._getScrollbarSize();
949
+ let hasVertical = false;
950
+ let hasHorizontal = false;
951
+ let effectiveWidth = viewport.width;
952
+ let effectiveHeight = viewport.height;
953
+
954
+ for (let i = 0; i < 4; i += 1) {
955
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
956
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
957
+
958
+ const nextHasVertical = contentHeight > effectiveHeight + 0.5;
959
+ const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
960
+
961
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
962
+ hasVertical = nextHasVertical;
963
+ hasHorizontal = nextHasHorizontal;
964
+ }
965
+
966
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
967
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
968
+
969
+ return {
970
+ width: hasHorizontal ? this._ceilCanvasDimension(contentWidth) : effectiveWidth,
971
+ height: hasVertical ? this._ceilCanvasDimension(contentHeight) : effectiveHeight,
972
+ viewportWidth: effectiveWidth,
973
+ viewportHeight: effectiveHeight,
974
+ hasHorizontal,
975
+ hasVertical
976
+ };
977
+ }
978
+
979
+ _calculateCoverCanvasLayout(imageWidth, imageHeight) {
980
+ const viewport = this._getContainerViewportSize();
981
+
982
+ if (this._hasFixedContainerScrollbars()) {
983
+ const safetyMargin = this._getScrollSafetyMargin();
984
+ const targetWidth = Math.max(1, viewport.width - safetyMargin);
985
+ const targetHeight = Math.max(1, viewport.height - safetyMargin);
986
+ const scale = Math.min(1, Math.max(targetWidth / imageWidth, targetHeight / imageHeight));
987
+ const contentWidth = imageWidth * scale;
988
+ const contentHeight = imageHeight * scale;
989
+ const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
990
+ return {
991
+ scale,
992
+ canvasWidth: canvasSize.width,
993
+ canvasHeight: canvasSize.height
994
+ };
995
+ }
996
+
997
+ const scrollbar = this._getScrollbarSize();
998
+ let hasVertical = false;
999
+ let hasHorizontal = false;
1000
+ let scale = 1;
1001
+ let contentWidth = imageWidth;
1002
+ let contentHeight = imageHeight;
1003
+ let effectiveWidth = viewport.width;
1004
+ let effectiveHeight = viewport.height;
1005
+
1006
+ for (let i = 0; i < 4; i += 1) {
1007
+ effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
1008
+ effectiveHeight = Math.max(1, viewport.height - (hasHorizontal ? scrollbar.height : 0));
1009
+ scale = Math.min(1, Math.max(effectiveWidth / imageWidth, effectiveHeight / imageHeight));
1010
+ contentWidth = imageWidth * scale;
1011
+ contentHeight = imageHeight * scale;
1012
+
1013
+ const nextHasVertical = contentHeight > effectiveHeight + 0.5;
1014
+ const nextHasHorizontal = contentWidth > effectiveWidth + 0.5;
1015
+
1016
+ if (nextHasVertical === hasVertical && nextHasHorizontal === hasHorizontal) break;
1017
+ hasVertical = nextHasVertical;
1018
+ hasHorizontal = nextHasHorizontal;
1019
+ }
1020
+
1021
+ const canvasSize = this._getScrollableCanvasSize(contentWidth, contentHeight, viewport);
1022
+ return {
1023
+ scale,
1024
+ canvasWidth: canvasSize.width,
1025
+ canvasHeight: canvasSize.height
1026
+ };
1027
+ }
1028
+
1029
+ _getStateProperties() {
1030
+ return [
1031
+ 'maskId',
1032
+ 'maskName',
1033
+ 'maskLabel',
1034
+ 'isCropRect',
1035
+ 'originalAlpha',
1036
+ 'originalStroke',
1037
+ 'originalStrokeWidth',
1038
+ 'selectable',
1039
+ 'evented',
1040
+ 'hasControls',
1041
+ 'lockRotation',
1042
+ 'borderColor',
1043
+ 'cornerColor',
1044
+ 'cornerSize',
1045
+ 'transparentCorners',
1046
+ 'strokeUniform',
1047
+ 'strokeDashArray'
1048
+ ];
1049
+ }
1050
+
1051
+ _getMaskNormalStyle(mask) {
1052
+ const strokeWidth = Number(mask && mask.originalStrokeWidth);
1053
+ const opacity = Number(mask && mask.originalAlpha);
1054
+ const style = {
1055
+ stroke: (mask && mask.originalStroke) || '#ccc',
1056
+ strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1
1057
+ };
1058
+ if (Number.isFinite(opacity)) style.opacity = opacity;
1059
+ return style;
1060
+ }
1061
+
1062
+ _withNormalizedMaskStyles(callback) {
1063
+ if (!this.canvas) return callback();
1064
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1065
+ const maskStyleBackups = masks.map(mask => ({
1066
+ object: mask,
1067
+ stroke: mask.stroke,
1068
+ strokeWidth: mask.strokeWidth,
1069
+ opacity: mask.opacity
1070
+ }));
1071
+
1072
+ try {
1073
+ masks.forEach(mask => {
1074
+ mask.set(this._getMaskNormalStyle(mask));
1075
+ });
1076
+ return callback();
1077
+ } finally {
1078
+ maskStyleBackups.forEach(backup => {
1079
+ try {
1080
+ backup.object.set({
1081
+ stroke: backup.stroke,
1082
+ strokeWidth: backup.strokeWidth,
1083
+ opacity: backup.opacity
1084
+ });
1085
+ } catch (error) { void error; }
1086
+ });
1087
+ }
1088
+ }
1089
+
1090
+ _restoreMaskControls(mask) {
1091
+ if (!mask) return;
1092
+
1093
+ const cornerSize = Number(mask.cornerSize);
1094
+ mask.set({
1095
+ selectable: mask.selectable !== false,
1096
+ evented: mask.evented !== false,
1097
+ hasControls: mask.hasControls !== false,
1098
+ lockRotation: typeof mask.lockRotation === 'boolean' ? mask.lockRotation : !this.options.maskRotatable,
1099
+ borderColor: mask.borderColor || 'red',
1100
+ cornerColor: mask.cornerColor || 'black',
1101
+ cornerSize: Number.isFinite(cornerSize) ? cornerSize : 8,
1102
+ transparentCorners: mask.transparentCorners === true,
1103
+ strokeUniform: mask.strokeUniform !== false
1104
+ });
1105
+ if (typeof mask.setCoords === 'function') mask.setCoords();
1106
+ }
1107
+
1108
+ /**
1109
+ * Captures editor-owned runtime state that Fabric does not include in canvas JSON.
1110
+ *
1111
+ * @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
1112
+ * @private
1113
+ */
1114
+ _serializeEditorMetadata() {
1115
+ const baseImageScale = Number(this.baseImageScale);
1116
+ const currentScale = Number(this.currentScale);
1117
+ const currentRotation = Number(this.currentRotation);
1118
+ const maskCounter = Number(this.maskCounter);
1119
+
1120
+ return {
1121
+ version: 1,
1122
+ baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
1123
+ currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
1124
+ currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
1125
+ maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
1126
+ };
1127
+ }
1128
+
1129
+ _serializeCanvasState() {
1130
+ if (!this.canvas) return null;
1131
+ return this._withNormalizedMaskStyles(() => {
1132
+ const jsonObject = this.canvas.toJSON(this._getStateProperties());
1133
+ if (Array.isArray(jsonObject.objects)) {
1134
+ jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
1135
+ }
1136
+ jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
1137
+ return JSON.stringify(jsonObject);
1138
+ });
1139
+ }
1140
+
1141
+ /**
1142
+ * Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
1143
+ *
1144
+ * @param {number} quality - Requested image quality.
1145
+ * @returns {number} A finite quality value between 0 and 1.
1146
+ * @private
1147
+ */
1148
+ _normalizeQuality(quality) {
1149
+ const numericQuality = Number(quality);
1150
+ if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
1151
+ return Math.max(0, Math.min(1, numericQuality));
1152
+ }
1153
+
1154
+ /**
1155
+ * Normalizes public image format aliases to canvas export format names.
1156
+ *
1157
+ * @param {string} format - Requested image format or MIME type.
1158
+ * @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
1159
+ * @private
1160
+ */
1161
+ _normalizeImageFormat(format) {
1162
+ const typeMapping = {
1163
+ 'jpeg': 'jpeg',
1164
+ 'jpg': 'jpeg',
1165
+ 'image/jpeg': 'jpeg',
1166
+ 'png': 'png',
1167
+ 'image/png': 'png',
1168
+ 'webp': 'webp',
1169
+ 'image/webp': 'webp'
1170
+ };
1171
+ return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
1172
+ }
1173
+
1174
+ /**
1175
+ * Converts a bounding rectangle into a canvas-safe integer source region.
1176
+ *
1177
+ * @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
1178
+ * @param {Object} [options={}] - Region rounding options.
1179
+ * @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
1180
+ * @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
1181
+ * @private
1182
+ */
1183
+ _getClampedCanvasRegion(bounds, options = {}) {
1184
+ const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
1185
+ const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
1186
+ const left = Number(bounds.left) || 0;
1187
+ const top = Number(bounds.top) || 0;
1188
+ const width = Math.max(0, Number(bounds.width) || 0);
1189
+ const height = Math.max(0, Number(bounds.height) || 0);
1190
+ const includePartialPixels = options.includePartialPixels !== false;
1191
+ const roundEnd = includePartialPixels ? Math.ceil : Math.floor;
1192
+ const sourceX = Math.min(canvasWidth - 1, Math.max(0, Math.floor(left)));
1193
+ const sourceY = Math.min(canvasHeight - 1, Math.max(0, Math.floor(top)));
1194
+ const endX = Math.min(canvasWidth, Math.max(sourceX + 1, roundEnd(left + width)));
1195
+ const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
1196
+
1197
+ return {
1198
+ sourceX,
1199
+ sourceY,
1200
+ sourceWidth: Math.max(1, endX - sourceX),
1201
+ sourceHeight: Math.max(1, endY - sourceY)
1202
+ };
1203
+ }
1204
+
1205
+ /**
1206
+ * Crops an image data URL to a source region using an offscreen canvas.
1207
+ *
1208
+ * @param {string} dataUrl - Source image data URL.
1209
+ * @param {number} sourceX - Source region x coordinate.
1210
+ * @param {number} sourceY - Source region y coordinate.
1211
+ * @param {number} sourceWidth - Source region width.
1212
+ * @param {number} sourceHeight - Source region height.
1213
+ * @param {number} multiplier - Export multiplier already applied to the source data URL.
1214
+ * @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
1215
+ * @param {number} [quality=0.92] - Output image quality for lossy formats.
1216
+ * @returns {Promise<string>} Resolves with the cropped image data URL.
1217
+ * @private
1218
+ */
1219
+ async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
1220
+ return new Promise((resolve, reject) => {
1221
+ const imageElement = new Image();
1222
+ let isSettled = false;
1223
+ const timeoutMs = Number(this.options.imageLoadTimeoutMs);
1224
+ const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
1225
+ let timerId;
1226
+ const settle = (callback) => {
1227
+ if (isSettled) return;
1228
+ isSettled = true;
1229
+ clearTimeout(timerId);
1230
+ imageElement.onload = null;
1231
+ imageElement.onerror = null;
1232
+ callback();
1233
+ };
1234
+ timerId = setTimeout(() => {
1235
+ settle(() => reject(new Error('Image crop load timed out')));
1236
+ try { imageElement.src = ''; } catch (error) { void error; }
1237
+ }, safeTimeoutMs);
1238
+ imageElement.onload = () => {
1239
+ try {
1240
+ const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1241
+ const scaledSourceX = Math.round(sourceX * safeMultiplier);
1242
+ const scaledSourceY = Math.round(sourceY * safeMultiplier);
1243
+ const scaledSourceWidth = Math.max(1, Math.round(sourceWidth * safeMultiplier));
1244
+ const scaledSourceHeight = Math.max(1, Math.round(sourceHeight * safeMultiplier));
1245
+ const offscreenCanvas = document.createElement('canvas');
1246
+ offscreenCanvas.width = scaledSourceWidth;
1247
+ offscreenCanvas.height = scaledSourceHeight;
1248
+ const context = offscreenCanvas.getContext('2d');
1249
+ if (!context) throw new Error('2D canvas context is unavailable');
1250
+
1251
+ context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
1252
+ settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
1253
+ } catch (error) {
1254
+ settle(() => reject(error));
1255
+ }
1256
+ };
1257
+ imageElement.onerror = (error) => settle(() => reject(error));
1258
+ imageElement.src = dataUrl;
1259
+ });
1260
+ }
1261
+
1262
+ /**
1263
+ * Exports the whole Fabric canvas, then crops the requested source region from that export.
1264
+ *
1265
+ * @param {Object} region - Canvas source region and export options.
1266
+ * @param {number} region.sourceX - Source region x coordinate.
1267
+ * @param {number} region.sourceY - Source region y coordinate.
1268
+ * @param {number} region.sourceWidth - Source region width.
1269
+ * @param {number} region.sourceHeight - Source region height.
1270
+ * @param {number} [region.multiplier=1] - Export multiplier.
1271
+ * @param {number} [region.quality=0.92] - Output image quality for lossy formats.
1272
+ * @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
1273
+ * @returns {Promise<string>} Resolves with an image data URL for the cropped region.
1274
+ * @private
1275
+ */
1276
+ async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
1277
+ const safeMultiplier = Math.max(1, Number(multiplier) || 1);
1278
+ const fullDataUrl = this.canvas.toDataURL({
1279
+ format,
1280
+ quality,
1281
+ multiplier: safeMultiplier
1282
+ });
1283
+
1284
+ return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
627
1285
  }
628
1286
 
629
1287
  /**
630
1288
  * Gets the top-left corner coordinates of the given object.
631
1289
  * Used for geometry calculations (e.g., scale, rotate).
632
1290
  *
633
- * @param {Object} obj - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
1291
+ * @param {Object} fabricObject - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
634
1292
  * @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
635
1293
  * @private
636
1294
  */
637
- _getObjectTopLeftPoint(obj) {
638
- if (!obj) return { x: 0, y: 0 };
639
- obj.setCoords();
640
- const coords = typeof obj.getCoords === 'function' ? obj.getCoords() : null;
1295
+ _getObjectTopLeftPoint(fabricObject) {
1296
+ if (!fabricObject) return { x: 0, y: 0 };
1297
+ fabricObject.setCoords();
1298
+ const coords = typeof fabricObject.getCoords === 'function' ? fabricObject.getCoords() : null;
641
1299
  if (coords && coords.length) return coords[0];
642
- const br = obj.getBoundingRect(true, true);
643
- return { x: br.left, y: br.top };
1300
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1301
+ return { x: boundingRect.left, y: boundingRect.top };
644
1302
  }
645
1303
 
646
1304
  /**
647
1305
  * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
648
1306
  *
649
- * @param {Object} obj - The object to modify. Should support set, setPositionByOrigin, and setCoords.
1307
+ * @param {Object} fabricObject - The object to modify. Should support set, setPositionByOrigin, and setCoords.
650
1308
  * @param {string} originX - The new originX ("left", "center", "right", etc.).
651
1309
  * @param {string} originY - The new originY ("top", "center", "bottom", etc.).
652
1310
  * @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
653
1311
  * @private
654
1312
  */
655
- _setObjectOriginKeepingPosition(obj, originX, originY, refPoint) {
656
- if (!obj || !refPoint || !obj.setPositionByOrigin) return;
657
- obj.set({ originX, originY });
658
- obj.setPositionByOrigin(refPoint, originX, originY);
659
- obj.setCoords();
1313
+ _setObjectOriginKeepingPosition(fabricObject, originX, originY, refPoint) {
1314
+ if (!fabricObject || !refPoint || !fabricObject.setPositionByOrigin) return;
1315
+ fabricObject.set({ originX, originY });
1316
+ fabricObject.setPositionByOrigin(refPoint, originX, originY);
1317
+ fabricObject.setCoords();
660
1318
  }
661
1319
 
662
1320
  /**
663
1321
  * Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
664
1322
  *
665
- * @param {Object} obj - The object to align.
1323
+ * @param {Object} fabricObject - The object to align.
666
1324
  * @private
667
1325
  */
668
- _alignObjectBoundingBoxToCanvasTopLeft(obj) {
669
- if (!obj) return;
670
- obj.setCoords();
671
- const br = obj.getBoundingRect(true, true);
672
- const dx = br.left;
673
- const dy = br.top;
674
- obj.set({ left: (obj.left || 0) - dx, top: (obj.top || 0) - dy });
675
- obj.setCoords();
1326
+ _alignObjectBoundingBoxToCanvasTopLeft(fabricObject) {
1327
+ if (!fabricObject) return;
1328
+ fabricObject.setCoords();
1329
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1330
+ const deltaX = boundingRect.left;
1331
+ const deltaY = boundingRect.top;
1332
+ fabricObject.set({ left: (fabricObject.left || 0) - deltaX, top: (fabricObject.top || 0) - deltaY });
1333
+ fabricObject.setCoords();
676
1334
  this.canvas.renderAll();
677
1335
  }
678
1336
 
@@ -684,22 +1342,64 @@ function ensureFabric() {
684
1342
  _updateCanvasSizeToImageBounds() {
685
1343
  if (!this.originalImage) return;
686
1344
  this.originalImage.setCoords();
687
- const br = this.originalImage.getBoundingRect(true, true);
1345
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
688
1346
 
689
- // Container integer sizes
690
- const containerW = this.containerEl ? Math.ceil(this.containerEl.clientWidth || 0) : 0;
691
- const containerH = this.containerEl ? Math.ceil(this.containerEl.clientHeight || 0) : 0;
1347
+ const size = this._getScrollableCanvasSize(imageBounds.width, imageBounds.height);
1348
+ this._setCanvasSizeInt(size.width, size.height);
1349
+ }
692
1350
 
693
- // If image smaller or equal than container in BOTH dims => keep canvas equal to container
694
- if (containerW > 0 && containerH > 0 && br.width <= containerW && br.height <= containerH) {
695
- this._setCanvasSizeInt(containerW, containerH);
696
- return;
1351
+ /**
1352
+ * Whether post-load edits should resize the canvas to keep transformed content visible.
1353
+ *
1354
+ * @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
1355
+ * @private
1356
+ */
1357
+ _shouldResizeCanvasToContentBounds() {
1358
+ return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
1359
+ }
1360
+
1361
+ /**
1362
+ * Expands the canvas once so all provided objects remain visible after an edit.
1363
+ *
1364
+ * @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
1365
+ * @param {number} [padding=10] - Extra canvas space after the farthest object edge.
1366
+ * @returns {void}
1367
+ * @private
1368
+ */
1369
+ _expandCanvasToFitObjects(fabricObjects, padding = 10) {
1370
+ if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
1371
+ try {
1372
+ let requiredWidth = this.canvas.getWidth();
1373
+ let requiredHeight = this.canvas.getHeight();
1374
+ fabricObjects.forEach(fabricObject => {
1375
+ if (!fabricObject) return;
1376
+ if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
1377
+ const boundingRect = fabricObject.getBoundingRect(true, true);
1378
+ requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
1379
+ requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
1380
+ });
1381
+ const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
1382
+ const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
1383
+ const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
1384
+ const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
1385
+ if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
1386
+ this._setCanvasSizeInt(newWidth, newHeight);
1387
+ }
1388
+ } catch (error) {
1389
+ this._reportWarning('expandCanvasToFitObjects: failed to expand canvas', error);
697
1390
  }
1391
+ }
698
1392
 
699
- // Else canvas follows image bounding box but not smaller than container dims individually
700
- const newW = Math.max(containerW || 0, Math.floor(br.width));
701
- const newH = Math.max(containerH || 0, Math.floor(br.height));
702
- this._setCanvasSizeInt(newW, newH);
1393
+ /**
1394
+ * Expands the canvas so one object remains visible after an edit.
1395
+ *
1396
+ * @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
1397
+ * @param {number} [padding=10] - Extra canvas space after the object edge.
1398
+ * @returns {void}
1399
+ * @private
1400
+ */
1401
+ _expandCanvasToFitObject(fabricObject, padding = 10) {
1402
+ this._expandCanvasToFitObjects([fabricObject], padding);
703
1403
  }
704
1404
 
705
1405
  /**
@@ -709,8 +1409,8 @@ function ensureFabric() {
709
1409
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
710
1410
  * @public
711
1411
  */
712
- scaleImage(factor) {
713
- return this.animQueue.add(() => this._scaleImageImpl(factor));
1412
+ scaleImage(factor, options = {}) {
1413
+ return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
714
1414
  }
715
1415
 
716
1416
  /**
@@ -720,50 +1420,53 @@ function ensureFabric() {
720
1420
  * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
721
1421
  * @private
722
1422
  */
723
- _scaleImageImpl(factor) {
1423
+ _scaleImageImpl(factor, options = {}) {
724
1424
  if (!this.originalImage) return Promise.resolve();
725
1425
  if (this.isAnimating) return Promise.resolve();
1426
+ const saveHistory = options.saveHistory !== false;
726
1427
  factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
727
1428
  this.currentScale = factor;
728
1429
  this.isAnimating = true;
729
1430
  this._updateUI();
730
1431
 
731
- const targetAbs = this.baseImageScale * factor;
1432
+ const targetScale = this.baseImageScale * factor;
732
1433
 
733
1434
  // Scale around current top-left (recompute)
734
1435
  const topLeft = this._getObjectTopLeftPoint(this.originalImage);
735
1436
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', topLeft);
736
1437
 
737
- const p1 = new Promise((res) => {
738
- this.originalImage.animate('scaleX', targetAbs, {
1438
+ const scaleXAnimation = new Promise((resolve) => {
1439
+ this.originalImage.animate('scaleX', targetScale, {
739
1440
  duration: this.options.animationDuration,
740
1441
  onChange: this.canvas.renderAll.bind(this.canvas),
741
- onComplete: res
1442
+ onComplete: resolve
742
1443
  });
743
1444
  });
744
- const p2 = new Promise((res) => {
745
- this.originalImage.animate('scaleY', targetAbs, {
1445
+ const scaleYAnimation = new Promise((resolve) => {
1446
+ this.originalImage.animate('scaleY', targetScale, {
746
1447
  duration: this.options.animationDuration,
747
1448
  onChange: this.canvas.renderAll.bind(this.canvas),
748
- onComplete: res
1449
+ onComplete: resolve
749
1450
  });
750
1451
  });
751
1452
 
752
- return Promise.all([p1, p2]).then(() => {
753
- this.originalImage.set({ scaleX: targetAbs, scaleY: targetAbs });
1453
+ return Promise.all([scaleXAnimation, scaleYAnimation]).then(() => {
1454
+ this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
754
1455
  this.originalImage.setCoords();
755
1456
 
756
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1457
+ if (this._shouldResizeCanvasToContentBounds()) {
1458
+ this._updateCanvasSizeToImageBounds();
1459
+ }
757
1460
 
758
1461
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
759
1462
 
760
1463
  // Sync mask labels
761
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1464
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
762
1465
 
763
1466
  this.isAnimating = false;
764
1467
  this._updateInputs();
765
1468
  this._updateUI();
766
- this.saveState();
1469
+ if (saveHistory) this.saveState();
767
1470
  }).catch(() => {
768
1471
  this.isAnimating = false;
769
1472
  this._updateUI();
@@ -777,8 +1480,8 @@ function ensureFabric() {
777
1480
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
778
1481
  * @public
779
1482
  */
780
- rotateImage(deg) {
781
- return this.animQueue.add(() => this._rotateImageImpl(deg));
1483
+ rotateImage(degrees, options = {}) {
1484
+ return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
782
1485
  }
783
1486
 
784
1487
  /**
@@ -788,10 +1491,11 @@ function ensureFabric() {
788
1491
  * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
789
1492
  * @private
790
1493
  */
791
- _rotateImageImpl(degrees) {
1494
+ _rotateImageImpl(degrees, options = {}) {
792
1495
  if (!this.originalImage) return Promise.resolve();
793
1496
  if (this.isAnimating) return Promise.resolve();
794
1497
  if (isNaN(degrees)) return Promise.resolve();
1498
+ const saveHistory = options.saveHistory !== false;
795
1499
  this.currentRotation = degrees;
796
1500
  this.isAnimating = true;
797
1501
  this._updateUI();
@@ -799,19 +1503,21 @@ function ensureFabric() {
799
1503
  const center = this.originalImage.getCenterPoint();
800
1504
  this._setObjectOriginKeepingPosition(this.originalImage, 'center', 'center', center);
801
1505
 
802
- const p = new Promise((res) => {
1506
+ const rotationAnimation = new Promise((resolve) => {
803
1507
  this.originalImage.animate('angle', degrees, {
804
1508
  duration: this.options.animationDuration,
805
1509
  onChange: this.canvas.renderAll.bind(this.canvas),
806
- onComplete: res
1510
+ onComplete: resolve
807
1511
  });
808
1512
  });
809
1513
 
810
- return p.then(() => {
1514
+ return rotationAnimation.then(() => {
811
1515
  this.originalImage.set('angle', degrees);
812
1516
  this.originalImage.setCoords();
813
1517
 
814
- if (this.options.expandCanvasToImage) this._updateCanvasSizeToImageBounds();
1518
+ if (this._shouldResizeCanvasToContentBounds()) {
1519
+ this._updateCanvasSizeToImageBounds();
1520
+ }
815
1521
 
816
1522
  this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
817
1523
 
@@ -819,12 +1525,12 @@ function ensureFabric() {
819
1525
  this._setObjectOriginKeepingPosition(this.originalImage, 'left', 'top', newTopLeft);
820
1526
 
821
1527
  // Sync mask labels
822
- this.canvas.getObjects().forEach(o => { if (o.maskId) this._syncMaskLabel(o); });
1528
+ this.canvas.getObjects().forEach(object => { if (object.maskId) this._syncMaskLabel(object); });
823
1529
 
824
1530
  this.isAnimating = false;
825
1531
  this._updateInputs();
826
1532
  this._updateUI();
827
- this.saveState();
1533
+ if (saveHistory) this.saveState();
828
1534
  }).catch(() => {
829
1535
  this.isAnimating = false;
830
1536
  this._updateUI();
@@ -832,150 +1538,294 @@ function ensureFabric() {
832
1538
  }
833
1539
 
834
1540
  /**
835
- * Resets the image: scales to 1 and rotates to 0 degrees.
836
- * @returns {Promise<void>} Promise that resolves when reset is complete.
1541
+ * Resets the image transform: scales to 1 and rotates to 0 degrees.
1542
+ *
1543
+ * @returns {Promise<void>} Resolves when the reset history transition has been recorded.
1544
+ * @public
837
1545
  */
838
- reset() {
1546
+ resetImageTransform() {
839
1547
  if (!this.originalImage) return Promise.resolve();
840
1548
 
841
- return this.scaleImage(1)
842
- .then(() => this.rotateImage(0))
843
- .then(() => {
844
- this.saveState();
845
- })
846
- .catch(err => {
847
- this._reportError('reset() failed', err);
848
- });
1549
+ return this.animationQueue.add(async () => {
1550
+ const before = this._lastSnapshot || this._serializeCanvasState();
1551
+ await this._scaleImageImpl(1, { saveHistory: false });
1552
+ await this._rotateImageImpl(0, { saveHistory: false });
1553
+ const after = this._serializeCanvasState();
1554
+ this._pushStateTransition(before, after);
1555
+ }).catch(error => {
1556
+ this._reportError('resetImageTransform() failed', error);
1557
+ });
849
1558
  }
850
1559
 
851
1560
  /**
852
- * Restores a canvas state that was previously stored by saveState().
853
- * @param {string} jsonString - the JSON string returned by fabric.toJSON().
1561
+ * Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
1562
+ *
1563
+ * @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
1564
+ * @returns {Promise<void>} Resolves when the image transform reset is complete.
854
1565
  */
855
- loadFromState(jsonString) {
856
- if (!jsonString || !this.canvas) return;
857
-
858
- try {
859
- const json = (typeof jsonString === 'string')
860
- ? JSON.parse(jsonString)
861
- : jsonString;
1566
+ reset() {
1567
+ return this.resetImageTransform();
1568
+ }
862
1569
 
863
- this.canvas.loadFromJSON(json, () => {
864
- try {
865
- this._hideAllMaskLabels();
866
- const objs = this.canvas.getObjects();
867
- this.originalImage = objs.find(o => o.type === 'image' && !o.maskId) || null;
1570
+ /**
1571
+ * Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
1572
+ *
1573
+ * @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
1574
+ * @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
1575
+ * @public
1576
+ */
1577
+ loadFromState(serializedState) {
1578
+ if (!serializedState || !this.canvas) return Promise.resolve();
868
1579
 
869
- if (this.originalImage) {
870
- this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
871
- this.canvas.sendToBack(this.originalImage);
872
- }
1580
+ return new Promise((resolve) => {
1581
+ try {
1582
+ const state = (typeof serializedState === 'string')
1583
+ ? JSON.parse(serializedState)
1584
+ : serializedState;
1585
+ const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
873
1586
 
874
- const masks = objs.filter(o => o.maskId);
875
- this.maskCounter = masks.reduce((max, m) =>
876
- Math.max(max, m.maskId), 0);
877
- this._lastMask = masks.length ? masks[masks.length - 1] : null;
878
- if (!this._lastMask) {
879
- this._lastMaskInitialLeft = null;
880
- this._lastMaskInitialTop = null;
881
- this._lastMaskInitialWidth = null;
1587
+ this.canvas.loadFromJSON(state, () => {
1588
+ try {
1589
+ this._hideAllMaskLabels();
1590
+ const canvasObjects = this.canvas.getObjects();
1591
+ this.originalImage = canvasObjects.find(object => object.type === 'image' && !object.maskId) || null;
1592
+
1593
+ if (this.originalImage) {
1594
+ this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
1595
+ this.canvas.sendToBack(this.originalImage);
1596
+ const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
1597
+ const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
1598
+ const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
1599
+
1600
+ if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
1601
+ this.baseImageScale = restoredBaseScale;
1602
+ }
1603
+
1604
+ if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
1605
+ this.currentScale = restoredCurrentScale;
1606
+ } else {
1607
+ const baseScale = Number(this.baseImageScale) || 1;
1608
+ const imageScale = Number(this.originalImage.scaleX) || baseScale;
1609
+ this.currentScale = imageScale / baseScale;
1610
+ }
1611
+
1612
+ this.currentRotation = Number.isFinite(restoredCurrentRotation)
1613
+ ? restoredCurrentRotation
1614
+ : (Number(this.originalImage.angle) || 0);
1615
+ } else {
1616
+ this.baseImageScale = 1;
1617
+ this.currentScale = 1;
1618
+ this.currentRotation = 0;
1619
+ }
1620
+
1621
+ const masks = canvasObjects.filter(object => object.maskId);
1622
+ masks.forEach(mask => {
1623
+ this._restoreMaskControls(mask);
1624
+ this._rebindMaskEvents(mask);
1625
+ mask.set(this._getMaskNormalStyle(mask));
1626
+ });
1627
+ const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
1628
+ const maxMaskId = masks.reduce((max, mask) =>
1629
+ Math.max(max, mask.maskId), 0);
1630
+ this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId
1631
+ ? Math.floor(restoredMaskCounter)
1632
+ : maxMaskId;
1633
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
1634
+ if (!this._lastMask) {
1635
+ this._lastMaskInitialLeft = null;
1636
+ this._lastMaskInitialTop = null;
1637
+ this._lastMaskInitialWidth = null;
1638
+ }
1639
+ this.isImageLoadedToCanvas = !!this.originalImage;
1640
+
1641
+ this.canvas.renderAll();
1642
+ this._updateInputs();
1643
+ this._updateMaskList();
1644
+ this._updatePlaceholderStatus();
1645
+ this._lastSnapshot = this._serializeCanvasState();
1646
+ this._updateUI();
1647
+ } catch (callbackError) {
1648
+ this._reportError('loadFromState() failed', callbackError);
1649
+ } finally {
1650
+ resolve();
882
1651
  }
883
- this.isImageLoadedToCanvas = !!this.originalImage;
884
-
885
- this.canvas.renderAll();
886
- this._updateMaskList();
887
- this._updatePlaceholderStatus();
888
- this._updateUI();
889
- } catch (callbackError) {
890
- this._reportError('loadFromState() failed', callbackError);
891
- }
892
- });
1652
+ });
893
1653
 
894
- } catch (e) {
895
- this._reportError('loadFromState() failed', e);
896
- }
1654
+ } catch (error) {
1655
+ this._reportError('loadFromState() failed', error);
1656
+ resolve();
1657
+ }
1658
+ });
897
1659
  }
898
1660
 
899
1661
  /**
900
- * Saves the current state of the canvas to history, storing any mask/raster label information.
1662
+ * Saves the current editable canvas state as an undoable history transition.
1663
+ *
1664
+ * Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
1665
+ * mask objects and restored by `loadFromState()`.
1666
+ *
1667
+ * @returns {void}
1668
+ * @public
901
1669
  */
902
1670
  saveState() {
903
1671
  if (!this.canvas) return;
904
- const activeObj = this.canvas.getActiveObject();
905
- this._hideAllMaskLabels();
1672
+ const activeObject = this.canvas.getActiveObject();
906
1673
 
907
1674
  try {
908
- // request JSON including the custom flag 'isCropRect' so we can filter it out
909
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
910
- if (Array.isArray(jsonObj.objects)) {
911
- // filter out crop-rect objects before stringifying
912
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
913
- }
914
- const after = JSON.stringify(jsonObj);
1675
+ const after = this._serializeCanvasState();
915
1676
  const before = this._lastSnapshot || after;
1677
+ if (after === before) return;
916
1678
  let executedOnce = false;
917
1679
 
918
- const cmd = new Command(
1680
+ const command = new Command(
919
1681
  () => {
920
1682
  if (executedOnce) {
921
- this.loadFromState(after);
1683
+ return this.loadFromState(after);
922
1684
  }
923
1685
  executedOnce = true;
1686
+ return undefined;
924
1687
  },
925
- () => {
926
- this.loadFromState(before);
927
- }
1688
+ () => this.loadFromState(before)
928
1689
  );
929
1690
 
930
- this.historyManager.execute(cmd);
1691
+ this.historyManager.execute(command);
931
1692
  this._lastSnapshot = after;
932
- if (activeObj && activeObj.maskId) {
933
- this._showLabelForMask(activeObj);
1693
+ } catch (error) {
1694
+ this._reportWarning('saveState: failed to save canvas snapshot', error);
1695
+ } finally {
1696
+ if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
1697
+ this._handleSelectionChanged([activeObject]);
934
1698
  }
935
1699
  this._updateUI();
936
- } catch (err) {
937
- this._reportWarning('saveState: failed to save canvas snapshot', err);
938
1700
  }
939
1701
  }
940
1702
 
1703
+ /**
1704
+ * Pushes a precomputed before/after state transition into history.
1705
+ *
1706
+ * Use this for operations such as crop and merge that build their snapshots around asynchronous image
1707
+ * loading, where the "after" state is already applied before the history command is recorded.
1708
+ *
1709
+ * @param {string} before - Serialized state before the operation.
1710
+ * @param {string} after - Serialized state after the operation.
1711
+ * @returns {void}
1712
+ * @private
1713
+ */
1714
+ _pushStateTransition(before, after) {
1715
+ if (!before || !after) return;
1716
+ if (before === after) return;
1717
+ if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1718
+
1719
+ const command = new Command(
1720
+ () => this.loadFromState(after),
1721
+ () => this.loadFromState(before)
1722
+ );
1723
+ this.historyManager.push(command);
1724
+ this._lastSnapshot = after;
1725
+ this._updateUI();
1726
+ }
1727
+
941
1728
  /**
942
1729
  * Undo the last state change, if possible.
1730
+ *
1731
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
1732
+ * @public
943
1733
  */
944
1734
  undo() {
945
- this.historyManager.undo();
1735
+ return this.historyManager.undo()
1736
+ .then(() => { this._updateUI(); })
1737
+ .catch(error => { this._reportError('undo failed', error); });
946
1738
  }
947
1739
 
948
1740
  /**
949
1741
  * Redo the next state change, if possible.
1742
+ *
1743
+ * @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
1744
+ * @public
950
1745
  */
951
1746
  redo() {
952
- this.historyManager.redo();
1747
+ return this.historyManager.redo()
1748
+ .then(() => { this._updateUI(); })
1749
+ .catch(error => { this._reportError('redo failed', error); });
953
1750
  }
954
1751
 
955
- /**
956
- * Adds a rectangular mask to the canvas.
957
- * Mask placement and properties are determined by the provided config and instance options.
958
- * Canvas and list UI are updated accordingly.
959
- * @param {Object} [config={}] - Optional mask configuration overrides:
960
- * @param {string} [config.shape='rect'] - 'rect', 'circle', 'ellipse', 'polygon', ...
961
- * @param {Object|Array} [config.points] - Required for polygon: [{x, y}, ...] or [[x, y], ...]
962
- * @param {number|function} [config.width/height/rx/ry/radius] - Can be number or function(canvas, options)
963
- * @param {number|string|function} [config.left/top] - Absolute, %, or function
964
- * @param {number|string} [config.angle] - Rotation angle (degree)
965
- * @param {string} [config.color] - Fill color in CSS color format (default 'rgba(0,0,0,0.5)')
966
- * @param {number} [config.alpha] - Opacity, from 0 to 1 (default 0.5)
967
- * @param {boolean} [config.selectable=true]
968
- * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
969
- * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
970
- * @param {function} [config.fabricGenerator] - (cfg) => new FabricObj
971
- * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
1752
+ _rebindMaskEvents(mask) {
1753
+ if (!mask) return;
1754
+ if (mask.__imageEditorMaskHandlers) {
1755
+ try {
1756
+ mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
1757
+ mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
1758
+ } catch (error) { void error; }
1759
+ }
1760
+
1761
+ const metadata = {};
1762
+ if (!Number.isFinite(Number(mask.originalAlpha))) {
1763
+ metadata.originalAlpha = Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : 0.5;
1764
+ }
1765
+ if (!mask.originalStroke) metadata.originalStroke = mask.stroke || '#ccc';
1766
+ if (!Number.isFinite(Number(mask.originalStrokeWidth))) {
1767
+ metadata.originalStrokeWidth = Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1;
1768
+ }
1769
+ if (Object.keys(metadata).length) mask.set(metadata);
1770
+
1771
+ const normalStyle = {
1772
+ stroke: mask.originalStroke || '#ccc',
1773
+ strokeWidth: mask.originalStrokeWidth,
1774
+ opacity: mask.originalAlpha
1775
+ };
1776
+ const hoverStyle = {
1777
+ stroke: '#ff5500',
1778
+ strokeWidth: 2,
1779
+ opacity: Math.min(mask.originalAlpha + 0.2, 1)
1780
+ };
1781
+
1782
+ const mouseover = () => {
1783
+ mask.set(hoverStyle);
1784
+ if (mask.canvas) mask.canvas.requestRenderAll();
1785
+ };
1786
+ const mouseout = () => {
1787
+ mask.set(normalStyle);
1788
+ if (mask.canvas) mask.canvas.requestRenderAll();
1789
+ };
1790
+
1791
+ mask.on('mouseover', mouseover);
1792
+ mask.on('mouseout', mouseout);
1793
+ mask.__imageEditorMaskHandlers = { mouseover, mouseout };
1794
+ }
1795
+
1796
+ /**
1797
+ * Creates a mask and adds it to the canvas.
1798
+ *
1799
+ * Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
1800
+ * after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
1801
+ * so controls and hit testing stay in sync with Fabric 5.x behavior.
1802
+ *
1803
+ * @param {Object} [config={}] - Optional mask configuration overrides.
1804
+ * @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
1805
+ * @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
1806
+ * @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
1807
+ * @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
1808
+ * @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
1809
+ * @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
1810
+ * @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
1811
+ * @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
1812
+ * @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
1813
+ * @param {number} [config.angle=0] - Rotation angle in degrees.
1814
+ * @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
1815
+ * @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
1816
+ * @param {boolean} [config.selectable=true] - Whether the mask can be selected.
1817
+ * @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
1818
+ * @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
1819
+ * @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
1820
+ * @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
1821
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
972
1822
  * @public
973
1823
  */
974
- addMask(config = {}) {
1824
+ createMask(config = {}) {
975
1825
  if (!this.canvas) return null;
976
1826
  const shapeType = config.shape || 'rect';
977
- // Default config
978
- const cfg = {
1827
+ // Normalize mask defaults before applying caller-provided overrides.
1828
+ const maskConfig = {
979
1829
  shape: shapeType,
980
1830
  width: this.options.defaultMaskWidth,
981
1831
  height: this.options.defaultMaskHeight,
@@ -994,84 +1844,77 @@ function ensureFabric() {
994
1844
  let left = firstOffset;
995
1845
  let top = firstOffset;
996
1846
 
997
- const resolveValue = (val, fallback) => {
998
- if (typeof val === 'function')
999
- return val(this.canvas, this.options); // This context is this of addMask
1000
- if (typeof val === 'string' && val.endsWith('%')) {
1001
- const percent = parseFloat(val) / 100;
1847
+ const resolveValue = (value, fallback) => {
1848
+ if (typeof value === 'function')
1849
+ return value(this.canvas, this.options);
1850
+ if (typeof value === 'string' && value.endsWith('%')) {
1851
+ const percent = parseFloat(value) / 100;
1002
1852
  return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
1003
1853
  }
1004
- return val != null ? val : fallback;
1854
+ return value != null ? value : fallback;
1005
1855
  }
1006
1856
 
1007
- if (cfg.left === undefined && this._lastMask) {
1008
- const prev = this._lastMask;
1009
- let prevRight = prev.left;
1857
+ if (maskConfig.left === undefined && this._lastMask) {
1858
+ const previousMask = this._lastMask;
1859
+ let previousMaskRight = previousMask.left;
1010
1860
 
1011
- if (prev.getScaledWidth) {
1012
- prevRight += prev.getScaledWidth();
1013
- } else if (prev.width) {
1014
- prevRight += prev.width * (prev.scaleX ?? 1);
1861
+ if (previousMask.getScaledWidth) {
1862
+ previousMaskRight += previousMask.getScaledWidth();
1863
+ } else if (previousMask.width) {
1864
+ previousMaskRight += previousMask.width * (previousMask.scaleX ?? 1);
1015
1865
  }
1016
- left = Math.round(prevRight + cfg.gap);
1017
- top = prev.top ?? firstOffset;
1866
+ left = Math.round(previousMaskRight + maskConfig.gap);
1867
+ top = previousMask.top ?? firstOffset;
1018
1868
  } else {
1019
- left = resolveValue(cfg.left, firstOffset);
1020
- top = resolveValue(cfg.top, firstOffset);
1869
+ left = resolveValue(maskConfig.left, firstOffset);
1870
+ top = resolveValue(maskConfig.top, firstOffset);
1021
1871
  }
1022
1872
 
1023
- cfg.width = resolveValue(cfg.width, this.options.defaultMaskWidth);
1024
- cfg.height = resolveValue(cfg.height, this.options.defaultMaskHeight);
1025
-
1026
- // If expandCanvasToImage mode, ensure canvas large enough to hold mask initial placement
1027
- if (this.options.expandCanvasToImage && shapeType === 'rect') {
1028
- const requiredW = Math.ceil(left + cfg.width + 10);
1029
- const requiredH = Math.ceil(top + cfg.height + 10);
1030
- const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
1031
- const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
1032
- const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
1033
- const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
1034
- this._setCanvasSizeInt(newW, newH);
1035
- }
1873
+ maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1874
+ maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
1875
+ maskConfig.left = left;
1876
+ maskConfig.top = top;
1036
1877
 
1037
1878
  let mask;
1038
- if (typeof cfg.fabricGenerator === 'function') {
1039
- mask = cfg.fabricGenerator(cfg, this.canvas, this.options);
1879
+ if (typeof maskConfig.fabricGenerator === 'function') {
1880
+ mask = maskConfig.fabricGenerator(maskConfig, this.canvas, this.options);
1040
1881
  } else {
1041
1882
  switch (shapeType) {
1042
1883
  case 'circle':
1043
1884
  mask = new fabric.Circle({
1044
1885
  left, top,
1045
- radius: resolveValue(cfg.radius, Math.min(cfg.width, cfg.height) / 2),
1046
- fill: cfg.color,
1047
- opacity: cfg.alpha,
1048
- angle: cfg.angle,
1049
- ...cfg.styles
1886
+ radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
1887
+ fill: maskConfig.color,
1888
+ opacity: maskConfig.alpha,
1889
+ angle: maskConfig.angle,
1890
+ ...maskConfig.styles
1050
1891
  });
1051
1892
  break;
1052
1893
  case 'ellipse':
1053
1894
  mask = new fabric.Ellipse({
1054
1895
  left, top,
1055
- rx: resolveValue(cfg.rx, cfg.width / 2),
1056
- ry: resolveValue(cfg.ry, cfg.height / 2),
1057
- fill: cfg.color,
1058
- opacity: cfg.alpha,
1059
- angle: cfg.angle,
1060
- ...cfg.styles
1896
+ rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
1897
+ ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
1898
+ fill: maskConfig.color,
1899
+ opacity: maskConfig.alpha,
1900
+ angle: maskConfig.angle,
1901
+ ...maskConfig.styles
1061
1902
  });
1062
1903
  break;
1063
1904
  case 'polygon': {
1064
- let polyPoints = cfg.points || [];
1065
- if (Array.isArray(polyPoints) && polyPoints.length && typeof polyPoints[0] === 'object') {
1066
- // Ensure numeric {x,y} objects for fabric.Polygon
1067
- polyPoints = polyPoints.map(pt => ({ x: Number(pt.x), y: Number(pt.y) }));
1905
+ let polygonPoints = maskConfig.points || [];
1906
+ if (Array.isArray(polygonPoints) && polygonPoints.length) {
1907
+ // Ensure numeric {x,y} objects for fabric.Polygon.
1908
+ polygonPoints = polygonPoints.map(point => Array.isArray(point)
1909
+ ? { x: Number(point[0]), y: Number(point[1]) }
1910
+ : { x: Number(point.x), y: Number(point.y) });
1068
1911
  }
1069
- mask = new fabric.Polygon(polyPoints, {
1912
+ mask = new fabric.Polygon(polygonPoints, {
1070
1913
  left, top,
1071
- fill: cfg.color,
1072
- opacity: cfg.alpha,
1073
- angle: cfg.angle,
1074
- ...cfg.styles
1914
+ fill: maskConfig.color,
1915
+ opacity: maskConfig.alpha,
1916
+ angle: maskConfig.angle,
1917
+ ...maskConfig.styles
1075
1918
  });
1076
1919
  break;
1077
1920
  }
@@ -1079,85 +1922,103 @@ function ensureFabric() {
1079
1922
  default:
1080
1923
  mask = new fabric.Rect({
1081
1924
  left, top,
1082
- width: resolveValue(cfg.width, this.options.defaultMaskWidth),
1083
- height: resolveValue(cfg.height, this.options.defaultMaskHeight),
1084
- fill: cfg.color,
1085
- opacity: cfg.alpha,
1086
- angle: cfg.angle,
1087
- rx: cfg.rx, // Rounded Corners
1088
- ry: cfg.ry,
1089
- ...cfg.styles
1925
+ width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
1926
+ height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
1927
+ fill: maskConfig.color,
1928
+ opacity: maskConfig.alpha,
1929
+ angle: maskConfig.angle,
1930
+ rx: maskConfig.rx,
1931
+ ry: maskConfig.ry,
1932
+ ...maskConfig.styles
1090
1933
  });
1091
1934
  }
1092
1935
  }
1093
1936
 
1094
- mask.selectable = cfg.selectable !== false;
1095
- mask.hasControls = ('hasControls' in cfg) ? cfg.hasControls : true;
1096
- mask.lockRotation = !this.options.maskRotatable;
1097
- mask.borderColor = cfg.borderColor || 'red';
1098
- mask.cornerColor = cfg.cornerColor || 'black';
1099
- mask.cornerSize = cfg.cornerSize || 8;
1100
- mask.transparentCorners = ('transparentCorners' in cfg) ? cfg.transparentCorners : false;
1101
- mask.stroke = (cfg.styles && cfg.styles.stroke) || '#ccc';
1102
- mask.strokeWidth = (cfg.styles && cfg.styles.strokeWidth) || 1;
1103
- mask.strokeUniform = ('strokeUniform' in cfg) ? cfg.strokeUniform : true;
1104
- if (cfg.styles && cfg.styles.strokeDashArray) mask.strokeDashArray = cfg.styles.strokeDashArray;
1105
-
1106
- mask.originalAlpha = cfg.alpha;
1107
- const normalStyle = { stroke: mask.stroke, strokeWidth: mask.strokeWidth, opacity: mask.originalAlpha };
1108
- const hoverStyle = { stroke: '#ff5500', strokeWidth: 2, opacity: Math.min(mask.originalAlpha + 0.2, 1) };
1109
-
1110
- mask.on('mouseover', () => {
1111
- mask.set(hoverStyle);
1112
- mask.canvas.requestRenderAll();
1113
- });
1114
-
1115
- mask.on('mouseout', () => {
1116
- mask.set(normalStyle);
1117
- mask.canvas.requestRenderAll();
1937
+ const styles = maskConfig.styles || {};
1938
+ const hasStyle = property => Object.prototype.hasOwnProperty.call(styles, property);
1939
+ const maskSettings = {
1940
+ selectable: maskConfig.selectable !== false,
1941
+ hasControls: ('hasControls' in maskConfig) ? maskConfig.hasControls : true,
1942
+ lockRotation: !this.options.maskRotatable,
1943
+ borderColor: ('borderColor' in maskConfig) ? maskConfig.borderColor : 'red',
1944
+ cornerColor: ('cornerColor' in maskConfig) ? maskConfig.cornerColor : 'black',
1945
+ cornerSize: ('cornerSize' in maskConfig) ? maskConfig.cornerSize : 8,
1946
+ transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
1947
+ stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
1948
+ strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
1949
+ opacity: hasStyle('opacity') ? styles.opacity : maskConfig.alpha,
1950
+ strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
1951
+ };
1952
+ if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
1953
+ mask.set(maskSettings);
1954
+ mask.setCoords();
1955
+
1956
+ mask.set({
1957
+ originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
1958
+ originalStroke: mask.stroke || '#ccc',
1959
+ originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
1118
1960
  });
1961
+ this._rebindMaskEvents(mask);
1962
+ this._expandCanvasToFitObject(mask);
1119
1963
 
1120
- // Remember initial for next one
1964
+ // Store placement values so the next mask can be positioned beside this one.
1121
1965
  this._lastMaskInitialLeft = left;
1122
1966
  this._lastMaskInitialTop = top;
1123
- this._lastMaskInitialWidth = resolveValue(cfg.width, this.options.defaultMaskWidth);
1967
+ this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
1124
1968
 
1125
- mask.maskId = ++this.maskCounter;
1126
- mask.maskName = `${this.options.maskName}${mask.maskId}`;
1969
+ const maskId = ++this.maskCounter;
1970
+ mask.set({
1971
+ maskId,
1972
+ maskName: `${this.options.maskName}${maskId}`
1973
+ });
1127
1974
  this._lastMask = mask;
1128
1975
 
1129
1976
  this.canvas.add(mask);
1130
1977
  this.canvas.bringToFront(mask);
1131
- if (cfg.selectable) this.canvas.setActiveObject(mask);
1132
- this._onSelectionChanged([mask]);
1978
+ if (maskConfig.selectable) this.canvas.setActiveObject(mask);
1979
+ this._handleSelectionChanged([mask]);
1133
1980
  this._updateMaskList();
1134
1981
  this._updateUI();
1135
1982
  this.canvas.renderAll();
1136
1983
  this.saveState();
1137
1984
 
1138
- if (typeof cfg.onCreate === 'function') cfg.onCreate(mask, this.canvas);
1985
+ if (typeof maskConfig.onCreate === 'function') maskConfig.onCreate(mask, this.canvas);
1139
1986
  return mask;
1140
1987
  }
1141
1988
 
1989
+ /**
1990
+ * Backward-compatible alias for {@link ImageEditor#createMask}.
1991
+ *
1992
+ * @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
1993
+ * @param {Object} [config={}] - Mask configuration passed to createMask().
1994
+ * @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
1995
+ */
1996
+ addMask(config = {}) {
1997
+ return this.createMask(config);
1998
+ }
1999
+
1142
2000
  /**
1143
2001
  * Removes the currently selected mask from the canvas, if any.
1144
2002
  * The associated label is also removed. UI and mask list are updated.
1145
2003
  */
1146
2004
  removeSelectedMask() {
1147
- const active = this.canvas.getActiveObject();
1148
- if (!active || !active.maskId) return;
1149
- this._removeLabelForMask(active);
1150
- this.canvas.remove(active);
1151
- if (this._lastMask === active) {
1152
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1153
- this._lastMask = masks.length ? masks[masks.length - 1] : null;
1154
- if (!this._lastMask) {
1155
- this._lastMaskInitialLeft = null;
1156
- this._lastMaskInitialTop = null;
1157
- this._lastMaskInitialWidth = null;
1158
- }
1159
- }
2005
+ const activeObject = this.canvas.getActiveObject();
2006
+ const selectedMasks = this._getModifiedMasks(activeObject);
2007
+ if (!selectedMasks.length) return;
2008
+
1160
2009
  this.canvas.discardActiveObject();
2010
+ selectedMasks.forEach(mask => {
2011
+ this._removeLabelForMask(mask);
2012
+ this.canvas.remove(mask);
2013
+ });
2014
+
2015
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
2016
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
2017
+ if (!this._lastMask) {
2018
+ this._lastMaskInitialLeft = null;
2019
+ this._lastMaskInitialTop = null;
2020
+ this._lastMaskInitialWidth = null;
2021
+ }
1161
2022
  this._updateMaskList();
1162
2023
  this._updateUI();
1163
2024
  this.canvas.renderAll();
@@ -1168,10 +2029,11 @@ function ensureFabric() {
1168
2029
  * Removes all masks from the canvas, including their labels.
1169
2030
  * UI and internal mask placement memory are reset.
1170
2031
  */
1171
- removeAllMasks() {
1172
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1173
- masks.forEach(m => this._removeLabelForMask(m));
1174
- masks.forEach(m => this.canvas.remove(m));
2032
+ removeAllMasks(options = {}) {
2033
+ const saveHistory = options.saveHistory !== false;
2034
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
2035
+ masks.forEach(mask => this._removeLabelForMask(mask));
2036
+ masks.forEach(mask => this.canvas.remove(mask));
1175
2037
  this.canvas.discardActiveObject();
1176
2038
  this._lastMask = null;
1177
2039
  this._lastMaskInitialLeft = null;
@@ -1180,7 +2042,7 @@ function ensureFabric() {
1180
2042
  this._updateMaskList();
1181
2043
  this._updateUI();
1182
2044
  this.canvas.renderAll();
1183
- this.saveState();
2045
+ if (saveHistory) this.saveState();
1184
2046
  }
1185
2047
 
1186
2048
  /**
@@ -1193,15 +2055,33 @@ function ensureFabric() {
1193
2055
  if (!mask || !this.canvas) return;
1194
2056
  if (mask.__label) {
1195
2057
  try {
1196
- const objs = this.canvas.getObjects();
1197
- if (objs.includes(mask.__label)) {
2058
+ const canvasObjects = this.canvas.getObjects();
2059
+ if (canvasObjects.includes(mask.__label)) {
1198
2060
  this.canvas.remove(mask.__label);
1199
2061
  }
1200
- } catch (e) { void e; }
1201
- try { delete mask.__label; } catch (e) { void e; }
2062
+ } catch (error) { void error; }
2063
+ try { delete mask.__label; } catch (error) { void error; }
1202
2064
  }
1203
2065
  }
1204
2066
 
2067
+ /**
2068
+ * Returns a stable zero-based creation index for label callbacks.
2069
+ *
2070
+ * Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
2071
+ * lifetime of a mask.
2072
+ *
2073
+ * @param {fabric.Object} mask - Mask object.
2074
+ * @returns {number} Stable zero-based creation index.
2075
+ * @private
2076
+ */
2077
+ _getMaskCreationIndex(mask) {
2078
+ const maskId = Number(mask && mask.maskId);
2079
+ if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
2080
+
2081
+ const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
2082
+ return Math.max(0, masks.indexOf(mask));
2083
+ }
2084
+
1205
2085
  /**
1206
2086
  * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1207
2087
  * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
@@ -1212,12 +2092,12 @@ function ensureFabric() {
1212
2092
  _createLabelForMask(mask) {
1213
2093
  if (!mask || !this.options.maskLabelOnSelect) return;
1214
2094
  this._removeLabelForMask(mask);
1215
- let textObj = null;
2095
+ let textObject = null;
1216
2096
  if (this.options.label && typeof this.options.label.create === 'function') {
1217
- textObj = this.options.label.create(mask, fabric);
2097
+ textObject = this.options.label.create(mask, fabric);
1218
2098
  }
1219
- if (!textObj) {
1220
- let txt = mask.maskName; // Default
2099
+ if (!textObject) {
2100
+ let labelText = mask.maskName;
1221
2101
  let textOptions = {
1222
2102
  left: 0,
1223
2103
  top: 0,
@@ -1232,20 +2112,20 @@ function ensureFabric() {
1232
2112
  };
1233
2113
  if (this.options.label) {
1234
2114
  if (typeof this.options.label.getText === 'function') {
1235
- txt = this.options.label.getText(mask, this.maskCounter);
2115
+ labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
1236
2116
  }
1237
2117
  // Merge external styles
1238
2118
  if (this.options.label.textOptions) {
1239
2119
  Object.assign(textOptions, this.options.label.textOptions);
1240
2120
  }
1241
2121
  }
1242
- textObj = new fabric.Text(txt, textOptions);
2122
+ textObject = new fabric.Text(labelText, textOptions);
1243
2123
  }
1244
2124
 
1245
- textObj.maskLabel = true;
1246
- mask.__label = textObj;
1247
- this.canvas.add(textObj);
1248
- this.canvas.bringToFront(textObj);
2125
+ textObject.maskLabel = true;
2126
+ mask.__label = textObject;
2127
+ this.canvas.add(textObject);
2128
+ this.canvas.bringToFront(textObject);
1249
2129
  this._syncMaskLabel(mask);
1250
2130
  }
1251
2131
 
@@ -1256,14 +2136,18 @@ function ensureFabric() {
1256
2136
  */
1257
2137
  _hideAllMaskLabels() {
1258
2138
  if (!this.canvas) return;
1259
- const objs = this.canvas.getObjects();
1260
- const labels = objs.filter(o => o.maskLabel);
1261
- labels.forEach(l => {
2139
+ const canvasObjects = this.canvas.getObjects();
2140
+ const labels = canvasObjects.filter(object => object.maskLabel);
2141
+ labels.forEach(label => {
1262
2142
  try {
1263
- if (objs.includes(l)) this.canvas.remove(l);
1264
- } catch (e) { void e; }
2143
+ if (canvasObjects.includes(label)) this.canvas.remove(label);
2144
+ } catch (error) { void error; }
2145
+ });
2146
+ canvasObjects.forEach(object => {
2147
+ if (object.maskId && object.__label) {
2148
+ try { delete object.__label; } catch (error) { void error; }
2149
+ }
1265
2150
  });
1266
- objs.forEach(o => { if (o.maskId && o.__label) { try { delete o.__label; } catch (e) { void e; } } });
1267
2151
  }
1268
2152
 
1269
2153
  /**
@@ -1303,7 +2187,11 @@ function ensureFabric() {
1303
2187
  visible: true
1304
2188
  });
1305
2189
  mask.__label.setCoords();
1306
- this.canvas.renderAll();
2190
+ if (typeof this.canvas.requestRenderAll === 'function') {
2191
+ this.canvas.requestRenderAll();
2192
+ } else {
2193
+ this.canvas.renderAll();
2194
+ }
1307
2195
  }
1308
2196
 
1309
2197
  /**
@@ -1316,7 +2204,7 @@ function ensureFabric() {
1316
2204
  if (!mask) return;
1317
2205
  if (!this.options.maskLabelOnSelect) return;
1318
2206
  if (!mask.__label) this._createLabelForMask(mask);
1319
- mask.__label.visible = true;
2207
+ mask.__label.set({ visible: true });
1320
2208
  this._syncMaskLabel(mask);
1321
2209
  }
1322
2210
 
@@ -1327,18 +2215,22 @@ function ensureFabric() {
1327
2215
  * @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
1328
2216
  * @private
1329
2217
  */
1330
- _onSelectionChanged(selected) {
1331
- const selectedMask = (selected || []).find(o => o.maskId);
1332
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1333
- masks.forEach(m => {
1334
- if (m !== selectedMask) {
1335
- if (m.__label) {
1336
- try { this.canvas.remove(m.__label); } catch (e) { void e; }
1337
- delete m.__label;
2218
+ _handleSelectionChanged(selected) {
2219
+ const selectedMask = (selected || []).find(object => object.maskId);
2220
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
2221
+ masks.forEach(mask => {
2222
+ if (mask !== selectedMask) {
2223
+ if (mask.__label) {
2224
+ try { this.canvas.remove(mask.__label); } catch (error) { void error; }
2225
+ delete mask.__label;
1338
2226
  }
1339
- m.set({ stroke: '#ccc', strokeWidth: 1 });
2227
+ const originalStrokeWidth = Number(mask.originalStrokeWidth);
2228
+ mask.set({
2229
+ stroke: mask.originalStroke || '#ccc',
2230
+ strokeWidth: Number.isFinite(originalStrokeWidth) ? originalStrokeWidth : 1
2231
+ });
1340
2232
  } else {
1341
- m.set({ stroke: '#ff0000', strokeWidth: 1 });
2233
+ mask.set({ stroke: '#ff0000', strokeWidth: 1 });
1342
2234
  }
1343
2235
  });
1344
2236
 
@@ -1355,16 +2247,16 @@ function ensureFabric() {
1355
2247
  * @private
1356
2248
  */
1357
2249
  _updateMaskList() {
1358
- const listEl = document.getElementById(this.elements.maskList);
1359
- if (!listEl) return;
1360
- listEl.innerHTML = '';
1361
- const masks = this.canvas.getObjects().filter(o => o.maskId);
2250
+ const maskListElement = document.getElementById(this.elements.maskList);
2251
+ if (!maskListElement) return;
2252
+ maskListElement.innerHTML = '';
2253
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1362
2254
  masks.forEach(mask => {
1363
- const li = document.createElement('li');
1364
- li.className = 'list-group-item mask-item';
1365
- li.textContent = mask.maskName;
1366
- li.onclick = () => { this.canvas.setActiveObject(mask); this._onSelectionChanged([mask]); };
1367
- listEl.appendChild(li);
2255
+ const listItemElement = document.createElement('li');
2256
+ listItemElement.className = 'list-group-item mask-item';
2257
+ listItemElement.textContent = mask.maskName;
2258
+ listItemElement.onclick = () => { this.canvas.setActiveObject(mask); this._handleSelectionChanged([mask]); };
2259
+ maskListElement.appendChild(listItemElement);
1368
2260
  });
1369
2261
  }
1370
2262
 
@@ -1375,168 +2267,178 @@ function ensureFabric() {
1375
2267
  * @private
1376
2268
  */
1377
2269
  _updateMaskListSelection(selectedMask) {
1378
- const listEl = document.getElementById(this.elements.maskList);
1379
- if (!listEl) return;
1380
- const items = listEl.querySelectorAll('.mask-item');
1381
- items.forEach(item => {
2270
+ const maskListElement = document.getElementById(this.elements.maskList);
2271
+ if (!maskListElement) return;
2272
+ const maskItems = maskListElement.querySelectorAll('.mask-item');
2273
+ maskItems.forEach(item => {
1382
2274
  const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
1383
2275
  item.classList.toggle('active', isSelected);
1384
2276
  });
1385
2277
  }
1386
2278
 
1387
2279
  /**
1388
- * Merges current masks into the image: exports a masked/cropped image, removes all masks, and re-imports the merged image.
1389
- * Will not run if no original image or no masks exist.
2280
+ * Flattens the current masks into the base image and reloads the flattened image.
2281
+ *
2282
+ * This removes editable mask objects after export and records the operation as one undoable history transition.
2283
+ * It does nothing when no base image or no masks exist.
2284
+ *
1390
2285
  * @async
1391
- * @returns {Promise<void>} Resolves when merge and load are complete.
2286
+ * @returns {Promise<void>} Resolves when the flattened image has been loaded.
2287
+ * @public
1392
2288
  */
1393
- async merge() {
2289
+ async mergeMasks() {
1394
2290
  if (!this.originalImage) return;
1395
- const masks = this.canvas.getObjects().filter(o => o.maskId);
2291
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1396
2292
  if (!masks.length) return;
1397
2293
 
1398
2294
  this.canvas.discardActiveObject();
1399
2295
  this.canvas.renderAll();
1400
2296
 
1401
2297
  try {
1402
- const merged = await this.getImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1403
- this.removeAllMasks();
1404
- await this.loadImage(merged);
1405
- this.saveState();
1406
- } catch (err) {
1407
- this._reportError('merge error', err);
1408
- if (this.canvasEl) this.canvasEl.style.visibility = '';
2298
+ const beforeJson = this._serializeCanvasState();
2299
+ const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
2300
+ this.removeAllMasks({ saveHistory: false });
2301
+ await this.loadImage(merged, { preserveScroll: true });
2302
+ const afterJson = this._serializeCanvasState();
2303
+ this._pushStateTransition(beforeJson, afterJson);
2304
+ } catch (error) {
2305
+ this._reportError('merge error', error);
1409
2306
  }
1410
2307
  }
1411
2308
 
1412
2309
  /**
1413
- * Triggers a JPEG image download of the current canvas (image plus masks if configured).
2310
+ * Backward-compatible alias for {@link ImageEditor#mergeMasks}.
2311
+ *
2312
+ * @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
2313
+ * @returns {Promise<void>} Resolves when mask flattening is complete.
2314
+ */
2315
+ async merge() {
2316
+ return this.mergeMasks();
2317
+ }
2318
+
2319
+ /**
2320
+ * Triggers a JPEG image download of the current canvas.
2321
+ *
1414
2322
  * The image area and multiplier are controlled by options.
1415
2323
  * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
2324
+ * @returns {void}
2325
+ * @public
1416
2326
  */
1417
2327
  downloadImage(fileName = this.options.defaultDownloadFileName) {
1418
2328
  if (!this.originalImage) return;
1419
2329
  const exportImageArea = this.options.exportImageAreaByDefault;
1420
- this.getImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
1421
- .then(base64 => {
2330
+ this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
2331
+ .then(imageBase64 => {
1422
2332
  const link = document.createElement('a');
1423
2333
  link.download = fileName;
1424
- link.href = base64;
2334
+ link.href = imageBase64;
1425
2335
  document.body.appendChild(link);
1426
2336
  link.click();
1427
2337
  document.body.removeChild(link);
1428
2338
  })
1429
- .catch(err => this._reportError('download error', err));
2339
+ .catch(error => this._reportError('download error', error));
1430
2340
  }
1431
2341
 
1432
2342
  /**
1433
- * Exports the image as a Base64-encoded JPEG.
1434
- * Can export either the original, or the current view including masks (clipped/cropped).
1435
- * Will restore masks' state after temporary modifications for export.
2343
+ * Exports the current image as a Base64-encoded data URL.
2344
+ *
2345
+ * When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
2346
+ * temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
2347
+ *
1436
2348
  * @async
1437
- * @param {Object} [opts={}] - Export options.
1438
- * @param {boolean} [opts.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1439
- * @param {number} [opts.multiplier=1] - Scaling multiplier for output (resolution).
1440
- * @returns {Promise<string>} Promise resolving to a JPEG image data URL.
2349
+ * @param {Object} [options={}] - Export options.
2350
+ * @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
2351
+ * @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
2352
+ * @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
2353
+ * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
2354
+ * @returns {Promise<string>} Resolves with an image data URL.
1441
2355
  * @throws {Error} If there is no image loaded.
2356
+ * @public
1442
2357
  */
1443
- async getImageBase64(opts = {}) {
2358
+ async exportImageBase64(options = {}) {
1444
2359
  if (!this.originalImage) throw new Error('No image loaded');
1445
- const exportImageArea = typeof opts.exportImageArea === 'boolean' ? opts.exportImageArea : this.options.exportImageAreaByDefault;
1446
- const multiplier = opts.multiplier || this.options.exportMultiplier || 1;
2360
+ const exportImageArea = typeof options.exportImageArea === 'boolean' ? options.exportImageArea : this.options.exportImageAreaByDefault;
2361
+ const multiplier = options.multiplier || this.options.exportMultiplier || 1;
2362
+ const quality = this._normalizeQuality(options.quality ?? this.options.downsampleQuality);
2363
+ const format = this._normalizeImageFormat(options.fileType || options.format);
1447
2364
 
1448
2365
  if (!exportImageArea) {
1449
- // Export original image pixels
1450
- const imgEl = this.originalImage.getElement ? this.originalImage.getElement() : (this.originalImage._element || null);
1451
- if (!imgEl) return this.canvas.toDataURL({ format: 'jpeg', quality: this.options.downsampleQuality, multiplier });
1452
- const w = this.originalImage.width;
1453
- const h = this.originalImage.height;
1454
- const oc = document.createElement('canvas');
1455
- oc.width = w;
1456
- oc.height = h;
1457
- const ctx = oc.getContext('2d');
1458
- ctx.drawImage(imgEl, 0, 0, w, h);
1459
- return oc.toDataURL('image/jpeg', this.options.downsampleQuality);
2366
+ const masks = this.canvas.getObjects().filter(object => object.maskId || object.maskLabel);
2367
+ const maskVisibilityBackups = masks.map(mask => ({ object: mask, visible: mask.visible }));
2368
+
2369
+ try {
2370
+ masks.forEach(mask => { mask.set({ visible: false }); });
2371
+ this.canvas.discardActiveObject();
2372
+ this.canvas.renderAll();
2373
+
2374
+ this.originalImage.setCoords();
2375
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2376
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
2377
+ return await this._exportCanvasRegionToDataURL({
2378
+ ...exportRegion,
2379
+ multiplier,
2380
+ quality,
2381
+ format
2382
+ });
2383
+ } finally {
2384
+ maskVisibilityBackups.forEach(backup => {
2385
+ try { backup.object.set({ visible: backup.visible }); } catch (error) { void error; }
2386
+ });
2387
+ this.canvas.renderAll();
2388
+ }
1460
2389
  }
1461
2390
 
1462
- // Export current scaled image area (masks clipped)
1463
- const masks = this.canvas.getObjects().filter(o => o.maskId);
1464
- const masksBackup = masks.map(m => ({
1465
- obj: m,
1466
- opacity: m.opacity,
1467
- fill: m.fill,
1468
- strokeWidth: m.strokeWidth,
1469
- stroke: m.stroke,
1470
- selectable: m.selectable,
1471
- lockRotation: m.lockRotation
2391
+ // Render masks as export shapes without mutating their editable styles.
2392
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
2393
+ const maskStyleBackups = masks.map(mask => ({
2394
+ object: mask,
2395
+ opacity: mask.opacity,
2396
+ fill: mask.fill,
2397
+ strokeWidth: mask.strokeWidth,
2398
+ stroke: mask.stroke,
2399
+ selectable: mask.selectable,
2400
+ lockRotation: mask.lockRotation
1472
2401
  }));
1473
2402
 
1474
2403
  let finalBase64;
1475
2404
  try {
1476
- // Remove labels, deselect
1477
- masks.forEach(m => this._removeLabelForMask(m));
2405
+ // Labels are UI overlays and should not be part of the flattened export.
2406
+ masks.forEach(mask => this._removeLabelForMask(mask));
1478
2407
  this.canvas.discardActiveObject();
1479
2408
  this.canvas.renderAll();
1480
2409
 
1481
- // Set masks to opaque black no border
1482
- masks.forEach(m => {
1483
- m.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
1484
- m.setCoords();
2410
+ // The export treats masks as opaque shapes with no editable border.
2411
+ masks.forEach(mask => {
2412
+ mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
2413
+ mask.setCoords();
1485
2414
  });
1486
2415
  this.canvas.renderAll();
1487
2416
 
1488
- // Compute integer bounding box for image
2417
+ // Compute an integer canvas region for the base image.
1489
2418
  this.originalImage.setCoords();
1490
- const imgBr = this.originalImage.getBoundingRect(true, true);
1491
- const sx = Math.max(0, Math.round(imgBr.left));
1492
- const sy = Math.max(0, Math.round(imgBr.top));
1493
- const sw = Math.max(1, Math.round(imgBr.width));
1494
- const sh = Math.max(1, Math.round(imgBr.height));
2419
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2420
+ const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
1495
2421
 
1496
2422
  // Crop precisely in offscreen canvas
1497
- finalBase64 = await new Promise((resolve, reject) => {
1498
- try {
1499
- const fullDataUrl = this.canvas.toDataURL({
1500
- format: 'jpeg',
1501
- quality: this.options.downsampleQuality,
1502
- multiplier: multiplier
1503
- });
1504
-
1505
- const img = new Image();
1506
- img.onload = () => {
1507
- try {
1508
- const sxM = Math.round(sx * multiplier);
1509
- const syM = Math.round(sy * multiplier);
1510
- const swM = Math.round(sw * multiplier);
1511
- const shM = Math.round(sh * multiplier);
1512
-
1513
- const oc = document.createElement('canvas');
1514
- oc.width = swM;
1515
- oc.height = shM;
1516
- const ctx = oc.getContext('2d');
1517
-
1518
- ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
1519
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality);
1520
- resolve(out);
1521
- } catch (e) { reject(e); }
1522
- };
1523
- img.onerror = reject;
1524
- img.src = fullDataUrl;
1525
- } catch (e) { reject(e); }
2423
+ finalBase64 = await this._exportCanvasRegionToDataURL({
2424
+ ...exportRegion,
2425
+ multiplier,
2426
+ quality,
2427
+ format
1526
2428
  });
1527
2429
  } finally {
1528
- masksBackup.forEach(b => {
2430
+ maskStyleBackups.forEach(backup => {
1529
2431
  try {
1530
- b.obj.set({
1531
- opacity: b.opacity,
1532
- fill: b.fill,
1533
- strokeWidth: b.strokeWidth,
1534
- stroke: b.stroke,
1535
- selectable: b.selectable,
1536
- lockRotation: b.lockRotation
2432
+ backup.object.set({
2433
+ opacity: backup.opacity,
2434
+ fill: backup.fill,
2435
+ strokeWidth: backup.strokeWidth,
2436
+ stroke: backup.stroke,
2437
+ selectable: backup.selectable,
2438
+ lockRotation: backup.lockRotation
1537
2439
  });
1538
- b.obj.setCoords();
1539
- } catch (e) { void e; }
2440
+ backup.object.setCoords();
2441
+ } catch (error) { void error; }
1540
2442
  });
1541
2443
 
1542
2444
  this.canvas.renderAll();
@@ -1546,22 +2448,35 @@ function ensureFabric() {
1546
2448
  }
1547
2449
 
1548
2450
  /**
1549
- * Exports the current canvas (with or without masks) as a File object.
1550
- * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
2451
+ * Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
2452
+ *
2453
+ * @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
2454
+ * @param {Object} [options={}] - Export options passed to exportImageBase64().
2455
+ * @returns {Promise<string>} Resolves with an image data URL.
2456
+ */
2457
+ async getImageBase64(options = {}) {
2458
+ return this.exportImageBase64(options);
2459
+ }
2460
+
2461
+ /**
2462
+ * Exports the current image as a File object.
2463
+ *
2464
+ * The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
2465
+ * Supported output formats are JPEG, PNG, and WebP.
1551
2466
  *
1552
2467
  * @async
1553
- * @param {Object} [opts={}] - Export options.
1554
- * @param {boolean} [opts.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
1555
- * @param {string} [opts.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
1556
- * @param {number} [opts.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
1557
- * @param {number} [opts.multiplier=1] - Output resolution multiplier.
1558
- * @param {string} [opts.fileName] - Optional file name (only used for download).
2468
+ * @param {Object} [options={}] - Export options.
2469
+ * @param {boolean} [options.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
2470
+ * @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
2471
+ * @param {number} [options.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
2472
+ * @param {number} [options.multiplier=1] - Output resolution multiplier.
2473
+ * @param {string} [options.fileName] - Optional file name (only used for download).
1559
2474
  * @returns {Promise<File>} Resolves with the exported image as a File object.
1560
2475
  *
1561
2476
  * @example
1562
2477
  * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
1563
2478
  */
1564
- async exportImageFile(opts = {}) {
2479
+ async exportImageFile(options = {}) {
1565
2480
  if (!this.originalImage) throw new Error('No image loaded');
1566
2481
  const {
1567
2482
  mergeMask = true,
@@ -1569,70 +2484,131 @@ function ensureFabric() {
1569
2484
  quality = this.options.downsampleQuality ?? 0.92,
1570
2485
  multiplier = this.options.exportMultiplier ?? 1,
1571
2486
  fileName = this.options.defaultDownloadFileName ?? 'exported_image.jpg'
1572
- } = opts;
2487
+ } = options;
1573
2488
 
1574
- const typeMapping = {
1575
- 'jpeg': 'jpeg',
1576
- 'jpg': 'jpeg',
1577
- 'image/jpeg': 'jpeg',
1578
- 'png': 'png',
1579
- 'image/png': 'png',
1580
- 'webp': 'webp',
1581
- 'image/webp': 'webp'
1582
- };
1583
- const safeFileType = typeMapping[String(fileType).toLowerCase()] || 'jpeg';
2489
+ const safeFileType = this._normalizeImageFormat(fileType);
1584
2490
 
1585
- // Get Base64
1586
- let base64;
2491
+ // Generate the data URL in the requested export mode.
2492
+ let imageBase64;
1587
2493
  if (mergeMask) {
1588
- base64 = await this.getImageBase64({
2494
+ imageBase64 = await this.exportImageBase64({
1589
2495
  exportImageArea: true,
1590
2496
  multiplier,
2497
+ quality,
2498
+ fileType: safeFileType
1591
2499
  });
1592
2500
  } else {
1593
- base64 = await this.getImageBase64({
2501
+ imageBase64 = await this.exportImageBase64({
1594
2502
  exportImageArea: false,
1595
2503
  multiplier,
2504
+ quality,
2505
+ fileType: safeFileType
1596
2506
  });
1597
2507
  }
1598
2508
 
1599
2509
  // Convert to the required image format
1600
- let imageDataUrl = base64;
2510
+ let imageDataUrl = imageBase64;
1601
2511
  if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1602
- // Redraw if not required format
2512
+ // Redraw the exported data URL when the browser returned a different image format.
1603
2513
  imageDataUrl = await new Promise((resolve, reject) => {
1604
- const img = new window.Image();
1605
- img.crossOrigin = "Anonymous";
1606
- img.onload = () => {
2514
+ const imageElement = new window.Image();
2515
+ imageElement.crossOrigin = "Anonymous";
2516
+ imageElement.onload = () => {
1607
2517
  try {
1608
- const oc = document.createElement('canvas');
1609
- oc.width = img.width;
1610
- oc.height = img.height;
1611
- const ctx = oc.getContext('2d');
1612
- ctx.drawImage(img, 0, 0);
1613
- const durl = oc.toDataURL(`image/${safeFileType}`, quality);
1614
- resolve(durl);
1615
- } catch (e) { reject(e); }
2518
+ const offscreenCanvas = document.createElement('canvas');
2519
+ offscreenCanvas.width = imageElement.width;
2520
+ offscreenCanvas.height = imageElement.height;
2521
+ const context = offscreenCanvas.getContext('2d');
2522
+ context.drawImage(imageElement, 0, 0);
2523
+ const convertedDataUrl = offscreenCanvas.toDataURL(`image/${safeFileType}`, quality);
2524
+ resolve(convertedDataUrl);
2525
+ } catch (error) { reject(error); }
1616
2526
  };
1617
- img.onerror = reject;
1618
- img.src = base64;
2527
+ imageElement.onerror = reject;
2528
+ imageElement.src = imageBase64;
1619
2529
  });
1620
2530
  }
1621
2531
 
1622
- // Convert DataURL to Blob and then to File
1623
- const bstr = atob(imageDataUrl.split(',')[1]);
2532
+ // Convert the final data URL to a File with the requested MIME type.
2533
+ const binaryString = atob(imageDataUrl.split(',')[1]);
1624
2534
  const mime = `image/${safeFileType}`;
1625
- let n = bstr.length;
1626
- const u8arr = new Uint8Array(n);
1627
- while (n--) {
1628
- u8arr[n] = bstr.charCodeAt(n);
2535
+ let byteIndex = binaryString.length;
2536
+ const bytes = new Uint8Array(byteIndex);
2537
+ while (byteIndex--) {
2538
+ bytes[byteIndex] = binaryString.charCodeAt(byteIndex);
2539
+ }
2540
+ return new File([bytes], fileName, { type: mime });
2541
+ }
2542
+
2543
+ _clearMaskPlacementMemory() {
2544
+ this._lastMask = null;
2545
+ this._lastMaskInitialLeft = null;
2546
+ this._lastMaskInitialTop = null;
2547
+ this._lastMaskInitialWidth = null;
2548
+ }
2549
+
2550
+ async _restoreStateAfterCropFailure(beforeJson, message, error) {
2551
+ this._reportError(message, error);
2552
+
2553
+ if (this._cropRect && this.canvas) this._removeCropRect();
2554
+ this._cropRect = null;
2555
+ this._cropMode = false;
2556
+ if (this.canvas && this._prevSelectionSetting !== undefined) {
2557
+ this.canvas.selection = !!this._prevSelectionSetting;
2558
+ }
2559
+ this._prevSelectionSetting = undefined;
2560
+
2561
+ if (beforeJson) {
2562
+ try {
2563
+ await this.loadFromState(beforeJson);
2564
+ } catch (restoreError) {
2565
+ this._reportError('applyCrop: rollback failed', restoreError);
2566
+ }
2567
+ }
2568
+
2569
+ this._updateUI();
2570
+ if (this.canvas) this.canvas.renderAll();
2571
+ }
2572
+
2573
+ _restoreCropObjectState() {
2574
+ if (Array.isArray(this._cropPrevEvented)) {
2575
+ this._cropPrevEvented.forEach(state => {
2576
+ try {
2577
+ state.object.set({
2578
+ evented: state.evented,
2579
+ selectable: state.selectable,
2580
+ visible: state.visible
2581
+ });
2582
+ } catch (error) { void error; }
2583
+ });
1629
2584
  }
1630
- const file = new File([u8arr], fileName, { type: mime });
1631
- return file;
2585
+ this._cropPrevEvented = null;
2586
+ }
2587
+
2588
+ _removeCropRect() {
2589
+ if (!this._cropRect) return;
2590
+ try {
2591
+ if (this._cropHandlers && this._cropHandlers.length) {
2592
+ this._cropHandlers.forEach(targetHandlers => {
2593
+ targetHandlers.handlers.forEach(handlerRecord => {
2594
+ targetHandlers.target.off(handlerRecord.eventName, handlerRecord.handler);
2595
+ });
2596
+ });
2597
+ }
2598
+ } catch (error) { void error; }
2599
+
2600
+ try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
2601
+ this._cropRect = null;
2602
+ this._cropHandlers = [];
1632
2603
  }
1633
2604
 
1634
2605
  /**
1635
- * Enter crop mode: create a resizable/movable selection rect on top of the image.
2606
+ * Enters crop mode by creating a resizable crop rectangle above the base image.
2607
+ *
2608
+ * Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
2609
+ * cropping when `crop.hideMasksDuringCrop` is enabled.
2610
+ *
2611
+ * @returns {void}
1636
2612
  * @public
1637
2613
  */
1638
2614
  enterCropMode() {
@@ -1640,24 +2616,30 @@ function ensureFabric() {
1640
2616
  if (!this.isImageLoaded()) return;
1641
2617
  this._cropMode = true;
1642
2618
 
1643
- // Disable canvas group selection to avoid accidental group selection while cropping
2619
+ // Disable group selection so only the crop rectangle can be manipulated.
1644
2620
  this._prevSelectionSetting = this.canvas.selection;
1645
2621
  this.canvas.selection = false;
1646
2622
 
1647
- // Make sure no active object
2623
+ // Clear the current selection before activating the crop rectangle.
1648
2624
  this.canvas.discardActiveObject();
1649
2625
 
1650
- // Create initial crop rect centered on the image bounding box
2626
+ // Create the initial crop rectangle inside the image bounds.
1651
2627
  this.originalImage.setCoords();
1652
- const imgBr = this.originalImage.getBoundingRect(true, true);
1653
- // Provide small inset so user can see a margin
2628
+ const imageBounds = this.originalImage.getBoundingRect(true, true);
2629
+ // Use a small inset so the user can see the crop boundary.
1654
2630
  const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
1655
- const left = Math.max(0, Math.floor(imgBr.left + padding));
1656
- const top = Math.max(0, Math.floor(imgBr.top + padding));
1657
- const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imgBr.width - padding * 2));
1658
- const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imgBr.height - padding * 2));
1659
-
1660
- // Visual style: translucent fill + dashed stroke
2631
+ const left = Math.max(0, Math.floor(imageBounds.left + padding));
2632
+ const top = Math.max(0, Math.floor(imageBounds.top + padding));
2633
+ const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
2634
+ const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
2635
+ const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
2636
+ const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
2637
+ const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
2638
+ const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
2639
+ const width = minCropWidth;
2640
+ const height = minCropHeight;
2641
+
2642
+ // Visual style for the temporary crop rectangle.
1661
2643
  const cropRect = new fabric.Rect({
1662
2644
  left, top,
1663
2645
  width, height,
@@ -1672,7 +2654,8 @@ function ensureFabric() {
1672
2654
  cornerSize: 8,
1673
2655
  objectCaching: false,
1674
2656
  originX: 'left',
1675
- originY: 'top'
2657
+ originY: 'top',
2658
+ lockScalingFlip: true
1676
2659
  });
1677
2660
 
1678
2661
  // Ensure the crop rect is above everything
@@ -1681,61 +2664,68 @@ function ensureFabric() {
1681
2664
  this.canvas.bringToFront(cropRect);
1682
2665
  this.canvas.setActiveObject(cropRect);
1683
2666
 
1684
- // Keep reference
2667
+ // Store the crop rectangle so apply/cancel can clean it up.
1685
2668
  this._cropRect = cropRect;
1686
2669
 
1687
- // While in crop mode: we want only the cropRect to be interactive
1688
- // but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
2670
+ // Keep only the crop rectangle interactive while preserving each object's previous state.
1689
2671
  this._cropPrevEvented = [];
1690
- this.canvas.getObjects().forEach(o => {
1691
- if (o !== cropRect) {
1692
- this._cropPrevEvented.push({ obj: o, evented: o.evented, selectable: o.selectable });
1693
- try { o.evented = false; o.selectable = false; } catch (e) { void e; }
2672
+ const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
2673
+ this.canvas.getObjects().forEach(object => {
2674
+ if (object !== cropRect) {
2675
+ this._cropPrevEvented.push({ object, evented: object.evented, selectable: object.selectable, visible: object.visible });
2676
+ try {
2677
+ const updates = {
2678
+ evented: false,
2679
+ selectable: false
2680
+ };
2681
+ if (shouldHideMasks && (object.maskId || object.maskLabel)) updates.visible = false;
2682
+ object.set(updates);
2683
+ } catch (error) { void error; }
1694
2684
  }
1695
2685
  });
1696
2686
 
1697
- // When the crop rect changes, re-render
1698
- const onModified = () => { try { cropRect.setCoords(); this.canvas.requestRenderAll(); } catch (e) { void e; } };
1699
- cropRect.on('modified', onModified);
1700
- cropRect.on('moving', onModified);
1701
- cropRect.on('scaling', onModified);
1702
-
1703
- // Keep handlers to remove later
1704
- this._cropHandlers.push({ target: cropRect, handlers: [{ evt: 'modified', fn: onModified }, { evt: 'moving', fn: onModified }, { evt: 'scaling', fn: onModified }] });
2687
+ // Keep Fabric controls and configured size limits in sync as the crop rectangle changes.
2688
+ const handleCropRectModified = () => {
2689
+ try {
2690
+ const cropWidth = Math.max(1, Number(cropRect.width) || 1);
2691
+ const cropHeight = Math.max(1, Number(cropRect.height) || 1);
2692
+ const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
2693
+ const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
2694
+ cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
2695
+ cropRect.setCoords();
2696
+ this.canvas.requestRenderAll();
2697
+ } catch (error) { void error; }
2698
+ };
2699
+ cropRect.on('modified', handleCropRectModified);
2700
+ cropRect.on('moving', handleCropRectModified);
2701
+ cropRect.on('scaling', handleCropRectModified);
2702
+
2703
+ // Store handlers so cancel/apply/dispose can unbind them.
2704
+ this._cropHandlers.push({
2705
+ target: cropRect,
2706
+ handlers: [
2707
+ { eventName: 'modified', handler: handleCropRectModified },
2708
+ { eventName: 'moving', handler: handleCropRectModified },
2709
+ { eventName: 'scaling', handler: handleCropRectModified }
2710
+ ]
2711
+ });
1705
2712
 
1706
2713
  this._updateUI();
1707
2714
  this.canvas.renderAll();
1708
2715
  }
1709
2716
 
1710
2717
  /**
1711
- * Cancel crop mode and remove the temporary selection rect.
2718
+ * Cancels crop mode and removes the temporary crop rectangle.
2719
+ *
2720
+ * @returns {void}
1712
2721
  * @public
1713
2722
  */
1714
2723
  cancelCrop() {
1715
2724
  if (!this.canvas || !this._cropMode) return;
1716
- // Remove handlers if any and remove object
1717
- if (this._cropRect) {
1718
- try {
1719
- if (this._cropHandlers && this._cropHandlers.length) {
1720
- this._cropHandlers.forEach(h => {
1721
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1722
- });
1723
- }
1724
- } catch (e) { void e; }
1725
-
1726
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
1727
- this._cropRect = null;
1728
- }
1729
- // restore evented/selectable flags
1730
- if (Array.isArray(this._cropPrevEvented)) {
1731
- this._cropPrevEvented.forEach(i => {
1732
- try { i.obj.evented = i.evented; i.obj.selectable = i.selectable; } catch (e) { void e; }
1733
- });
1734
- }
1735
- this._cropPrevEvented = null;
1736
- this._cropHandlers = [];
2725
+ this._removeCropRect();
2726
+ this._restoreCropObjectState();
1737
2727
  this._cropMode = false;
1738
- // restore selection setting
2728
+ // Restore the canvas selection setting that was active before crop mode.
1739
2729
  this.canvas.selection = !!this._prevSelectionSetting;
1740
2730
  this._prevSelectionSetting = undefined;
1741
2731
 
@@ -1745,159 +2735,129 @@ function ensureFabric() {
1745
2735
  }
1746
2736
 
1747
2737
  /**
1748
- * Apply the current crop rectangle.
1749
- * remove all masks and export canvas snapshot and crop via offscreen canvas
2738
+ * Applies the current crop rectangle to the base image.
2739
+ *
2740
+ * Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
2741
+ * region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
2742
+ * single undoable history transition.
2743
+ *
2744
+ * @async
2745
+ * @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
1750
2746
  * @public
1751
2747
  */
1752
2748
  async applyCrop() {
1753
2749
  if (!this.canvas || !this._cropMode || !this._cropRect) return;
1754
2750
 
1755
- // Ensure crop rect coords are fresh
2751
+ // Fabric does not update control coordinates automatically after programmatic transforms.
1756
2752
  this._cropRect.setCoords();
1757
2753
  const rectBounds = this._cropRect.getBoundingRect(true, true);
1758
2754
 
1759
- // Compute integer crop region clamped to canvas
1760
- const sx = Math.max(0, Math.round(rectBounds.left));
1761
- const sy = Math.max(0, Math.round(rectBounds.top));
1762
- const sw = Math.max(1, Math.round(Math.min(rectBounds.width, this.canvas.getWidth() - sx)));
1763
- const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
2755
+ const cropRegion = this._getClampedCanvasRegion(rectBounds);
2756
+ const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
2757
+
2758
+ this._restoreCropObjectState();
1764
2759
 
1765
- // Include isCropRect in toJSON whitelist so we can detect and filter them out.
1766
2760
  let beforeJson = null;
1767
2761
  try {
1768
- const jsonObj = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1769
- if (Array.isArray(jsonObj.objects)) {
1770
- jsonObj.objects = jsonObj.objects.filter(o => !o.isCropRect);
1771
- }
1772
- beforeJson = JSON.stringify(jsonObj);
1773
- } catch (e) {
1774
- this._reportWarning('applyCrop: could not serialize before state', e);
2762
+ beforeJson = this._serializeCanvasState();
2763
+ } catch (error) {
2764
+ this._reportWarning('applyCrop: could not serialize before state', error);
1775
2765
  beforeJson = null;
1776
2766
  }
1777
2767
 
2768
+ const preservedMasks = [];
1778
2769
 
1779
- // Remove ALL un-merged masks so they won't be baked into exported pixels
1780
2770
  try {
1781
- const masks = this.canvas.getObjects().filter(o => o.maskId);
2771
+ const masks = this.canvas.getObjects().filter(object => object.maskId);
1782
2772
  if (masks && masks.length) {
1783
- masks.forEach(m => {
2773
+ masks.forEach(mask => {
1784
2774
  try {
1785
- this._removeLabelForMask(m);
1786
- this.canvas.remove(m);
1787
- } catch (err) {
1788
- this._reportWarning('applyCrop: failed to remove mask', err);
2775
+ mask.setCoords();
2776
+ const maskBounds = mask.getBoundingRect(true, true);
2777
+ const intersectsCrop =
2778
+ maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
2779
+ maskBounds.left + maskBounds.width > cropRegion.sourceX &&
2780
+ maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
2781
+ maskBounds.top + maskBounds.height > cropRegion.sourceY;
2782
+ this._removeLabelForMask(mask);
2783
+ this.canvas.remove(mask);
2784
+ if (shouldPreserveMasks && intersectsCrop) {
2785
+ mask.set({
2786
+ left: (mask.left || 0) - cropRegion.sourceX,
2787
+ top: (mask.top || 0) - cropRegion.sourceY,
2788
+ visible: true
2789
+ });
2790
+ mask.setCoords();
2791
+ preservedMasks.push(mask);
2792
+ }
2793
+ } catch (error) {
2794
+ this._reportWarning('applyCrop: failed to remove mask', error);
1789
2795
  }
1790
2796
  });
1791
- this._lastMask = null;
1792
- this._lastMaskInitialLeft = null;
1793
- this._lastMaskInitialTop = null;
1794
- this._lastMaskInitialWidth = null;
2797
+ this._clearMaskPlacementMemory();
1795
2798
  this.canvas.discardActiveObject();
1796
2799
  this.canvas.renderAll();
1797
2800
  }
1798
- } catch (e) {
1799
- this._reportWarning('applyCrop: error while removing masks', e);
2801
+ } catch (error) {
2802
+ this._reportWarning('applyCrop: error while removing masks', error);
1800
2803
  }
1801
2804
 
1802
- try {
1803
- if (this._cropRect) {
1804
- try {
1805
- if (this._cropHandlers && this._cropHandlers.length) {
1806
- this._cropHandlers.forEach(h => {
1807
- h.handlers.forEach(rec => h.target.off(rec.evt, rec.fn));
1808
- });
1809
- }
1810
- } catch (e) { void e; }
1811
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
1812
- this._cropRect = null;
1813
- }
1814
- } catch (e) { void e; }
2805
+ this._removeCropRect();
1815
2806
 
1816
- // End crop mode
2807
+ // End crop mode before loading the cropped image.
1817
2808
  this._cropMode = false;
1818
2809
  this.canvas.selection = !!this._prevSelectionSetting;
1819
2810
  this._prevSelectionSetting = undefined;
1820
2811
 
1821
- // Export full canvas and crop on offscreen canvas
2812
+ // Export the crop region from the current canvas.
1822
2813
  let croppedBase64;
1823
2814
  try {
1824
- const fullDataUrl = this.canvas.toDataURL({
1825
- format: 'jpeg',
1826
- quality: this.options.downsampleQuality || 0.92,
1827
- multiplier: 1
1828
- });
1829
-
1830
- croppedBase64 = await new Promise((resolve, reject) => {
1831
- const img = new Image();
1832
- img.onload = () => {
1833
- try {
1834
- const oc = document.createElement('canvas');
1835
- oc.width = sw;
1836
- oc.height = sh;
1837
- const ctx = oc.getContext('2d');
1838
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
1839
- const out = oc.toDataURL('image/jpeg', this.options.downsampleQuality || 0.92);
1840
- resolve(out);
1841
- } catch (err) {
1842
- reject(err);
1843
- }
1844
- };
1845
- img.onerror = (e) => reject(e);
1846
- img.src = fullDataUrl;
2815
+ croppedBase64 = await this._exportCanvasRegionToDataURL({
2816
+ ...cropRegion,
2817
+ multiplier: 1,
2818
+ quality: this._normalizeQuality(this.options.downsampleQuality),
2819
+ format: 'jpeg'
1847
2820
  });
1848
- } catch (e) {
1849
- this._reportError('applyCrop: failed to create cropped image', e);
1850
- this._updateUI();
2821
+ } catch (error) {
2822
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: failed to create cropped image', error);
1851
2823
  return;
1852
2824
  }
1853
2825
 
1854
- // Load the cropped image as the new base image
2826
+ // Load the cropped image as the new base image.
1855
2827
  try {
1856
2828
  await this.loadImage(croppedBase64);
1857
- } catch (e) {
1858
- this._reportError('applyCrop: loadImage(croppedBase64) failed', e);
1859
- this._updateUI();
2829
+ if (preservedMasks.length) {
2830
+ preservedMasks.forEach(mask => {
2831
+ this._rebindMaskEvents(mask);
2832
+ this.canvas.add(mask);
2833
+ this.canvas.bringToFront(mask);
2834
+ });
2835
+ this._lastMask = preservedMasks[preservedMasks.length - 1];
2836
+ this.maskCounter = preservedMasks.reduce((max, mask) => Math.max(max, mask.maskId || 0), this.maskCounter);
2837
+ this._updateMaskList();
2838
+ this.canvas.renderAll();
2839
+ }
2840
+ } catch (error) {
2841
+ await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
1860
2842
  return;
1861
2843
  }
1862
2844
 
1863
- // Create "after" snapshot (also exclude crop rect if any) and push history command
2845
+ // Create an after snapshot and push one history command for the crop operation.
1864
2846
  let afterJson = null;
1865
2847
  try {
1866
- const jsonObj2 = this.canvas.toJSON(['maskId', 'maskName', 'isCropRect']);
1867
- if (Array.isArray(jsonObj2.objects)) {
1868
- jsonObj2.objects = jsonObj2.objects.filter(o => !o.isCropRect);
1869
- }
1870
- afterJson = JSON.stringify(jsonObj2);
1871
- } catch (e) {
1872
- this._reportWarning('applyCrop: failed to serialize after state', e);
2848
+ afterJson = this._serializeCanvasState();
2849
+ } catch (error) {
2850
+ this._reportWarning('applyCrop: failed to serialize after state', error);
1873
2851
  afterJson = null;
1874
2852
  }
1875
2853
 
1876
2854
  try {
1877
- const self = this;
1878
- const cmd = new Command(
1879
- () => { if (afterJson) self.loadFromState(afterJson); },
1880
- () => { if (beforeJson) self.loadFromState(beforeJson); }
1881
- );
1882
-
1883
- if (!this.historyManager) this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1884
-
1885
- // trim future redo history
1886
- if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
1887
- this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
1888
- }
1889
-
1890
- this.historyManager.history.push(cmd);
1891
- if (this.historyManager.history.length > this.historyManager.maxSize) {
1892
- this.historyManager.history.shift();
1893
- } else {
1894
- this.historyManager.currentIndex++;
1895
- }
1896
- } catch (e) {
1897
- this._reportWarning('applyCrop: failed to push history command', e);
2855
+ this._pushStateTransition(beforeJson, afterJson);
2856
+ } catch (error) {
2857
+ this._reportWarning('applyCrop: failed to push history command', error);
1898
2858
  }
1899
2859
 
1900
- // Final UI update
2860
+ // Refresh UI state after crop completion.
1901
2861
  this._updateUI();
1902
2862
  this.canvas.renderAll();
1903
2863
  }
@@ -1911,8 +2871,8 @@ function ensureFabric() {
1911
2871
  * @private
1912
2872
  */
1913
2873
  _updateInputs() {
1914
- const scaleEl = document.getElementById(this.elements.scaleRate);
1915
- if (scaleEl) scaleEl.value = Math.round(this.currentScale * 100);
2874
+ const scaleInputElement = document.getElementById(this.elements.scaleRate);
2875
+ if (scaleInputElement) scaleInputElement.value = Math.round(this.currentScale * 100);
1916
2876
  }
1917
2877
 
1918
2878
  /**
@@ -1921,45 +2881,47 @@ function ensureFabric() {
1921
2881
  * @private
1922
2882
  */
1923
2883
  _updateUI() {
1924
- const hasImg = !!this.originalImage;
1925
- const masks = hasImg ? this.canvas.getObjects().filter(o => o.maskId) : [];
2884
+ const hasImage = !!this.originalImage;
2885
+ const masks = hasImage ? this.canvas.getObjects().filter(object => object.maskId) : [];
1926
2886
  const hasMasks = masks.length > 0;
1927
- const active = this.canvas.getActiveObject();
1928
- const hasSelectedMask = active && active.maskId;
1929
- const isDefault = this.currentScale === 1 && this.currentRotation === 0;
2887
+ const activeObject = this.canvas.getActiveObject();
2888
+ const hasSelectedMask = activeObject && activeObject.maskId;
2889
+ const isDefaultTransform = this.currentScale === 1 && this.currentRotation === 0;
1930
2890
  const canUndo = this.historyManager?.canUndo();
1931
2891
  const canRedo = this.historyManager?.canRedo();
1932
- const inCrop = !!this._cropMode;
1933
-
1934
- if (inCrop) {
1935
- // iterate all element keys and disable unless key is applyCropBtn or cancelCropBtn
1936
- for (const k of Object.keys(this.elements || {})) {
1937
- const el = document.getElementById(this.elements[k]);
1938
- if (!el) continue;
1939
- if (k === 'applyCropBtn' || k === 'cancelCropBtn') {
1940
- el.disabled = false;
2892
+ const isInCropMode = !!this._cropMode;
2893
+
2894
+ if (isInCropMode) {
2895
+ // Disable all controls except the crop action buttons while crop mode is active.
2896
+ for (const key of Object.keys(this.elements || {})) {
2897
+ const element = document.getElementById(this.elements[key]);
2898
+ if (!element) continue;
2899
+ if (key === 'applyCropBtn' || key === 'cancelCropBtn') {
2900
+ this._setDisabled(key, false);
1941
2901
  } else {
1942
- el.disabled = true;
2902
+ this._setDisabled(key, true);
1943
2903
  }
1944
2904
  }
1945
2905
  return;
1946
2906
  }
1947
2907
 
1948
- this._setDisabled('zoomInBtn', !hasImg || this.isAnimating || this.currentScale >= this.options.maxScale);
1949
- this._setDisabled('zoomOutBtn', !hasImg || this.isAnimating || this.currentScale <= this.options.minScale);
1950
- this._setDisabled('rotateLeftBtn', !hasImg || this.isAnimating);
1951
- this._setDisabled('rotateRightBtn', !hasImg || this.isAnimating);
1952
- this._setDisabled('addMaskBtn', !hasImg || this.isAnimating);
2908
+ this._setDisabled('zoomInBtn', !hasImage || this.isAnimating || this.currentScale >= this.options.maxScale);
2909
+ this._setDisabled('zoomOutBtn', !hasImage || this.isAnimating || this.currentScale <= this.options.minScale);
2910
+ this._setDisabled('rotateLeftBtn', !hasImage || this.isAnimating);
2911
+ this._setDisabled('rotateRightBtn', !hasImage || this.isAnimating);
2912
+ this._setDisabled('addMaskBtn', !hasImage || this.isAnimating);
1953
2913
  this._setDisabled('removeMaskBtn', !hasSelectedMask || this.isAnimating);
1954
2914
  this._setDisabled('removeAllMasksBtn', !hasMasks || this.isAnimating);
1955
- this._setDisabled('mergeBtn', !hasImg || !hasMasks || this.isAnimating);
1956
- this._setDisabled('downloadBtn', !hasImg || this.isAnimating);
1957
- this._setDisabled('resetBtn', !hasImg || isDefault || this.isAnimating);
1958
- this._setDisabled('undoBtn', !hasImg || this.isAnimating || !canUndo);
1959
- this._setDisabled('redoBtn', !hasImg || this.isAnimating || !canRedo);
1960
- this._setDisabled('cropBtn', !hasImg || this.isAnimating);
2915
+ this._setDisabled('mergeBtn', !hasImage || !hasMasks || this.isAnimating);
2916
+ this._setDisabled('downloadBtn', !hasImage || this.isAnimating);
2917
+ this._setDisabled('resetBtn', !hasImage || isDefaultTransform || this.isAnimating);
2918
+ this._setDisabled('undoBtn', !hasImage || this.isAnimating || !canUndo);
2919
+ this._setDisabled('redoBtn', !hasImage || this.isAnimating || !canRedo);
2920
+ this._setDisabled('cropBtn', !hasImage || this.isAnimating);
1961
2921
  this._setDisabled('applyCropBtn', true);
1962
2922
  this._setDisabled('cancelCropBtn', true);
2923
+ this._setDisabled('imageInput', this.isAnimating);
2924
+ this._setDisabled('uploadArea', this.isAnimating);
1963
2925
  }
1964
2926
 
1965
2927
  /**
@@ -1970,12 +2932,30 @@ function ensureFabric() {
1970
2932
  * @private
1971
2933
  */
1972
2934
  _setDisabled(key, disabled) {
1973
- const el = document.getElementById(this.elements[key]);
1974
- if (el) el.disabled = !!disabled;
2935
+ const element = document.getElementById(this.elements[key]);
2936
+ if (!element) return;
2937
+ if ('disabled' in element) {
2938
+ element.disabled = !!disabled;
2939
+ return;
2940
+ }
2941
+
2942
+ if (disabled) {
2943
+ element.setAttribute('aria-disabled', 'true');
2944
+ element.style.pointerEvents = 'none';
2945
+ } else {
2946
+ element.removeAttribute('aria-disabled');
2947
+ element.style.pointerEvents = '';
2948
+ }
2949
+ }
2950
+
2951
+ _isElementDisabled(element) {
2952
+ if (!element) return false;
2953
+ if ('disabled' in element) return !!element.disabled;
2954
+ return element.getAttribute('aria-disabled') === 'true';
1975
2955
  }
1976
2956
 
1977
2957
  /**
1978
- * Automatically display and hide placeholders and containers based on the current image content
2958
+ * Updates placeholder and canvas container visibility based on whether an image is loaded.
1979
2959
  * @private
1980
2960
  */
1981
2961
  _updatePlaceholderStatus() {
@@ -1984,20 +2964,21 @@ function ensureFabric() {
1984
2964
  }
1985
2965
 
1986
2966
  /**
1987
- * Controls the display/hiding of the Placeholder and Canvas container.
1988
- * @param {boolean} show - true displays the placeholder, false displays the canvas container
2967
+ * Shows or hides the placeholder and canvas container.
2968
+ *
2969
+ * @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
1989
2970
  * @private
1990
2971
  */
1991
2972
  _setPlaceholderVisible(show) {
1992
- if (!this.placeholderEl) return;
2973
+ if (!this.placeholderElement || !this.containerElement) return;
1993
2974
  if (show) {
1994
- this.placeholderEl.classList.remove('d-none');
1995
- this.placeholderEl.classList.add('d-flex');
1996
- this.containerEl.classList.add('d-none');
2975
+ this.placeholderElement.classList.remove('d-none');
2976
+ this.placeholderElement.classList.add('d-flex');
2977
+ this.containerElement.classList.add('d-none');
1997
2978
  } else {
1998
- this.placeholderEl.classList.remove('d-flex');
1999
- this.placeholderEl.classList.add('d-none');
2000
- this.containerEl.classList.remove('d-none');
2979
+ this.placeholderElement.classList.remove('d-flex');
2980
+ this.placeholderElement.classList.add('d-none');
2981
+ this.containerElement.classList.remove('d-none');
2001
2982
  }
2002
2983
  }
2003
2984
 
@@ -2009,132 +2990,190 @@ function ensureFabric() {
2009
2990
  dispose() {
2010
2991
  // Remove bound DOM event listeners
2011
2992
  try {
2012
- for (const key in (this._boundHandlers || {})) {
2013
- const handlers = this._boundHandlers[key] || [];
2014
- const el = document.getElementById(this.elements[key]);
2015
- if (!el) continue;
2016
- handlers.forEach(h => {
2017
- try { el.removeEventListener(h.event, h.handler); } catch (e) { void e; }
2993
+ for (const key in (this._handlersByElementKey || {})) {
2994
+ const handlers = this._handlersByElementKey[key] || [];
2995
+ const element = document.getElementById(this.elements[key]);
2996
+ if (!element) continue;
2997
+ handlers.forEach(handlerRecord => {
2998
+ try { element.removeEventListener(handlerRecord.eventName, handlerRecord.handler); } catch (error) { void error; }
2018
2999
  });
2019
3000
  }
2020
- } catch (e) { void e; }
3001
+ } catch (error) { void error; }
2021
3002
 
2022
3003
  if (this._cropRect) {
2023
- try { this.canvas.remove(this._cropRect); } catch (e) { void e; }
3004
+ try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
2024
3005
  this._cropRect = null;
2025
3006
  }
2026
3007
 
3008
+ if (this.containerElement && this._containerOriginalOverflow !== undefined) {
3009
+ try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
3010
+ }
3011
+
2027
3012
  if (this.canvas) {
2028
- try { this.canvas.dispose(); } catch (e) { void e; }
3013
+ try { this.canvas.dispose(); } catch (error) { void error; }
2029
3014
  this.canvas = null;
2030
- this.canvasEl = null;
3015
+ this.canvasElement = null;
2031
3016
  this.isImageLoadedToCanvas = false;
2032
3017
  }
2033
- this._boundHandlers = {};
3018
+ this._handlersByElementKey = {};
2034
3019
  }
2035
3020
  }
2036
3021
 
2037
3022
  /**
2038
- * A simple FIFO queue that guarantees animations are executed sequentially.
2039
- * @class AnimationQueue
3023
+ * @callback AnimationTaskCallback
3024
+ * @returns {unknown} Animation result or awaitable animation result.
3025
+ */
3026
+
3027
+ /**
3028
+ * @callback PromiseResolveCallback
3029
+ * @param {unknown} value - Promise resolution value.
3030
+ * @returns {void}
3031
+ */
3032
+
3033
+ /**
3034
+ * @callback PromiseRejectCallback
3035
+ * @param {unknown} reason - Promise rejection reason.
3036
+ * @returns {void}
3037
+ */
3038
+
3039
+ /**
3040
+ * @typedef {Object} QueuedAnimationTask
3041
+ * @property {AnimationTaskCallback} animationFn - Queued animation function.
3042
+ * @property {PromiseResolveCallback} resolve - Promise resolver for the queued animation.
3043
+ * @property {PromiseRejectCallback} reject - Promise rejecter for the queued animation.
3044
+ */
3045
+
3046
+ /**
3047
+ * @callback HistoryTaskCallback
3048
+ * @returns {void|Promise<void>} Result of a history operation.
3049
+ */
3050
+
3051
+ /**
3052
+ * FIFO queue that serializes transform animations so Fabric state changes do not overlap.
3053
+ *
3054
+ * @private
2040
3055
  */
2041
3056
  class AnimationQueue {
2042
3057
  /**
2043
- * Creates a new AnimationQueue.
2044
- *
2045
- * @constructor
3058
+ * Creates an empty animation queue.
2046
3059
  */
2047
3060
  constructor() {
2048
3061
  /**
2049
- * Internal queue holding animation descriptors.
2050
- * @type {Array<{fn: Function, resolve: Function, reject: Function}>}
3062
+ * Pending animation descriptors.
3063
+ * @type {Array<QueuedAnimationTask>}
2051
3064
  */
2052
- this.queue = [];
3065
+ this.animationTasks = [];
2053
3066
  /**
2054
- * Flag indicating whether an animation is currently running.
3067
+ * Whether an animation task is currently running.
2055
3068
  * @type {boolean}
2056
3069
  */
2057
- this.running = false;
3070
+ this.isRunning = false;
2058
3071
  }
2059
3072
 
2060
3073
  /**
2061
3074
  * Adds an animation function to the queue.
2062
3075
  *
2063
- * @param {Function} animationFn A function that returns a Promise or any await-able.
2064
- * @returns {Promise<*>} A Promise that resolves/rejects with the animation result.
3076
+ * @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
3077
+ * @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
2065
3078
  */
2066
3079
  async add(animationFn) {
2067
3080
  return new Promise((resolve, reject) => {
2068
- // Push the animation into the queue.
2069
- this.queue.push({ fn: animationFn, resolve, reject });
2070
- // Start processing if it's not already running.
2071
- if (!this.running) {
2072
- this.processQueue();
3081
+ this.animationTasks.push({ animationFn, resolve, reject });
3082
+ if (!this.isRunning) {
3083
+ this._drainQueue();
2073
3084
  }
2074
3085
  });
2075
3086
  }
2076
3087
 
2077
3088
  /**
2078
- * Internal helper that processes the animation queue sequentially until it is empty.
3089
+ * Runs queued animation tasks sequentially until the queue is empty.
2079
3090
  *
2080
3091
  * @private
2081
3092
  * @returns {Promise<void>}
2082
3093
  */
2083
- async processQueue() {
2084
- if (this.queue.length === 0) {
2085
- this.running = false;
3094
+ async _drainQueue() {
3095
+ if (this.animationTasks.length === 0) {
3096
+ this.isRunning = false;
2086
3097
  return;
2087
3098
  }
2088
3099
 
2089
- this.running = true;
2090
- const { fn, resolve, reject } = this.queue.shift();
3100
+ this.isRunning = true;
3101
+ const { animationFn, resolve, reject } = this.animationTasks.shift();
2091
3102
 
2092
3103
  try {
2093
- const result = await fn();
3104
+ const result = await animationFn();
2094
3105
  resolve(result);
2095
3106
  } catch (error) {
2096
3107
  reject(error);
2097
3108
  }
2098
3109
 
2099
- this.processQueue();
3110
+ await this._drainQueue();
2100
3111
  }
2101
3112
  }
2102
3113
 
2103
3114
  /**
2104
- * Command object encapsulating an executable action and its corresponding undo operation.
2105
- * @class Command
3115
+ * Undoable command with paired execute and undo operations.
3116
+ *
3117
+ * @private
2106
3118
  */
2107
3119
  class Command {
2108
3120
  /**
2109
- * @param {Function} execute The function that performs the action.
2110
- * @param {Function} undo The function that reverts the action.
3121
+ * @param {HistoryTaskCallback} execute - Function that performs the action.
3122
+ * @param {HistoryTaskCallback} undo - Function that reverts the action.
2111
3123
  */
2112
3124
  constructor(execute, undo) {
2113
3125
  /**
2114
3126
  * Executes the command.
2115
- * @type {Function}
3127
+ * @type {HistoryTaskCallback}
2116
3128
  */
2117
3129
  this.execute = execute;
2118
3130
  /**
2119
3131
  * Undoes the command.
2120
- * @type {Function}
3132
+ * @type {HistoryTaskCallback}
2121
3133
  */
2122
3134
  this.undo = undo;
2123
3135
  }
2124
3136
  }
2125
3137
 
2126
3138
  /**
2127
- * Manages a history of Command objects enabling undo/redo functionality.
2128
- * @class HistoryManager
3139
+ * Manages undo/redo history and serializes asynchronous history operations.
3140
+ *
3141
+ * @private
2129
3142
  */
2130
3143
  class HistoryManager {
2131
3144
  /**
2132
- * @param {number} [maxSize=50] Maximum number of commands to keep in history.
3145
+ * @param {number} [maxSize=50] - Maximum number of commands to keep in history.
2133
3146
  */
2134
3147
  constructor(maxSize = 50) {
3148
+ /** @type {Array<Command>} */
2135
3149
  this.history = [];
3150
+ /** @type {number} */
2136
3151
  this.currentIndex = -1;
3152
+ /** @type {number} */
2137
3153
  this.maxSize = maxSize;
3154
+ /** @type {Promise<void>} */
3155
+ this.pending = Promise.resolve();
3156
+ }
3157
+
3158
+ /**
3159
+ * Queues a history task after the previously queued undo/redo task completes.
3160
+ *
3161
+ * @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
3162
+ * @returns {Promise<void>} Resolves or rejects with the queued task result.
3163
+ * @private
3164
+ */
3165
+ enqueue(task) {
3166
+ const nextTask = this.pending.then(task, task);
3167
+ let pendingAfterTask;
3168
+ const resetPending = () => {
3169
+ if (this.pending === pendingAfterTask) {
3170
+ this.pending = Promise.resolve();
3171
+ }
3172
+ };
3173
+
3174
+ pendingAfterTask = nextTask.then(resetPending, resetPending);
3175
+ this.pending = pendingAfterTask;
3176
+ return nextTask;
2138
3177
  }
2139
3178
 
2140
3179
  /**
@@ -2145,20 +3184,27 @@ function ensureFabric() {
2145
3184
  * @returns {void}
2146
3185
  */
2147
3186
  execute(command) {
2148
- // Perform the command.
2149
3187
  command.execute();
3188
+ this.push(command);
3189
+ }
2150
3190
 
2151
- // Remove any commands that are ahead of the current index.
3191
+ /**
3192
+ * Pushes an already-applied command onto the history stack.
3193
+ * Truncates any "future" history when branching.
3194
+ *
3195
+ * @param {Command} command The command to push.
3196
+ * @returns {void}
3197
+ */
3198
+ push(command) {
3199
+ // Discard redo commands when a new branch is created.
2152
3200
  if (this.currentIndex < this.history.length - 1) {
2153
3201
  this.history = this.history.slice(0, this.currentIndex + 1);
2154
3202
  }
2155
3203
 
2156
- // Add the new command.
2157
3204
  this.history.push(command);
2158
3205
 
2159
- // Maintain the max size of the buffer.
2160
3206
  if (this.history.length > this.maxSize) {
2161
- this.history.shift(); // Remove the oldest command.
3207
+ this.history.shift();
2162
3208
  } else {
2163
3209
  this.currentIndex++;
2164
3210
  }
@@ -2185,25 +3231,31 @@ function ensureFabric() {
2185
3231
  /**
2186
3232
  * Undoes the last executed command if possible.
2187
3233
  *
2188
- * @returns {void}
3234
+ * @returns {Promise<void>} Resolves after the undo task completes.
2189
3235
  */
2190
3236
  undo() {
2191
- if (this.currentIndex >= 0) {
2192
- this.history[this.currentIndex].undo();
2193
- this.currentIndex--;
2194
- }
3237
+ return this.enqueue(async () => {
3238
+ if (this.currentIndex >= 0) {
3239
+ const index = this.currentIndex;
3240
+ await this.history[index].undo();
3241
+ this.currentIndex = index - 1;
3242
+ }
3243
+ });
2195
3244
  }
2196
3245
 
2197
3246
  /**
2198
3247
  * Redoes the next command in history if possible.
2199
3248
  *
2200
- * @returns {void}
3249
+ * @returns {Promise<void>} Resolves after the redo task completes.
2201
3250
  */
2202
3251
  redo() {
2203
- if (this.currentIndex < this.history.length - 1) {
2204
- this.currentIndex++;
2205
- this.history[this.currentIndex].execute();
2206
- }
3252
+ return this.enqueue(async () => {
3253
+ if (this.currentIndex < this.history.length - 1) {
3254
+ const index = this.currentIndex + 1;
3255
+ await this.history[index].execute();
3256
+ this.currentIndex = index;
3257
+ }
3258
+ });
2207
3259
  }
2208
3260
  }
2209
3261