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