@overtone-art/canvas-editor-core 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-MCBRZQ4M.mjs +261 -0
- package/dist/chunk-MCBRZQ4M.mjs.map +1 -0
- package/dist/index.d.mts +326 -5
- package/dist/index.d.ts +326 -5
- package/dist/index.global.js +71 -71
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +1350 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1136 -35
- 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-Dh1unrzT.d.mts} +107 -2
- package/dist/{types-bOY7oYVq.d.ts → types-Dh1unrzT.d.ts} +107 -2
- package/package.json +42 -5
- package/dist/chunk-NINRTPOJ.mjs +0 -104
- package/dist/chunk-NINRTPOJ.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
computeCoverPlacement,
|
|
3
3
|
computePrintAreaClip,
|
|
4
|
+
displaceRgba,
|
|
4
5
|
exportDataURL,
|
|
6
|
+
exportIsolatedPNG,
|
|
5
7
|
exportMockup,
|
|
6
8
|
exportPNG,
|
|
9
|
+
exportPrintArea,
|
|
7
10
|
exportSVG
|
|
8
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-MCBRZQ4M.mjs";
|
|
9
12
|
|
|
10
13
|
// src/editor.ts
|
|
11
|
-
import { Canvas, FabricImage as
|
|
14
|
+
import { Canvas, FabricImage as FabricImage4, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
|
|
12
15
|
|
|
13
16
|
// src/events.ts
|
|
14
17
|
var EventEmitter = class {
|
|
@@ -246,9 +249,13 @@ var LayerManager = class {
|
|
|
246
249
|
};
|
|
247
250
|
|
|
248
251
|
// src/history.ts
|
|
249
|
-
var HistoryManager = class {
|
|
252
|
+
var HistoryManager = class _HistoryManager {
|
|
253
|
+
static ASSET_KEY = "__canvasEditorHistoryAsset";
|
|
250
254
|
undoStack = [];
|
|
251
255
|
redoStack = [];
|
|
256
|
+
assets = /* @__PURE__ */ new Map();
|
|
257
|
+
assetIds = /* @__PURE__ */ new Map();
|
|
258
|
+
nextAssetId = 1;
|
|
252
259
|
maxSize;
|
|
253
260
|
maxBytes;
|
|
254
261
|
paused = false;
|
|
@@ -291,7 +298,8 @@ var HistoryManager = class {
|
|
|
291
298
|
return;
|
|
292
299
|
}
|
|
293
300
|
this.cancelPending();
|
|
294
|
-
const
|
|
301
|
+
const rawState = this.getState();
|
|
302
|
+
const state = this.compactState(rawState);
|
|
295
303
|
if (this.undoStack.at(-1) === state) {
|
|
296
304
|
this.emitChanged();
|
|
297
305
|
return;
|
|
@@ -303,7 +311,7 @@ var HistoryManager = class {
|
|
|
303
311
|
this.redoStack = [];
|
|
304
312
|
this.trimToBudget();
|
|
305
313
|
this.events.emit("history:snapshot", {
|
|
306
|
-
bytes:
|
|
314
|
+
bytes: rawState.length * 2,
|
|
307
315
|
totalBytes: this.snapshotBytes(),
|
|
308
316
|
entries: this.undoStack.length
|
|
309
317
|
});
|
|
@@ -331,15 +339,15 @@ var HistoryManager = class {
|
|
|
331
339
|
}
|
|
332
340
|
async undo() {
|
|
333
341
|
this.cancelPending();
|
|
334
|
-
const current = this.getState();
|
|
335
342
|
const committed = this.undoStack.at(-1);
|
|
336
343
|
if (!committed) return;
|
|
344
|
+
const current = this.compactState(this.getState());
|
|
337
345
|
const currentIsCommitted = current === committed;
|
|
338
346
|
if (currentIsCommitted && this.undoStack.length < 2) return;
|
|
339
347
|
const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
|
|
340
348
|
this.paused = true;
|
|
341
349
|
try {
|
|
342
|
-
await this.restoreState(target);
|
|
350
|
+
await this.restoreState(this.expandState(target));
|
|
343
351
|
if (currentIsCommitted) this.undoStack.pop();
|
|
344
352
|
this.redoStack.push(current);
|
|
345
353
|
this.trimToBudget();
|
|
@@ -357,7 +365,7 @@ var HistoryManager = class {
|
|
|
357
365
|
if (!state) return;
|
|
358
366
|
this.paused = true;
|
|
359
367
|
try {
|
|
360
|
-
await this.restoreState(state);
|
|
368
|
+
await this.restoreState(this.expandState(state));
|
|
361
369
|
this.redoStack.pop();
|
|
362
370
|
if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
|
|
363
371
|
this.trimToBudget();
|
|
@@ -390,6 +398,8 @@ var HistoryManager = class {
|
|
|
390
398
|
this.cancelPending();
|
|
391
399
|
this.undoStack = [];
|
|
392
400
|
this.redoStack = [];
|
|
401
|
+
this.assets.clear();
|
|
402
|
+
this.assetIds.clear();
|
|
393
403
|
this.emitChanged();
|
|
394
404
|
}
|
|
395
405
|
getSnapshotBytes() {
|
|
@@ -405,6 +415,10 @@ var HistoryManager = class {
|
|
|
405
415
|
this.cancelPending();
|
|
406
416
|
this.transactionDepth = 0;
|
|
407
417
|
this.transactionDirty = false;
|
|
418
|
+
this.undoStack = [];
|
|
419
|
+
this.redoStack = [];
|
|
420
|
+
this.assets.clear();
|
|
421
|
+
this.assetIds.clear();
|
|
408
422
|
}
|
|
409
423
|
emitChanged() {
|
|
410
424
|
this.events.emit("history:changed", {
|
|
@@ -413,17 +427,91 @@ var HistoryManager = class {
|
|
|
413
427
|
});
|
|
414
428
|
}
|
|
415
429
|
snapshotBytes() {
|
|
416
|
-
|
|
430
|
+
const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
|
|
417
431
|
(total, state) => total + state.length * 2,
|
|
418
432
|
0
|
|
419
433
|
);
|
|
434
|
+
const assetBytes = [...this.assets.values()].reduce(
|
|
435
|
+
(total, asset) => total + asset.length * 2,
|
|
436
|
+
0
|
|
437
|
+
);
|
|
438
|
+
return stackBytes + assetBytes;
|
|
420
439
|
}
|
|
421
440
|
trimToBudget() {
|
|
441
|
+
this.pruneAssets();
|
|
422
442
|
while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
423
443
|
this.undoStack.shift();
|
|
444
|
+
this.pruneAssets();
|
|
424
445
|
}
|
|
425
446
|
while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
426
447
|
this.redoStack.shift();
|
|
448
|
+
this.pruneAssets();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* History is internal and can content-address large raster strings without
|
|
453
|
+
* changing the public EditorState wire format. Unchanged images are retained
|
|
454
|
+
* once even when dozens of snapshots reference them.
|
|
455
|
+
*/
|
|
456
|
+
compactState(state) {
|
|
457
|
+
let parsed;
|
|
458
|
+
try {
|
|
459
|
+
parsed = JSON.parse(state);
|
|
460
|
+
} catch {
|
|
461
|
+
return state;
|
|
462
|
+
}
|
|
463
|
+
const visit = (value) => {
|
|
464
|
+
if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
|
|
465
|
+
let id = this.assetIds.get(value);
|
|
466
|
+
if (!id) {
|
|
467
|
+
id = `a${this.nextAssetId++}`;
|
|
468
|
+
this.assetIds.set(value, id);
|
|
469
|
+
this.assets.set(id, value);
|
|
470
|
+
}
|
|
471
|
+
return { [_HistoryManager.ASSET_KEY]: id };
|
|
472
|
+
}
|
|
473
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
474
|
+
if (!value || typeof value !== "object") return value;
|
|
475
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
|
|
476
|
+
};
|
|
477
|
+
return JSON.stringify(visit(parsed));
|
|
478
|
+
}
|
|
479
|
+
expandState(state) {
|
|
480
|
+
let parsed;
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(state);
|
|
483
|
+
} catch {
|
|
484
|
+
return state;
|
|
485
|
+
}
|
|
486
|
+
const visit = (value) => {
|
|
487
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
488
|
+
if (!value || typeof value !== "object") return value;
|
|
489
|
+
const record = value;
|
|
490
|
+
const id = record[_HistoryManager.ASSET_KEY];
|
|
491
|
+
if (typeof id === "string" && Object.keys(record).length === 1) {
|
|
492
|
+
const asset = this.assets.get(id);
|
|
493
|
+
if (!asset) throw new Error(`Missing history raster asset: ${id}`);
|
|
494
|
+
return asset;
|
|
495
|
+
}
|
|
496
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
|
|
497
|
+
};
|
|
498
|
+
return JSON.stringify(visit(parsed));
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Runs on every commit, so it scans for the serialized reference marker
|
|
502
|
+
* instead of re-parsing every snapshot — parsing multi-megabyte raster states
|
|
503
|
+
* per save would cost more than the retention it reclaims. `JSON.stringify`
|
|
504
|
+
* emits the marker verbatim; the same text inside user data is escaped and
|
|
505
|
+
* therefore cannot match.
|
|
506
|
+
*/
|
|
507
|
+
pruneAssets() {
|
|
508
|
+
if (this.assets.size === 0) return;
|
|
509
|
+
const states = [...this.undoStack, ...this.redoStack];
|
|
510
|
+
for (const [id, asset] of this.assets) {
|
|
511
|
+
const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
|
|
512
|
+
if (states.some((state) => state.includes(marker))) continue;
|
|
513
|
+
this.assets.delete(id);
|
|
514
|
+
this.assetIds.delete(asset);
|
|
427
515
|
}
|
|
428
516
|
}
|
|
429
517
|
};
|
|
@@ -1001,6 +1089,521 @@ function mod2(n) {
|
|
|
1001
1089
|
return (n % 2 + 2) % 2;
|
|
1002
1090
|
}
|
|
1003
1091
|
|
|
1092
|
+
// src/text-curve.ts
|
|
1093
|
+
import { Path } from "fabric";
|
|
1094
|
+
var DEFAULT_TEXT_CURVE = { arc: 0, wave: 0 };
|
|
1095
|
+
var MIN_ARC = 0.5;
|
|
1096
|
+
var FULL_CIRCLE_ARC = 99.5;
|
|
1097
|
+
var MIN_SWEEP = 0.12;
|
|
1098
|
+
var WAVE_PERIOD_EM = 4.1;
|
|
1099
|
+
var WAVE_AMPLITUDE_EM = 0.9;
|
|
1100
|
+
var WAVE_STEP = 6;
|
|
1101
|
+
var MEASURE_WIDTH = 1e5;
|
|
1102
|
+
var PATH_SLACK = 0.06;
|
|
1103
|
+
function isCurvable(object) {
|
|
1104
|
+
return !!object && typeof object.text === "string";
|
|
1105
|
+
}
|
|
1106
|
+
function measureText(text) {
|
|
1107
|
+
const authored = text.width;
|
|
1108
|
+
try {
|
|
1109
|
+
text.set({ width: MEASURE_WIDTH });
|
|
1110
|
+
text.initDimensions?.();
|
|
1111
|
+
return Math.max(1, text.calcTextWidth?.() ?? text.width ?? 1);
|
|
1112
|
+
} finally {
|
|
1113
|
+
if (authored !== void 0) text.set({ width: authored });
|
|
1114
|
+
text.initDimensions?.();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function arcPathData(width, arc) {
|
|
1118
|
+
const magnitude = Math.min(100, Math.abs(arc));
|
|
1119
|
+
const direction = arc < 0 ? -1 : 1;
|
|
1120
|
+
const full = magnitude >= FULL_CIRCLE_ARC;
|
|
1121
|
+
const sweep = full ? Math.PI * 2 : Math.max(MIN_SWEEP, magnitude / 100 * Math.PI);
|
|
1122
|
+
const radius = width / sweep;
|
|
1123
|
+
const centerX = width / 2;
|
|
1124
|
+
const point = (angle) => [
|
|
1125
|
+
centerX + radius * Math.sin(angle),
|
|
1126
|
+
direction * radius * (1 - Math.cos(angle))
|
|
1127
|
+
];
|
|
1128
|
+
const sweepFlag = direction > 0 ? 1 : 0;
|
|
1129
|
+
const format = ([x, y]) => `${round(x)} ${round(y)}`;
|
|
1130
|
+
if (full) {
|
|
1131
|
+
const start2 = point(-Math.PI);
|
|
1132
|
+
const top = point(0);
|
|
1133
|
+
return {
|
|
1134
|
+
data: [
|
|
1135
|
+
`M ${format(start2)}`,
|
|
1136
|
+
`A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(top)}`,
|
|
1137
|
+
`A ${round(radius)} ${round(radius)} 0 0 ${sweepFlag} ${format(start2)}`
|
|
1138
|
+
].join(" "),
|
|
1139
|
+
length: width
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
const start = point(-sweep / 2);
|
|
1143
|
+
const end = point(sweep / 2);
|
|
1144
|
+
const largeArc = sweep > Math.PI ? 1 : 0;
|
|
1145
|
+
return {
|
|
1146
|
+
data: `M ${format(start)} A ${round(radius)} ${round(radius)} 0 ${largeArc} ${sweepFlag} ${format(end)}`,
|
|
1147
|
+
// radius = width / sweep, so the arc is exactly `width` long.
|
|
1148
|
+
length: width
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function wavePathData(width, fontSize, wave) {
|
|
1152
|
+
const amplitude = clamp(wave, 0, 100) / 100 * fontSize * WAVE_AMPLITUDE_EM;
|
|
1153
|
+
const period = Math.max(1, fontSize * WAVE_PERIOD_EM);
|
|
1154
|
+
const steps = Math.max(2, Math.ceil(width / WAVE_STEP));
|
|
1155
|
+
const commands = [];
|
|
1156
|
+
let length = 0;
|
|
1157
|
+
let previous = null;
|
|
1158
|
+
for (let index = 0; index <= steps; index++) {
|
|
1159
|
+
const x = width * index / steps;
|
|
1160
|
+
const y = amplitude * Math.sin(x / period * Math.PI * 2);
|
|
1161
|
+
if (previous) length += Math.hypot(x - previous[0], y - previous[1]);
|
|
1162
|
+
previous = [x, y];
|
|
1163
|
+
commands.push(`${index === 0 ? "M" : "L"} ${round(x)} ${round(y)}`);
|
|
1164
|
+
}
|
|
1165
|
+
return { data: commands.join(" "), length };
|
|
1166
|
+
}
|
|
1167
|
+
function round(value) {
|
|
1168
|
+
return Math.round(value * 100) / 100;
|
|
1169
|
+
}
|
|
1170
|
+
function buildCurvePathData(config, width, fontSize) {
|
|
1171
|
+
if (Math.abs(config.arc) >= MIN_ARC) return arcPathData(width, config.arc);
|
|
1172
|
+
if (config.wave > 0) return wavePathData(width, fontSize, config.wave);
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
function normalize(config) {
|
|
1176
|
+
const arc = clamp(config.arc ?? 0, -100, 100);
|
|
1177
|
+
return { arc, wave: Math.abs(arc) >= MIN_ARC ? 0 : clamp(config.wave ?? 0, 0, 100) };
|
|
1178
|
+
}
|
|
1179
|
+
var TextCurveManager = class {
|
|
1180
|
+
constructor(canvas, layers, history, events) {
|
|
1181
|
+
this.canvas = canvas;
|
|
1182
|
+
this.layers = layers;
|
|
1183
|
+
this.history = history;
|
|
1184
|
+
this.events = events;
|
|
1185
|
+
}
|
|
1186
|
+
canvas;
|
|
1187
|
+
layers;
|
|
1188
|
+
history;
|
|
1189
|
+
events;
|
|
1190
|
+
/** Curve parameters for a layer, or null when it is not curved text. */
|
|
1191
|
+
get(layerId) {
|
|
1192
|
+
const layer = this.layers.get(layerId);
|
|
1193
|
+
if (!layer || !isCurvable(layer.fabricObject)) return null;
|
|
1194
|
+
return layer.meta.curve ?? { ...DEFAULT_TEXT_CURVE };
|
|
1195
|
+
}
|
|
1196
|
+
isCurved(layerId) {
|
|
1197
|
+
const curve = this.get(layerId);
|
|
1198
|
+
return !!curve && (Math.abs(curve.arc) >= MIN_ARC || curve.wave > 0);
|
|
1199
|
+
}
|
|
1200
|
+
/** Apply (or update) the curve on a text layer. Zeroed config clears it. */
|
|
1201
|
+
apply(layerId, config, save = true) {
|
|
1202
|
+
const layer = this.layers.get(layerId);
|
|
1203
|
+
if (!layer || !isCurvable(layer.fabricObject)) return false;
|
|
1204
|
+
const next = normalize(config);
|
|
1205
|
+
const text = layer.fabricObject;
|
|
1206
|
+
const run = measureText(text);
|
|
1207
|
+
const curve = buildCurvePathData(next, run * (1 + PATH_SLACK), text.fontSize);
|
|
1208
|
+
if (!curve) {
|
|
1209
|
+
this.detach(text, layer.meta.curveWidth);
|
|
1210
|
+
delete layer.meta.curve;
|
|
1211
|
+
delete layer.meta.curveWidth;
|
|
1212
|
+
} else {
|
|
1213
|
+
if (layer.meta.curveWidth === void 0) layer.meta.curveWidth = text.width ?? 0;
|
|
1214
|
+
text.set({ width: Math.max(layer.meta.curveWidth, run + 2) });
|
|
1215
|
+
text.set({
|
|
1216
|
+
path: new Path(curve.data, { visible: false, objectCaching: false }),
|
|
1217
|
+
pathAlign: "center",
|
|
1218
|
+
pathSide: "left",
|
|
1219
|
+
pathStartOffset: Math.max(0, (curve.length - run) / 2)
|
|
1220
|
+
});
|
|
1221
|
+
layer.meta.curve = next;
|
|
1222
|
+
}
|
|
1223
|
+
text.initDimensions?.();
|
|
1224
|
+
text.setCoords();
|
|
1225
|
+
text.dirty = true;
|
|
1226
|
+
this.canvas.requestRenderAll();
|
|
1227
|
+
this.events.emit("layer:modified", { layerId });
|
|
1228
|
+
if (save) this.history.save();
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
/** Remove the curve, restoring the authored text box width. */
|
|
1232
|
+
clear(layerId, save = true) {
|
|
1233
|
+
return this.apply(layerId, DEFAULT_TEXT_CURVE, save);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Rebuild the path from the stored parameters. Text content, font family and
|
|
1237
|
+
* font size all change the run's width, and the path is sized to that width —
|
|
1238
|
+
* without this the curve keeps the geometry of the text it was created from.
|
|
1239
|
+
*/
|
|
1240
|
+
refresh(layerId, save = false) {
|
|
1241
|
+
const curve = this.layers.get(layerId)?.meta.curve;
|
|
1242
|
+
if (!curve) return false;
|
|
1243
|
+
return this.apply(layerId, curve, save);
|
|
1244
|
+
}
|
|
1245
|
+
/** Rebuild every curved layer — used after a state restore. */
|
|
1246
|
+
refreshAll() {
|
|
1247
|
+
for (const layer of this.layers.getAll()) {
|
|
1248
|
+
if (layer.meta.curve) this.refresh(layer.id);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
detach(text, authoredWidth) {
|
|
1252
|
+
text.set({ path: null, pathStartOffset: 0 });
|
|
1253
|
+
if (authoredWidth !== void 0 && authoredWidth > 0) text.set({ width: authoredWidth });
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// src/mask-presets/manager.ts
|
|
1258
|
+
import { FabricImage as FabricImage2, Path as Path2 } from "fabric";
|
|
1259
|
+
|
|
1260
|
+
// src/mask-presets/shapes.ts
|
|
1261
|
+
var SHAPE_MASK_IDS = [
|
|
1262
|
+
"circle",
|
|
1263
|
+
"square",
|
|
1264
|
+
"triangle",
|
|
1265
|
+
"star",
|
|
1266
|
+
"heart",
|
|
1267
|
+
"octagram",
|
|
1268
|
+
"arch",
|
|
1269
|
+
"zigzag"
|
|
1270
|
+
];
|
|
1271
|
+
function isShapeMaskId(value) {
|
|
1272
|
+
return typeof value === "string" && SHAPE_MASK_IDS.includes(value);
|
|
1273
|
+
}
|
|
1274
|
+
function starPoints(points, outer, inner, start = -Math.PI / 2) {
|
|
1275
|
+
const step = Math.PI / points;
|
|
1276
|
+
const coordinates = [];
|
|
1277
|
+
for (let index = 0; index < points * 2; index++) {
|
|
1278
|
+
const radius = index % 2 === 0 ? outer : inner;
|
|
1279
|
+
const angle = start + index * step;
|
|
1280
|
+
const x = 50 + Math.cos(angle) * radius;
|
|
1281
|
+
const y = 50 + Math.sin(angle) * radius;
|
|
1282
|
+
coordinates.push(`${index === 0 ? "M" : "L"} ${round3(x)} ${round3(y)}`);
|
|
1283
|
+
}
|
|
1284
|
+
return `${coordinates.join(" ")} Z`;
|
|
1285
|
+
}
|
|
1286
|
+
function round3(value) {
|
|
1287
|
+
return Math.round(value * 100) / 100;
|
|
1288
|
+
}
|
|
1289
|
+
var SHAPE_PATHS = {
|
|
1290
|
+
circle: "M 96 50 A 46 46 0 1 1 4 50 A 46 46 0 1 1 96 50 Z",
|
|
1291
|
+
square: "M 4 4 H 96 V 96 H 4 Z",
|
|
1292
|
+
triangle: "M 50 4 L 96 96 L 4 96 Z",
|
|
1293
|
+
star: starPoints(5, 46, 22),
|
|
1294
|
+
heart: "M 50 96 C -8 55 12 4 50 28 C 88 4 108 55 50 96 Z",
|
|
1295
|
+
octagram: starPoints(8, 46, 27),
|
|
1296
|
+
arch: "M 4 96 V 50 A 46 46 0 0 1 96 50 V 96 Z",
|
|
1297
|
+
zigzag: starPoints(16, 46, 39)
|
|
1298
|
+
};
|
|
1299
|
+
function shapeMaskPathData(id) {
|
|
1300
|
+
return SHAPE_PATHS[id];
|
|
1301
|
+
}
|
|
1302
|
+
var SHAPE_MASK_BOX = 100;
|
|
1303
|
+
|
|
1304
|
+
// src/mask-presets/textures.ts
|
|
1305
|
+
var TEXTURE_MASK_IDS = [
|
|
1306
|
+
"vignette",
|
|
1307
|
+
"halftone",
|
|
1308
|
+
"spray",
|
|
1309
|
+
"grunge",
|
|
1310
|
+
"torn",
|
|
1311
|
+
"band"
|
|
1312
|
+
];
|
|
1313
|
+
function isTextureMaskId(value) {
|
|
1314
|
+
return typeof value === "string" && TEXTURE_MASK_IDS.includes(value);
|
|
1315
|
+
}
|
|
1316
|
+
var TEXTURE_MASK_SIZE = 320;
|
|
1317
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1318
|
+
function seeded(seed) {
|
|
1319
|
+
let state = seed;
|
|
1320
|
+
return () => {
|
|
1321
|
+
state = state * 16807 % 2147483647;
|
|
1322
|
+
return (state - 1) / 2147483646;
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
function traceRoughEdge(context, random, jitter, inset) {
|
|
1326
|
+
const size = TEXTURE_MASK_SIZE;
|
|
1327
|
+
const corners = [
|
|
1328
|
+
[inset, inset],
|
|
1329
|
+
[size - inset, inset],
|
|
1330
|
+
[size - inset, size - inset],
|
|
1331
|
+
[inset, size - inset]
|
|
1332
|
+
];
|
|
1333
|
+
context.beginPath();
|
|
1334
|
+
for (let corner = 0; corner < corners.length; corner++) {
|
|
1335
|
+
const [fromX, fromY] = corners[corner];
|
|
1336
|
+
const [toX, toY] = corners[(corner + 1) % corners.length];
|
|
1337
|
+
for (let step = 0; step <= 26; step++) {
|
|
1338
|
+
const ratio = step / 26;
|
|
1339
|
+
const x = fromX + (toX - fromX) * ratio + (random() - 0.5) * jitter;
|
|
1340
|
+
const y = fromY + (toY - fromY) * ratio + (random() - 0.5) * jitter;
|
|
1341
|
+
if (corner === 0 && step === 0) context.moveTo(x, y);
|
|
1342
|
+
else context.lineTo(x, y);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
context.closePath();
|
|
1346
|
+
context.fill();
|
|
1347
|
+
}
|
|
1348
|
+
function paint(id, context) {
|
|
1349
|
+
const size = TEXTURE_MASK_SIZE;
|
|
1350
|
+
const center = size / 2;
|
|
1351
|
+
context.fillStyle = "#ffffff";
|
|
1352
|
+
switch (id) {
|
|
1353
|
+
case "vignette": {
|
|
1354
|
+
const gradient = context.createRadialGradient(
|
|
1355
|
+
center,
|
|
1356
|
+
center,
|
|
1357
|
+
size * 0.18,
|
|
1358
|
+
center,
|
|
1359
|
+
center,
|
|
1360
|
+
size * 0.52
|
|
1361
|
+
);
|
|
1362
|
+
gradient.addColorStop(0, "rgba(255,255,255,1)");
|
|
1363
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
1364
|
+
context.fillStyle = gradient;
|
|
1365
|
+
context.fillRect(0, 0, size, size);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
case "band": {
|
|
1369
|
+
const gradient = context.createLinearGradient(0, 0, 0, size);
|
|
1370
|
+
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
|
1371
|
+
gradient.addColorStop(0.25, "rgba(255,255,255,1)");
|
|
1372
|
+
gradient.addColorStop(0.75, "rgba(255,255,255,1)");
|
|
1373
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
1374
|
+
context.fillStyle = gradient;
|
|
1375
|
+
context.fillRect(0, 0, size, size);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
case "halftone": {
|
|
1379
|
+
const pitch = 16;
|
|
1380
|
+
for (let y = pitch / 2; y < size; y += pitch) {
|
|
1381
|
+
for (let x = pitch / 2; x < size; x += pitch) {
|
|
1382
|
+
const distance = Math.hypot(x - center, y - center) / (size * 0.52);
|
|
1383
|
+
const radius = Math.max(0, pitch / 2 * (1 - distance) * 1.15);
|
|
1384
|
+
if (radius < 0.4) continue;
|
|
1385
|
+
context.beginPath();
|
|
1386
|
+
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
1387
|
+
context.fill();
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
case "spray": {
|
|
1393
|
+
const random = seeded(42);
|
|
1394
|
+
const core = context.createRadialGradient(center, center, 10, center, center, size * 0.42);
|
|
1395
|
+
core.addColorStop(0, "rgba(255,255,255,1)");
|
|
1396
|
+
core.addColorStop(1, "rgba(255,255,255,0.85)");
|
|
1397
|
+
context.fillStyle = core;
|
|
1398
|
+
context.beginPath();
|
|
1399
|
+
context.arc(center, center, size * 0.42, 0, Math.PI * 2);
|
|
1400
|
+
context.fill();
|
|
1401
|
+
context.fillStyle = "rgba(255,255,255,0.9)";
|
|
1402
|
+
for (let dot = 0; dot < 900; dot++) {
|
|
1403
|
+
const angle = random() * Math.PI * 2;
|
|
1404
|
+
const distance = size * (0.3 + random() * 0.24);
|
|
1405
|
+
const x = center + Math.cos(angle) * distance;
|
|
1406
|
+
const y = center + Math.sin(angle) * distance;
|
|
1407
|
+
context.globalAlpha = 1 - (distance / size - 0.3) / 0.24;
|
|
1408
|
+
context.beginPath();
|
|
1409
|
+
context.arc(x, y, random() * 2.4, 0, Math.PI * 2);
|
|
1410
|
+
context.fill();
|
|
1411
|
+
}
|
|
1412
|
+
context.globalAlpha = 1;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
case "grunge": {
|
|
1416
|
+
const random = seeded(7);
|
|
1417
|
+
traceRoughEdge(context, random, 14, size * 0.06);
|
|
1418
|
+
context.globalCompositeOperation = "destination-out";
|
|
1419
|
+
for (let speckle = 0; speckle < 240; speckle++) {
|
|
1420
|
+
const x = random() * size;
|
|
1421
|
+
const y = random() * size;
|
|
1422
|
+
const nearEdge = Math.min(x, y, size - x, size - y) < size * 0.12;
|
|
1423
|
+
if (!nearEdge && random() >= 0.12) continue;
|
|
1424
|
+
context.beginPath();
|
|
1425
|
+
context.arc(x, y, random() * 6 + 1, 0, Math.PI * 2);
|
|
1426
|
+
context.fill();
|
|
1427
|
+
}
|
|
1428
|
+
context.globalCompositeOperation = "source-over";
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
case "torn": {
|
|
1432
|
+
traceRoughEdge(context, seeded(99), 10, size * 0.08);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function renderTextureMask(id) {
|
|
1438
|
+
const cached = cache.get(id);
|
|
1439
|
+
if (cached) return cached;
|
|
1440
|
+
if (typeof document === "undefined") {
|
|
1441
|
+
throw new Error("Texture masks require a DOM canvas");
|
|
1442
|
+
}
|
|
1443
|
+
const canvas = document.createElement("canvas");
|
|
1444
|
+
canvas.width = TEXTURE_MASK_SIZE;
|
|
1445
|
+
canvas.height = TEXTURE_MASK_SIZE;
|
|
1446
|
+
const context = canvas.getContext("2d");
|
|
1447
|
+
if (!context) throw new Error("Texture masks require a 2D canvas context");
|
|
1448
|
+
paint(id, context);
|
|
1449
|
+
cache.set(id, canvas);
|
|
1450
|
+
return canvas;
|
|
1451
|
+
}
|
|
1452
|
+
function clearTextureMaskCache() {
|
|
1453
|
+
cache.clear();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/mask-presets/manager.ts
|
|
1457
|
+
function isMaskPresetId(value) {
|
|
1458
|
+
return isShapeMaskId(value) || isTextureMaskId(value);
|
|
1459
|
+
}
|
|
1460
|
+
var MaskPresetManager = class {
|
|
1461
|
+
constructor(canvas, layers, history, events) {
|
|
1462
|
+
this.canvas = canvas;
|
|
1463
|
+
this.layers = layers;
|
|
1464
|
+
this.history = history;
|
|
1465
|
+
this.events = events;
|
|
1466
|
+
}
|
|
1467
|
+
canvas;
|
|
1468
|
+
layers;
|
|
1469
|
+
history;
|
|
1470
|
+
events;
|
|
1471
|
+
get(layerId) {
|
|
1472
|
+
return this.layers.get(layerId)?.meta.maskPreset ?? null;
|
|
1473
|
+
}
|
|
1474
|
+
/** Clip the layer to `id`. Passing null (or an unknown id) clears the clip. */
|
|
1475
|
+
apply(layerId, id, save = true) {
|
|
1476
|
+
const layer = this.layers.get(layerId);
|
|
1477
|
+
if (!layer) return false;
|
|
1478
|
+
const object = layer.fabricObject;
|
|
1479
|
+
if (id === null) {
|
|
1480
|
+
if (!layer.meta.maskPreset) return false;
|
|
1481
|
+
object.clipPath = void 0;
|
|
1482
|
+
delete layer.meta.maskPreset;
|
|
1483
|
+
} else {
|
|
1484
|
+
if (!isMaskPresetId(id)) return false;
|
|
1485
|
+
object.clipPath = this.buildClip(id, object);
|
|
1486
|
+
layer.meta.maskPreset = id;
|
|
1487
|
+
}
|
|
1488
|
+
object.dirty = true;
|
|
1489
|
+
object.setCoords();
|
|
1490
|
+
this.canvas.requestRenderAll();
|
|
1491
|
+
this.events.emit("layer:modified", { layerId });
|
|
1492
|
+
if (save) this.history.save();
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
clear(layerId, save = true) {
|
|
1496
|
+
return this.apply(layerId, null, save);
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Re-fit the clip to the layer's current size. The clip is built for the
|
|
1500
|
+
* object's dimensions at the time it was applied; editing text or replacing an
|
|
1501
|
+
* image changes them, and a stale clip would crop the wrong region.
|
|
1502
|
+
*/
|
|
1503
|
+
refresh(layerId, save = false) {
|
|
1504
|
+
const id = this.get(layerId);
|
|
1505
|
+
if (!id) return false;
|
|
1506
|
+
return this.apply(layerId, id, save);
|
|
1507
|
+
}
|
|
1508
|
+
/** Re-fit every masked layer — used after a state restore. */
|
|
1509
|
+
refreshAll() {
|
|
1510
|
+
for (const layer of this.layers.getAll()) {
|
|
1511
|
+
if (layer.meta.maskPreset) this.refresh(layer.id);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
buildClip(id, object) {
|
|
1515
|
+
const width = Math.max(1, object.width ?? 1);
|
|
1516
|
+
const height = Math.max(1, object.height ?? 1);
|
|
1517
|
+
const shared = {
|
|
1518
|
+
originX: "center",
|
|
1519
|
+
originY: "center",
|
|
1520
|
+
left: 0,
|
|
1521
|
+
top: 0,
|
|
1522
|
+
objectCaching: false
|
|
1523
|
+
};
|
|
1524
|
+
if (isShapeMaskId(id)) {
|
|
1525
|
+
return new Path2(shapeMaskPathData(id), {
|
|
1526
|
+
...shared,
|
|
1527
|
+
scaleX: width / SHAPE_MASK_BOX,
|
|
1528
|
+
scaleY: height / SHAPE_MASK_BOX
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
return new FabricImage2(renderTextureMask(id), {
|
|
1532
|
+
...shared,
|
|
1533
|
+
scaleX: width / TEXTURE_MASK_SIZE,
|
|
1534
|
+
scaleY: height / TEXTURE_MASK_SIZE
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
// src/shadow.ts
|
|
1540
|
+
import { Shadow } from "fabric";
|
|
1541
|
+
|
|
1542
|
+
// src/utils/color.ts
|
|
1543
|
+
var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
|
|
1544
|
+
function isCssColor(value, allowEmpty = false) {
|
|
1545
|
+
if (typeof value !== "string") return false;
|
|
1546
|
+
const trimmed = value.trim();
|
|
1547
|
+
if (!trimmed) return allowEmpty;
|
|
1548
|
+
return CSS_COLOR.test(trimmed);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/shadow.ts
|
|
1552
|
+
var DEFAULT_LAYER_SHADOW = {
|
|
1553
|
+
enabled: false,
|
|
1554
|
+
color: "#000000",
|
|
1555
|
+
blur: 12,
|
|
1556
|
+
offsetX: 6,
|
|
1557
|
+
offsetY: 6
|
|
1558
|
+
};
|
|
1559
|
+
function readLayerShadow(object) {
|
|
1560
|
+
const shadow = object.shadow;
|
|
1561
|
+
if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
|
|
1562
|
+
return {
|
|
1563
|
+
enabled: true,
|
|
1564
|
+
color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
|
|
1565
|
+
blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
|
|
1566
|
+
offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
|
|
1567
|
+
offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
function applyLayerShadow(object, config) {
|
|
1571
|
+
const next = { ...readLayerShadow(object), ...config };
|
|
1572
|
+
if (!next.enabled) {
|
|
1573
|
+
object.set({ shadow: null });
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
|
|
1577
|
+
object.set({
|
|
1578
|
+
shadow: new Shadow({
|
|
1579
|
+
color,
|
|
1580
|
+
blur: Math.max(0, next.blur),
|
|
1581
|
+
offsetX: next.offsetX,
|
|
1582
|
+
offsetY: next.offsetY
|
|
1583
|
+
})
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// src/transform.ts
|
|
1588
|
+
var SIDE_CONTROLS = ["ml", "mr", "mt", "mb"];
|
|
1589
|
+
function applyAspectLock(object, locked) {
|
|
1590
|
+
for (const control of SIDE_CONTROLS) {
|
|
1591
|
+
object.setControlVisible(control, !locked);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
function resetTransform(object) {
|
|
1595
|
+
object.set({
|
|
1596
|
+
scaleX: 1,
|
|
1597
|
+
scaleY: 1,
|
|
1598
|
+
angle: 0,
|
|
1599
|
+
skewX: 0,
|
|
1600
|
+
skewY: 0,
|
|
1601
|
+
flipX: false,
|
|
1602
|
+
flipY: false
|
|
1603
|
+
});
|
|
1604
|
+
object.setCoords();
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1004
1607
|
// src/utils/units.ts
|
|
1005
1608
|
var MM_PER_INCH = 25.4;
|
|
1006
1609
|
var UnitConverter = class {
|
|
@@ -1050,17 +1653,6 @@ var UnitConverter = class {
|
|
|
1050
1653
|
|
|
1051
1654
|
// src/serialization.ts
|
|
1052
1655
|
import { util as util2 } from "fabric";
|
|
1053
|
-
|
|
1054
|
-
// src/utils/color.ts
|
|
1055
|
-
var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
|
|
1056
|
-
function isCssColor(value, allowEmpty = false) {
|
|
1057
|
-
if (typeof value !== "string") return false;
|
|
1058
|
-
const trimmed = value.trim();
|
|
1059
|
-
if (!trimmed) return allowEmpty;
|
|
1060
|
-
return CSS_COLOR.test(trimmed);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// src/serialization.ts
|
|
1064
1656
|
var VERSION = "2.0.0";
|
|
1065
1657
|
function serializeEditor(editor) {
|
|
1066
1658
|
return {
|
|
@@ -1076,6 +1668,7 @@ function serializeEditor(editor) {
|
|
|
1076
1668
|
// forced transparent while a mockup preview is active).
|
|
1077
1669
|
background: editor.getDesignBackground(),
|
|
1078
1670
|
backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
|
|
1671
|
+
backgroundImageOptions: editor.getBackgroundImageOptions(),
|
|
1079
1672
|
mockup: editor.getMockup()
|
|
1080
1673
|
};
|
|
1081
1674
|
}
|
|
@@ -1094,13 +1687,28 @@ async function deserializeEditor(editor, state) {
|
|
|
1094
1687
|
throw new Error("Invalid editor background color");
|
|
1095
1688
|
}
|
|
1096
1689
|
const staged = await Promise.all(
|
|
1097
|
-
state.layers.map(async (serialized) =>
|
|
1098
|
-
serialized
|
|
1099
|
-
|
|
1100
|
-
|
|
1690
|
+
state.layers.map(async (serialized) => {
|
|
1691
|
+
const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
|
|
1692
|
+
if (!fabricObject) {
|
|
1693
|
+
const source = serialized.fabricObject.src;
|
|
1694
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1695
|
+
throw new Error(`Failed to restore expired object URL: ${source}`);
|
|
1696
|
+
}
|
|
1697
|
+
throw new Error(`Failed to restore layer: ${serialized.id}`);
|
|
1698
|
+
}
|
|
1699
|
+
return { serialized, fabricObject };
|
|
1700
|
+
})
|
|
1101
1701
|
);
|
|
1102
1702
|
const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
|
|
1703
|
+
if (state.backgroundImage && !stagedBackground) {
|
|
1704
|
+
const source = state.backgroundImage.src;
|
|
1705
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1706
|
+
throw new Error(`Failed to restore expired background object URL: ${source}`);
|
|
1707
|
+
}
|
|
1708
|
+
throw new Error("Failed to restore background image");
|
|
1709
|
+
}
|
|
1103
1710
|
editor.crop.cancel();
|
|
1711
|
+
editor.masks.detach();
|
|
1104
1712
|
editor.layers.clear();
|
|
1105
1713
|
if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
|
|
1106
1714
|
if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
|
|
@@ -1108,7 +1716,11 @@ async function deserializeEditor(editor, state) {
|
|
|
1108
1716
|
if (state.background !== void 0) {
|
|
1109
1717
|
editor.setBackground(state.background);
|
|
1110
1718
|
}
|
|
1111
|
-
editor.setBackgroundImageObject(
|
|
1719
|
+
editor.setBackgroundImageObject(
|
|
1720
|
+
stagedBackground ?? null,
|
|
1721
|
+
false,
|
|
1722
|
+
state.backgroundImageOptions ?? null
|
|
1723
|
+
);
|
|
1112
1724
|
editor.setMockup(state.mockup ?? null);
|
|
1113
1725
|
for (const item of staged) {
|
|
1114
1726
|
restoreLayer(editor, item.serialized, item.fabricObject);
|
|
@@ -1119,6 +1731,7 @@ function restoreLayer(editor, serialized, fabricObject) {
|
|
|
1119
1731
|
const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
|
|
1120
1732
|
if (serialized.meta) {
|
|
1121
1733
|
layer.meta = serialized.meta;
|
|
1734
|
+
if (layer.meta.lockAspect) applyAspectLock(fabricObject, true);
|
|
1122
1735
|
}
|
|
1123
1736
|
if (!serialized.visible) {
|
|
1124
1737
|
editor.layers.setVisibility(layer.id, false);
|
|
@@ -1445,9 +2058,270 @@ var ProjectManager = class {
|
|
|
1445
2058
|
}
|
|
1446
2059
|
};
|
|
1447
2060
|
|
|
2061
|
+
// src/mask.ts
|
|
2062
|
+
import { FabricImage as FabricImage3 } from "fabric";
|
|
2063
|
+
var MaskRefinementError = class extends Error {
|
|
2064
|
+
constructor(code, message, cause) {
|
|
2065
|
+
super(message);
|
|
2066
|
+
this.code = code;
|
|
2067
|
+
this.cause = cause;
|
|
2068
|
+
this.name = "MaskRefinementError";
|
|
2069
|
+
}
|
|
2070
|
+
code;
|
|
2071
|
+
cause;
|
|
2072
|
+
};
|
|
2073
|
+
var MaskController = class {
|
|
2074
|
+
constructor(editor) {
|
|
2075
|
+
this.editor = editor;
|
|
2076
|
+
}
|
|
2077
|
+
editor;
|
|
2078
|
+
backing = null;
|
|
2079
|
+
context = null;
|
|
2080
|
+
layerId = null;
|
|
2081
|
+
brush = null;
|
|
2082
|
+
previousPoint = null;
|
|
2083
|
+
strokeBackup = null;
|
|
2084
|
+
strokeStartedAt = null;
|
|
2085
|
+
lastInteractionLatencyMs = 0;
|
|
2086
|
+
refinement = null;
|
|
2087
|
+
disposed = false;
|
|
2088
|
+
async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
|
|
2089
|
+
this.assertActive();
|
|
2090
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
2091
|
+
throw new Error("Mask dimensions must be positive integers");
|
|
2092
|
+
}
|
|
2093
|
+
const backing = this.makeCanvas(width, height);
|
|
2094
|
+
const image = new FabricImage3(backing, {
|
|
2095
|
+
left: 0,
|
|
2096
|
+
top: 0,
|
|
2097
|
+
originX: "left",
|
|
2098
|
+
originY: "top",
|
|
2099
|
+
selectable: false
|
|
2100
|
+
});
|
|
2101
|
+
const layer = this.editor.layers.add("mask", image, "Mask");
|
|
2102
|
+
layer.meta.mask = { width, height, revision: 0 };
|
|
2103
|
+
this.editor.history.saveImmediate();
|
|
2104
|
+
this.attachBacking(layer.id, backing);
|
|
2105
|
+
return layer;
|
|
2106
|
+
}
|
|
2107
|
+
attach(layerId) {
|
|
2108
|
+
this.assertActive();
|
|
2109
|
+
this.cancelStroke();
|
|
2110
|
+
const layer = this.requireMask(layerId);
|
|
2111
|
+
const image = layer.fabricObject;
|
|
2112
|
+
const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
|
|
2113
|
+
const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
|
|
2114
|
+
const backing = this.makeCanvas(width, height);
|
|
2115
|
+
const context = backing.getContext("2d");
|
|
2116
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
2117
|
+
const element = image.getElement();
|
|
2118
|
+
if (element) context.drawImage(element, 0, 0, width, height);
|
|
2119
|
+
this.attachBacking(layerId, backing);
|
|
2120
|
+
}
|
|
2121
|
+
beginStroke(options) {
|
|
2122
|
+
this.assertActive();
|
|
2123
|
+
if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
|
|
2124
|
+
if (this.brush) throw new Error("A mask stroke is already active");
|
|
2125
|
+
if (!Number.isFinite(options.size) || options.size <= 0) {
|
|
2126
|
+
throw new Error("Mask brush size must be positive");
|
|
2127
|
+
}
|
|
2128
|
+
this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
|
|
2129
|
+
this.previousPoint = null;
|
|
2130
|
+
this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
|
|
2131
|
+
this.strokeStartedAt = performance.now();
|
|
2132
|
+
}
|
|
2133
|
+
addPoint(point) {
|
|
2134
|
+
if (!this.brush || !this.context || !this.backing || !this.layerId) {
|
|
2135
|
+
throw new Error("No active mask stroke");
|
|
2136
|
+
}
|
|
2137
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
2138
|
+
const previous = this.previousPoint ?? point;
|
|
2139
|
+
const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
2140
|
+
const step = Math.max(1, this.brush.size / 4);
|
|
2141
|
+
const samples = Math.max(1, Math.ceil(distance / step));
|
|
2142
|
+
for (let index = 0; index <= samples; index += 1) {
|
|
2143
|
+
const ratio = index / samples;
|
|
2144
|
+
this.drawDot({
|
|
2145
|
+
x: previous.x + (point.x - previous.x) * ratio,
|
|
2146
|
+
y: previous.y + (point.y - previous.y) * ratio
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
this.previousPoint = point;
|
|
2150
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
2151
|
+
this.editor.canvas.requestRenderAll();
|
|
2152
|
+
}
|
|
2153
|
+
async endStroke() {
|
|
2154
|
+
if (!this.brush || !this.backing || !this.layerId) return;
|
|
2155
|
+
const layerId = this.layerId;
|
|
2156
|
+
this.brush = null;
|
|
2157
|
+
this.previousPoint = null;
|
|
2158
|
+
this.strokeBackup = null;
|
|
2159
|
+
const dataUrl = this.backing.toDataURL("image/png");
|
|
2160
|
+
await this.editor.history.transaction(async () => {
|
|
2161
|
+
await this.editor.replaceImageSource(layerId, dataUrl);
|
|
2162
|
+
const layer = this.requireMask(layerId);
|
|
2163
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
2164
|
+
});
|
|
2165
|
+
this.attach(layerId);
|
|
2166
|
+
if (this.strokeStartedAt !== null) {
|
|
2167
|
+
this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
|
|
2168
|
+
this.strokeStartedAt = null;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
cancelStroke() {
|
|
2172
|
+
if (this.strokeBackup && this.context && this.backing && this.layerId) {
|
|
2173
|
+
this.context.putImageData(this.strokeBackup, 0, 0);
|
|
2174
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
2175
|
+
this.editor.canvas.requestRenderAll();
|
|
2176
|
+
}
|
|
2177
|
+
this.brush = null;
|
|
2178
|
+
this.previousPoint = null;
|
|
2179
|
+
this.strokeBackup = null;
|
|
2180
|
+
this.strokeStartedAt = null;
|
|
2181
|
+
}
|
|
2182
|
+
isStrokeActive() {
|
|
2183
|
+
return this.brush !== null;
|
|
2184
|
+
}
|
|
2185
|
+
activeLayerId() {
|
|
2186
|
+
return this.layerId;
|
|
2187
|
+
}
|
|
2188
|
+
detach(layerId) {
|
|
2189
|
+
if (layerId && this.layerId !== layerId) return;
|
|
2190
|
+
this.cancelStroke();
|
|
2191
|
+
this.cancelRefinement();
|
|
2192
|
+
this.backing = null;
|
|
2193
|
+
this.context = null;
|
|
2194
|
+
this.layerId = null;
|
|
2195
|
+
}
|
|
2196
|
+
async refine(layerId, provider, prompts, options = {}) {
|
|
2197
|
+
this.assertActive();
|
|
2198
|
+
const layer = this.editor.layers.get(layerId);
|
|
2199
|
+
if (!layer || layer.type !== "mask") {
|
|
2200
|
+
throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
|
|
2201
|
+
}
|
|
2202
|
+
this.cancelRefinement();
|
|
2203
|
+
const controller = new AbortController();
|
|
2204
|
+
this.refinement = controller;
|
|
2205
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
2206
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
2207
|
+
if (options.signal?.aborted) abort();
|
|
2208
|
+
try {
|
|
2209
|
+
const image = layer.fabricObject;
|
|
2210
|
+
const result = await provider.refine(
|
|
2211
|
+
{
|
|
2212
|
+
mask: image.getSrc(),
|
|
2213
|
+
width: layer.meta.mask?.width ?? image.width ?? 1,
|
|
2214
|
+
height: layer.meta.mask?.height ?? image.height ?? 1,
|
|
2215
|
+
prompts: structuredClone(prompts)
|
|
2216
|
+
},
|
|
2217
|
+
{ signal: controller.signal, onProgress: options.onProgress }
|
|
2218
|
+
);
|
|
2219
|
+
if (controller.signal.aborted)
|
|
2220
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
|
|
2221
|
+
if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
|
|
2222
|
+
throw new MaskRefinementError(
|
|
2223
|
+
"invalid-result",
|
|
2224
|
+
"Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
await this.editor.history.transaction(async () => {
|
|
2228
|
+
await this.editor.replaceImageSource(layerId, result.dataUrl);
|
|
2229
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
2230
|
+
});
|
|
2231
|
+
this.attach(layerId);
|
|
2232
|
+
return result;
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
if (error instanceof MaskRefinementError) throw error;
|
|
2235
|
+
if (controller.signal.aborted) {
|
|
2236
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
|
|
2237
|
+
}
|
|
2238
|
+
throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
|
|
2239
|
+
} finally {
|
|
2240
|
+
options.signal?.removeEventListener("abort", abort);
|
|
2241
|
+
if (this.refinement === controller) this.refinement = null;
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
cancelRefinement() {
|
|
2245
|
+
this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
|
|
2246
|
+
this.refinement = null;
|
|
2247
|
+
}
|
|
2248
|
+
measure() {
|
|
2249
|
+
if (!this.backing) return null;
|
|
2250
|
+
const started = performance.now();
|
|
2251
|
+
this.context?.getImageData(0, 0, 1, 1);
|
|
2252
|
+
const backingBytes = this.backing.width * this.backing.height * 4;
|
|
2253
|
+
const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
|
|
2254
|
+
const memory = performance;
|
|
2255
|
+
return {
|
|
2256
|
+
width: this.backing.width,
|
|
2257
|
+
height: this.backing.height,
|
|
2258
|
+
backingBytes,
|
|
2259
|
+
strokeBackupBytes,
|
|
2260
|
+
// The backing store and rollback ImageData dominate interactive mask
|
|
2261
|
+
// memory. Encoded historical rasters are reported separately below.
|
|
2262
|
+
estimatedPeakBytes: backingBytes + strokeBackupBytes,
|
|
2263
|
+
historyBytes: this.editor.history.getSnapshotBytes(),
|
|
2264
|
+
interactionLatencyMs: this.lastInteractionLatencyMs,
|
|
2265
|
+
...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
|
|
2266
|
+
elapsedMs: performance.now() - started
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
dispose() {
|
|
2270
|
+
this.detach();
|
|
2271
|
+
this.disposed = true;
|
|
2272
|
+
}
|
|
2273
|
+
drawDot(point) {
|
|
2274
|
+
const context = this.context;
|
|
2275
|
+
const brush = this.brush;
|
|
2276
|
+
const radius = brush.size / 2;
|
|
2277
|
+
context.save();
|
|
2278
|
+
context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
|
|
2279
|
+
const gradient = context.createRadialGradient(
|
|
2280
|
+
point.x,
|
|
2281
|
+
point.y,
|
|
2282
|
+
radius * brush.hardness,
|
|
2283
|
+
point.x,
|
|
2284
|
+
point.y,
|
|
2285
|
+
radius
|
|
2286
|
+
);
|
|
2287
|
+
const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
|
|
2288
|
+
gradient.addColorStop(0, color);
|
|
2289
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
2290
|
+
context.fillStyle = gradient;
|
|
2291
|
+
context.beginPath();
|
|
2292
|
+
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
|
2293
|
+
context.fill();
|
|
2294
|
+
context.restore();
|
|
2295
|
+
}
|
|
2296
|
+
makeCanvas(width, height) {
|
|
2297
|
+
const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
2298
|
+
canvas.width = width;
|
|
2299
|
+
canvas.height = height;
|
|
2300
|
+
return canvas;
|
|
2301
|
+
}
|
|
2302
|
+
attachBacking(layerId, backing) {
|
|
2303
|
+
const context = backing.getContext("2d");
|
|
2304
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
2305
|
+
this.layerId = layerId;
|
|
2306
|
+
this.backing = backing;
|
|
2307
|
+
this.context = context;
|
|
2308
|
+
}
|
|
2309
|
+
requireMask(layerId) {
|
|
2310
|
+
const layer = this.editor.layers.get(layerId);
|
|
2311
|
+
if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
|
|
2312
|
+
return layer;
|
|
2313
|
+
}
|
|
2314
|
+
assertActive() {
|
|
2315
|
+
if (this.disposed) throw new Error("Mask controller has been disposed");
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
|
|
1448
2319
|
// src/editor.ts
|
|
1449
2320
|
var MIN_ZOOM = 0.1;
|
|
1450
2321
|
var MAX_ZOOM = 8;
|
|
2322
|
+
function isTaintedCanvasError(error) {
|
|
2323
|
+
return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
|
|
2324
|
+
}
|
|
1451
2325
|
var CanvasEditor = class {
|
|
1452
2326
|
canvas;
|
|
1453
2327
|
layers;
|
|
@@ -1457,9 +2331,12 @@ var CanvasEditor = class {
|
|
|
1457
2331
|
snapping;
|
|
1458
2332
|
crop;
|
|
1459
2333
|
patterns;
|
|
2334
|
+
curves;
|
|
2335
|
+
maskPresets;
|
|
1460
2336
|
fonts;
|
|
1461
2337
|
licensing;
|
|
1462
2338
|
pages;
|
|
2339
|
+
masks;
|
|
1463
2340
|
fileAdapter;
|
|
1464
2341
|
imageProvider;
|
|
1465
2342
|
zoomLevel = 1;
|
|
@@ -1469,6 +2346,7 @@ var CanvasEditor = class {
|
|
|
1469
2346
|
// for serialization and export — not the (possibly transient) canvas value.
|
|
1470
2347
|
designBackground;
|
|
1471
2348
|
designBackgroundImage = null;
|
|
2349
|
+
backgroundImageOptions = null;
|
|
1472
2350
|
constructor(canvasElement, config) {
|
|
1473
2351
|
this.events = new EventEmitter();
|
|
1474
2352
|
this.fonts = new FontRegistry();
|
|
@@ -1506,14 +2384,17 @@ var CanvasEditor = class {
|
|
|
1506
2384
|
this.events,
|
|
1507
2385
|
config.patternSourceResolver
|
|
1508
2386
|
);
|
|
2387
|
+
this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
|
|
2388
|
+
this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
|
|
1509
2389
|
this.setupCanvasEvents();
|
|
1510
2390
|
this.history.saveImmediate();
|
|
1511
2391
|
this.pages = new ProjectManager(this);
|
|
2392
|
+
this.masks = new MaskController(this);
|
|
1512
2393
|
}
|
|
1513
2394
|
// ─── Layer Operations ────────────────────────────────
|
|
1514
2395
|
async addImage(url, options) {
|
|
1515
2396
|
try {
|
|
1516
|
-
const img = await
|
|
2397
|
+
const img = await FabricImage4.fromURL(
|
|
1517
2398
|
url,
|
|
1518
2399
|
{},
|
|
1519
2400
|
{ originX: "left", originY: "top", ...options }
|
|
@@ -1529,14 +2410,16 @@ var CanvasEditor = class {
|
|
|
1529
2410
|
/** Replace an image source without changing its layer identity or visual transform. */
|
|
1530
2411
|
async replaceImageSource(layerId, url) {
|
|
1531
2412
|
const layer = this.layers.get(layerId);
|
|
1532
|
-
if (!layer || layer.type !== "image"
|
|
2413
|
+
if (!layer || layer.type !== "image" && layer.type !== "mask") {
|
|
2414
|
+
throw new Error(`Image or mask layer not found: ${layerId}`);
|
|
2415
|
+
}
|
|
1533
2416
|
if (layer.meta.pattern) {
|
|
1534
2417
|
throw new Error("Clear the pattern before replacing the image source");
|
|
1535
2418
|
}
|
|
1536
2419
|
if (this.crop.activeLayerId() === layerId) this.crop.cancel();
|
|
1537
2420
|
const previous = layer.fabricObject;
|
|
1538
2421
|
try {
|
|
1539
|
-
const replacement = await
|
|
2422
|
+
const replacement = await FabricImage4.fromURL(url, {}, { originX: "left", originY: "top" });
|
|
1540
2423
|
replacement.set({
|
|
1541
2424
|
left: previous.left,
|
|
1542
2425
|
top: previous.top,
|
|
@@ -1620,6 +2503,9 @@ var CanvasEditor = class {
|
|
|
1620
2503
|
}
|
|
1621
2504
|
removeLayer(id) {
|
|
1622
2505
|
if (this.crop.activeLayerId() === id) this.crop.cancel();
|
|
2506
|
+
if (this.masks.activeLayerId() === id) {
|
|
2507
|
+
this.masks.detach(id);
|
|
2508
|
+
}
|
|
1623
2509
|
if (this.layers.remove(id)) this.history.save();
|
|
1624
2510
|
}
|
|
1625
2511
|
selectLayer(id) {
|
|
@@ -1767,6 +2653,50 @@ var CanvasEditor = class {
|
|
|
1767
2653
|
async toWebP(options) {
|
|
1768
2654
|
return this.toRaster("webp", options);
|
|
1769
2655
|
}
|
|
2656
|
+
/** Export one layer in document coordinates or at its native image resolution. */
|
|
2657
|
+
async exportLayer(id, options = {}) {
|
|
2658
|
+
const layer = this.layers.get(id);
|
|
2659
|
+
if (!layer) throw new Error(`Layer not found: ${id}`);
|
|
2660
|
+
try {
|
|
2661
|
+
if (options.resolution === "source" && layer.fabricObject instanceof FabricImage4) {
|
|
2662
|
+
const image = await layer.fabricObject.clone();
|
|
2663
|
+
image.set({
|
|
2664
|
+
left: 0,
|
|
2665
|
+
top: 0,
|
|
2666
|
+
originX: "left",
|
|
2667
|
+
originY: "top",
|
|
2668
|
+
scaleX: 1,
|
|
2669
|
+
scaleY: 1,
|
|
2670
|
+
angle: 0,
|
|
2671
|
+
flipX: false,
|
|
2672
|
+
flipY: false
|
|
2673
|
+
});
|
|
2674
|
+
return await exportIsolatedPNG(this.canvas, [image], {
|
|
2675
|
+
...options,
|
|
2676
|
+
width: image.width || 1,
|
|
2677
|
+
height: image.height || 1,
|
|
2678
|
+
cloneObjects: false
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
|
|
2684
|
+
throw error;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
/** Export only the configured document background, excluding design layers. */
|
|
2688
|
+
async exportBackground(options = {}) {
|
|
2689
|
+
try {
|
|
2690
|
+
return await exportIsolatedPNG(this.canvas, [], {
|
|
2691
|
+
...options,
|
|
2692
|
+
backgroundColor: this.designBackground,
|
|
2693
|
+
backgroundImage: this.designBackgroundImage
|
|
2694
|
+
});
|
|
2695
|
+
} catch (error) {
|
|
2696
|
+
this.events.emit("error", { message: "Failed to export background", error });
|
|
2697
|
+
throw error;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
1770
2700
|
async toRaster(format, options) {
|
|
1771
2701
|
this.events.emit("export:start", { format });
|
|
1772
2702
|
try {
|
|
@@ -1778,7 +2708,10 @@ var CanvasEditor = class {
|
|
|
1778
2708
|
this.licensing.track(`export:${format}`);
|
|
1779
2709
|
return blob;
|
|
1780
2710
|
} catch (error) {
|
|
1781
|
-
this.events.emit("error", {
|
|
2711
|
+
this.events.emit("error", {
|
|
2712
|
+
message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
|
|
2713
|
+
error
|
|
2714
|
+
});
|
|
1782
2715
|
throw error;
|
|
1783
2716
|
}
|
|
1784
2717
|
}
|
|
@@ -1809,6 +2742,23 @@ var CanvasEditor = class {
|
|
|
1809
2742
|
toDataURL(format = "png", multiplier = 1) {
|
|
1810
2743
|
return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
|
|
1811
2744
|
}
|
|
2745
|
+
/**
|
|
2746
|
+
* Export the print file: the design cropped to the mockup's print area, on
|
|
2747
|
+
* transparency. Without a print area this is the whole canvas, still
|
|
2748
|
+
* transparent — a print file never carries the design background.
|
|
2749
|
+
*/
|
|
2750
|
+
async toPrintFile(options = {}) {
|
|
2751
|
+
await this.fonts.ready();
|
|
2752
|
+
try {
|
|
2753
|
+
const area = this.mockup?.printArea;
|
|
2754
|
+
const blob = area ? await exportPrintArea(this.canvas, area, options) : await exportIsolatedPNG(this.canvas, this.canvas.getObjects(), options);
|
|
2755
|
+
this.licensing.track(`export:print:${options.format ?? "png"}`);
|
|
2756
|
+
return blob;
|
|
2757
|
+
} catch (error) {
|
|
2758
|
+
this.events.emit("error", { message: "Failed to export print file", error });
|
|
2759
|
+
throw error;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
1812
2762
|
/** Export the current product-preview composite. Advanced warping is host-defined. */
|
|
1813
2763
|
async toMockupImage(options = {}) {
|
|
1814
2764
|
if (!this.mockup) throw new Error("No mockup is configured");
|
|
@@ -1971,13 +2921,20 @@ var CanvasEditor = class {
|
|
|
1971
2921
|
getDesignBackgroundImage() {
|
|
1972
2922
|
return this.designBackgroundImage;
|
|
1973
2923
|
}
|
|
2924
|
+
getBackgroundImageOptions() {
|
|
2925
|
+
return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
|
|
2926
|
+
}
|
|
1974
2927
|
async setBackgroundImage(url, options = {}) {
|
|
1975
2928
|
if (url === null) {
|
|
1976
2929
|
this.setBackgroundImageObject(null);
|
|
1977
2930
|
return;
|
|
1978
2931
|
}
|
|
1979
2932
|
try {
|
|
1980
|
-
const image = await
|
|
2933
|
+
const image = await FabricImage4.fromURL(
|
|
2934
|
+
url,
|
|
2935
|
+
{ crossOrigin: options.crossOrigin ?? null, signal: options.signal },
|
|
2936
|
+
{ originX: "left", originY: "top" }
|
|
2937
|
+
);
|
|
1981
2938
|
const width = image.width || 1;
|
|
1982
2939
|
const height = image.height || 1;
|
|
1983
2940
|
const canvasWidth = this.canvas.getWidth();
|
|
@@ -1996,15 +2953,18 @@ var CanvasEditor = class {
|
|
|
1996
2953
|
selectable: false,
|
|
1997
2954
|
evented: false
|
|
1998
2955
|
});
|
|
1999
|
-
|
|
2956
|
+
const serializableOptions = { ...options };
|
|
2957
|
+
delete serializableOptions.signal;
|
|
2958
|
+
this.setBackgroundImageObject(image, true, serializableOptions);
|
|
2000
2959
|
} catch (error) {
|
|
2001
2960
|
this.events.emit("error", { message: "Failed to set background image", error });
|
|
2002
2961
|
throw error;
|
|
2003
2962
|
}
|
|
2004
2963
|
}
|
|
2005
2964
|
/** Used by state restoration and advanced integrations with an existing Fabric object. */
|
|
2006
|
-
setBackgroundImageObject(image, save = true) {
|
|
2965
|
+
setBackgroundImageObject(image, save = true, options = null) {
|
|
2007
2966
|
this.designBackgroundImage = image;
|
|
2967
|
+
this.backgroundImageOptions = image ? options : null;
|
|
2008
2968
|
this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
|
|
2009
2969
|
this.canvas.requestRenderAll();
|
|
2010
2970
|
if (save) this.history.save();
|
|
@@ -2145,14 +3105,89 @@ var CanvasEditor = class {
|
|
|
2145
3105
|
clearPattern(layerId) {
|
|
2146
3106
|
return this.patterns.disable(layerId);
|
|
2147
3107
|
}
|
|
3108
|
+
// ─── Text curve ─────────────────────────────────────
|
|
3109
|
+
applyTextCurve(layerId, config) {
|
|
3110
|
+
return this.curves.apply(layerId, config);
|
|
3111
|
+
}
|
|
3112
|
+
clearTextCurve(layerId) {
|
|
3113
|
+
return this.curves.clear(layerId);
|
|
3114
|
+
}
|
|
3115
|
+
getTextCurve(layerId) {
|
|
3116
|
+
return this.curves.get(layerId);
|
|
3117
|
+
}
|
|
3118
|
+
// ─── Mask presets ───────────────────────────────────
|
|
3119
|
+
applyMaskPreset(layerId, id) {
|
|
3120
|
+
return this.maskPresets.apply(layerId, id);
|
|
3121
|
+
}
|
|
3122
|
+
clearMaskPreset(layerId) {
|
|
3123
|
+
return this.maskPresets.clear(layerId);
|
|
3124
|
+
}
|
|
3125
|
+
getMaskPreset(layerId) {
|
|
3126
|
+
return this.maskPresets.get(layerId);
|
|
3127
|
+
}
|
|
3128
|
+
// ─── Layer transform ────────────────────────────────
|
|
3129
|
+
/**
|
|
3130
|
+
* Constrain a layer to its current proportions. Persisted on the layer so a
|
|
3131
|
+
* reopened design still resizes the way it was set up to.
|
|
3132
|
+
*/
|
|
3133
|
+
setLayerAspectLock(layerId, locked) {
|
|
3134
|
+
const layer = this.layers.get(layerId);
|
|
3135
|
+
if (!layer) return false;
|
|
3136
|
+
applyAspectLock(layer.fabricObject, locked);
|
|
3137
|
+
if (locked) layer.meta.lockAspect = true;
|
|
3138
|
+
else delete layer.meta.lockAspect;
|
|
3139
|
+
this.canvas.requestRenderAll();
|
|
3140
|
+
this.events.emit("layer:modified", { layerId });
|
|
3141
|
+
this.history.save();
|
|
3142
|
+
return true;
|
|
3143
|
+
}
|
|
3144
|
+
getLayerAspectLock(layerId) {
|
|
3145
|
+
return this.layers.get(layerId)?.meta.lockAspect === true;
|
|
3146
|
+
}
|
|
3147
|
+
/** Re-apply every stored aspect lock — control visibility is not serialized. */
|
|
3148
|
+
restoreAspectLocks() {
|
|
3149
|
+
for (const layer of this.layers.getAll()) {
|
|
3150
|
+
if (layer.meta.lockAspect) applyAspectLock(layer.fabricObject, true);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
/** Drop scale, rotation, skew and flips; the layer stays where it is. */
|
|
3154
|
+
resetLayerTransform(layerId) {
|
|
3155
|
+
const layer = this.layers.get(layerId);
|
|
3156
|
+
if (!layer) return false;
|
|
3157
|
+
resetTransform(layer.fabricObject);
|
|
3158
|
+
this.canvas.requestRenderAll();
|
|
3159
|
+
this.events.emit("layer:modified", { layerId });
|
|
3160
|
+
this.history.save();
|
|
3161
|
+
return true;
|
|
3162
|
+
}
|
|
3163
|
+
// ─── Shadow ─────────────────────────────────────────
|
|
3164
|
+
setLayerShadow(layerId, config) {
|
|
3165
|
+
const layer = this.layers.get(layerId);
|
|
3166
|
+
if (!layer) return false;
|
|
3167
|
+
applyLayerShadow(layer.fabricObject, config);
|
|
3168
|
+
layer.fabricObject.dirty = true;
|
|
3169
|
+
this.canvas.requestRenderAll();
|
|
3170
|
+
this.events.emit("layer:modified", { layerId });
|
|
3171
|
+
this.history.save();
|
|
3172
|
+
return true;
|
|
3173
|
+
}
|
|
3174
|
+
getLayerShadow(layerId) {
|
|
3175
|
+
const layer = this.layers.get(layerId);
|
|
3176
|
+
return layer ? readLayerShadow(layer.fabricObject) : null;
|
|
3177
|
+
}
|
|
2148
3178
|
// ─── Mockup (preview-only) ──────────────────────────
|
|
2149
|
-
|
|
3179
|
+
/**
|
|
3180
|
+
* Show (or clear) the product preview. Pass `history: false` for preview-only
|
|
3181
|
+
* changes such as swapping a colourway — those are not design edits and
|
|
3182
|
+
* should not fill the undo stack.
|
|
3183
|
+
*/
|
|
3184
|
+
setMockup(mockup, options = {}) {
|
|
2150
3185
|
this.mockup = mockup;
|
|
2151
3186
|
this.canvas.backgroundColor = mockup ? "" : this.designBackground;
|
|
2152
3187
|
this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
|
|
2153
3188
|
this.canvas.requestRenderAll();
|
|
2154
3189
|
this.events.emit("mockup:changed", { mockup });
|
|
2155
|
-
this.history.save();
|
|
3190
|
+
if (options.history !== false) this.history.save();
|
|
2156
3191
|
}
|
|
2157
3192
|
clearMockup() {
|
|
2158
3193
|
this.setMockup(null);
|
|
@@ -2162,6 +3197,7 @@ var CanvasEditor = class {
|
|
|
2162
3197
|
}
|
|
2163
3198
|
// ─── Cleanup ────────────────────────────────────────
|
|
2164
3199
|
dispose() {
|
|
3200
|
+
this.masks.dispose();
|
|
2165
3201
|
this.snapping.dispose();
|
|
2166
3202
|
this.crop.dispose();
|
|
2167
3203
|
this.history.dispose();
|
|
@@ -2216,42 +3252,107 @@ var CANVAS_SIZE_PRESETS = [
|
|
|
2216
3252
|
{ id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
|
|
2217
3253
|
{ id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
|
|
2218
3254
|
];
|
|
3255
|
+
|
|
3256
|
+
// src/annotations.ts
|
|
3257
|
+
var AnnotationOverlay = class {
|
|
3258
|
+
items = /* @__PURE__ */ new Map();
|
|
3259
|
+
transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
|
|
3260
|
+
set(annotation) {
|
|
3261
|
+
this.items.set(annotation.id, structuredClone(annotation));
|
|
3262
|
+
}
|
|
3263
|
+
remove(id) {
|
|
3264
|
+
return this.items.delete(id);
|
|
3265
|
+
}
|
|
3266
|
+
clear() {
|
|
3267
|
+
this.items.clear();
|
|
3268
|
+
}
|
|
3269
|
+
getAll() {
|
|
3270
|
+
return [...this.items.values()].map((item) => structuredClone(item));
|
|
3271
|
+
}
|
|
3272
|
+
setTransform(transform) {
|
|
3273
|
+
if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
|
|
3274
|
+
throw new Error("Annotation zoom must be positive");
|
|
3275
|
+
}
|
|
3276
|
+
this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
|
|
3277
|
+
}
|
|
3278
|
+
documentToViewport(point) {
|
|
3279
|
+
return {
|
|
3280
|
+
x: point.x * this.transform.zoom + this.transform.panX,
|
|
3281
|
+
y: point.y * this.transform.zoom + this.transform.panY
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
3284
|
+
viewportToDocument(point) {
|
|
3285
|
+
return {
|
|
3286
|
+
x: (point.x - this.transform.panX) / this.transform.zoom,
|
|
3287
|
+
y: (point.y - this.transform.panY) / this.transform.zoom
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
documentToDevice(point) {
|
|
3291
|
+
const viewport = this.documentToViewport(point);
|
|
3292
|
+
const ratio = this.transform.devicePixelRatio ?? 1;
|
|
3293
|
+
return { x: viewport.x * ratio, y: viewport.y * ratio };
|
|
3294
|
+
}
|
|
3295
|
+
};
|
|
2219
3296
|
export {
|
|
3297
|
+
AnnotationOverlay,
|
|
2220
3298
|
CANVAS_SIZE_PRESETS,
|
|
2221
3299
|
CanvasEditor,
|
|
2222
3300
|
CropController,
|
|
3301
|
+
DEFAULT_LAYER_SHADOW,
|
|
2223
3302
|
DEFAULT_PATTERN_CONFIG,
|
|
3303
|
+
DEFAULT_TEXT_CURVE,
|
|
2224
3304
|
EventEmitter,
|
|
2225
3305
|
FontRegistry,
|
|
2226
3306
|
HistoryManager,
|
|
2227
3307
|
Layer,
|
|
2228
3308
|
LayerManager,
|
|
2229
3309
|
LicenseManager,
|
|
3310
|
+
MaskController,
|
|
3311
|
+
MaskPresetManager,
|
|
3312
|
+
MaskRefinementError,
|
|
2230
3313
|
PatternManager,
|
|
2231
3314
|
ProjectManager,
|
|
3315
|
+
SHAPE_MASK_BOX,
|
|
3316
|
+
SHAPE_MASK_IDS,
|
|
2232
3317
|
SnapManager,
|
|
3318
|
+
TEXTURE_MASK_IDS,
|
|
3319
|
+
TEXTURE_MASK_SIZE,
|
|
3320
|
+
TextCurveManager,
|
|
2233
3321
|
UnitConverter,
|
|
3322
|
+
applyAspectLock,
|
|
3323
|
+
applyLayerShadow,
|
|
2234
3324
|
applyPatternLocks,
|
|
3325
|
+
buildCurvePathData,
|
|
2235
3326
|
buildPatternDataURL,
|
|
2236
3327
|
captureLocks,
|
|
2237
3328
|
clamp,
|
|
2238
3329
|
clearPatternImageCache,
|
|
3330
|
+
clearTextureMaskCache,
|
|
2239
3331
|
computeCoverPlacement,
|
|
2240
3332
|
computePrintAreaClip,
|
|
2241
3333
|
computeTilePositions,
|
|
2242
3334
|
deserializeEditor,
|
|
3335
|
+
displaceRgba,
|
|
2243
3336
|
drawTiles,
|
|
2244
3337
|
escapeXml,
|
|
2245
3338
|
exportDataURL,
|
|
2246
3339
|
exportMockup,
|
|
2247
3340
|
exportPNG,
|
|
3341
|
+
exportPrintArea,
|
|
2248
3342
|
exportSVG,
|
|
2249
3343
|
generateId,
|
|
2250
3344
|
isCssColor,
|
|
3345
|
+
isMaskPresetId,
|
|
3346
|
+
isShapeMaskId,
|
|
3347
|
+
isTextureMaskId,
|
|
2251
3348
|
loadPatternImage,
|
|
3349
|
+
readLayerShadow,
|
|
3350
|
+
renderTextureMask,
|
|
3351
|
+
resetTransform,
|
|
2252
3352
|
restoreLocks,
|
|
2253
3353
|
round2,
|
|
2254
3354
|
sanitizeSvg,
|
|
2255
|
-
serializeEditor
|
|
3355
|
+
serializeEditor,
|
|
3356
|
+
shapeMaskPathData
|
|
2256
3357
|
};
|
|
2257
3358
|
//# sourceMappingURL=index.mjs.map
|