@overtone-art/canvas-editor-core 0.2.7 → 0.2.8
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/dist/chunk-ORZZ6MGQ.mjs +228 -0
- package/dist/chunk-ORZZ6MGQ.mjs.map +1 -0
- package/dist/index.d.mts +157 -4
- package/dist/index.d.ts +157 -4
- package/dist/index.global.js +59 -59
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +660 -55
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +500 -21
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +51 -2
- package/dist/node.d.ts +51 -2
- package/dist/node.js +546 -13
- package/dist/node.js.map +1 -1
- package/dist/node.mjs +446 -4
- package/dist/node.mjs.map +1 -1
- package/dist/{types-bOY7oYVq.d.mts → types-D60CfxL9.d.mts} +80 -2
- package/dist/{types-bOY7oYVq.d.ts → types-D60CfxL9.d.ts} +80 -2
- package/package.json +37 -2
- package/dist/chunk-NINRTPOJ.mjs +0 -104
- package/dist/chunk-NINRTPOJ.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
computeCoverPlacement,
|
|
3
3
|
computePrintAreaClip,
|
|
4
|
+
displaceRgba,
|
|
4
5
|
exportDataURL,
|
|
6
|
+
exportIsolatedPNG,
|
|
5
7
|
exportMockup,
|
|
6
8
|
exportPNG,
|
|
7
9
|
exportSVG
|
|
8
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-ORZZ6MGQ.mjs";
|
|
9
11
|
|
|
10
12
|
// src/editor.ts
|
|
11
|
-
import { Canvas, FabricImage as
|
|
13
|
+
import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
|
|
12
14
|
|
|
13
15
|
// src/events.ts
|
|
14
16
|
var EventEmitter = class {
|
|
@@ -246,9 +248,13 @@ var LayerManager = class {
|
|
|
246
248
|
};
|
|
247
249
|
|
|
248
250
|
// src/history.ts
|
|
249
|
-
var HistoryManager = class {
|
|
251
|
+
var HistoryManager = class _HistoryManager {
|
|
252
|
+
static ASSET_KEY = "__canvasEditorHistoryAsset";
|
|
250
253
|
undoStack = [];
|
|
251
254
|
redoStack = [];
|
|
255
|
+
assets = /* @__PURE__ */ new Map();
|
|
256
|
+
assetIds = /* @__PURE__ */ new Map();
|
|
257
|
+
nextAssetId = 1;
|
|
252
258
|
maxSize;
|
|
253
259
|
maxBytes;
|
|
254
260
|
paused = false;
|
|
@@ -291,7 +297,8 @@ var HistoryManager = class {
|
|
|
291
297
|
return;
|
|
292
298
|
}
|
|
293
299
|
this.cancelPending();
|
|
294
|
-
const
|
|
300
|
+
const rawState = this.getState();
|
|
301
|
+
const state = this.compactState(rawState);
|
|
295
302
|
if (this.undoStack.at(-1) === state) {
|
|
296
303
|
this.emitChanged();
|
|
297
304
|
return;
|
|
@@ -303,7 +310,7 @@ var HistoryManager = class {
|
|
|
303
310
|
this.redoStack = [];
|
|
304
311
|
this.trimToBudget();
|
|
305
312
|
this.events.emit("history:snapshot", {
|
|
306
|
-
bytes:
|
|
313
|
+
bytes: rawState.length * 2,
|
|
307
314
|
totalBytes: this.snapshotBytes(),
|
|
308
315
|
entries: this.undoStack.length
|
|
309
316
|
});
|
|
@@ -331,15 +338,15 @@ var HistoryManager = class {
|
|
|
331
338
|
}
|
|
332
339
|
async undo() {
|
|
333
340
|
this.cancelPending();
|
|
334
|
-
const current = this.getState();
|
|
335
341
|
const committed = this.undoStack.at(-1);
|
|
336
342
|
if (!committed) return;
|
|
343
|
+
const current = this.compactState(this.getState());
|
|
337
344
|
const currentIsCommitted = current === committed;
|
|
338
345
|
if (currentIsCommitted && this.undoStack.length < 2) return;
|
|
339
346
|
const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
|
|
340
347
|
this.paused = true;
|
|
341
348
|
try {
|
|
342
|
-
await this.restoreState(target);
|
|
349
|
+
await this.restoreState(this.expandState(target));
|
|
343
350
|
if (currentIsCommitted) this.undoStack.pop();
|
|
344
351
|
this.redoStack.push(current);
|
|
345
352
|
this.trimToBudget();
|
|
@@ -357,7 +364,7 @@ var HistoryManager = class {
|
|
|
357
364
|
if (!state) return;
|
|
358
365
|
this.paused = true;
|
|
359
366
|
try {
|
|
360
|
-
await this.restoreState(state);
|
|
367
|
+
await this.restoreState(this.expandState(state));
|
|
361
368
|
this.redoStack.pop();
|
|
362
369
|
if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
|
|
363
370
|
this.trimToBudget();
|
|
@@ -390,6 +397,8 @@ var HistoryManager = class {
|
|
|
390
397
|
this.cancelPending();
|
|
391
398
|
this.undoStack = [];
|
|
392
399
|
this.redoStack = [];
|
|
400
|
+
this.assets.clear();
|
|
401
|
+
this.assetIds.clear();
|
|
393
402
|
this.emitChanged();
|
|
394
403
|
}
|
|
395
404
|
getSnapshotBytes() {
|
|
@@ -405,6 +414,10 @@ var HistoryManager = class {
|
|
|
405
414
|
this.cancelPending();
|
|
406
415
|
this.transactionDepth = 0;
|
|
407
416
|
this.transactionDirty = false;
|
|
417
|
+
this.undoStack = [];
|
|
418
|
+
this.redoStack = [];
|
|
419
|
+
this.assets.clear();
|
|
420
|
+
this.assetIds.clear();
|
|
408
421
|
}
|
|
409
422
|
emitChanged() {
|
|
410
423
|
this.events.emit("history:changed", {
|
|
@@ -413,17 +426,91 @@ var HistoryManager = class {
|
|
|
413
426
|
});
|
|
414
427
|
}
|
|
415
428
|
snapshotBytes() {
|
|
416
|
-
|
|
429
|
+
const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
|
|
417
430
|
(total, state) => total + state.length * 2,
|
|
418
431
|
0
|
|
419
432
|
);
|
|
433
|
+
const assetBytes = [...this.assets.values()].reduce(
|
|
434
|
+
(total, asset) => total + asset.length * 2,
|
|
435
|
+
0
|
|
436
|
+
);
|
|
437
|
+
return stackBytes + assetBytes;
|
|
420
438
|
}
|
|
421
439
|
trimToBudget() {
|
|
440
|
+
this.pruneAssets();
|
|
422
441
|
while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
423
442
|
this.undoStack.shift();
|
|
443
|
+
this.pruneAssets();
|
|
424
444
|
}
|
|
425
445
|
while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
426
446
|
this.redoStack.shift();
|
|
447
|
+
this.pruneAssets();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* History is internal and can content-address large raster strings without
|
|
452
|
+
* changing the public EditorState wire format. Unchanged images are retained
|
|
453
|
+
* once even when dozens of snapshots reference them.
|
|
454
|
+
*/
|
|
455
|
+
compactState(state) {
|
|
456
|
+
let parsed;
|
|
457
|
+
try {
|
|
458
|
+
parsed = JSON.parse(state);
|
|
459
|
+
} catch {
|
|
460
|
+
return state;
|
|
461
|
+
}
|
|
462
|
+
const visit = (value) => {
|
|
463
|
+
if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
|
|
464
|
+
let id = this.assetIds.get(value);
|
|
465
|
+
if (!id) {
|
|
466
|
+
id = `a${this.nextAssetId++}`;
|
|
467
|
+
this.assetIds.set(value, id);
|
|
468
|
+
this.assets.set(id, value);
|
|
469
|
+
}
|
|
470
|
+
return { [_HistoryManager.ASSET_KEY]: id };
|
|
471
|
+
}
|
|
472
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
473
|
+
if (!value || typeof value !== "object") return value;
|
|
474
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
|
|
475
|
+
};
|
|
476
|
+
return JSON.stringify(visit(parsed));
|
|
477
|
+
}
|
|
478
|
+
expandState(state) {
|
|
479
|
+
let parsed;
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(state);
|
|
482
|
+
} catch {
|
|
483
|
+
return state;
|
|
484
|
+
}
|
|
485
|
+
const visit = (value) => {
|
|
486
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
487
|
+
if (!value || typeof value !== "object") return value;
|
|
488
|
+
const record = value;
|
|
489
|
+
const id = record[_HistoryManager.ASSET_KEY];
|
|
490
|
+
if (typeof id === "string" && Object.keys(record).length === 1) {
|
|
491
|
+
const asset = this.assets.get(id);
|
|
492
|
+
if (!asset) throw new Error(`Missing history raster asset: ${id}`);
|
|
493
|
+
return asset;
|
|
494
|
+
}
|
|
495
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
|
|
496
|
+
};
|
|
497
|
+
return JSON.stringify(visit(parsed));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Runs on every commit, so it scans for the serialized reference marker
|
|
501
|
+
* instead of re-parsing every snapshot — parsing multi-megabyte raster states
|
|
502
|
+
* per save would cost more than the retention it reclaims. `JSON.stringify`
|
|
503
|
+
* emits the marker verbatim; the same text inside user data is escaped and
|
|
504
|
+
* therefore cannot match.
|
|
505
|
+
*/
|
|
506
|
+
pruneAssets() {
|
|
507
|
+
if (this.assets.size === 0) return;
|
|
508
|
+
const states = [...this.undoStack, ...this.redoStack];
|
|
509
|
+
for (const [id, asset] of this.assets) {
|
|
510
|
+
const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
|
|
511
|
+
if (states.some((state) => state.includes(marker))) continue;
|
|
512
|
+
this.assets.delete(id);
|
|
513
|
+
this.assetIds.delete(asset);
|
|
427
514
|
}
|
|
428
515
|
}
|
|
429
516
|
};
|
|
@@ -1076,6 +1163,7 @@ function serializeEditor(editor) {
|
|
|
1076
1163
|
// forced transparent while a mockup preview is active).
|
|
1077
1164
|
background: editor.getDesignBackground(),
|
|
1078
1165
|
backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
|
|
1166
|
+
backgroundImageOptions: editor.getBackgroundImageOptions(),
|
|
1079
1167
|
mockup: editor.getMockup()
|
|
1080
1168
|
};
|
|
1081
1169
|
}
|
|
@@ -1094,13 +1182,28 @@ async function deserializeEditor(editor, state) {
|
|
|
1094
1182
|
throw new Error("Invalid editor background color");
|
|
1095
1183
|
}
|
|
1096
1184
|
const staged = await Promise.all(
|
|
1097
|
-
state.layers.map(async (serialized) =>
|
|
1098
|
-
serialized
|
|
1099
|
-
|
|
1100
|
-
|
|
1185
|
+
state.layers.map(async (serialized) => {
|
|
1186
|
+
const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
|
|
1187
|
+
if (!fabricObject) {
|
|
1188
|
+
const source = serialized.fabricObject.src;
|
|
1189
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1190
|
+
throw new Error(`Failed to restore expired object URL: ${source}`);
|
|
1191
|
+
}
|
|
1192
|
+
throw new Error(`Failed to restore layer: ${serialized.id}`);
|
|
1193
|
+
}
|
|
1194
|
+
return { serialized, fabricObject };
|
|
1195
|
+
})
|
|
1101
1196
|
);
|
|
1102
1197
|
const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
|
|
1198
|
+
if (state.backgroundImage && !stagedBackground) {
|
|
1199
|
+
const source = state.backgroundImage.src;
|
|
1200
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1201
|
+
throw new Error(`Failed to restore expired background object URL: ${source}`);
|
|
1202
|
+
}
|
|
1203
|
+
throw new Error("Failed to restore background image");
|
|
1204
|
+
}
|
|
1103
1205
|
editor.crop.cancel();
|
|
1206
|
+
editor.masks.detach();
|
|
1104
1207
|
editor.layers.clear();
|
|
1105
1208
|
if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
|
|
1106
1209
|
if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
|
|
@@ -1108,7 +1211,11 @@ async function deserializeEditor(editor, state) {
|
|
|
1108
1211
|
if (state.background !== void 0) {
|
|
1109
1212
|
editor.setBackground(state.background);
|
|
1110
1213
|
}
|
|
1111
|
-
editor.setBackgroundImageObject(
|
|
1214
|
+
editor.setBackgroundImageObject(
|
|
1215
|
+
stagedBackground ?? null,
|
|
1216
|
+
false,
|
|
1217
|
+
state.backgroundImageOptions ?? null
|
|
1218
|
+
);
|
|
1112
1219
|
editor.setMockup(state.mockup ?? null);
|
|
1113
1220
|
for (const item of staged) {
|
|
1114
1221
|
restoreLayer(editor, item.serialized, item.fabricObject);
|
|
@@ -1445,9 +1552,270 @@ var ProjectManager = class {
|
|
|
1445
1552
|
}
|
|
1446
1553
|
};
|
|
1447
1554
|
|
|
1555
|
+
// src/mask.ts
|
|
1556
|
+
import { FabricImage as FabricImage2 } from "fabric";
|
|
1557
|
+
var MaskRefinementError = class extends Error {
|
|
1558
|
+
constructor(code, message, cause) {
|
|
1559
|
+
super(message);
|
|
1560
|
+
this.code = code;
|
|
1561
|
+
this.cause = cause;
|
|
1562
|
+
this.name = "MaskRefinementError";
|
|
1563
|
+
}
|
|
1564
|
+
code;
|
|
1565
|
+
cause;
|
|
1566
|
+
};
|
|
1567
|
+
var MaskController = class {
|
|
1568
|
+
constructor(editor) {
|
|
1569
|
+
this.editor = editor;
|
|
1570
|
+
}
|
|
1571
|
+
editor;
|
|
1572
|
+
backing = null;
|
|
1573
|
+
context = null;
|
|
1574
|
+
layerId = null;
|
|
1575
|
+
brush = null;
|
|
1576
|
+
previousPoint = null;
|
|
1577
|
+
strokeBackup = null;
|
|
1578
|
+
strokeStartedAt = null;
|
|
1579
|
+
lastInteractionLatencyMs = 0;
|
|
1580
|
+
refinement = null;
|
|
1581
|
+
disposed = false;
|
|
1582
|
+
async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
|
|
1583
|
+
this.assertActive();
|
|
1584
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
1585
|
+
throw new Error("Mask dimensions must be positive integers");
|
|
1586
|
+
}
|
|
1587
|
+
const backing = this.makeCanvas(width, height);
|
|
1588
|
+
const image = new FabricImage2(backing, {
|
|
1589
|
+
left: 0,
|
|
1590
|
+
top: 0,
|
|
1591
|
+
originX: "left",
|
|
1592
|
+
originY: "top",
|
|
1593
|
+
selectable: false
|
|
1594
|
+
});
|
|
1595
|
+
const layer = this.editor.layers.add("mask", image, "Mask");
|
|
1596
|
+
layer.meta.mask = { width, height, revision: 0 };
|
|
1597
|
+
this.editor.history.saveImmediate();
|
|
1598
|
+
this.attachBacking(layer.id, backing);
|
|
1599
|
+
return layer;
|
|
1600
|
+
}
|
|
1601
|
+
attach(layerId) {
|
|
1602
|
+
this.assertActive();
|
|
1603
|
+
this.cancelStroke();
|
|
1604
|
+
const layer = this.requireMask(layerId);
|
|
1605
|
+
const image = layer.fabricObject;
|
|
1606
|
+
const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
|
|
1607
|
+
const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
|
|
1608
|
+
const backing = this.makeCanvas(width, height);
|
|
1609
|
+
const context = backing.getContext("2d");
|
|
1610
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
1611
|
+
const element = image.getElement();
|
|
1612
|
+
if (element) context.drawImage(element, 0, 0, width, height);
|
|
1613
|
+
this.attachBacking(layerId, backing);
|
|
1614
|
+
}
|
|
1615
|
+
beginStroke(options) {
|
|
1616
|
+
this.assertActive();
|
|
1617
|
+
if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
|
|
1618
|
+
if (this.brush) throw new Error("A mask stroke is already active");
|
|
1619
|
+
if (!Number.isFinite(options.size) || options.size <= 0) {
|
|
1620
|
+
throw new Error("Mask brush size must be positive");
|
|
1621
|
+
}
|
|
1622
|
+
this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
|
|
1623
|
+
this.previousPoint = null;
|
|
1624
|
+
this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
|
|
1625
|
+
this.strokeStartedAt = performance.now();
|
|
1626
|
+
}
|
|
1627
|
+
addPoint(point) {
|
|
1628
|
+
if (!this.brush || !this.context || !this.backing || !this.layerId) {
|
|
1629
|
+
throw new Error("No active mask stroke");
|
|
1630
|
+
}
|
|
1631
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
1632
|
+
const previous = this.previousPoint ?? point;
|
|
1633
|
+
const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
1634
|
+
const step = Math.max(1, this.brush.size / 4);
|
|
1635
|
+
const samples = Math.max(1, Math.ceil(distance / step));
|
|
1636
|
+
for (let index = 0; index <= samples; index += 1) {
|
|
1637
|
+
const ratio = index / samples;
|
|
1638
|
+
this.drawDot({
|
|
1639
|
+
x: previous.x + (point.x - previous.x) * ratio,
|
|
1640
|
+
y: previous.y + (point.y - previous.y) * ratio
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
this.previousPoint = point;
|
|
1644
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1645
|
+
this.editor.canvas.requestRenderAll();
|
|
1646
|
+
}
|
|
1647
|
+
async endStroke() {
|
|
1648
|
+
if (!this.brush || !this.backing || !this.layerId) return;
|
|
1649
|
+
const layerId = this.layerId;
|
|
1650
|
+
this.brush = null;
|
|
1651
|
+
this.previousPoint = null;
|
|
1652
|
+
this.strokeBackup = null;
|
|
1653
|
+
const dataUrl = this.backing.toDataURL("image/png");
|
|
1654
|
+
await this.editor.history.transaction(async () => {
|
|
1655
|
+
await this.editor.replaceImageSource(layerId, dataUrl);
|
|
1656
|
+
const layer = this.requireMask(layerId);
|
|
1657
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1658
|
+
});
|
|
1659
|
+
this.attach(layerId);
|
|
1660
|
+
if (this.strokeStartedAt !== null) {
|
|
1661
|
+
this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
|
|
1662
|
+
this.strokeStartedAt = null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
cancelStroke() {
|
|
1666
|
+
if (this.strokeBackup && this.context && this.backing && this.layerId) {
|
|
1667
|
+
this.context.putImageData(this.strokeBackup, 0, 0);
|
|
1668
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1669
|
+
this.editor.canvas.requestRenderAll();
|
|
1670
|
+
}
|
|
1671
|
+
this.brush = null;
|
|
1672
|
+
this.previousPoint = null;
|
|
1673
|
+
this.strokeBackup = null;
|
|
1674
|
+
this.strokeStartedAt = null;
|
|
1675
|
+
}
|
|
1676
|
+
isStrokeActive() {
|
|
1677
|
+
return this.brush !== null;
|
|
1678
|
+
}
|
|
1679
|
+
activeLayerId() {
|
|
1680
|
+
return this.layerId;
|
|
1681
|
+
}
|
|
1682
|
+
detach(layerId) {
|
|
1683
|
+
if (layerId && this.layerId !== layerId) return;
|
|
1684
|
+
this.cancelStroke();
|
|
1685
|
+
this.cancelRefinement();
|
|
1686
|
+
this.backing = null;
|
|
1687
|
+
this.context = null;
|
|
1688
|
+
this.layerId = null;
|
|
1689
|
+
}
|
|
1690
|
+
async refine(layerId, provider, prompts, options = {}) {
|
|
1691
|
+
this.assertActive();
|
|
1692
|
+
const layer = this.editor.layers.get(layerId);
|
|
1693
|
+
if (!layer || layer.type !== "mask") {
|
|
1694
|
+
throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
|
|
1695
|
+
}
|
|
1696
|
+
this.cancelRefinement();
|
|
1697
|
+
const controller = new AbortController();
|
|
1698
|
+
this.refinement = controller;
|
|
1699
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
1700
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1701
|
+
if (options.signal?.aborted) abort();
|
|
1702
|
+
try {
|
|
1703
|
+
const image = layer.fabricObject;
|
|
1704
|
+
const result = await provider.refine(
|
|
1705
|
+
{
|
|
1706
|
+
mask: image.getSrc(),
|
|
1707
|
+
width: layer.meta.mask?.width ?? image.width ?? 1,
|
|
1708
|
+
height: layer.meta.mask?.height ?? image.height ?? 1,
|
|
1709
|
+
prompts: structuredClone(prompts)
|
|
1710
|
+
},
|
|
1711
|
+
{ signal: controller.signal, onProgress: options.onProgress }
|
|
1712
|
+
);
|
|
1713
|
+
if (controller.signal.aborted)
|
|
1714
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
|
|
1715
|
+
if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
|
|
1716
|
+
throw new MaskRefinementError(
|
|
1717
|
+
"invalid-result",
|
|
1718
|
+
"Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
await this.editor.history.transaction(async () => {
|
|
1722
|
+
await this.editor.replaceImageSource(layerId, result.dataUrl);
|
|
1723
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1724
|
+
});
|
|
1725
|
+
this.attach(layerId);
|
|
1726
|
+
return result;
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
if (error instanceof MaskRefinementError) throw error;
|
|
1729
|
+
if (controller.signal.aborted) {
|
|
1730
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
|
|
1731
|
+
}
|
|
1732
|
+
throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
|
|
1733
|
+
} finally {
|
|
1734
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1735
|
+
if (this.refinement === controller) this.refinement = null;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
cancelRefinement() {
|
|
1739
|
+
this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
|
|
1740
|
+
this.refinement = null;
|
|
1741
|
+
}
|
|
1742
|
+
measure() {
|
|
1743
|
+
if (!this.backing) return null;
|
|
1744
|
+
const started = performance.now();
|
|
1745
|
+
this.context?.getImageData(0, 0, 1, 1);
|
|
1746
|
+
const backingBytes = this.backing.width * this.backing.height * 4;
|
|
1747
|
+
const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
|
|
1748
|
+
const memory = performance;
|
|
1749
|
+
return {
|
|
1750
|
+
width: this.backing.width,
|
|
1751
|
+
height: this.backing.height,
|
|
1752
|
+
backingBytes,
|
|
1753
|
+
strokeBackupBytes,
|
|
1754
|
+
// The backing store and rollback ImageData dominate interactive mask
|
|
1755
|
+
// memory. Encoded historical rasters are reported separately below.
|
|
1756
|
+
estimatedPeakBytes: backingBytes + strokeBackupBytes,
|
|
1757
|
+
historyBytes: this.editor.history.getSnapshotBytes(),
|
|
1758
|
+
interactionLatencyMs: this.lastInteractionLatencyMs,
|
|
1759
|
+
...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
|
|
1760
|
+
elapsedMs: performance.now() - started
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
dispose() {
|
|
1764
|
+
this.detach();
|
|
1765
|
+
this.disposed = true;
|
|
1766
|
+
}
|
|
1767
|
+
drawDot(point) {
|
|
1768
|
+
const context = this.context;
|
|
1769
|
+
const brush = this.brush;
|
|
1770
|
+
const radius = brush.size / 2;
|
|
1771
|
+
context.save();
|
|
1772
|
+
context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
|
|
1773
|
+
const gradient = context.createRadialGradient(
|
|
1774
|
+
point.x,
|
|
1775
|
+
point.y,
|
|
1776
|
+
radius * brush.hardness,
|
|
1777
|
+
point.x,
|
|
1778
|
+
point.y,
|
|
1779
|
+
radius
|
|
1780
|
+
);
|
|
1781
|
+
const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
|
|
1782
|
+
gradient.addColorStop(0, color);
|
|
1783
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
1784
|
+
context.fillStyle = gradient;
|
|
1785
|
+
context.beginPath();
|
|
1786
|
+
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
|
1787
|
+
context.fill();
|
|
1788
|
+
context.restore();
|
|
1789
|
+
}
|
|
1790
|
+
makeCanvas(width, height) {
|
|
1791
|
+
const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
1792
|
+
canvas.width = width;
|
|
1793
|
+
canvas.height = height;
|
|
1794
|
+
return canvas;
|
|
1795
|
+
}
|
|
1796
|
+
attachBacking(layerId, backing) {
|
|
1797
|
+
const context = backing.getContext("2d");
|
|
1798
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
1799
|
+
this.layerId = layerId;
|
|
1800
|
+
this.backing = backing;
|
|
1801
|
+
this.context = context;
|
|
1802
|
+
}
|
|
1803
|
+
requireMask(layerId) {
|
|
1804
|
+
const layer = this.editor.layers.get(layerId);
|
|
1805
|
+
if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
|
|
1806
|
+
return layer;
|
|
1807
|
+
}
|
|
1808
|
+
assertActive() {
|
|
1809
|
+
if (this.disposed) throw new Error("Mask controller has been disposed");
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1448
1813
|
// src/editor.ts
|
|
1449
1814
|
var MIN_ZOOM = 0.1;
|
|
1450
1815
|
var MAX_ZOOM = 8;
|
|
1816
|
+
function isTaintedCanvasError(error) {
|
|
1817
|
+
return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
|
|
1818
|
+
}
|
|
1451
1819
|
var CanvasEditor = class {
|
|
1452
1820
|
canvas;
|
|
1453
1821
|
layers;
|
|
@@ -1460,6 +1828,7 @@ var CanvasEditor = class {
|
|
|
1460
1828
|
fonts;
|
|
1461
1829
|
licensing;
|
|
1462
1830
|
pages;
|
|
1831
|
+
masks;
|
|
1463
1832
|
fileAdapter;
|
|
1464
1833
|
imageProvider;
|
|
1465
1834
|
zoomLevel = 1;
|
|
@@ -1469,6 +1838,7 @@ var CanvasEditor = class {
|
|
|
1469
1838
|
// for serialization and export — not the (possibly transient) canvas value.
|
|
1470
1839
|
designBackground;
|
|
1471
1840
|
designBackgroundImage = null;
|
|
1841
|
+
backgroundImageOptions = null;
|
|
1472
1842
|
constructor(canvasElement, config) {
|
|
1473
1843
|
this.events = new EventEmitter();
|
|
1474
1844
|
this.fonts = new FontRegistry();
|
|
@@ -1509,11 +1879,12 @@ var CanvasEditor = class {
|
|
|
1509
1879
|
this.setupCanvasEvents();
|
|
1510
1880
|
this.history.saveImmediate();
|
|
1511
1881
|
this.pages = new ProjectManager(this);
|
|
1882
|
+
this.masks = new MaskController(this);
|
|
1512
1883
|
}
|
|
1513
1884
|
// ─── Layer Operations ────────────────────────────────
|
|
1514
1885
|
async addImage(url, options) {
|
|
1515
1886
|
try {
|
|
1516
|
-
const img = await
|
|
1887
|
+
const img = await FabricImage3.fromURL(
|
|
1517
1888
|
url,
|
|
1518
1889
|
{},
|
|
1519
1890
|
{ originX: "left", originY: "top", ...options }
|
|
@@ -1529,14 +1900,16 @@ var CanvasEditor = class {
|
|
|
1529
1900
|
/** Replace an image source without changing its layer identity or visual transform. */
|
|
1530
1901
|
async replaceImageSource(layerId, url) {
|
|
1531
1902
|
const layer = this.layers.get(layerId);
|
|
1532
|
-
if (!layer || layer.type !== "image"
|
|
1903
|
+
if (!layer || layer.type !== "image" && layer.type !== "mask") {
|
|
1904
|
+
throw new Error(`Image or mask layer not found: ${layerId}`);
|
|
1905
|
+
}
|
|
1533
1906
|
if (layer.meta.pattern) {
|
|
1534
1907
|
throw new Error("Clear the pattern before replacing the image source");
|
|
1535
1908
|
}
|
|
1536
1909
|
if (this.crop.activeLayerId() === layerId) this.crop.cancel();
|
|
1537
1910
|
const previous = layer.fabricObject;
|
|
1538
1911
|
try {
|
|
1539
|
-
const replacement = await
|
|
1912
|
+
const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
|
|
1540
1913
|
replacement.set({
|
|
1541
1914
|
left: previous.left,
|
|
1542
1915
|
top: previous.top,
|
|
@@ -1620,6 +1993,9 @@ var CanvasEditor = class {
|
|
|
1620
1993
|
}
|
|
1621
1994
|
removeLayer(id) {
|
|
1622
1995
|
if (this.crop.activeLayerId() === id) this.crop.cancel();
|
|
1996
|
+
if (this.masks.activeLayerId() === id) {
|
|
1997
|
+
this.masks.detach(id);
|
|
1998
|
+
}
|
|
1623
1999
|
if (this.layers.remove(id)) this.history.save();
|
|
1624
2000
|
}
|
|
1625
2001
|
selectLayer(id) {
|
|
@@ -1767,6 +2143,50 @@ var CanvasEditor = class {
|
|
|
1767
2143
|
async toWebP(options) {
|
|
1768
2144
|
return this.toRaster("webp", options);
|
|
1769
2145
|
}
|
|
2146
|
+
/** Export one layer in document coordinates or at its native image resolution. */
|
|
2147
|
+
async exportLayer(id, options = {}) {
|
|
2148
|
+
const layer = this.layers.get(id);
|
|
2149
|
+
if (!layer) throw new Error(`Layer not found: ${id}`);
|
|
2150
|
+
try {
|
|
2151
|
+
if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
|
|
2152
|
+
const image = await layer.fabricObject.clone();
|
|
2153
|
+
image.set({
|
|
2154
|
+
left: 0,
|
|
2155
|
+
top: 0,
|
|
2156
|
+
originX: "left",
|
|
2157
|
+
originY: "top",
|
|
2158
|
+
scaleX: 1,
|
|
2159
|
+
scaleY: 1,
|
|
2160
|
+
angle: 0,
|
|
2161
|
+
flipX: false,
|
|
2162
|
+
flipY: false
|
|
2163
|
+
});
|
|
2164
|
+
return await exportIsolatedPNG(this.canvas, [image], {
|
|
2165
|
+
...options,
|
|
2166
|
+
width: image.width || 1,
|
|
2167
|
+
height: image.height || 1,
|
|
2168
|
+
cloneObjects: false
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
|
|
2174
|
+
throw error;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/** Export only the configured document background, excluding design layers. */
|
|
2178
|
+
async exportBackground(options = {}) {
|
|
2179
|
+
try {
|
|
2180
|
+
return await exportIsolatedPNG(this.canvas, [], {
|
|
2181
|
+
...options,
|
|
2182
|
+
backgroundColor: this.designBackground,
|
|
2183
|
+
backgroundImage: this.designBackgroundImage
|
|
2184
|
+
});
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
this.events.emit("error", { message: "Failed to export background", error });
|
|
2187
|
+
throw error;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
1770
2190
|
async toRaster(format, options) {
|
|
1771
2191
|
this.events.emit("export:start", { format });
|
|
1772
2192
|
try {
|
|
@@ -1778,7 +2198,10 @@ var CanvasEditor = class {
|
|
|
1778
2198
|
this.licensing.track(`export:${format}`);
|
|
1779
2199
|
return blob;
|
|
1780
2200
|
} catch (error) {
|
|
1781
|
-
this.events.emit("error", {
|
|
2201
|
+
this.events.emit("error", {
|
|
2202
|
+
message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
|
|
2203
|
+
error
|
|
2204
|
+
});
|
|
1782
2205
|
throw error;
|
|
1783
2206
|
}
|
|
1784
2207
|
}
|
|
@@ -1971,13 +2394,20 @@ var CanvasEditor = class {
|
|
|
1971
2394
|
getDesignBackgroundImage() {
|
|
1972
2395
|
return this.designBackgroundImage;
|
|
1973
2396
|
}
|
|
2397
|
+
getBackgroundImageOptions() {
|
|
2398
|
+
return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
|
|
2399
|
+
}
|
|
1974
2400
|
async setBackgroundImage(url, options = {}) {
|
|
1975
2401
|
if (url === null) {
|
|
1976
2402
|
this.setBackgroundImageObject(null);
|
|
1977
2403
|
return;
|
|
1978
2404
|
}
|
|
1979
2405
|
try {
|
|
1980
|
-
const image = await
|
|
2406
|
+
const image = await FabricImage3.fromURL(
|
|
2407
|
+
url,
|
|
2408
|
+
{ crossOrigin: options.crossOrigin ?? null, signal: options.signal },
|
|
2409
|
+
{ originX: "left", originY: "top" }
|
|
2410
|
+
);
|
|
1981
2411
|
const width = image.width || 1;
|
|
1982
2412
|
const height = image.height || 1;
|
|
1983
2413
|
const canvasWidth = this.canvas.getWidth();
|
|
@@ -1996,15 +2426,18 @@ var CanvasEditor = class {
|
|
|
1996
2426
|
selectable: false,
|
|
1997
2427
|
evented: false
|
|
1998
2428
|
});
|
|
1999
|
-
|
|
2429
|
+
const serializableOptions = { ...options };
|
|
2430
|
+
delete serializableOptions.signal;
|
|
2431
|
+
this.setBackgroundImageObject(image, true, serializableOptions);
|
|
2000
2432
|
} catch (error) {
|
|
2001
2433
|
this.events.emit("error", { message: "Failed to set background image", error });
|
|
2002
2434
|
throw error;
|
|
2003
2435
|
}
|
|
2004
2436
|
}
|
|
2005
2437
|
/** Used by state restoration and advanced integrations with an existing Fabric object. */
|
|
2006
|
-
setBackgroundImageObject(image, save = true) {
|
|
2438
|
+
setBackgroundImageObject(image, save = true, options = null) {
|
|
2007
2439
|
this.designBackgroundImage = image;
|
|
2440
|
+
this.backgroundImageOptions = image ? options : null;
|
|
2008
2441
|
this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
|
|
2009
2442
|
this.canvas.requestRenderAll();
|
|
2010
2443
|
if (save) this.history.save();
|
|
@@ -2162,6 +2595,7 @@ var CanvasEditor = class {
|
|
|
2162
2595
|
}
|
|
2163
2596
|
// ─── Cleanup ────────────────────────────────────────
|
|
2164
2597
|
dispose() {
|
|
2598
|
+
this.masks.dispose();
|
|
2165
2599
|
this.snapping.dispose();
|
|
2166
2600
|
this.crop.dispose();
|
|
2167
2601
|
this.history.dispose();
|
|
@@ -2216,7 +2650,49 @@ var CANVAS_SIZE_PRESETS = [
|
|
|
2216
2650
|
{ id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
|
|
2217
2651
|
{ id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
|
|
2218
2652
|
];
|
|
2653
|
+
|
|
2654
|
+
// src/annotations.ts
|
|
2655
|
+
var AnnotationOverlay = class {
|
|
2656
|
+
items = /* @__PURE__ */ new Map();
|
|
2657
|
+
transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
|
|
2658
|
+
set(annotation) {
|
|
2659
|
+
this.items.set(annotation.id, structuredClone(annotation));
|
|
2660
|
+
}
|
|
2661
|
+
remove(id) {
|
|
2662
|
+
return this.items.delete(id);
|
|
2663
|
+
}
|
|
2664
|
+
clear() {
|
|
2665
|
+
this.items.clear();
|
|
2666
|
+
}
|
|
2667
|
+
getAll() {
|
|
2668
|
+
return [...this.items.values()].map((item) => structuredClone(item));
|
|
2669
|
+
}
|
|
2670
|
+
setTransform(transform) {
|
|
2671
|
+
if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
|
|
2672
|
+
throw new Error("Annotation zoom must be positive");
|
|
2673
|
+
}
|
|
2674
|
+
this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
|
|
2675
|
+
}
|
|
2676
|
+
documentToViewport(point) {
|
|
2677
|
+
return {
|
|
2678
|
+
x: point.x * this.transform.zoom + this.transform.panX,
|
|
2679
|
+
y: point.y * this.transform.zoom + this.transform.panY
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
viewportToDocument(point) {
|
|
2683
|
+
return {
|
|
2684
|
+
x: (point.x - this.transform.panX) / this.transform.zoom,
|
|
2685
|
+
y: (point.y - this.transform.panY) / this.transform.zoom
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
documentToDevice(point) {
|
|
2689
|
+
const viewport = this.documentToViewport(point);
|
|
2690
|
+
const ratio = this.transform.devicePixelRatio ?? 1;
|
|
2691
|
+
return { x: viewport.x * ratio, y: viewport.y * ratio };
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2219
2694
|
export {
|
|
2695
|
+
AnnotationOverlay,
|
|
2220
2696
|
CANVAS_SIZE_PRESETS,
|
|
2221
2697
|
CanvasEditor,
|
|
2222
2698
|
CropController,
|
|
@@ -2227,6 +2703,8 @@ export {
|
|
|
2227
2703
|
Layer,
|
|
2228
2704
|
LayerManager,
|
|
2229
2705
|
LicenseManager,
|
|
2706
|
+
MaskController,
|
|
2707
|
+
MaskRefinementError,
|
|
2230
2708
|
PatternManager,
|
|
2231
2709
|
ProjectManager,
|
|
2232
2710
|
SnapManager,
|
|
@@ -2240,6 +2718,7 @@ export {
|
|
|
2240
2718
|
computePrintAreaClip,
|
|
2241
2719
|
computeTilePositions,
|
|
2242
2720
|
deserializeEditor,
|
|
2721
|
+
displaceRgba,
|
|
2243
2722
|
drawTiles,
|
|
2244
2723
|
escapeXml,
|
|
2245
2724
|
exportDataURL,
|