@overtone-art/canvas-editor-core 0.2.6 → 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/LICENSE +21 -0
- package/dist/chunk-ORZZ6MGQ.mjs +228 -0
- package/dist/chunk-ORZZ6MGQ.mjs.map +1 -0
- package/dist/index.d.mts +297 -225
- package/dist/index.d.ts +297 -225
- package/dist/index.global.js +506 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +1719 -99
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1501 -94
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +86 -0
- package/dist/node.d.ts +86 -0
- package/dist/node.js +814 -0
- package/dist/node.js.map +1 -0
- package/dist/node.mjs +675 -0
- package/dist/node.mjs.map +1 -0
- package/dist/types-D60CfxL9.d.mts +461 -0
- package/dist/types-D60CfxL9.d.ts +461 -0
- package/package.json +49 -2
package/dist/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
6
|
var __export = (target, all) => {
|
|
9
7
|
for (var name in all)
|
|
@@ -17,27 +15,26 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
17
15
|
}
|
|
18
16
|
return to;
|
|
19
17
|
};
|
|
20
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
-
mod
|
|
27
|
-
));
|
|
28
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
19
|
|
|
30
20
|
// src/index.ts
|
|
31
21
|
var index_exports = {};
|
|
32
22
|
__export(index_exports, {
|
|
23
|
+
AnnotationOverlay: () => AnnotationOverlay,
|
|
24
|
+
CANVAS_SIZE_PRESETS: () => CANVAS_SIZE_PRESETS,
|
|
33
25
|
CanvasEditor: () => CanvasEditor,
|
|
34
26
|
CropController: () => CropController,
|
|
35
27
|
DEFAULT_PATTERN_CONFIG: () => DEFAULT_PATTERN_CONFIG,
|
|
36
28
|
EventEmitter: () => EventEmitter,
|
|
29
|
+
FontRegistry: () => FontRegistry,
|
|
37
30
|
HistoryManager: () => HistoryManager,
|
|
38
31
|
Layer: () => Layer,
|
|
39
32
|
LayerManager: () => LayerManager,
|
|
33
|
+
LicenseManager: () => LicenseManager,
|
|
34
|
+
MaskController: () => MaskController,
|
|
35
|
+
MaskRefinementError: () => MaskRefinementError,
|
|
40
36
|
PatternManager: () => PatternManager,
|
|
37
|
+
ProjectManager: () => ProjectManager,
|
|
41
38
|
SnapManager: () => SnapManager,
|
|
42
39
|
UnitConverter: () => UnitConverter,
|
|
43
40
|
applyPatternLocks: () => applyPatternLocks,
|
|
@@ -45,21 +42,29 @@ __export(index_exports, {
|
|
|
45
42
|
captureLocks: () => captureLocks,
|
|
46
43
|
clamp: () => clamp,
|
|
47
44
|
clearPatternImageCache: () => clearPatternImageCache,
|
|
45
|
+
computeCoverPlacement: () => computeCoverPlacement,
|
|
46
|
+
computePrintAreaClip: () => computePrintAreaClip,
|
|
48
47
|
computeTilePositions: () => computeTilePositions,
|
|
49
48
|
deserializeEditor: () => deserializeEditor,
|
|
49
|
+
displaceRgba: () => displaceRgba,
|
|
50
50
|
drawTiles: () => drawTiles,
|
|
51
|
+
escapeXml: () => escapeXml,
|
|
51
52
|
exportDataURL: () => exportDataURL,
|
|
53
|
+
exportMockup: () => exportMockup,
|
|
52
54
|
exportPNG: () => exportPNG,
|
|
53
55
|
exportSVG: () => exportSVG,
|
|
54
56
|
generateId: () => generateId,
|
|
57
|
+
isCssColor: () => isCssColor,
|
|
58
|
+
loadPatternImage: () => loadPatternImage,
|
|
55
59
|
restoreLocks: () => restoreLocks,
|
|
56
60
|
round2: () => round2,
|
|
61
|
+
sanitizeSvg: () => sanitizeSvg,
|
|
57
62
|
serializeEditor: () => serializeEditor
|
|
58
63
|
});
|
|
59
64
|
module.exports = __toCommonJS(index_exports);
|
|
60
65
|
|
|
61
66
|
// src/editor.ts
|
|
62
|
-
var
|
|
67
|
+
var import_fabric6 = require("fabric");
|
|
63
68
|
|
|
64
69
|
// src/events.ts
|
|
65
70
|
var EventEmitter = class {
|
|
@@ -151,6 +156,10 @@ var LayerManager = class {
|
|
|
151
156
|
canvas;
|
|
152
157
|
events;
|
|
153
158
|
layers = [];
|
|
159
|
+
onPropertyChanged;
|
|
160
|
+
setHistoryCallback(callback) {
|
|
161
|
+
this.onPropertyChanged = callback;
|
|
162
|
+
}
|
|
154
163
|
add(type, fabricObject, name, id) {
|
|
155
164
|
const layer = new Layer(type, fabricObject, name, id);
|
|
156
165
|
this.layers.push(layer);
|
|
@@ -167,12 +176,39 @@ var LayerManager = class {
|
|
|
167
176
|
this.layers.splice(index, 1);
|
|
168
177
|
this.events.emit("layer:removed", { layerId: id });
|
|
169
178
|
this.emitChanged();
|
|
179
|
+
this.onPropertyChanged?.();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/** Replace a layer's render object while preserving its immutable ID and panel state. */
|
|
183
|
+
replaceObject(id, fabricObject) {
|
|
184
|
+
const layer = this.get(id);
|
|
185
|
+
if (!layer || layer.fabricObject === fabricObject) return false;
|
|
186
|
+
const previous = layer.fabricObject;
|
|
187
|
+
const stackIndex = this.canvas.getObjects().indexOf(previous);
|
|
188
|
+
const wasActive = this.canvas.getActiveObject() === previous;
|
|
189
|
+
this.canvas.remove(previous);
|
|
190
|
+
layer.fabricObject = fabricObject;
|
|
191
|
+
fabricObject._layerId = id;
|
|
192
|
+
fabricObject.set({
|
|
193
|
+
visible: layer.visible,
|
|
194
|
+
opacity: layer.opacity,
|
|
195
|
+
selectable: !layer.locked,
|
|
196
|
+
evented: !layer.locked
|
|
197
|
+
});
|
|
198
|
+
this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
|
|
199
|
+
if (wasActive) this.canvas.setActiveObject(fabricObject);
|
|
200
|
+
this.canvas.requestRenderAll();
|
|
201
|
+
this.events.emit("layer:modified", { layerId: id });
|
|
202
|
+
this.emitChanged();
|
|
203
|
+
this.onPropertyChanged?.();
|
|
170
204
|
return true;
|
|
171
205
|
}
|
|
172
206
|
reorder(id, newIndex) {
|
|
173
207
|
const oldIndex = this.layers.findIndex((l) => l.id === id);
|
|
174
208
|
if (oldIndex === -1) return false;
|
|
175
|
-
|
|
209
|
+
if (!Number.isFinite(newIndex)) return false;
|
|
210
|
+
const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
|
|
211
|
+
if (oldIndex === clamped) return false;
|
|
176
212
|
const [layer] = this.layers.splice(oldIndex, 1);
|
|
177
213
|
this.layers.splice(clamped, 0, layer);
|
|
178
214
|
this.layers.forEach((l, i) => {
|
|
@@ -180,6 +216,7 @@ var LayerManager = class {
|
|
|
180
216
|
});
|
|
181
217
|
this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
|
|
182
218
|
this.emitChanged();
|
|
219
|
+
this.onPropertyChanged?.();
|
|
183
220
|
return true;
|
|
184
221
|
}
|
|
185
222
|
select(id) {
|
|
@@ -210,33 +247,42 @@ var LayerManager = class {
|
|
|
210
247
|
setVisibility(id, visible) {
|
|
211
248
|
const layer = this.get(id);
|
|
212
249
|
if (!layer) return;
|
|
250
|
+
if (layer.visible === visible) return;
|
|
213
251
|
layer.visible = visible;
|
|
214
252
|
layer.fabricObject.visible = visible;
|
|
215
253
|
this.canvas.requestRenderAll();
|
|
216
254
|
this.emitChanged();
|
|
255
|
+
this.onPropertyChanged?.();
|
|
217
256
|
}
|
|
218
257
|
setLocked(id, locked) {
|
|
219
258
|
const layer = this.get(id);
|
|
220
259
|
if (!layer) return;
|
|
260
|
+
if (layer.locked === locked) return;
|
|
221
261
|
layer.locked = locked;
|
|
222
262
|
layer.fabricObject.selectable = !locked;
|
|
223
263
|
layer.fabricObject.evented = !locked;
|
|
224
264
|
this.canvas.requestRenderAll();
|
|
225
265
|
this.emitChanged();
|
|
266
|
+
this.onPropertyChanged?.();
|
|
226
267
|
}
|
|
227
268
|
setOpacity(id, opacity) {
|
|
228
269
|
const layer = this.get(id);
|
|
229
|
-
if (!layer) return;
|
|
230
|
-
|
|
231
|
-
layer.
|
|
270
|
+
if (!layer || !Number.isFinite(opacity)) return;
|
|
271
|
+
const next = Math.max(0, Math.min(1, opacity));
|
|
272
|
+
if (layer.opacity === next) return;
|
|
273
|
+
layer.opacity = next;
|
|
274
|
+
layer.fabricObject.opacity = next;
|
|
232
275
|
this.canvas.requestRenderAll();
|
|
233
276
|
this.emitChanged();
|
|
277
|
+
this.onPropertyChanged?.();
|
|
234
278
|
}
|
|
235
279
|
setName(id, name) {
|
|
236
280
|
const layer = this.get(id);
|
|
237
281
|
if (!layer) return;
|
|
282
|
+
if (layer.name === name) return;
|
|
238
283
|
layer.name = name;
|
|
239
284
|
this.emitChanged();
|
|
285
|
+
this.onPropertyChanged?.();
|
|
240
286
|
}
|
|
241
287
|
clear() {
|
|
242
288
|
for (const layer of this.layers) {
|
|
@@ -256,61 +302,133 @@ var LayerManager = class {
|
|
|
256
302
|
};
|
|
257
303
|
|
|
258
304
|
// src/history.ts
|
|
259
|
-
var HistoryManager = class {
|
|
305
|
+
var HistoryManager = class _HistoryManager {
|
|
306
|
+
static ASSET_KEY = "__canvasEditorHistoryAsset";
|
|
260
307
|
undoStack = [];
|
|
261
308
|
redoStack = [];
|
|
309
|
+
assets = /* @__PURE__ */ new Map();
|
|
310
|
+
assetIds = /* @__PURE__ */ new Map();
|
|
311
|
+
nextAssetId = 1;
|
|
262
312
|
maxSize;
|
|
313
|
+
maxBytes;
|
|
263
314
|
paused = false;
|
|
264
315
|
debounceTimer = null;
|
|
265
316
|
debounceMs;
|
|
266
317
|
getState;
|
|
267
318
|
restoreState;
|
|
268
319
|
events;
|
|
320
|
+
transactionDepth = 0;
|
|
321
|
+
transactionDirty = false;
|
|
269
322
|
constructor(opts) {
|
|
270
323
|
this.getState = opts.getState;
|
|
271
324
|
this.restoreState = opts.restoreState;
|
|
272
325
|
this.events = opts.events;
|
|
273
326
|
this.maxSize = opts.maxSize ?? 50;
|
|
327
|
+
this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
|
|
274
328
|
this.debounceMs = opts.debounceMs ?? 300;
|
|
275
329
|
}
|
|
276
330
|
save() {
|
|
277
331
|
if (this.paused) return;
|
|
332
|
+
if (this.transactionDepth > 0) {
|
|
333
|
+
this.transactionDirty = true;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
278
336
|
if (this.debounceTimer) {
|
|
279
337
|
clearTimeout(this.debounceTimer);
|
|
280
338
|
}
|
|
281
339
|
this.debounceTimer = setTimeout(() => {
|
|
282
340
|
this.saveImmediate();
|
|
283
341
|
}, this.debounceMs);
|
|
342
|
+
this.emitChanged();
|
|
284
343
|
}
|
|
285
344
|
saveImmediate() {
|
|
286
|
-
if (this.paused)
|
|
287
|
-
|
|
345
|
+
if (this.paused) {
|
|
346
|
+
this.cancelPending();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (this.transactionDepth > 0) {
|
|
350
|
+
this.transactionDirty = true;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.cancelPending();
|
|
354
|
+
const rawState = this.getState();
|
|
355
|
+
const state = this.compactState(rawState);
|
|
356
|
+
if (this.undoStack.at(-1) === state) {
|
|
357
|
+
this.emitChanged();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
288
360
|
this.undoStack.push(state);
|
|
289
|
-
|
|
361
|
+
while (this.undoStack.length > this.maxSize) {
|
|
290
362
|
this.undoStack.shift();
|
|
291
363
|
}
|
|
292
364
|
this.redoStack = [];
|
|
365
|
+
this.trimToBudget();
|
|
366
|
+
this.events.emit("history:snapshot", {
|
|
367
|
+
bytes: rawState.length * 2,
|
|
368
|
+
totalBytes: this.snapshotBytes(),
|
|
369
|
+
entries: this.undoStack.length
|
|
370
|
+
});
|
|
293
371
|
this.emitChanged();
|
|
294
372
|
}
|
|
373
|
+
beginTransaction() {
|
|
374
|
+
if (this.transactionDepth === 0 && this.debounceTimer) this.saveImmediate();
|
|
375
|
+
this.transactionDepth += 1;
|
|
376
|
+
}
|
|
377
|
+
endTransaction() {
|
|
378
|
+
if (this.transactionDepth === 0) return;
|
|
379
|
+
this.transactionDepth -= 1;
|
|
380
|
+
if (this.transactionDepth === 0 && this.transactionDirty) {
|
|
381
|
+
this.transactionDirty = false;
|
|
382
|
+
this.saveImmediate();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async transaction(operation) {
|
|
386
|
+
this.beginTransaction();
|
|
387
|
+
try {
|
|
388
|
+
return await operation();
|
|
389
|
+
} finally {
|
|
390
|
+
this.endTransaction();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
295
393
|
async undo() {
|
|
296
394
|
this.cancelPending();
|
|
297
|
-
const
|
|
298
|
-
if (!
|
|
299
|
-
this.
|
|
395
|
+
const committed = this.undoStack.at(-1);
|
|
396
|
+
if (!committed) return;
|
|
397
|
+
const current = this.compactState(this.getState());
|
|
398
|
+
const currentIsCommitted = current === committed;
|
|
399
|
+
if (currentIsCommitted && this.undoStack.length < 2) return;
|
|
400
|
+
const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
|
|
300
401
|
this.paused = true;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
402
|
+
try {
|
|
403
|
+
await this.restoreState(this.expandState(target));
|
|
404
|
+
if (currentIsCommitted) this.undoStack.pop();
|
|
405
|
+
this.redoStack.push(current);
|
|
406
|
+
this.trimToBudget();
|
|
407
|
+
} catch (error) {
|
|
408
|
+
this.events.emit("error", { message: "Failed to undo the last change", error });
|
|
409
|
+
throw error;
|
|
410
|
+
} finally {
|
|
411
|
+
this.paused = false;
|
|
412
|
+
this.emitChanged();
|
|
413
|
+
}
|
|
304
414
|
}
|
|
305
415
|
async redo() {
|
|
306
416
|
this.cancelPending();
|
|
307
|
-
const state = this.redoStack.
|
|
417
|
+
const state = this.redoStack.at(-1);
|
|
308
418
|
if (!state) return;
|
|
309
|
-
this.undoStack.push(this.getState());
|
|
310
419
|
this.paused = true;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
420
|
+
try {
|
|
421
|
+
await this.restoreState(this.expandState(state));
|
|
422
|
+
this.redoStack.pop();
|
|
423
|
+
if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
|
|
424
|
+
this.trimToBudget();
|
|
425
|
+
} catch (error) {
|
|
426
|
+
this.events.emit("error", { message: "Failed to redo the last change", error });
|
|
427
|
+
throw error;
|
|
428
|
+
} finally {
|
|
429
|
+
this.paused = false;
|
|
430
|
+
this.emitChanged();
|
|
431
|
+
}
|
|
314
432
|
}
|
|
315
433
|
/** True while a restore (undo/redo/deserialize) is in flight. Managers that
|
|
316
434
|
* react to canvas events should skip mutating history during this window. */
|
|
@@ -324,16 +442,22 @@ var HistoryManager = class {
|
|
|
324
442
|
this.paused = false;
|
|
325
443
|
}
|
|
326
444
|
canUndo() {
|
|
327
|
-
return this.undoStack.length >
|
|
445
|
+
return this.undoStack.length > 1 || this.debounceTimer !== null;
|
|
328
446
|
}
|
|
329
447
|
canRedo() {
|
|
330
448
|
return this.redoStack.length > 0;
|
|
331
449
|
}
|
|
332
450
|
clear() {
|
|
451
|
+
this.cancelPending();
|
|
333
452
|
this.undoStack = [];
|
|
334
453
|
this.redoStack = [];
|
|
454
|
+
this.assets.clear();
|
|
455
|
+
this.assetIds.clear();
|
|
335
456
|
this.emitChanged();
|
|
336
457
|
}
|
|
458
|
+
getSnapshotBytes() {
|
|
459
|
+
return this.snapshotBytes();
|
|
460
|
+
}
|
|
337
461
|
cancelPending() {
|
|
338
462
|
if (this.debounceTimer) {
|
|
339
463
|
clearTimeout(this.debounceTimer);
|
|
@@ -341,9 +465,13 @@ var HistoryManager = class {
|
|
|
341
465
|
}
|
|
342
466
|
}
|
|
343
467
|
dispose() {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
468
|
+
this.cancelPending();
|
|
469
|
+
this.transactionDepth = 0;
|
|
470
|
+
this.transactionDirty = false;
|
|
471
|
+
this.undoStack = [];
|
|
472
|
+
this.redoStack = [];
|
|
473
|
+
this.assets.clear();
|
|
474
|
+
this.assetIds.clear();
|
|
347
475
|
}
|
|
348
476
|
emitChanged() {
|
|
349
477
|
this.events.emit("history:changed", {
|
|
@@ -351,6 +479,94 @@ var HistoryManager = class {
|
|
|
351
479
|
canRedo: this.canRedo()
|
|
352
480
|
});
|
|
353
481
|
}
|
|
482
|
+
snapshotBytes() {
|
|
483
|
+
const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
|
|
484
|
+
(total, state) => total + state.length * 2,
|
|
485
|
+
0
|
|
486
|
+
);
|
|
487
|
+
const assetBytes = [...this.assets.values()].reduce(
|
|
488
|
+
(total, asset) => total + asset.length * 2,
|
|
489
|
+
0
|
|
490
|
+
);
|
|
491
|
+
return stackBytes + assetBytes;
|
|
492
|
+
}
|
|
493
|
+
trimToBudget() {
|
|
494
|
+
this.pruneAssets();
|
|
495
|
+
while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
496
|
+
this.undoStack.shift();
|
|
497
|
+
this.pruneAssets();
|
|
498
|
+
}
|
|
499
|
+
while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
500
|
+
this.redoStack.shift();
|
|
501
|
+
this.pruneAssets();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* History is internal and can content-address large raster strings without
|
|
506
|
+
* changing the public EditorState wire format. Unchanged images are retained
|
|
507
|
+
* once even when dozens of snapshots reference them.
|
|
508
|
+
*/
|
|
509
|
+
compactState(state) {
|
|
510
|
+
let parsed;
|
|
511
|
+
try {
|
|
512
|
+
parsed = JSON.parse(state);
|
|
513
|
+
} catch {
|
|
514
|
+
return state;
|
|
515
|
+
}
|
|
516
|
+
const visit = (value) => {
|
|
517
|
+
if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
|
|
518
|
+
let id = this.assetIds.get(value);
|
|
519
|
+
if (!id) {
|
|
520
|
+
id = `a${this.nextAssetId++}`;
|
|
521
|
+
this.assetIds.set(value, id);
|
|
522
|
+
this.assets.set(id, value);
|
|
523
|
+
}
|
|
524
|
+
return { [_HistoryManager.ASSET_KEY]: id };
|
|
525
|
+
}
|
|
526
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
527
|
+
if (!value || typeof value !== "object") return value;
|
|
528
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
|
|
529
|
+
};
|
|
530
|
+
return JSON.stringify(visit(parsed));
|
|
531
|
+
}
|
|
532
|
+
expandState(state) {
|
|
533
|
+
let parsed;
|
|
534
|
+
try {
|
|
535
|
+
parsed = JSON.parse(state);
|
|
536
|
+
} catch {
|
|
537
|
+
return state;
|
|
538
|
+
}
|
|
539
|
+
const visit = (value) => {
|
|
540
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
541
|
+
if (!value || typeof value !== "object") return value;
|
|
542
|
+
const record = value;
|
|
543
|
+
const id = record[_HistoryManager.ASSET_KEY];
|
|
544
|
+
if (typeof id === "string" && Object.keys(record).length === 1) {
|
|
545
|
+
const asset = this.assets.get(id);
|
|
546
|
+
if (!asset) throw new Error(`Missing history raster asset: ${id}`);
|
|
547
|
+
return asset;
|
|
548
|
+
}
|
|
549
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
|
|
550
|
+
};
|
|
551
|
+
return JSON.stringify(visit(parsed));
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Runs on every commit, so it scans for the serialized reference marker
|
|
555
|
+
* instead of re-parsing every snapshot — parsing multi-megabyte raster states
|
|
556
|
+
* per save would cost more than the retention it reclaims. `JSON.stringify`
|
|
557
|
+
* emits the marker verbatim; the same text inside user data is escaped and
|
|
558
|
+
* therefore cannot match.
|
|
559
|
+
*/
|
|
560
|
+
pruneAssets() {
|
|
561
|
+
if (this.assets.size === 0) return;
|
|
562
|
+
const states = [...this.undoStack, ...this.redoStack];
|
|
563
|
+
for (const [id, asset] of this.assets) {
|
|
564
|
+
const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
|
|
565
|
+
if (states.some((state) => state.includes(marker))) continue;
|
|
566
|
+
this.assets.delete(id);
|
|
567
|
+
this.assetIds.delete(asset);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
354
570
|
};
|
|
355
571
|
|
|
356
572
|
// src/snapping.ts
|
|
@@ -570,6 +786,10 @@ var CropController = class {
|
|
|
570
786
|
top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
|
|
571
787
|
});
|
|
572
788
|
image.setCoords();
|
|
789
|
+
if (s.prevAngle) {
|
|
790
|
+
image.rotate(s.prevAngle);
|
|
791
|
+
image.setCoords();
|
|
792
|
+
}
|
|
573
793
|
this.finish();
|
|
574
794
|
this.history.save();
|
|
575
795
|
}
|
|
@@ -604,14 +824,18 @@ var STROKE2 = "#22c55e";
|
|
|
604
824
|
var import_fabric2 = require("fabric");
|
|
605
825
|
var MAX_TILES_PER_AXIS = 200;
|
|
606
826
|
var PatternManager = class {
|
|
607
|
-
constructor(canvas, layers, history) {
|
|
827
|
+
constructor(canvas, layers, history, events, sourceResolver) {
|
|
608
828
|
this.canvas = canvas;
|
|
609
829
|
this.layers = layers;
|
|
610
830
|
this.history = history;
|
|
831
|
+
this.events = events;
|
|
832
|
+
this.sourceResolver = sourceResolver;
|
|
611
833
|
}
|
|
612
834
|
canvas;
|
|
613
835
|
layers;
|
|
614
836
|
history;
|
|
837
|
+
events;
|
|
838
|
+
sourceResolver;
|
|
615
839
|
// Per-layer task chain. apply()/disable() both await an async setSrc on the
|
|
616
840
|
// same fabric image; running two concurrently lets their setSrc resolutions
|
|
617
841
|
// interleave (wrong image installed, original lost). Serialising per layer
|
|
@@ -659,6 +883,9 @@ var PatternManager = class {
|
|
|
659
883
|
throw err;
|
|
660
884
|
}
|
|
661
885
|
this.history.save();
|
|
886
|
+
}).catch((error) => {
|
|
887
|
+
this.events.emit("error", { message: "Failed to apply image pattern", error });
|
|
888
|
+
throw error;
|
|
662
889
|
});
|
|
663
890
|
}
|
|
664
891
|
/**
|
|
@@ -714,6 +941,9 @@ var PatternManager = class {
|
|
|
714
941
|
delete layer.meta.pattern;
|
|
715
942
|
this.canvas.requestRenderAll();
|
|
716
943
|
this.history.save();
|
|
944
|
+
}).catch((error) => {
|
|
945
|
+
this.events.emit("error", { message: "Failed to clear image pattern", error });
|
|
946
|
+
throw error;
|
|
717
947
|
});
|
|
718
948
|
}
|
|
719
949
|
/** Run `task` after any in-flight work for this layer, regardless of outcome. */
|
|
@@ -743,7 +973,8 @@ var PatternManager = class {
|
|
|
743
973
|
cw,
|
|
744
974
|
ch,
|
|
745
975
|
tileW,
|
|
746
|
-
tileH
|
|
976
|
+
tileH,
|
|
977
|
+
this.sourceResolver
|
|
747
978
|
);
|
|
748
979
|
await image.setSrc(dataUrl);
|
|
749
980
|
image.set({
|
|
@@ -814,14 +1045,23 @@ function elementToDataURL(image) {
|
|
|
814
1045
|
}
|
|
815
1046
|
var IMAGE_CACHE_MAX = 16;
|
|
816
1047
|
var imageCache = /* @__PURE__ */ new Map();
|
|
817
|
-
function
|
|
1048
|
+
function loadPatternImage(src, resolver) {
|
|
818
1049
|
const cached = imageCache.get(src);
|
|
819
1050
|
if (cached) {
|
|
820
1051
|
imageCache.delete(src);
|
|
821
1052
|
imageCache.set(src, cached);
|
|
822
1053
|
return cached;
|
|
823
1054
|
}
|
|
824
|
-
const promise = decodeImage(src)
|
|
1055
|
+
const promise = decodeImage(src).catch(async (originalError) => {
|
|
1056
|
+
if (!resolver) throw originalError;
|
|
1057
|
+
const resolved = await resolver(src);
|
|
1058
|
+
if (!resolved || resolved === src) {
|
|
1059
|
+
throw new Error("Pattern source resolver did not return a usable alternate URL", {
|
|
1060
|
+
cause: originalError
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return decodeImage(resolved);
|
|
1064
|
+
});
|
|
825
1065
|
promise.catch(() => {
|
|
826
1066
|
if (imageCache.get(src) === promise) imageCache.delete(src);
|
|
827
1067
|
});
|
|
@@ -844,8 +1084,8 @@ function decodeImage(src) {
|
|
|
844
1084
|
function clearPatternImageCache() {
|
|
845
1085
|
imageCache.clear();
|
|
846
1086
|
}
|
|
847
|
-
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
|
|
848
|
-
const img = await
|
|
1087
|
+
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
|
|
1088
|
+
const img = await loadPatternImage(src, sourceResolver);
|
|
849
1089
|
const off = document.createElement("canvas");
|
|
850
1090
|
off.width = Math.max(1, Math.round(targetW));
|
|
851
1091
|
off.height = Math.max(1, Math.round(targetH));
|
|
@@ -905,16 +1145,18 @@ function mod2(n) {
|
|
|
905
1145
|
// src/utils/units.ts
|
|
906
1146
|
var MM_PER_INCH = 25.4;
|
|
907
1147
|
var UnitConverter = class {
|
|
1148
|
+
unit;
|
|
1149
|
+
dpi;
|
|
908
1150
|
constructor(unit = "px", dpi = 72) {
|
|
909
1151
|
this.unit = unit;
|
|
910
|
-
this.dpi =
|
|
1152
|
+
this.dpi = 72;
|
|
1153
|
+
this.setDpi(dpi);
|
|
911
1154
|
}
|
|
912
|
-
unit;
|
|
913
|
-
dpi;
|
|
914
1155
|
setUnit(unit) {
|
|
915
1156
|
this.unit = unit;
|
|
916
1157
|
}
|
|
917
1158
|
setDpi(dpi) {
|
|
1159
|
+
if (!Number.isFinite(dpi) || dpi <= 0) throw new Error("DPI must be a positive number");
|
|
918
1160
|
this.dpi = dpi;
|
|
919
1161
|
}
|
|
920
1162
|
getUnit() {
|
|
@@ -949,38 +1191,92 @@ var UnitConverter = class {
|
|
|
949
1191
|
|
|
950
1192
|
// src/serialization.ts
|
|
951
1193
|
var import_fabric3 = require("fabric");
|
|
952
|
-
|
|
1194
|
+
|
|
1195
|
+
// src/utils/color.ts
|
|
1196
|
+
var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
|
|
1197
|
+
function isCssColor(value, allowEmpty = false) {
|
|
1198
|
+
if (typeof value !== "string") return false;
|
|
1199
|
+
const trimmed = value.trim();
|
|
1200
|
+
if (!trimmed) return allowEmpty;
|
|
1201
|
+
return CSS_COLOR.test(trimmed);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/serialization.ts
|
|
1205
|
+
var VERSION = "2.0.0";
|
|
953
1206
|
function serializeEditor(editor) {
|
|
954
1207
|
return {
|
|
955
1208
|
version: VERSION,
|
|
956
1209
|
canvas: {
|
|
957
1210
|
width: editor.canvas.getWidth(),
|
|
958
|
-
height: editor.canvas.getHeight()
|
|
1211
|
+
height: editor.canvas.getHeight(),
|
|
1212
|
+
unit: editor.units.getUnit(),
|
|
1213
|
+
dpi: editor.units.getDpi()
|
|
959
1214
|
},
|
|
960
1215
|
layers: editor.layers.getAll().map((layer) => layer.serialize()),
|
|
961
1216
|
// The configured design background, not the live canvas value (which is
|
|
962
1217
|
// forced transparent while a mockup preview is active).
|
|
963
1218
|
background: editor.getDesignBackground(),
|
|
1219
|
+
backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
|
|
1220
|
+
backgroundImageOptions: editor.getBackgroundImageOptions(),
|
|
964
1221
|
mockup: editor.getMockup()
|
|
965
1222
|
};
|
|
966
1223
|
}
|
|
967
1224
|
async function deserializeEditor(editor, state) {
|
|
1225
|
+
if (!state || !state.canvas || !Array.isArray(state.layers)) {
|
|
1226
|
+
throw new Error("Invalid editor state");
|
|
1227
|
+
}
|
|
1228
|
+
if (!Number.isFinite(state.canvas.width) || !Number.isFinite(state.canvas.height) || state.canvas.width <= 0 || state.canvas.height <= 0 || state.canvas.unit !== void 0 && !["px", "mm", "in"].includes(state.canvas.unit) || state.canvas.dpi !== void 0 && (!Number.isFinite(state.canvas.dpi) || state.canvas.dpi <= 0)) {
|
|
1229
|
+
throw new Error("Invalid editor canvas settings");
|
|
1230
|
+
}
|
|
1231
|
+
const major = Number.parseInt(state.version?.split(".")[0] ?? "1", 10);
|
|
1232
|
+
if (!Number.isFinite(major) || major > 2) {
|
|
1233
|
+
throw new Error(`Unsupported editor state version: ${state.version}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (state.background !== void 0 && !isCssColor(state.background, true)) {
|
|
1236
|
+
throw new Error("Invalid editor background color");
|
|
1237
|
+
}
|
|
1238
|
+
const staged = await Promise.all(
|
|
1239
|
+
state.layers.map(async (serialized) => {
|
|
1240
|
+
const fabricObject = (await import_fabric3.util.enlivenObjects([serialized.fabricObject]))[0];
|
|
1241
|
+
if (!fabricObject) {
|
|
1242
|
+
const source = serialized.fabricObject.src;
|
|
1243
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1244
|
+
throw new Error(`Failed to restore expired object URL: ${source}`);
|
|
1245
|
+
}
|
|
1246
|
+
throw new Error(`Failed to restore layer: ${serialized.id}`);
|
|
1247
|
+
}
|
|
1248
|
+
return { serialized, fabricObject };
|
|
1249
|
+
})
|
|
1250
|
+
);
|
|
1251
|
+
const stagedBackground = state.backgroundImage ? (await import_fabric3.util.enlivenObjects([state.backgroundImage]))[0] : null;
|
|
1252
|
+
if (state.backgroundImage && !stagedBackground) {
|
|
1253
|
+
const source = state.backgroundImage.src;
|
|
1254
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1255
|
+
throw new Error(`Failed to restore expired background object URL: ${source}`);
|
|
1256
|
+
}
|
|
1257
|
+
throw new Error("Failed to restore background image");
|
|
1258
|
+
}
|
|
1259
|
+
editor.crop.cancel();
|
|
1260
|
+
editor.masks.detach();
|
|
968
1261
|
editor.layers.clear();
|
|
1262
|
+
if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
|
|
1263
|
+
if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
|
|
969
1264
|
editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
|
|
970
|
-
if (state.background) {
|
|
1265
|
+
if (state.background !== void 0) {
|
|
971
1266
|
editor.setBackground(state.background);
|
|
972
1267
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1268
|
+
editor.setBackgroundImageObject(
|
|
1269
|
+
stagedBackground ?? null,
|
|
1270
|
+
false,
|
|
1271
|
+
state.backgroundImageOptions ?? null
|
|
1272
|
+
);
|
|
1273
|
+
editor.setMockup(state.mockup ?? null);
|
|
1274
|
+
for (const item of staged) {
|
|
1275
|
+
restoreLayer(editor, item.serialized, item.fabricObject);
|
|
978
1276
|
}
|
|
979
1277
|
editor.canvas.requestRenderAll();
|
|
980
1278
|
}
|
|
981
|
-
|
|
982
|
-
const objects = await import_fabric3.util.enlivenObjects([serialized.fabricObject]);
|
|
983
|
-
const fabricObject = objects[0];
|
|
1279
|
+
function restoreLayer(editor, serialized, fabricObject) {
|
|
984
1280
|
const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
|
|
985
1281
|
if (serialized.meta) {
|
|
986
1282
|
layer.meta = serialized.meta;
|
|
@@ -998,15 +1294,216 @@ async function restoreLayer(editor, serialized) {
|
|
|
998
1294
|
}
|
|
999
1295
|
|
|
1000
1296
|
// src/export.ts
|
|
1297
|
+
var import_fabric4 = require("fabric");
|
|
1298
|
+
|
|
1299
|
+
// src/displacement.ts
|
|
1300
|
+
var CHANNEL_INDEX = {
|
|
1301
|
+
red: 0,
|
|
1302
|
+
green: 1,
|
|
1303
|
+
blue: 2,
|
|
1304
|
+
alpha: 3
|
|
1305
|
+
};
|
|
1306
|
+
function finiteScale(value, fallback, label) {
|
|
1307
|
+
const resolved = value ?? fallback;
|
|
1308
|
+
if (!Number.isFinite(resolved)) throw new Error(`${label} must be finite`);
|
|
1309
|
+
return resolved;
|
|
1310
|
+
}
|
|
1311
|
+
function sample(source, width, height, x, y, channel) {
|
|
1312
|
+
const clampedX = Math.max(0, Math.min(width - 1, x));
|
|
1313
|
+
const clampedY = Math.max(0, Math.min(height - 1, y));
|
|
1314
|
+
const x0 = Math.floor(clampedX);
|
|
1315
|
+
const y0 = Math.floor(clampedY);
|
|
1316
|
+
const x1 = Math.min(width - 1, x0 + 1);
|
|
1317
|
+
const y1 = Math.min(height - 1, y0 + 1);
|
|
1318
|
+
const tx = clampedX - x0;
|
|
1319
|
+
const ty = clampedY - y0;
|
|
1320
|
+
const top = source[(y0 * width + x0) * 4 + channel] * (1 - tx) + source[(y0 * width + x1) * 4 + channel] * tx;
|
|
1321
|
+
const bottom = source[(y1 * width + x0) * 4 + channel] * (1 - tx) + source[(y1 * width + x1) * 4 + channel] * tx;
|
|
1322
|
+
return top * (1 - ty) + bottom * ty;
|
|
1323
|
+
}
|
|
1324
|
+
function displaceRgba(source, map, width, height, options) {
|
|
1325
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
1326
|
+
throw new Error("Displacement dimensions must be positive integers");
|
|
1327
|
+
}
|
|
1328
|
+
const expectedLength = width * height * 4;
|
|
1329
|
+
if (source.length !== expectedLength || map.length !== expectedLength) {
|
|
1330
|
+
throw new Error("Displacement source and map must match the requested dimensions");
|
|
1331
|
+
}
|
|
1332
|
+
const scaleX = finiteScale(options.scaleX, 10, "Displacement scaleX");
|
|
1333
|
+
const scaleY = finiteScale(options.scaleY, 10, "Displacement scaleY");
|
|
1334
|
+
const channelX = CHANNEL_INDEX[options.channelX ?? "red"];
|
|
1335
|
+
const channelY = CHANNEL_INDEX[options.channelY ?? "green"];
|
|
1336
|
+
const output = new Uint8ClampedArray(expectedLength);
|
|
1337
|
+
for (let y = 0; y < height; y += 1) {
|
|
1338
|
+
for (let x = 0; x < width; x += 1) {
|
|
1339
|
+
const offset = (y * width + x) * 4;
|
|
1340
|
+
const sourceX = x + (map[offset + channelX] - 128) / 127 * scaleX;
|
|
1341
|
+
const sourceY = y + (map[offset + channelY] - 128) / 127 * scaleY;
|
|
1342
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
1343
|
+
output[offset + channel] = Math.round(
|
|
1344
|
+
sample(source, width, height, sourceX, sourceY, channel)
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return output;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/export.ts
|
|
1353
|
+
function computePrintAreaClip(area, scaleX, scaleY, targetWidth, targetHeight) {
|
|
1354
|
+
const left = Math.max(0, Math.min(targetWidth, area.left * scaleX));
|
|
1355
|
+
const top = Math.max(0, Math.min(targetHeight, area.top * scaleY));
|
|
1356
|
+
const right = Math.max(left, Math.min(targetWidth, (area.left + area.width) * scaleX));
|
|
1357
|
+
const bottom = Math.max(top, Math.min(targetHeight, (area.top + area.height) * scaleY));
|
|
1358
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
1359
|
+
}
|
|
1360
|
+
function computeCoverPlacement(sourceWidth, sourceHeight, targetWidth, targetHeight) {
|
|
1361
|
+
if (sourceWidth <= 0 || sourceHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
|
|
1362
|
+
throw new Error("Cover dimensions must be positive");
|
|
1363
|
+
}
|
|
1364
|
+
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
|
1365
|
+
const width = sourceWidth * scale;
|
|
1366
|
+
const height = sourceHeight * scale;
|
|
1367
|
+
return {
|
|
1368
|
+
left: (targetWidth - width) / 2,
|
|
1369
|
+
top: (targetHeight - height) / 2,
|
|
1370
|
+
width,
|
|
1371
|
+
height
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function canvasElementToBlob(output, format, quality) {
|
|
1375
|
+
const mime = format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
|
1376
|
+
return new Promise((resolve, reject) => {
|
|
1377
|
+
output.toBlob(
|
|
1378
|
+
(blob) => blob ? resolve(blob) : reject(new Error(`Failed to export ${format}`)),
|
|
1379
|
+
mime,
|
|
1380
|
+
quality
|
|
1381
|
+
);
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1001
1384
|
async function exportPNG(canvas, options = {}) {
|
|
1002
1385
|
const { multiplier = 1, format = "png", quality = 1 } = options;
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1386
|
+
const output = canvas.toCanvasElement(multiplier);
|
|
1387
|
+
return canvasElementToBlob(output, format, quality);
|
|
1388
|
+
}
|
|
1389
|
+
async function exportIsolatedPNG(source, objects, options = {}) {
|
|
1390
|
+
const element = source.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
1391
|
+
const canvas = new import_fabric4.StaticCanvas(element, {
|
|
1392
|
+
width: options.width ?? source.getWidth(),
|
|
1393
|
+
height: options.height ?? source.getHeight(),
|
|
1394
|
+
backgroundColor: options.backgroundColor || void 0
|
|
1007
1395
|
});
|
|
1008
|
-
|
|
1009
|
-
|
|
1396
|
+
try {
|
|
1397
|
+
const clones = options.cloneObjects === false ? objects : await Promise.all(objects.map((object) => object.clone()));
|
|
1398
|
+
if (clones.length) canvas.add(...clones);
|
|
1399
|
+
if (options.backgroundImage) canvas.backgroundImage = await options.backgroundImage.clone();
|
|
1400
|
+
canvas.requestRenderAll();
|
|
1401
|
+
return await exportPNG(canvas, options);
|
|
1402
|
+
} finally {
|
|
1403
|
+
canvas.dispose();
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
async function exportMockup(canvas, mockup, options = {}) {
|
|
1407
|
+
const { multiplier = 1, format = "png", quality = 1 } = options;
|
|
1408
|
+
const design = canvas.toCanvasElement(multiplier);
|
|
1409
|
+
const output = design.ownerDocument.createElement("canvas");
|
|
1410
|
+
output.width = design.width;
|
|
1411
|
+
output.height = design.height;
|
|
1412
|
+
const context = output.getContext("2d");
|
|
1413
|
+
if (!context) throw new Error("2D canvas context is unavailable");
|
|
1414
|
+
const loadImage = (url) => new Promise((resolve, reject) => {
|
|
1415
|
+
const element = new Image();
|
|
1416
|
+
element.crossOrigin = "anonymous";
|
|
1417
|
+
element.onload = () => resolve(element);
|
|
1418
|
+
element.onerror = () => reject(new Error(`Failed to load mockup image: ${url}`));
|
|
1419
|
+
element.src = url;
|
|
1420
|
+
});
|
|
1421
|
+
const drawCover = (image, targetContext = context) => {
|
|
1422
|
+
const placement = computeCoverPlacement(
|
|
1423
|
+
image.naturalWidth || image.width,
|
|
1424
|
+
image.naturalHeight || image.height,
|
|
1425
|
+
output.width,
|
|
1426
|
+
output.height
|
|
1427
|
+
);
|
|
1428
|
+
targetContext.drawImage(
|
|
1429
|
+
image,
|
|
1430
|
+
placement.left,
|
|
1431
|
+
placement.top,
|
|
1432
|
+
placement.width,
|
|
1433
|
+
placement.height
|
|
1434
|
+
);
|
|
1435
|
+
};
|
|
1436
|
+
const scratch = [design];
|
|
1437
|
+
try {
|
|
1438
|
+
drawCover(await loadImage(mockup.image));
|
|
1439
|
+
let compositedDesign = design;
|
|
1440
|
+
if (mockup.displacement) {
|
|
1441
|
+
const sourceContext = design.getContext("2d");
|
|
1442
|
+
if (!sourceContext) throw new Error("2D design context is unavailable");
|
|
1443
|
+
const mapCanvas = design.ownerDocument.createElement("canvas");
|
|
1444
|
+
scratch.push(mapCanvas);
|
|
1445
|
+
mapCanvas.width = design.width;
|
|
1446
|
+
mapCanvas.height = design.height;
|
|
1447
|
+
const mapContext = mapCanvas.getContext("2d");
|
|
1448
|
+
if (!mapContext) throw new Error("2D displacement-map context is unavailable");
|
|
1449
|
+
drawCover(await loadImage(mockup.displacement.image), mapContext);
|
|
1450
|
+
const warped = design.ownerDocument.createElement("canvas");
|
|
1451
|
+
scratch.push(warped);
|
|
1452
|
+
warped.width = design.width;
|
|
1453
|
+
warped.height = design.height;
|
|
1454
|
+
const warpedContext = warped.getContext("2d");
|
|
1455
|
+
if (!warpedContext) throw new Error("2D displaced-design context is unavailable");
|
|
1456
|
+
let sourcePixels;
|
|
1457
|
+
let mapPixels;
|
|
1458
|
+
try {
|
|
1459
|
+
sourcePixels = sourceContext.getImageData(0, 0, design.width, design.height).data;
|
|
1460
|
+
mapPixels = mapContext.getImageData(0, 0, design.width, design.height).data;
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
throw new Error("Failed to apply mockup displacement map; verify image CORS access", {
|
|
1463
|
+
cause: error
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
const pixels = displaceRgba(sourcePixels, mapPixels, design.width, design.height, {
|
|
1467
|
+
...mockup.displacement,
|
|
1468
|
+
scaleX: (mockup.displacement.scaleX ?? 10) * multiplier,
|
|
1469
|
+
scaleY: (mockup.displacement.scaleY ?? 10) * multiplier
|
|
1470
|
+
});
|
|
1471
|
+
const imageData = warpedContext.createImageData(design.width, design.height);
|
|
1472
|
+
imageData.data.set(pixels);
|
|
1473
|
+
warpedContext.putImageData(imageData, 0, 0);
|
|
1474
|
+
compositedDesign = warped;
|
|
1475
|
+
}
|
|
1476
|
+
context.save();
|
|
1477
|
+
if (mockup.printArea && mockup.clipToPrintArea !== false) {
|
|
1478
|
+
const clip = computePrintAreaClip(
|
|
1479
|
+
mockup.printArea,
|
|
1480
|
+
output.width / canvas.getWidth(),
|
|
1481
|
+
output.height / canvas.getHeight(),
|
|
1482
|
+
output.width,
|
|
1483
|
+
output.height
|
|
1484
|
+
);
|
|
1485
|
+
context.beginPath();
|
|
1486
|
+
context.rect(clip.left, clip.top, clip.width, clip.height);
|
|
1487
|
+
context.clip();
|
|
1488
|
+
}
|
|
1489
|
+
context.globalAlpha = Math.max(0, Math.min(1, mockup.designOpacity ?? 1));
|
|
1490
|
+
context.globalCompositeOperation = !mockup.designBlendMode || mockup.designBlendMode === "normal" ? "source-over" : mockup.designBlendMode;
|
|
1491
|
+
context.drawImage(compositedDesign, 0, 0);
|
|
1492
|
+
context.restore();
|
|
1493
|
+
if (mockup.overlay) {
|
|
1494
|
+
context.save();
|
|
1495
|
+
context.globalAlpha = Math.max(0, Math.min(1, mockup.overlay.opacity ?? 1));
|
|
1496
|
+
context.globalCompositeOperation = mockup.overlay.blendMode === "normal" ? "source-over" : mockup.overlay.blendMode ?? "multiply";
|
|
1497
|
+
drawCover(await loadImage(mockup.overlay.image));
|
|
1498
|
+
context.restore();
|
|
1499
|
+
}
|
|
1500
|
+
return await canvasElementToBlob(output, format, quality);
|
|
1501
|
+
} finally {
|
|
1502
|
+
for (const element of scratch) {
|
|
1503
|
+
element.width = 0;
|
|
1504
|
+
element.height = 0;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1010
1507
|
}
|
|
1011
1508
|
function exportSVG(canvas) {
|
|
1012
1509
|
return canvas.toSVG();
|
|
@@ -1015,9 +1512,583 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
|
|
|
1015
1512
|
return canvas.toDataURL({ format, multiplier });
|
|
1016
1513
|
}
|
|
1017
1514
|
|
|
1515
|
+
// src/utils/svg.ts
|
|
1516
|
+
function escapeXml(value) {
|
|
1517
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1518
|
+
}
|
|
1519
|
+
function sanitizeSvg(svg) {
|
|
1520
|
+
const document2 = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
1521
|
+
if (document2.querySelector("parsererror")) throw new Error("Invalid template SVG");
|
|
1522
|
+
document2.querySelectorAll("script, foreignObject, iframe, object, embed, link, style").forEach((node) => node.remove());
|
|
1523
|
+
document2.querySelectorAll("*").forEach((node) => {
|
|
1524
|
+
for (const attribute of [...node.attributes]) {
|
|
1525
|
+
const name = attribute.name.toLowerCase();
|
|
1526
|
+
const value = attribute.value.trim().toLowerCase();
|
|
1527
|
+
const isLink = name === "href" || name === "xlink:href" || name === "src";
|
|
1528
|
+
const safeLink = value.startsWith("#") || /^data:image\/(?:png|jpeg|webp|gif);base64,/.test(value);
|
|
1529
|
+
if (name.startsWith("on") || isLink && !safeLink || /url\s*\(/.test(value) && !/url\s*\(\s*['"]?#/.test(value) || /(?:javascript:|expression\s*\()/.test(value)) {
|
|
1530
|
+
node.removeAttribute(attribute.name);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
return new XMLSerializer().serializeToString(document2.documentElement);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/fonts.ts
|
|
1538
|
+
function fontSource(source) {
|
|
1539
|
+
return /^(?:url|local)\(/.test(source.trim()) ? source : `url(${JSON.stringify(source)})`;
|
|
1540
|
+
}
|
|
1541
|
+
function mimeForSource(source) {
|
|
1542
|
+
const path = source.split(/[?#]/)[0].toLowerCase();
|
|
1543
|
+
if (path.endsWith(".woff2")) return "font/woff2";
|
|
1544
|
+
if (path.endsWith(".woff")) return "font/woff";
|
|
1545
|
+
if (path.endsWith(".otf")) return "font/otf";
|
|
1546
|
+
return "font/ttf";
|
|
1547
|
+
}
|
|
1548
|
+
function arrayBufferToBase64(buffer) {
|
|
1549
|
+
const bytes = new Uint8Array(buffer);
|
|
1550
|
+
let binary = "";
|
|
1551
|
+
for (let index = 0; index < bytes.length; index += 32768) {
|
|
1552
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 32768));
|
|
1553
|
+
}
|
|
1554
|
+
return btoa(binary);
|
|
1555
|
+
}
|
|
1556
|
+
function cssString(value) {
|
|
1557
|
+
return JSON.stringify(value).replace(/[<>&]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
|
|
1558
|
+
}
|
|
1559
|
+
var SAFE_WEIGHT = /^(?:normal|bold|bolder|lighter|[1-9]\d{0,2}(?:\s+[1-9]\d{0,2})?)$/i;
|
|
1560
|
+
var SAFE_STYLE = /^(?:normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg)?)$/i;
|
|
1561
|
+
var SAFE_DISPLAY = /^(?:auto|block|swap|fallback|optional)$/i;
|
|
1562
|
+
function cssKeyword(value, pattern, fallback) {
|
|
1563
|
+
const trimmed = value?.trim();
|
|
1564
|
+
return trimmed && pattern.test(trimmed) ? trimmed : fallback;
|
|
1565
|
+
}
|
|
1566
|
+
function sourceUrl(source) {
|
|
1567
|
+
const trimmed = source.trim();
|
|
1568
|
+
if (trimmed.startsWith("data:")) return trimmed;
|
|
1569
|
+
const match = trimmed.match(/^url\(\s*(['"]?)(.*?)\1\s*\)/i);
|
|
1570
|
+
if (match) return match[2];
|
|
1571
|
+
if (/^local\(/i.test(trimmed)) return null;
|
|
1572
|
+
return trimmed;
|
|
1573
|
+
}
|
|
1574
|
+
function localName(source) {
|
|
1575
|
+
const match = source.trim().match(/^local\(\s*(['"]?)([^)"'{};]*)\1\s*\)$/i);
|
|
1576
|
+
return match ? match[2].trim() || null : null;
|
|
1577
|
+
}
|
|
1578
|
+
var FontRegistry = class {
|
|
1579
|
+
definitions = /* @__PURE__ */ new Map();
|
|
1580
|
+
loads = /* @__PURE__ */ new Map();
|
|
1581
|
+
register(definition) {
|
|
1582
|
+
if (!definition.family.trim() || !definition.source.trim()) {
|
|
1583
|
+
throw new Error("Font family and source are required");
|
|
1584
|
+
}
|
|
1585
|
+
this.definitions.set(definition.family, { ...definition });
|
|
1586
|
+
this.loads.delete(definition.family);
|
|
1587
|
+
}
|
|
1588
|
+
unregister(family) {
|
|
1589
|
+
this.loads.delete(family);
|
|
1590
|
+
return this.definitions.delete(family);
|
|
1591
|
+
}
|
|
1592
|
+
getAll() {
|
|
1593
|
+
return [...this.definitions.values()].map((definition) => ({ ...definition }));
|
|
1594
|
+
}
|
|
1595
|
+
load(family) {
|
|
1596
|
+
const cached = this.loads.get(family);
|
|
1597
|
+
if (cached) return cached;
|
|
1598
|
+
const definition = this.definitions.get(family);
|
|
1599
|
+
if (!definition) return Promise.reject(new Error(`Font is not registered: ${family}`));
|
|
1600
|
+
if (typeof FontFace === "undefined" || typeof document === "undefined") {
|
|
1601
|
+
return Promise.reject(new Error("Font loading requires a browser FontFace API"));
|
|
1602
|
+
}
|
|
1603
|
+
const promise = new FontFace(definition.family, fontSource(definition.source), {
|
|
1604
|
+
weight: definition.weight,
|
|
1605
|
+
style: definition.style,
|
|
1606
|
+
display: definition.display
|
|
1607
|
+
}).load().then((font) => {
|
|
1608
|
+
document.fonts.add(font);
|
|
1609
|
+
return font;
|
|
1610
|
+
});
|
|
1611
|
+
promise.catch(() => {
|
|
1612
|
+
if (this.loads.get(family) === promise) this.loads.delete(family);
|
|
1613
|
+
});
|
|
1614
|
+
this.loads.set(family, promise);
|
|
1615
|
+
return promise;
|
|
1616
|
+
}
|
|
1617
|
+
async ready() {
|
|
1618
|
+
if (this.definitions.size === 0) return;
|
|
1619
|
+
await Promise.all([...this.definitions.keys()].map((family) => this.load(family)));
|
|
1620
|
+
await document.fonts.ready;
|
|
1621
|
+
}
|
|
1622
|
+
async getEmbeddedCss() {
|
|
1623
|
+
const rules = await Promise.all(
|
|
1624
|
+
this.getAll().map(async (definition) => {
|
|
1625
|
+
const url = sourceUrl(definition.source);
|
|
1626
|
+
let cssSource;
|
|
1627
|
+
if (url && !url.startsWith("data:")) {
|
|
1628
|
+
const response = await fetch(url);
|
|
1629
|
+
if (!response.ok) throw new Error(`Failed to fetch font: ${url}`);
|
|
1630
|
+
const data = arrayBufferToBase64(await response.arrayBuffer());
|
|
1631
|
+
const mime = response.headers.get("content-type") || mimeForSource(url);
|
|
1632
|
+
cssSource = `url(${cssString(`data:${mime};base64,${data}`)})`;
|
|
1633
|
+
} else if (url) {
|
|
1634
|
+
cssSource = `url(${cssString(url)})`;
|
|
1635
|
+
} else {
|
|
1636
|
+
const name = localName(definition.source);
|
|
1637
|
+
if (!name) {
|
|
1638
|
+
throw new Error(`Unsupported font source for embedding: ${definition.family}`);
|
|
1639
|
+
}
|
|
1640
|
+
cssSource = `local(${cssString(name)})`;
|
|
1641
|
+
}
|
|
1642
|
+
const weight = cssKeyword(definition.weight, SAFE_WEIGHT, "normal");
|
|
1643
|
+
const style = cssKeyword(definition.style, SAFE_STYLE, "normal");
|
|
1644
|
+
const display = cssKeyword(definition.display, SAFE_DISPLAY, "swap");
|
|
1645
|
+
return `@font-face{font-family:${cssString(definition.family)};src:${cssSource};font-weight:${weight};font-style:${style};font-display:${display}}`;
|
|
1646
|
+
})
|
|
1647
|
+
);
|
|
1648
|
+
return rules.join("\n");
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
// src/licensing.ts
|
|
1653
|
+
function domainMatches(hostname, pattern) {
|
|
1654
|
+
const host = hostname.toLowerCase();
|
|
1655
|
+
const expected = pattern.toLowerCase();
|
|
1656
|
+
if (expected.startsWith("*.")) {
|
|
1657
|
+
const suffix = expected.slice(1);
|
|
1658
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
1659
|
+
}
|
|
1660
|
+
return host === expected;
|
|
1661
|
+
}
|
|
1662
|
+
function isLocal(hostname) {
|
|
1663
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
|
|
1664
|
+
}
|
|
1665
|
+
var LicenseManager = class {
|
|
1666
|
+
constructor(config = {}) {
|
|
1667
|
+
this.config = config;
|
|
1668
|
+
const hostname = config.hostname ?? globalThis.location?.hostname ?? "localhost";
|
|
1669
|
+
const environment = config.environment ?? "development";
|
|
1670
|
+
if (environment !== "production" || isLocal(hostname)) {
|
|
1671
|
+
this.status = { state: "exempt", payload: null };
|
|
1672
|
+
this.readyPromise = Promise.resolve(this.status);
|
|
1673
|
+
} else if (!config.key) {
|
|
1674
|
+
this.status = { state: "community", payload: null };
|
|
1675
|
+
this.readyPromise = Promise.resolve(this.status);
|
|
1676
|
+
} else {
|
|
1677
|
+
this.status = { state: "checking", payload: null };
|
|
1678
|
+
this.readyPromise = this.validate(config.key, hostname);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
config;
|
|
1682
|
+
status;
|
|
1683
|
+
readyPromise;
|
|
1684
|
+
getStatus() {
|
|
1685
|
+
return this.status;
|
|
1686
|
+
}
|
|
1687
|
+
ready() {
|
|
1688
|
+
return this.readyPromise;
|
|
1689
|
+
}
|
|
1690
|
+
hasFeature(feature) {
|
|
1691
|
+
return this.status.state === "valid" && (this.status.payload.features ?? []).includes(feature);
|
|
1692
|
+
}
|
|
1693
|
+
track(name) {
|
|
1694
|
+
this.config.onUsage?.({
|
|
1695
|
+
name,
|
|
1696
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1697
|
+
licenseId: this.status.state === "valid" ? this.status.payload.id : void 0
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
async validate(key, hostname) {
|
|
1701
|
+
const payload = await this.config.verifyOffline?.(key) ?? null;
|
|
1702
|
+
if (!payload) return this.status = { state: "invalid", payload: null };
|
|
1703
|
+
if (payload.expiresAt && Date.parse(payload.expiresAt) < Date.now()) {
|
|
1704
|
+
return this.status = { state: "expired", payload };
|
|
1705
|
+
}
|
|
1706
|
+
if (!payload.domains.some((domain) => domainMatches(hostname, domain))) {
|
|
1707
|
+
return this.status = { state: "domain-mismatch", payload };
|
|
1708
|
+
}
|
|
1709
|
+
return this.status = { state: "valid", payload };
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
// src/project.ts
|
|
1714
|
+
var ProjectManager = class {
|
|
1715
|
+
constructor(editor) {
|
|
1716
|
+
this.editor = editor;
|
|
1717
|
+
const first = { id: generateId(), name: "Page 1", state: editor.toJSON() };
|
|
1718
|
+
this.pages = [first];
|
|
1719
|
+
this.activePageId = first.id;
|
|
1720
|
+
}
|
|
1721
|
+
editor;
|
|
1722
|
+
pages;
|
|
1723
|
+
activePageId;
|
|
1724
|
+
getAll() {
|
|
1725
|
+
return this.pages.map(({ id, name }) => ({ id, name }));
|
|
1726
|
+
}
|
|
1727
|
+
getActivePageId() {
|
|
1728
|
+
return this.activePageId;
|
|
1729
|
+
}
|
|
1730
|
+
add(name = `Page ${this.pages.length + 1}`, cloneCurrent = false) {
|
|
1731
|
+
this.saveCurrent();
|
|
1732
|
+
const source = structuredClone(this.editor.toJSON());
|
|
1733
|
+
const state = cloneCurrent ? source : this.blankState(source);
|
|
1734
|
+
const page = { id: generateId(), name, state };
|
|
1735
|
+
this.pages.push(page);
|
|
1736
|
+
this.emitChanged();
|
|
1737
|
+
return page.id;
|
|
1738
|
+
}
|
|
1739
|
+
async switchTo(id) {
|
|
1740
|
+
if (id === this.activePageId) return true;
|
|
1741
|
+
const page = this.pages.find((candidate) => candidate.id === id);
|
|
1742
|
+
if (!page) return false;
|
|
1743
|
+
this.saveCurrent();
|
|
1744
|
+
await this.editor.fromJSON(structuredClone(page.state));
|
|
1745
|
+
this.activePageId = id;
|
|
1746
|
+
this.emitChanged();
|
|
1747
|
+
return true;
|
|
1748
|
+
}
|
|
1749
|
+
async duplicate(id) {
|
|
1750
|
+
this.saveCurrent();
|
|
1751
|
+
const source = this.pages.find((page2) => page2.id === id);
|
|
1752
|
+
if (!source) return null;
|
|
1753
|
+
const page = {
|
|
1754
|
+
id: generateId(),
|
|
1755
|
+
name: `${source.name} copy`,
|
|
1756
|
+
state: structuredClone(source.state)
|
|
1757
|
+
};
|
|
1758
|
+
const index = this.pages.indexOf(source);
|
|
1759
|
+
this.pages.splice(index + 1, 0, page);
|
|
1760
|
+
this.emitChanged();
|
|
1761
|
+
return page.id;
|
|
1762
|
+
}
|
|
1763
|
+
async remove(id) {
|
|
1764
|
+
if (this.pages.length === 1) return false;
|
|
1765
|
+
const index = this.pages.findIndex((page) => page.id === id);
|
|
1766
|
+
if (index < 0) return false;
|
|
1767
|
+
if (id === this.activePageId) {
|
|
1768
|
+
const next = this.pages[index + 1] ?? this.pages[index - 1];
|
|
1769
|
+
await this.editor.fromJSON(structuredClone(next.state));
|
|
1770
|
+
this.activePageId = next.id;
|
|
1771
|
+
}
|
|
1772
|
+
this.pages.splice(index, 1);
|
|
1773
|
+
this.emitChanged();
|
|
1774
|
+
return true;
|
|
1775
|
+
}
|
|
1776
|
+
rename(id, name) {
|
|
1777
|
+
const page = this.pages.find((candidate) => candidate.id === id);
|
|
1778
|
+
const trimmed = name.trim();
|
|
1779
|
+
if (!page || !trimmed || page.name === trimmed) return false;
|
|
1780
|
+
page.name = trimmed;
|
|
1781
|
+
this.emitChanged();
|
|
1782
|
+
return true;
|
|
1783
|
+
}
|
|
1784
|
+
reorder(id, newIndex) {
|
|
1785
|
+
const index = this.pages.findIndex((page2) => page2.id === id);
|
|
1786
|
+
if (index < 0 || !Number.isFinite(newIndex)) return false;
|
|
1787
|
+
const target = Math.max(0, Math.min(this.pages.length - 1, Math.round(newIndex)));
|
|
1788
|
+
if (target === index) return false;
|
|
1789
|
+
const [page] = this.pages.splice(index, 1);
|
|
1790
|
+
this.pages.splice(target, 0, page);
|
|
1791
|
+
this.emitChanged();
|
|
1792
|
+
return true;
|
|
1793
|
+
}
|
|
1794
|
+
toJSON() {
|
|
1795
|
+
this.saveCurrent();
|
|
1796
|
+
return {
|
|
1797
|
+
version: "1.0.0",
|
|
1798
|
+
activePageId: this.activePageId,
|
|
1799
|
+
pages: structuredClone(this.pages)
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
async fromJSON(project) {
|
|
1803
|
+
if (project?.version !== "1.0.0" || !Array.isArray(project.pages) || project.pages.length === 0 || !project.pages.some((page) => page.id === project.activePageId)) {
|
|
1804
|
+
throw new Error("Invalid project state");
|
|
1805
|
+
}
|
|
1806
|
+
const pages = structuredClone(project.pages);
|
|
1807
|
+
const active = pages.find((page) => page.id === project.activePageId);
|
|
1808
|
+
await this.editor.fromJSON(structuredClone(active.state));
|
|
1809
|
+
this.pages = pages;
|
|
1810
|
+
this.activePageId = active.id;
|
|
1811
|
+
this.emitChanged();
|
|
1812
|
+
}
|
|
1813
|
+
saveCurrent() {
|
|
1814
|
+
const page = this.pages.find((candidate) => candidate.id === this.activePageId);
|
|
1815
|
+
if (page) page.state = structuredClone(this.editor.toJSON());
|
|
1816
|
+
}
|
|
1817
|
+
blankState(source) {
|
|
1818
|
+
return { ...source, layers: [], mockup: null };
|
|
1819
|
+
}
|
|
1820
|
+
emitChanged() {
|
|
1821
|
+
this.editor.events.emit("project:changed", {
|
|
1822
|
+
activePageId: this.activePageId,
|
|
1823
|
+
pages: this.getAll()
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// src/mask.ts
|
|
1829
|
+
var import_fabric5 = require("fabric");
|
|
1830
|
+
var MaskRefinementError = class extends Error {
|
|
1831
|
+
constructor(code, message, cause) {
|
|
1832
|
+
super(message);
|
|
1833
|
+
this.code = code;
|
|
1834
|
+
this.cause = cause;
|
|
1835
|
+
this.name = "MaskRefinementError";
|
|
1836
|
+
}
|
|
1837
|
+
code;
|
|
1838
|
+
cause;
|
|
1839
|
+
};
|
|
1840
|
+
var MaskController = class {
|
|
1841
|
+
constructor(editor) {
|
|
1842
|
+
this.editor = editor;
|
|
1843
|
+
}
|
|
1844
|
+
editor;
|
|
1845
|
+
backing = null;
|
|
1846
|
+
context = null;
|
|
1847
|
+
layerId = null;
|
|
1848
|
+
brush = null;
|
|
1849
|
+
previousPoint = null;
|
|
1850
|
+
strokeBackup = null;
|
|
1851
|
+
strokeStartedAt = null;
|
|
1852
|
+
lastInteractionLatencyMs = 0;
|
|
1853
|
+
refinement = null;
|
|
1854
|
+
disposed = false;
|
|
1855
|
+
async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
|
|
1856
|
+
this.assertActive();
|
|
1857
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
1858
|
+
throw new Error("Mask dimensions must be positive integers");
|
|
1859
|
+
}
|
|
1860
|
+
const backing = this.makeCanvas(width, height);
|
|
1861
|
+
const image = new import_fabric5.FabricImage(backing, {
|
|
1862
|
+
left: 0,
|
|
1863
|
+
top: 0,
|
|
1864
|
+
originX: "left",
|
|
1865
|
+
originY: "top",
|
|
1866
|
+
selectable: false
|
|
1867
|
+
});
|
|
1868
|
+
const layer = this.editor.layers.add("mask", image, "Mask");
|
|
1869
|
+
layer.meta.mask = { width, height, revision: 0 };
|
|
1870
|
+
this.editor.history.saveImmediate();
|
|
1871
|
+
this.attachBacking(layer.id, backing);
|
|
1872
|
+
return layer;
|
|
1873
|
+
}
|
|
1874
|
+
attach(layerId) {
|
|
1875
|
+
this.assertActive();
|
|
1876
|
+
this.cancelStroke();
|
|
1877
|
+
const layer = this.requireMask(layerId);
|
|
1878
|
+
const image = layer.fabricObject;
|
|
1879
|
+
const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
|
|
1880
|
+
const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
|
|
1881
|
+
const backing = this.makeCanvas(width, height);
|
|
1882
|
+
const context = backing.getContext("2d");
|
|
1883
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
1884
|
+
const element = image.getElement();
|
|
1885
|
+
if (element) context.drawImage(element, 0, 0, width, height);
|
|
1886
|
+
this.attachBacking(layerId, backing);
|
|
1887
|
+
}
|
|
1888
|
+
beginStroke(options) {
|
|
1889
|
+
this.assertActive();
|
|
1890
|
+
if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
|
|
1891
|
+
if (this.brush) throw new Error("A mask stroke is already active");
|
|
1892
|
+
if (!Number.isFinite(options.size) || options.size <= 0) {
|
|
1893
|
+
throw new Error("Mask brush size must be positive");
|
|
1894
|
+
}
|
|
1895
|
+
this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
|
|
1896
|
+
this.previousPoint = null;
|
|
1897
|
+
this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
|
|
1898
|
+
this.strokeStartedAt = performance.now();
|
|
1899
|
+
}
|
|
1900
|
+
addPoint(point) {
|
|
1901
|
+
if (!this.brush || !this.context || !this.backing || !this.layerId) {
|
|
1902
|
+
throw new Error("No active mask stroke");
|
|
1903
|
+
}
|
|
1904
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
1905
|
+
const previous = this.previousPoint ?? point;
|
|
1906
|
+
const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
1907
|
+
const step = Math.max(1, this.brush.size / 4);
|
|
1908
|
+
const samples = Math.max(1, Math.ceil(distance / step));
|
|
1909
|
+
for (let index = 0; index <= samples; index += 1) {
|
|
1910
|
+
const ratio = index / samples;
|
|
1911
|
+
this.drawDot({
|
|
1912
|
+
x: previous.x + (point.x - previous.x) * ratio,
|
|
1913
|
+
y: previous.y + (point.y - previous.y) * ratio
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
this.previousPoint = point;
|
|
1917
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1918
|
+
this.editor.canvas.requestRenderAll();
|
|
1919
|
+
}
|
|
1920
|
+
async endStroke() {
|
|
1921
|
+
if (!this.brush || !this.backing || !this.layerId) return;
|
|
1922
|
+
const layerId = this.layerId;
|
|
1923
|
+
this.brush = null;
|
|
1924
|
+
this.previousPoint = null;
|
|
1925
|
+
this.strokeBackup = null;
|
|
1926
|
+
const dataUrl = this.backing.toDataURL("image/png");
|
|
1927
|
+
await this.editor.history.transaction(async () => {
|
|
1928
|
+
await this.editor.replaceImageSource(layerId, dataUrl);
|
|
1929
|
+
const layer = this.requireMask(layerId);
|
|
1930
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1931
|
+
});
|
|
1932
|
+
this.attach(layerId);
|
|
1933
|
+
if (this.strokeStartedAt !== null) {
|
|
1934
|
+
this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
|
|
1935
|
+
this.strokeStartedAt = null;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
cancelStroke() {
|
|
1939
|
+
if (this.strokeBackup && this.context && this.backing && this.layerId) {
|
|
1940
|
+
this.context.putImageData(this.strokeBackup, 0, 0);
|
|
1941
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1942
|
+
this.editor.canvas.requestRenderAll();
|
|
1943
|
+
}
|
|
1944
|
+
this.brush = null;
|
|
1945
|
+
this.previousPoint = null;
|
|
1946
|
+
this.strokeBackup = null;
|
|
1947
|
+
this.strokeStartedAt = null;
|
|
1948
|
+
}
|
|
1949
|
+
isStrokeActive() {
|
|
1950
|
+
return this.brush !== null;
|
|
1951
|
+
}
|
|
1952
|
+
activeLayerId() {
|
|
1953
|
+
return this.layerId;
|
|
1954
|
+
}
|
|
1955
|
+
detach(layerId) {
|
|
1956
|
+
if (layerId && this.layerId !== layerId) return;
|
|
1957
|
+
this.cancelStroke();
|
|
1958
|
+
this.cancelRefinement();
|
|
1959
|
+
this.backing = null;
|
|
1960
|
+
this.context = null;
|
|
1961
|
+
this.layerId = null;
|
|
1962
|
+
}
|
|
1963
|
+
async refine(layerId, provider, prompts, options = {}) {
|
|
1964
|
+
this.assertActive();
|
|
1965
|
+
const layer = this.editor.layers.get(layerId);
|
|
1966
|
+
if (!layer || layer.type !== "mask") {
|
|
1967
|
+
throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
|
|
1968
|
+
}
|
|
1969
|
+
this.cancelRefinement();
|
|
1970
|
+
const controller = new AbortController();
|
|
1971
|
+
this.refinement = controller;
|
|
1972
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
1973
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1974
|
+
if (options.signal?.aborted) abort();
|
|
1975
|
+
try {
|
|
1976
|
+
const image = layer.fabricObject;
|
|
1977
|
+
const result = await provider.refine(
|
|
1978
|
+
{
|
|
1979
|
+
mask: image.getSrc(),
|
|
1980
|
+
width: layer.meta.mask?.width ?? image.width ?? 1,
|
|
1981
|
+
height: layer.meta.mask?.height ?? image.height ?? 1,
|
|
1982
|
+
prompts: structuredClone(prompts)
|
|
1983
|
+
},
|
|
1984
|
+
{ signal: controller.signal, onProgress: options.onProgress }
|
|
1985
|
+
);
|
|
1986
|
+
if (controller.signal.aborted)
|
|
1987
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
|
|
1988
|
+
if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
|
|
1989
|
+
throw new MaskRefinementError(
|
|
1990
|
+
"invalid-result",
|
|
1991
|
+
"Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
await this.editor.history.transaction(async () => {
|
|
1995
|
+
await this.editor.replaceImageSource(layerId, result.dataUrl);
|
|
1996
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1997
|
+
});
|
|
1998
|
+
this.attach(layerId);
|
|
1999
|
+
return result;
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
if (error instanceof MaskRefinementError) throw error;
|
|
2002
|
+
if (controller.signal.aborted) {
|
|
2003
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
|
|
2004
|
+
}
|
|
2005
|
+
throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
|
|
2006
|
+
} finally {
|
|
2007
|
+
options.signal?.removeEventListener("abort", abort);
|
|
2008
|
+
if (this.refinement === controller) this.refinement = null;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
cancelRefinement() {
|
|
2012
|
+
this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
|
|
2013
|
+
this.refinement = null;
|
|
2014
|
+
}
|
|
2015
|
+
measure() {
|
|
2016
|
+
if (!this.backing) return null;
|
|
2017
|
+
const started = performance.now();
|
|
2018
|
+
this.context?.getImageData(0, 0, 1, 1);
|
|
2019
|
+
const backingBytes = this.backing.width * this.backing.height * 4;
|
|
2020
|
+
const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
|
|
2021
|
+
const memory = performance;
|
|
2022
|
+
return {
|
|
2023
|
+
width: this.backing.width,
|
|
2024
|
+
height: this.backing.height,
|
|
2025
|
+
backingBytes,
|
|
2026
|
+
strokeBackupBytes,
|
|
2027
|
+
// The backing store and rollback ImageData dominate interactive mask
|
|
2028
|
+
// memory. Encoded historical rasters are reported separately below.
|
|
2029
|
+
estimatedPeakBytes: backingBytes + strokeBackupBytes,
|
|
2030
|
+
historyBytes: this.editor.history.getSnapshotBytes(),
|
|
2031
|
+
interactionLatencyMs: this.lastInteractionLatencyMs,
|
|
2032
|
+
...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
|
|
2033
|
+
elapsedMs: performance.now() - started
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
dispose() {
|
|
2037
|
+
this.detach();
|
|
2038
|
+
this.disposed = true;
|
|
2039
|
+
}
|
|
2040
|
+
drawDot(point) {
|
|
2041
|
+
const context = this.context;
|
|
2042
|
+
const brush = this.brush;
|
|
2043
|
+
const radius = brush.size / 2;
|
|
2044
|
+
context.save();
|
|
2045
|
+
context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
|
|
2046
|
+
const gradient = context.createRadialGradient(
|
|
2047
|
+
point.x,
|
|
2048
|
+
point.y,
|
|
2049
|
+
radius * brush.hardness,
|
|
2050
|
+
point.x,
|
|
2051
|
+
point.y,
|
|
2052
|
+
radius
|
|
2053
|
+
);
|
|
2054
|
+
const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
|
|
2055
|
+
gradient.addColorStop(0, color);
|
|
2056
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
2057
|
+
context.fillStyle = gradient;
|
|
2058
|
+
context.beginPath();
|
|
2059
|
+
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
|
2060
|
+
context.fill();
|
|
2061
|
+
context.restore();
|
|
2062
|
+
}
|
|
2063
|
+
makeCanvas(width, height) {
|
|
2064
|
+
const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
2065
|
+
canvas.width = width;
|
|
2066
|
+
canvas.height = height;
|
|
2067
|
+
return canvas;
|
|
2068
|
+
}
|
|
2069
|
+
attachBacking(layerId, backing) {
|
|
2070
|
+
const context = backing.getContext("2d");
|
|
2071
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
2072
|
+
this.layerId = layerId;
|
|
2073
|
+
this.backing = backing;
|
|
2074
|
+
this.context = context;
|
|
2075
|
+
}
|
|
2076
|
+
requireMask(layerId) {
|
|
2077
|
+
const layer = this.editor.layers.get(layerId);
|
|
2078
|
+
if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
|
|
2079
|
+
return layer;
|
|
2080
|
+
}
|
|
2081
|
+
assertActive() {
|
|
2082
|
+
if (this.disposed) throw new Error("Mask controller has been disposed");
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
|
|
1018
2086
|
// src/editor.ts
|
|
1019
2087
|
var MIN_ZOOM = 0.1;
|
|
1020
2088
|
var MAX_ZOOM = 8;
|
|
2089
|
+
function isTaintedCanvasError(error) {
|
|
2090
|
+
return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
|
|
2091
|
+
}
|
|
1021
2092
|
var CanvasEditor = class {
|
|
1022
2093
|
canvas;
|
|
1023
2094
|
layers;
|
|
@@ -1027,6 +2098,10 @@ var CanvasEditor = class {
|
|
|
1027
2098
|
snapping;
|
|
1028
2099
|
crop;
|
|
1029
2100
|
patterns;
|
|
2101
|
+
fonts;
|
|
2102
|
+
licensing;
|
|
2103
|
+
pages;
|
|
2104
|
+
masks;
|
|
1030
2105
|
fileAdapter;
|
|
1031
2106
|
imageProvider;
|
|
1032
2107
|
zoomLevel = 1;
|
|
@@ -1035,13 +2110,18 @@ var CanvasEditor = class {
|
|
|
1035
2110
|
// transparent while a mockup preview is shown, so this is the source of truth
|
|
1036
2111
|
// for serialization and export — not the (possibly transient) canvas value.
|
|
1037
2112
|
designBackground;
|
|
2113
|
+
designBackgroundImage = null;
|
|
2114
|
+
backgroundImageOptions = null;
|
|
1038
2115
|
constructor(canvasElement, config) {
|
|
1039
2116
|
this.events = new EventEmitter();
|
|
2117
|
+
this.fonts = new FontRegistry();
|
|
2118
|
+
config.fonts?.forEach((font) => this.fonts.register(font));
|
|
2119
|
+
this.licensing = new LicenseManager(config.license);
|
|
1040
2120
|
this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
|
|
1041
2121
|
const widthPx = this.units.toPixels(config.width);
|
|
1042
2122
|
const heightPx = this.units.toPixels(config.height);
|
|
1043
2123
|
this.designBackground = config.backgroundColor ?? "#ffffff";
|
|
1044
|
-
this.canvas = new
|
|
2124
|
+
this.canvas = new import_fabric6.Canvas(canvasElement, {
|
|
1045
2125
|
width: widthPx,
|
|
1046
2126
|
height: heightPx,
|
|
1047
2127
|
backgroundColor: this.designBackground,
|
|
@@ -1059,21 +2139,79 @@ var CanvasEditor = class {
|
|
|
1059
2139
|
},
|
|
1060
2140
|
events: this.events
|
|
1061
2141
|
});
|
|
2142
|
+
this.layers.setHistoryCallback(() => this.history.save());
|
|
1062
2143
|
this.snapping = new SnapManager(this.canvas, this.events);
|
|
1063
2144
|
this.crop = new CropController(this.canvas, this.history, this.events);
|
|
1064
|
-
this.patterns = new PatternManager(
|
|
2145
|
+
this.patterns = new PatternManager(
|
|
2146
|
+
this.canvas,
|
|
2147
|
+
this.layers,
|
|
2148
|
+
this.history,
|
|
2149
|
+
this.events,
|
|
2150
|
+
config.patternSourceResolver
|
|
2151
|
+
);
|
|
1065
2152
|
this.setupCanvasEvents();
|
|
1066
2153
|
this.history.saveImmediate();
|
|
2154
|
+
this.pages = new ProjectManager(this);
|
|
2155
|
+
this.masks = new MaskController(this);
|
|
1067
2156
|
}
|
|
1068
2157
|
// ─── Layer Operations ────────────────────────────────
|
|
1069
2158
|
async addImage(url, options) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
2159
|
+
try {
|
|
2160
|
+
const img = await import_fabric6.FabricImage.fromURL(
|
|
2161
|
+
url,
|
|
2162
|
+
{},
|
|
2163
|
+
{ originX: "left", originY: "top", ...options }
|
|
2164
|
+
);
|
|
2165
|
+
const layer = this.layers.add("image", img);
|
|
2166
|
+
this.history.save();
|
|
2167
|
+
return layer;
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
this.events.emit("error", { message: "Failed to add image", error });
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
/** Replace an image source without changing its layer identity or visual transform. */
|
|
2174
|
+
async replaceImageSource(layerId, url) {
|
|
2175
|
+
const layer = this.layers.get(layerId);
|
|
2176
|
+
if (!layer || layer.type !== "image" && layer.type !== "mask") {
|
|
2177
|
+
throw new Error(`Image or mask layer not found: ${layerId}`);
|
|
2178
|
+
}
|
|
2179
|
+
if (layer.meta.pattern) {
|
|
2180
|
+
throw new Error("Clear the pattern before replacing the image source");
|
|
2181
|
+
}
|
|
2182
|
+
if (this.crop.activeLayerId() === layerId) this.crop.cancel();
|
|
2183
|
+
const previous = layer.fabricObject;
|
|
2184
|
+
try {
|
|
2185
|
+
const replacement = await import_fabric6.FabricImage.fromURL(url, {}, { originX: "left", originY: "top" });
|
|
2186
|
+
replacement.set({
|
|
2187
|
+
left: previous.left,
|
|
2188
|
+
top: previous.top,
|
|
2189
|
+
originX: previous.originX,
|
|
2190
|
+
originY: previous.originY,
|
|
2191
|
+
width: previous.width,
|
|
2192
|
+
height: previous.height,
|
|
2193
|
+
cropX: previous.cropX,
|
|
2194
|
+
cropY: previous.cropY,
|
|
2195
|
+
scaleX: previous.scaleX,
|
|
2196
|
+
scaleY: previous.scaleY,
|
|
2197
|
+
angle: previous.angle,
|
|
2198
|
+
skewX: previous.skewX,
|
|
2199
|
+
skewY: previous.skewY,
|
|
2200
|
+
flipX: previous.flipX,
|
|
2201
|
+
flipY: previous.flipY
|
|
2202
|
+
});
|
|
2203
|
+
replacement.filters = [...previous.filters];
|
|
2204
|
+
replacement.applyFilters();
|
|
2205
|
+
replacement.setCoords();
|
|
2206
|
+
this.layers.replaceObject(layerId, replacement);
|
|
2207
|
+
return layer;
|
|
2208
|
+
} catch (error) {
|
|
2209
|
+
this.events.emit("error", { message: "Failed to replace image source", error });
|
|
2210
|
+
throw error;
|
|
2211
|
+
}
|
|
1074
2212
|
}
|
|
1075
2213
|
addText(text, options) {
|
|
1076
|
-
const textbox = new
|
|
2214
|
+
const textbox = new import_fabric6.Textbox(text, {
|
|
1077
2215
|
fontSize: 32,
|
|
1078
2216
|
fontFamily: "Arial",
|
|
1079
2217
|
fill: "#000000",
|
|
@@ -1093,27 +2231,45 @@ var CanvasEditor = class {
|
|
|
1093
2231
|
this.history.save();
|
|
1094
2232
|
return layer;
|
|
1095
2233
|
}
|
|
1096
|
-
async addTemplate(template,
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
2234
|
+
async addTemplate(template, params) {
|
|
2235
|
+
const values = {};
|
|
2236
|
+
for (const parameter of template.parameters) {
|
|
2237
|
+
const value = params[parameter.key] ?? parameter.default;
|
|
2238
|
+
if (value === void 0) throw new Error(`Missing template parameter: ${parameter.key}`);
|
|
2239
|
+
if (parameter.type === "number" && !Number.isFinite(Number(value))) {
|
|
2240
|
+
throw new Error(`Invalid number for template parameter: ${parameter.key}`);
|
|
2241
|
+
}
|
|
2242
|
+
if (parameter.type === "color" && !isCssColor(value)) {
|
|
2243
|
+
throw new Error(`Invalid color for template parameter: ${parameter.key}`);
|
|
2244
|
+
}
|
|
2245
|
+
values[parameter.key] = value;
|
|
2246
|
+
}
|
|
2247
|
+
const resolved = sanitizeSvg(
|
|
2248
|
+
template.svg.replace(/\{\{(\w+)(?:[|:]([^}]*))?\}\}/g, (token, key, fallback) => {
|
|
2249
|
+
const value = values[key] ?? fallback;
|
|
2250
|
+
return value === void 0 ? token : escapeXml(value);
|
|
2251
|
+
})
|
|
1110
2252
|
);
|
|
2253
|
+
const { objects, options } = await (0, import_fabric6.loadSVGFromString)(resolved);
|
|
2254
|
+
const validObjects = objects.filter((object) => object !== null);
|
|
2255
|
+
if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
|
|
2256
|
+
const group = import_fabric6.util.groupSVGElements(validObjects, options);
|
|
2257
|
+
group.set({
|
|
2258
|
+
left: this.canvas.getWidth() / 2,
|
|
2259
|
+
top: this.canvas.getHeight() / 2,
|
|
2260
|
+
originX: "center",
|
|
2261
|
+
originY: "center"
|
|
2262
|
+
});
|
|
2263
|
+
const layer = this.layers.add("template", group, template.name);
|
|
1111
2264
|
this.history.save();
|
|
1112
2265
|
return layer;
|
|
1113
2266
|
}
|
|
1114
2267
|
removeLayer(id) {
|
|
1115
|
-
this.
|
|
1116
|
-
this.
|
|
2268
|
+
if (this.crop.activeLayerId() === id) this.crop.cancel();
|
|
2269
|
+
if (this.masks.activeLayerId() === id) {
|
|
2270
|
+
this.masks.detach(id);
|
|
2271
|
+
}
|
|
2272
|
+
if (this.layers.remove(id)) this.history.save();
|
|
1117
2273
|
}
|
|
1118
2274
|
selectLayer(id) {
|
|
1119
2275
|
this.layers.select(id);
|
|
@@ -1148,35 +2304,220 @@ var CanvasEditor = class {
|
|
|
1148
2304
|
clone.setCoords();
|
|
1149
2305
|
const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
|
|
1150
2306
|
copy.meta = structuredClone(layer.meta);
|
|
2307
|
+
copy.visible = layer.visible;
|
|
2308
|
+
copy.locked = layer.locked;
|
|
2309
|
+
copy.opacity = layer.opacity;
|
|
2310
|
+
clone.set({
|
|
2311
|
+
visible: layer.visible,
|
|
2312
|
+
selectable: !layer.locked,
|
|
2313
|
+
evented: !layer.locked,
|
|
2314
|
+
opacity: layer.opacity
|
|
2315
|
+
});
|
|
1151
2316
|
this.canvas.setActiveObject(clone);
|
|
1152
2317
|
this.canvas.requestRenderAll();
|
|
1153
2318
|
this.history.save();
|
|
1154
2319
|
return copy;
|
|
1155
2320
|
}
|
|
2321
|
+
applyImageAdjustments(layerId, adjustments) {
|
|
2322
|
+
const layer = this.layers.get(layerId);
|
|
2323
|
+
if (!layer || layer.type !== "image") return false;
|
|
2324
|
+
const image = layer.fabricObject;
|
|
2325
|
+
const previous = layer.meta.imageAdjustments ?? {};
|
|
2326
|
+
const next = { ...previous, ...adjustments };
|
|
2327
|
+
const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
|
|
2328
|
+
image.filters = [
|
|
2329
|
+
new import_fabric6.filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
|
|
2330
|
+
new import_fabric6.filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
|
|
2331
|
+
new import_fabric6.filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
|
|
2332
|
+
new import_fabric6.filters.Blur({ blur: clampAdjustment(next.blur, 0) })
|
|
2333
|
+
];
|
|
2334
|
+
layer.meta.imageAdjustments = next;
|
|
2335
|
+
image.applyFilters();
|
|
2336
|
+
this.canvas.requestRenderAll();
|
|
2337
|
+
this.history.save();
|
|
2338
|
+
return true;
|
|
2339
|
+
}
|
|
2340
|
+
/** Combine two or more layers into a single editable group layer. */
|
|
2341
|
+
async groupLayers(ids, name = "Group") {
|
|
2342
|
+
const uniqueIds = [...new Set(ids)];
|
|
2343
|
+
const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
|
|
2344
|
+
if (children.length < 2 || children.length !== uniqueIds.length) return null;
|
|
2345
|
+
return this.history.transaction(() => {
|
|
2346
|
+
const childData = children.map((layer) => structuredClone(layer.toData()));
|
|
2347
|
+
const objects = children.map((layer) => layer.fabricObject);
|
|
2348
|
+
for (const layer of children) this.layers.remove(layer.id);
|
|
2349
|
+
const group = new import_fabric6.Group(objects);
|
|
2350
|
+
const grouped = this.layers.add("group", group, name);
|
|
2351
|
+
grouped.meta.groupChildren = childData;
|
|
2352
|
+
this.layers.select(grouped.id);
|
|
2353
|
+
this.history.save();
|
|
2354
|
+
return grouped;
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
/** Restore a group created by groupLayers back to its original layer records. */
|
|
2358
|
+
async ungroupLayer(id) {
|
|
2359
|
+
const grouped = this.layers.get(id);
|
|
2360
|
+
const childData = grouped?.meta.groupChildren;
|
|
2361
|
+
if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
|
|
2362
|
+
const group = grouped.fabricObject;
|
|
2363
|
+
return this.history.transaction(() => {
|
|
2364
|
+
const transform = group.calcTransformMatrix();
|
|
2365
|
+
const objects = group.removeAll();
|
|
2366
|
+
this.layers.remove(id);
|
|
2367
|
+
const restored = objects.map((object, index) => {
|
|
2368
|
+
import_fabric6.util.addTransformToObject(object, transform);
|
|
2369
|
+
object.setCoords();
|
|
2370
|
+
const data = childData[index];
|
|
2371
|
+
const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
|
|
2372
|
+
if (data?.meta) layer.meta = structuredClone(data.meta);
|
|
2373
|
+
if (data && !data.visible) this.layers.setVisibility(layer.id, false);
|
|
2374
|
+
if (data?.locked) this.layers.setLocked(layer.id, true);
|
|
2375
|
+
if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
|
|
2376
|
+
return layer;
|
|
2377
|
+
});
|
|
2378
|
+
this.history.save();
|
|
2379
|
+
this.layers.select(restored[0]?.id ?? null);
|
|
2380
|
+
return restored;
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
1156
2383
|
// ─── Serialization ──────────────────────────────────
|
|
1157
2384
|
toJSON() {
|
|
1158
2385
|
return serializeEditor(this);
|
|
1159
2386
|
}
|
|
1160
2387
|
async fromJSON(state) {
|
|
1161
|
-
|
|
1162
|
-
|
|
2388
|
+
const managedByHistory = this.history.isRestoring();
|
|
2389
|
+
if (!managedByHistory) {
|
|
2390
|
+
this.history.saveImmediate();
|
|
2391
|
+
this.history.pause();
|
|
2392
|
+
}
|
|
2393
|
+
try {
|
|
2394
|
+
await deserializeEditor(this, state);
|
|
2395
|
+
this.patterns.repinAll();
|
|
2396
|
+
} catch (error) {
|
|
2397
|
+
if (!managedByHistory) {
|
|
2398
|
+
this.events.emit("error", { message: "Failed to load editor state", error });
|
|
2399
|
+
}
|
|
2400
|
+
throw error;
|
|
2401
|
+
} finally {
|
|
2402
|
+
if (!managedByHistory) this.history.resume();
|
|
2403
|
+
}
|
|
2404
|
+
if (!managedByHistory) {
|
|
2405
|
+
this.history.clear();
|
|
2406
|
+
this.history.saveImmediate();
|
|
2407
|
+
}
|
|
1163
2408
|
}
|
|
1164
2409
|
// ─── Export ──────────────────────────────────────────
|
|
1165
2410
|
async toPNG(options) {
|
|
1166
|
-
this.
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
return
|
|
2411
|
+
return this.toRaster(options?.format ?? "png", options);
|
|
2412
|
+
}
|
|
2413
|
+
async toJPEG(options) {
|
|
2414
|
+
return this.toRaster("jpeg", options);
|
|
2415
|
+
}
|
|
2416
|
+
async toWebP(options) {
|
|
2417
|
+
return this.toRaster("webp", options);
|
|
2418
|
+
}
|
|
2419
|
+
/** Export one layer in document coordinates or at its native image resolution. */
|
|
2420
|
+
async exportLayer(id, options = {}) {
|
|
2421
|
+
const layer = this.layers.get(id);
|
|
2422
|
+
if (!layer) throw new Error(`Layer not found: ${id}`);
|
|
2423
|
+
try {
|
|
2424
|
+
if (options.resolution === "source" && layer.fabricObject instanceof import_fabric6.FabricImage) {
|
|
2425
|
+
const image = await layer.fabricObject.clone();
|
|
2426
|
+
image.set({
|
|
2427
|
+
left: 0,
|
|
2428
|
+
top: 0,
|
|
2429
|
+
originX: "left",
|
|
2430
|
+
originY: "top",
|
|
2431
|
+
scaleX: 1,
|
|
2432
|
+
scaleY: 1,
|
|
2433
|
+
angle: 0,
|
|
2434
|
+
flipX: false,
|
|
2435
|
+
flipY: false
|
|
2436
|
+
});
|
|
2437
|
+
return await exportIsolatedPNG(this.canvas, [image], {
|
|
2438
|
+
...options,
|
|
2439
|
+
width: image.width || 1,
|
|
2440
|
+
height: image.height || 1,
|
|
2441
|
+
cloneObjects: false
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
|
|
2447
|
+
throw error;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
/** Export only the configured document background, excluding design layers. */
|
|
2451
|
+
async exportBackground(options = {}) {
|
|
2452
|
+
try {
|
|
2453
|
+
return await exportIsolatedPNG(this.canvas, [], {
|
|
2454
|
+
...options,
|
|
2455
|
+
backgroundColor: this.designBackground,
|
|
2456
|
+
backgroundImage: this.designBackgroundImage
|
|
2457
|
+
});
|
|
2458
|
+
} catch (error) {
|
|
2459
|
+
this.events.emit("error", { message: "Failed to export background", error });
|
|
2460
|
+
throw error;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
async toRaster(format, options) {
|
|
2464
|
+
this.events.emit("export:start", { format });
|
|
2465
|
+
try {
|
|
2466
|
+
await this.fonts.ready();
|
|
2467
|
+
const blob = await this.withDesignBackground(
|
|
2468
|
+
() => exportPNG(this.canvas, { ...options, format })
|
|
2469
|
+
);
|
|
2470
|
+
this.events.emit("export:complete", { format });
|
|
2471
|
+
this.licensing.track(`export:${format}`);
|
|
2472
|
+
return blob;
|
|
2473
|
+
} catch (error) {
|
|
2474
|
+
this.events.emit("error", {
|
|
2475
|
+
message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
|
|
2476
|
+
error
|
|
2477
|
+
});
|
|
2478
|
+
throw error;
|
|
2479
|
+
}
|
|
1170
2480
|
}
|
|
1171
2481
|
toSVG() {
|
|
1172
2482
|
this.events.emit("export:start", { format: "svg" });
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
2483
|
+
try {
|
|
2484
|
+
const svg = this.withDesignBackground(() => exportSVG(this.canvas));
|
|
2485
|
+
this.events.emit("export:complete", { format: "svg" });
|
|
2486
|
+
this.licensing.track("export:svg");
|
|
2487
|
+
return svg;
|
|
2488
|
+
} catch (error) {
|
|
2489
|
+
this.events.emit("error", { message: "Failed to export SVG", error });
|
|
2490
|
+
throw error;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
async toSVGAsync(options = {}) {
|
|
2494
|
+
await this.fonts.ready();
|
|
2495
|
+
const svg = this.toSVG();
|
|
2496
|
+
if (options.embedFonts === false || this.fonts.getAll().length === 0) return svg;
|
|
2497
|
+
try {
|
|
2498
|
+
const css = await this.fonts.getEmbeddedCss();
|
|
2499
|
+
return svg.replace(/(<svg\b[^>]*>)/i, `$1<defs><style>${css}</style></defs>`);
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
this.events.emit("error", { message: "Failed to embed fonts in SVG", error });
|
|
2502
|
+
throw error;
|
|
2503
|
+
}
|
|
1176
2504
|
}
|
|
1177
2505
|
toDataURL(format = "png", multiplier = 1) {
|
|
1178
2506
|
return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
|
|
1179
2507
|
}
|
|
2508
|
+
/** Export the current product-preview composite. Advanced warping is host-defined. */
|
|
2509
|
+
async toMockupImage(options = {}) {
|
|
2510
|
+
if (!this.mockup) throw new Error("No mockup is configured");
|
|
2511
|
+
await this.fonts.ready();
|
|
2512
|
+
try {
|
|
2513
|
+
const blob = await exportMockup(this.canvas, this.mockup, options);
|
|
2514
|
+
this.licensing.track(`export:mockup:${options.format ?? "png"}`);
|
|
2515
|
+
return blob;
|
|
2516
|
+
} catch (error) {
|
|
2517
|
+
this.events.emit("error", { message: "Failed to export mockup", error });
|
|
2518
|
+
throw error;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
1180
2521
|
/**
|
|
1181
2522
|
* Run an export with the configured design background applied, even when a
|
|
1182
2523
|
* mockup preview has forced the live canvas transparent — so exports reflect
|
|
@@ -1185,11 +2526,14 @@ var CanvasEditor = class {
|
|
|
1185
2526
|
withDesignBackground(fn) {
|
|
1186
2527
|
if (!this.mockup) return fn();
|
|
1187
2528
|
const previewBg = this.canvas.backgroundColor;
|
|
2529
|
+
const previewImage = this.canvas.backgroundImage;
|
|
1188
2530
|
this.canvas.backgroundColor = this.designBackground;
|
|
2531
|
+
this.canvas.backgroundImage = this.designBackgroundImage ?? void 0;
|
|
1189
2532
|
try {
|
|
1190
2533
|
return fn();
|
|
1191
2534
|
} finally {
|
|
1192
2535
|
this.canvas.backgroundColor = previewBg;
|
|
2536
|
+
this.canvas.backgroundImage = previewImage;
|
|
1193
2537
|
}
|
|
1194
2538
|
}
|
|
1195
2539
|
toPrintifyPositioning() {
|
|
@@ -1204,7 +2548,8 @@ var CanvasEditor = class {
|
|
|
1204
2548
|
for (const layer of imageLayers) {
|
|
1205
2549
|
const obj = layer.fabricObject;
|
|
1206
2550
|
const center = obj.getCenterPoint();
|
|
1207
|
-
result[layer.name]
|
|
2551
|
+
const key = result[layer.name] ? `${layer.name}-${layer.id}` : layer.name;
|
|
2552
|
+
result[key] = {
|
|
1208
2553
|
x: round2(center.x / canvasW),
|
|
1209
2554
|
y: round2(center.y / canvasH),
|
|
1210
2555
|
scale: round2(obj.scaleX ?? 1),
|
|
@@ -1213,26 +2558,92 @@ var CanvasEditor = class {
|
|
|
1213
2558
|
}
|
|
1214
2559
|
return result;
|
|
1215
2560
|
}
|
|
2561
|
+
/** Provider-neutral, ID-stable placement data in 0..1 canvas coordinates. */
|
|
2562
|
+
toNormalizedPositioning() {
|
|
2563
|
+
const canvasWidth = this.canvas.getWidth();
|
|
2564
|
+
const canvasHeight = this.canvas.getHeight();
|
|
2565
|
+
return this.layers.getAll().map((layer) => {
|
|
2566
|
+
const object = layer.fabricObject;
|
|
2567
|
+
const center = object.getCenterPoint();
|
|
2568
|
+
const bounds = object.getBoundingRect();
|
|
2569
|
+
return {
|
|
2570
|
+
layerId: layer.id,
|
|
2571
|
+
name: layer.name,
|
|
2572
|
+
type: layer.type,
|
|
2573
|
+
centerX: round2(center.x / canvasWidth),
|
|
2574
|
+
centerY: round2(center.y / canvasHeight),
|
|
2575
|
+
width: round2(bounds.width / canvasWidth),
|
|
2576
|
+
height: round2(bounds.height / canvasHeight),
|
|
2577
|
+
scaleX: round2(object.scaleX ?? 1),
|
|
2578
|
+
scaleY: round2(object.scaleY ?? 1),
|
|
2579
|
+
angle: round2(object.angle ?? 0)
|
|
2580
|
+
};
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
toProviderPositioning(adapter) {
|
|
2584
|
+
return adapter.map(this.toNormalizedPositioning(), {
|
|
2585
|
+
width: this.canvas.getWidth(),
|
|
2586
|
+
height: this.canvas.getHeight()
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
1216
2589
|
// ─── File Operations ────────────────────────────────
|
|
1217
2590
|
async save(filename, format) {
|
|
1218
2591
|
if (!this.fileAdapter) return void 0;
|
|
1219
2592
|
let data;
|
|
1220
2593
|
if (format === "png") {
|
|
1221
2594
|
data = await this.toPNG();
|
|
2595
|
+
} else if (format === "jpeg") {
|
|
2596
|
+
data = await this.toJPEG();
|
|
2597
|
+
} else if (format === "webp") {
|
|
2598
|
+
data = await this.toWebP();
|
|
1222
2599
|
} else if (format === "svg") {
|
|
1223
|
-
data = this.
|
|
2600
|
+
data = await this.toSVGAsync();
|
|
1224
2601
|
} else {
|
|
1225
2602
|
data = JSON.stringify(this.toJSON(), null, 2);
|
|
1226
2603
|
}
|
|
1227
|
-
|
|
2604
|
+
try {
|
|
2605
|
+
return await this.fileAdapter.save(data, filename, format);
|
|
2606
|
+
} catch (error) {
|
|
2607
|
+
this.events.emit("error", { message: `Failed to save ${format}`, error });
|
|
2608
|
+
throw error;
|
|
2609
|
+
}
|
|
1228
2610
|
}
|
|
1229
2611
|
async uploadImage(file) {
|
|
1230
|
-
if (!this.imageProvider) return void 0;
|
|
1231
|
-
|
|
2612
|
+
if (!this.imageProvider?.upload) return void 0;
|
|
2613
|
+
try {
|
|
2614
|
+
return await this.imageProvider.upload(file);
|
|
2615
|
+
} catch (error) {
|
|
2616
|
+
this.events.emit("error", { message: "Failed to upload image", error });
|
|
2617
|
+
throw error;
|
|
2618
|
+
}
|
|
1232
2619
|
}
|
|
1233
2620
|
async browseImages() {
|
|
1234
2621
|
if (!this.imageProvider?.browse) return null;
|
|
1235
|
-
|
|
2622
|
+
try {
|
|
2623
|
+
return await this.imageProvider.browse();
|
|
2624
|
+
} catch (error) {
|
|
2625
|
+
this.events.emit("error", { message: "Failed to browse images", error });
|
|
2626
|
+
throw error;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
async searchImages(query, options) {
|
|
2630
|
+
if (!this.imageProvider?.search) return null;
|
|
2631
|
+
try {
|
|
2632
|
+
return await this.imageProvider.search(query, options);
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
this.events.emit("error", { message: "Failed to search images", error });
|
|
2635
|
+
throw error;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
/** Track provider usage, then insert its hotlinked image as a normal layer. */
|
|
2639
|
+
async addProviderImage(image, options) {
|
|
2640
|
+
try {
|
|
2641
|
+
await this.imageProvider?.trackUse?.(image);
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
this.events.emit("error", { message: "Failed to record image use", error });
|
|
2644
|
+
throw error;
|
|
2645
|
+
}
|
|
2646
|
+
return this.addImage(image.url, options);
|
|
1236
2647
|
}
|
|
1237
2648
|
setFileAdapter(adapter) {
|
|
1238
2649
|
this.fileAdapter = adapter;
|
|
@@ -1248,15 +2659,140 @@ var CanvasEditor = class {
|
|
|
1248
2659
|
this.canvas.requestRenderAll();
|
|
1249
2660
|
}
|
|
1250
2661
|
this.history.save();
|
|
2662
|
+
this.events.emit("canvas:modified", {});
|
|
1251
2663
|
}
|
|
1252
2664
|
getDesignBackground() {
|
|
1253
2665
|
return this.designBackground;
|
|
1254
2666
|
}
|
|
1255
|
-
|
|
2667
|
+
getDesignBackgroundImage() {
|
|
2668
|
+
return this.designBackgroundImage;
|
|
2669
|
+
}
|
|
2670
|
+
getBackgroundImageOptions() {
|
|
2671
|
+
return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
|
|
2672
|
+
}
|
|
2673
|
+
async setBackgroundImage(url, options = {}) {
|
|
2674
|
+
if (url === null) {
|
|
2675
|
+
this.setBackgroundImageObject(null);
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
const image = await import_fabric6.FabricImage.fromURL(
|
|
2680
|
+
url,
|
|
2681
|
+
{ crossOrigin: options.crossOrigin ?? null, signal: options.signal },
|
|
2682
|
+
{ originX: "left", originY: "top" }
|
|
2683
|
+
);
|
|
2684
|
+
const width = image.width || 1;
|
|
2685
|
+
const height = image.height || 1;
|
|
2686
|
+
const canvasWidth = this.canvas.getWidth();
|
|
2687
|
+
const canvasHeight = this.canvas.getHeight();
|
|
2688
|
+
const fit = options.fit ?? "cover";
|
|
2689
|
+
const sx = canvasWidth / width;
|
|
2690
|
+
const sy = canvasHeight / height;
|
|
2691
|
+
const scaleX = fit === "stretch" ? sx : fit === "contain" ? Math.min(sx, sy) : Math.max(sx, sy);
|
|
2692
|
+
const scaleY = fit === "stretch" ? sy : scaleX;
|
|
2693
|
+
image.set({
|
|
2694
|
+
left: (canvasWidth - width * scaleX) / 2,
|
|
2695
|
+
top: (canvasHeight - height * scaleY) / 2,
|
|
2696
|
+
scaleX,
|
|
2697
|
+
scaleY,
|
|
2698
|
+
opacity: clamp(options.opacity ?? 1, 0, 1),
|
|
2699
|
+
selectable: false,
|
|
2700
|
+
evented: false
|
|
2701
|
+
});
|
|
2702
|
+
const serializableOptions = { ...options };
|
|
2703
|
+
delete serializableOptions.signal;
|
|
2704
|
+
this.setBackgroundImageObject(image, true, serializableOptions);
|
|
2705
|
+
} catch (error) {
|
|
2706
|
+
this.events.emit("error", { message: "Failed to set background image", error });
|
|
2707
|
+
throw error;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
/** Used by state restoration and advanced integrations with an existing Fabric object. */
|
|
2711
|
+
setBackgroundImageObject(image, save = true, options = null) {
|
|
2712
|
+
this.designBackgroundImage = image;
|
|
2713
|
+
this.backgroundImageOptions = image ? options : null;
|
|
2714
|
+
this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
|
|
2715
|
+
this.canvas.requestRenderAll();
|
|
2716
|
+
if (save) this.history.save();
|
|
2717
|
+
this.events.emit("canvas:modified", {});
|
|
2718
|
+
}
|
|
2719
|
+
setTransparentBackground() {
|
|
2720
|
+
this.setBackground("");
|
|
2721
|
+
}
|
|
2722
|
+
resize(width, height, options = {}) {
|
|
1256
2723
|
const widthPx = this.units.toPixels(width);
|
|
1257
2724
|
const heightPx = this.units.toPixels(height);
|
|
2725
|
+
const oldWidth = this.canvas.getWidth();
|
|
2726
|
+
const oldHeight = this.canvas.getHeight();
|
|
2727
|
+
const scaleContent = options.scaleContent ?? true;
|
|
2728
|
+
if (scaleContent && oldWidth > 0 && oldHeight > 0) {
|
|
2729
|
+
const sx = widthPx / oldWidth;
|
|
2730
|
+
const sy = heightPx / oldHeight;
|
|
2731
|
+
for (const layer of this.layers.getAll()) {
|
|
2732
|
+
if (layer.meta.pattern) continue;
|
|
2733
|
+
const object = layer.fabricObject;
|
|
2734
|
+
object.set({
|
|
2735
|
+
left: (object.left ?? 0) * sx,
|
|
2736
|
+
top: (object.top ?? 0) * sy,
|
|
2737
|
+
scaleX: (object.scaleX ?? 1) * sx,
|
|
2738
|
+
scaleY: (object.scaleY ?? 1) * sy
|
|
2739
|
+
});
|
|
2740
|
+
object.setCoords();
|
|
2741
|
+
}
|
|
2742
|
+
if (this.designBackgroundImage) {
|
|
2743
|
+
this.designBackgroundImage.set({
|
|
2744
|
+
left: (this.designBackgroundImage.left ?? 0) * sx,
|
|
2745
|
+
top: (this.designBackgroundImage.top ?? 0) * sy,
|
|
2746
|
+
scaleX: (this.designBackgroundImage.scaleX ?? 1) * sx,
|
|
2747
|
+
scaleY: (this.designBackgroundImage.scaleY ?? 1) * sy
|
|
2748
|
+
});
|
|
2749
|
+
this.designBackgroundImage.setCoords();
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
1258
2752
|
this.canvas.setDimensions({ width: widthPx, height: heightPx });
|
|
2753
|
+
this.patterns.repinAll();
|
|
1259
2754
|
this.canvas.requestRenderAll();
|
|
2755
|
+
this.history.save();
|
|
2756
|
+
this.events.emit("canvas:modified", {});
|
|
2757
|
+
}
|
|
2758
|
+
/** Effective source resolution for an image at its current physical size. */
|
|
2759
|
+
getImageDpi(layerId) {
|
|
2760
|
+
const layer = this.layers.get(layerId);
|
|
2761
|
+
if (!layer || layer.type !== "image") return null;
|
|
2762
|
+
const image = layer.fabricObject;
|
|
2763
|
+
const sourcePixels = image.width ?? 0;
|
|
2764
|
+
const displayedPixels = image.getScaledWidth();
|
|
2765
|
+
if (sourcePixels <= 0 || displayedPixels <= 0) return null;
|
|
2766
|
+
return round2(sourcePixels / (displayedPixels / this.units.getDpi()));
|
|
2767
|
+
}
|
|
2768
|
+
validateImageDpi(minimumDpi = 300) {
|
|
2769
|
+
return this.layers.getAll().filter((layer) => layer.type === "image" && !layer.meta.pattern).flatMap((layer) => {
|
|
2770
|
+
const effectiveDpi = this.getImageDpi(layer.id);
|
|
2771
|
+
return effectiveDpi !== null && effectiveDpi < minimumDpi ? [{ layerId: layer.id, layerName: layer.name, effectiveDpi, minimumDpi }] : [];
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
/** Render a small data-URL preview without changing document dimensions. */
|
|
2775
|
+
toThumbnail(maxWidth = 320, maxHeight = 320, format = "png") {
|
|
2776
|
+
const multiplier = Math.min(
|
|
2777
|
+
1,
|
|
2778
|
+
maxWidth / this.canvas.getWidth(),
|
|
2779
|
+
maxHeight / this.canvas.getHeight()
|
|
2780
|
+
);
|
|
2781
|
+
return this.toDataURL(format, multiplier);
|
|
2782
|
+
}
|
|
2783
|
+
bringToFront(id) {
|
|
2784
|
+
return this.layers.reorder(id, this.layers.count() - 1);
|
|
2785
|
+
}
|
|
2786
|
+
sendToBack(id) {
|
|
2787
|
+
return this.layers.reorder(id, 0);
|
|
2788
|
+
}
|
|
2789
|
+
bringForward(id) {
|
|
2790
|
+
const index = this.layers.getAll().findIndex((layer) => layer.id === id);
|
|
2791
|
+
return index >= 0 && this.layers.reorder(id, index + 1);
|
|
2792
|
+
}
|
|
2793
|
+
sendBackward(id) {
|
|
2794
|
+
const index = this.layers.getAll().findIndex((layer) => layer.id === id);
|
|
2795
|
+
return index >= 0 && this.layers.reorder(id, index - 1);
|
|
1260
2796
|
}
|
|
1261
2797
|
// ─── Zoom ───────────────────────────────────────────
|
|
1262
2798
|
//
|
|
@@ -1294,6 +2830,20 @@ var CanvasEditor = class {
|
|
|
1294
2830
|
const sy = (viewportHeight - padding * 2) / h;
|
|
1295
2831
|
this.setZoom(Math.min(sx, sy));
|
|
1296
2832
|
}
|
|
2833
|
+
/** Zoom until the current selection fills the artboard viewport. */
|
|
2834
|
+
zoomToSelection(padding = 24) {
|
|
2835
|
+
const active = this.canvas.getActiveObject();
|
|
2836
|
+
if (!active) return;
|
|
2837
|
+
active.setCoords();
|
|
2838
|
+
const bounds = active.getBoundingRect();
|
|
2839
|
+
if (bounds.width <= 0 || bounds.height <= 0) return;
|
|
2840
|
+
this.setZoom(
|
|
2841
|
+
Math.min(
|
|
2842
|
+
(this.canvas.getWidth() - padding * 2) / bounds.width,
|
|
2843
|
+
(this.canvas.getHeight() - padding * 2) / bounds.height
|
|
2844
|
+
)
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
1297
2847
|
// ─── Patterns ───────────────────────────────────────
|
|
1298
2848
|
applyPattern(layerId, config) {
|
|
1299
2849
|
return this.patterns.apply(layerId, config);
|
|
@@ -1305,8 +2855,10 @@ var CanvasEditor = class {
|
|
|
1305
2855
|
setMockup(mockup) {
|
|
1306
2856
|
this.mockup = mockup;
|
|
1307
2857
|
this.canvas.backgroundColor = mockup ? "" : this.designBackground;
|
|
2858
|
+
this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
|
|
1308
2859
|
this.canvas.requestRenderAll();
|
|
1309
2860
|
this.events.emit("mockup:changed", { mockup });
|
|
2861
|
+
this.history.save();
|
|
1310
2862
|
}
|
|
1311
2863
|
clearMockup() {
|
|
1312
2864
|
this.setMockup(null);
|
|
@@ -1316,9 +2868,11 @@ var CanvasEditor = class {
|
|
|
1316
2868
|
}
|
|
1317
2869
|
// ─── Cleanup ────────────────────────────────────────
|
|
1318
2870
|
dispose() {
|
|
2871
|
+
this.masks.dispose();
|
|
1319
2872
|
this.snapping.dispose();
|
|
1320
2873
|
this.crop.dispose();
|
|
1321
2874
|
this.history.dispose();
|
|
2875
|
+
clearPatternImageCache();
|
|
1322
2876
|
this.events.removeAllListeners();
|
|
1323
2877
|
this.canvas.dispose();
|
|
1324
2878
|
}
|
|
@@ -1359,16 +2913,74 @@ var DEFAULT_PATTERN_CONFIG = {
|
|
|
1359
2913
|
rotationStepH: 0,
|
|
1360
2914
|
rotationStepV: 0
|
|
1361
2915
|
};
|
|
2916
|
+
|
|
2917
|
+
// src/presets.ts
|
|
2918
|
+
var CANVAS_SIZE_PRESETS = [
|
|
2919
|
+
{ id: "a4-portrait", name: "A4 portrait", width: 210, height: 297, unit: "mm", dpi: 300 },
|
|
2920
|
+
{ id: "a4-landscape", name: "A4 landscape", width: 297, height: 210, unit: "mm", dpi: 300 },
|
|
2921
|
+
{ id: "us-letter", name: "US Letter", width: 8.5, height: 11, unit: "in", dpi: 300 },
|
|
2922
|
+
{ id: "shirt-front", name: "Garment front", width: 12, height: 16, unit: "in", dpi: 300 },
|
|
2923
|
+
{ id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
|
|
2924
|
+
{ id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
|
|
2925
|
+
];
|
|
2926
|
+
|
|
2927
|
+
// src/annotations.ts
|
|
2928
|
+
var AnnotationOverlay = class {
|
|
2929
|
+
items = /* @__PURE__ */ new Map();
|
|
2930
|
+
transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
|
|
2931
|
+
set(annotation) {
|
|
2932
|
+
this.items.set(annotation.id, structuredClone(annotation));
|
|
2933
|
+
}
|
|
2934
|
+
remove(id) {
|
|
2935
|
+
return this.items.delete(id);
|
|
2936
|
+
}
|
|
2937
|
+
clear() {
|
|
2938
|
+
this.items.clear();
|
|
2939
|
+
}
|
|
2940
|
+
getAll() {
|
|
2941
|
+
return [...this.items.values()].map((item) => structuredClone(item));
|
|
2942
|
+
}
|
|
2943
|
+
setTransform(transform) {
|
|
2944
|
+
if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
|
|
2945
|
+
throw new Error("Annotation zoom must be positive");
|
|
2946
|
+
}
|
|
2947
|
+
this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
|
|
2948
|
+
}
|
|
2949
|
+
documentToViewport(point) {
|
|
2950
|
+
return {
|
|
2951
|
+
x: point.x * this.transform.zoom + this.transform.panX,
|
|
2952
|
+
y: point.y * this.transform.zoom + this.transform.panY
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
viewportToDocument(point) {
|
|
2956
|
+
return {
|
|
2957
|
+
x: (point.x - this.transform.panX) / this.transform.zoom,
|
|
2958
|
+
y: (point.y - this.transform.panY) / this.transform.zoom
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
documentToDevice(point) {
|
|
2962
|
+
const viewport = this.documentToViewport(point);
|
|
2963
|
+
const ratio = this.transform.devicePixelRatio ?? 1;
|
|
2964
|
+
return { x: viewport.x * ratio, y: viewport.y * ratio };
|
|
2965
|
+
}
|
|
2966
|
+
};
|
|
1362
2967
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1363
2968
|
0 && (module.exports = {
|
|
2969
|
+
AnnotationOverlay,
|
|
2970
|
+
CANVAS_SIZE_PRESETS,
|
|
1364
2971
|
CanvasEditor,
|
|
1365
2972
|
CropController,
|
|
1366
2973
|
DEFAULT_PATTERN_CONFIG,
|
|
1367
2974
|
EventEmitter,
|
|
2975
|
+
FontRegistry,
|
|
1368
2976
|
HistoryManager,
|
|
1369
2977
|
Layer,
|
|
1370
2978
|
LayerManager,
|
|
2979
|
+
LicenseManager,
|
|
2980
|
+
MaskController,
|
|
2981
|
+
MaskRefinementError,
|
|
1371
2982
|
PatternManager,
|
|
2983
|
+
ProjectManager,
|
|
1372
2984
|
SnapManager,
|
|
1373
2985
|
UnitConverter,
|
|
1374
2986
|
applyPatternLocks,
|
|
@@ -1376,15 +2988,23 @@ var DEFAULT_PATTERN_CONFIG = {
|
|
|
1376
2988
|
captureLocks,
|
|
1377
2989
|
clamp,
|
|
1378
2990
|
clearPatternImageCache,
|
|
2991
|
+
computeCoverPlacement,
|
|
2992
|
+
computePrintAreaClip,
|
|
1379
2993
|
computeTilePositions,
|
|
1380
2994
|
deserializeEditor,
|
|
2995
|
+
displaceRgba,
|
|
1381
2996
|
drawTiles,
|
|
2997
|
+
escapeXml,
|
|
1382
2998
|
exportDataURL,
|
|
2999
|
+
exportMockup,
|
|
1383
3000
|
exportPNG,
|
|
1384
3001
|
exportSVG,
|
|
1385
3002
|
generateId,
|
|
3003
|
+
isCssColor,
|
|
3004
|
+
loadPatternImage,
|
|
1386
3005
|
restoreLocks,
|
|
1387
3006
|
round2,
|
|
3007
|
+
sanitizeSvg,
|
|
1388
3008
|
serializeEditor
|
|
1389
3009
|
});
|
|
1390
3010
|
//# sourceMappingURL=index.js.map
|