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