@bensitu/image-editor 1.2.2 → 1.3.1
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 +743 -463
- 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 +743 -463
- package/dist/image-editor.esm.mjs.map +2 -2
- package/dist/image-editor.js +743 -463
- 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 +9 -4
- package/src/image-editor.js +825 -338
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.1
|
|
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
|
|
516
623
|
*/
|
|
517
|
-
|
|
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 = {}) {
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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);
|
|
665
799
|
imageElement.onload = null;
|
|
666
800
|
imageElement.onerror = null;
|
|
667
|
-
|
|
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
|
}
|
|
@@ -727,25 +868,36 @@ function ensureFabric() {
|
|
|
727
868
|
};
|
|
728
869
|
}
|
|
729
870
|
|
|
871
|
+
let width = Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1));
|
|
872
|
+
let height = Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1));
|
|
873
|
+
|
|
730
874
|
if (this._hasFixedContainerScrollbars()) {
|
|
731
|
-
return {
|
|
732
|
-
width: Math.max(1, Math.floor(this.containerElement.clientWidth || this.options.canvasWidth || 1)),
|
|
733
|
-
height: Math.max(1, Math.floor(this.containerElement.clientHeight || this.options.canvasHeight || 1))
|
|
734
|
-
};
|
|
875
|
+
return { width, height };
|
|
735
876
|
}
|
|
736
877
|
|
|
737
|
-
const
|
|
738
|
-
|
|
878
|
+
const overflow = this._getContainerOverflowValues();
|
|
879
|
+
const canScrollX = overflow.x.some(value => value === 'auto' || value === 'scroll');
|
|
880
|
+
const canScrollY = overflow.y.some(value => value === 'auto' || value === 'scroll');
|
|
881
|
+
const hasHorizontalScrollbar = canScrollX && this.containerElement.scrollWidth > this.containerElement.clientWidth;
|
|
882
|
+
const hasVerticalScrollbar = canScrollY && this.containerElement.scrollHeight > this.containerElement.clientHeight;
|
|
739
883
|
|
|
740
|
-
|
|
741
|
-
|
|
884
|
+
if (hasHorizontalScrollbar || hasVerticalScrollbar) {
|
|
885
|
+
const scrollbar = this._getScrollbarSize();
|
|
886
|
+
if (hasVerticalScrollbar) width += scrollbar.width;
|
|
887
|
+
if (hasHorizontalScrollbar) height += scrollbar.height;
|
|
888
|
+
}
|
|
742
889
|
|
|
743
|
-
this.containerElement.style.overflow = previousOverflow;
|
|
744
890
|
return { width, height };
|
|
745
891
|
}
|
|
746
892
|
|
|
747
|
-
|
|
748
|
-
|
|
893
|
+
/**
|
|
894
|
+
* Reads inline and computed overflow values for both scroll axes.
|
|
895
|
+
*
|
|
896
|
+
* @returns {{x:string[], y:string[]}} Overflow values grouped by axis.
|
|
897
|
+
* @private
|
|
898
|
+
*/
|
|
899
|
+
_getContainerOverflowValues() {
|
|
900
|
+
if (!this.containerElement) return { x: [], y: [] };
|
|
749
901
|
const inlineOverflow = this.containerElement.style.overflow;
|
|
750
902
|
const inlineOverflowX = this.containerElement.style.overflowX;
|
|
751
903
|
const inlineOverflowY = this.containerElement.style.overflowY;
|
|
@@ -760,11 +912,22 @@ function ensureFabric() {
|
|
|
760
912
|
computedOverflowY = style.overflowY;
|
|
761
913
|
}
|
|
762
914
|
|
|
763
|
-
return
|
|
764
|
-
|
|
915
|
+
return {
|
|
916
|
+
x: [inlineOverflow, inlineOverflowX, computedOverflow, computedOverflowX],
|
|
917
|
+
y: [inlineOverflow, inlineOverflowY, computedOverflow, computedOverflowY]
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
_hasFixedContainerScrollbars() {
|
|
922
|
+
if (!this.containerElement) return false;
|
|
923
|
+
const overflow = this._getContainerOverflowValues();
|
|
924
|
+
return [...overflow.x, ...overflow.y].some(value => value === 'scroll');
|
|
765
925
|
}
|
|
766
926
|
|
|
767
927
|
_getScrollbarSize() {
|
|
928
|
+
if (this._scrollbarSizeCache) {
|
|
929
|
+
return { ...this._scrollbarSizeCache };
|
|
930
|
+
}
|
|
768
931
|
if (typeof document === 'undefined' || !document.createElement || !document.body) {
|
|
769
932
|
return { width: 0, height: 0 };
|
|
770
933
|
}
|
|
@@ -782,7 +945,8 @@ function ensureFabric() {
|
|
|
782
945
|
const height = Math.max(0, probe.offsetHeight - probe.clientHeight);
|
|
783
946
|
document.body.removeChild(probe);
|
|
784
947
|
|
|
785
|
-
|
|
948
|
+
this._scrollbarSizeCache = { width, height };
|
|
949
|
+
return { ...this._scrollbarSizeCache };
|
|
786
950
|
}
|
|
787
951
|
|
|
788
952
|
_getScrollSafetyMargin() {
|
|
@@ -807,8 +971,8 @@ function ensureFabric() {
|
|
|
807
971
|
const scrollbar = this._getScrollbarSize();
|
|
808
972
|
let hasVertical = false;
|
|
809
973
|
let hasHorizontal = false;
|
|
810
|
-
let effectiveWidth
|
|
811
|
-
let effectiveHeight
|
|
974
|
+
let effectiveWidth;
|
|
975
|
+
let effectiveHeight;
|
|
812
976
|
|
|
813
977
|
for (let i = 0; i < 4; i += 1) {
|
|
814
978
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
@@ -859,8 +1023,8 @@ function ensureFabric() {
|
|
|
859
1023
|
let scale = 1;
|
|
860
1024
|
let contentWidth = imageWidth;
|
|
861
1025
|
let contentHeight = imageHeight;
|
|
862
|
-
let effectiveWidth
|
|
863
|
-
let effectiveHeight
|
|
1026
|
+
let effectiveWidth;
|
|
1027
|
+
let effectiveHeight;
|
|
864
1028
|
|
|
865
1029
|
for (let i = 0; i < 4; i += 1) {
|
|
866
1030
|
effectiveWidth = Math.max(1, viewport.width - (hasVertical ? scrollbar.width : 0));
|
|
@@ -921,26 +1085,36 @@ function ensureFabric() {
|
|
|
921
1085
|
_withNormalizedMaskStyles(callback) {
|
|
922
1086
|
if (!this.canvas) return callback();
|
|
923
1087
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
924
|
-
const maskStyleBackups =
|
|
925
|
-
object: mask,
|
|
926
|
-
stroke: mask.stroke,
|
|
927
|
-
strokeWidth: mask.strokeWidth,
|
|
928
|
-
opacity: mask.opacity
|
|
929
|
-
}));
|
|
1088
|
+
const maskStyleBackups = [];
|
|
930
1089
|
|
|
931
1090
|
try {
|
|
932
1091
|
masks.forEach(mask => {
|
|
933
|
-
|
|
1092
|
+
const normalStyle = this._getMaskNormalStyle(mask);
|
|
1093
|
+
const stylePatch = {};
|
|
1094
|
+
Object.keys(normalStyle).forEach(property => {
|
|
1095
|
+
if (mask[property] !== normalStyle[property]) {
|
|
1096
|
+
stylePatch[property] = normalStyle[property];
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
const changedProperties = Object.keys(stylePatch);
|
|
1100
|
+
if (!changedProperties.length) return;
|
|
1101
|
+
|
|
1102
|
+
const backup = { object: mask };
|
|
1103
|
+
changedProperties.forEach(property => {
|
|
1104
|
+
backup[property] = mask[property];
|
|
1105
|
+
});
|
|
1106
|
+
maskStyleBackups.push(backup);
|
|
1107
|
+
mask.set(stylePatch);
|
|
934
1108
|
});
|
|
935
1109
|
return callback();
|
|
936
1110
|
} finally {
|
|
937
1111
|
maskStyleBackups.forEach(backup => {
|
|
938
1112
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
opacity: backup.opacity
|
|
1113
|
+
const restorePatch = {};
|
|
1114
|
+
Object.keys(backup).forEach(property => {
|
|
1115
|
+
if (property !== 'object') restorePatch[property] = backup[property];
|
|
943
1116
|
});
|
|
1117
|
+
backup.object.set(restorePatch);
|
|
944
1118
|
} catch (error) { void error; }
|
|
945
1119
|
});
|
|
946
1120
|
}
|
|
@@ -964,6 +1138,27 @@ function ensureFabric() {
|
|
|
964
1138
|
if (typeof mask.setCoords === 'function') mask.setCoords();
|
|
965
1139
|
}
|
|
966
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Captures editor-owned runtime state that Fabric does not include in canvas JSON.
|
|
1143
|
+
*
|
|
1144
|
+
* @returns {{version:number, baseImageScale:number, currentScale:number, currentRotation:number, maskCounter:number}} Serializable editor metadata.
|
|
1145
|
+
* @private
|
|
1146
|
+
*/
|
|
1147
|
+
_serializeEditorMetadata() {
|
|
1148
|
+
const baseImageScale = Number(this.baseImageScale);
|
|
1149
|
+
const currentScale = Number(this.currentScale);
|
|
1150
|
+
const currentRotation = Number(this.currentRotation);
|
|
1151
|
+
const maskCounter = Number(this.maskCounter);
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
version: 1,
|
|
1155
|
+
baseImageScale: Number.isFinite(baseImageScale) && baseImageScale > 0 ? baseImageScale : 1,
|
|
1156
|
+
currentScale: Number.isFinite(currentScale) && currentScale > 0 ? currentScale : 1,
|
|
1157
|
+
currentRotation: Number.isFinite(currentRotation) ? currentRotation : 0,
|
|
1158
|
+
maskCounter: Number.isFinite(maskCounter) && maskCounter > 0 ? Math.floor(maskCounter) : 0
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
967
1162
|
_serializeCanvasState() {
|
|
968
1163
|
if (!this.canvas) return null;
|
|
969
1164
|
return this._withNormalizedMaskStyles(() => {
|
|
@@ -971,16 +1166,31 @@ function ensureFabric() {
|
|
|
971
1166
|
if (Array.isArray(jsonObject.objects)) {
|
|
972
1167
|
jsonObject.objects = jsonObject.objects.filter(object => !object.isCropRect && !object.maskLabel);
|
|
973
1168
|
}
|
|
1169
|
+
jsonObject.imageEditorMetadata = this._serializeEditorMetadata();
|
|
974
1170
|
return JSON.stringify(jsonObject);
|
|
975
1171
|
});
|
|
976
1172
|
}
|
|
977
1173
|
|
|
1174
|
+
/**
|
|
1175
|
+
* Normalizes a lossy image quality value to Fabric/canvas's 0..1 range.
|
|
1176
|
+
*
|
|
1177
|
+
* @param {number} quality - Requested image quality.
|
|
1178
|
+
* @returns {number} A finite quality value between 0 and 1.
|
|
1179
|
+
* @private
|
|
1180
|
+
*/
|
|
978
1181
|
_normalizeQuality(quality) {
|
|
979
1182
|
const numericQuality = Number(quality);
|
|
980
1183
|
if (!Number.isFinite(numericQuality)) return this.options.downsampleQuality ?? 0.92;
|
|
981
1184
|
return Math.max(0, Math.min(1, numericQuality));
|
|
982
1185
|
}
|
|
983
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Normalizes public image format aliases to canvas export format names.
|
|
1189
|
+
*
|
|
1190
|
+
* @param {string} format - Requested image format or MIME type.
|
|
1191
|
+
* @returns {'jpeg'|'png'|'webp'} Canvas-compatible image format.
|
|
1192
|
+
* @private
|
|
1193
|
+
*/
|
|
984
1194
|
_normalizeImageFormat(format) {
|
|
985
1195
|
const typeMapping = {
|
|
986
1196
|
'jpeg': 'jpeg',
|
|
@@ -994,6 +1204,15 @@ function ensureFabric() {
|
|
|
994
1204
|
return typeMapping[String(format || 'jpeg').toLowerCase()] || 'jpeg';
|
|
995
1205
|
}
|
|
996
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* Converts a bounding rectangle into a canvas-safe integer source region.
|
|
1209
|
+
*
|
|
1210
|
+
* @param {{left:number, top:number, width:number, height:number}} bounds - Bounds in canvas coordinates.
|
|
1211
|
+
* @param {Object} [options={}] - Region rounding options.
|
|
1212
|
+
* @param {boolean} [options.includePartialPixels=true] - If false, excludes partially covered trailing pixels.
|
|
1213
|
+
* @returns {{sourceX:number, sourceY:number, sourceWidth:number, sourceHeight:number}} Clamped source region.
|
|
1214
|
+
* @private
|
|
1215
|
+
*/
|
|
997
1216
|
_getClampedCanvasRegion(bounds, options = {}) {
|
|
998
1217
|
const canvasWidth = Math.max(1, Math.round(this.canvas.getWidth()));
|
|
999
1218
|
const canvasHeight = Math.max(1, Math.round(this.canvas.getHeight()));
|
|
@@ -1009,16 +1228,46 @@ function ensureFabric() {
|
|
|
1009
1228
|
const endY = Math.min(canvasHeight, Math.max(sourceY + 1, roundEnd(top + height)));
|
|
1010
1229
|
|
|
1011
1230
|
return {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1231
|
+
sourceX,
|
|
1232
|
+
sourceY,
|
|
1233
|
+
sourceWidth: Math.max(1, endX - sourceX),
|
|
1234
|
+
sourceHeight: Math.max(1, endY - sourceY)
|
|
1016
1235
|
};
|
|
1017
1236
|
}
|
|
1018
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* Crops an image data URL to a source region using an offscreen canvas.
|
|
1240
|
+
*
|
|
1241
|
+
* @param {string} dataUrl - Source image data URL.
|
|
1242
|
+
* @param {number} sourceX - Source region x coordinate.
|
|
1243
|
+
* @param {number} sourceY - Source region y coordinate.
|
|
1244
|
+
* @param {number} sourceWidth - Source region width.
|
|
1245
|
+
* @param {number} sourceHeight - Source region height.
|
|
1246
|
+
* @param {number} multiplier - Export multiplier already applied to the source data URL.
|
|
1247
|
+
* @param {'jpeg'|'png'|'webp'} [format='jpeg'] - Output image format.
|
|
1248
|
+
* @param {number} [quality=0.92] - Output image quality for lossy formats.
|
|
1249
|
+
* @returns {Promise<string>} Resolves with the cropped image data URL.
|
|
1250
|
+
* @private
|
|
1251
|
+
*/
|
|
1019
1252
|
async _cropDataUrl(dataUrl, sourceX, sourceY, sourceWidth, sourceHeight, multiplier, format = 'jpeg', quality = 0.92) {
|
|
1020
1253
|
return new Promise((resolve, reject) => {
|
|
1021
1254
|
const imageElement = new Image();
|
|
1255
|
+
let isSettled = false;
|
|
1256
|
+
const timeoutMs = Number(this.options.imageLoadTimeoutMs);
|
|
1257
|
+
const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
|
|
1258
|
+
let timerId;
|
|
1259
|
+
const settle = (callback) => {
|
|
1260
|
+
if (isSettled) return;
|
|
1261
|
+
isSettled = true;
|
|
1262
|
+
clearTimeout(timerId);
|
|
1263
|
+
imageElement.onload = null;
|
|
1264
|
+
imageElement.onerror = null;
|
|
1265
|
+
callback();
|
|
1266
|
+
};
|
|
1267
|
+
timerId = setTimeout(() => {
|
|
1268
|
+
settle(() => reject(new Error('Image crop load timed out')));
|
|
1269
|
+
try { imageElement.src = ''; } catch (error) { void error; }
|
|
1270
|
+
}, safeTimeoutMs);
|
|
1022
1271
|
imageElement.onload = () => {
|
|
1023
1272
|
try {
|
|
1024
1273
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
@@ -1030,19 +1279,34 @@ function ensureFabric() {
|
|
|
1030
1279
|
offscreenCanvas.width = scaledSourceWidth;
|
|
1031
1280
|
offscreenCanvas.height = scaledSourceHeight;
|
|
1032
1281
|
const context = offscreenCanvas.getContext('2d');
|
|
1282
|
+
if (!context) throw new Error('2D canvas context is unavailable');
|
|
1033
1283
|
|
|
1034
1284
|
context.drawImage(imageElement, scaledSourceX, scaledSourceY, scaledSourceWidth, scaledSourceHeight, 0, 0, scaledSourceWidth, scaledSourceHeight);
|
|
1035
|
-
resolve(offscreenCanvas.toDataURL(`image/${format}`, quality));
|
|
1285
|
+
settle(() => resolve(offscreenCanvas.toDataURL(`image/${format}`, quality)));
|
|
1036
1286
|
} catch (error) {
|
|
1037
|
-
reject(error);
|
|
1287
|
+
settle(() => reject(error));
|
|
1038
1288
|
}
|
|
1039
1289
|
};
|
|
1040
|
-
imageElement.onerror = reject;
|
|
1290
|
+
imageElement.onerror = (error) => settle(() => reject(error));
|
|
1041
1291
|
imageElement.src = dataUrl;
|
|
1042
1292
|
});
|
|
1043
1293
|
}
|
|
1044
1294
|
|
|
1045
|
-
|
|
1295
|
+
/**
|
|
1296
|
+
* Exports the whole Fabric canvas, then crops the requested source region from that export.
|
|
1297
|
+
*
|
|
1298
|
+
* @param {Object} region - Canvas source region and export options.
|
|
1299
|
+
* @param {number} region.sourceX - Source region x coordinate.
|
|
1300
|
+
* @param {number} region.sourceY - Source region y coordinate.
|
|
1301
|
+
* @param {number} region.sourceWidth - Source region width.
|
|
1302
|
+
* @param {number} region.sourceHeight - Source region height.
|
|
1303
|
+
* @param {number} [region.multiplier=1] - Export multiplier.
|
|
1304
|
+
* @param {number} [region.quality=0.92] - Output image quality for lossy formats.
|
|
1305
|
+
* @param {'jpeg'|'png'|'webp'} [region.format='jpeg'] - Output image format.
|
|
1306
|
+
* @returns {Promise<string>} Resolves with an image data URL for the cropped region.
|
|
1307
|
+
* @private
|
|
1308
|
+
*/
|
|
1309
|
+
async _exportCanvasRegionToDataURL({ sourceX, sourceY, sourceWidth, sourceHeight, multiplier = 1, quality = 0.92, format = 'jpeg' }) {
|
|
1046
1310
|
const safeMultiplier = Math.max(1, Number(multiplier) || 1);
|
|
1047
1311
|
const fullDataUrl = this.canvas.toDataURL({
|
|
1048
1312
|
format,
|
|
@@ -1050,7 +1314,7 @@ function ensureFabric() {
|
|
|
1050
1314
|
multiplier: safeMultiplier
|
|
1051
1315
|
});
|
|
1052
1316
|
|
|
1053
|
-
return this._cropDataUrl(fullDataUrl,
|
|
1317
|
+
return this._cropDataUrl(fullDataUrl, sourceX, sourceY, sourceWidth, sourceHeight, safeMultiplier, format, quality);
|
|
1054
1318
|
}
|
|
1055
1319
|
|
|
1056
1320
|
/**
|
|
@@ -1117,23 +1381,60 @@ function ensureFabric() {
|
|
|
1117
1381
|
this._setCanvasSizeInt(size.width, size.height);
|
|
1118
1382
|
}
|
|
1119
1383
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1384
|
+
/**
|
|
1385
|
+
* Whether post-load edits should resize the canvas to keep transformed content visible.
|
|
1386
|
+
*
|
|
1387
|
+
* @returns {boolean} True when canvas bounds should follow edited image or mask bounds.
|
|
1388
|
+
* @private
|
|
1389
|
+
*/
|
|
1390
|
+
_shouldResizeCanvasToContentBounds() {
|
|
1391
|
+
return !!(this.options.expandCanvasToImage || this.options.coverImageToCanvas || this.options.fitImageToCanvas);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Expands the canvas once so all provided objects remain visible after an edit.
|
|
1396
|
+
*
|
|
1397
|
+
* @param {Array<fabric.Object>} fabricObjects - Objects whose bounds should fit inside the canvas.
|
|
1398
|
+
* @param {number} [padding=10] - Extra canvas space after the farthest object edge.
|
|
1399
|
+
* @returns {void}
|
|
1400
|
+
* @private
|
|
1401
|
+
*/
|
|
1402
|
+
_expandCanvasToFitObjects(fabricObjects, padding = 10) {
|
|
1403
|
+
if (!this.canvas || !Array.isArray(fabricObjects) || !fabricObjects.length || !this._shouldResizeCanvasToContentBounds()) return;
|
|
1122
1404
|
try {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1405
|
+
let requiredWidth = this.canvas.getWidth();
|
|
1406
|
+
let requiredHeight = this.canvas.getHeight();
|
|
1407
|
+
fabricObjects.forEach(fabricObject => {
|
|
1408
|
+
if (!fabricObject) return;
|
|
1409
|
+
if (typeof fabricObject.setCoords === 'function') fabricObject.setCoords();
|
|
1410
|
+
const boundingRect = fabricObject.getBoundingRect(true, true);
|
|
1411
|
+
requiredWidth = Math.max(requiredWidth, Math.ceil(boundingRect.left + boundingRect.width + padding));
|
|
1412
|
+
requiredHeight = Math.max(requiredHeight, Math.ceil(boundingRect.top + boundingRect.height + padding));
|
|
1413
|
+
});
|
|
1127
1414
|
const minWidth = this.containerElement ? Math.floor(this.containerElement.clientWidth || 0) : 0;
|
|
1128
1415
|
const minHeight = this.containerElement ? Math.floor(this.containerElement.clientHeight || 0) : 0;
|
|
1129
1416
|
const newWidth = Math.max(this.canvas.getWidth(), minWidth, requiredWidth);
|
|
1130
1417
|
const newHeight = Math.max(this.canvas.getHeight(), minHeight, requiredHeight);
|
|
1131
|
-
this.
|
|
1418
|
+
if (newWidth !== this.canvas.getWidth() || newHeight !== this.canvas.getHeight()) {
|
|
1419
|
+
this._setCanvasSizeInt(newWidth, newHeight);
|
|
1420
|
+
}
|
|
1132
1421
|
} catch (error) {
|
|
1133
|
-
this._reportWarning('
|
|
1422
|
+
this._reportWarning('expandCanvasToFitObjects: failed to expand canvas', error);
|
|
1134
1423
|
}
|
|
1135
1424
|
}
|
|
1136
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* Expands the canvas so one object remains visible after an edit.
|
|
1428
|
+
*
|
|
1429
|
+
* @param {fabric.Object} fabricObject - Object whose bounds should fit inside the canvas.
|
|
1430
|
+
* @param {number} [padding=10] - Extra canvas space after the object edge.
|
|
1431
|
+
* @returns {void}
|
|
1432
|
+
* @private
|
|
1433
|
+
*/
|
|
1434
|
+
_expandCanvasToFitObject(fabricObject, padding = 10) {
|
|
1435
|
+
this._expandCanvasToFitObjects([fabricObject], padding);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1137
1438
|
/**
|
|
1138
1439
|
* Scales the original image by a given factor, with animation.
|
|
1139
1440
|
* Returns a promise that resolves when the scale animation is complete.
|
|
@@ -1142,7 +1443,7 @@ function ensureFabric() {
|
|
|
1142
1443
|
* @public
|
|
1143
1444
|
*/
|
|
1144
1445
|
scaleImage(factor, options = {}) {
|
|
1145
|
-
return this.
|
|
1446
|
+
return this.animationQueue.add(() => this._scaleImageImpl(factor, options));
|
|
1146
1447
|
}
|
|
1147
1448
|
|
|
1148
1449
|
/**
|
|
@@ -1186,7 +1487,7 @@ function ensureFabric() {
|
|
|
1186
1487
|
this.originalImage.set({ scaleX: targetScale, scaleY: targetScale });
|
|
1187
1488
|
this.originalImage.setCoords();
|
|
1188
1489
|
|
|
1189
|
-
if (this.
|
|
1490
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1190
1491
|
this._updateCanvasSizeToImageBounds();
|
|
1191
1492
|
}
|
|
1192
1493
|
|
|
@@ -1213,7 +1514,7 @@ function ensureFabric() {
|
|
|
1213
1514
|
* @public
|
|
1214
1515
|
*/
|
|
1215
1516
|
rotateImage(degrees, options = {}) {
|
|
1216
|
-
return this.
|
|
1517
|
+
return this.animationQueue.add(() => this._rotateImageImpl(degrees, options));
|
|
1217
1518
|
}
|
|
1218
1519
|
|
|
1219
1520
|
/**
|
|
@@ -1247,7 +1548,7 @@ function ensureFabric() {
|
|
|
1247
1548
|
this.originalImage.set('angle', degrees);
|
|
1248
1549
|
this.originalImage.setCoords();
|
|
1249
1550
|
|
|
1250
|
-
if (this.
|
|
1551
|
+
if (this._shouldResizeCanvasToContentBounds()) {
|
|
1251
1552
|
this._updateCanvasSizeToImageBounds();
|
|
1252
1553
|
}
|
|
1253
1554
|
|
|
@@ -1271,43 +1572,52 @@ function ensureFabric() {
|
|
|
1271
1572
|
|
|
1272
1573
|
/**
|
|
1273
1574
|
* Resets the image transform: scales to 1 and rotates to 0 degrees.
|
|
1274
|
-
*
|
|
1575
|
+
*
|
|
1576
|
+
* @returns {Promise<void>} Resolves when the reset history transition has been recorded.
|
|
1577
|
+
* @public
|
|
1275
1578
|
*/
|
|
1276
1579
|
resetImageTransform() {
|
|
1277
1580
|
if (!this.originalImage) return Promise.resolve();
|
|
1278
1581
|
|
|
1279
|
-
return this.
|
|
1280
|
-
const before = this._serializeCanvasState();
|
|
1582
|
+
return this.animationQueue.add(async () => {
|
|
1583
|
+
const before = this._lastSnapshot || this._serializeCanvasState();
|
|
1281
1584
|
await this._scaleImageImpl(1, { saveHistory: false });
|
|
1282
1585
|
await this._rotateImageImpl(0, { saveHistory: false });
|
|
1283
1586
|
const after = this._serializeCanvasState();
|
|
1284
1587
|
this._pushStateTransition(before, after);
|
|
1285
|
-
}).catch(
|
|
1286
|
-
this._reportError('resetImageTransform() failed',
|
|
1588
|
+
}).catch(error => {
|
|
1589
|
+
this._reportError('resetImageTransform() failed', error);
|
|
1287
1590
|
});
|
|
1288
1591
|
}
|
|
1289
1592
|
|
|
1290
1593
|
/**
|
|
1291
|
-
* @
|
|
1594
|
+
* Backward-compatible alias for {@link ImageEditor#resetImageTransform}.
|
|
1595
|
+
*
|
|
1596
|
+
* @deprecated Use resetImageTransform() instead. This alias will be removed in v2.0.0.
|
|
1597
|
+
* @returns {Promise<void>} Resolves when the image transform reset is complete.
|
|
1292
1598
|
*/
|
|
1293
1599
|
reset() {
|
|
1294
1600
|
return this.resetImageTransform();
|
|
1295
1601
|
}
|
|
1296
1602
|
|
|
1297
1603
|
/**
|
|
1298
|
-
* Restores a canvas state
|
|
1299
|
-
*
|
|
1604
|
+
* Restores a serialized canvas state and rebinds editor-specific mask/image metadata.
|
|
1605
|
+
*
|
|
1606
|
+
* @param {string|Object} serializedState - State returned by `_serializeCanvasState()` as a JSON string or object.
|
|
1607
|
+
* @returns {Promise<void>} Resolves after Fabric has loaded the state and UI state has been refreshed.
|
|
1608
|
+
* @public
|
|
1300
1609
|
*/
|
|
1301
|
-
loadFromState(
|
|
1302
|
-
if (!
|
|
1610
|
+
loadFromState(serializedState) {
|
|
1611
|
+
if (!serializedState || !this.canvas) return Promise.resolve();
|
|
1303
1612
|
|
|
1304
1613
|
return new Promise((resolve) => {
|
|
1305
1614
|
try {
|
|
1306
|
-
const
|
|
1307
|
-
? JSON.parse(
|
|
1308
|
-
:
|
|
1615
|
+
const state = (typeof serializedState === 'string')
|
|
1616
|
+
? JSON.parse(serializedState)
|
|
1617
|
+
: serializedState;
|
|
1618
|
+
const editorMetadata = state && state.imageEditorMetadata ? state.imageEditorMetadata : null;
|
|
1309
1619
|
|
|
1310
|
-
this.canvas.loadFromJSON(
|
|
1620
|
+
this.canvas.loadFromJSON(state, () => {
|
|
1311
1621
|
try {
|
|
1312
1622
|
this._hideAllMaskLabels();
|
|
1313
1623
|
const canvasObjects = this.canvas.getObjects();
|
|
@@ -1316,11 +1626,27 @@ function ensureFabric() {
|
|
|
1316
1626
|
if (this.originalImage) {
|
|
1317
1627
|
this.originalImage.set({ originX: 'left', originY: 'top', selectable: false, evented: false, hasControls: false, hoverCursor: 'default' });
|
|
1318
1628
|
this.canvas.sendToBack(this.originalImage);
|
|
1319
|
-
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1629
|
+
const restoredBaseScale = Number(editorMetadata && editorMetadata.baseImageScale);
|
|
1630
|
+
const restoredCurrentScale = Number(editorMetadata && editorMetadata.currentScale);
|
|
1631
|
+
const restoredCurrentRotation = Number(editorMetadata && editorMetadata.currentRotation);
|
|
1632
|
+
|
|
1633
|
+
if (Number.isFinite(restoredBaseScale) && restoredBaseScale > 0) {
|
|
1634
|
+
this.baseImageScale = restoredBaseScale;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (Number.isFinite(restoredCurrentScale) && restoredCurrentScale > 0) {
|
|
1638
|
+
this.currentScale = restoredCurrentScale;
|
|
1639
|
+
} else {
|
|
1640
|
+
const baseScale = Number(this.baseImageScale) || 1;
|
|
1641
|
+
const imageScale = Number(this.originalImage.scaleX) || baseScale;
|
|
1642
|
+
this.currentScale = imageScale / baseScale;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
this.currentRotation = Number.isFinite(restoredCurrentRotation)
|
|
1646
|
+
? restoredCurrentRotation
|
|
1647
|
+
: (Number(this.originalImage.angle) || 0);
|
|
1323
1648
|
} else {
|
|
1649
|
+
this.baseImageScale = 1;
|
|
1324
1650
|
this.currentScale = 1;
|
|
1325
1651
|
this.currentRotation = 0;
|
|
1326
1652
|
}
|
|
@@ -1331,8 +1657,12 @@ function ensureFabric() {
|
|
|
1331
1657
|
this._rebindMaskEvents(mask);
|
|
1332
1658
|
mask.set(this._getMaskNormalStyle(mask));
|
|
1333
1659
|
});
|
|
1334
|
-
|
|
1660
|
+
const restoredMaskCounter = Number(editorMetadata && editorMetadata.maskCounter);
|
|
1661
|
+
const maxMaskId = masks.reduce((max, mask) =>
|
|
1335
1662
|
Math.max(max, mask.maskId), 0);
|
|
1663
|
+
this.maskCounter = Number.isFinite(restoredMaskCounter) && restoredMaskCounter >= maxMaskId
|
|
1664
|
+
? Math.floor(restoredMaskCounter)
|
|
1665
|
+
: maxMaskId;
|
|
1336
1666
|
this._lastMask = masks.length ? masks[masks.length - 1] : null;
|
|
1337
1667
|
if (!this._lastMask) {
|
|
1338
1668
|
this._lastMaskInitialLeft = null;
|
|
@@ -1362,12 +1692,17 @@ function ensureFabric() {
|
|
|
1362
1692
|
}
|
|
1363
1693
|
|
|
1364
1694
|
/**
|
|
1365
|
-
* Saves the current
|
|
1695
|
+
* Saves the current editable canvas state as an undoable history transition.
|
|
1696
|
+
*
|
|
1697
|
+
* Labels are hidden before serialization because labels are UI overlays, while mask metadata is kept on
|
|
1698
|
+
* mask objects and restored by `loadFromState()`.
|
|
1699
|
+
*
|
|
1700
|
+
* @returns {void}
|
|
1701
|
+
* @public
|
|
1366
1702
|
*/
|
|
1367
1703
|
saveState() {
|
|
1368
1704
|
if (!this.canvas) return;
|
|
1369
1705
|
const activeObject = this.canvas.getActiveObject();
|
|
1370
|
-
this._hideAllMaskLabels();
|
|
1371
1706
|
|
|
1372
1707
|
try {
|
|
1373
1708
|
const after = this._serializeCanvasState();
|
|
@@ -1391,13 +1726,24 @@ function ensureFabric() {
|
|
|
1391
1726
|
} catch (error) {
|
|
1392
1727
|
this._reportWarning('saveState: failed to save canvas snapshot', error);
|
|
1393
1728
|
} finally {
|
|
1394
|
-
if (activeObject && activeObject.maskId && this.canvas.getObjects().includes(activeObject)) {
|
|
1729
|
+
if (activeObject && activeObject.maskId && !activeObject.__label && this.canvas.getObjects().includes(activeObject)) {
|
|
1395
1730
|
this._handleSelectionChanged([activeObject]);
|
|
1396
1731
|
}
|
|
1397
1732
|
this._updateUI();
|
|
1398
1733
|
}
|
|
1399
1734
|
}
|
|
1400
1735
|
|
|
1736
|
+
/**
|
|
1737
|
+
* Pushes a precomputed before/after state transition into history.
|
|
1738
|
+
*
|
|
1739
|
+
* Use this for operations such as crop and merge that build their snapshots around asynchronous image
|
|
1740
|
+
* loading, where the "after" state is already applied before the history command is recorded.
|
|
1741
|
+
*
|
|
1742
|
+
* @param {string} before - Serialized state before the operation.
|
|
1743
|
+
* @param {string} after - Serialized state after the operation.
|
|
1744
|
+
* @returns {void}
|
|
1745
|
+
* @private
|
|
1746
|
+
*/
|
|
1401
1747
|
_pushStateTransition(before, after) {
|
|
1402
1748
|
if (!before || !after) return;
|
|
1403
1749
|
if (before === after) return;
|
|
@@ -1414,6 +1760,9 @@ function ensureFabric() {
|
|
|
1414
1760
|
|
|
1415
1761
|
/**
|
|
1416
1762
|
* Undo the last state change, if possible.
|
|
1763
|
+
*
|
|
1764
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued undo.
|
|
1765
|
+
* @public
|
|
1417
1766
|
*/
|
|
1418
1767
|
undo() {
|
|
1419
1768
|
return this.historyManager.undo()
|
|
@@ -1423,6 +1772,9 @@ function ensureFabric() {
|
|
|
1423
1772
|
|
|
1424
1773
|
/**
|
|
1425
1774
|
* Redo the next state change, if possible.
|
|
1775
|
+
*
|
|
1776
|
+
* @returns {Promise<void>} Resolves after the history manager finishes the queued redo.
|
|
1777
|
+
* @public
|
|
1426
1778
|
*/
|
|
1427
1779
|
redo() {
|
|
1428
1780
|
return this.historyManager.redo()
|
|
@@ -1436,7 +1788,7 @@ function ensureFabric() {
|
|
|
1436
1788
|
try {
|
|
1437
1789
|
mask.off('mouseover', mask.__imageEditorMaskHandlers.mouseover);
|
|
1438
1790
|
mask.off('mouseout', mask.__imageEditorMaskHandlers.mouseout);
|
|
1439
|
-
} catch (
|
|
1791
|
+
} catch (error) { void error; }
|
|
1440
1792
|
}
|
|
1441
1793
|
|
|
1442
1794
|
const metadata = {};
|
|
@@ -1474,29 +1826,38 @@ function ensureFabric() {
|
|
|
1474
1826
|
mask.__imageEditorMaskHandlers = { mouseover, mouseout };
|
|
1475
1827
|
}
|
|
1476
1828
|
|
|
1477
|
-
/**
|
|
1829
|
+
/**
|
|
1478
1830
|
* Creates a mask and adds it to the canvas.
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
* @
|
|
1831
|
+
*
|
|
1832
|
+
* Placement is based on explicit `left`/`top` values when provided; otherwise each new mask is placed
|
|
1833
|
+
* after the previously created mask. Fabric object properties are applied through `set()` and `setCoords()`
|
|
1834
|
+
* so controls and hit testing stay in sync with Fabric 5.x behavior.
|
|
1835
|
+
*
|
|
1836
|
+
* @param {Object} [config={}] - Optional mask configuration overrides.
|
|
1837
|
+
* @param {string} [config.shape='rect'] - Mask shape: `rect`, `circle`, `ellipse`, `polygon`, or a custom shape handled by `fabricGenerator`.
|
|
1838
|
+
* @param {Array<{x:number,y:number}>|Array<Array<number>>} [config.points] - Polygon points.
|
|
1839
|
+
* @param {number|string|MaskValueResolver} [config.width] - Width in pixels, percentage string, or resolver callback.
|
|
1840
|
+
* @param {number|string|MaskValueResolver} [config.height] - Height in pixels, percentage string, or resolver callback.
|
|
1841
|
+
* @param {number|string|MaskValueResolver} [config.radius] - Circle radius in pixels, percentage string, or resolver callback.
|
|
1842
|
+
* @param {number|string|MaskValueResolver} [config.rx] - Ellipse horizontal radius or rectangle corner radius.
|
|
1843
|
+
* @param {number|string|MaskValueResolver} [config.ry] - Ellipse vertical radius or rectangle corner radius.
|
|
1844
|
+
* @param {number|string|MaskValueResolver} [config.left] - Left position in pixels, percentage string, or resolver callback.
|
|
1845
|
+
* @param {number|string|MaskValueResolver} [config.top] - Top position in pixels, percentage string, or resolver callback.
|
|
1846
|
+
* @param {number} [config.angle=0] - Rotation angle in degrees.
|
|
1847
|
+
* @param {string} [config.color='rgba(0,0,0,0.5)'] - Fill color.
|
|
1848
|
+
* @param {number} [config.alpha=0.5] - Opacity from 0 to 1.
|
|
1849
|
+
* @param {boolean} [config.selectable=true] - Whether the mask can be selected.
|
|
1850
|
+
* @param {boolean} [config.hasControls=true] - Whether Fabric transform controls are shown.
|
|
1851
|
+
* @param {Object} [config.styles] - Additional Fabric style properties, such as `stroke` or `strokeDashArray`.
|
|
1852
|
+
* @param {MaskFabricGenerator} [config.fabricGenerator] - Factory callback that returns a custom Fabric object.
|
|
1853
|
+
* @param {MaskCreateCallback} [config.onCreate] - Callback invoked after the mask is added to the canvas.
|
|
1854
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1494
1855
|
* @public
|
|
1495
1856
|
*/
|
|
1496
1857
|
createMask(config = {}) {
|
|
1497
1858
|
if (!this.canvas) return null;
|
|
1498
1859
|
const shapeType = config.shape || 'rect';
|
|
1499
|
-
//
|
|
1860
|
+
// Normalize mask defaults before applying caller-provided overrides.
|
|
1500
1861
|
const maskConfig = {
|
|
1501
1862
|
shape: shapeType,
|
|
1502
1863
|
width: this.options.defaultMaskWidth,
|
|
@@ -1513,18 +1874,27 @@ function ensureFabric() {
|
|
|
1513
1874
|
|
|
1514
1875
|
// Always start placement relative to canvas left/top.
|
|
1515
1876
|
const firstOffset = 10;
|
|
1516
|
-
let left
|
|
1517
|
-
let top
|
|
1877
|
+
let left;
|
|
1878
|
+
let top;
|
|
1879
|
+
|
|
1880
|
+
const getCanvasBasis = (axis) => {
|
|
1881
|
+
const canvasWidth = this.canvas ? this.canvas.getWidth() : 0;
|
|
1882
|
+
const canvasHeight = this.canvas ? this.canvas.getHeight() : 0;
|
|
1883
|
+
if (axis === 'height') return canvasHeight;
|
|
1884
|
+
if (axis === 'min') return Math.min(canvasWidth, canvasHeight);
|
|
1885
|
+
return canvasWidth;
|
|
1886
|
+
};
|
|
1518
1887
|
|
|
1519
|
-
const resolveValue = (value, fallback) => {
|
|
1888
|
+
const resolveValue = (value, fallback, axis = 'width') => {
|
|
1520
1889
|
if (typeof value === 'function')
|
|
1521
1890
|
return value(this.canvas, this.options);
|
|
1522
1891
|
if (typeof value === 'string' && value.endsWith('%')) {
|
|
1523
|
-
const percent = parseFloat(value) / 100;
|
|
1524
|
-
|
|
1892
|
+
const percent = Number.parseFloat(value) / 100;
|
|
1893
|
+
if (!Number.isFinite(percent)) return fallback;
|
|
1894
|
+
return Math.floor(getCanvasBasis(axis) * percent);
|
|
1525
1895
|
}
|
|
1526
1896
|
return value != null ? value : fallback;
|
|
1527
|
-
}
|
|
1897
|
+
};
|
|
1528
1898
|
|
|
1529
1899
|
if (maskConfig.left === undefined && this._lastMask) {
|
|
1530
1900
|
const previousMask = this._lastMask;
|
|
@@ -1538,12 +1908,14 @@ function ensureFabric() {
|
|
|
1538
1908
|
left = Math.round(previousMaskRight + maskConfig.gap);
|
|
1539
1909
|
top = previousMask.top ?? firstOffset;
|
|
1540
1910
|
} else {
|
|
1541
|
-
left = resolveValue(maskConfig.left, firstOffset);
|
|
1542
|
-
top = resolveValue(maskConfig.top, firstOffset);
|
|
1911
|
+
left = resolveValue(maskConfig.left, firstOffset, 'width');
|
|
1912
|
+
top = resolveValue(maskConfig.top, firstOffset, 'height');
|
|
1543
1913
|
}
|
|
1544
1914
|
|
|
1545
|
-
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
1546
|
-
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight);
|
|
1915
|
+
maskConfig.width = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
|
|
1916
|
+
maskConfig.height = resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height');
|
|
1917
|
+
maskConfig.left = left;
|
|
1918
|
+
maskConfig.top = top;
|
|
1547
1919
|
|
|
1548
1920
|
let mask;
|
|
1549
1921
|
if (typeof maskConfig.fabricGenerator === 'function') {
|
|
@@ -1553,7 +1925,7 @@ function ensureFabric() {
|
|
|
1553
1925
|
case 'circle':
|
|
1554
1926
|
mask = new fabric.Circle({
|
|
1555
1927
|
left, top,
|
|
1556
|
-
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2),
|
|
1928
|
+
radius: resolveValue(maskConfig.radius, Math.min(maskConfig.width, maskConfig.height) / 2, 'min'),
|
|
1557
1929
|
fill: maskConfig.color,
|
|
1558
1930
|
opacity: maskConfig.alpha,
|
|
1559
1931
|
angle: maskConfig.angle,
|
|
@@ -1563,8 +1935,8 @@ function ensureFabric() {
|
|
|
1563
1935
|
case 'ellipse':
|
|
1564
1936
|
mask = new fabric.Ellipse({
|
|
1565
1937
|
left, top,
|
|
1566
|
-
rx: resolveValue(maskConfig.rx, maskConfig.width / 2),
|
|
1567
|
-
ry: resolveValue(maskConfig.ry, maskConfig.height / 2),
|
|
1938
|
+
rx: resolveValue(maskConfig.rx, maskConfig.width / 2, 'width'),
|
|
1939
|
+
ry: resolveValue(maskConfig.ry, maskConfig.height / 2, 'height'),
|
|
1568
1940
|
fill: maskConfig.color,
|
|
1569
1941
|
opacity: maskConfig.alpha,
|
|
1570
1942
|
angle: maskConfig.angle,
|
|
@@ -1573,9 +1945,11 @@ function ensureFabric() {
|
|
|
1573
1945
|
break;
|
|
1574
1946
|
case 'polygon': {
|
|
1575
1947
|
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 =>
|
|
1948
|
+
if (Array.isArray(polygonPoints) && polygonPoints.length) {
|
|
1949
|
+
// Ensure numeric {x,y} objects for fabric.Polygon.
|
|
1950
|
+
polygonPoints = polygonPoints.map(point => Array.isArray(point)
|
|
1951
|
+
? { x: Number(point[0]), y: Number(point[1]) }
|
|
1952
|
+
: { x: Number(point.x), y: Number(point.y) });
|
|
1579
1953
|
}
|
|
1580
1954
|
mask = new fabric.Polygon(polygonPoints, {
|
|
1581
1955
|
left, top,
|
|
@@ -1590,12 +1964,12 @@ function ensureFabric() {
|
|
|
1590
1964
|
default:
|
|
1591
1965
|
mask = new fabric.Rect({
|
|
1592
1966
|
left, top,
|
|
1593
|
-
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth),
|
|
1594
|
-
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight),
|
|
1967
|
+
width: resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width'),
|
|
1968
|
+
height: resolveValue(maskConfig.height, this.options.defaultMaskHeight, 'height'),
|
|
1595
1969
|
fill: maskConfig.color,
|
|
1596
1970
|
opacity: maskConfig.alpha,
|
|
1597
1971
|
angle: maskConfig.angle,
|
|
1598
|
-
rx: maskConfig.rx,
|
|
1972
|
+
rx: maskConfig.rx,
|
|
1599
1973
|
ry: maskConfig.ry,
|
|
1600
1974
|
...maskConfig.styles
|
|
1601
1975
|
});
|
|
@@ -1614,6 +1988,7 @@ function ensureFabric() {
|
|
|
1614
1988
|
transparentCorners: ('transparentCorners' in maskConfig) ? maskConfig.transparentCorners : false,
|
|
1615
1989
|
stroke: hasStyle('stroke') ? styles.stroke : '#ccc',
|
|
1616
1990
|
strokeWidth: hasStyle('strokeWidth') ? styles.strokeWidth : 1,
|
|
1991
|
+
opacity: hasStyle('opacity') ? styles.opacity : maskConfig.alpha,
|
|
1617
1992
|
strokeUniform: ('strokeUniform' in maskConfig) ? maskConfig.strokeUniform : (hasStyle('strokeUniform') ? styles.strokeUniform : true)
|
|
1618
1993
|
};
|
|
1619
1994
|
if (hasStyle('strokeDashArray')) maskSettings.strokeDashArray = styles.strokeDashArray;
|
|
@@ -1621,17 +1996,17 @@ function ensureFabric() {
|
|
|
1621
1996
|
mask.setCoords();
|
|
1622
1997
|
|
|
1623
1998
|
mask.set({
|
|
1624
|
-
originalAlpha: maskConfig.alpha,
|
|
1999
|
+
originalAlpha: Number.isFinite(Number(mask.opacity)) ? Number(mask.opacity) : maskConfig.alpha,
|
|
1625
2000
|
originalStroke: mask.stroke || '#ccc',
|
|
1626
2001
|
originalStrokeWidth: Number.isFinite(Number(mask.strokeWidth)) ? Number(mask.strokeWidth) : 1
|
|
1627
2002
|
});
|
|
1628
2003
|
this._rebindMaskEvents(mask);
|
|
1629
2004
|
this._expandCanvasToFitObject(mask);
|
|
1630
2005
|
|
|
1631
|
-
//
|
|
2006
|
+
// Store placement values so the next mask can be positioned beside this one.
|
|
1632
2007
|
this._lastMaskInitialLeft = left;
|
|
1633
2008
|
this._lastMaskInitialTop = top;
|
|
1634
|
-
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth);
|
|
2009
|
+
this._lastMaskInitialWidth = resolveValue(maskConfig.width, this.options.defaultMaskWidth, 'width');
|
|
1635
2010
|
|
|
1636
2011
|
const maskId = ++this.maskCounter;
|
|
1637
2012
|
mask.set({
|
|
@@ -1654,7 +2029,11 @@ function ensureFabric() {
|
|
|
1654
2029
|
}
|
|
1655
2030
|
|
|
1656
2031
|
/**
|
|
1657
|
-
* @
|
|
2032
|
+
* Backward-compatible alias for {@link ImageEditor#createMask}.
|
|
2033
|
+
*
|
|
2034
|
+
* @deprecated Use createMask() instead. This alias will be removed in v2.0.0.
|
|
2035
|
+
* @param {Object} [config={}] - Mask configuration passed to createMask().
|
|
2036
|
+
* @returns {fabric.Object|null} The created mask object, or null if the canvas is not initialized.
|
|
1658
2037
|
*/
|
|
1659
2038
|
addMask(config = {}) {
|
|
1660
2039
|
return this.createMask(config);
|
|
@@ -1727,6 +2106,24 @@ function ensureFabric() {
|
|
|
1727
2106
|
}
|
|
1728
2107
|
}
|
|
1729
2108
|
|
|
2109
|
+
/**
|
|
2110
|
+
* Returns a stable zero-based creation index for label callbacks.
|
|
2111
|
+
*
|
|
2112
|
+
* Mask ids are one-based and are not renumbered after deletion, so this value remains stable for the
|
|
2113
|
+
* lifetime of a mask.
|
|
2114
|
+
*
|
|
2115
|
+
* @param {fabric.Object} mask - Mask object.
|
|
2116
|
+
* @returns {number} Stable zero-based creation index.
|
|
2117
|
+
* @private
|
|
2118
|
+
*/
|
|
2119
|
+
_getMaskCreationIndex(mask) {
|
|
2120
|
+
const maskId = Number(mask && mask.maskId);
|
|
2121
|
+
if (Number.isFinite(maskId) && maskId > 0) return Math.floor(maskId) - 1;
|
|
2122
|
+
|
|
2123
|
+
const masks = this.canvas ? this.canvas.getObjects().filter(object => object.maskId) : [];
|
|
2124
|
+
return Math.max(0, masks.indexOf(mask));
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1730
2127
|
/**
|
|
1731
2128
|
* Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
|
|
1732
2129
|
* The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
|
|
@@ -1757,9 +2154,7 @@ function ensureFabric() {
|
|
|
1757
2154
|
};
|
|
1758
2155
|
if (this.options.label) {
|
|
1759
2156
|
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);
|
|
2157
|
+
labelText = this.options.label.getText(mask, this._getMaskCreationIndex(mask));
|
|
1763
2158
|
}
|
|
1764
2159
|
// Merge external styles
|
|
1765
2160
|
if (this.options.label.textOptions) {
|
|
@@ -1924,10 +2319,14 @@ function ensureFabric() {
|
|
|
1924
2319
|
}
|
|
1925
2320
|
|
|
1926
2321
|
/**
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
2322
|
+
* Flattens the current masks into the base image and reloads the flattened image.
|
|
2323
|
+
*
|
|
2324
|
+
* This removes editable mask objects after export and records the operation as one undoable history transition.
|
|
2325
|
+
* It does nothing when no base image or no masks exist.
|
|
2326
|
+
*
|
|
1929
2327
|
* @async
|
|
1930
|
-
* @returns {Promise<void>} Resolves when
|
|
2328
|
+
* @returns {Promise<void>} Resolves when the flattened image has been loaded.
|
|
2329
|
+
* @public
|
|
1931
2330
|
*/
|
|
1932
2331
|
async mergeMasks() {
|
|
1933
2332
|
if (!this.originalImage) return;
|
|
@@ -1941,53 +2340,62 @@ function ensureFabric() {
|
|
|
1941
2340
|
const beforeJson = this._serializeCanvasState();
|
|
1942
2341
|
const merged = await this.exportImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
|
|
1943
2342
|
this.removeAllMasks({ saveHistory: false });
|
|
1944
|
-
await this.loadImage(merged);
|
|
2343
|
+
await this.loadImage(merged, { preserveScroll: true });
|
|
1945
2344
|
const afterJson = this._serializeCanvasState();
|
|
1946
2345
|
this._pushStateTransition(beforeJson, afterJson);
|
|
1947
|
-
} catch (
|
|
1948
|
-
this._reportError('merge error',
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
this._reportError('merge error', error);
|
|
1949
2348
|
}
|
|
1950
2349
|
}
|
|
1951
2350
|
|
|
1952
2351
|
/**
|
|
1953
|
-
* @
|
|
2352
|
+
* Backward-compatible alias for {@link ImageEditor#mergeMasks}.
|
|
2353
|
+
*
|
|
2354
|
+
* @deprecated Use mergeMasks() instead. This alias will be removed in v2.0.0.
|
|
2355
|
+
* @returns {Promise<void>} Resolves when mask flattening is complete.
|
|
1954
2356
|
*/
|
|
1955
2357
|
async merge() {
|
|
1956
2358
|
return this.mergeMasks();
|
|
1957
2359
|
}
|
|
1958
2360
|
|
|
1959
2361
|
/**
|
|
1960
|
-
* Triggers a JPEG image download of the current canvas
|
|
2362
|
+
* Triggers a JPEG image download of the current canvas.
|
|
2363
|
+
*
|
|
1961
2364
|
* The image area and multiplier are controlled by options.
|
|
1962
2365
|
* @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
|
|
2366
|
+
* @returns {void}
|
|
2367
|
+
* @public
|
|
1963
2368
|
*/
|
|
1964
2369
|
downloadImage(fileName = this.options.defaultDownloadFileName) {
|
|
1965
2370
|
if (!this.originalImage) return;
|
|
1966
2371
|
const exportImageArea = this.options.exportImageAreaByDefault;
|
|
1967
2372
|
this.exportImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier })
|
|
1968
|
-
.then(
|
|
2373
|
+
.then(imageBase64 => {
|
|
1969
2374
|
const link = document.createElement('a');
|
|
1970
2375
|
link.download = fileName;
|
|
1971
|
-
link.href =
|
|
2376
|
+
link.href = imageBase64;
|
|
1972
2377
|
document.body.appendChild(link);
|
|
1973
2378
|
link.click();
|
|
1974
2379
|
document.body.removeChild(link);
|
|
1975
2380
|
})
|
|
1976
|
-
.catch(
|
|
2381
|
+
.catch(error => this._reportError('download error', error));
|
|
1977
2382
|
}
|
|
1978
2383
|
|
|
1979
2384
|
/**
|
|
1980
|
-
* Exports the image as a Base64-encoded
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
2385
|
+
* Exports the current image as a Base64-encoded data URL.
|
|
2386
|
+
*
|
|
2387
|
+
* When `exportImageArea` is false, the export omits masks and labels. When it is true, masks are
|
|
2388
|
+
* temporarily rendered as opaque export shapes and then restored, so editable mask state is not mutated.
|
|
2389
|
+
*
|
|
1983
2390
|
* @async
|
|
1984
2391
|
* @param {Object} [options={}] - Export options.
|
|
1985
2392
|
* @param {boolean} [options.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
|
|
1986
2393
|
* @param {number} [options.multiplier=1] - Scaling multiplier for output (resolution).
|
|
1987
2394
|
* @param {number} [options.quality=0.92] - Image quality between 0 and 1 for lossy formats.
|
|
1988
2395
|
* @param {string} [options.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp').
|
|
1989
|
-
* @returns {Promise<string>}
|
|
2396
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
1990
2397
|
* @throws {Error} If there is no image loaded.
|
|
2398
|
+
* @public
|
|
1991
2399
|
*/
|
|
1992
2400
|
async exportImageBase64(options = {}) {
|
|
1993
2401
|
if (!this.originalImage) throw new Error('No image loaded');
|
|
@@ -2007,12 +2415,9 @@ function ensureFabric() {
|
|
|
2007
2415
|
|
|
2008
2416
|
this.originalImage.setCoords();
|
|
2009
2417
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2010
|
-
const
|
|
2418
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2011
2419
|
return await this._exportCanvasRegionToDataURL({
|
|
2012
|
-
|
|
2013
|
-
sy,
|
|
2014
|
-
sw,
|
|
2015
|
-
sh,
|
|
2420
|
+
...exportRegion,
|
|
2016
2421
|
multiplier,
|
|
2017
2422
|
quality,
|
|
2018
2423
|
format
|
|
@@ -2025,7 +2430,7 @@ function ensureFabric() {
|
|
|
2025
2430
|
}
|
|
2026
2431
|
}
|
|
2027
2432
|
|
|
2028
|
-
//
|
|
2433
|
+
// Render masks as export shapes without mutating their editable styles.
|
|
2029
2434
|
const masks = this.canvas.getObjects().filter(object => object.maskId);
|
|
2030
2435
|
const maskStyleBackups = masks.map(mask => ({
|
|
2031
2436
|
object: mask,
|
|
@@ -2039,29 +2444,26 @@ function ensureFabric() {
|
|
|
2039
2444
|
|
|
2040
2445
|
let finalBase64;
|
|
2041
2446
|
try {
|
|
2042
|
-
//
|
|
2447
|
+
// Labels are UI overlays and should not be part of the flattened export.
|
|
2043
2448
|
masks.forEach(mask => this._removeLabelForMask(mask));
|
|
2044
2449
|
this.canvas.discardActiveObject();
|
|
2045
2450
|
this.canvas.renderAll();
|
|
2046
2451
|
|
|
2047
|
-
//
|
|
2452
|
+
// The export treats masks as opaque shapes with no editable border.
|
|
2048
2453
|
masks.forEach(mask => {
|
|
2049
2454
|
mask.set({ opacity: 1, fill: '#000000', strokeWidth: 0, stroke: null, selectable: false });
|
|
2050
2455
|
mask.setCoords();
|
|
2051
2456
|
});
|
|
2052
2457
|
this.canvas.renderAll();
|
|
2053
2458
|
|
|
2054
|
-
// Compute integer
|
|
2459
|
+
// Compute an integer canvas region for the base image.
|
|
2055
2460
|
this.originalImage.setCoords();
|
|
2056
2461
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2057
|
-
const
|
|
2462
|
+
const exportRegion = this._getClampedCanvasRegion(imageBounds, { includePartialPixels: false });
|
|
2058
2463
|
|
|
2059
2464
|
// Crop precisely in offscreen canvas
|
|
2060
2465
|
finalBase64 = await this._exportCanvasRegionToDataURL({
|
|
2061
|
-
|
|
2062
|
-
sy,
|
|
2063
|
-
sw,
|
|
2064
|
-
sh,
|
|
2466
|
+
...exportRegion,
|
|
2065
2467
|
multiplier,
|
|
2066
2468
|
quality,
|
|
2067
2469
|
format
|
|
@@ -2088,15 +2490,21 @@ function ensureFabric() {
|
|
|
2088
2490
|
}
|
|
2089
2491
|
|
|
2090
2492
|
/**
|
|
2091
|
-
* @
|
|
2493
|
+
* Backward-compatible alias for {@link ImageEditor#exportImageBase64}.
|
|
2494
|
+
*
|
|
2495
|
+
* @deprecated Use exportImageBase64() instead. This alias will be removed in v2.0.0.
|
|
2496
|
+
* @param {Object} [options={}] - Export options passed to exportImageBase64().
|
|
2497
|
+
* @returns {Promise<string>} Resolves with an image data URL.
|
|
2092
2498
|
*/
|
|
2093
2499
|
async getImageBase64(options = {}) {
|
|
2094
2500
|
return this.exportImageBase64(options);
|
|
2095
2501
|
}
|
|
2096
2502
|
|
|
2097
2503
|
/**
|
|
2098
|
-
* Exports the current
|
|
2099
|
-
*
|
|
2504
|
+
* Exports the current image as a File object.
|
|
2505
|
+
*
|
|
2506
|
+
* The export can include flattened masks (`mergeMask: true`) or only the plain base image (`mergeMask: false`).
|
|
2507
|
+
* Supported output formats are JPEG, PNG, and WebP.
|
|
2100
2508
|
*
|
|
2101
2509
|
* @async
|
|
2102
2510
|
* @param {Object} [options={}] - Export options.
|
|
@@ -2122,17 +2530,17 @@ function ensureFabric() {
|
|
|
2122
2530
|
|
|
2123
2531
|
const safeFileType = this._normalizeImageFormat(fileType);
|
|
2124
2532
|
|
|
2125
|
-
//
|
|
2126
|
-
let
|
|
2533
|
+
// Generate the data URL in the requested export mode.
|
|
2534
|
+
let imageBase64;
|
|
2127
2535
|
if (mergeMask) {
|
|
2128
|
-
|
|
2536
|
+
imageBase64 = await this.exportImageBase64({
|
|
2129
2537
|
exportImageArea: true,
|
|
2130
2538
|
multiplier,
|
|
2131
2539
|
quality,
|
|
2132
2540
|
fileType: safeFileType
|
|
2133
2541
|
});
|
|
2134
2542
|
} else {
|
|
2135
|
-
|
|
2543
|
+
imageBase64 = await this.exportImageBase64({
|
|
2136
2544
|
exportImageArea: false,
|
|
2137
2545
|
multiplier,
|
|
2138
2546
|
quality,
|
|
@@ -2141,9 +2549,9 @@ function ensureFabric() {
|
|
|
2141
2549
|
}
|
|
2142
2550
|
|
|
2143
2551
|
// Convert to the required image format
|
|
2144
|
-
let imageDataUrl =
|
|
2552
|
+
let imageDataUrl = imageBase64;
|
|
2145
2553
|
if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
|
|
2146
|
-
// Redraw
|
|
2554
|
+
// Redraw the exported data URL when the browser returned a different image format.
|
|
2147
2555
|
imageDataUrl = await new Promise((resolve, reject) => {
|
|
2148
2556
|
const imageElement = new window.Image();
|
|
2149
2557
|
imageElement.crossOrigin = "Anonymous";
|
|
@@ -2159,11 +2567,11 @@ function ensureFabric() {
|
|
|
2159
2567
|
} catch (error) { reject(error); }
|
|
2160
2568
|
};
|
|
2161
2569
|
imageElement.onerror = reject;
|
|
2162
|
-
imageElement.src =
|
|
2570
|
+
imageElement.src = imageBase64;
|
|
2163
2571
|
});
|
|
2164
2572
|
}
|
|
2165
2573
|
|
|
2166
|
-
// Convert
|
|
2574
|
+
// Convert the final data URL to a File with the requested MIME type.
|
|
2167
2575
|
const binaryString = atob(imageDataUrl.split(',')[1]);
|
|
2168
2576
|
const mime = `image/${safeFileType}`;
|
|
2169
2577
|
let byteIndex = binaryString.length;
|
|
@@ -2237,7 +2645,12 @@ function ensureFabric() {
|
|
|
2237
2645
|
}
|
|
2238
2646
|
|
|
2239
2647
|
/**
|
|
2240
|
-
*
|
|
2648
|
+
* Enters crop mode by creating a resizable crop rectangle above the base image.
|
|
2649
|
+
*
|
|
2650
|
+
* Other canvas objects are made non-interactive while crop mode is active. Masks can be hidden during
|
|
2651
|
+
* cropping when `crop.hideMasksDuringCrop` is enabled.
|
|
2652
|
+
*
|
|
2653
|
+
* @returns {void}
|
|
2241
2654
|
* @public
|
|
2242
2655
|
*/
|
|
2243
2656
|
enterCropMode() {
|
|
@@ -2245,24 +2658,30 @@ function ensureFabric() {
|
|
|
2245
2658
|
if (!this.isImageLoaded()) return;
|
|
2246
2659
|
this._cropMode = true;
|
|
2247
2660
|
|
|
2248
|
-
// Disable
|
|
2661
|
+
// Disable group selection so only the crop rectangle can be manipulated.
|
|
2249
2662
|
this._prevSelectionSetting = this.canvas.selection;
|
|
2250
2663
|
this.canvas.selection = false;
|
|
2251
2664
|
|
|
2252
|
-
//
|
|
2665
|
+
// Clear the current selection before activating the crop rectangle.
|
|
2253
2666
|
this.canvas.discardActiveObject();
|
|
2254
2667
|
|
|
2255
|
-
// Create initial crop
|
|
2668
|
+
// Create the initial crop rectangle inside the image bounds.
|
|
2256
2669
|
this.originalImage.setCoords();
|
|
2257
2670
|
const imageBounds = this.originalImage.getBoundingRect(true, true);
|
|
2258
|
-
//
|
|
2671
|
+
// Use a small inset so the user can see the crop boundary.
|
|
2259
2672
|
const padding = (this.options.crop && this.options.crop.padding) ? this.options.crop.padding : 10;
|
|
2260
2673
|
const left = Math.max(0, Math.floor(imageBounds.left + padding));
|
|
2261
2674
|
const top = Math.max(0, Math.floor(imageBounds.top + padding));
|
|
2262
|
-
const
|
|
2263
|
-
const
|
|
2264
|
-
|
|
2265
|
-
|
|
2675
|
+
const maxCropWidth = Math.max(1, Math.floor(imageBounds.width - padding * 2));
|
|
2676
|
+
const maxCropHeight = Math.max(1, Math.floor(imageBounds.height - padding * 2));
|
|
2677
|
+
const configuredMinWidth = Math.max(1, Number(this.options.crop.minWidth) || 50);
|
|
2678
|
+
const configuredMinHeight = Math.max(1, Number(this.options.crop.minHeight) || 50);
|
|
2679
|
+
const minCropWidth = Math.min(configuredMinWidth, maxCropWidth);
|
|
2680
|
+
const minCropHeight = Math.min(configuredMinHeight, maxCropHeight);
|
|
2681
|
+
const width = minCropWidth;
|
|
2682
|
+
const height = minCropHeight;
|
|
2683
|
+
|
|
2684
|
+
// Visual style for the temporary crop rectangle.
|
|
2266
2685
|
const cropRect = new fabric.Rect({
|
|
2267
2686
|
left, top,
|
|
2268
2687
|
width, height,
|
|
@@ -2277,7 +2696,8 @@ function ensureFabric() {
|
|
|
2277
2696
|
cornerSize: 8,
|
|
2278
2697
|
objectCaching: false,
|
|
2279
2698
|
originX: 'left',
|
|
2280
|
-
originY: 'top'
|
|
2699
|
+
originY: 'top',
|
|
2700
|
+
lockScalingFlip: true
|
|
2281
2701
|
});
|
|
2282
2702
|
|
|
2283
2703
|
// Ensure the crop rect is above everything
|
|
@@ -2286,11 +2706,10 @@ function ensureFabric() {
|
|
|
2286
2706
|
this.canvas.bringToFront(cropRect);
|
|
2287
2707
|
this.canvas.setActiveObject(cropRect);
|
|
2288
2708
|
|
|
2289
|
-
//
|
|
2709
|
+
// Store the crop rectangle so apply/cancel can clean it up.
|
|
2290
2710
|
this._cropRect = cropRect;
|
|
2291
2711
|
|
|
2292
|
-
//
|
|
2293
|
-
// but still allow moving/scaling it. To be safe, set other objects evented=false temporarily.
|
|
2712
|
+
// Keep only the crop rectangle interactive while preserving each object's previous state.
|
|
2294
2713
|
this._cropPrevEvented = [];
|
|
2295
2714
|
const shouldHideMasks = !!(this.options.crop && this.options.crop.hideMasksDuringCrop);
|
|
2296
2715
|
this.canvas.getObjects().forEach(object => {
|
|
@@ -2307,13 +2726,23 @@ function ensureFabric() {
|
|
|
2307
2726
|
}
|
|
2308
2727
|
});
|
|
2309
2728
|
|
|
2310
|
-
//
|
|
2311
|
-
const handleCropRectModified = () => {
|
|
2729
|
+
// Keep Fabric controls and configured size limits in sync as the crop rectangle changes.
|
|
2730
|
+
const handleCropRectModified = () => {
|
|
2731
|
+
try {
|
|
2732
|
+
const cropWidth = Math.max(1, Number(cropRect.width) || 1);
|
|
2733
|
+
const cropHeight = Math.max(1, Number(cropRect.height) || 1);
|
|
2734
|
+
const nextScaleX = Math.min(maxCropWidth / cropWidth, Math.max(minCropWidth / cropWidth, Number(cropRect.scaleX) || 1));
|
|
2735
|
+
const nextScaleY = Math.min(maxCropHeight / cropHeight, Math.max(minCropHeight / cropHeight, Number(cropRect.scaleY) || 1));
|
|
2736
|
+
cropRect.set({ scaleX: nextScaleX, scaleY: nextScaleY });
|
|
2737
|
+
cropRect.setCoords();
|
|
2738
|
+
this.canvas.requestRenderAll();
|
|
2739
|
+
} catch (error) { void error; }
|
|
2740
|
+
};
|
|
2312
2741
|
cropRect.on('modified', handleCropRectModified);
|
|
2313
2742
|
cropRect.on('moving', handleCropRectModified);
|
|
2314
2743
|
cropRect.on('scaling', handleCropRectModified);
|
|
2315
2744
|
|
|
2316
|
-
//
|
|
2745
|
+
// Store handlers so cancel/apply/dispose can unbind them.
|
|
2317
2746
|
this._cropHandlers.push({
|
|
2318
2747
|
target: cropRect,
|
|
2319
2748
|
handlers: [
|
|
@@ -2328,7 +2757,9 @@ function ensureFabric() {
|
|
|
2328
2757
|
}
|
|
2329
2758
|
|
|
2330
2759
|
/**
|
|
2331
|
-
*
|
|
2760
|
+
* Cancels crop mode and removes the temporary crop rectangle.
|
|
2761
|
+
*
|
|
2762
|
+
* @returns {void}
|
|
2332
2763
|
* @public
|
|
2333
2764
|
*/
|
|
2334
2765
|
cancelCrop() {
|
|
@@ -2336,7 +2767,7 @@ function ensureFabric() {
|
|
|
2336
2767
|
this._removeCropRect();
|
|
2337
2768
|
this._restoreCropObjectState();
|
|
2338
2769
|
this._cropMode = false;
|
|
2339
|
-
//
|
|
2770
|
+
// Restore the canvas selection setting that was active before crop mode.
|
|
2340
2771
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2341
2772
|
this._prevSelectionSetting = undefined;
|
|
2342
2773
|
|
|
@@ -2346,23 +2777,29 @@ function ensureFabric() {
|
|
|
2346
2777
|
}
|
|
2347
2778
|
|
|
2348
2779
|
/**
|
|
2349
|
-
*
|
|
2350
|
-
*
|
|
2780
|
+
* Applies the current crop rectangle to the base image.
|
|
2781
|
+
*
|
|
2782
|
+
* Masks are removed by default. When `crop.preserveMasksAfterCrop` is true, masks that intersect the crop
|
|
2783
|
+
* region are shifted into the cropped coordinate space and remain editable. The operation is recorded as a
|
|
2784
|
+
* single undoable history transition.
|
|
2785
|
+
*
|
|
2786
|
+
* @async
|
|
2787
|
+
* @returns {Promise<void>} Resolves after the cropped image has been loaded and history is updated.
|
|
2351
2788
|
* @public
|
|
2352
2789
|
*/
|
|
2353
2790
|
async applyCrop() {
|
|
2354
2791
|
if (!this.canvas || !this._cropMode || !this._cropRect) return;
|
|
2355
2792
|
|
|
2356
|
-
//
|
|
2793
|
+
// Fabric does not update control coordinates automatically after programmatic transforms.
|
|
2357
2794
|
this._cropRect.setCoords();
|
|
2358
2795
|
const rectBounds = this._cropRect.getBoundingRect(true, true);
|
|
2359
2796
|
|
|
2360
|
-
const
|
|
2797
|
+
const cropRegion = this._getClampedCanvasRegion(rectBounds, { includePartialPixels: false });
|
|
2361
2798
|
const shouldPreserveMasks = !!(this.options.crop && this.options.crop.preserveMasksAfterCrop);
|
|
2362
2799
|
|
|
2363
2800
|
this._restoreCropObjectState();
|
|
2364
2801
|
|
|
2365
|
-
let beforeJson
|
|
2802
|
+
let beforeJson;
|
|
2366
2803
|
try {
|
|
2367
2804
|
beforeJson = this._serializeCanvasState();
|
|
2368
2805
|
} catch (error) {
|
|
@@ -2380,16 +2817,16 @@ function ensureFabric() {
|
|
|
2380
2817
|
mask.setCoords();
|
|
2381
2818
|
const maskBounds = mask.getBoundingRect(true, true);
|
|
2382
2819
|
const intersectsCrop =
|
|
2383
|
-
maskBounds.left <
|
|
2384
|
-
maskBounds.left + maskBounds.width >
|
|
2385
|
-
maskBounds.top <
|
|
2386
|
-
maskBounds.top + maskBounds.height >
|
|
2820
|
+
maskBounds.left < cropRegion.sourceX + cropRegion.sourceWidth &&
|
|
2821
|
+
maskBounds.left + maskBounds.width > cropRegion.sourceX &&
|
|
2822
|
+
maskBounds.top < cropRegion.sourceY + cropRegion.sourceHeight &&
|
|
2823
|
+
maskBounds.top + maskBounds.height > cropRegion.sourceY;
|
|
2387
2824
|
this._removeLabelForMask(mask);
|
|
2388
2825
|
this.canvas.remove(mask);
|
|
2389
2826
|
if (shouldPreserveMasks && intersectsCrop) {
|
|
2390
2827
|
mask.set({
|
|
2391
|
-
left: (mask.left || 0) -
|
|
2392
|
-
top: (mask.top || 0) -
|
|
2828
|
+
left: (mask.left || 0) - cropRegion.sourceX,
|
|
2829
|
+
top: (mask.top || 0) - cropRegion.sourceY,
|
|
2393
2830
|
visible: true
|
|
2394
2831
|
});
|
|
2395
2832
|
mask.setCoords();
|
|
@@ -2409,19 +2846,16 @@ function ensureFabric() {
|
|
|
2409
2846
|
|
|
2410
2847
|
this._removeCropRect();
|
|
2411
2848
|
|
|
2412
|
-
// End crop mode
|
|
2849
|
+
// End crop mode before loading the cropped image.
|
|
2413
2850
|
this._cropMode = false;
|
|
2414
2851
|
this.canvas.selection = !!this._prevSelectionSetting;
|
|
2415
2852
|
this._prevSelectionSetting = undefined;
|
|
2416
2853
|
|
|
2417
|
-
// Export
|
|
2854
|
+
// Export the crop region from the current canvas.
|
|
2418
2855
|
let croppedBase64;
|
|
2419
2856
|
try {
|
|
2420
2857
|
croppedBase64 = await this._exportCanvasRegionToDataURL({
|
|
2421
|
-
|
|
2422
|
-
sy,
|
|
2423
|
-
sw,
|
|
2424
|
-
sh,
|
|
2858
|
+
...cropRegion,
|
|
2425
2859
|
multiplier: 1,
|
|
2426
2860
|
quality: this._normalizeQuality(this.options.downsampleQuality),
|
|
2427
2861
|
format: 'jpeg'
|
|
@@ -2431,7 +2865,7 @@ function ensureFabric() {
|
|
|
2431
2865
|
return;
|
|
2432
2866
|
}
|
|
2433
2867
|
|
|
2434
|
-
// Load the cropped image as the new base image
|
|
2868
|
+
// Load the cropped image as the new base image.
|
|
2435
2869
|
try {
|
|
2436
2870
|
await this.loadImage(croppedBase64);
|
|
2437
2871
|
if (preservedMasks.length) {
|
|
@@ -2445,27 +2879,27 @@ function ensureFabric() {
|
|
|
2445
2879
|
this._updateMaskList();
|
|
2446
2880
|
this.canvas.renderAll();
|
|
2447
2881
|
}
|
|
2448
|
-
} catch (
|
|
2449
|
-
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed',
|
|
2882
|
+
} catch (error) {
|
|
2883
|
+
await this._restoreStateAfterCropFailure(beforeJson, 'applyCrop: loadImage(croppedBase64) failed', error);
|
|
2450
2884
|
return;
|
|
2451
2885
|
}
|
|
2452
2886
|
|
|
2453
|
-
// Create
|
|
2454
|
-
let afterJson
|
|
2887
|
+
// Create an after snapshot and push one history command for the crop operation.
|
|
2888
|
+
let afterJson;
|
|
2455
2889
|
try {
|
|
2456
2890
|
afterJson = this._serializeCanvasState();
|
|
2457
|
-
} catch (
|
|
2458
|
-
this._reportWarning('applyCrop: failed to serialize after state',
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
this._reportWarning('applyCrop: failed to serialize after state', error);
|
|
2459
2893
|
afterJson = null;
|
|
2460
2894
|
}
|
|
2461
2895
|
|
|
2462
2896
|
try {
|
|
2463
2897
|
this._pushStateTransition(beforeJson, afterJson);
|
|
2464
|
-
} catch (
|
|
2465
|
-
this._reportWarning('applyCrop: failed to push history command',
|
|
2898
|
+
} catch (error) {
|
|
2899
|
+
this._reportWarning('applyCrop: failed to push history command', error);
|
|
2466
2900
|
}
|
|
2467
2901
|
|
|
2468
|
-
//
|
|
2902
|
+
// Refresh UI state after crop completion.
|
|
2469
2903
|
this._updateUI();
|
|
2470
2904
|
this.canvas.renderAll();
|
|
2471
2905
|
}
|
|
@@ -2500,7 +2934,7 @@ function ensureFabric() {
|
|
|
2500
2934
|
const isInCropMode = !!this._cropMode;
|
|
2501
2935
|
|
|
2502
2936
|
if (isInCropMode) {
|
|
2503
|
-
//
|
|
2937
|
+
// Disable all controls except the crop action buttons while crop mode is active.
|
|
2504
2938
|
for (const key of Object.keys(this.elements || {})) {
|
|
2505
2939
|
const element = document.getElementById(this.elements[key]);
|
|
2506
2940
|
if (!element) continue;
|
|
@@ -2563,7 +2997,7 @@ function ensureFabric() {
|
|
|
2563
2997
|
}
|
|
2564
2998
|
|
|
2565
2999
|
/**
|
|
2566
|
-
*
|
|
3000
|
+
* Updates placeholder and canvas container visibility based on whether an image is loaded.
|
|
2567
3001
|
* @private
|
|
2568
3002
|
*/
|
|
2569
3003
|
_updatePlaceholderStatus() {
|
|
@@ -2572,21 +3006,30 @@ function ensureFabric() {
|
|
|
2572
3006
|
}
|
|
2573
3007
|
|
|
2574
3008
|
/**
|
|
2575
|
-
*
|
|
2576
|
-
*
|
|
3009
|
+
* Shows or hides the placeholder and canvas container.
|
|
3010
|
+
*
|
|
3011
|
+
* @param {boolean} show - If true, displays the placeholder; otherwise displays the canvas container.
|
|
2577
3012
|
* @private
|
|
2578
3013
|
*/
|
|
2579
3014
|
_setPlaceholderVisible(show) {
|
|
2580
|
-
if (!this.placeholderElement) return;
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
3015
|
+
if (!this.placeholderElement || !this.containerElement) return;
|
|
3016
|
+
this._setElementVisible(this.placeholderElement, show);
|
|
3017
|
+
this._setElementVisible(this.containerElement, !show);
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* Updates element visibility.
|
|
3022
|
+
*
|
|
3023
|
+
* @param {HTMLElement} element - Element whose visibility should be updated.
|
|
3024
|
+
* @param {boolean} isVisible - If true, removes the hidden state.
|
|
3025
|
+
* @returns {void}
|
|
3026
|
+
* @private
|
|
3027
|
+
*/
|
|
3028
|
+
_setElementVisible(element, isVisible) {
|
|
3029
|
+
if (!element) return;
|
|
3030
|
+
element.hidden = !isVisible;
|
|
3031
|
+
element.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
|
|
3032
|
+
if (isVisible && element.classList) element.classList.remove('d-none');
|
|
2590
3033
|
}
|
|
2591
3034
|
|
|
2592
3035
|
/**
|
|
@@ -2608,16 +3051,16 @@ function ensureFabric() {
|
|
|
2608
3051
|
} catch (error) { void error; }
|
|
2609
3052
|
|
|
2610
3053
|
if (this._cropRect) {
|
|
2611
|
-
try { this.canvas.remove(this._cropRect); } catch (
|
|
3054
|
+
try { this.canvas.remove(this._cropRect); } catch (error) { void error; }
|
|
2612
3055
|
this._cropRect = null;
|
|
2613
3056
|
}
|
|
2614
3057
|
|
|
2615
3058
|
if (this.containerElement && this._containerOriginalOverflow !== undefined) {
|
|
2616
|
-
try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (
|
|
3059
|
+
try { this.containerElement.style.overflow = this._containerOriginalOverflow; } catch (error) { void error; }
|
|
2617
3060
|
}
|
|
2618
3061
|
|
|
2619
3062
|
if (this.canvas) {
|
|
2620
|
-
try { this.canvas.dispose(); } catch (
|
|
3063
|
+
try { this.canvas.dispose(); } catch (error) { void error; }
|
|
2621
3064
|
this.canvas = null;
|
|
2622
3065
|
this.canvasElement = null;
|
|
2623
3066
|
this.isImageLoadedToCanvas = false;
|
|
@@ -2627,113 +3070,160 @@ function ensureFabric() {
|
|
|
2627
3070
|
}
|
|
2628
3071
|
|
|
2629
3072
|
/**
|
|
2630
|
-
*
|
|
2631
|
-
* @
|
|
3073
|
+
* @callback AnimationTaskCallback
|
|
3074
|
+
* @returns {unknown} Animation result or awaitable animation result.
|
|
3075
|
+
*/
|
|
3076
|
+
|
|
3077
|
+
/**
|
|
3078
|
+
* @callback PromiseResolveCallback
|
|
3079
|
+
* @param {unknown} value - Promise resolution value.
|
|
3080
|
+
* @returns {void}
|
|
3081
|
+
*/
|
|
3082
|
+
|
|
3083
|
+
/**
|
|
3084
|
+
* @callback PromiseRejectCallback
|
|
3085
|
+
* @param {unknown} reason - Promise rejection reason.
|
|
3086
|
+
* @returns {void}
|
|
3087
|
+
*/
|
|
3088
|
+
|
|
3089
|
+
/**
|
|
3090
|
+
* @typedef {Object} QueuedAnimationTask
|
|
3091
|
+
* @property {AnimationTaskCallback} animationFn - Queued animation function.
|
|
3092
|
+
* @property {PromiseResolveCallback} resolve - Promise resolver for the queued animation.
|
|
3093
|
+
* @property {PromiseRejectCallback} reject - Promise rejecter for the queued animation.
|
|
3094
|
+
*/
|
|
3095
|
+
|
|
3096
|
+
/**
|
|
3097
|
+
* @callback HistoryTaskCallback
|
|
3098
|
+
* @returns {void|Promise<void>} Result of a history operation.
|
|
3099
|
+
*/
|
|
3100
|
+
|
|
3101
|
+
/**
|
|
3102
|
+
* FIFO queue that serializes transform animations so Fabric state changes do not overlap.
|
|
3103
|
+
*
|
|
3104
|
+
* @private
|
|
2632
3105
|
*/
|
|
2633
3106
|
class AnimationQueue {
|
|
2634
3107
|
/**
|
|
2635
|
-
* Creates
|
|
2636
|
-
*
|
|
2637
|
-
* @constructor
|
|
3108
|
+
* Creates an empty animation queue.
|
|
2638
3109
|
*/
|
|
2639
3110
|
constructor() {
|
|
2640
3111
|
/**
|
|
2641
|
-
*
|
|
2642
|
-
* @type {Array<
|
|
3112
|
+
* Pending animation descriptors.
|
|
3113
|
+
* @type {Array<QueuedAnimationTask>}
|
|
2643
3114
|
*/
|
|
2644
|
-
this.
|
|
3115
|
+
this.animationTasks = [];
|
|
2645
3116
|
/**
|
|
2646
|
-
*
|
|
3117
|
+
* Whether an animation task is currently running.
|
|
2647
3118
|
* @type {boolean}
|
|
2648
3119
|
*/
|
|
2649
|
-
this.
|
|
3120
|
+
this.isRunning = false;
|
|
2650
3121
|
}
|
|
2651
3122
|
|
|
2652
3123
|
/**
|
|
2653
3124
|
* Adds an animation function to the queue.
|
|
2654
3125
|
*
|
|
2655
|
-
* @param
|
|
2656
|
-
* @returns {Promise
|
|
3126
|
+
* @param {AnimationTaskCallback} animationFn - Function that returns a value, Promise, or awaitable animation result.
|
|
3127
|
+
* @returns {Promise<unknown>} Resolves or rejects with the queued animation result.
|
|
2657
3128
|
*/
|
|
2658
3129
|
async add(animationFn) {
|
|
2659
3130
|
return new Promise((resolve, reject) => {
|
|
2660
|
-
|
|
2661
|
-
this.
|
|
2662
|
-
|
|
2663
|
-
if (!this.running) {
|
|
2664
|
-
this.processQueue();
|
|
3131
|
+
this.animationTasks.push({ animationFn, resolve, reject });
|
|
3132
|
+
if (!this.isRunning) {
|
|
3133
|
+
this._drainQueue();
|
|
2665
3134
|
}
|
|
2666
3135
|
});
|
|
2667
3136
|
}
|
|
2668
3137
|
|
|
2669
3138
|
/**
|
|
2670
|
-
*
|
|
3139
|
+
* Runs queued animation tasks sequentially until the queue is empty.
|
|
2671
3140
|
*
|
|
2672
3141
|
* @private
|
|
2673
3142
|
* @returns {Promise<void>}
|
|
2674
3143
|
*/
|
|
2675
|
-
async
|
|
2676
|
-
if (this.
|
|
2677
|
-
this.
|
|
3144
|
+
async _drainQueue() {
|
|
3145
|
+
if (this.animationTasks.length === 0) {
|
|
3146
|
+
this.isRunning = false;
|
|
2678
3147
|
return;
|
|
2679
3148
|
}
|
|
2680
3149
|
|
|
2681
|
-
this.
|
|
2682
|
-
const {
|
|
3150
|
+
this.isRunning = true;
|
|
3151
|
+
const { animationFn, resolve, reject } = this.animationTasks.shift();
|
|
2683
3152
|
|
|
2684
3153
|
try {
|
|
2685
|
-
const result = await
|
|
3154
|
+
const result = await animationFn();
|
|
2686
3155
|
resolve(result);
|
|
2687
3156
|
} catch (error) {
|
|
2688
3157
|
reject(error);
|
|
2689
3158
|
}
|
|
2690
3159
|
|
|
2691
|
-
this.
|
|
3160
|
+
await this._drainQueue();
|
|
2692
3161
|
}
|
|
2693
3162
|
}
|
|
2694
3163
|
|
|
2695
3164
|
/**
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
3165
|
+
* Undoable command with paired execute and undo operations.
|
|
3166
|
+
*
|
|
3167
|
+
* @private
|
|
2698
3168
|
*/
|
|
2699
3169
|
class Command {
|
|
2700
3170
|
/**
|
|
2701
|
-
* @param {
|
|
2702
|
-
* @param {
|
|
3171
|
+
* @param {HistoryTaskCallback} execute - Function that performs the action.
|
|
3172
|
+
* @param {HistoryTaskCallback} undo - Function that reverts the action.
|
|
2703
3173
|
*/
|
|
2704
3174
|
constructor(execute, undo) {
|
|
2705
3175
|
/**
|
|
2706
3176
|
* Executes the command.
|
|
2707
|
-
* @type {
|
|
3177
|
+
* @type {HistoryTaskCallback}
|
|
2708
3178
|
*/
|
|
2709
3179
|
this.execute = execute;
|
|
2710
3180
|
/**
|
|
2711
3181
|
* Undoes the command.
|
|
2712
|
-
* @type {
|
|
3182
|
+
* @type {HistoryTaskCallback}
|
|
2713
3183
|
*/
|
|
2714
3184
|
this.undo = undo;
|
|
2715
3185
|
}
|
|
2716
3186
|
}
|
|
2717
3187
|
|
|
2718
3188
|
/**
|
|
2719
|
-
* Manages
|
|
2720
|
-
*
|
|
3189
|
+
* Manages undo/redo history and serializes asynchronous history operations.
|
|
3190
|
+
*
|
|
3191
|
+
* @private
|
|
2721
3192
|
*/
|
|
2722
3193
|
class HistoryManager {
|
|
2723
3194
|
/**
|
|
2724
|
-
* @param {number} [maxSize=50]
|
|
3195
|
+
* @param {number} [maxSize=50] - Maximum number of commands to keep in history.
|
|
2725
3196
|
*/
|
|
2726
3197
|
constructor(maxSize = 50) {
|
|
3198
|
+
/** @type {Array<Command>} */
|
|
2727
3199
|
this.history = [];
|
|
3200
|
+
/** @type {number} */
|
|
2728
3201
|
this.currentIndex = -1;
|
|
3202
|
+
/** @type {number} */
|
|
2729
3203
|
this.maxSize = maxSize;
|
|
3204
|
+
/** @type {Promise<void>} */
|
|
2730
3205
|
this.pending = Promise.resolve();
|
|
2731
3206
|
}
|
|
2732
3207
|
|
|
3208
|
+
/**
|
|
3209
|
+
* Queues a history task after the previously queued undo/redo task completes.
|
|
3210
|
+
*
|
|
3211
|
+
* @param {HistoryTaskCallback} task - Task to run after earlier history work settles.
|
|
3212
|
+
* @returns {Promise<void>} Resolves or rejects with the queued task result.
|
|
3213
|
+
* @private
|
|
3214
|
+
*/
|
|
2733
3215
|
enqueue(task) {
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
|
|
3216
|
+
const nextTask = this.pending.then(task, task);
|
|
3217
|
+
let pendingAfterTask;
|
|
3218
|
+
const resetPending = () => {
|
|
3219
|
+
if (this.pending === pendingAfterTask) {
|
|
3220
|
+
this.pending = Promise.resolve();
|
|
3221
|
+
}
|
|
3222
|
+
};
|
|
3223
|
+
|
|
3224
|
+
pendingAfterTask = nextTask.then(resetPending, resetPending);
|
|
3225
|
+
this.pending = pendingAfterTask;
|
|
3226
|
+
return nextTask;
|
|
2737
3227
|
}
|
|
2738
3228
|
|
|
2739
3229
|
/**
|
|
@@ -2744,7 +3234,6 @@ function ensureFabric() {
|
|
|
2744
3234
|
* @returns {void}
|
|
2745
3235
|
*/
|
|
2746
3236
|
execute(command) {
|
|
2747
|
-
// Perform the command.
|
|
2748
3237
|
command.execute();
|
|
2749
3238
|
this.push(command);
|
|
2750
3239
|
}
|
|
@@ -2757,17 +3246,15 @@ function ensureFabric() {
|
|
|
2757
3246
|
* @returns {void}
|
|
2758
3247
|
*/
|
|
2759
3248
|
push(command) {
|
|
2760
|
-
//
|
|
3249
|
+
// Discard redo commands when a new branch is created.
|
|
2761
3250
|
if (this.currentIndex < this.history.length - 1) {
|
|
2762
3251
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
2763
3252
|
}
|
|
2764
3253
|
|
|
2765
|
-
// Add the new command.
|
|
2766
3254
|
this.history.push(command);
|
|
2767
3255
|
|
|
2768
|
-
// Maintain the max size of the buffer.
|
|
2769
3256
|
if (this.history.length > this.maxSize) {
|
|
2770
|
-
this.history.shift();
|
|
3257
|
+
this.history.shift();
|
|
2771
3258
|
} else {
|
|
2772
3259
|
this.currentIndex++;
|
|
2773
3260
|
}
|
|
@@ -2794,7 +3281,7 @@ function ensureFabric() {
|
|
|
2794
3281
|
/**
|
|
2795
3282
|
* Undoes the last executed command if possible.
|
|
2796
3283
|
*
|
|
2797
|
-
* @returns {void}
|
|
3284
|
+
* @returns {Promise<void>} Resolves after the undo task completes.
|
|
2798
3285
|
*/
|
|
2799
3286
|
undo() {
|
|
2800
3287
|
return this.enqueue(async () => {
|
|
@@ -2809,7 +3296,7 @@ function ensureFabric() {
|
|
|
2809
3296
|
/**
|
|
2810
3297
|
* Redoes the next command in history if possible.
|
|
2811
3298
|
*
|
|
2812
|
-
* @returns {void}
|
|
3299
|
+
* @returns {Promise<void>} Resolves after the redo task completes.
|
|
2813
3300
|
*/
|
|
2814
3301
|
redo() {
|
|
2815
3302
|
return this.enqueue(async () => {
|