@bensitu/image-editor 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2004 @@
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.1
9
+ * @author Ben Situ
10
+ * @license MIT
11
+ * @description Lightweight canvas-based image editor with masking/transform/export support.
12
+ *
13
+ * This source file is free software, available under the MIT license.
14
+ * It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
15
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
16
+ * See the license files for details.
17
+ */
18
+ var fabric = null;
19
+ function getGlobalScope() {
20
+ if (typeof globalThis !== "undefined")
21
+ return globalThis;
22
+ if (typeof self !== "undefined")
23
+ return self;
24
+ if (typeof window !== "undefined")
25
+ return window;
26
+ return null;
27
+ }
28
+ function getGlobalFabric() {
29
+ const scope = getGlobalScope();
30
+ return scope && scope.fabric ? scope.fabric : null;
31
+ }
32
+ function setFabric(fabricInstance2) {
33
+ fabric = fabricInstance2 || getGlobalFabric();
34
+ return fabric;
35
+ }
36
+ function ensureFabric() {
37
+ if (!fabric)
38
+ setFabric();
39
+ return fabric;
40
+ }
41
+ var ImageEditor = class {
42
+ constructor(options = {}) {
43
+ const defaultLabel = {
44
+ getText: (mask) => mask.maskName,
45
+ textOptions: {
46
+ fontSize: 12,
47
+ fill: "#fff",
48
+ backgroundColor: "rgba(0,0,0,0.7)",
49
+ padding: 2,
50
+ fontFamily: "monospace",
51
+ fontWeight: "bold",
52
+ selectable: false,
53
+ evented: false,
54
+ originX: "left",
55
+ originY: "top"
56
+ }
57
+ };
58
+ const defaultCrop = {
59
+ minWidth: 100,
60
+ minHeight: 100,
61
+ padding: 10,
62
+ hideMasksDuringCrop: true,
63
+ preserveMasksAfterCrop: false,
64
+ allowRotationOfCropRect: false
65
+ };
66
+ const userLabel = options.label || {};
67
+ const userCrop = options.crop || {};
68
+ this.options = {
69
+ canvasWidth: 800,
70
+ canvasHeight: 600,
71
+ backgroundColor: "transparent",
72
+ animationDuration: 300,
73
+ minScale: 0.1,
74
+ maxScale: 5,
75
+ scaleStep: 0.05,
76
+ rotationStep: 90,
77
+ expandCanvasToImage: true,
78
+ fitImageToCanvas: false,
79
+ coverImageToCanvas: false,
80
+ downsampleOnLoad: true,
81
+ downsampleMaxWidth: 4e3,
82
+ downsampleMaxHeight: 3e3,
83
+ downsampleQuality: 0.92,
84
+ exportMultiplier: 1,
85
+ exportImageAreaByDefault: true,
86
+ defaultMaskWidth: 50,
87
+ defaultMaskHeight: 80,
88
+ maskRotatable: false,
89
+ maskLabelOnSelect: true,
90
+ maskLabelOffset: 3,
91
+ maskName: "mask",
92
+ groupSelection: false,
93
+ showPlaceholder: true,
94
+ initialImageBase64: null,
95
+ // Provide a base64 'data:image/...' string here if you want auto-load
96
+ defaultDownloadFileName: "edited_image.jpg",
97
+ onError: null,
98
+ onWarning: null,
99
+ ...options,
100
+ label: {
101
+ ...defaultLabel,
102
+ ...userLabel,
103
+ textOptions: {
104
+ ...defaultLabel.textOptions,
105
+ ...userLabel.textOptions || {}
106
+ }
107
+ },
108
+ crop: {
109
+ ...defaultCrop,
110
+ ...userCrop
111
+ }
112
+ };
113
+ this._fabricLoaded = !!ensureFabric();
114
+ if (!this._fabricLoaded) {
115
+ this._reportError("fabric.js is not loaded. Please include fabric.js first. Initialization will be aborted.");
116
+ }
117
+ this.canvas = null;
118
+ this.canvasEl = null;
119
+ this.containerEl = null;
120
+ this.placeholderEl = null;
121
+ this.originalImage = null;
122
+ this.baseImageScale = 1;
123
+ this.currentScale = 1;
124
+ this.currentRotation = 0;
125
+ this.maskCounter = 0;
126
+ this.isAnimating = false;
127
+ this.elements = {};
128
+ this.isImageLoadedToCanvas = false;
129
+ this.maxHistorySize = 50;
130
+ this._boundHandlers = {};
131
+ this._lastMask = null;
132
+ this._lastMaskInitialLeft = null;
133
+ this._lastMaskInitialTop = null;
134
+ this._lastMaskInitialWidth = null;
135
+ this._cropMode = false;
136
+ this._cropRect = null;
137
+ this._cropHandlers = [];
138
+ this.onImageLoaded = typeof options.onImageLoaded === "function" ? options.onImageLoaded : null;
139
+ this.animQueue = new AnimationQueue();
140
+ this.historyManager = new HistoryManager(this.maxHistorySize);
141
+ }
142
+ /**
143
+ * Initializes the editor, binds to DOM elements, sets up event handlers,
144
+ * and (optionally) loads an initial image.
145
+ * Use this method to set up the editor UI before interacting with it.
146
+ *
147
+ * @param {Object} [idMap={}] - Optional mapping from logical element names to actual DOM element IDs.
148
+ * Supported keys include: canvas, canvasContainer, imgPlaceholder, scaleRate, rotationLeftInput, rotationRightInput,
149
+ * rotateLeftBtn, rotateRightBtn, addMaskBtn, removeMaskBtn, removeAllMasksBtn, mergeBtn, downloadBtn, maskList,
150
+ * zoomInBtn, zoomOutBtn, resetBtn, imageInput. Unknown keys are ignored.
151
+ *
152
+ * @returns {void}
153
+ *
154
+ * @public
155
+ *
156
+ * @example
157
+ * editor.init({
158
+ * canvas: 'myFabricCanvasId',
159
+ * downloadBtn: 'myDownloadButtonId'
160
+ * });
161
+ */
162
+ init(idMap = {}) {
163
+ if (!this._fabricLoaded)
164
+ return;
165
+ const defaults = {
166
+ canvas: "fabricCanvas",
167
+ canvasContainer: null,
168
+ // Pass an ID here if you have a scrollable viewport container
169
+ imgPlaceholder: "imgPlaceholder",
170
+ scaleRate: "scaleRate",
171
+ rotationLeftInput: "rotationLeftInput",
172
+ rotationRightInput: "rotationRightInput",
173
+ rotateLeftBtn: "rotateLeftBtn",
174
+ rotateRightBtn: "rotateRightBtn",
175
+ addMaskBtn: "addMaskBtn",
176
+ removeMaskBtn: "removeMaskBtn",
177
+ removeAllMasksBtn: "removeAllMasksBtn",
178
+ mergeBtn: "mergeBtn",
179
+ downloadBtn: "downloadBtn",
180
+ maskList: "maskList",
181
+ zoomInBtn: "zoomInBtn",
182
+ zoomOutBtn: "zoomOutBtn",
183
+ resetBtn: "resetBtn",
184
+ undoBtn: "undoBtn",
185
+ redoBtn: "redoBtn",
186
+ imageInput: "imageInput",
187
+ cropBtn: "cropBtn",
188
+ applyCropBtn: "applyCropBtn",
189
+ cancelCropBtn: "cancelCropBtn"
190
+ };
191
+ this.elements = { ...defaults, ...idMap };
192
+ this._initCanvas();
193
+ this._bindEvents();
194
+ this._updateInputs();
195
+ this._updateMaskList();
196
+ this._updateUI();
197
+ if (this.options.initialImageBase64) {
198
+ this.loadImage(this.options.initialImageBase64);
199
+ } else {
200
+ this._updatePlaceholderStatus();
201
+ }
202
+ }
203
+ _reportError(message, error = null) {
204
+ const handler = this.options && this.options.onError;
205
+ if (typeof handler !== "function")
206
+ return;
207
+ try {
208
+ handler(error, message);
209
+ } catch {
210
+ }
211
+ }
212
+ _reportWarning(message, error = null) {
213
+ const handler = this.options && this.options.onWarning;
214
+ if (typeof handler !== "function")
215
+ return;
216
+ try {
217
+ handler(error, message);
218
+ } catch {
219
+ }
220
+ }
221
+ /**
222
+ * Canvas setup helpers
223
+ * @private
224
+ */
225
+ _initCanvas() {
226
+ const canvasEl = document.getElementById(this.elements.canvas);
227
+ if (!canvasEl)
228
+ throw new Error("Canvas is not found: " + this.elements.canvas);
229
+ this.canvasEl = canvasEl;
230
+ if (this.elements.canvasContainer) {
231
+ const ce = document.getElementById(this.elements.canvasContainer);
232
+ this.containerEl = ce || canvasEl.parentElement;
233
+ } else {
234
+ this.containerEl = canvasEl.parentElement;
235
+ }
236
+ this.placeholderEl = document.getElementById(this.elements.imgPlaceholder) || null;
237
+ let initialW = this.options.canvasWidth;
238
+ let initialH = this.options.canvasHeight;
239
+ if (this.containerEl) {
240
+ const cw = Math.floor(this.containerEl.clientWidth);
241
+ const ch = Math.floor(this.containerEl.clientHeight);
242
+ if (cw > 0 && ch > 0) {
243
+ initialW = cw;
244
+ initialH = ch;
245
+ }
246
+ }
247
+ this.canvas = new fabric.Canvas(canvasEl, {
248
+ width: initialW,
249
+ height: initialH,
250
+ backgroundColor: this.options.backgroundColor,
251
+ selection: this.options.groupSelection,
252
+ preserveObjectStacking: true
253
+ });
254
+ this.canvas.on("selection:created", (e) => this._onSelectionChanged(e.selected));
255
+ this.canvas.on("selection:updated", (e) => this._onSelectionChanged(e.selected));
256
+ this.canvas.on("selection:cleared", () => this._onSelectionChanged([]));
257
+ this.canvas.on("object:moving", (e) => {
258
+ if (e.target && e.target.maskId)
259
+ this._syncMaskLabel(e.target);
260
+ });
261
+ this.canvas.on("object:scaling", (e) => {
262
+ if (e.target && e.target.maskId)
263
+ this._syncMaskLabel(e.target);
264
+ });
265
+ this.canvas.on("object:rotating", (e) => {
266
+ if (e.target && e.target.maskId)
267
+ this._syncMaskLabel(e.target);
268
+ });
269
+ this.canvas.on("object:modified", (e) => {
270
+ if (e.target && e.target.maskId)
271
+ this._syncMaskLabel(e.target);
272
+ });
273
+ this.canvasEl.style.display = "block";
274
+ }
275
+ /**
276
+ * DOM / UI bindings
277
+ * @private
278
+ */
279
+ _bindEvents() {
280
+ this._bindIfExists("uploadArea", "click", () => document.getElementById(this.elements.imageInput)?.click());
281
+ const inputEl = document.getElementById(this.elements.imageInput);
282
+ if (inputEl) {
283
+ inputEl.addEventListener("change", (e) => {
284
+ const f = e.target.files && e.target.files[0];
285
+ if (f)
286
+ this._loadImageFile(f);
287
+ });
288
+ }
289
+ this._bindIfExists("zoomInBtn", "click", () => this.scaleImage(this.currentScale + this.options.scaleStep));
290
+ this._bindIfExists("zoomOutBtn", "click", () => this.scaleImage(this.currentScale - this.options.scaleStep));
291
+ this._bindIfExists("resetBtn", "click", () => {
292
+ this.reset();
293
+ });
294
+ this._bindIfExists("addMaskBtn", "click", () => this.addMask());
295
+ this._bindIfExists("removeMaskBtn", "click", () => this.removeSelectedMask());
296
+ this._bindIfExists("removeAllMasksBtn", "click", () => this.removeAllMasks());
297
+ this._bindIfExists("mergeBtn", "click", () => this.merge());
298
+ this._bindIfExists("downloadBtn", "click", () => this.downloadImage());
299
+ this._bindIfExists("undoBtn", "click", () => this.undo());
300
+ this._bindIfExists("redoBtn", "click", () => this.redo());
301
+ const rotLeftBtn = document.getElementById(this.elements.rotateLeftBtn);
302
+ const rotRightBtn = document.getElementById(this.elements.rotateRightBtn);
303
+ if (rotLeftBtn)
304
+ rotLeftBtn.addEventListener("click", () => {
305
+ const el = document.getElementById(this.elements.rotationLeftInput);
306
+ let step = this.options.rotationStep;
307
+ if (el) {
308
+ const p = parseFloat(el.value);
309
+ if (!isNaN(p))
310
+ step = p;
311
+ }
312
+ this.rotateImage(this.currentRotation - step);
313
+ });
314
+ if (rotRightBtn)
315
+ rotRightBtn.addEventListener("click", () => {
316
+ const el = document.getElementById(this.elements.rotationRightInput);
317
+ let step = this.options.rotationStep;
318
+ if (el) {
319
+ const p = parseFloat(el.value);
320
+ if (!isNaN(p))
321
+ step = p;
322
+ }
323
+ this.rotateImage(this.currentRotation + step);
324
+ });
325
+ this._bindIfExists("cropBtn", "click", () => this.enterCropMode());
326
+ this._bindIfExists("applyCropBtn", "click", () => {
327
+ this.applyCrop().catch((e) => this._reportError("applyCrop failed", e));
328
+ });
329
+ this._bindIfExists("cancelCropBtn", "click", () => this.cancelCrop());
330
+ }
331
+ /**
332
+ * Event binding element check
333
+ *
334
+ * @param {*} event
335
+ * @param {*} handler
336
+ * @param {*} key
337
+ * @private
338
+ */
339
+ _bindIfExists(key, event, handler) {
340
+ const el = document.getElementById(this.elements[key]);
341
+ if (el) {
342
+ el.addEventListener(event, handler);
343
+ this._boundHandlers = this._boundHandlers || {};
344
+ if (!this._boundHandlers[key])
345
+ this._boundHandlers[key] = [];
346
+ this._boundHandlers[key].push({ event, handler });
347
+ }
348
+ }
349
+ /**
350
+ * Image loading helpers
351
+ *
352
+ * @param {File} file
353
+ * @private
354
+ */
355
+ _loadImageFile(file) {
356
+ if (!file || !file.type.startsWith("image/"))
357
+ return;
358
+ const reader = new FileReader();
359
+ reader.onload = (e) => this.loadImage(e.target.result);
360
+ reader.onerror = (e) => {
361
+ this._reportError("Image file could not be read", e);
362
+ };
363
+ reader.readAsDataURL(file);
364
+ }
365
+ /**
366
+ * Load a base64 encoded image string into fabric.
367
+ * @async
368
+ * @param {String} base64
369
+ */
370
+ async loadImage(base64) {
371
+ if (!this._fabricLoaded)
372
+ return;
373
+ if (!this.canvas)
374
+ return;
375
+ if (!base64 || typeof base64 !== "string" || !base64.startsWith("data:image/"))
376
+ return;
377
+ this._setPlaceholderVisible(false);
378
+ const imgEl = await this._createImageElement(base64);
379
+ let loadSrc = base64;
380
+ if (this.options.downsampleOnLoad) {
381
+ const needResize = imgEl.naturalWidth > this.options.downsampleMaxWidth || imgEl.naturalHeight > this.options.downsampleMaxHeight;
382
+ if (needResize) {
383
+ const ratio = Math.min(
384
+ this.options.downsampleMaxWidth / imgEl.naturalWidth,
385
+ this.options.downsampleMaxHeight / imgEl.naturalHeight
386
+ );
387
+ const tw = Math.round(imgEl.naturalWidth * ratio);
388
+ const th = Math.round(imgEl.naturalHeight * ratio);
389
+ loadSrc = this._resampleImageToDataURL(imgEl, tw, th, this.options.downsampleQuality);
390
+ }
391
+ }
392
+ return new Promise((resolve, reject) => {
393
+ fabric.Image.fromURL(loadSrc, (fimg) => {
394
+ try {
395
+ if (!fimg)
396
+ throw new Error("Image could not be loaded");
397
+ this.canvas.discardActiveObject();
398
+ this._hideAllMaskLabels();
399
+ this.canvas.clear();
400
+ this.canvas.setBackgroundColor(this.options.backgroundColor, this.canvas.renderAll.bind(this.canvas));
401
+ fimg.set({ originX: "left", originY: "top", selectable: false, evented: false });
402
+ const imgW = fimg.width;
403
+ const imgH = fimg.height;
404
+ const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || this.options.canvasWidth) : this.options.canvasWidth;
405
+ const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || this.options.canvasHeight) : this.options.canvasHeight;
406
+ if (this.options.fitImageToCanvas) {
407
+ const cw = Math.max(1, Math.min(this.options.canvasWidth, minW) - 1);
408
+ const ch = Math.max(1, Math.min(this.options.canvasHeight, minH) - 1);
409
+ this._setCanvasSizeInt(cw, ch);
410
+ const fitScale = Math.min(cw / imgW, ch / imgH, 1);
411
+ fimg.set({ left: 0, top: 0 });
412
+ fimg.scale(fitScale);
413
+ this.baseImageScale = fimg.scaleX || 1;
414
+ } else if (this.options.coverImageToCanvas) {
415
+ const cw = Math.max(this.options.canvasWidth, minW);
416
+ const ch = Math.max(this.options.canvasHeight, minH);
417
+ this._setCanvasSizeInt(cw, ch);
418
+ const coverScale = Math.min(1, Math.max(cw / imgW, ch / imgH));
419
+ fimg.set({ left: 0, top: 0 });
420
+ fimg.scale(coverScale);
421
+ this.baseImageScale = fimg.scaleX || 1;
422
+ } else if (this.options.expandCanvasToImage) {
423
+ const cw = Math.max(minW, Math.floor(imgW));
424
+ const ch = Math.max(minH, Math.floor(imgH));
425
+ this._setCanvasSizeInt(cw, ch);
426
+ fimg.set({ left: 0, top: 0 });
427
+ fimg.scale(1);
428
+ this.baseImageScale = 1;
429
+ } else {
430
+ const cw = Math.max(this.options.canvasWidth, minW);
431
+ const ch = Math.max(this.options.canvasHeight, minH);
432
+ this._setCanvasSizeInt(cw, ch);
433
+ const fitScale = Math.min(cw / imgW, ch / imgH, 1);
434
+ fimg.set({ left: 0, top: 0 });
435
+ fimg.scale(fitScale);
436
+ this.baseImageScale = fimg.scaleX || 1;
437
+ }
438
+ this.originalImage = fimg;
439
+ this.canvas.add(fimg);
440
+ this.canvas.sendToBack(fimg);
441
+ this._lastMask = null;
442
+ this._lastMaskInitialLeft = null;
443
+ this._lastMaskInitialTop = null;
444
+ this._lastMaskInitialWidth = null;
445
+ this.maskCounter = 0;
446
+ this.currentScale = 1;
447
+ this.currentRotation = 0;
448
+ this._updateInputs();
449
+ this._updateMaskList();
450
+ this.isImageLoadedToCanvas = true;
451
+ this._updateUI();
452
+ this.canvas.renderAll();
453
+ if (typeof this.onImageLoaded === "function") {
454
+ this.onImageLoaded();
455
+ }
456
+ resolve();
457
+ } catch (err) {
458
+ reject(err);
459
+ }
460
+ }, { crossOrigin: "anonymous" });
461
+ });
462
+ }
463
+ /**
464
+ * Checks whether there is a loaded image on the current canvas.
465
+ * @returns {boolean} true if loaded, false if not
466
+ */
467
+ isImageLoaded() {
468
+ const fabricInstance2 = ensureFabric();
469
+ return !!(this.originalImage && fabricInstance2 && this.originalImage instanceof fabricInstance2.Image && this.originalImage.width > 0 && this.originalImage.height > 0);
470
+ }
471
+ /**
472
+ * Creates an HTMLImageElement from a given data URL.
473
+ *
474
+ * @param {string} dataURL - A data URL representing the image (e.g., "data:image/png;base64,...").
475
+ * @returns {Promise<HTMLImageElement>} A promise that resolves to the created image element when loaded, or rejects on error.
476
+ * @private
477
+ */
478
+ _createImageElement(dataURL) {
479
+ return new Promise((res, rej) => {
480
+ const img = new Image();
481
+ img.onload = () => {
482
+ img.onload = null;
483
+ img.onerror = null;
484
+ res(img);
485
+ };
486
+ img.onerror = (e) => {
487
+ img.onload = null;
488
+ img.onerror = null;
489
+ rej(e);
490
+ };
491
+ img.src = dataURL;
492
+ });
493
+ }
494
+ /**
495
+ * Resamples the given image element to a new width and height and returns the result as a JPEG data URL.
496
+ *
497
+ * @param {HTMLImageElement} imgEl - The image element to resample.
498
+ * @param {number} w - Target width (in pixels) for the resampled image.
499
+ * @param {number} h - Target height (in pixels) for the resampled image.
500
+ * @param {number} [quality=0.92] - JPEG image quality between 0 and 1 (optional, default 0.92).
501
+ * @returns {string} A data URL representing the resampled image as JPEG.
502
+ * @private
503
+ */
504
+ _resampleImageToDataURL(imgEl, w, h, quality = 0.92) {
505
+ const oc = document.createElement("canvas");
506
+ oc.width = w;
507
+ oc.height = h;
508
+ const ctx = oc.getContext("2d");
509
+ ctx.drawImage(imgEl, 0, 0, imgEl.naturalWidth, imgEl.naturalHeight, 0, 0, w, h);
510
+ return oc.toDataURL("image/jpeg", quality);
511
+ }
512
+ /**
513
+ * Sets canvas size to integer width and height values to prevent scrollbars due to sub-pixel rendering.
514
+ * Also updates the corresponding style attributes.
515
+ *
516
+ * @param {number} w - Canvas width (in pixels).
517
+ * @param {number} h - Canvas height (in pixels).
518
+ * @private
519
+ */
520
+ _setCanvasSizeInt(w, h) {
521
+ const iw = Math.max(1, Math.round(Number(w) || 1));
522
+ const ih = Math.max(1, Math.round(Number(h) || 1));
523
+ this.canvas.setWidth(iw);
524
+ this.canvas.setHeight(ih);
525
+ if (typeof this.canvas.calcOffset === "function")
526
+ this.canvas.calcOffset();
527
+ if (this.canvasEl) {
528
+ this.canvasEl.style.width = iw + "px";
529
+ this.canvasEl.style.height = ih + "px";
530
+ this.canvasEl.style.maxWidth = "none";
531
+ }
532
+ }
533
+ /**
534
+ * Gets the top-left corner coordinates of the given object.
535
+ * Used for geometry calculations (e.g., scale, rotate).
536
+ *
537
+ * @param {Object} obj - The object for which to get the top-left coordinates. Should support setCoords and getCoords/getBoundingRect methods.
538
+ * @returns {{x: number, y: number}} The top-left corner point as an object with x and y properties.
539
+ * @private
540
+ */
541
+ _getObjectTopLeftPoint(obj) {
542
+ if (!obj)
543
+ return { x: 0, y: 0 };
544
+ obj.setCoords();
545
+ const coords = typeof obj.getCoords === "function" ? obj.getCoords() : null;
546
+ if (coords && coords.length)
547
+ return coords[0];
548
+ const br = obj.getBoundingRect(true, true);
549
+ return { x: br.left, y: br.top };
550
+ }
551
+ /**
552
+ * Sets the object's origin at the specified origin point, keeping a reference point fixed in position.
553
+ *
554
+ * @param {Object} obj - The object to modify. Should support set, setPositionByOrigin, and setCoords.
555
+ * @param {string} originX - The new originX ("left", "center", "right", etc.).
556
+ * @param {string} originY - The new originY ("top", "center", "bottom", etc.).
557
+ * @param {{x: number, y: number}} refPoint - The point to keep fixed while setting the new origins.
558
+ * @private
559
+ */
560
+ _setObjectOriginKeepingPosition(obj, originX, originY, refPoint) {
561
+ if (!obj || !refPoint || !obj.setPositionByOrigin)
562
+ return;
563
+ obj.set({ originX, originY });
564
+ obj.setPositionByOrigin(refPoint, originX, originY);
565
+ obj.setCoords();
566
+ }
567
+ /**
568
+ * Moves the object so its bounding box aligns with the canvas's top-left corner (0, 0).
569
+ *
570
+ * @param {Object} obj - The object to align.
571
+ * @private
572
+ */
573
+ _alignObjectBoundingBoxToCanvasTopLeft(obj) {
574
+ if (!obj)
575
+ return;
576
+ obj.setCoords();
577
+ const br = obj.getBoundingRect(true, true);
578
+ const dx = br.left;
579
+ const dy = br.top;
580
+ obj.set({ left: (obj.left || 0) - dx, top: (obj.top || 0) - dy });
581
+ obj.setCoords();
582
+ this.canvas.renderAll();
583
+ }
584
+ /**
585
+ * Updates the canvas size to match the bounding box of the original image,
586
+ * ensuring that the canvas is always at least as large as its container.
587
+ * @private
588
+ */
589
+ _updateCanvasSizeToImageBounds() {
590
+ if (!this.originalImage)
591
+ return;
592
+ this.originalImage.setCoords();
593
+ const br = this.originalImage.getBoundingRect(true, true);
594
+ const containerW = this.containerEl ? Math.ceil(this.containerEl.clientWidth || 0) : 0;
595
+ const containerH = this.containerEl ? Math.ceil(this.containerEl.clientHeight || 0) : 0;
596
+ if (containerW > 0 && containerH > 0 && br.width <= containerW && br.height <= containerH) {
597
+ this._setCanvasSizeInt(containerW, containerH);
598
+ return;
599
+ }
600
+ const newW = Math.max(containerW || 0, Math.floor(br.width));
601
+ const newH = Math.max(containerH || 0, Math.floor(br.height));
602
+ this._setCanvasSizeInt(newW, newH);
603
+ }
604
+ /**
605
+ * Scales the original image by a given factor, with animation.
606
+ * Returns a promise that resolves when the scale animation is complete.
607
+ * @param {number} factor - The scaling factor (will be clamped between `options.minScale` and `options.maxScale`).
608
+ * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
609
+ * @public
610
+ */
611
+ scaleImage(factor) {
612
+ return this.animQueue.add(() => this._scaleImageImpl(factor));
613
+ }
614
+ /**
615
+ * Scales the original image by a given factor, with animation.
616
+ * Returns a promise that resolves when the scale animation is complete.
617
+ * @param {number} factor - The scaling factor (will be clamped between `options.minScale` and `options.maxScale`).
618
+ * @returns {Promise<void>} Promise that resolves once the scaling animation finishes.
619
+ * @private
620
+ */
621
+ _scaleImageImpl(factor) {
622
+ if (!this.originalImage)
623
+ return Promise.resolve();
624
+ if (this.isAnimating)
625
+ return Promise.resolve();
626
+ factor = Math.max(this.options.minScale, Math.min(this.options.maxScale, factor));
627
+ this.currentScale = factor;
628
+ this.isAnimating = true;
629
+ this._updateUI();
630
+ const targetAbs = this.baseImageScale * factor;
631
+ const topLeft = this._getObjectTopLeftPoint(this.originalImage);
632
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", topLeft);
633
+ const p1 = new Promise((res) => {
634
+ this.originalImage.animate("scaleX", targetAbs, {
635
+ duration: this.options.animationDuration,
636
+ onChange: this.canvas.renderAll.bind(this.canvas),
637
+ onComplete: res
638
+ });
639
+ });
640
+ const p2 = new Promise((res) => {
641
+ this.originalImage.animate("scaleY", targetAbs, {
642
+ duration: this.options.animationDuration,
643
+ onChange: this.canvas.renderAll.bind(this.canvas),
644
+ onComplete: res
645
+ });
646
+ });
647
+ return Promise.all([p1, p2]).then(() => {
648
+ this.originalImage.set({ scaleX: targetAbs, scaleY: targetAbs });
649
+ this.originalImage.setCoords();
650
+ if (this.options.expandCanvasToImage)
651
+ this._updateCanvasSizeToImageBounds();
652
+ this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
653
+ this.canvas.getObjects().forEach((o) => {
654
+ if (o.maskId)
655
+ this._syncMaskLabel(o);
656
+ });
657
+ this.isAnimating = false;
658
+ this._updateInputs();
659
+ this._updateUI();
660
+ this.saveState();
661
+ }).catch(() => {
662
+ this.isAnimating = false;
663
+ this._updateUI();
664
+ });
665
+ }
666
+ /**
667
+ * Rotates the original image by a given number of degrees, with animation.
668
+ * Returns a promise that resolves when the rotation animation is complete.
669
+ * @param {number} degrees - The angle in degrees to rotate the image.
670
+ * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
671
+ * @public
672
+ */
673
+ rotateImage(deg) {
674
+ return this.animQueue.add(() => this._rotateImageImpl(deg));
675
+ }
676
+ /**
677
+ * Rotates the original image by a given number of degrees, with animation.
678
+ * Returns a promise that resolves when the rotation animation is complete.
679
+ * @param {number} degrees - The angle in degrees to rotate the image.
680
+ * @returns {Promise<void>} Promise that resolves once the rotation animation finishes.
681
+ * @private
682
+ */
683
+ _rotateImageImpl(degrees) {
684
+ if (!this.originalImage)
685
+ return Promise.resolve();
686
+ if (this.isAnimating)
687
+ return Promise.resolve();
688
+ if (isNaN(degrees))
689
+ return Promise.resolve();
690
+ this.currentRotation = degrees;
691
+ this.isAnimating = true;
692
+ this._updateUI();
693
+ const center = this.originalImage.getCenterPoint();
694
+ this._setObjectOriginKeepingPosition(this.originalImage, "center", "center", center);
695
+ const p = new Promise((res) => {
696
+ this.originalImage.animate("angle", degrees, {
697
+ duration: this.options.animationDuration,
698
+ onChange: this.canvas.renderAll.bind(this.canvas),
699
+ onComplete: res
700
+ });
701
+ });
702
+ return p.then(() => {
703
+ this.originalImage.set("angle", degrees);
704
+ this.originalImage.setCoords();
705
+ if (this.options.expandCanvasToImage)
706
+ this._updateCanvasSizeToImageBounds();
707
+ this._alignObjectBoundingBoxToCanvasTopLeft(this.originalImage);
708
+ const newTopLeft = this._getObjectTopLeftPoint(this.originalImage);
709
+ this._setObjectOriginKeepingPosition(this.originalImage, "left", "top", newTopLeft);
710
+ this.canvas.getObjects().forEach((o) => {
711
+ if (o.maskId)
712
+ this._syncMaskLabel(o);
713
+ });
714
+ this.isAnimating = false;
715
+ this._updateInputs();
716
+ this._updateUI();
717
+ this.saveState();
718
+ }).catch(() => {
719
+ this.isAnimating = false;
720
+ this._updateUI();
721
+ });
722
+ }
723
+ /**
724
+ * Resets the image: scales to 1 and rotates to 0 degrees.
725
+ * @returns {Promise<void>} Promise that resolves when reset is complete.
726
+ */
727
+ reset() {
728
+ if (!this.originalImage)
729
+ return Promise.resolve();
730
+ return this.scaleImage(1).then(() => this.rotateImage(0)).then(() => {
731
+ this.saveState();
732
+ }).catch((err) => {
733
+ this._reportError("reset() failed", err);
734
+ });
735
+ }
736
+ /**
737
+ * Restores a canvas state that was previously stored by saveState().
738
+ * @param {string} jsonString - the JSON string returned by fabric.toJSON().
739
+ */
740
+ loadFromState(jsonString) {
741
+ if (!jsonString || !this.canvas)
742
+ return;
743
+ try {
744
+ const json = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;
745
+ this.canvas.loadFromJSON(json, () => {
746
+ try {
747
+ this._hideAllMaskLabels();
748
+ const objs = this.canvas.getObjects();
749
+ this.originalImage = objs.find((o) => o.type === "image" && !o.maskId) || null;
750
+ if (this.originalImage) {
751
+ this.originalImage.set({ originX: "left", originY: "top", selectable: false, evented: false, hasControls: false, hoverCursor: "default" });
752
+ this.canvas.sendToBack(this.originalImage);
753
+ }
754
+ const masks = objs.filter((o) => o.maskId);
755
+ this.maskCounter = masks.reduce((max, m) => Math.max(max, m.maskId), 0);
756
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
757
+ if (!this._lastMask) {
758
+ this._lastMaskInitialLeft = null;
759
+ this._lastMaskInitialTop = null;
760
+ this._lastMaskInitialWidth = null;
761
+ }
762
+ this.isImageLoadedToCanvas = !!this.originalImage;
763
+ this.canvas.renderAll();
764
+ this._updateMaskList();
765
+ this._updatePlaceholderStatus();
766
+ this._updateUI();
767
+ } catch (callbackError) {
768
+ this._reportError("loadFromState() failed", callbackError);
769
+ }
770
+ });
771
+ } catch (e) {
772
+ this._reportError("loadFromState() failed", e);
773
+ }
774
+ }
775
+ /**
776
+ * Saves the current state of the canvas to history, storing any mask/raster label information.
777
+ */
778
+ saveState() {
779
+ if (!this.canvas)
780
+ return;
781
+ const activeObj = this.canvas.getActiveObject();
782
+ this._hideAllMaskLabels();
783
+ try {
784
+ const jsonObj = this.canvas.toJSON(["maskId", "maskName", "isCropRect"]);
785
+ if (Array.isArray(jsonObj.objects)) {
786
+ jsonObj.objects = jsonObj.objects.filter((o) => !o.isCropRect);
787
+ }
788
+ const after = JSON.stringify(jsonObj);
789
+ const before = this._lastSnapshot || after;
790
+ let executedOnce = false;
791
+ const cmd = new Command(
792
+ () => {
793
+ if (executedOnce) {
794
+ this.loadFromState(after);
795
+ }
796
+ executedOnce = true;
797
+ },
798
+ () => {
799
+ this.loadFromState(before);
800
+ }
801
+ );
802
+ this.historyManager.execute(cmd);
803
+ this._lastSnapshot = after;
804
+ if (activeObj && activeObj.maskId) {
805
+ this._showLabelForMask(activeObj);
806
+ }
807
+ this._updateUI();
808
+ } catch (err) {
809
+ this._reportWarning("saveState: failed to save canvas snapshot", err);
810
+ }
811
+ }
812
+ /**
813
+ * Undo the last state change, if possible.
814
+ */
815
+ undo() {
816
+ this.historyManager.undo();
817
+ }
818
+ /**
819
+ * Redo the next state change, if possible.
820
+ */
821
+ redo() {
822
+ this.historyManager.redo();
823
+ }
824
+ /**
825
+ * Adds a rectangular mask to the canvas.
826
+ * Mask placement and properties are determined by the provided config and instance options.
827
+ * Canvas and list UI are updated accordingly.
828
+ * @param {Object} [config={}] - Optional mask configuration overrides:
829
+ * @param {string} [config.shape='rect'] - 'rect', 'circle', 'ellipse', 'polygon', ...
830
+ * @param {Object|Array} [config.points] - Required for polygon: [{x, y}, ...] or [[x, y], ...]
831
+ * @param {number|function} [config.width/height/rx/ry/radius] - Can be number or function(canvas, options)
832
+ * @param {number|string|function} [config.left/top] - Absolute, %, or function
833
+ * @param {number|string} [config.angle] - Rotation angle (degree)
834
+ * @param {string} [config.color] - Fill color in CSS color format (default 'rgba(0,0,0,0.5)')
835
+ * @param {number} [config.alpha] - Opacity, from 0 to 1 (default 0.5)
836
+ * @param {boolean} [config.selectable=true]
837
+ * @param {Object} [config.styles] - Custom styles (stroke, dashArray, etc)
838
+ * @param {function} [config.onCreate] - Callback after mask created (receives Fabric object)
839
+ * @param {function} [config.fabricGenerator] - (cfg) => new FabricObj
840
+ * @returns {fabric.Rect|null} The created mask object, or null if canvas is not available.
841
+ * @public
842
+ */
843
+ addMask(config = {}) {
844
+ if (!this.canvas)
845
+ return null;
846
+ const shapeType = config.shape || "rect";
847
+ const cfg = {
848
+ shape: shapeType,
849
+ width: this.options.defaultMaskWidth,
850
+ height: this.options.defaultMaskHeight,
851
+ color: "rgba(0,0,0,0.5)",
852
+ alpha: 0.5,
853
+ gap: 5,
854
+ left: void 0,
855
+ top: void 0,
856
+ angle: 0,
857
+ selectable: true,
858
+ ...config
859
+ };
860
+ const firstOffset = 10;
861
+ let left = firstOffset;
862
+ let top = firstOffset;
863
+ const resolveValue = (val, fallback) => {
864
+ if (typeof val === "function")
865
+ return val(this.canvas, this.options);
866
+ if (typeof val === "string" && val.endsWith("%")) {
867
+ const percent = parseFloat(val) / 100;
868
+ return Math.floor((this.canvas ? this.canvas.getWidth() : 0) * percent);
869
+ }
870
+ return val != null ? val : fallback;
871
+ };
872
+ if (cfg.left === void 0 && this._lastMask) {
873
+ const prev = this._lastMask;
874
+ let prevRight = prev.left;
875
+ if (prev.getScaledWidth) {
876
+ prevRight += prev.getScaledWidth();
877
+ } else if (prev.width) {
878
+ prevRight += prev.width * (prev.scaleX ?? 1);
879
+ }
880
+ left = Math.round(prevRight + cfg.gap);
881
+ top = prev.top ?? firstOffset;
882
+ } else {
883
+ left = resolveValue(cfg.left, firstOffset);
884
+ top = resolveValue(cfg.top, firstOffset);
885
+ }
886
+ cfg.width = resolveValue(cfg.width, this.options.defaultMaskWidth);
887
+ cfg.height = resolveValue(cfg.height, this.options.defaultMaskHeight);
888
+ if (this.options.expandCanvasToImage && shapeType === "rect") {
889
+ const requiredW = Math.ceil(left + cfg.width + 10);
890
+ const requiredH = Math.ceil(top + cfg.height + 10);
891
+ const minW = this.containerEl ? Math.floor(this.containerEl.clientWidth || 0) : 0;
892
+ const minH = this.containerEl ? Math.floor(this.containerEl.clientHeight || 0) : 0;
893
+ const newW = Math.max(this.canvas.getWidth(), minW, requiredW);
894
+ const newH = Math.max(this.canvas.getHeight(), minH, requiredH);
895
+ this._setCanvasSizeInt(newW, newH);
896
+ }
897
+ let mask;
898
+ if (typeof cfg.fabricGenerator === "function") {
899
+ mask = cfg.fabricGenerator(cfg, this.canvas, this.options);
900
+ } else {
901
+ switch (shapeType) {
902
+ case "circle":
903
+ mask = new fabric.Circle({
904
+ left,
905
+ top,
906
+ radius: resolveValue(cfg.radius, Math.min(cfg.width, cfg.height) / 2),
907
+ fill: cfg.color,
908
+ opacity: cfg.alpha,
909
+ angle: cfg.angle,
910
+ ...cfg.styles
911
+ });
912
+ break;
913
+ case "ellipse":
914
+ mask = new fabric.Ellipse({
915
+ left,
916
+ top,
917
+ rx: resolveValue(cfg.rx, cfg.width / 2),
918
+ ry: resolveValue(cfg.ry, cfg.height / 2),
919
+ fill: cfg.color,
920
+ opacity: cfg.alpha,
921
+ angle: cfg.angle,
922
+ ...cfg.styles
923
+ });
924
+ break;
925
+ case "polygon": {
926
+ let polyPoints = cfg.points || [];
927
+ if (Array.isArray(polyPoints) && polyPoints.length && typeof polyPoints[0] === "object") {
928
+ polyPoints = polyPoints.map((pt) => ({ x: Number(pt.x), y: Number(pt.y) }));
929
+ }
930
+ mask = new fabric.Polygon(polyPoints, {
931
+ left,
932
+ top,
933
+ fill: cfg.color,
934
+ opacity: cfg.alpha,
935
+ angle: cfg.angle,
936
+ ...cfg.styles
937
+ });
938
+ break;
939
+ }
940
+ case "rect":
941
+ default:
942
+ mask = new fabric.Rect({
943
+ left,
944
+ top,
945
+ width: resolveValue(cfg.width, this.options.defaultMaskWidth),
946
+ height: resolveValue(cfg.height, this.options.defaultMaskHeight),
947
+ fill: cfg.color,
948
+ opacity: cfg.alpha,
949
+ angle: cfg.angle,
950
+ rx: cfg.rx,
951
+ // Rounded Corners
952
+ ry: cfg.ry,
953
+ ...cfg.styles
954
+ });
955
+ }
956
+ }
957
+ mask.selectable = cfg.selectable !== false;
958
+ mask.hasControls = "hasControls" in cfg ? cfg.hasControls : true;
959
+ mask.lockRotation = !this.options.maskRotatable;
960
+ mask.borderColor = cfg.borderColor || "red";
961
+ mask.cornerColor = cfg.cornerColor || "black";
962
+ mask.cornerSize = cfg.cornerSize || 8;
963
+ mask.transparentCorners = "transparentCorners" in cfg ? cfg.transparentCorners : false;
964
+ mask.stroke = cfg.styles && cfg.styles.stroke || "#ccc";
965
+ mask.strokeWidth = cfg.styles && cfg.styles.strokeWidth || 1;
966
+ mask.strokeUniform = "strokeUniform" in cfg ? cfg.strokeUniform : true;
967
+ if (cfg.styles && cfg.styles.strokeDashArray)
968
+ mask.strokeDashArray = cfg.styles.strokeDashArray;
969
+ mask.originalAlpha = cfg.alpha;
970
+ const normalStyle = { stroke: mask.stroke, strokeWidth: mask.strokeWidth, opacity: mask.originalAlpha };
971
+ const hoverStyle = { stroke: "#ff5500", strokeWidth: 2, opacity: Math.min(mask.originalAlpha + 0.2, 1) };
972
+ mask.on("mouseover", () => {
973
+ mask.set(hoverStyle);
974
+ mask.canvas.requestRenderAll();
975
+ });
976
+ mask.on("mouseout", () => {
977
+ mask.set(normalStyle);
978
+ mask.canvas.requestRenderAll();
979
+ });
980
+ this._lastMaskInitialLeft = left;
981
+ this._lastMaskInitialTop = top;
982
+ this._lastMaskInitialWidth = resolveValue(cfg.width, this.options.defaultMaskWidth);
983
+ mask.maskId = ++this.maskCounter;
984
+ mask.maskName = `${this.options.maskName}${mask.maskId}`;
985
+ this._lastMask = mask;
986
+ this.canvas.add(mask);
987
+ this.canvas.bringToFront(mask);
988
+ if (cfg.selectable)
989
+ this.canvas.setActiveObject(mask);
990
+ this._onSelectionChanged([mask]);
991
+ this._updateMaskList();
992
+ this._updateUI();
993
+ this.canvas.renderAll();
994
+ this.saveState();
995
+ if (typeof cfg.onCreate === "function")
996
+ cfg.onCreate(mask, this.canvas);
997
+ return mask;
998
+ }
999
+ /**
1000
+ * Removes the currently selected mask from the canvas, if any.
1001
+ * The associated label is also removed. UI and mask list are updated.
1002
+ */
1003
+ removeSelectedMask() {
1004
+ const active = this.canvas.getActiveObject();
1005
+ if (!active || !active.maskId)
1006
+ return;
1007
+ this._removeLabelForMask(active);
1008
+ this.canvas.remove(active);
1009
+ if (this._lastMask === active) {
1010
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1011
+ this._lastMask = masks.length ? masks[masks.length - 1] : null;
1012
+ if (!this._lastMask) {
1013
+ this._lastMaskInitialLeft = null;
1014
+ this._lastMaskInitialTop = null;
1015
+ this._lastMaskInitialWidth = null;
1016
+ }
1017
+ }
1018
+ this.canvas.discardActiveObject();
1019
+ this._updateMaskList();
1020
+ this._updateUI();
1021
+ this.canvas.renderAll();
1022
+ this.saveState();
1023
+ }
1024
+ /**
1025
+ * Removes all masks from the canvas, including their labels.
1026
+ * UI and internal mask placement memory are reset.
1027
+ */
1028
+ removeAllMasks() {
1029
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1030
+ masks.forEach((m) => this._removeLabelForMask(m));
1031
+ masks.forEach((m) => this.canvas.remove(m));
1032
+ this.canvas.discardActiveObject();
1033
+ this._lastMask = null;
1034
+ this._lastMaskInitialLeft = null;
1035
+ this._lastMaskInitialTop = null;
1036
+ this._lastMaskInitialWidth = null;
1037
+ this._updateMaskList();
1038
+ this._updateUI();
1039
+ this.canvas.renderAll();
1040
+ this.saveState();
1041
+ }
1042
+ /**
1043
+ * Removes the label associated with the specified mask object, if it exists.
1044
+ *
1045
+ * @param {fabric.Object} mask - The mask object whose label should be removed.
1046
+ * @private
1047
+ */
1048
+ _removeLabelForMask(mask) {
1049
+ if (!mask || !this.canvas)
1050
+ return;
1051
+ if (mask.__label) {
1052
+ try {
1053
+ const objs = this.canvas.getObjects();
1054
+ if (objs.includes(mask.__label)) {
1055
+ this.canvas.remove(mask.__label);
1056
+ }
1057
+ } catch (e) {
1058
+ }
1059
+ try {
1060
+ delete mask.__label;
1061
+ } catch (e) {
1062
+ }
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Creates and adds a custom label (fabric.Text or fabric.IText) for the mask.
1067
+ * The label is default bound to the top-left of the mask and managed as a non-interactive overlay.
1068
+ *
1069
+ * @param {fabric.Object} mask - The mask to create a label for.
1070
+ * @private
1071
+ */
1072
+ _createLabelForMask(mask) {
1073
+ if (!mask || !this.options.maskLabelOnSelect)
1074
+ return;
1075
+ this._removeLabelForMask(mask);
1076
+ let textObj = null;
1077
+ if (this.options.label && typeof this.options.label.create === "function") {
1078
+ textObj = this.options.label.create(mask, fabric);
1079
+ }
1080
+ if (!textObj) {
1081
+ let txt = mask.maskName;
1082
+ let textOptions = {
1083
+ left: 0,
1084
+ top: 0,
1085
+ fontSize: 12,
1086
+ fill: "#fff",
1087
+ backgroundColor: "rgba(0,0,0,0.7)",
1088
+ selectable: false,
1089
+ evented: false,
1090
+ padding: 2,
1091
+ originX: "left",
1092
+ originY: "top"
1093
+ };
1094
+ if (this.options.label) {
1095
+ if (typeof this.options.label.getText === "function") {
1096
+ txt = this.options.label.getText(mask, this.maskCounter);
1097
+ }
1098
+ if (this.options.label.textOptions) {
1099
+ Object.assign(textOptions, this.options.label.textOptions);
1100
+ }
1101
+ }
1102
+ textObj = new fabric.Text(txt, textOptions);
1103
+ }
1104
+ textObj.maskLabel = true;
1105
+ mask.__label = textObj;
1106
+ this.canvas.add(textObj);
1107
+ this.canvas.bringToFront(textObj);
1108
+ this._syncMaskLabel(mask);
1109
+ }
1110
+ /**
1111
+ * Hides (removes) all mask labels from the canvas.
1112
+ * Internal label references on mask objects are also deleted.
1113
+ * @private
1114
+ */
1115
+ _hideAllMaskLabels() {
1116
+ if (!this.canvas)
1117
+ return;
1118
+ const objs = this.canvas.getObjects();
1119
+ const labels = objs.filter((o) => o.maskLabel);
1120
+ labels.forEach((l) => {
1121
+ try {
1122
+ if (objs.includes(l))
1123
+ this.canvas.remove(l);
1124
+ } catch (e) {
1125
+ }
1126
+ });
1127
+ objs.forEach((o) => {
1128
+ if (o.maskId && o.__label) {
1129
+ try {
1130
+ delete o.__label;
1131
+ } catch (e) {
1132
+ }
1133
+ }
1134
+ });
1135
+ }
1136
+ /**
1137
+ * Synchronizes the position, angle, and visibility of the mask's label so that it appears properly above the mask.
1138
+ *
1139
+ * @param {fabric.Object} mask - The mask whose label should be repositioned.
1140
+ * @private
1141
+ */
1142
+ _syncMaskLabel(mask) {
1143
+ if (!mask)
1144
+ return;
1145
+ if (!this.options.maskLabelOnSelect)
1146
+ return;
1147
+ if (!mask.__label)
1148
+ return;
1149
+ const coords = mask.getCoords ? mask.getCoords() : null;
1150
+ if (!coords || coords.length < 4)
1151
+ return;
1152
+ const tl = coords[0];
1153
+ const center = mask.getCenterPoint();
1154
+ const vx = center.x - tl.x;
1155
+ const vy = center.y - tl.y;
1156
+ const dist = Math.sqrt(vx * vx + vy * vy) || 1;
1157
+ const ux = vx / dist;
1158
+ const uy = vy / dist;
1159
+ const offset = Math.max(0, this.options.maskLabelOffset ?? 3);
1160
+ const px = tl.x + ux * offset;
1161
+ const py = tl.y + uy * offset;
1162
+ mask.__label.set({
1163
+ left: Math.round(px),
1164
+ top: Math.round(py),
1165
+ angle: mask.angle || 0,
1166
+ originX: "left",
1167
+ originY: "top",
1168
+ visible: true
1169
+ });
1170
+ mask.__label.setCoords();
1171
+ this.canvas.renderAll();
1172
+ }
1173
+ /**
1174
+ * Shows the label for the given mask, creating it if necessary and synchronizing its position.
1175
+ *
1176
+ * @param {fabric.Object} mask - The mask whose label should be shown.
1177
+ * @private
1178
+ */
1179
+ _showLabelForMask(mask) {
1180
+ if (!mask)
1181
+ return;
1182
+ if (!this.options.maskLabelOnSelect)
1183
+ return;
1184
+ if (!mask.__label)
1185
+ this._createLabelForMask(mask);
1186
+ mask.__label.visible = true;
1187
+ this._syncMaskLabel(mask);
1188
+ }
1189
+ /**
1190
+ * Handles changes to the selection of canvas objects (masks),
1191
+ * updates mask stroke and label display, and syncs mask list selection.
1192
+ *
1193
+ * @param {Array<Object>} selected - The currently selected objects (e.g. [mask] or []).
1194
+ * @private
1195
+ */
1196
+ _onSelectionChanged(selected) {
1197
+ const selectedMask = (selected || []).find((o) => o.maskId);
1198
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1199
+ masks.forEach((m) => {
1200
+ if (m !== selectedMask) {
1201
+ if (m.__label) {
1202
+ try {
1203
+ this.canvas.remove(m.__label);
1204
+ } catch (e) {
1205
+ }
1206
+ delete m.__label;
1207
+ }
1208
+ m.set({ stroke: "#ccc", strokeWidth: 1 });
1209
+ } else {
1210
+ m.set({ stroke: "#ff0000", strokeWidth: 1 });
1211
+ }
1212
+ });
1213
+ if (selectedMask)
1214
+ this._showLabelForMask(selectedMask);
1215
+ this._updateMaskListSelection(selectedMask);
1216
+ this.canvas.renderAll();
1217
+ this._updateUI();
1218
+ }
1219
+ /**
1220
+ * Updates the mask list in the DOM to reflect the current masks on the canvas.
1221
+ * Each list entry becomes a clickable element for mask selection.
1222
+ * @private
1223
+ */
1224
+ _updateMaskList() {
1225
+ const listEl = document.getElementById(this.elements.maskList);
1226
+ if (!listEl)
1227
+ return;
1228
+ listEl.innerHTML = "";
1229
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1230
+ masks.forEach((mask) => {
1231
+ const li = document.createElement("li");
1232
+ li.className = "list-group-item mask-item";
1233
+ li.textContent = mask.maskName;
1234
+ li.onclick = () => {
1235
+ this.canvas.setActiveObject(mask);
1236
+ this._onSelectionChanged([mask]);
1237
+ };
1238
+ listEl.appendChild(li);
1239
+ });
1240
+ }
1241
+ /**
1242
+ * Updates the visual selection (CSS 'active') state for the mask list in the DOM.
1243
+ *
1244
+ * @param {Object|null} selectedMask - The currently selected mask, or null if none selected.
1245
+ * @private
1246
+ */
1247
+ _updateMaskListSelection(selectedMask) {
1248
+ const listEl = document.getElementById(this.elements.maskList);
1249
+ if (!listEl)
1250
+ return;
1251
+ const items = listEl.querySelectorAll(".mask-item");
1252
+ items.forEach((item) => {
1253
+ const isSelected = !!selectedMask && item.textContent === selectedMask.maskName;
1254
+ item.classList.toggle("active", isSelected);
1255
+ });
1256
+ }
1257
+ /**
1258
+ * Merges current masks into the image: exports a masked/cropped image, removes all masks, and re-imports the merged image.
1259
+ * Will not run if no original image or no masks exist.
1260
+ * @async
1261
+ * @returns {Promise<void>} Resolves when merge and load are complete.
1262
+ */
1263
+ async merge() {
1264
+ if (!this.originalImage)
1265
+ return;
1266
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1267
+ if (!masks.length)
1268
+ return;
1269
+ this.canvas.discardActiveObject();
1270
+ this.canvas.renderAll();
1271
+ try {
1272
+ const merged = await this.getImageBase64({ exportImageArea: true, multiplier: this.options.exportMultiplier });
1273
+ this.removeAllMasks();
1274
+ await this.loadImage(merged);
1275
+ this.saveState();
1276
+ } catch (err) {
1277
+ this._reportError("merge error", err);
1278
+ if (this.canvasEl)
1279
+ this.canvasEl.style.visibility = "";
1280
+ }
1281
+ }
1282
+ /**
1283
+ * Triggers a JPEG image download of the current canvas (image plus masks if configured).
1284
+ * The image area and multiplier are controlled by options.
1285
+ * @param {string} [fileName=this.options.defaultDownloadFileName] - Desired download file name.
1286
+ */
1287
+ downloadImage(fileName = this.options.defaultDownloadFileName) {
1288
+ if (!this.originalImage)
1289
+ return;
1290
+ const exportImageArea = this.options.exportImageAreaByDefault;
1291
+ this.getImageBase64({ exportImageArea, multiplier: this.options.exportMultiplier }).then((base64) => {
1292
+ const link = document.createElement("a");
1293
+ link.download = fileName;
1294
+ link.href = base64;
1295
+ document.body.appendChild(link);
1296
+ link.click();
1297
+ document.body.removeChild(link);
1298
+ }).catch((err) => this._reportError("download error", err));
1299
+ }
1300
+ /**
1301
+ * Exports the image as a Base64-encoded JPEG.
1302
+ * Can export either the original, or the current view including masks (clipped/cropped).
1303
+ * Will restore masks' state after temporary modifications for export.
1304
+ * @async
1305
+ * @param {Object} [opts={}] - Export options.
1306
+ * @param {boolean} [opts.exportImageArea] - If true, exports only the image bounding area with masks cropped and blended.
1307
+ * @param {number} [opts.multiplier=1] - Scaling multiplier for output (resolution).
1308
+ * @returns {Promise<string>} Promise resolving to a JPEG image data URL.
1309
+ * @throws {Error} If there is no image loaded.
1310
+ */
1311
+ async getImageBase64(opts = {}) {
1312
+ if (!this.originalImage)
1313
+ throw new Error("No image loaded");
1314
+ const exportImageArea = typeof opts.exportImageArea === "boolean" ? opts.exportImageArea : this.options.exportImageAreaByDefault;
1315
+ const multiplier = opts.multiplier || this.options.exportMultiplier || 1;
1316
+ if (!exportImageArea) {
1317
+ const imgEl = this.originalImage.getElement ? this.originalImage.getElement() : this.originalImage._element || null;
1318
+ if (!imgEl)
1319
+ return this.canvas.toDataURL({ format: "jpeg", quality: this.options.downsampleQuality, multiplier });
1320
+ const w = this.originalImage.width;
1321
+ const h = this.originalImage.height;
1322
+ const oc = document.createElement("canvas");
1323
+ oc.width = w;
1324
+ oc.height = h;
1325
+ const ctx = oc.getContext("2d");
1326
+ ctx.drawImage(imgEl, 0, 0, w, h);
1327
+ return oc.toDataURL("image/jpeg", this.options.downsampleQuality);
1328
+ }
1329
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1330
+ const masksBackup = masks.map((m) => ({
1331
+ obj: m,
1332
+ opacity: m.opacity,
1333
+ fill: m.fill,
1334
+ strokeWidth: m.strokeWidth,
1335
+ stroke: m.stroke,
1336
+ selectable: m.selectable,
1337
+ lockRotation: m.lockRotation
1338
+ }));
1339
+ let finalBase64;
1340
+ try {
1341
+ masks.forEach((m) => this._removeLabelForMask(m));
1342
+ this.canvas.discardActiveObject();
1343
+ this.canvas.renderAll();
1344
+ masks.forEach((m) => {
1345
+ m.set({ opacity: 1, fill: "#000000", strokeWidth: 0, stroke: null, selectable: false });
1346
+ m.setCoords();
1347
+ });
1348
+ this.canvas.renderAll();
1349
+ this.originalImage.setCoords();
1350
+ const imgBr = this.originalImage.getBoundingRect(true, true);
1351
+ const sx = Math.max(0, Math.round(imgBr.left));
1352
+ const sy = Math.max(0, Math.round(imgBr.top));
1353
+ const sw = Math.max(1, Math.round(imgBr.width));
1354
+ const sh = Math.max(1, Math.round(imgBr.height));
1355
+ finalBase64 = await new Promise((resolve, reject) => {
1356
+ try {
1357
+ const fullDataUrl = this.canvas.toDataURL({
1358
+ format: "jpeg",
1359
+ quality: this.options.downsampleQuality,
1360
+ multiplier
1361
+ });
1362
+ const img = new Image();
1363
+ img.onload = () => {
1364
+ try {
1365
+ const sxM = Math.round(sx * multiplier);
1366
+ const syM = Math.round(sy * multiplier);
1367
+ const swM = Math.round(sw * multiplier);
1368
+ const shM = Math.round(sh * multiplier);
1369
+ const oc = document.createElement("canvas");
1370
+ oc.width = swM;
1371
+ oc.height = shM;
1372
+ const ctx = oc.getContext("2d");
1373
+ ctx.drawImage(img, sxM, syM, swM, shM, 0, 0, swM, shM);
1374
+ const out = oc.toDataURL("image/jpeg", this.options.downsampleQuality);
1375
+ resolve(out);
1376
+ } catch (e) {
1377
+ reject(e);
1378
+ }
1379
+ };
1380
+ img.onerror = reject;
1381
+ img.src = fullDataUrl;
1382
+ } catch (e) {
1383
+ reject(e);
1384
+ }
1385
+ });
1386
+ } finally {
1387
+ masksBackup.forEach((b) => {
1388
+ try {
1389
+ b.obj.set({
1390
+ opacity: b.opacity,
1391
+ fill: b.fill,
1392
+ strokeWidth: b.strokeWidth,
1393
+ stroke: b.stroke,
1394
+ selectable: b.selectable,
1395
+ lockRotation: b.lockRotation
1396
+ });
1397
+ b.obj.setCoords();
1398
+ } catch (e) {
1399
+ }
1400
+ });
1401
+ this.canvas.renderAll();
1402
+ }
1403
+ return finalBase64;
1404
+ }
1405
+ /**
1406
+ * Exports the current canvas (with or without masks) as a File object.
1407
+ * Allows you to choose whether to merge masks and specify file type (jpeg/png/webp).
1408
+ *
1409
+ * @async
1410
+ * @param {Object} [opts={}] - Export options.
1411
+ * @param {boolean} [opts.mergeMask=true] - If true, export image area with masks merged; if false, export the plain image without masks.
1412
+ * @param {string} [opts.fileType='jpeg'] - Output file type ('jpeg' | 'png' | 'webp'). Defaults to 'jpeg' on invalid input.
1413
+ * @param {number} [opts.quality=0.92] - Image quality for lossy types (0-1, default based on options.downsampleQuality).
1414
+ * @param {number} [opts.multiplier=1] - Output resolution multiplier.
1415
+ * @param {string} [opts.fileName] - Optional file name (only used for download).
1416
+ * @returns {Promise<File>} Resolves with the exported image as a File object.
1417
+ *
1418
+ * @example
1419
+ * const file = await this.exportImageFile({ mergeMask: false, fileType: 'png' });
1420
+ */
1421
+ async exportImageFile(opts = {}) {
1422
+ if (!this.originalImage)
1423
+ throw new Error("No image loaded");
1424
+ const {
1425
+ mergeMask = true,
1426
+ fileType = "jpeg",
1427
+ quality = this.options.downsampleQuality ?? 0.92,
1428
+ multiplier = this.options.exportMultiplier ?? 1,
1429
+ fileName = this.options.defaultDownloadFileName ?? "exported_image.jpg"
1430
+ } = opts;
1431
+ const typeMapping = {
1432
+ "jpeg": "jpeg",
1433
+ "jpg": "jpeg",
1434
+ "image/jpeg": "jpeg",
1435
+ "png": "png",
1436
+ "image/png": "png",
1437
+ "webp": "webp",
1438
+ "image/webp": "webp"
1439
+ };
1440
+ const safeFileType = typeMapping[String(fileType).toLowerCase()] || "jpeg";
1441
+ let base64;
1442
+ if (mergeMask) {
1443
+ base64 = await this.getImageBase64({
1444
+ exportImageArea: true,
1445
+ multiplier
1446
+ });
1447
+ } else {
1448
+ base64 = await this.getImageBase64({
1449
+ exportImageArea: false,
1450
+ multiplier
1451
+ });
1452
+ }
1453
+ let imageDataUrl = base64;
1454
+ if (!imageDataUrl.startsWith(`data:image/${safeFileType}`)) {
1455
+ imageDataUrl = await new Promise((resolve, reject) => {
1456
+ const img = new window.Image();
1457
+ img.crossOrigin = "Anonymous";
1458
+ img.onload = () => {
1459
+ try {
1460
+ const oc = document.createElement("canvas");
1461
+ oc.width = img.width;
1462
+ oc.height = img.height;
1463
+ const ctx = oc.getContext("2d");
1464
+ ctx.drawImage(img, 0, 0);
1465
+ const durl = oc.toDataURL(`image/${safeFileType}`, quality);
1466
+ resolve(durl);
1467
+ } catch (e) {
1468
+ reject(e);
1469
+ }
1470
+ };
1471
+ img.onerror = reject;
1472
+ img.src = base64;
1473
+ });
1474
+ }
1475
+ const bstr = atob(imageDataUrl.split(",")[1]);
1476
+ const mime = `image/${safeFileType}`;
1477
+ let n = bstr.length;
1478
+ const u8arr = new Uint8Array(n);
1479
+ while (n--) {
1480
+ u8arr[n] = bstr.charCodeAt(n);
1481
+ }
1482
+ const file = new File([u8arr], fileName, { type: mime });
1483
+ return file;
1484
+ }
1485
+ /**
1486
+ * Enter crop mode: create a resizable/movable selection rect on top of the image.
1487
+ * @public
1488
+ */
1489
+ enterCropMode() {
1490
+ if (!this.canvas || !this.originalImage || this._cropMode)
1491
+ return;
1492
+ if (!this.isImageLoaded())
1493
+ return;
1494
+ this._cropMode = true;
1495
+ this._prevSelectionSetting = this.canvas.selection;
1496
+ this.canvas.selection = false;
1497
+ this.canvas.discardActiveObject();
1498
+ this.originalImage.setCoords();
1499
+ const imgBr = this.originalImage.getBoundingRect(true, true);
1500
+ const padding = this.options.crop && this.options.crop.padding ? this.options.crop.padding : 10;
1501
+ const left = Math.max(0, Math.floor(imgBr.left + padding));
1502
+ const top = Math.max(0, Math.floor(imgBr.top + padding));
1503
+ const width = Math.min(this.options.crop.minWidth || 50, Math.floor(imgBr.width - padding * 2));
1504
+ const height = Math.min(this.options.crop.minHeight || 50, Math.floor(imgBr.height - padding * 2));
1505
+ const cropRect = new fabric.Rect({
1506
+ left,
1507
+ top,
1508
+ width,
1509
+ height,
1510
+ fill: "rgba(0,0,0,0.12)",
1511
+ stroke: "#00aaff",
1512
+ strokeDashArray: [6, 4],
1513
+ strokeWidth: 1,
1514
+ strokeUniform: true,
1515
+ selectable: true,
1516
+ hasRotatingPoint: !!(this.options.crop && this.options.crop.allowRotationOfCropRect),
1517
+ lockRotation: !(this.options.crop && this.options.crop.allowRotationOfCropRect),
1518
+ cornerSize: 8,
1519
+ objectCaching: false,
1520
+ originX: "left",
1521
+ originY: "top"
1522
+ });
1523
+ this.canvas.add(cropRect);
1524
+ cropRect.isCropRect = true;
1525
+ this.canvas.bringToFront(cropRect);
1526
+ this.canvas.setActiveObject(cropRect);
1527
+ this._cropRect = cropRect;
1528
+ this._cropPrevEvented = [];
1529
+ this.canvas.getObjects().forEach((o) => {
1530
+ if (o !== cropRect) {
1531
+ this._cropPrevEvented.push({ obj: o, evented: o.evented, selectable: o.selectable });
1532
+ try {
1533
+ o.evented = false;
1534
+ o.selectable = false;
1535
+ } catch (e) {
1536
+ }
1537
+ }
1538
+ });
1539
+ const onModified = () => {
1540
+ try {
1541
+ cropRect.setCoords();
1542
+ this.canvas.requestRenderAll();
1543
+ } catch (e) {
1544
+ }
1545
+ };
1546
+ cropRect.on("modified", onModified);
1547
+ cropRect.on("moving", onModified);
1548
+ cropRect.on("scaling", onModified);
1549
+ this._cropHandlers.push({ target: cropRect, handlers: [{ evt: "modified", fn: onModified }, { evt: "moving", fn: onModified }, { evt: "scaling", fn: onModified }] });
1550
+ this._updateUI();
1551
+ this.canvas.renderAll();
1552
+ }
1553
+ /**
1554
+ * Cancel crop mode and remove the temporary selection rect.
1555
+ * @public
1556
+ */
1557
+ cancelCrop() {
1558
+ if (!this.canvas || !this._cropMode)
1559
+ return;
1560
+ if (this._cropRect) {
1561
+ try {
1562
+ if (this._cropHandlers && this._cropHandlers.length) {
1563
+ this._cropHandlers.forEach((h) => {
1564
+ h.handlers.forEach((rec) => h.target.off(rec.evt, rec.fn));
1565
+ });
1566
+ }
1567
+ } catch (e) {
1568
+ }
1569
+ try {
1570
+ this.canvas.remove(this._cropRect);
1571
+ } catch (e) {
1572
+ }
1573
+ this._cropRect = null;
1574
+ }
1575
+ if (Array.isArray(this._cropPrevEvented)) {
1576
+ this._cropPrevEvented.forEach((i) => {
1577
+ try {
1578
+ i.obj.evented = i.evented;
1579
+ i.obj.selectable = i.selectable;
1580
+ } catch (e) {
1581
+ }
1582
+ });
1583
+ }
1584
+ this._cropPrevEvented = null;
1585
+ this._cropHandlers = [];
1586
+ this._cropMode = false;
1587
+ this.canvas.selection = !!this._prevSelectionSetting;
1588
+ this._prevSelectionSetting = void 0;
1589
+ this.canvas.discardActiveObject();
1590
+ this._updateUI();
1591
+ this.canvas.renderAll();
1592
+ }
1593
+ /**
1594
+ * Apply the current crop rectangle.
1595
+ * remove all masks and export canvas snapshot and crop via offscreen canvas
1596
+ * @public
1597
+ */
1598
+ async applyCrop() {
1599
+ if (!this.canvas || !this._cropMode || !this._cropRect)
1600
+ return;
1601
+ this._cropRect.setCoords();
1602
+ const rectBounds = this._cropRect.getBoundingRect(true, true);
1603
+ const sx = Math.max(0, Math.round(rectBounds.left));
1604
+ const sy = Math.max(0, Math.round(rectBounds.top));
1605
+ const sw = Math.max(1, Math.round(Math.min(rectBounds.width, this.canvas.getWidth() - sx)));
1606
+ const sh = Math.max(1, Math.round(Math.min(rectBounds.height, this.canvas.getHeight() - sy)));
1607
+ let beforeJson = null;
1608
+ try {
1609
+ const jsonObj = this.canvas.toJSON(["maskId", "maskName", "isCropRect"]);
1610
+ if (Array.isArray(jsonObj.objects)) {
1611
+ jsonObj.objects = jsonObj.objects.filter((o) => !o.isCropRect);
1612
+ }
1613
+ beforeJson = JSON.stringify(jsonObj);
1614
+ } catch (e) {
1615
+ this._reportWarning("applyCrop: could not serialize before state", e);
1616
+ beforeJson = null;
1617
+ }
1618
+ try {
1619
+ const masks = this.canvas.getObjects().filter((o) => o.maskId);
1620
+ if (masks && masks.length) {
1621
+ masks.forEach((m) => {
1622
+ try {
1623
+ this._removeLabelForMask(m);
1624
+ this.canvas.remove(m);
1625
+ } catch (err) {
1626
+ this._reportWarning("applyCrop: failed to remove mask", err);
1627
+ }
1628
+ });
1629
+ this._lastMask = null;
1630
+ this._lastMaskInitialLeft = null;
1631
+ this._lastMaskInitialTop = null;
1632
+ this._lastMaskInitialWidth = null;
1633
+ this.canvas.discardActiveObject();
1634
+ this.canvas.renderAll();
1635
+ }
1636
+ } catch (e) {
1637
+ this._reportWarning("applyCrop: error while removing masks", e);
1638
+ }
1639
+ try {
1640
+ if (this._cropRect) {
1641
+ try {
1642
+ if (this._cropHandlers && this._cropHandlers.length) {
1643
+ this._cropHandlers.forEach((h) => {
1644
+ h.handlers.forEach((rec) => h.target.off(rec.evt, rec.fn));
1645
+ });
1646
+ }
1647
+ } catch (e) {
1648
+ }
1649
+ try {
1650
+ this.canvas.remove(this._cropRect);
1651
+ } catch (e) {
1652
+ }
1653
+ this._cropRect = null;
1654
+ }
1655
+ } catch (e) {
1656
+ }
1657
+ this._cropMode = false;
1658
+ this.canvas.selection = !!this._prevSelectionSetting;
1659
+ this._prevSelectionSetting = void 0;
1660
+ let croppedBase64;
1661
+ try {
1662
+ const fullDataUrl = this.canvas.toDataURL({
1663
+ format: "jpeg",
1664
+ quality: this.options.downsampleQuality || 0.92,
1665
+ multiplier: 1
1666
+ });
1667
+ croppedBase64 = await new Promise((resolve, reject) => {
1668
+ const img = new Image();
1669
+ img.onload = () => {
1670
+ try {
1671
+ const oc = document.createElement("canvas");
1672
+ oc.width = sw;
1673
+ oc.height = sh;
1674
+ const ctx = oc.getContext("2d");
1675
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
1676
+ const out = oc.toDataURL("image/jpeg", this.options.downsampleQuality || 0.92);
1677
+ resolve(out);
1678
+ } catch (err) {
1679
+ reject(err);
1680
+ }
1681
+ };
1682
+ img.onerror = (e) => reject(e);
1683
+ img.src = fullDataUrl;
1684
+ });
1685
+ } catch (e) {
1686
+ this._reportError("applyCrop: failed to create cropped image", e);
1687
+ this._updateUI();
1688
+ return;
1689
+ }
1690
+ try {
1691
+ await this.loadImage(croppedBase64);
1692
+ } catch (e) {
1693
+ this._reportError("applyCrop: loadImage(croppedBase64) failed", e);
1694
+ this._updateUI();
1695
+ return;
1696
+ }
1697
+ let afterJson = null;
1698
+ try {
1699
+ const jsonObj2 = this.canvas.toJSON(["maskId", "maskName", "isCropRect"]);
1700
+ if (Array.isArray(jsonObj2.objects)) {
1701
+ jsonObj2.objects = jsonObj2.objects.filter((o) => !o.isCropRect);
1702
+ }
1703
+ afterJson = JSON.stringify(jsonObj2);
1704
+ } catch (e) {
1705
+ this._reportWarning("applyCrop: failed to serialize after state", e);
1706
+ afterJson = null;
1707
+ }
1708
+ try {
1709
+ const self2 = this;
1710
+ const cmd = new Command(
1711
+ () => {
1712
+ if (afterJson)
1713
+ self2.loadFromState(afterJson);
1714
+ },
1715
+ () => {
1716
+ if (beforeJson)
1717
+ self2.loadFromState(beforeJson);
1718
+ }
1719
+ );
1720
+ if (!this.historyManager)
1721
+ this.historyManager = new HistoryManager(this.maxHistorySize || 50);
1722
+ if (this.historyManager.currentIndex < this.historyManager.history.length - 1) {
1723
+ this.historyManager.history = this.historyManager.history.slice(0, this.historyManager.currentIndex + 1);
1724
+ }
1725
+ this.historyManager.history.push(cmd);
1726
+ if (this.historyManager.history.length > this.historyManager.maxSize) {
1727
+ this.historyManager.history.shift();
1728
+ } else {
1729
+ this.historyManager.currentIndex++;
1730
+ }
1731
+ } catch (e) {
1732
+ this._reportWarning("applyCrop: failed to push history command", e);
1733
+ }
1734
+ this._updateUI();
1735
+ this.canvas.renderAll();
1736
+ }
1737
+ /* ---------- Misc / UI ---------- */
1738
+ /**
1739
+ * Updates the scale input field in the UI to reflect the current scale.
1740
+ * Sets the value (as percentage) if the element is present.
1741
+ * @private
1742
+ */
1743
+ _updateInputs() {
1744
+ const scaleEl = document.getElementById(this.elements.scaleRate);
1745
+ if (scaleEl)
1746
+ scaleEl.value = Math.round(this.currentScale * 100);
1747
+ }
1748
+ /**
1749
+ * Updates the enabled/disabled state of various UI controls (buttons)
1750
+ * based on the current application state (image/mask presence, animation, etc).
1751
+ * @private
1752
+ */
1753
+ _updateUI() {
1754
+ const hasImg = !!this.originalImage;
1755
+ const masks = hasImg ? this.canvas.getObjects().filter((o) => o.maskId) : [];
1756
+ const hasMasks = masks.length > 0;
1757
+ const active = this.canvas.getActiveObject();
1758
+ const hasSelectedMask = active && active.maskId;
1759
+ const isDefault = this.currentScale === 1 && this.currentRotation === 0;
1760
+ const canUndo = this.historyManager?.canUndo();
1761
+ const canRedo = this.historyManager?.canRedo();
1762
+ const inCrop = !!this._cropMode;
1763
+ if (inCrop) {
1764
+ for (const k of Object.keys(this.elements || {})) {
1765
+ const el = document.getElementById(this.elements[k]);
1766
+ if (!el)
1767
+ continue;
1768
+ if (k === "applyCropBtn" || k === "cancelCropBtn") {
1769
+ el.disabled = false;
1770
+ } else {
1771
+ el.disabled = true;
1772
+ }
1773
+ }
1774
+ return;
1775
+ }
1776
+ this._setDisabled("zoomInBtn", !hasImg || this.isAnimating || this.currentScale >= this.options.maxScale);
1777
+ this._setDisabled("zoomOutBtn", !hasImg || this.isAnimating || this.currentScale <= this.options.minScale);
1778
+ this._setDisabled("rotateLeftBtn", !hasImg || this.isAnimating);
1779
+ this._setDisabled("rotateRightBtn", !hasImg || this.isAnimating);
1780
+ this._setDisabled("addMaskBtn", !hasImg || this.isAnimating);
1781
+ this._setDisabled("removeMaskBtn", !hasSelectedMask || this.isAnimating);
1782
+ this._setDisabled("removeAllMasksBtn", !hasMasks || this.isAnimating);
1783
+ this._setDisabled("mergeBtn", !hasImg || !hasMasks || this.isAnimating);
1784
+ this._setDisabled("downloadBtn", !hasImg || this.isAnimating);
1785
+ this._setDisabled("resetBtn", !hasImg || isDefault || this.isAnimating);
1786
+ this._setDisabled("undoBtn", !hasImg || this.isAnimating || !canUndo);
1787
+ this._setDisabled("redoBtn", !hasImg || this.isAnimating || !canRedo);
1788
+ this._setDisabled("cropBtn", !hasImg || this.isAnimating);
1789
+ this._setDisabled("applyCropBtn", true);
1790
+ this._setDisabled("cancelCropBtn", true);
1791
+ }
1792
+ /**
1793
+ * Enables or disables a specific UI element (typically a button) by its key.
1794
+ *
1795
+ * @param {string} key - Key of the element in this.elements (e.g. 'zoomInBtn').
1796
+ * @param {boolean} disabled - If true, disables the element; otherwise enables.
1797
+ * @private
1798
+ */
1799
+ _setDisabled(key, disabled) {
1800
+ const el = document.getElementById(this.elements[key]);
1801
+ if (el)
1802
+ el.disabled = !!disabled;
1803
+ }
1804
+ /**
1805
+ * Automatically display and hide placeholders and containers based on the current image content
1806
+ * @private
1807
+ */
1808
+ _updatePlaceholderStatus() {
1809
+ if (!this.options.showPlaceholder)
1810
+ return;
1811
+ this._setPlaceholderVisible(!this.originalImage);
1812
+ }
1813
+ /**
1814
+ * Controls the display/hiding of the Placeholder and Canvas container.
1815
+ * @param {boolean} show - true displays the placeholder, false displays the canvas container
1816
+ * @private
1817
+ */
1818
+ _setPlaceholderVisible(show) {
1819
+ if (!this.placeholderEl)
1820
+ return;
1821
+ if (show) {
1822
+ this.placeholderEl.classList.remove("d-none");
1823
+ this.placeholderEl.classList.add("d-flex");
1824
+ this.containerEl.classList.add("d-none");
1825
+ } else {
1826
+ this.placeholderEl.classList.remove("d-flex");
1827
+ this.placeholderEl.classList.add("d-none");
1828
+ this.containerEl.classList.remove("d-none");
1829
+ }
1830
+ }
1831
+ /**
1832
+ * Cleans up and disposes of the canvas and related references.
1833
+ * Call this method to free memory and remove canvas listeners when the editor is no longer needed.
1834
+ * @public
1835
+ */
1836
+ dispose() {
1837
+ try {
1838
+ for (const key in this._boundHandlers || {}) {
1839
+ const handlers = this._boundHandlers[key] || [];
1840
+ const el = document.getElementById(this.elements[key]);
1841
+ if (!el)
1842
+ continue;
1843
+ handlers.forEach((h) => {
1844
+ try {
1845
+ el.removeEventListener(h.event, h.handler);
1846
+ } catch (e) {
1847
+ }
1848
+ });
1849
+ }
1850
+ } catch (e) {
1851
+ }
1852
+ if (this._cropRect) {
1853
+ try {
1854
+ this.canvas.remove(this._cropRect);
1855
+ } catch (e) {
1856
+ }
1857
+ this._cropRect = null;
1858
+ }
1859
+ if (this.canvas) {
1860
+ try {
1861
+ this.canvas.dispose();
1862
+ } catch (e) {
1863
+ }
1864
+ this.canvas = null;
1865
+ this.canvasEl = null;
1866
+ this.isImageLoadedToCanvas = false;
1867
+ }
1868
+ this._boundHandlers = {};
1869
+ }
1870
+ };
1871
+ var AnimationQueue = class {
1872
+ /**
1873
+ * Creates a new AnimationQueue.
1874
+ *
1875
+ * @constructor
1876
+ */
1877
+ constructor() {
1878
+ this.queue = [];
1879
+ this.running = false;
1880
+ }
1881
+ /**
1882
+ * Adds an animation function to the queue.
1883
+ *
1884
+ * @param {Function} animationFn A function that returns a Promise or any await-able.
1885
+ * @returns {Promise<*>} A Promise that resolves/rejects with the animation result.
1886
+ */
1887
+ async add(animationFn) {
1888
+ return new Promise((resolve, reject) => {
1889
+ this.queue.push({ fn: animationFn, resolve, reject });
1890
+ if (!this.running) {
1891
+ this.processQueue();
1892
+ }
1893
+ });
1894
+ }
1895
+ /**
1896
+ * Internal helper that processes the animation queue sequentially until it is empty.
1897
+ *
1898
+ * @private
1899
+ * @returns {Promise<void>}
1900
+ */
1901
+ async processQueue() {
1902
+ if (this.queue.length === 0) {
1903
+ this.running = false;
1904
+ return;
1905
+ }
1906
+ this.running = true;
1907
+ const { fn, resolve, reject } = this.queue.shift();
1908
+ try {
1909
+ const result = await fn();
1910
+ resolve(result);
1911
+ } catch (error) {
1912
+ reject(error);
1913
+ }
1914
+ this.processQueue();
1915
+ }
1916
+ };
1917
+ var Command = class {
1918
+ /**
1919
+ * @param {Function} execute The function that performs the action.
1920
+ * @param {Function} undo The function that reverts the action.
1921
+ */
1922
+ constructor(execute, undo) {
1923
+ this.execute = execute;
1924
+ this.undo = undo;
1925
+ }
1926
+ };
1927
+ var HistoryManager = class {
1928
+ /**
1929
+ * @param {number} [maxSize=50] Maximum number of commands to keep in history.
1930
+ */
1931
+ constructor(maxSize = 50) {
1932
+ this.history = [];
1933
+ this.currentIndex = -1;
1934
+ this.maxSize = maxSize;
1935
+ }
1936
+ /**
1937
+ * Executes a new command and pushes it onto the history stack.
1938
+ * Truncates any "future" history when branching.
1939
+ *
1940
+ * @param {Command} command The command to execute.
1941
+ * @returns {void}
1942
+ */
1943
+ execute(command) {
1944
+ command.execute();
1945
+ if (this.currentIndex < this.history.length - 1) {
1946
+ this.history = this.history.slice(0, this.currentIndex + 1);
1947
+ }
1948
+ this.history.push(command);
1949
+ if (this.history.length > this.maxSize) {
1950
+ this.history.shift();
1951
+ } else {
1952
+ this.currentIndex++;
1953
+ }
1954
+ }
1955
+ /**
1956
+ * Checks whether an undo operation is possible.
1957
+ *
1958
+ * @returns {boolean} True if undo can be performed.
1959
+ */
1960
+ canUndo() {
1961
+ return this.currentIndex >= 0;
1962
+ }
1963
+ /**
1964
+ * Checks whether a redo operation is possible.
1965
+ *
1966
+ * @returns {boolean} True if redo can be performed.
1967
+ */
1968
+ canRedo() {
1969
+ return this.currentIndex < this.history.length - 1;
1970
+ }
1971
+ /**
1972
+ * Undoes the last executed command if possible.
1973
+ *
1974
+ * @returns {void}
1975
+ */
1976
+ undo() {
1977
+ if (this.currentIndex >= 0) {
1978
+ this.history[this.currentIndex].undo();
1979
+ this.currentIndex--;
1980
+ }
1981
+ }
1982
+ /**
1983
+ * Redoes the next command in history if possible.
1984
+ *
1985
+ * @returns {void}
1986
+ */
1987
+ redo() {
1988
+ if (this.currentIndex < this.history.length - 1) {
1989
+ this.currentIndex++;
1990
+ this.history[this.currentIndex].execute();
1991
+ }
1992
+ }
1993
+ };
1994
+ var image_editor_default = ImageEditor;
1995
+
1996
+ // src/esm.js
1997
+ var fabricInstance = fabricModule && (fabricModule.fabric || fabricModule.default || fabricModule);
1998
+ setFabric(fabricInstance);
1999
+ var esm_default = image_editor_default;
2000
+ export {
2001
+ image_editor_default as ImageEditor,
2002
+ esm_default as default
2003
+ };
2004
+ //# sourceMappingURL=image-editor.esm.js.map