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