@bensitu/image-editor 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/image-editor.esm.js +534 -207
- package/dist/image-editor.esm.js.map +2 -2
- package/dist/image-editor.esm.min.js +3 -3
- package/dist/image-editor.esm.min.js.map +3 -3
- package/dist/image-editor.esm.min.mjs +3 -3
- package/dist/image-editor.esm.min.mjs.map +3 -3
- package/dist/image-editor.esm.mjs +534 -207
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +534 -207
- package/dist/image-editor.js.map +2 -2
- package/dist/image-editor.min.js +2 -2
- package/dist/image-editor.min.js.map +3 -3
- package/image-editor.d.ts +21 -11
- package/package.json +1 -1
- package/src/image-editor.js +724 -287
package/src/image-editor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file image-editor.js
|
|
3
3
|
* @module image-editor
|
|
4
|
-
* @version 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.
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
let fabric = null;
|
|
11
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
|
+
*/
|
|
12
18
|
function getGlobalScope() {
|
|
13
19
|
if (typeof globalThis !== 'undefined') return globalThis;
|
|
14
20
|
if (typeof self !== 'undefined') return self;
|
|
@@ -16,37 +22,96 @@ function getGlobalScope() {
|
|
|
16
22
|
return null;
|
|
17
23
|
}
|
|
18
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
|
+
*/
|
|
19
31
|
function getGlobalFabric() {
|
|
20
32
|
const scope = getGlobalScope();
|
|
21
33
|
return scope && scope.fabric ? scope.fabric : null;
|
|
22
34
|
}
|
|
23
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
|
+
*/
|
|
24
45
|
export function setFabric(fabricInstance) {
|
|
25
46
|
fabric = fabricInstance || getGlobalFabric();
|
|
26
47
|
return fabric;
|
|
27
48
|
}
|
|
28
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
|
+
*/
|
|
29
56
|
function ensureFabric() {
|
|
30
57
|
if (!fabric) setFabric();
|
|
31
58
|
return fabric;
|
|
32
59
|
}
|
|
33
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
|
+
|
|
34
107
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* A lightweight wrapper around fabric.js providing masking, scaling, rotation,
|
|
38
|
-
* merging/export helpers, and UI integrations for image editing.
|
|
108
|
+
* Fabric.js-based image editor with masking, transform, crop, history, and export helpers.
|
|
39
109
|
*
|
|
40
|
-
*
|
|
110
|
+
* Requires Fabric.js v5.x through the ESM package entry or a globally available `fabric` namespace.
|
|
41
111
|
*
|
|
42
|
-
*
|
|
43
|
-
* Example usage:
|
|
112
|
+
* @example
|
|
44
113
|
* const editor = new ImageEditor({ canvasWidth: 1024, canvasHeight: 768 });
|
|
45
114
|
* editor.init();
|
|
46
|
-
* </pre>
|
|
47
|
-
*
|
|
48
|
-
* @class ImageEditor
|
|
49
|
-
* @classdesc Fabric.js-based image editor with simple mask, transform, export and UI features.
|
|
50
115
|
*
|
|
51
116
|
* @param {Object} [options={}] - Customization options to override defaults.
|
|
52
117
|
* @param {number} [options.canvasWidth=800] - The initial canvas width in pixels.
|
|
@@ -57,33 +122,36 @@ function ensureFabric() {
|
|
|
57
122
|
* @param {number} [options.maxScale=5.0] - Maximum image scaling factor.
|
|
58
123
|
* @param {number} [options.scaleStep=0.05] - Scale increment/decrement per step.
|
|
59
124
|
* @param {number} [options.rotationStep=90] - Rotation step in degrees.
|
|
60
|
-
* @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit image
|
|
125
|
+
* @param {boolean} [options.expandCanvasToImage=true] - If true, expands the canvas to fit the loaded image.
|
|
61
126
|
* @param {boolean} [options.fitImageToCanvas=false] - If true, fits loaded image inside canvas.
|
|
62
127
|
* @param {boolean} [options.coverImageToCanvas=false] - If true, scales image to cover the visible canvas viewport.
|
|
63
128
|
* @param {boolean} [options.downsampleOnLoad=true] - Whether to downsample very large images on load.
|
|
64
129
|
* @param {number} [options.downsampleMaxWidth=4000] - Max width for downsampling.
|
|
65
130
|
* @param {number} [options.downsampleMaxHeight=3000] - Max height for downsampling.
|
|
66
131
|
* @param {number} [options.downsampleQuality=0.92] - JPEG quality for downsampling/export.
|
|
132
|
+
* @param {number} [options.imageLoadTimeoutMs=30000] - Timeout for image decode operations.
|
|
67
133
|
* @param {number} [options.exportMultiplier=1] - Scale output image by this multiplier on export.
|
|
68
134
|
* @param {boolean} [options.exportImageAreaByDefault=true] - Export only the image area (clipped to masks).
|
|
69
|
-
* @param {number} [options.defaultMaskWidth=50] - Default width of new
|
|
135
|
+
* @param {number} [options.defaultMaskWidth=50] - Default width of new masks.
|
|
70
136
|
* @param {number} [options.defaultMaskHeight=80] - Default height of new masks.
|
|
71
137
|
* @param {boolean} [options.maskRotatable=false] - If true, masks can be rotated.
|
|
72
138
|
* @param {boolean} [options.maskLabelOnSelect=true] - Show label on selected mask.
|
|
73
139
|
* @param {number} [options.maskLabelOffset=3] - Offset for mask labels from top-left corner.
|
|
74
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.
|
|
75
142
|
* @param {boolean} [options.showPlaceholder=true] - If true, shows placeholder when no image is loaded.
|
|
76
143
|
* @param {string|null} [options.initialImageBase64=null] - Base64 string to auto-load as initial image, if any.
|
|
77
144
|
* @param {string} [options.defaultDownloadFileName='edited_image.jpg'] - Default file name for downloads.
|
|
78
|
-
* @param {
|
|
79
|
-
* @param {
|
|
80
|
-
* @param {
|
|
81
|
-
*
|
|
82
|
-
* @
|
|
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.
|
|
83
151
|
*/
|
|
84
152
|
class ImageEditor {
|
|
85
153
|
constructor(options = {}) {
|
|
86
|
-
// Default options
|
|
154
|
+
// Default options that callers can override with constructor options.
|
|
87
155
|
const defaultLabel = {
|
|
88
156
|
getText: (mask) => mask.maskName,
|
|
89
157
|
textOptions: {
|
|
@@ -128,6 +196,7 @@ function ensureFabric() {
|
|
|
128
196
|
downsampleMaxWidth: 4000,
|
|
129
197
|
downsampleMaxHeight: 3000,
|
|
130
198
|
downsampleQuality: 0.92,
|
|
199
|
+
imageLoadTimeoutMs: 30000,
|
|
131
200
|
|
|
132
201
|
exportMultiplier: 1,
|
|
133
202
|
exportImageAreaByDefault: true,
|
|
@@ -163,19 +232,19 @@ function ensureFabric() {
|
|
|
163
232
|
}
|
|
164
233
|
};
|
|
165
234
|
|
|
166
|
-
// Verify that
|
|
235
|
+
// Verify that Fabric.js is present before any canvas work starts.
|
|
167
236
|
this._fabricLoaded = !!ensureFabric();
|
|
168
237
|
if (!this._fabricLoaded) {
|
|
169
238
|
this._reportError('fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.');
|
|
170
239
|
}
|
|
171
240
|
|
|
172
|
-
// Runtime state
|
|
241
|
+
// Runtime state owned by this editor instance.
|
|
173
242
|
this.canvas = null;
|
|
174
243
|
this.canvasElement = null;
|
|
175
244
|
this.containerElement = null;
|
|
176
245
|
this.placeholderElement = null;
|
|
177
246
|
|
|
178
|
-
this.originalImage = null;
|
|
247
|
+
this.originalImage = null;
|
|
179
248
|
this.baseImageScale = 1;
|
|
180
249
|
this.currentScale = 1;
|
|
181
250
|
this.currentRotation = 0;
|
|
@@ -199,15 +268,19 @@ function ensureFabric() {
|
|
|
199
268
|
this._cropPrevEvented = null;
|
|
200
269
|
this._prevSelectionSetting = undefined;
|
|
201
270
|
this._containerOriginalOverflow = undefined;
|
|
271
|
+
this._scrollbarSizeCache = null;
|
|
202
272
|
|
|
203
273
|
this.onImageLoaded = typeof options.onImageLoaded === 'function' ? options.onImageLoaded : null;
|
|
204
274
|
|
|
205
|
-
this.
|
|
275
|
+
this.animationQueue = new AnimationQueue();
|
|
206
276
|
this.historyManager = new HistoryManager(this.maxHistorySize);
|
|
207
277
|
}
|
|
208
278
|
|
|
209
279
|
/**
|
|
210
|
-
* @
|
|
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.
|
|
211
284
|
*/
|
|
212
285
|
get canvasEl() {
|
|
213
286
|
return this.canvasElement;
|
|
@@ -218,7 +291,10 @@ function ensureFabric() {
|
|
|
218
291
|
}
|
|
219
292
|
|
|
220
293
|
/**
|
|
221
|
-
* @
|
|
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.
|
|
222
298
|
*/
|
|
223
299
|
get containerEl() {
|
|
224
300
|
return this.containerElement;
|
|
@@ -229,7 +305,10 @@ function ensureFabric() {
|
|
|
229
305
|
}
|
|
230
306
|
|
|
231
307
|
/**
|
|
232
|
-
* @
|
|
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.
|
|
233
312
|
*/
|
|
234
313
|
get placeholderEl() {
|
|
235
314
|
return this.placeholderElement;
|
|
@@ -245,9 +324,10 @@ function ensureFabric() {
|
|
|
245
324
|
* Use this method to set up the editor UI before interacting with it.
|
|
246
325
|
*
|
|
247
326
|
* @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
|
|
248
|
-
* Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput,
|
|
249
|
-
* rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn,
|
|
250
|
-
* zoomInBtn, zoomOutBtn, resetBtn,
|
|
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.
|
|
251
331
|
*
|
|
252
332
|
* @returns {void}
|
|
253
333
|
*
|
|
@@ -327,7 +407,9 @@ function ensureFabric() {
|
|
|
327
407
|
}
|
|
328
408
|
|
|
329
409
|
/**
|
|
330
|
-
*
|
|
410
|
+
* Initializes the Fabric canvas, viewport elements, and selection event handlers.
|
|
411
|
+
*
|
|
412
|
+
* @returns {void}
|
|
331
413
|
* @private
|
|
332
414
|
*/
|
|
333
415
|
_initCanvas() {
|
|
@@ -335,7 +417,7 @@ function ensureFabric() {
|
|
|
335
417
|
if (!canvasElement) throw new Error('Canvas is not found: ' + this.elements.canvas);
|
|
336
418
|
this.canvasElement = canvasElement;
|
|
337
419
|
|
|
338
|
-
// Decide which element acts as
|
|
420
|
+
// Decide which element acts as the viewport for size fallback and scrolling.
|
|
339
421
|
if (this.elements.canvasContainer) {
|
|
340
422
|
const containerElement = document.getElementById(this.elements.canvasContainer);
|
|
341
423
|
this.containerElement = containerElement || canvasElement.parentElement;
|
|
@@ -345,7 +427,7 @@ function ensureFabric() {
|
|
|
345
427
|
|
|
346
428
|
this.placeholderElement = document.getElementById(this.elements.imgPlaceholder) || null;
|
|
347
429
|
|
|
348
|
-
//
|
|
430
|
+
// Prefer a measured container size when it is available.
|
|
349
431
|
let initialWidth = this.options.canvasWidth;
|
|
350
432
|
let initialHeight = this.options.canvasHeight;
|
|
351
433
|
if (this.containerElement) {
|
|
@@ -365,7 +447,7 @@ function ensureFabric() {
|
|
|
365
447
|
preserveObjectStacking: true
|
|
366
448
|
});
|
|
367
449
|
|
|
368
|
-
// Fabric event wiring
|
|
450
|
+
// Fabric event wiring keeps selection, mask labels, and history in sync.
|
|
369
451
|
this.canvas.on('selection:created', (event) => this._handleSelectionChanged(event.selected));
|
|
370
452
|
this.canvas.on('selection:updated', (event) => this._handleSelectionChanged(event.selected));
|
|
371
453
|
this.canvas.on('selection:cleared', () => this._handleSelectionChanged([]));
|
|
@@ -374,21 +456,35 @@ function ensureFabric() {
|
|
|
374
456
|
this.canvas.on('object:rotating', (event) => { if (event.target && event.target.maskId) this._syncMaskLabel(event.target); });
|
|
375
457
|
this.canvas.on('object:modified', (event) => this._handleObjectModified(event.target));
|
|
376
458
|
|
|
377
|
-
// Avoid inline-element whitespace
|
|
459
|
+
// Avoid inline-element whitespace artifacts around the canvas.
|
|
378
460
|
this.canvasElement.style.display = 'block';
|
|
379
461
|
}
|
|
380
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
|
+
*/
|
|
381
470
|
_handleObjectModified(target) {
|
|
382
471
|
const masks = this._getModifiedMasks(target);
|
|
383
472
|
if (!masks.length) return;
|
|
384
473
|
masks.forEach(mask => {
|
|
385
474
|
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
386
475
|
this._syncMaskLabel(mask);
|
|
387
|
-
this._expandCanvasToFitObject(mask);
|
|
388
476
|
});
|
|
477
|
+
this._expandCanvasToFitObjects(masks);
|
|
389
478
|
this.saveState();
|
|
390
479
|
}
|
|
391
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
|
+
*/
|
|
392
488
|
_getModifiedMasks(target) {
|
|
393
489
|
if (!target) return [];
|
|
394
490
|
if (target.maskId) return [target];
|
|
@@ -398,23 +494,33 @@ function ensureFabric() {
|
|
|
398
494
|
return Array.isArray(objects) ? objects.filter(object => object && object.maskId) : [];
|
|
399
495
|
}
|
|
400
496
|
|
|
401
|
-
|
|
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 = {}) {
|
|
402
506
|
if (!this.containerElement || !this.containerElement.style) return;
|
|
403
507
|
if (this._containerOriginalOverflow === undefined) {
|
|
404
508
|
this._containerOriginalOverflow = this.containerElement.style.overflow || '';
|
|
405
509
|
}
|
|
406
510
|
|
|
511
|
+
const shouldPreserveScroll = options.preserveScroll === true;
|
|
407
512
|
if (this.options.coverImageToCanvas) {
|
|
408
|
-
const shouldResetScroll = !this.isImageLoadedToCanvas;
|
|
409
513
|
this.containerElement.style.overflow = 'scroll';
|
|
410
|
-
if (
|
|
514
|
+
if (!shouldPreserveScroll) {
|
|
411
515
|
this.containerElement.scrollLeft = 0;
|
|
412
516
|
this.containerElement.scrollTop = 0;
|
|
413
517
|
}
|
|
414
518
|
} else if (this.options.fitImageToCanvas) {
|
|
415
519
|
this.containerElement.style.overflow = 'auto';
|
|
416
|
-
|
|
417
|
-
|
|
520
|
+
if (!shouldPreserveScroll) {
|
|
521
|
+
this.containerElement.scrollLeft = 0;
|
|
522
|
+
this.containerElement.scrollTop = 0;
|
|
523
|
+
}
|
|
418
524
|
} else {
|
|
419
525
|
this.containerElement.style.overflow = this._containerOriginalOverflow;
|
|
420
526
|
}
|
|
@@ -477,12 +583,12 @@ function ensureFabric() {
|
|
|
477
583
|
this._bindIfExists('cancelCropBtn', 'click', () => this.cancelCrop());
|
|
478
584
|
}
|
|
479
585
|
|
|
480
|
-
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* @param {
|
|
484
|
-
* @param {
|
|
485
|
-
* @param {
|
|
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.
|
|
486
592
|
* @private
|
|
487
593
|
*/
|
|
488
594
|
_bindIfExists(key, eventName, handler) {
|
|
@@ -495,10 +601,10 @@ function ensureFabric() {
|
|
|
495
601
|
}
|
|
496
602
|
}
|
|
497
603
|
|
|
498
|
-
/**
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* @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.
|
|
502
608
|
* @private
|
|
503
609
|
*/
|
|
504
610
|
_loadImageFile(file) {
|
|
@@ -510,17 +616,41 @@ function ensureFabric() {
|
|
|
510
616
|
}
|
|
511
617
|
|
|
512
618
|
/**
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
* @
|
|
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
|
|
516
645
|
*/
|
|
517
|
-
async loadImage(imageBase64) {
|
|
646
|
+
async loadImage(imageBase64, options = {}) {
|
|
518
647
|
if (!this._fabricLoaded) return;
|
|
519
648
|
if (!this.canvas) return;
|
|
520
649
|
if (!imageBase64 || typeof imageBase64 !== 'string' || !imageBase64.startsWith('data:image/')) return;
|
|
521
650
|
|
|
651
|
+
this._warnOnImageLayoutOptionConflict();
|
|
522
652
|
this._setPlaceholderVisible(false);
|
|
523
|
-
this._syncContainerOverflow();
|
|
653
|
+
this._syncContainerOverflow({ preserveScroll: options.preserveScroll === true });
|
|
524
654
|
|
|
525
655
|
const imageElement = await this._createImageElement(imageBase64);
|
|
526
656
|
|
|
@@ -561,9 +691,9 @@ function ensureFabric() {
|
|
|
561
691
|
const minHeight = viewport.height;
|
|
562
692
|
|
|
563
693
|
if (this.options.fitImageToCanvas) {
|
|
564
|
-
// Fit into
|
|
565
|
-
const canvasWidth = Math.max(1,
|
|
566
|
-
const canvasHeight = Math.max(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);
|
|
567
697
|
this._setCanvasSizeInt(canvasWidth, canvasHeight);
|
|
568
698
|
const fitScale = Math.min(canvasWidth / imageWidth, canvasHeight / imageHeight, 1);
|
|
569
699
|
fabricImage.set({ left: 0, top: 0 });
|
|
@@ -650,22 +780,32 @@ function ensureFabric() {
|
|
|
650
780
|
* Creates an HTMLImageElement from a given data URL.
|
|
651
781
|
*
|
|
652
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.
|
|
653
784
|
* @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
|
|
654
785
|
* @private
|
|
655
786
|
*/
|
|
656
|
-
_createImageElement(dataUrl) {
|
|
787
|
+
_createImageElement(dataUrl, timeoutMs = this.options.imageLoadTimeoutMs) {
|
|
657
788
|
return new Promise((resolve, reject) => {
|
|
658
789
|
const imageElement = new Image();
|
|
659
|
-
|
|
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);
|
|
660
799
|
imageElement.onload = null;
|
|
661
800
|
imageElement.onerror = null;
|
|
662
|
-
|
|
663
|
-
};
|
|
664
|
-
imageElement.onerror = (error) => {
|
|
665
|
-
imageElement.onload = null;
|
|
666
|
-
imageElement.onerror = null;
|
|
667
|
-
reject(error);
|
|
801
|
+
callback();
|
|
668
802
|
};
|
|
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));
|
|
669
809
|
imageElement.src = dataUrl;
|
|
670
810
|
});
|
|
671
811
|
}
|
|
@@ -685,6 +825,7 @@ function ensureFabric() {
|
|
|
685
825
|
offscreenCanvas.width = targetWidth;
|
|
686
826
|
offscreenCanvas.height = targetHeight;
|
|
687
827
|
const context = offscreenCanvas.getContext('2d');
|
|
828
|
+
if (!context) throw new Error('2D canvas context is unavailable');
|
|
688
829
|
context.drawImage(imageElement, 0, 0, imageElement.naturalWidth, imageElement.naturalHeight, 0, 0, targetWidth, targetHeight);
|
|
689
830
|
return offscreenCanvas.toDataURL('image/jpeg', quality);
|
|
690
831
|
}
|
|
@@ -693,21 +834,21 @@ function ensureFabric() {
|
|
|
693
834
|
* Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
|
|
694
835
|
* Also updates the corresponding style attributes.
|
|
695
836
|
*
|
|
696
|
-
* @param {number}
|
|
697
|
-
* @param {number}
|
|
837
|
+
* @param {number} width - Canvas width in pixels.
|
|
838
|
+
* @param {number} height - Canvas height in pixels.
|
|
698
839
|
* @private
|
|
699
840
|
*/
|
|
700
|
-
_setCanvasSizeInt(
|
|
701
|
-
const
|
|
702
|
-
const
|
|
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));
|
|
703
844
|
// Set fabric internal and also style attributes to keep DOM consistent
|
|
704
|
-
this.canvas.setWidth(
|
|
705
|
-
this.canvas.setHeight(
|
|
845
|
+
this.canvas.setWidth(integerWidth);
|
|
846
|
+
this.canvas.setHeight(integerHeight);
|
|
706
847
|
if (typeof this.canvas.calcOffset === 'function') this.canvas.calcOffset();
|
|
707
848
|
// Keep DOM element in sync (avoid fractional CSS pixels)
|
|
708
849
|
if (this.canvasElement) {
|
|
709
|
-
this.canvasElement.style.width =
|
|
710
|
-
this.canvasElement.style.height =
|
|
850
|
+
this.canvasElement.style.width = integerWidth + 'px';
|
|
851
|
+
this.canvasElement.style.height = integerHeight + 'px';
|
|
711
852
|
this.canvasElement.style.maxWidth = 'none';
|
|
712
853
|
}
|
|
713
854
|
}
|
|
@@ -734,13 +875,9 @@ function ensureFabric() {
|
|
|
734
875
|
};
|
|
735
876
|
}
|
|
736
877
|
|
|
737
|
-
const previousOverflow = this.containerElement.style.overflow;
|
|
738
|
-
this.containerElement.style.overflow = 'hidden';
|
|
739
|
-
|
|
740
878
|
const width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
741
879
|
const height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
742
880
|
|
|
743
|
-
this.containerElement.style.overflow = previousOverflow;
|
|
744
881
|
return { width, height };
|
|
745
882
|
}
|
|
746
883
|
|
|
@@ -765,6 +902,9 @@ function ensureFabric() {
|
|
|
765
902
|
}
|
|
766
903
|
|
|
767
904
|
_getScrollbarSize() {
|
|
905
|
+
if (this._scrollbarSizeCache) {
|
|
906
|
+
return { ...this._scrollbarSizeCache };
|
|
907
|
+
}
|
|
768
908
|
if (typeof document === 'undefined' || !document.createElement || !document.body) {
|
|
769
909
|
return { width: 0, height: 0 };
|
|
770
910
|
}
|
|
@@ -782,7 +922,8 @@ function ensureFabric() {
|
|
|
782
922
|
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
783
923
|
document.body.removeChild(probe);
|
|
784
924
|
|
|
785
|
-
|
|
925
|
+
this._scrollbarSizeCache = { width, height };
|
|
926
|
+
return { ...this._scrollbarSizeCache };
|
|
786
927
|
}
|
|
787
928
|
|
|
788
929
|
_getScrollSafetyMargin() {
|
|
@@ -964,6 +1105,27 @@ function ensureFabric() {
|
|
|
964
1105
|
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
965
1106
|
}
|
|
966
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
|
+
|
|
967
1129
|
_serializeCanvasState() {
|
|
968
1130
|
if (!this.canvas) return null;
|
|
969
1131
|
return this._withNormalizedMaskStyles(() => {
|
|
@@ -971,16 +1133,31 @@ function ensureFabric() {
|
|
|
971
1133
|
if (Array.isArray(jsonObject.objects)) {
|
|
972
1134
|
jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
|
|
973
1135
|
}
|
|
1136
|
+
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
974
1137
|
return JSON.stringify(jsonObject);
|
|
975
1138
|
});
|
|
976
1139
|
}
|
|
977
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
|
+
*/
|
|
978
1148
|
_normalizeQuality(quality) {
|
|
979
1149
|
const numericQuality = Number(quality);
|
|
980
1150
|
if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
|
|
981
1151
|
return Math.max(0, Math.min(1, numericQuality));
|
|
982
1152
|
}
|
|
983
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
|
+
*/
|
|
984
1161
|
_normalizeImageFormat(format) {
|
|
985
1162
|
const typeMapping = {
|
|
986
1163
|
'jpeg': 'jpeg',
|
|
@@ -994,6 +1171,15 @@ function ensureFabric() {
|
|
|
994
1171
|
return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
|
|
995
1172
|
}
|
|
996
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
|
+
*/
|
|
997
1183
|
_getClampedCanvasRegion(bounds, options = {}) {
|
|
998
1184
|
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
999
1185
|
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
@@ -1009,16 +1195,46 @@ function ensureFabric() {
|
|
|
1009
1195
|
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
1010
1196
|
|
|
1011
1197
|
return {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1198
|
+
sourceX,
|
|
1199
|
+
sourceY,
|
|
1200
|
+
sourceWidth: Math.max(1, endX - sourceX),
|
|
1201
|
+
sourceHeight: Math.max(1, endY - sourceY)
|
|
1016
1202
|
};
|
|
1017
1203
|
}
|
|
1018
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
|
+
*/
|
|
1019
1219
|
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
|
|
1020
1220
|
return new Promise((resolve, reject) => {
|
|
1021
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);
|
|
1022
1238
|
imageElement.onload = () => {
|
|
1023
1239
|
try {
|
|
1024
1240
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
@@ -1030,19 +1246,34 @@ function ensureFabric() {
|
|
|
1030
1246
|
offscreenCanvas.width = scaledSourceWidth;
|
|
1031
1247
|
offscreenCanvas.height = scaledSourceHeight;
|
|
1032
1248
|
const context = offscreenCanvas.getContext('2d');
|
|
1249
|
+
if (!context) throw new Error('2D canvas context is unavailable');
|
|
1033
1250
|
|
|
1034
1251
|
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
1035
|
-
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
1252
|
+
settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
|
|
1036
1253
|
} catch (error) {
|
|
1037
|
-
reject(error);
|
|
1254
|
+
settle(() => reject(error));
|
|
1038
1255
|
}
|
|
1039
1256
|
};
|
|
1040
|
-
imageElement.onerror = reject;
|
|
1257
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
1041
1258
|
imageElement.src = dataUrl;
|
|
1042
1259
|
});
|
|
1043
1260
|
}
|
|
1044
1261
|
|
|
1045
|
-
|
|
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' }) {
|
|
1046
1277
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1047
1278
|
const fullDataUrl = this.canvas.toDataURL({
|
|
1048
1279
|
format,
|
|
@@ -1050,7 +1281,7 @@ function ensureFabric() {
|
|
|
1050
1281
|
multiplier: safeMultiplier
|
|
1051
1282
|
});
|
|
1052
1283
|
|
|
1053
|
-
return this._cropDataUrl(fullDataUrl,
|
|
1284
|
+
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
1054
1285
|
}
|
|
1055
1286
|
|
|
1056
1287
|
/**
|
|
@@ -1117,23 +1348,60 @@ function ensureFabric() {
|
|
|
1117
1348
|
this._setCanvasSizeInt(size.width, size.height);
|
|
1118
1349
|
}
|
|
1119
1350
|
|
|
1120
|
-
|
|
1121
|
-
|
|
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;
|
|
1122
1371
|
try {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
+
});
|
|
1127
1381
|
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
1128
1382
|
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
1129
1383
|
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
1130
1384
|
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
1131
|
-
this.
|
|
1385
|
+
if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
|
|
1386
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1387
|
+
}
|
|
1132
1388
|
} catch (error) {
|
|
1133
|
-
this._reportWarning('
|
|
1389
|
+
this._reportWarning('expandCanvasToFitObjects: failed to expand canvas', error);
|
|
1134
1390
|
}
|
|
1135
1391
|
}
|
|
1136
1392
|
|
|
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);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1137
1405
|
/**
|
|
1138
1406
|
* Scales the original image by a given factor, with animation.
|
|
1139
1407
|
* Returns a promise that resolves when the scale animation is complete.
|
|
@@ -1142,7 +1410,7 @@ function ensureFabric() {
|
|
|
1142
1410
|
* @public
|
|
1143
1411
|
*/
|
|
1144
1412
|
scaleImage(factor, options = {}) {
|
|
1145
|
-
return this.
|
|
1413
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
1146
1414
|
}
|
|
1147
1415
|
|
|
1148
1416
|
/**
|
|
@@ -1186,7 +1454,7 @@ function ensureFabric() {
|
|
|
1186
1454
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1187
1455
|
this.originalImage.setCoords();
|
|
1188
1456
|
|
|
1189
|
-
if (this.
|
|
1457
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1190
1458
|
this._updateCanvasSizeToImageBounds();
|
|
1191
1459
|
}
|
|
1192
1460
|
|
|
@@ -1213,7 +1481,7 @@ function ensureFabric() {
|
|
|
1213
1481
|
* @public
|
|
1214
1482
|
*/
|
|
1215
1483
|
rotateImage(degrees, options = {}) {
|
|
1216
|
-
return this.
|
|
1484
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
1217
1485
|
}
|
|
1218
1486
|
|
|
1219
1487
|
/**
|
|
@@ -1247,7 +1515,7 @@ function ensureFabric() {
|
|
|
1247
1515
|
this.originalImage.set('angle', degrees);
|
|
1248
1516
|
this.originalImage.setCoords();
|
|
1249
1517
|
|
|
1250
|
-
if (this.
|
|
1518
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1251
1519
|
this._updateCanvasSizeToImageBounds();
|
|
1252
1520
|
}
|
|
1253
1521
|
|
|
@@ -1271,43 +1539,52 @@ function ensureFabric() {
|
|
|
1271
1539
|
|
|
1272
1540
|
/**
|
|
1273
1541
|
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
1274
|
-
*
|
|
1542
|
+
*
|
|
1543
|
+
* @returns {Promise<void>} Resolves when the reset history transition has been recorded.
|
|
1544
|
+
* @public
|
|
1275
1545
|
*/
|
|
1276
1546
|
resetImageTransform() {
|
|
1277
1547
|
if (!this.originalImage) return Promise.resolve();
|
|
1278
1548
|
|
|
1279
|
-
return this.
|
|
1280
|
-
const before = this._serializeCanvasState();
|
|
1549
|
+
return this.animationQueue.add(async () => {
|
|
1550
|
+
const before = this._lastSnapshot || this._serializeCanvasState();
|
|
1281
1551
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1282
1552
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1283
1553
|
const after = this._serializeCanvasState();
|
|
1284
1554
|
this._pushStateTransition(before, after);
|
|
1285
|
-
}).catch(
|
|
1286
|
-
this._reportError('resetImageTransform() failed',
|
|
1555
|
+
}).catch(error => {
|
|
1556
|
+
this._reportError('resetImageTransform() failed', error);
|
|
1287
1557
|
});
|
|
1288
1558
|
}
|
|
1289
1559
|
|
|
1290
1560
|
/**
|
|
1291
|
-
* @
|
|
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.
|
|
1292
1565
|
*/
|
|
1293
1566
|
reset() {
|
|
1294
1567
|
return this.resetImageTransform();
|
|
1295
1568
|
}
|
|
1296
1569
|
|
|
1297
1570
|
/**
|
|
1298
|
-
* Restores a canvas state
|
|
1299
|
-
*
|
|
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
|
|
1300
1576
|
*/
|
|
1301
|
-
loadFromState(
|
|
1302
|
-
if (!
|
|
1577
|
+
loadFromState(serializedState) {
|
|
1578
|
+
if (!serializedState || !this.canvas) return Promise.resolve();
|
|
1303
1579
|
|
|
1304
1580
|
return new Promise((resolve) => {
|
|
1305
1581
|
try {
|
|
1306
|
-
const
|
|
1307
|
-
? JSON.parse(
|
|
1308
|
-
:
|
|
1582
|
+
const state = (typeof serializedState === 'string')
|
|
1583
|
+
? JSON.parse(serializedState)
|
|
1584
|
+
: serializedState;
|
|
1585
|
+
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1309
1586
|
|
|
1310
|
-
this.canvas.loadFromJSON(
|
|
1587
|
+
this.canvas.loadFromJSON(state, () => {
|
|
1311
1588
|
try {
|
|
1312
1589
|
this._hideAllMaskLabels();
|
|
1313
1590
|
const canvasObjects = this.canvas.getObjects();
|
|
@@ -1316,11 +1593,27 @@ function ensureFabric() {
|
|
|
1316
1593
|
if (this.originalImage) {
|
|
1317
1594
|
this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
|
|
1318
1595
|
this.canvas.sendToBack(this.originalImage);
|
|
1319
|
-
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
1322
|
-
|
|
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);
|
|
1323
1615
|
} else {
|
|
1616
|
+
this.baseImageScale = 1;
|
|
1324
1617
|
this.currentScale = 1;
|
|
1325
1618
|
this.currentRotation = 0;
|
|
1326
1619
|
}
|
|
@@ -1331,8 +1624,12 @@ function ensureFabric() {
|
|
|
1331
1624
|
this._rebindMaskEvents(mask);
|
|
1332
1625
|
mask.set(this._getMaskNormalStyle(mask));
|
|
1333
1626
|
});
|
|
1334
|
-
|
|
1627
|
+
const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
|
|
1628
|
+
const maxMaskId = masks.reduce((max, mask) =>
|
|
1335
1629
|
Math.max(max, mask.maskId), 0);
|
|
1630
|
+
this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId
|
|
1631
|
+
? Math.floor(restoredMaskCounter)
|
|
1632
|
+
: maxMaskId;
|
|
1336
1633
|
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1337
1634
|
if (!this._lastMask) {
|
|
1338
1635
|
this._lastMaskInitialLeft = null;
|
|
@@ -1362,12 +1659,17 @@ function ensureFabric() {
|
|
|
1362
1659
|
}
|
|
1363
1660
|
|
|
1364
1661
|
/**
|
|
1365
|
-
* Saves the current
|
|
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
|
|
1366
1669
|
*/
|
|
1367
1670
|
saveState() {
|
|
1368
1671
|
if (!this.canvas) return;
|
|
1369
1672
|
const activeObject = this.canvas.getActiveObject();
|
|
1370
|
-
this._hideAllMaskLabels();
|
|
1371
1673
|
|
|
1372
1674
|
try {
|
|
1373
1675
|
const after = this._serializeCanvasState();
|
|
@@ -1391,13 +1693,24 @@ function ensureFabric() {
|
|
|
1391
1693
|
} catch (error) {
|
|
1392
1694
|
this._reportWarning('saveState: failed to save canvas snapshot', error);
|
|
1393
1695
|
} finally {
|
|
1394
|
-
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1696
|
+
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1395
1697
|
this._handleSelectionChanged([activeObject]);
|
|
1396
1698
|
}
|
|
1397
1699
|
this._updateUI();
|
|
1398
1700
|
}
|
|
1399
1701
|
}
|
|
1400
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
|
+
*/
|
|
1401
1714
|
_pushStateTransition(before, after) {
|
|
1402
1715
|
if (!before || !after) return;
|
|
1403
1716
|
if (before === after) return;
|
|
@@ -1414,6 +1727,9 @@ function ensureFabric() {
|
|
|
1414
1727
|
|
|
1415
1728
|
/**
|
|
1416
1729
|
* Undo the last state change, if possible.
|
|
1730
|
+
*
|
|
1731
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
|
|
1732
|
+
* @public
|
|
1417
1733
|
*/
|
|
1418
1734
|
undo() {
|
|
1419
1735
|
return this.historyManager.undo()
|
|
@@ -1423,6 +1739,9 @@ function ensureFabric() {
|
|
|
1423
1739
|
|
|
1424
1740
|
/**
|
|
1425
1741
|
* Redo the next state change, if possible.
|
|
1742
|
+
*
|
|
1743
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
|
|
1744
|
+
* @public
|
|
1426
1745
|
*/
|
|
1427
1746
|
redo() {
|
|
1428
1747
|
return this.historyManager.redo()
|
|
@@ -1436,7 +1755,7 @@ function ensureFabric() {
|
|
|
1436
1755
|
try {
|
|
1437
1756
|
mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
|
|
1438
1757
|
mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
|
|
1439
|
-
} catch (
|
|
1758
|
+
} catch (error) { void error; }
|
|
1440
1759
|
}
|
|
1441
1760
|
|
|
1442
1761
|
const metadata = {};
|
|
@@ -1474,29 +1793,38 @@ function ensureFabric() {
|
|
|
1474
1793
|
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
1475
1794
|
}
|
|
1476
1795
|
|
|
1477
|
-
/**
|
|
1796
|
+
/**
|
|
1478
1797
|
* Creates a mask and adds it to the canvas.
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
* @
|
|
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.
|
|
1494
1822
|
* @public
|
|
1495
1823
|
*/
|
|
1496
1824
|
createMask(config = {}) {
|
|
1497
1825
|
if (!this.canvas) return null;
|
|
1498
1826
|
const shapeType = config.shape || 'rect';
|
|
1499
|
-
//
|
|
1827
|
+
// Normalize mask defaults before applying caller-provided overrides.
|
|
1500
1828
|
const maskConfig = {
|
|
1501
1829
|
shape: shapeType,
|
|
1502
1830
|
width: this.options.defaultMaskWidth,
|
|
@@ -1544,6 +1872,8 @@ function ensureFabric() {
|
|
|
1544
1872
|
|
|
1545
1873
|
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1546
1874
|
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1875
|
+
maskConfig.left = left;
|
|
1876
|
+
maskConfig.top = top;
|
|
1547
1877
|
|
|
1548
1878
|
let mask;
|
|
1549
1879
|
if (typeof maskConfig.fabricGenerator === 'function') {
|
|
@@ -1573,9 +1903,11 @@ function ensureFabric() {
|
|
|
1573
1903
|
break;
|
|
1574
1904
|
case 'polygon': {
|
|
1575
1905
|
let polygonPoints = maskConfig.points || [];
|
|
1576
|
-
if (Array.isArray(polygonPoints) && polygonPoints.length
|
|
1577
|
-
// Ensure numeric {x,y} objects for fabric.Polygon
|
|
1578
|
-
polygonPoints = polygonPoints.map(point =>
|
|
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) });
|
|
1579
1911
|
}
|
|
1580
1912
|
mask = new fabric.Polygon(polygonPoints, {
|
|
1581
1913
|
left, top,
|
|
@@ -1595,7 +1927,7 @@ function ensureFabric() {
|
|
|
1595
1927
|
fill: maskConfig.color,
|
|
1596
1928
|
opacity: maskConfig.alpha,
|
|
1597
1929
|
angle: maskConfig.angle,
|
|
1598
|
-
rx: maskConfig.rx,
|
|
1930
|
+
rx: maskConfig.rx,
|
|
1599
1931
|
ry: maskConfig.ry,
|
|
1600
1932
|
...maskConfig.styles
|
|
1601
1933
|
});
|
|
@@ -1614,6 +1946,7 @@ function ensureFabric() {
|
|
|
1614
1946
|
transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
|
|
1615
1947
|
stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
|
|
1616
1948
|
strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
|
|
1949
|
+
opacity: hasStyle('opacity') ? styles.opacity : maskConfig.alpha,
|
|
1617
1950
|
strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
|
|
1618
1951
|
};
|
|
1619
1952
|
if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
@@ -1621,14 +1954,14 @@ function ensureFabric() {
|
|
|
1621
1954
|
mask.setCoords();
|
|
1622
1955
|
|
|
1623
1956
|
mask.set({
|
|
1624
|
-
originalAlpha: maskConfig.alpha,
|
|
1957
|
+
originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
|
|
1625
1958
|
originalStroke: mask.stroke || '#ccc',
|
|
1626
1959
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
1627
1960
|
});
|
|
1628
1961
|
this._rebindMaskEvents(mask);
|
|
1629
1962
|
this._expandCanvasToFitObject(mask);
|
|
1630
1963
|
|
|
1631
|
-
//
|
|
1964
|
+
// Store placement values so the next mask can be positioned beside this one.
|
|
1632
1965
|
this._lastMaskInitialLeft = left;
|
|
1633
1966
|
this._lastMaskInitialTop = top;
|
|
1634
1967
|
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
@@ -1654,7 +1987,11 @@ function ensureFabric() {
|
|
|
1654
1987
|
}
|
|
1655
1988
|
|
|
1656
1989
|
/**
|
|
1657
|
-
* @
|
|
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.
|
|
1658
1995
|
*/
|
|
1659
1996
|
addMask(config = {}) {
|
|
1660
1997
|
return this.createMask(config);
|
|
@@ -1727,6 +2064,24 @@ function ensureFabric() {
|
|
|
1727
2064
|
}
|
|
1728
2065
|
}
|
|
1729
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
|
+
|
|
1730
2085
|
/**
|
|
1731
2086
|
* Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
|
|
1732
2087
|
* The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
|
|
@@ -1757,9 +2112,7 @@ function ensureFabric() {
|
|
|
1757
2112
|
};
|
|
1758
2113
|
if (this.options.label) {
|
|
1759
2114
|
if (typeof this.options.label.getText === 'function') {
|
|
1760
|
-
|
|
1761
|
-
const maskIndex = Math.max(0, masks.indexOf(mask));
|
|
1762
|
-
labelText = this.options.label.getText(mask, maskIndex);
|
|
2115
|
+
labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
|
|
1763
2116
|
}
|
|
1764
2117
|
// Merge external styles
|
|
1765
2118
|
if (this.options.label.textOptions) {
|
|
@@ -1924,10 +2277,14 @@ function ensureFabric() {
|
|
|
1924
2277
|
}
|
|
1925
2278
|
|
|
1926
2279
|
/**
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
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
|
+
*
|
|
1929
2285
|
* @async
|
|
1930
|
-
* @returns {Promise<void>} Resolves when
|
|
2286
|
+
* @returns {Promise<void>} Resolves when the flattened image has been loaded.
|
|
2287
|
+
* @public
|
|
1931
2288
|
*/
|
|
1932
2289
|
async mergeMasks() {
|
|
1933
2290
|
if (!this.originalImage) return;
|
|
@@ -1941,53 +2298,62 @@ function ensureFabric() {
|
|
|
1941
2298
|
const beforeJson = this._serializeCanvasState();
|
|
1942
2299
|
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1943
2300
|
this.removeAllMasks({ saveHistory: false });
|
|
1944
|
-
await this.loadImage(merged);
|
|
2301
|
+
await this.loadImage(merged, { preserveScroll: true });
|
|
1945
2302
|
const afterJson = this._serializeCanvasState();
|
|
1946
2303
|
this._pushStateTransition(beforeJson, afterJson);
|
|
1947
|
-
} catch (
|
|
1948
|
-
this._reportError('merge error',
|
|
2304
|
+
} catch (error) {
|
|
2305
|
+
this._reportError('merge error', error);
|
|
1949
2306
|
}
|
|
1950
2307
|
}
|
|
1951
2308
|
|
|
1952
2309
|
/**
|
|
1953
|
-
* @
|
|
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.
|
|
1954
2314
|
*/
|
|
1955
2315
|
async merge() {
|
|
1956
2316
|
return this.mergeMasks();
|
|
1957
2317
|
}
|
|
1958
2318
|
|
|
1959
2319
|
/**
|
|
1960
|
-
* Triggers a JPEG image download of the current canvas
|
|
2320
|
+
* Triggers a JPEG image download of the current canvas.
|
|
2321
|
+
*
|
|
1961
2322
|
* The image area and multiplier are controlled by options.
|
|
1962
2323
|
* @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
|
|
2324
|
+
* @returns {void}
|
|
2325
|
+
* @public
|
|
1963
2326
|
*/
|
|
1964
2327
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1965
2328
|
if (!this.originalImage) return;
|
|
1966
2329
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1967
2330
|
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
|
|
1968
|
-
.then(
|
|
2331
|
+
.then(imageBase64 => {
|
|
1969
2332
|
const link = document.createElement('a');
|
|
1970
2333
|
link.download = fileName;
|
|
1971
|
-
link.href =
|
|
2334
|
+
link.href = imageBase64;
|
|
1972
2335
|
document.body.appendChild(link);
|
|
1973
2336
|
link.click();
|
|
1974
2337
|
document.body.removeChild(link);
|
|
1975
2338
|
})
|
|
1976
|
-
.catch(
|
|
2339
|
+
.catch(error => this._reportError('download error', error));
|
|
1977
2340
|
}
|
|
1978
2341
|
|
|
1979
2342
|
/**
|
|
1980
|
-
* Exports the image as a Base64-encoded
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
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
|
+
*
|
|
1983
2348
|
* @async
|
|
1984
2349
|
* @param {Object} [options={}] - Export options.
|
|
1985
2350
|
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1986
2351
|
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1987
2352
|
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1988
2353
|
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1989
|
-
* @returns {Promise<string>}
|
|
2354
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1990
2355
|
* @throws {Error} If there is no image loaded.
|
|
2356
|
+
* @public
|
|
1991
2357
|
*/
|
|
1992
2358
|
async exportImageBase64(options = {}) {
|
|
1993
2359
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
@@ -2007,12 +2373,9 @@ function ensureFabric() {
|
|
|
2007
2373
|
|
|
2008
2374
|
this.originalImage.setCoords();
|
|
2009
2375
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2010
|
-
const
|
|
2376
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2011
2377
|
return await this._exportCanvasRegionToDataURL({
|
|
2012
|
-
|
|
2013
|
-
sy,
|
|
2014
|
-
sw,
|
|
2015
|
-
sh,
|
|
2378
|
+
...exportRegion,
|
|
2016
2379
|
multiplier,
|
|
2017
2380
|
quality,
|
|
2018
2381
|
format
|
|
@@ -2025,7 +2388,7 @@ function ensureFabric() {
|
|
|
2025
2388
|
}
|
|
2026
2389
|
}
|
|
2027
2390
|
|
|
2028
|
-
//
|
|
2391
|
+
// Render masks as export shapes without mutating their editable styles.
|
|
2029
2392
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2030
2393
|
const maskStyleBackups = masks.map(mask => ({
|
|
2031
2394
|
object: mask,
|
|
@@ -2039,29 +2402,26 @@ function ensureFabric() {
|
|
|
2039
2402
|
|
|
2040
2403
|
let finalBase64;
|
|
2041
2404
|
try {
|
|
2042
|
-
//
|
|
2405
|
+
// Labels are UI overlays and should not be part of the flattened export.
|
|
2043
2406
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
2044
2407
|
this.canvas.discardActiveObject();
|
|
2045
2408
|
this.canvas.renderAll();
|
|
2046
2409
|
|
|
2047
|
-
//
|
|
2410
|
+
// The export treats masks as opaque shapes with no editable border.
|
|
2048
2411
|
masks.forEach(mask => {
|
|
2049
2412
|
mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
|
|
2050
2413
|
mask.setCoords();
|
|
2051
2414
|
});
|
|
2052
2415
|
this.canvas.renderAll();
|
|
2053
2416
|
|
|
2054
|
-
// Compute integer
|
|
2417
|
+
// Compute an integer canvas region for the base image.
|
|
2055
2418
|
this.originalImage.setCoords();
|
|
2056
2419
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2057
|
-
const
|
|
2420
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2058
2421
|
|
|
2059
2422
|
// Crop precisely in offscreen canvas
|
|
2060
2423
|
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
2061
|
-
|
|
2062
|
-
sy,
|
|
2063
|
-
sw,
|
|
2064
|
-
sh,
|
|
2424
|
+
...exportRegion,
|
|
2065
2425
|
multiplier,
|
|
2066
2426
|
quality,
|
|
2067
2427
|
format
|
|
@@ -2088,15 +2448,21 @@ function ensureFabric() {
|
|
|
2088
2448
|
}
|
|
2089
2449
|
|
|
2090
2450
|
/**
|
|
2091
|
-
* @
|
|
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.
|
|
2092
2456
|
*/
|
|
2093
2457
|
async getImageBase64(options = {}) {
|
|
2094
2458
|
return this.exportImageBase64(options);
|
|
2095
2459
|
}
|
|
2096
2460
|
|
|
2097
2461
|
/**
|
|
2098
|
-
* Exports the current
|
|
2099
|
-
*
|
|
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.
|
|
2100
2466
|
*
|
|
2101
2467
|
* @async
|
|
2102
2468
|
* @param {Object} [options={}] - Export options.
|
|
@@ -2122,17 +2488,17 @@ function ensureFabric() {
|
|
|
2122
2488
|
|
|
2123
2489
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
2124
2490
|
|
|
2125
|
-
//
|
|
2126
|
-
let
|
|
2491
|
+
// Generate the data URL in the requested export mode.
|
|
2492
|
+
let imageBase64;
|
|
2127
2493
|
if (mergeMask) {
|
|
2128
|
-
|
|
2494
|
+
imageBase64 = await this.exportImageBase64({
|
|
2129
2495
|
exportImageArea: true,
|
|
2130
2496
|
multiplier,
|
|
2131
2497
|
quality,
|
|
2132
2498
|
fileType: safeFileType
|
|
2133
2499
|
});
|
|
2134
2500
|
} else {
|
|
2135
|
-
|
|
2501
|
+
imageBase64 = await this.exportImageBase64({
|
|
2136
2502
|
exportImageArea: false,
|
|
2137
2503
|
multiplier,
|
|
2138
2504
|
quality,
|
|
@@ -2141,9 +2507,9 @@ function ensureFabric() {
|
|
|
2141
2507
|
}
|
|
2142
2508
|
|
|
2143
2509
|
// Convert to the required image format
|
|
2144
|
-
let imageDataUrl =
|
|
2510
|
+
let imageDataUrl = imageBase64;
|
|
2145
2511
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
2146
|
-
// Redraw
|
|
2512
|
+
// Redraw the exported data URL when the browser returned a different image format.
|
|
2147
2513
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
2148
2514
|
const imageElement = new window.Image();
|
|
2149
2515
|
imageElement.crossOrigin = "Anonymous";
|
|
@@ -2159,11 +2525,11 @@ function ensureFabric() {
|
|
|
2159
2525
|
} catch (error) { reject(error); }
|
|
2160
2526
|
};
|
|
2161
2527
|
imageElement.onerror = reject;
|
|
2162
|
-
imageElement.src =
|
|
2528
|
+
imageElement.src = imageBase64;
|
|
2163
2529
|
});
|
|
2164
2530
|
}
|
|
2165
2531
|
|
|
2166
|
-
// Convert
|
|
2532
|
+
// Convert the final data URL to a File with the requested MIME type.
|
|
2167
2533
|
const binaryString = atob(imageDataUrl.split(',')[1]);
|
|
2168
2534
|
const mime = `image/${safeFileType}`;
|
|
2169
2535
|
let byteIndex = binaryString.length;
|
|
@@ -2237,7 +2603,12 @@ function ensureFabric() {
|
|
|
2237
2603
|
}
|
|
2238
2604
|
|
|
2239
2605
|
/**
|
|
2240
|
-
*
|
|
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}
|
|
2241
2612
|
* @public
|
|
2242
2613
|
*/
|
|
2243
2614
|
enterCropMode() {
|
|
@@ -2245,24 +2616,30 @@ function ensureFabric() {
|
|
|
2245
2616
|
if (!this.isImageLoaded()) return;
|
|
2246
2617
|
this._cropMode = true;
|
|
2247
2618
|
|
|
2248
|
-
// Disable
|
|
2619
|
+
// Disable group selection so only the crop rectangle can be manipulated.
|
|
2249
2620
|
this._prevSelectionSetting = this.canvas.selection;
|
|
2250
2621
|
this.canvas.selection = false;
|
|
2251
2622
|
|
|
2252
|
-
//
|
|
2623
|
+
// Clear the current selection before activating the crop rectangle.
|
|
2253
2624
|
this.canvas.discardActiveObject();
|
|
2254
2625
|
|
|
2255
|
-
// Create initial crop
|
|
2626
|
+
// Create the initial crop rectangle inside the image bounds.
|
|
2256
2627
|
this.originalImage.setCoords();
|
|
2257
2628
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2258
|
-
//
|
|
2629
|
+
// Use a small inset so the user can see the crop boundary.
|
|
2259
2630
|
const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
|
|
2260
2631
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2261
2632
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
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.
|
|
2266
2643
|
const cropRect = new fabric.Rect({
|
|
2267
2644
|
left, top,
|
|
2268
2645
|
width, height,
|
|
@@ -2277,7 +2654,8 @@ function ensureFabric() {
|
|
|
2277
2654
|
cornerSize: 8,
|
|
2278
2655
|
objectCaching: false,
|
|
2279
2656
|
originX: 'left',
|
|
2280
|
-
originY: 'top'
|
|
2657
|
+
originY: 'top',
|
|
2658
|
+
lockScalingFlip: true
|
|
2281
2659
|
});
|
|
2282
2660
|
|
|
2283
2661
|
// Ensure the crop rect is above everything
|
|
@@ -2286,11 +2664,10 @@ function ensureFabric() {
|
|
|
2286
2664
|
this.canvas.bringToFront(cropRect);
|
|
2287
2665
|
this.canvas.setActiveObject(cropRect);
|
|
2288
2666
|
|
|
2289
|
-
//
|
|
2667
|
+
// Store the crop rectangle so apply/cancel can clean it up.
|
|
2290
2668
|
this._cropRect = cropRect;
|
|
2291
2669
|
|
|
2292
|
-
//
|
|
2293
|
-
// 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.
|
|
2294
2671
|
this._cropPrevEvented = [];
|
|
2295
2672
|
const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
|
|
2296
2673
|
this.canvas.getObjects().forEach(object => {
|
|
@@ -2307,13 +2684,23 @@ function ensureFabric() {
|
|
|
2307
2684
|
}
|
|
2308
2685
|
});
|
|
2309
2686
|
|
|
2310
|
-
//
|
|
2311
|
-
const handleCropRectModified = () => {
|
|
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
|
+
};
|
|
2312
2699
|
cropRect.on('modified', handleCropRectModified);
|
|
2313
2700
|
cropRect.on('moving', handleCropRectModified);
|
|
2314
2701
|
cropRect.on('scaling', handleCropRectModified);
|
|
2315
2702
|
|
|
2316
|
-
//
|
|
2703
|
+
// Store handlers so cancel/apply/dispose can unbind them.
|
|
2317
2704
|
this._cropHandlers.push({
|
|
2318
2705
|
target: cropRect,
|
|
2319
2706
|
handlers: [
|
|
@@ -2328,7 +2715,9 @@ function ensureFabric() {
|
|
|
2328
2715
|
}
|
|
2329
2716
|
|
|
2330
2717
|
/**
|
|
2331
|
-
*
|
|
2718
|
+
* Cancels crop mode and removes the temporary crop rectangle.
|
|
2719
|
+
*
|
|
2720
|
+
* @returns {void}
|
|
2332
2721
|
* @public
|
|
2333
2722
|
*/
|
|
2334
2723
|
cancelCrop() {
|
|
@@ -2336,7 +2725,7 @@ function ensureFabric() {
|
|
|
2336
2725
|
this._removeCropRect();
|
|
2337
2726
|
this._restoreCropObjectState();
|
|
2338
2727
|
this._cropMode = false;
|
|
2339
|
-
//
|
|
2728
|
+
// Restore the canvas selection setting that was active before crop mode.
|
|
2340
2729
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2341
2730
|
this._prevSelectionSetting = undefined;
|
|
2342
2731
|
|
|
@@ -2346,18 +2735,24 @@ function ensureFabric() {
|
|
|
2346
2735
|
}
|
|
2347
2736
|
|
|
2348
2737
|
/**
|
|
2349
|
-
*
|
|
2350
|
-
*
|
|
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.
|
|
2351
2746
|
* @public
|
|
2352
2747
|
*/
|
|
2353
2748
|
async applyCrop() {
|
|
2354
2749
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
2355
2750
|
|
|
2356
|
-
//
|
|
2751
|
+
// Fabric does not update control coordinates automatically after programmatic transforms.
|
|
2357
2752
|
this._cropRect.setCoords();
|
|
2358
2753
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
2359
2754
|
|
|
2360
|
-
const
|
|
2755
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds);
|
|
2361
2756
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2362
2757
|
|
|
2363
2758
|
this._restoreCropObjectState();
|
|
@@ -2380,16 +2775,16 @@ function ensureFabric() {
|
|
|
2380
2775
|
mask.setCoords();
|
|
2381
2776
|
const maskBounds = mask.getBoundingRect(true, true);
|
|
2382
2777
|
const intersectsCrop =
|
|
2383
|
-
maskBounds.left <
|
|
2384
|
-
maskBounds.left + maskBounds.width >
|
|
2385
|
-
maskBounds.top <
|
|
2386
|
-
maskBounds.top + maskBounds.height >
|
|
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;
|
|
2387
2782
|
this._removeLabelForMask(mask);
|
|
2388
2783
|
this.canvas.remove(mask);
|
|
2389
2784
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2390
2785
|
mask.set({
|
|
2391
|
-
left: (mask.left || 0) -
|
|
2392
|
-
top: (mask.top || 0) -
|
|
2786
|
+
left: (mask.left || 0) - cropRegion.sourceX,
|
|
2787
|
+
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2393
2788
|
visible: true
|
|
2394
2789
|
});
|
|
2395
2790
|
mask.setCoords();
|
|
@@ -2409,19 +2804,16 @@ function ensureFabric() {
|
|
|
2409
2804
|
|
|
2410
2805
|
this._removeCropRect();
|
|
2411
2806
|
|
|
2412
|
-
// End crop mode
|
|
2807
|
+
// End crop mode before loading the cropped image.
|
|
2413
2808
|
this._cropMode = false;
|
|
2414
2809
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2415
2810
|
this._prevSelectionSetting = undefined;
|
|
2416
2811
|
|
|
2417
|
-
// Export
|
|
2812
|
+
// Export the crop region from the current canvas.
|
|
2418
2813
|
let croppedBase64;
|
|
2419
2814
|
try {
|
|
2420
2815
|
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2421
|
-
|
|
2422
|
-
sy,
|
|
2423
|
-
sw,
|
|
2424
|
-
sh,
|
|
2816
|
+
...cropRegion,
|
|
2425
2817
|
multiplier: 1,
|
|
2426
2818
|
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2427
2819
|
format: 'jpeg'
|
|
@@ -2431,7 +2823,7 @@ function ensureFabric() {
|
|
|
2431
2823
|
return;
|
|
2432
2824
|
}
|
|
2433
2825
|
|
|
2434
|
-
// Load the cropped image as the new base image
|
|
2826
|
+
// Load the cropped image as the new base image.
|
|
2435
2827
|
try {
|
|
2436
2828
|
await this.loadImage(croppedBase64);
|
|
2437
2829
|
if (preservedMasks.length) {
|
|
@@ -2445,27 +2837,27 @@ function ensureFabric() {
|
|
|
2445
2837
|
this._updateMaskList();
|
|
2446
2838
|
this.canvas.renderAll();
|
|
2447
2839
|
}
|
|
2448
|
-
} catch (
|
|
2449
|
-
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed',
|
|
2840
|
+
} catch (error) {
|
|
2841
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
|
|
2450
2842
|
return;
|
|
2451
2843
|
}
|
|
2452
2844
|
|
|
2453
|
-
// Create
|
|
2845
|
+
// Create an after snapshot and push one history command for the crop operation.
|
|
2454
2846
|
let afterJson = null;
|
|
2455
2847
|
try {
|
|
2456
2848
|
afterJson = this._serializeCanvasState();
|
|
2457
|
-
} catch (
|
|
2458
|
-
this._reportWarning('applyCrop: failed to serialize after state',
|
|
2849
|
+
} catch (error) {
|
|
2850
|
+
this._reportWarning('applyCrop: failed to serialize after state', error);
|
|
2459
2851
|
afterJson = null;
|
|
2460
2852
|
}
|
|
2461
2853
|
|
|
2462
2854
|
try {
|
|
2463
2855
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2464
|
-
} catch (
|
|
2465
|
-
this._reportWarning('applyCrop: failed to push history command',
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
this._reportWarning('applyCrop: failed to push history command', error);
|
|
2466
2858
|
}
|
|
2467
2859
|
|
|
2468
|
-
//
|
|
2860
|
+
// Refresh UI state after crop completion.
|
|
2469
2861
|
this._updateUI();
|
|
2470
2862
|
this.canvas.renderAll();
|
|
2471
2863
|
}
|
|
@@ -2500,7 +2892,7 @@ function ensureFabric() {
|
|
|
2500
2892
|
const isInCropMode = !!this._cropMode;
|
|
2501
2893
|
|
|
2502
2894
|
if (isInCropMode) {
|
|
2503
|
-
//
|
|
2895
|
+
// Disable all controls except the crop action buttons while crop mode is active.
|
|
2504
2896
|
for (const key of Object.keys(this.elements || {})) {
|
|
2505
2897
|
const element = document.getElementById(this.elements[key]);
|
|
2506
2898
|
if (!element) continue;
|
|
@@ -2563,7 +2955,7 @@ function ensureFabric() {
|
|
|
2563
2955
|
}
|
|
2564
2956
|
|
|
2565
2957
|
/**
|
|
2566
|
-
*
|
|
2958
|
+
* Updates placeholder and canvas container visibility based on whether an image is loaded.
|
|
2567
2959
|
* @private
|
|
2568
2960
|
*/
|
|
2569
2961
|
_updatePlaceholderStatus() {
|
|
@@ -2572,12 +2964,13 @@ function ensureFabric() {
|
|
|
2572
2964
|
}
|
|
2573
2965
|
|
|
2574
2966
|
/**
|
|
2575
|
-
*
|
|
2576
|
-
*
|
|
2967
|
+
* Shows or hides the placeholder and canvas container.
|
|
2968
|
+
*
|
|
2969
|
+
* @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
|
|
2577
2970
|
* @private
|
|
2578
2971
|
*/
|
|
2579
2972
|
_setPlaceholderVisible(show) {
|
|
2580
|
-
if (!this.placeholderElement) return;
|
|
2973
|
+
if (!this.placeholderElement || !this.containerElement) return;
|
|
2581
2974
|
if (show) {
|
|
2582
2975
|
this.placeholderElement.classList.remove('d-none');
|
|
2583
2976
|
this.placeholderElement.classList.add('d-flex');
|
|
@@ -2608,16 +3001,16 @@ function ensureFabric() {
|
|
|
2608
3001
|
} catch (error) { void error; }
|
|
2609
3002
|
|
|
2610
3003
|
if (this._cropRect) {
|
|
2611
|
-
try { this.canvas.remove(this._cropRect); } catch (
|
|
3004
|
+
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
2612
3005
|
this._cropRect = null;
|
|
2613
3006
|
}
|
|
2614
3007
|
|
|
2615
3008
|
if (this.containerElement && this._containerOriginalOverflow !== undefined) {
|
|
2616
|
-
try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (
|
|
3009
|
+
try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
|
|
2617
3010
|
}
|
|
2618
3011
|
|
|
2619
3012
|
if (this.canvas) {
|
|
2620
|
-
try { this.canvas.dispose(); } catch (
|
|
3013
|
+
try { this.canvas.dispose(); } catch (error) { void error; }
|
|
2621
3014
|
this.canvas = null;
|
|
2622
3015
|
this.canvasElement = null;
|
|
2623
3016
|
this.isImageLoadedToCanvas = false;
|
|
@@ -2627,113 +3020,160 @@ function ensureFabric() {
|
|
|
2627
3020
|
}
|
|
2628
3021
|
|
|
2629
3022
|
/**
|
|
2630
|
-
*
|
|
2631
|
-
* @
|
|
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
|
|
2632
3055
|
*/
|
|
2633
3056
|
class AnimationQueue {
|
|
2634
3057
|
/**
|
|
2635
|
-
* Creates
|
|
2636
|
-
*
|
|
2637
|
-
* @constructor
|
|
3058
|
+
* Creates an empty animation queue.
|
|
2638
3059
|
*/
|
|
2639
3060
|
constructor() {
|
|
2640
3061
|
/**
|
|
2641
|
-
*
|
|
2642
|
-
* @type {Array<
|
|
3062
|
+
* Pending animation descriptors.
|
|
3063
|
+
* @type {Array<QueuedAnimationTask>}
|
|
2643
3064
|
*/
|
|
2644
|
-
this.
|
|
3065
|
+
this.animationTasks = [];
|
|
2645
3066
|
/**
|
|
2646
|
-
*
|
|
3067
|
+
* Whether an animation task is currently running.
|
|
2647
3068
|
* @type {boolean}
|
|
2648
3069
|
*/
|
|
2649
|
-
this.
|
|
3070
|
+
this.isRunning = false;
|
|
2650
3071
|
}
|
|
2651
3072
|
|
|
2652
3073
|
/**
|
|
2653
3074
|
* Adds an animation function to the queue.
|
|
2654
3075
|
*
|
|
2655
|
-
* @param
|
|
2656
|
-
* @returns {Promise
|
|
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.
|
|
2657
3078
|
*/
|
|
2658
3079
|
async add(animationFn) {
|
|
2659
3080
|
return new Promise((resolve, reject) => {
|
|
2660
|
-
|
|
2661
|
-
this.
|
|
2662
|
-
|
|
2663
|
-
if (!this.running) {
|
|
2664
|
-
this.processQueue();
|
|
3081
|
+
this.animationTasks.push({ animationFn, resolve, reject });
|
|
3082
|
+
if (!this.isRunning) {
|
|
3083
|
+
this._drainQueue();
|
|
2665
3084
|
}
|
|
2666
3085
|
});
|
|
2667
3086
|
}
|
|
2668
3087
|
|
|
2669
3088
|
/**
|
|
2670
|
-
*
|
|
3089
|
+
* Runs queued animation tasks sequentially until the queue is empty.
|
|
2671
3090
|
*
|
|
2672
3091
|
* @private
|
|
2673
3092
|
* @returns {Promise<void>}
|
|
2674
3093
|
*/
|
|
2675
|
-
async
|
|
2676
|
-
if (this.
|
|
2677
|
-
this.
|
|
3094
|
+
async _drainQueue() {
|
|
3095
|
+
if (this.animationTasks.length === 0) {
|
|
3096
|
+
this.isRunning = false;
|
|
2678
3097
|
return;
|
|
2679
3098
|
}
|
|
2680
3099
|
|
|
2681
|
-
this.
|
|
2682
|
-
const {
|
|
3100
|
+
this.isRunning = true;
|
|
3101
|
+
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
2683
3102
|
|
|
2684
3103
|
try {
|
|
2685
|
-
const result = await
|
|
3104
|
+
const result = await animationFn();
|
|
2686
3105
|
resolve(result);
|
|
2687
3106
|
} catch (error) {
|
|
2688
3107
|
reject(error);
|
|
2689
3108
|
}
|
|
2690
3109
|
|
|
2691
|
-
this.
|
|
3110
|
+
await this._drainQueue();
|
|
2692
3111
|
}
|
|
2693
3112
|
}
|
|
2694
3113
|
|
|
2695
3114
|
/**
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
3115
|
+
* Undoable command with paired execute and undo operations.
|
|
3116
|
+
*
|
|
3117
|
+
* @private
|
|
2698
3118
|
*/
|
|
2699
3119
|
class Command {
|
|
2700
3120
|
/**
|
|
2701
|
-
* @param {
|
|
2702
|
-
* @param {
|
|
3121
|
+
* @param {HistoryTaskCallback} execute - Function that performs the action.
|
|
3122
|
+
* @param {HistoryTaskCallback} undo - Function that reverts the action.
|
|
2703
3123
|
*/
|
|
2704
3124
|
constructor(execute, undo) {
|
|
2705
3125
|
/**
|
|
2706
3126
|
* Executes the command.
|
|
2707
|
-
* @type {
|
|
3127
|
+
* @type {HistoryTaskCallback}
|
|
2708
3128
|
*/
|
|
2709
3129
|
this.execute = execute;
|
|
2710
3130
|
/**
|
|
2711
3131
|
* Undoes the command.
|
|
2712
|
-
* @type {
|
|
3132
|
+
* @type {HistoryTaskCallback}
|
|
2713
3133
|
*/
|
|
2714
3134
|
this.undo = undo;
|
|
2715
3135
|
}
|
|
2716
3136
|
}
|
|
2717
3137
|
|
|
2718
3138
|
/**
|
|
2719
|
-
* Manages
|
|
2720
|
-
*
|
|
3139
|
+
* Manages undo/redo history and serializes asynchronous history operations.
|
|
3140
|
+
*
|
|
3141
|
+
* @private
|
|
2721
3142
|
*/
|
|
2722
3143
|
class HistoryManager {
|
|
2723
3144
|
/**
|
|
2724
|
-
* @param {number} [maxSize=50]
|
|
3145
|
+
* @param {number} [maxSize=50] - Maximum number of commands to keep in history.
|
|
2725
3146
|
*/
|
|
2726
3147
|
constructor(maxSize = 50) {
|
|
3148
|
+
/** @type {Array<Command>} */
|
|
2727
3149
|
this.history = [];
|
|
3150
|
+
/** @type {number} */
|
|
2728
3151
|
this.currentIndex = -1;
|
|
3152
|
+
/** @type {number} */
|
|
2729
3153
|
this.maxSize = maxSize;
|
|
3154
|
+
/** @type {Promise<void>} */
|
|
2730
3155
|
this.pending = Promise.resolve();
|
|
2731
3156
|
}
|
|
2732
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
|
+
*/
|
|
2733
3165
|
enqueue(task) {
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
|
|
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;
|
|
2737
3177
|
}
|
|
2738
3178
|
|
|
2739
3179
|
/**
|
|
@@ -2744,7 +3184,6 @@ function ensureFabric() {
|
|
|
2744
3184
|
* @returns {void}
|
|
2745
3185
|
*/
|
|
2746
3186
|
execute(command) {
|
|
2747
|
-
// Perform the command.
|
|
2748
3187
|
command.execute();
|
|
2749
3188
|
this.push(command);
|
|
2750
3189
|
}
|
|
@@ -2757,17 +3196,15 @@ function ensureFabric() {
|
|
|
2757
3196
|
* @returns {void}
|
|
2758
3197
|
*/
|
|
2759
3198
|
push(command) {
|
|
2760
|
-
//
|
|
3199
|
+
// Discard redo commands when a new branch is created.
|
|
2761
3200
|
if (this.currentIndex < this.history.length - 1) {
|
|
2762
3201
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
2763
3202
|
}
|
|
2764
3203
|
|
|
2765
|
-
// Add the new command.
|
|
2766
3204
|
this.history.push(command);
|
|
2767
3205
|
|
|
2768
|
-
// Maintain the max size of the buffer.
|
|
2769
3206
|
if (this.history.length > this.maxSize) {
|
|
2770
|
-
this.history.shift();
|
|
3207
|
+
this.history.shift();
|
|
2771
3208
|
} else {
|
|
2772
3209
|
this.currentIndex++;
|
|
2773
3210
|
}
|
|
@@ -2794,7 +3231,7 @@ function ensureFabric() {
|
|
|
2794
3231
|
/**
|
|
2795
3232
|
* Undoes the last executed command if possible.
|
|
2796
3233
|
*
|
|
2797
|
-
* @returns {void}
|
|
3234
|
+
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
2798
3235
|
*/
|
|
2799
3236
|
undo() {
|
|
2800
3237
|
return this.enqueue(async () => {
|
|
@@ -2809,7 +3246,7 @@ function ensureFabric() {
|
|
|
2809
3246
|
/**
|
|
2810
3247
|
* Redoes the next command in history if possible.
|
|
2811
3248
|
*
|
|
2812
|
-
* @returns {void}
|
|
3249
|
+
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
2813
3250
|
*/
|
|
2814
3251
|
redo() {
|
|
2815
3252
|
return this.enqueue(async () => {
|