@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.mjs
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeCoverPlacement,
|
|
3
|
+
computePrintAreaClip,
|
|
4
|
+
displaceRgba,
|
|
5
|
+
exportDataURL,
|
|
6
|
+
exportIsolatedPNG,
|
|
7
|
+
exportMockup,
|
|
8
|
+
exportPNG,
|
|
9
|
+
exportSVG
|
|
10
|
+
} from "./chunk-ORZZ6MGQ.mjs";
|
|
11
|
+
|
|
1
12
|
// src/editor.ts
|
|
2
|
-
import { Canvas, FabricImage as
|
|
13
|
+
import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
|
|
3
14
|
|
|
4
15
|
// src/events.ts
|
|
5
16
|
var EventEmitter = class {
|
|
@@ -91,6 +102,10 @@ var LayerManager = class {
|
|
|
91
102
|
canvas;
|
|
92
103
|
events;
|
|
93
104
|
layers = [];
|
|
105
|
+
onPropertyChanged;
|
|
106
|
+
setHistoryCallback(callback) {
|
|
107
|
+
this.onPropertyChanged = callback;
|
|
108
|
+
}
|
|
94
109
|
add(type, fabricObject, name, id) {
|
|
95
110
|
const layer = new Layer(type, fabricObject, name, id);
|
|
96
111
|
this.layers.push(layer);
|
|
@@ -107,12 +122,39 @@ var LayerManager = class {
|
|
|
107
122
|
this.layers.splice(index, 1);
|
|
108
123
|
this.events.emit("layer:removed", { layerId: id });
|
|
109
124
|
this.emitChanged();
|
|
125
|
+
this.onPropertyChanged?.();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/** Replace a layer's render object while preserving its immutable ID and panel state. */
|
|
129
|
+
replaceObject(id, fabricObject) {
|
|
130
|
+
const layer = this.get(id);
|
|
131
|
+
if (!layer || layer.fabricObject === fabricObject) return false;
|
|
132
|
+
const previous = layer.fabricObject;
|
|
133
|
+
const stackIndex = this.canvas.getObjects().indexOf(previous);
|
|
134
|
+
const wasActive = this.canvas.getActiveObject() === previous;
|
|
135
|
+
this.canvas.remove(previous);
|
|
136
|
+
layer.fabricObject = fabricObject;
|
|
137
|
+
fabricObject._layerId = id;
|
|
138
|
+
fabricObject.set({
|
|
139
|
+
visible: layer.visible,
|
|
140
|
+
opacity: layer.opacity,
|
|
141
|
+
selectable: !layer.locked,
|
|
142
|
+
evented: !layer.locked
|
|
143
|
+
});
|
|
144
|
+
this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
|
|
145
|
+
if (wasActive) this.canvas.setActiveObject(fabricObject);
|
|
146
|
+
this.canvas.requestRenderAll();
|
|
147
|
+
this.events.emit("layer:modified", { layerId: id });
|
|
148
|
+
this.emitChanged();
|
|
149
|
+
this.onPropertyChanged?.();
|
|
110
150
|
return true;
|
|
111
151
|
}
|
|
112
152
|
reorder(id, newIndex) {
|
|
113
153
|
const oldIndex = this.layers.findIndex((l) => l.id === id);
|
|
114
154
|
if (oldIndex === -1) return false;
|
|
115
|
-
|
|
155
|
+
if (!Number.isFinite(newIndex)) return false;
|
|
156
|
+
const clamped = Math.max(0, Math.min(this.layers.length - 1, Math.round(newIndex)));
|
|
157
|
+
if (oldIndex === clamped) return false;
|
|
116
158
|
const [layer] = this.layers.splice(oldIndex, 1);
|
|
117
159
|
this.layers.splice(clamped, 0, layer);
|
|
118
160
|
this.layers.forEach((l, i) => {
|
|
@@ -120,6 +162,7 @@ var LayerManager = class {
|
|
|
120
162
|
});
|
|
121
163
|
this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
|
|
122
164
|
this.emitChanged();
|
|
165
|
+
this.onPropertyChanged?.();
|
|
123
166
|
return true;
|
|
124
167
|
}
|
|
125
168
|
select(id) {
|
|
@@ -150,33 +193,42 @@ var LayerManager = class {
|
|
|
150
193
|
setVisibility(id, visible) {
|
|
151
194
|
const layer = this.get(id);
|
|
152
195
|
if (!layer) return;
|
|
196
|
+
if (layer.visible === visible) return;
|
|
153
197
|
layer.visible = visible;
|
|
154
198
|
layer.fabricObject.visible = visible;
|
|
155
199
|
this.canvas.requestRenderAll();
|
|
156
200
|
this.emitChanged();
|
|
201
|
+
this.onPropertyChanged?.();
|
|
157
202
|
}
|
|
158
203
|
setLocked(id, locked) {
|
|
159
204
|
const layer = this.get(id);
|
|
160
205
|
if (!layer) return;
|
|
206
|
+
if (layer.locked === locked) return;
|
|
161
207
|
layer.locked = locked;
|
|
162
208
|
layer.fabricObject.selectable = !locked;
|
|
163
209
|
layer.fabricObject.evented = !locked;
|
|
164
210
|
this.canvas.requestRenderAll();
|
|
165
211
|
this.emitChanged();
|
|
212
|
+
this.onPropertyChanged?.();
|
|
166
213
|
}
|
|
167
214
|
setOpacity(id, opacity) {
|
|
168
215
|
const layer = this.get(id);
|
|
169
|
-
if (!layer) return;
|
|
170
|
-
|
|
171
|
-
layer.
|
|
216
|
+
if (!layer || !Number.isFinite(opacity)) return;
|
|
217
|
+
const next = Math.max(0, Math.min(1, opacity));
|
|
218
|
+
if (layer.opacity === next) return;
|
|
219
|
+
layer.opacity = next;
|
|
220
|
+
layer.fabricObject.opacity = next;
|
|
172
221
|
this.canvas.requestRenderAll();
|
|
173
222
|
this.emitChanged();
|
|
223
|
+
this.onPropertyChanged?.();
|
|
174
224
|
}
|
|
175
225
|
setName(id, name) {
|
|
176
226
|
const layer = this.get(id);
|
|
177
227
|
if (!layer) return;
|
|
228
|
+
if (layer.name === name) return;
|
|
178
229
|
layer.name = name;
|
|
179
230
|
this.emitChanged();
|
|
231
|
+
this.onPropertyChanged?.();
|
|
180
232
|
}
|
|
181
233
|
clear() {
|
|
182
234
|
for (const layer of this.layers) {
|
|
@@ -196,61 +248,133 @@ var LayerManager = class {
|
|
|
196
248
|
};
|
|
197
249
|
|
|
198
250
|
// src/history.ts
|
|
199
|
-
var HistoryManager = class {
|
|
251
|
+
var HistoryManager = class _HistoryManager {
|
|
252
|
+
static ASSET_KEY = "__canvasEditorHistoryAsset";
|
|
200
253
|
undoStack = [];
|
|
201
254
|
redoStack = [];
|
|
255
|
+
assets = /* @__PURE__ */ new Map();
|
|
256
|
+
assetIds = /* @__PURE__ */ new Map();
|
|
257
|
+
nextAssetId = 1;
|
|
202
258
|
maxSize;
|
|
259
|
+
maxBytes;
|
|
203
260
|
paused = false;
|
|
204
261
|
debounceTimer = null;
|
|
205
262
|
debounceMs;
|
|
206
263
|
getState;
|
|
207
264
|
restoreState;
|
|
208
265
|
events;
|
|
266
|
+
transactionDepth = 0;
|
|
267
|
+
transactionDirty = false;
|
|
209
268
|
constructor(opts) {
|
|
210
269
|
this.getState = opts.getState;
|
|
211
270
|
this.restoreState = opts.restoreState;
|
|
212
271
|
this.events = opts.events;
|
|
213
272
|
this.maxSize = opts.maxSize ?? 50;
|
|
273
|
+
this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
|
|
214
274
|
this.debounceMs = opts.debounceMs ?? 300;
|
|
215
275
|
}
|
|
216
276
|
save() {
|
|
217
277
|
if (this.paused) return;
|
|
278
|
+
if (this.transactionDepth > 0) {
|
|
279
|
+
this.transactionDirty = true;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
218
282
|
if (this.debounceTimer) {
|
|
219
283
|
clearTimeout(this.debounceTimer);
|
|
220
284
|
}
|
|
221
285
|
this.debounceTimer = setTimeout(() => {
|
|
222
286
|
this.saveImmediate();
|
|
223
287
|
}, this.debounceMs);
|
|
288
|
+
this.emitChanged();
|
|
224
289
|
}
|
|
225
290
|
saveImmediate() {
|
|
226
|
-
if (this.paused)
|
|
227
|
-
|
|
291
|
+
if (this.paused) {
|
|
292
|
+
this.cancelPending();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (this.transactionDepth > 0) {
|
|
296
|
+
this.transactionDirty = true;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.cancelPending();
|
|
300
|
+
const rawState = this.getState();
|
|
301
|
+
const state = this.compactState(rawState);
|
|
302
|
+
if (this.undoStack.at(-1) === state) {
|
|
303
|
+
this.emitChanged();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
228
306
|
this.undoStack.push(state);
|
|
229
|
-
|
|
307
|
+
while (this.undoStack.length > this.maxSize) {
|
|
230
308
|
this.undoStack.shift();
|
|
231
309
|
}
|
|
232
310
|
this.redoStack = [];
|
|
311
|
+
this.trimToBudget();
|
|
312
|
+
this.events.emit("history:snapshot", {
|
|
313
|
+
bytes: rawState.length * 2,
|
|
314
|
+
totalBytes: this.snapshotBytes(),
|
|
315
|
+
entries: this.undoStack.length
|
|
316
|
+
});
|
|
233
317
|
this.emitChanged();
|
|
234
318
|
}
|
|
319
|
+
beginTransaction() {
|
|
320
|
+
if (this.transactionDepth === 0 && this.debounceTimer) this.saveImmediate();
|
|
321
|
+
this.transactionDepth += 1;
|
|
322
|
+
}
|
|
323
|
+
endTransaction() {
|
|
324
|
+
if (this.transactionDepth === 0) return;
|
|
325
|
+
this.transactionDepth -= 1;
|
|
326
|
+
if (this.transactionDepth === 0 && this.transactionDirty) {
|
|
327
|
+
this.transactionDirty = false;
|
|
328
|
+
this.saveImmediate();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async transaction(operation) {
|
|
332
|
+
this.beginTransaction();
|
|
333
|
+
try {
|
|
334
|
+
return await operation();
|
|
335
|
+
} finally {
|
|
336
|
+
this.endTransaction();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
235
339
|
async undo() {
|
|
236
340
|
this.cancelPending();
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
239
|
-
this.
|
|
341
|
+
const committed = this.undoStack.at(-1);
|
|
342
|
+
if (!committed) return;
|
|
343
|
+
const current = this.compactState(this.getState());
|
|
344
|
+
const currentIsCommitted = current === committed;
|
|
345
|
+
if (currentIsCommitted && this.undoStack.length < 2) return;
|
|
346
|
+
const target = currentIsCommitted ? this.undoStack[this.undoStack.length - 2] : committed;
|
|
240
347
|
this.paused = true;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
348
|
+
try {
|
|
349
|
+
await this.restoreState(this.expandState(target));
|
|
350
|
+
if (currentIsCommitted) this.undoStack.pop();
|
|
351
|
+
this.redoStack.push(current);
|
|
352
|
+
this.trimToBudget();
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.events.emit("error", { message: "Failed to undo the last change", error });
|
|
355
|
+
throw error;
|
|
356
|
+
} finally {
|
|
357
|
+
this.paused = false;
|
|
358
|
+
this.emitChanged();
|
|
359
|
+
}
|
|
244
360
|
}
|
|
245
361
|
async redo() {
|
|
246
362
|
this.cancelPending();
|
|
247
|
-
const state = this.redoStack.
|
|
363
|
+
const state = this.redoStack.at(-1);
|
|
248
364
|
if (!state) return;
|
|
249
|
-
this.undoStack.push(this.getState());
|
|
250
365
|
this.paused = true;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
366
|
+
try {
|
|
367
|
+
await this.restoreState(this.expandState(state));
|
|
368
|
+
this.redoStack.pop();
|
|
369
|
+
if (this.undoStack.at(-1) !== state) this.undoStack.push(state);
|
|
370
|
+
this.trimToBudget();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
this.events.emit("error", { message: "Failed to redo the last change", error });
|
|
373
|
+
throw error;
|
|
374
|
+
} finally {
|
|
375
|
+
this.paused = false;
|
|
376
|
+
this.emitChanged();
|
|
377
|
+
}
|
|
254
378
|
}
|
|
255
379
|
/** True while a restore (undo/redo/deserialize) is in flight. Managers that
|
|
256
380
|
* react to canvas events should skip mutating history during this window. */
|
|
@@ -264,16 +388,22 @@ var HistoryManager = class {
|
|
|
264
388
|
this.paused = false;
|
|
265
389
|
}
|
|
266
390
|
canUndo() {
|
|
267
|
-
return this.undoStack.length >
|
|
391
|
+
return this.undoStack.length > 1 || this.debounceTimer !== null;
|
|
268
392
|
}
|
|
269
393
|
canRedo() {
|
|
270
394
|
return this.redoStack.length > 0;
|
|
271
395
|
}
|
|
272
396
|
clear() {
|
|
397
|
+
this.cancelPending();
|
|
273
398
|
this.undoStack = [];
|
|
274
399
|
this.redoStack = [];
|
|
400
|
+
this.assets.clear();
|
|
401
|
+
this.assetIds.clear();
|
|
275
402
|
this.emitChanged();
|
|
276
403
|
}
|
|
404
|
+
getSnapshotBytes() {
|
|
405
|
+
return this.snapshotBytes();
|
|
406
|
+
}
|
|
277
407
|
cancelPending() {
|
|
278
408
|
if (this.debounceTimer) {
|
|
279
409
|
clearTimeout(this.debounceTimer);
|
|
@@ -281,9 +411,13 @@ var HistoryManager = class {
|
|
|
281
411
|
}
|
|
282
412
|
}
|
|
283
413
|
dispose() {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
414
|
+
this.cancelPending();
|
|
415
|
+
this.transactionDepth = 0;
|
|
416
|
+
this.transactionDirty = false;
|
|
417
|
+
this.undoStack = [];
|
|
418
|
+
this.redoStack = [];
|
|
419
|
+
this.assets.clear();
|
|
420
|
+
this.assetIds.clear();
|
|
287
421
|
}
|
|
288
422
|
emitChanged() {
|
|
289
423
|
this.events.emit("history:changed", {
|
|
@@ -291,6 +425,94 @@ var HistoryManager = class {
|
|
|
291
425
|
canRedo: this.canRedo()
|
|
292
426
|
});
|
|
293
427
|
}
|
|
428
|
+
snapshotBytes() {
|
|
429
|
+
const stackBytes = [...this.undoStack, ...this.redoStack].reduce(
|
|
430
|
+
(total, state) => total + state.length * 2,
|
|
431
|
+
0
|
|
432
|
+
);
|
|
433
|
+
const assetBytes = [...this.assets.values()].reduce(
|
|
434
|
+
(total, asset) => total + asset.length * 2,
|
|
435
|
+
0
|
|
436
|
+
);
|
|
437
|
+
return stackBytes + assetBytes;
|
|
438
|
+
}
|
|
439
|
+
trimToBudget() {
|
|
440
|
+
this.pruneAssets();
|
|
441
|
+
while (this.undoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
442
|
+
this.undoStack.shift();
|
|
443
|
+
this.pruneAssets();
|
|
444
|
+
}
|
|
445
|
+
while (this.redoStack.length > 1 && this.snapshotBytes() > this.maxBytes) {
|
|
446
|
+
this.redoStack.shift();
|
|
447
|
+
this.pruneAssets();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* History is internal and can content-address large raster strings without
|
|
452
|
+
* changing the public EditorState wire format. Unchanged images are retained
|
|
453
|
+
* once even when dozens of snapshots reference them.
|
|
454
|
+
*/
|
|
455
|
+
compactState(state) {
|
|
456
|
+
let parsed;
|
|
457
|
+
try {
|
|
458
|
+
parsed = JSON.parse(state);
|
|
459
|
+
} catch {
|
|
460
|
+
return state;
|
|
461
|
+
}
|
|
462
|
+
const visit = (value) => {
|
|
463
|
+
if (typeof value === "string" && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)) {
|
|
464
|
+
let id = this.assetIds.get(value);
|
|
465
|
+
if (!id) {
|
|
466
|
+
id = `a${this.nextAssetId++}`;
|
|
467
|
+
this.assetIds.set(value, id);
|
|
468
|
+
this.assets.set(id, value);
|
|
469
|
+
}
|
|
470
|
+
return { [_HistoryManager.ASSET_KEY]: id };
|
|
471
|
+
}
|
|
472
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
473
|
+
if (!value || typeof value !== "object") return value;
|
|
474
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, visit(entry)]));
|
|
475
|
+
};
|
|
476
|
+
return JSON.stringify(visit(parsed));
|
|
477
|
+
}
|
|
478
|
+
expandState(state) {
|
|
479
|
+
let parsed;
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(state);
|
|
482
|
+
} catch {
|
|
483
|
+
return state;
|
|
484
|
+
}
|
|
485
|
+
const visit = (value) => {
|
|
486
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
487
|
+
if (!value || typeof value !== "object") return value;
|
|
488
|
+
const record = value;
|
|
489
|
+
const id = record[_HistoryManager.ASSET_KEY];
|
|
490
|
+
if (typeof id === "string" && Object.keys(record).length === 1) {
|
|
491
|
+
const asset = this.assets.get(id);
|
|
492
|
+
if (!asset) throw new Error(`Missing history raster asset: ${id}`);
|
|
493
|
+
return asset;
|
|
494
|
+
}
|
|
495
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, visit(entry)]));
|
|
496
|
+
};
|
|
497
|
+
return JSON.stringify(visit(parsed));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Runs on every commit, so it scans for the serialized reference marker
|
|
501
|
+
* instead of re-parsing every snapshot — parsing multi-megabyte raster states
|
|
502
|
+
* per save would cost more than the retention it reclaims. `JSON.stringify`
|
|
503
|
+
* emits the marker verbatim; the same text inside user data is escaped and
|
|
504
|
+
* therefore cannot match.
|
|
505
|
+
*/
|
|
506
|
+
pruneAssets() {
|
|
507
|
+
if (this.assets.size === 0) return;
|
|
508
|
+
const states = [...this.undoStack, ...this.redoStack];
|
|
509
|
+
for (const [id, asset] of this.assets) {
|
|
510
|
+
const marker = `"${_HistoryManager.ASSET_KEY}":"${id}"`;
|
|
511
|
+
if (states.some((state) => state.includes(marker))) continue;
|
|
512
|
+
this.assets.delete(id);
|
|
513
|
+
this.assetIds.delete(asset);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
294
516
|
};
|
|
295
517
|
|
|
296
518
|
// src/snapping.ts
|
|
@@ -510,6 +732,10 @@ var CropController = class {
|
|
|
510
732
|
top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
|
|
511
733
|
});
|
|
512
734
|
image.setCoords();
|
|
735
|
+
if (s.prevAngle) {
|
|
736
|
+
image.rotate(s.prevAngle);
|
|
737
|
+
image.setCoords();
|
|
738
|
+
}
|
|
513
739
|
this.finish();
|
|
514
740
|
this.history.save();
|
|
515
741
|
}
|
|
@@ -544,14 +770,18 @@ var STROKE2 = "#22c55e";
|
|
|
544
770
|
import { util } from "fabric";
|
|
545
771
|
var MAX_TILES_PER_AXIS = 200;
|
|
546
772
|
var PatternManager = class {
|
|
547
|
-
constructor(canvas, layers, history) {
|
|
773
|
+
constructor(canvas, layers, history, events, sourceResolver) {
|
|
548
774
|
this.canvas = canvas;
|
|
549
775
|
this.layers = layers;
|
|
550
776
|
this.history = history;
|
|
777
|
+
this.events = events;
|
|
778
|
+
this.sourceResolver = sourceResolver;
|
|
551
779
|
}
|
|
552
780
|
canvas;
|
|
553
781
|
layers;
|
|
554
782
|
history;
|
|
783
|
+
events;
|
|
784
|
+
sourceResolver;
|
|
555
785
|
// Per-layer task chain. apply()/disable() both await an async setSrc on the
|
|
556
786
|
// same fabric image; running two concurrently lets their setSrc resolutions
|
|
557
787
|
// interleave (wrong image installed, original lost). Serialising per layer
|
|
@@ -599,6 +829,9 @@ var PatternManager = class {
|
|
|
599
829
|
throw err;
|
|
600
830
|
}
|
|
601
831
|
this.history.save();
|
|
832
|
+
}).catch((error) => {
|
|
833
|
+
this.events.emit("error", { message: "Failed to apply image pattern", error });
|
|
834
|
+
throw error;
|
|
602
835
|
});
|
|
603
836
|
}
|
|
604
837
|
/**
|
|
@@ -654,6 +887,9 @@ var PatternManager = class {
|
|
|
654
887
|
delete layer.meta.pattern;
|
|
655
888
|
this.canvas.requestRenderAll();
|
|
656
889
|
this.history.save();
|
|
890
|
+
}).catch((error) => {
|
|
891
|
+
this.events.emit("error", { message: "Failed to clear image pattern", error });
|
|
892
|
+
throw error;
|
|
657
893
|
});
|
|
658
894
|
}
|
|
659
895
|
/** Run `task` after any in-flight work for this layer, regardless of outcome. */
|
|
@@ -683,7 +919,8 @@ var PatternManager = class {
|
|
|
683
919
|
cw,
|
|
684
920
|
ch,
|
|
685
921
|
tileW,
|
|
686
|
-
tileH
|
|
922
|
+
tileH,
|
|
923
|
+
this.sourceResolver
|
|
687
924
|
);
|
|
688
925
|
await image.setSrc(dataUrl);
|
|
689
926
|
image.set({
|
|
@@ -754,14 +991,23 @@ function elementToDataURL(image) {
|
|
|
754
991
|
}
|
|
755
992
|
var IMAGE_CACHE_MAX = 16;
|
|
756
993
|
var imageCache = /* @__PURE__ */ new Map();
|
|
757
|
-
function
|
|
994
|
+
function loadPatternImage(src, resolver) {
|
|
758
995
|
const cached = imageCache.get(src);
|
|
759
996
|
if (cached) {
|
|
760
997
|
imageCache.delete(src);
|
|
761
998
|
imageCache.set(src, cached);
|
|
762
999
|
return cached;
|
|
763
1000
|
}
|
|
764
|
-
const promise = decodeImage(src)
|
|
1001
|
+
const promise = decodeImage(src).catch(async (originalError) => {
|
|
1002
|
+
if (!resolver) throw originalError;
|
|
1003
|
+
const resolved = await resolver(src);
|
|
1004
|
+
if (!resolved || resolved === src) {
|
|
1005
|
+
throw new Error("Pattern source resolver did not return a usable alternate URL", {
|
|
1006
|
+
cause: originalError
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return decodeImage(resolved);
|
|
1010
|
+
});
|
|
765
1011
|
promise.catch(() => {
|
|
766
1012
|
if (imageCache.get(src) === promise) imageCache.delete(src);
|
|
767
1013
|
});
|
|
@@ -784,8 +1030,8 @@ function decodeImage(src) {
|
|
|
784
1030
|
function clearPatternImageCache() {
|
|
785
1031
|
imageCache.clear();
|
|
786
1032
|
}
|
|
787
|
-
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
|
|
788
|
-
const img = await
|
|
1033
|
+
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
|
|
1034
|
+
const img = await loadPatternImage(src, sourceResolver);
|
|
789
1035
|
const off = document.createElement("canvas");
|
|
790
1036
|
off.width = Math.max(1, Math.round(targetW));
|
|
791
1037
|
off.height = Math.max(1, Math.round(targetH));
|
|
@@ -845,16 +1091,18 @@ function mod2(n) {
|
|
|
845
1091
|
// src/utils/units.ts
|
|
846
1092
|
var MM_PER_INCH = 25.4;
|
|
847
1093
|
var UnitConverter = class {
|
|
1094
|
+
unit;
|
|
1095
|
+
dpi;
|
|
848
1096
|
constructor(unit = "px", dpi = 72) {
|
|
849
1097
|
this.unit = unit;
|
|
850
|
-
this.dpi =
|
|
1098
|
+
this.dpi = 72;
|
|
1099
|
+
this.setDpi(dpi);
|
|
851
1100
|
}
|
|
852
|
-
unit;
|
|
853
|
-
dpi;
|
|
854
1101
|
setUnit(unit) {
|
|
855
1102
|
this.unit = unit;
|
|
856
1103
|
}
|
|
857
1104
|
setDpi(dpi) {
|
|
1105
|
+
if (!Number.isFinite(dpi) || dpi <= 0) throw new Error("DPI must be a positive number");
|
|
858
1106
|
this.dpi = dpi;
|
|
859
1107
|
}
|
|
860
1108
|
getUnit() {
|
|
@@ -889,38 +1137,92 @@ var UnitConverter = class {
|
|
|
889
1137
|
|
|
890
1138
|
// src/serialization.ts
|
|
891
1139
|
import { util as util2 } from "fabric";
|
|
892
|
-
|
|
1140
|
+
|
|
1141
|
+
// src/utils/color.ts
|
|
1142
|
+
var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
|
|
1143
|
+
function isCssColor(value, allowEmpty = false) {
|
|
1144
|
+
if (typeof value !== "string") return false;
|
|
1145
|
+
const trimmed = value.trim();
|
|
1146
|
+
if (!trimmed) return allowEmpty;
|
|
1147
|
+
return CSS_COLOR.test(trimmed);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/serialization.ts
|
|
1151
|
+
var VERSION = "2.0.0";
|
|
893
1152
|
function serializeEditor(editor) {
|
|
894
1153
|
return {
|
|
895
1154
|
version: VERSION,
|
|
896
1155
|
canvas: {
|
|
897
1156
|
width: editor.canvas.getWidth(),
|
|
898
|
-
height: editor.canvas.getHeight()
|
|
1157
|
+
height: editor.canvas.getHeight(),
|
|
1158
|
+
unit: editor.units.getUnit(),
|
|
1159
|
+
dpi: editor.units.getDpi()
|
|
899
1160
|
},
|
|
900
1161
|
layers: editor.layers.getAll().map((layer) => layer.serialize()),
|
|
901
1162
|
// The configured design background, not the live canvas value (which is
|
|
902
1163
|
// forced transparent while a mockup preview is active).
|
|
903
1164
|
background: editor.getDesignBackground(),
|
|
1165
|
+
backgroundImage: editor.getDesignBackgroundImage() ? editor.getDesignBackgroundImage().toObject() : null,
|
|
1166
|
+
backgroundImageOptions: editor.getBackgroundImageOptions(),
|
|
904
1167
|
mockup: editor.getMockup()
|
|
905
1168
|
};
|
|
906
1169
|
}
|
|
907
1170
|
async function deserializeEditor(editor, state) {
|
|
1171
|
+
if (!state || !state.canvas || !Array.isArray(state.layers)) {
|
|
1172
|
+
throw new Error("Invalid editor state");
|
|
1173
|
+
}
|
|
1174
|
+
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)) {
|
|
1175
|
+
throw new Error("Invalid editor canvas settings");
|
|
1176
|
+
}
|
|
1177
|
+
const major = Number.parseInt(state.version?.split(".")[0] ?? "1", 10);
|
|
1178
|
+
if (!Number.isFinite(major) || major > 2) {
|
|
1179
|
+
throw new Error(`Unsupported editor state version: ${state.version}`);
|
|
1180
|
+
}
|
|
1181
|
+
if (state.background !== void 0 && !isCssColor(state.background, true)) {
|
|
1182
|
+
throw new Error("Invalid editor background color");
|
|
1183
|
+
}
|
|
1184
|
+
const staged = await Promise.all(
|
|
1185
|
+
state.layers.map(async (serialized) => {
|
|
1186
|
+
const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
|
|
1187
|
+
if (!fabricObject) {
|
|
1188
|
+
const source = serialized.fabricObject.src;
|
|
1189
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1190
|
+
throw new Error(`Failed to restore expired object URL: ${source}`);
|
|
1191
|
+
}
|
|
1192
|
+
throw new Error(`Failed to restore layer: ${serialized.id}`);
|
|
1193
|
+
}
|
|
1194
|
+
return { serialized, fabricObject };
|
|
1195
|
+
})
|
|
1196
|
+
);
|
|
1197
|
+
const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
|
|
1198
|
+
if (state.backgroundImage && !stagedBackground) {
|
|
1199
|
+
const source = state.backgroundImage.src;
|
|
1200
|
+
if (typeof source === "string" && source.startsWith("blob:")) {
|
|
1201
|
+
throw new Error(`Failed to restore expired background object URL: ${source}`);
|
|
1202
|
+
}
|
|
1203
|
+
throw new Error("Failed to restore background image");
|
|
1204
|
+
}
|
|
1205
|
+
editor.crop.cancel();
|
|
1206
|
+
editor.masks.detach();
|
|
908
1207
|
editor.layers.clear();
|
|
1208
|
+
if (state.canvas.unit) editor.units.setUnit(state.canvas.unit);
|
|
1209
|
+
if (state.canvas.dpi !== void 0) editor.units.setDpi(state.canvas.dpi);
|
|
909
1210
|
editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
|
|
910
|
-
if (state.background) {
|
|
1211
|
+
if (state.background !== void 0) {
|
|
911
1212
|
editor.setBackground(state.background);
|
|
912
1213
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1214
|
+
editor.setBackgroundImageObject(
|
|
1215
|
+
stagedBackground ?? null,
|
|
1216
|
+
false,
|
|
1217
|
+
state.backgroundImageOptions ?? null
|
|
1218
|
+
);
|
|
1219
|
+
editor.setMockup(state.mockup ?? null);
|
|
1220
|
+
for (const item of staged) {
|
|
1221
|
+
restoreLayer(editor, item.serialized, item.fabricObject);
|
|
918
1222
|
}
|
|
919
1223
|
editor.canvas.requestRenderAll();
|
|
920
1224
|
}
|
|
921
|
-
|
|
922
|
-
const objects = await util2.enlivenObjects([serialized.fabricObject]);
|
|
923
|
-
const fabricObject = objects[0];
|
|
1225
|
+
function restoreLayer(editor, serialized, fabricObject) {
|
|
924
1226
|
const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
|
|
925
1227
|
if (serialized.meta) {
|
|
926
1228
|
layer.meta = serialized.meta;
|
|
@@ -937,27 +1239,583 @@ async function restoreLayer(editor, serialized) {
|
|
|
937
1239
|
return layer;
|
|
938
1240
|
}
|
|
939
1241
|
|
|
940
|
-
// src/
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1242
|
+
// src/utils/svg.ts
|
|
1243
|
+
function escapeXml(value) {
|
|
1244
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1245
|
+
}
|
|
1246
|
+
function sanitizeSvg(svg) {
|
|
1247
|
+
const document2 = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
1248
|
+
if (document2.querySelector("parsererror")) throw new Error("Invalid template SVG");
|
|
1249
|
+
document2.querySelectorAll("script, foreignObject, iframe, object, embed, link, style").forEach((node) => node.remove());
|
|
1250
|
+
document2.querySelectorAll("*").forEach((node) => {
|
|
1251
|
+
for (const attribute of [...node.attributes]) {
|
|
1252
|
+
const name = attribute.name.toLowerCase();
|
|
1253
|
+
const value = attribute.value.trim().toLowerCase();
|
|
1254
|
+
const isLink = name === "href" || name === "xlink:href" || name === "src";
|
|
1255
|
+
const safeLink = value.startsWith("#") || /^data:image\/(?:png|jpeg|webp|gif);base64,/.test(value);
|
|
1256
|
+
if (name.startsWith("on") || isLink && !safeLink || /url\s*\(/.test(value) && !/url\s*\(\s*['"]?#/.test(value) || /(?:javascript:|expression\s*\()/.test(value)) {
|
|
1257
|
+
node.removeAttribute(attribute.name);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
947
1260
|
});
|
|
948
|
-
|
|
949
|
-
return response.blob();
|
|
1261
|
+
return new XMLSerializer().serializeToString(document2.documentElement);
|
|
950
1262
|
}
|
|
951
|
-
|
|
952
|
-
|
|
1263
|
+
|
|
1264
|
+
// src/fonts.ts
|
|
1265
|
+
function fontSource(source) {
|
|
1266
|
+
return /^(?:url|local)\(/.test(source.trim()) ? source : `url(${JSON.stringify(source)})`;
|
|
1267
|
+
}
|
|
1268
|
+
function mimeForSource(source) {
|
|
1269
|
+
const path = source.split(/[?#]/)[0].toLowerCase();
|
|
1270
|
+
if (path.endsWith(".woff2")) return "font/woff2";
|
|
1271
|
+
if (path.endsWith(".woff")) return "font/woff";
|
|
1272
|
+
if (path.endsWith(".otf")) return "font/otf";
|
|
1273
|
+
return "font/ttf";
|
|
1274
|
+
}
|
|
1275
|
+
function arrayBufferToBase64(buffer) {
|
|
1276
|
+
const bytes = new Uint8Array(buffer);
|
|
1277
|
+
let binary = "";
|
|
1278
|
+
for (let index = 0; index < bytes.length; index += 32768) {
|
|
1279
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 32768));
|
|
1280
|
+
}
|
|
1281
|
+
return btoa(binary);
|
|
1282
|
+
}
|
|
1283
|
+
function cssString(value) {
|
|
1284
|
+
return JSON.stringify(value).replace(/[<>&]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
|
|
953
1285
|
}
|
|
954
|
-
|
|
955
|
-
|
|
1286
|
+
var SAFE_WEIGHT = /^(?:normal|bold|bolder|lighter|[1-9]\d{0,2}(?:\s+[1-9]\d{0,2})?)$/i;
|
|
1287
|
+
var SAFE_STYLE = /^(?:normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg)?)$/i;
|
|
1288
|
+
var SAFE_DISPLAY = /^(?:auto|block|swap|fallback|optional)$/i;
|
|
1289
|
+
function cssKeyword(value, pattern, fallback) {
|
|
1290
|
+
const trimmed = value?.trim();
|
|
1291
|
+
return trimmed && pattern.test(trimmed) ? trimmed : fallback;
|
|
956
1292
|
}
|
|
1293
|
+
function sourceUrl(source) {
|
|
1294
|
+
const trimmed = source.trim();
|
|
1295
|
+
if (trimmed.startsWith("data:")) return trimmed;
|
|
1296
|
+
const match = trimmed.match(/^url\(\s*(['"]?)(.*?)\1\s*\)/i);
|
|
1297
|
+
if (match) return match[2];
|
|
1298
|
+
if (/^local\(/i.test(trimmed)) return null;
|
|
1299
|
+
return trimmed;
|
|
1300
|
+
}
|
|
1301
|
+
function localName(source) {
|
|
1302
|
+
const match = source.trim().match(/^local\(\s*(['"]?)([^)"'{};]*)\1\s*\)$/i);
|
|
1303
|
+
return match ? match[2].trim() || null : null;
|
|
1304
|
+
}
|
|
1305
|
+
var FontRegistry = class {
|
|
1306
|
+
definitions = /* @__PURE__ */ new Map();
|
|
1307
|
+
loads = /* @__PURE__ */ new Map();
|
|
1308
|
+
register(definition) {
|
|
1309
|
+
if (!definition.family.trim() || !definition.source.trim()) {
|
|
1310
|
+
throw new Error("Font family and source are required");
|
|
1311
|
+
}
|
|
1312
|
+
this.definitions.set(definition.family, { ...definition });
|
|
1313
|
+
this.loads.delete(definition.family);
|
|
1314
|
+
}
|
|
1315
|
+
unregister(family) {
|
|
1316
|
+
this.loads.delete(family);
|
|
1317
|
+
return this.definitions.delete(family);
|
|
1318
|
+
}
|
|
1319
|
+
getAll() {
|
|
1320
|
+
return [...this.definitions.values()].map((definition) => ({ ...definition }));
|
|
1321
|
+
}
|
|
1322
|
+
load(family) {
|
|
1323
|
+
const cached = this.loads.get(family);
|
|
1324
|
+
if (cached) return cached;
|
|
1325
|
+
const definition = this.definitions.get(family);
|
|
1326
|
+
if (!definition) return Promise.reject(new Error(`Font is not registered: ${family}`));
|
|
1327
|
+
if (typeof FontFace === "undefined" || typeof document === "undefined") {
|
|
1328
|
+
return Promise.reject(new Error("Font loading requires a browser FontFace API"));
|
|
1329
|
+
}
|
|
1330
|
+
const promise = new FontFace(definition.family, fontSource(definition.source), {
|
|
1331
|
+
weight: definition.weight,
|
|
1332
|
+
style: definition.style,
|
|
1333
|
+
display: definition.display
|
|
1334
|
+
}).load().then((font) => {
|
|
1335
|
+
document.fonts.add(font);
|
|
1336
|
+
return font;
|
|
1337
|
+
});
|
|
1338
|
+
promise.catch(() => {
|
|
1339
|
+
if (this.loads.get(family) === promise) this.loads.delete(family);
|
|
1340
|
+
});
|
|
1341
|
+
this.loads.set(family, promise);
|
|
1342
|
+
return promise;
|
|
1343
|
+
}
|
|
1344
|
+
async ready() {
|
|
1345
|
+
if (this.definitions.size === 0) return;
|
|
1346
|
+
await Promise.all([...this.definitions.keys()].map((family) => this.load(family)));
|
|
1347
|
+
await document.fonts.ready;
|
|
1348
|
+
}
|
|
1349
|
+
async getEmbeddedCss() {
|
|
1350
|
+
const rules = await Promise.all(
|
|
1351
|
+
this.getAll().map(async (definition) => {
|
|
1352
|
+
const url = sourceUrl(definition.source);
|
|
1353
|
+
let cssSource;
|
|
1354
|
+
if (url && !url.startsWith("data:")) {
|
|
1355
|
+
const response = await fetch(url);
|
|
1356
|
+
if (!response.ok) throw new Error(`Failed to fetch font: ${url}`);
|
|
1357
|
+
const data = arrayBufferToBase64(await response.arrayBuffer());
|
|
1358
|
+
const mime = response.headers.get("content-type") || mimeForSource(url);
|
|
1359
|
+
cssSource = `url(${cssString(`data:${mime};base64,${data}`)})`;
|
|
1360
|
+
} else if (url) {
|
|
1361
|
+
cssSource = `url(${cssString(url)})`;
|
|
1362
|
+
} else {
|
|
1363
|
+
const name = localName(definition.source);
|
|
1364
|
+
if (!name) {
|
|
1365
|
+
throw new Error(`Unsupported font source for embedding: ${definition.family}`);
|
|
1366
|
+
}
|
|
1367
|
+
cssSource = `local(${cssString(name)})`;
|
|
1368
|
+
}
|
|
1369
|
+
const weight = cssKeyword(definition.weight, SAFE_WEIGHT, "normal");
|
|
1370
|
+
const style = cssKeyword(definition.style, SAFE_STYLE, "normal");
|
|
1371
|
+
const display = cssKeyword(definition.display, SAFE_DISPLAY, "swap");
|
|
1372
|
+
return `@font-face{font-family:${cssString(definition.family)};src:${cssSource};font-weight:${weight};font-style:${style};font-display:${display}}`;
|
|
1373
|
+
})
|
|
1374
|
+
);
|
|
1375
|
+
return rules.join("\n");
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// src/licensing.ts
|
|
1380
|
+
function domainMatches(hostname, pattern) {
|
|
1381
|
+
const host = hostname.toLowerCase();
|
|
1382
|
+
const expected = pattern.toLowerCase();
|
|
1383
|
+
if (expected.startsWith("*.")) {
|
|
1384
|
+
const suffix = expected.slice(1);
|
|
1385
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
1386
|
+
}
|
|
1387
|
+
return host === expected;
|
|
1388
|
+
}
|
|
1389
|
+
function isLocal(hostname) {
|
|
1390
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
|
|
1391
|
+
}
|
|
1392
|
+
var LicenseManager = class {
|
|
1393
|
+
constructor(config = {}) {
|
|
1394
|
+
this.config = config;
|
|
1395
|
+
const hostname = config.hostname ?? globalThis.location?.hostname ?? "localhost";
|
|
1396
|
+
const environment = config.environment ?? "development";
|
|
1397
|
+
if (environment !== "production" || isLocal(hostname)) {
|
|
1398
|
+
this.status = { state: "exempt", payload: null };
|
|
1399
|
+
this.readyPromise = Promise.resolve(this.status);
|
|
1400
|
+
} else if (!config.key) {
|
|
1401
|
+
this.status = { state: "community", payload: null };
|
|
1402
|
+
this.readyPromise = Promise.resolve(this.status);
|
|
1403
|
+
} else {
|
|
1404
|
+
this.status = { state: "checking", payload: null };
|
|
1405
|
+
this.readyPromise = this.validate(config.key, hostname);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
config;
|
|
1409
|
+
status;
|
|
1410
|
+
readyPromise;
|
|
1411
|
+
getStatus() {
|
|
1412
|
+
return this.status;
|
|
1413
|
+
}
|
|
1414
|
+
ready() {
|
|
1415
|
+
return this.readyPromise;
|
|
1416
|
+
}
|
|
1417
|
+
hasFeature(feature) {
|
|
1418
|
+
return this.status.state === "valid" && (this.status.payload.features ?? []).includes(feature);
|
|
1419
|
+
}
|
|
1420
|
+
track(name) {
|
|
1421
|
+
this.config.onUsage?.({
|
|
1422
|
+
name,
|
|
1423
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1424
|
+
licenseId: this.status.state === "valid" ? this.status.payload.id : void 0
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
async validate(key, hostname) {
|
|
1428
|
+
const payload = await this.config.verifyOffline?.(key) ?? null;
|
|
1429
|
+
if (!payload) return this.status = { state: "invalid", payload: null };
|
|
1430
|
+
if (payload.expiresAt && Date.parse(payload.expiresAt) < Date.now()) {
|
|
1431
|
+
return this.status = { state: "expired", payload };
|
|
1432
|
+
}
|
|
1433
|
+
if (!payload.domains.some((domain) => domainMatches(hostname, domain))) {
|
|
1434
|
+
return this.status = { state: "domain-mismatch", payload };
|
|
1435
|
+
}
|
|
1436
|
+
return this.status = { state: "valid", payload };
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
// src/project.ts
|
|
1441
|
+
var ProjectManager = class {
|
|
1442
|
+
constructor(editor) {
|
|
1443
|
+
this.editor = editor;
|
|
1444
|
+
const first = { id: generateId(), name: "Page 1", state: editor.toJSON() };
|
|
1445
|
+
this.pages = [first];
|
|
1446
|
+
this.activePageId = first.id;
|
|
1447
|
+
}
|
|
1448
|
+
editor;
|
|
1449
|
+
pages;
|
|
1450
|
+
activePageId;
|
|
1451
|
+
getAll() {
|
|
1452
|
+
return this.pages.map(({ id, name }) => ({ id, name }));
|
|
1453
|
+
}
|
|
1454
|
+
getActivePageId() {
|
|
1455
|
+
return this.activePageId;
|
|
1456
|
+
}
|
|
1457
|
+
add(name = `Page ${this.pages.length + 1}`, cloneCurrent = false) {
|
|
1458
|
+
this.saveCurrent();
|
|
1459
|
+
const source = structuredClone(this.editor.toJSON());
|
|
1460
|
+
const state = cloneCurrent ? source : this.blankState(source);
|
|
1461
|
+
const page = { id: generateId(), name, state };
|
|
1462
|
+
this.pages.push(page);
|
|
1463
|
+
this.emitChanged();
|
|
1464
|
+
return page.id;
|
|
1465
|
+
}
|
|
1466
|
+
async switchTo(id) {
|
|
1467
|
+
if (id === this.activePageId) return true;
|
|
1468
|
+
const page = this.pages.find((candidate) => candidate.id === id);
|
|
1469
|
+
if (!page) return false;
|
|
1470
|
+
this.saveCurrent();
|
|
1471
|
+
await this.editor.fromJSON(structuredClone(page.state));
|
|
1472
|
+
this.activePageId = id;
|
|
1473
|
+
this.emitChanged();
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
async duplicate(id) {
|
|
1477
|
+
this.saveCurrent();
|
|
1478
|
+
const source = this.pages.find((page2) => page2.id === id);
|
|
1479
|
+
if (!source) return null;
|
|
1480
|
+
const page = {
|
|
1481
|
+
id: generateId(),
|
|
1482
|
+
name: `${source.name} copy`,
|
|
1483
|
+
state: structuredClone(source.state)
|
|
1484
|
+
};
|
|
1485
|
+
const index = this.pages.indexOf(source);
|
|
1486
|
+
this.pages.splice(index + 1, 0, page);
|
|
1487
|
+
this.emitChanged();
|
|
1488
|
+
return page.id;
|
|
1489
|
+
}
|
|
1490
|
+
async remove(id) {
|
|
1491
|
+
if (this.pages.length === 1) return false;
|
|
1492
|
+
const index = this.pages.findIndex((page) => page.id === id);
|
|
1493
|
+
if (index < 0) return false;
|
|
1494
|
+
if (id === this.activePageId) {
|
|
1495
|
+
const next = this.pages[index + 1] ?? this.pages[index - 1];
|
|
1496
|
+
await this.editor.fromJSON(structuredClone(next.state));
|
|
1497
|
+
this.activePageId = next.id;
|
|
1498
|
+
}
|
|
1499
|
+
this.pages.splice(index, 1);
|
|
1500
|
+
this.emitChanged();
|
|
1501
|
+
return true;
|
|
1502
|
+
}
|
|
1503
|
+
rename(id, name) {
|
|
1504
|
+
const page = this.pages.find((candidate) => candidate.id === id);
|
|
1505
|
+
const trimmed = name.trim();
|
|
1506
|
+
if (!page || !trimmed || page.name === trimmed) return false;
|
|
1507
|
+
page.name = trimmed;
|
|
1508
|
+
this.emitChanged();
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
reorder(id, newIndex) {
|
|
1512
|
+
const index = this.pages.findIndex((page2) => page2.id === id);
|
|
1513
|
+
if (index < 0 || !Number.isFinite(newIndex)) return false;
|
|
1514
|
+
const target = Math.max(0, Math.min(this.pages.length - 1, Math.round(newIndex)));
|
|
1515
|
+
if (target === index) return false;
|
|
1516
|
+
const [page] = this.pages.splice(index, 1);
|
|
1517
|
+
this.pages.splice(target, 0, page);
|
|
1518
|
+
this.emitChanged();
|
|
1519
|
+
return true;
|
|
1520
|
+
}
|
|
1521
|
+
toJSON() {
|
|
1522
|
+
this.saveCurrent();
|
|
1523
|
+
return {
|
|
1524
|
+
version: "1.0.0",
|
|
1525
|
+
activePageId: this.activePageId,
|
|
1526
|
+
pages: structuredClone(this.pages)
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
async fromJSON(project) {
|
|
1530
|
+
if (project?.version !== "1.0.0" || !Array.isArray(project.pages) || project.pages.length === 0 || !project.pages.some((page) => page.id === project.activePageId)) {
|
|
1531
|
+
throw new Error("Invalid project state");
|
|
1532
|
+
}
|
|
1533
|
+
const pages = structuredClone(project.pages);
|
|
1534
|
+
const active = pages.find((page) => page.id === project.activePageId);
|
|
1535
|
+
await this.editor.fromJSON(structuredClone(active.state));
|
|
1536
|
+
this.pages = pages;
|
|
1537
|
+
this.activePageId = active.id;
|
|
1538
|
+
this.emitChanged();
|
|
1539
|
+
}
|
|
1540
|
+
saveCurrent() {
|
|
1541
|
+
const page = this.pages.find((candidate) => candidate.id === this.activePageId);
|
|
1542
|
+
if (page) page.state = structuredClone(this.editor.toJSON());
|
|
1543
|
+
}
|
|
1544
|
+
blankState(source) {
|
|
1545
|
+
return { ...source, layers: [], mockup: null };
|
|
1546
|
+
}
|
|
1547
|
+
emitChanged() {
|
|
1548
|
+
this.editor.events.emit("project:changed", {
|
|
1549
|
+
activePageId: this.activePageId,
|
|
1550
|
+
pages: this.getAll()
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
// src/mask.ts
|
|
1556
|
+
import { FabricImage as FabricImage2 } from "fabric";
|
|
1557
|
+
var MaskRefinementError = class extends Error {
|
|
1558
|
+
constructor(code, message, cause) {
|
|
1559
|
+
super(message);
|
|
1560
|
+
this.code = code;
|
|
1561
|
+
this.cause = cause;
|
|
1562
|
+
this.name = "MaskRefinementError";
|
|
1563
|
+
}
|
|
1564
|
+
code;
|
|
1565
|
+
cause;
|
|
1566
|
+
};
|
|
1567
|
+
var MaskController = class {
|
|
1568
|
+
constructor(editor) {
|
|
1569
|
+
this.editor = editor;
|
|
1570
|
+
}
|
|
1571
|
+
editor;
|
|
1572
|
+
backing = null;
|
|
1573
|
+
context = null;
|
|
1574
|
+
layerId = null;
|
|
1575
|
+
brush = null;
|
|
1576
|
+
previousPoint = null;
|
|
1577
|
+
strokeBackup = null;
|
|
1578
|
+
strokeStartedAt = null;
|
|
1579
|
+
lastInteractionLatencyMs = 0;
|
|
1580
|
+
refinement = null;
|
|
1581
|
+
disposed = false;
|
|
1582
|
+
async create(width = this.editor.canvas.getWidth(), height = this.editor.canvas.getHeight()) {
|
|
1583
|
+
this.assertActive();
|
|
1584
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
|
1585
|
+
throw new Error("Mask dimensions must be positive integers");
|
|
1586
|
+
}
|
|
1587
|
+
const backing = this.makeCanvas(width, height);
|
|
1588
|
+
const image = new FabricImage2(backing, {
|
|
1589
|
+
left: 0,
|
|
1590
|
+
top: 0,
|
|
1591
|
+
originX: "left",
|
|
1592
|
+
originY: "top",
|
|
1593
|
+
selectable: false
|
|
1594
|
+
});
|
|
1595
|
+
const layer = this.editor.layers.add("mask", image, "Mask");
|
|
1596
|
+
layer.meta.mask = { width, height, revision: 0 };
|
|
1597
|
+
this.editor.history.saveImmediate();
|
|
1598
|
+
this.attachBacking(layer.id, backing);
|
|
1599
|
+
return layer;
|
|
1600
|
+
}
|
|
1601
|
+
attach(layerId) {
|
|
1602
|
+
this.assertActive();
|
|
1603
|
+
this.cancelStroke();
|
|
1604
|
+
const layer = this.requireMask(layerId);
|
|
1605
|
+
const image = layer.fabricObject;
|
|
1606
|
+
const width = layer.meta.mask?.width ?? image.width ?? this.editor.canvas.getWidth();
|
|
1607
|
+
const height = layer.meta.mask?.height ?? image.height ?? this.editor.canvas.getHeight();
|
|
1608
|
+
const backing = this.makeCanvas(width, height);
|
|
1609
|
+
const context = backing.getContext("2d");
|
|
1610
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
1611
|
+
const element = image.getElement();
|
|
1612
|
+
if (element) context.drawImage(element, 0, 0, width, height);
|
|
1613
|
+
this.attachBacking(layerId, backing);
|
|
1614
|
+
}
|
|
1615
|
+
beginStroke(options) {
|
|
1616
|
+
this.assertActive();
|
|
1617
|
+
if (!this.context || !this.backing || !this.layerId) throw new Error("Attach a mask first");
|
|
1618
|
+
if (this.brush) throw new Error("A mask stroke is already active");
|
|
1619
|
+
if (!Number.isFinite(options.size) || options.size <= 0) {
|
|
1620
|
+
throw new Error("Mask brush size must be positive");
|
|
1621
|
+
}
|
|
1622
|
+
this.brush = { ...options, hardness: clamp(options.hardness, 0, 1) };
|
|
1623
|
+
this.previousPoint = null;
|
|
1624
|
+
this.strokeBackup = this.context.getImageData(0, 0, this.backing.width, this.backing.height);
|
|
1625
|
+
this.strokeStartedAt = performance.now();
|
|
1626
|
+
}
|
|
1627
|
+
addPoint(point) {
|
|
1628
|
+
if (!this.brush || !this.context || !this.backing || !this.layerId) {
|
|
1629
|
+
throw new Error("No active mask stroke");
|
|
1630
|
+
}
|
|
1631
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
|
1632
|
+
const previous = this.previousPoint ?? point;
|
|
1633
|
+
const distance = Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
1634
|
+
const step = Math.max(1, this.brush.size / 4);
|
|
1635
|
+
const samples = Math.max(1, Math.ceil(distance / step));
|
|
1636
|
+
for (let index = 0; index <= samples; index += 1) {
|
|
1637
|
+
const ratio = index / samples;
|
|
1638
|
+
this.drawDot({
|
|
1639
|
+
x: previous.x + (point.x - previous.x) * ratio,
|
|
1640
|
+
y: previous.y + (point.y - previous.y) * ratio
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
this.previousPoint = point;
|
|
1644
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1645
|
+
this.editor.canvas.requestRenderAll();
|
|
1646
|
+
}
|
|
1647
|
+
async endStroke() {
|
|
1648
|
+
if (!this.brush || !this.backing || !this.layerId) return;
|
|
1649
|
+
const layerId = this.layerId;
|
|
1650
|
+
this.brush = null;
|
|
1651
|
+
this.previousPoint = null;
|
|
1652
|
+
this.strokeBackup = null;
|
|
1653
|
+
const dataUrl = this.backing.toDataURL("image/png");
|
|
1654
|
+
await this.editor.history.transaction(async () => {
|
|
1655
|
+
await this.editor.replaceImageSource(layerId, dataUrl);
|
|
1656
|
+
const layer = this.requireMask(layerId);
|
|
1657
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1658
|
+
});
|
|
1659
|
+
this.attach(layerId);
|
|
1660
|
+
if (this.strokeStartedAt !== null) {
|
|
1661
|
+
this.lastInteractionLatencyMs = performance.now() - this.strokeStartedAt;
|
|
1662
|
+
this.strokeStartedAt = null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
cancelStroke() {
|
|
1666
|
+
if (this.strokeBackup && this.context && this.backing && this.layerId) {
|
|
1667
|
+
this.context.putImageData(this.strokeBackup, 0, 0);
|
|
1668
|
+
this.requireMask(this.layerId).fabricObject.setElement(this.backing);
|
|
1669
|
+
this.editor.canvas.requestRenderAll();
|
|
1670
|
+
}
|
|
1671
|
+
this.brush = null;
|
|
1672
|
+
this.previousPoint = null;
|
|
1673
|
+
this.strokeBackup = null;
|
|
1674
|
+
this.strokeStartedAt = null;
|
|
1675
|
+
}
|
|
1676
|
+
isStrokeActive() {
|
|
1677
|
+
return this.brush !== null;
|
|
1678
|
+
}
|
|
1679
|
+
activeLayerId() {
|
|
1680
|
+
return this.layerId;
|
|
1681
|
+
}
|
|
1682
|
+
detach(layerId) {
|
|
1683
|
+
if (layerId && this.layerId !== layerId) return;
|
|
1684
|
+
this.cancelStroke();
|
|
1685
|
+
this.cancelRefinement();
|
|
1686
|
+
this.backing = null;
|
|
1687
|
+
this.context = null;
|
|
1688
|
+
this.layerId = null;
|
|
1689
|
+
}
|
|
1690
|
+
async refine(layerId, provider, prompts, options = {}) {
|
|
1691
|
+
this.assertActive();
|
|
1692
|
+
const layer = this.editor.layers.get(layerId);
|
|
1693
|
+
if (!layer || layer.type !== "mask") {
|
|
1694
|
+
throw new MaskRefinementError("not-found", `Mask layer not found: ${layerId}`);
|
|
1695
|
+
}
|
|
1696
|
+
this.cancelRefinement();
|
|
1697
|
+
const controller = new AbortController();
|
|
1698
|
+
this.refinement = controller;
|
|
1699
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
1700
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1701
|
+
if (options.signal?.aborted) abort();
|
|
1702
|
+
try {
|
|
1703
|
+
const image = layer.fabricObject;
|
|
1704
|
+
const result = await provider.refine(
|
|
1705
|
+
{
|
|
1706
|
+
mask: image.getSrc(),
|
|
1707
|
+
width: layer.meta.mask?.width ?? image.width ?? 1,
|
|
1708
|
+
height: layer.meta.mask?.height ?? image.height ?? 1,
|
|
1709
|
+
prompts: structuredClone(prompts)
|
|
1710
|
+
},
|
|
1711
|
+
{ signal: controller.signal, onProgress: options.onProgress }
|
|
1712
|
+
);
|
|
1713
|
+
if (controller.signal.aborted)
|
|
1714
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled");
|
|
1715
|
+
if (!/^data:image\/(?:png|jpeg|webp);base64,/i.test(result.dataUrl)) {
|
|
1716
|
+
throw new MaskRefinementError(
|
|
1717
|
+
"invalid-result",
|
|
1718
|
+
"Mask refinement must return a base64 PNG, JPEG, or WebP data URL"
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
await this.editor.history.transaction(async () => {
|
|
1722
|
+
await this.editor.replaceImageSource(layerId, result.dataUrl);
|
|
1723
|
+
if (layer.meta.mask) layer.meta.mask.revision += 1;
|
|
1724
|
+
});
|
|
1725
|
+
this.attach(layerId);
|
|
1726
|
+
return result;
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
if (error instanceof MaskRefinementError) throw error;
|
|
1729
|
+
if (controller.signal.aborted) {
|
|
1730
|
+
throw new MaskRefinementError("cancelled", "Mask refinement cancelled", error);
|
|
1731
|
+
}
|
|
1732
|
+
throw new MaskRefinementError("provider", "Mask refinement provider failed", error);
|
|
1733
|
+
} finally {
|
|
1734
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1735
|
+
if (this.refinement === controller) this.refinement = null;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
cancelRefinement() {
|
|
1739
|
+
this.refinement?.abort(new DOMException("Cancelled", "AbortError"));
|
|
1740
|
+
this.refinement = null;
|
|
1741
|
+
}
|
|
1742
|
+
measure() {
|
|
1743
|
+
if (!this.backing) return null;
|
|
1744
|
+
const started = performance.now();
|
|
1745
|
+
this.context?.getImageData(0, 0, 1, 1);
|
|
1746
|
+
const backingBytes = this.backing.width * this.backing.height * 4;
|
|
1747
|
+
const strokeBackupBytes = this.strokeBackup ? backingBytes : 0;
|
|
1748
|
+
const memory = performance;
|
|
1749
|
+
return {
|
|
1750
|
+
width: this.backing.width,
|
|
1751
|
+
height: this.backing.height,
|
|
1752
|
+
backingBytes,
|
|
1753
|
+
strokeBackupBytes,
|
|
1754
|
+
// The backing store and rollback ImageData dominate interactive mask
|
|
1755
|
+
// memory. Encoded historical rasters are reported separately below.
|
|
1756
|
+
estimatedPeakBytes: backingBytes + strokeBackupBytes,
|
|
1757
|
+
historyBytes: this.editor.history.getSnapshotBytes(),
|
|
1758
|
+
interactionLatencyMs: this.lastInteractionLatencyMs,
|
|
1759
|
+
...typeof memory.memory?.usedJSHeapSize === "number" ? { usedJsHeapBytes: memory.memory.usedJSHeapSize } : {},
|
|
1760
|
+
elapsedMs: performance.now() - started
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
dispose() {
|
|
1764
|
+
this.detach();
|
|
1765
|
+
this.disposed = true;
|
|
1766
|
+
}
|
|
1767
|
+
drawDot(point) {
|
|
1768
|
+
const context = this.context;
|
|
1769
|
+
const brush = this.brush;
|
|
1770
|
+
const radius = brush.size / 2;
|
|
1771
|
+
context.save();
|
|
1772
|
+
context.globalCompositeOperation = brush.mode === "subtract" ? "destination-out" : "source-over";
|
|
1773
|
+
const gradient = context.createRadialGradient(
|
|
1774
|
+
point.x,
|
|
1775
|
+
point.y,
|
|
1776
|
+
radius * brush.hardness,
|
|
1777
|
+
point.x,
|
|
1778
|
+
point.y,
|
|
1779
|
+
radius
|
|
1780
|
+
);
|
|
1781
|
+
const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
|
|
1782
|
+
gradient.addColorStop(0, color);
|
|
1783
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
|
1784
|
+
context.fillStyle = gradient;
|
|
1785
|
+
context.beginPath();
|
|
1786
|
+
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
|
1787
|
+
context.fill();
|
|
1788
|
+
context.restore();
|
|
1789
|
+
}
|
|
1790
|
+
makeCanvas(width, height) {
|
|
1791
|
+
const canvas = this.editor.canvas.lowerCanvasEl.ownerDocument.createElement("canvas");
|
|
1792
|
+
canvas.width = width;
|
|
1793
|
+
canvas.height = height;
|
|
1794
|
+
return canvas;
|
|
1795
|
+
}
|
|
1796
|
+
attachBacking(layerId, backing) {
|
|
1797
|
+
const context = backing.getContext("2d");
|
|
1798
|
+
if (!context) throw new Error("2D mask context is unavailable");
|
|
1799
|
+
this.layerId = layerId;
|
|
1800
|
+
this.backing = backing;
|
|
1801
|
+
this.context = context;
|
|
1802
|
+
}
|
|
1803
|
+
requireMask(layerId) {
|
|
1804
|
+
const layer = this.editor.layers.get(layerId);
|
|
1805
|
+
if (!layer || layer.type !== "mask") throw new Error(`Mask layer not found: ${layerId}`);
|
|
1806
|
+
return layer;
|
|
1807
|
+
}
|
|
1808
|
+
assertActive() {
|
|
1809
|
+
if (this.disposed) throw new Error("Mask controller has been disposed");
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
957
1812
|
|
|
958
1813
|
// src/editor.ts
|
|
959
1814
|
var MIN_ZOOM = 0.1;
|
|
960
1815
|
var MAX_ZOOM = 8;
|
|
1816
|
+
function isTaintedCanvasError(error) {
|
|
1817
|
+
return error instanceof DOMException && error.name === "SecurityError" || error instanceof Error && /taint|cross-origin|insecure/i.test(error.message);
|
|
1818
|
+
}
|
|
961
1819
|
var CanvasEditor = class {
|
|
962
1820
|
canvas;
|
|
963
1821
|
layers;
|
|
@@ -967,6 +1825,10 @@ var CanvasEditor = class {
|
|
|
967
1825
|
snapping;
|
|
968
1826
|
crop;
|
|
969
1827
|
patterns;
|
|
1828
|
+
fonts;
|
|
1829
|
+
licensing;
|
|
1830
|
+
pages;
|
|
1831
|
+
masks;
|
|
970
1832
|
fileAdapter;
|
|
971
1833
|
imageProvider;
|
|
972
1834
|
zoomLevel = 1;
|
|
@@ -975,8 +1837,13 @@ var CanvasEditor = class {
|
|
|
975
1837
|
// transparent while a mockup preview is shown, so this is the source of truth
|
|
976
1838
|
// for serialization and export — not the (possibly transient) canvas value.
|
|
977
1839
|
designBackground;
|
|
1840
|
+
designBackgroundImage = null;
|
|
1841
|
+
backgroundImageOptions = null;
|
|
978
1842
|
constructor(canvasElement, config) {
|
|
979
1843
|
this.events = new EventEmitter();
|
|
1844
|
+
this.fonts = new FontRegistry();
|
|
1845
|
+
config.fonts?.forEach((font) => this.fonts.register(font));
|
|
1846
|
+
this.licensing = new LicenseManager(config.license);
|
|
980
1847
|
this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
|
|
981
1848
|
const widthPx = this.units.toPixels(config.width);
|
|
982
1849
|
const heightPx = this.units.toPixels(config.height);
|
|
@@ -999,18 +1866,76 @@ var CanvasEditor = class {
|
|
|
999
1866
|
},
|
|
1000
1867
|
events: this.events
|
|
1001
1868
|
});
|
|
1869
|
+
this.layers.setHistoryCallback(() => this.history.save());
|
|
1002
1870
|
this.snapping = new SnapManager(this.canvas, this.events);
|
|
1003
1871
|
this.crop = new CropController(this.canvas, this.history, this.events);
|
|
1004
|
-
this.patterns = new PatternManager(
|
|
1872
|
+
this.patterns = new PatternManager(
|
|
1873
|
+
this.canvas,
|
|
1874
|
+
this.layers,
|
|
1875
|
+
this.history,
|
|
1876
|
+
this.events,
|
|
1877
|
+
config.patternSourceResolver
|
|
1878
|
+
);
|
|
1005
1879
|
this.setupCanvasEvents();
|
|
1006
1880
|
this.history.saveImmediate();
|
|
1881
|
+
this.pages = new ProjectManager(this);
|
|
1882
|
+
this.masks = new MaskController(this);
|
|
1007
1883
|
}
|
|
1008
1884
|
// ─── Layer Operations ────────────────────────────────
|
|
1009
1885
|
async addImage(url, options) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1886
|
+
try {
|
|
1887
|
+
const img = await FabricImage3.fromURL(
|
|
1888
|
+
url,
|
|
1889
|
+
{},
|
|
1890
|
+
{ originX: "left", originY: "top", ...options }
|
|
1891
|
+
);
|
|
1892
|
+
const layer = this.layers.add("image", img);
|
|
1893
|
+
this.history.save();
|
|
1894
|
+
return layer;
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
this.events.emit("error", { message: "Failed to add image", error });
|
|
1897
|
+
throw error;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
/** Replace an image source without changing its layer identity or visual transform. */
|
|
1901
|
+
async replaceImageSource(layerId, url) {
|
|
1902
|
+
const layer = this.layers.get(layerId);
|
|
1903
|
+
if (!layer || layer.type !== "image" && layer.type !== "mask") {
|
|
1904
|
+
throw new Error(`Image or mask layer not found: ${layerId}`);
|
|
1905
|
+
}
|
|
1906
|
+
if (layer.meta.pattern) {
|
|
1907
|
+
throw new Error("Clear the pattern before replacing the image source");
|
|
1908
|
+
}
|
|
1909
|
+
if (this.crop.activeLayerId() === layerId) this.crop.cancel();
|
|
1910
|
+
const previous = layer.fabricObject;
|
|
1911
|
+
try {
|
|
1912
|
+
const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
|
|
1913
|
+
replacement.set({
|
|
1914
|
+
left: previous.left,
|
|
1915
|
+
top: previous.top,
|
|
1916
|
+
originX: previous.originX,
|
|
1917
|
+
originY: previous.originY,
|
|
1918
|
+
width: previous.width,
|
|
1919
|
+
height: previous.height,
|
|
1920
|
+
cropX: previous.cropX,
|
|
1921
|
+
cropY: previous.cropY,
|
|
1922
|
+
scaleX: previous.scaleX,
|
|
1923
|
+
scaleY: previous.scaleY,
|
|
1924
|
+
angle: previous.angle,
|
|
1925
|
+
skewX: previous.skewX,
|
|
1926
|
+
skewY: previous.skewY,
|
|
1927
|
+
flipX: previous.flipX,
|
|
1928
|
+
flipY: previous.flipY
|
|
1929
|
+
});
|
|
1930
|
+
replacement.filters = [...previous.filters];
|
|
1931
|
+
replacement.applyFilters();
|
|
1932
|
+
replacement.setCoords();
|
|
1933
|
+
this.layers.replaceObject(layerId, replacement);
|
|
1934
|
+
return layer;
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
this.events.emit("error", { message: "Failed to replace image source", error });
|
|
1937
|
+
throw error;
|
|
1938
|
+
}
|
|
1014
1939
|
}
|
|
1015
1940
|
addText(text, options) {
|
|
1016
1941
|
const textbox = new Textbox(text, {
|
|
@@ -1033,27 +1958,45 @@ var CanvasEditor = class {
|
|
|
1033
1958
|
this.history.save();
|
|
1034
1959
|
return layer;
|
|
1035
1960
|
}
|
|
1036
|
-
async addTemplate(template,
|
|
1037
|
-
const
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1961
|
+
async addTemplate(template, params) {
|
|
1962
|
+
const values = {};
|
|
1963
|
+
for (const parameter of template.parameters) {
|
|
1964
|
+
const value = params[parameter.key] ?? parameter.default;
|
|
1965
|
+
if (value === void 0) throw new Error(`Missing template parameter: ${parameter.key}`);
|
|
1966
|
+
if (parameter.type === "number" && !Number.isFinite(Number(value))) {
|
|
1967
|
+
throw new Error(`Invalid number for template parameter: ${parameter.key}`);
|
|
1968
|
+
}
|
|
1969
|
+
if (parameter.type === "color" && !isCssColor(value)) {
|
|
1970
|
+
throw new Error(`Invalid color for template parameter: ${parameter.key}`);
|
|
1971
|
+
}
|
|
1972
|
+
values[parameter.key] = value;
|
|
1973
|
+
}
|
|
1974
|
+
const resolved = sanitizeSvg(
|
|
1975
|
+
template.svg.replace(/\{\{(\w+)(?:[|:]([^}]*))?\}\}/g, (token, key, fallback) => {
|
|
1976
|
+
const value = values[key] ?? fallback;
|
|
1977
|
+
return value === void 0 ? token : escapeXml(value);
|
|
1978
|
+
})
|
|
1050
1979
|
);
|
|
1980
|
+
const { objects, options } = await loadSVGFromString(resolved);
|
|
1981
|
+
const validObjects = objects.filter((object) => object !== null);
|
|
1982
|
+
if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
|
|
1983
|
+
const group = util3.groupSVGElements(validObjects, options);
|
|
1984
|
+
group.set({
|
|
1985
|
+
left: this.canvas.getWidth() / 2,
|
|
1986
|
+
top: this.canvas.getHeight() / 2,
|
|
1987
|
+
originX: "center",
|
|
1988
|
+
originY: "center"
|
|
1989
|
+
});
|
|
1990
|
+
const layer = this.layers.add("template", group, template.name);
|
|
1051
1991
|
this.history.save();
|
|
1052
1992
|
return layer;
|
|
1053
1993
|
}
|
|
1054
1994
|
removeLayer(id) {
|
|
1055
|
-
this.
|
|
1056
|
-
this.
|
|
1995
|
+
if (this.crop.activeLayerId() === id) this.crop.cancel();
|
|
1996
|
+
if (this.masks.activeLayerId() === id) {
|
|
1997
|
+
this.masks.detach(id);
|
|
1998
|
+
}
|
|
1999
|
+
if (this.layers.remove(id)) this.history.save();
|
|
1057
2000
|
}
|
|
1058
2001
|
selectLayer(id) {
|
|
1059
2002
|
this.layers.select(id);
|
|
@@ -1088,35 +2031,220 @@ var CanvasEditor = class {
|
|
|
1088
2031
|
clone.setCoords();
|
|
1089
2032
|
const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
|
|
1090
2033
|
copy.meta = structuredClone(layer.meta);
|
|
2034
|
+
copy.visible = layer.visible;
|
|
2035
|
+
copy.locked = layer.locked;
|
|
2036
|
+
copy.opacity = layer.opacity;
|
|
2037
|
+
clone.set({
|
|
2038
|
+
visible: layer.visible,
|
|
2039
|
+
selectable: !layer.locked,
|
|
2040
|
+
evented: !layer.locked,
|
|
2041
|
+
opacity: layer.opacity
|
|
2042
|
+
});
|
|
1091
2043
|
this.canvas.setActiveObject(clone);
|
|
1092
2044
|
this.canvas.requestRenderAll();
|
|
1093
2045
|
this.history.save();
|
|
1094
2046
|
return copy;
|
|
1095
2047
|
}
|
|
2048
|
+
applyImageAdjustments(layerId, adjustments) {
|
|
2049
|
+
const layer = this.layers.get(layerId);
|
|
2050
|
+
if (!layer || layer.type !== "image") return false;
|
|
2051
|
+
const image = layer.fabricObject;
|
|
2052
|
+
const previous = layer.meta.imageAdjustments ?? {};
|
|
2053
|
+
const next = { ...previous, ...adjustments };
|
|
2054
|
+
const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
|
|
2055
|
+
image.filters = [
|
|
2056
|
+
new filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
|
|
2057
|
+
new filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
|
|
2058
|
+
new filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
|
|
2059
|
+
new filters.Blur({ blur: clampAdjustment(next.blur, 0) })
|
|
2060
|
+
];
|
|
2061
|
+
layer.meta.imageAdjustments = next;
|
|
2062
|
+
image.applyFilters();
|
|
2063
|
+
this.canvas.requestRenderAll();
|
|
2064
|
+
this.history.save();
|
|
2065
|
+
return true;
|
|
2066
|
+
}
|
|
2067
|
+
/** Combine two or more layers into a single editable group layer. */
|
|
2068
|
+
async groupLayers(ids, name = "Group") {
|
|
2069
|
+
const uniqueIds = [...new Set(ids)];
|
|
2070
|
+
const children = uniqueIds.map((id) => this.layers.get(id)).filter((layer) => layer !== void 0);
|
|
2071
|
+
if (children.length < 2 || children.length !== uniqueIds.length) return null;
|
|
2072
|
+
return this.history.transaction(() => {
|
|
2073
|
+
const childData = children.map((layer) => structuredClone(layer.toData()));
|
|
2074
|
+
const objects = children.map((layer) => layer.fabricObject);
|
|
2075
|
+
for (const layer of children) this.layers.remove(layer.id);
|
|
2076
|
+
const group = new Group(objects);
|
|
2077
|
+
const grouped = this.layers.add("group", group, name);
|
|
2078
|
+
grouped.meta.groupChildren = childData;
|
|
2079
|
+
this.layers.select(grouped.id);
|
|
2080
|
+
this.history.save();
|
|
2081
|
+
return grouped;
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
/** Restore a group created by groupLayers back to its original layer records. */
|
|
2085
|
+
async ungroupLayer(id) {
|
|
2086
|
+
const grouped = this.layers.get(id);
|
|
2087
|
+
const childData = grouped?.meta.groupChildren;
|
|
2088
|
+
if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
|
|
2089
|
+
const group = grouped.fabricObject;
|
|
2090
|
+
return this.history.transaction(() => {
|
|
2091
|
+
const transform = group.calcTransformMatrix();
|
|
2092
|
+
const objects = group.removeAll();
|
|
2093
|
+
this.layers.remove(id);
|
|
2094
|
+
const restored = objects.map((object, index) => {
|
|
2095
|
+
util3.addTransformToObject(object, transform);
|
|
2096
|
+
object.setCoords();
|
|
2097
|
+
const data = childData[index];
|
|
2098
|
+
const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
|
|
2099
|
+
if (data?.meta) layer.meta = structuredClone(data.meta);
|
|
2100
|
+
if (data && !data.visible) this.layers.setVisibility(layer.id, false);
|
|
2101
|
+
if (data?.locked) this.layers.setLocked(layer.id, true);
|
|
2102
|
+
if (data && data.opacity !== 1) this.layers.setOpacity(layer.id, data.opacity);
|
|
2103
|
+
return layer;
|
|
2104
|
+
});
|
|
2105
|
+
this.history.save();
|
|
2106
|
+
this.layers.select(restored[0]?.id ?? null);
|
|
2107
|
+
return restored;
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
1096
2110
|
// ─── Serialization ──────────────────────────────────
|
|
1097
2111
|
toJSON() {
|
|
1098
2112
|
return serializeEditor(this);
|
|
1099
2113
|
}
|
|
1100
2114
|
async fromJSON(state) {
|
|
1101
|
-
|
|
1102
|
-
|
|
2115
|
+
const managedByHistory = this.history.isRestoring();
|
|
2116
|
+
if (!managedByHistory) {
|
|
2117
|
+
this.history.saveImmediate();
|
|
2118
|
+
this.history.pause();
|
|
2119
|
+
}
|
|
2120
|
+
try {
|
|
2121
|
+
await deserializeEditor(this, state);
|
|
2122
|
+
this.patterns.repinAll();
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
if (!managedByHistory) {
|
|
2125
|
+
this.events.emit("error", { message: "Failed to load editor state", error });
|
|
2126
|
+
}
|
|
2127
|
+
throw error;
|
|
2128
|
+
} finally {
|
|
2129
|
+
if (!managedByHistory) this.history.resume();
|
|
2130
|
+
}
|
|
2131
|
+
if (!managedByHistory) {
|
|
2132
|
+
this.history.clear();
|
|
2133
|
+
this.history.saveImmediate();
|
|
2134
|
+
}
|
|
1103
2135
|
}
|
|
1104
2136
|
// ─── Export ──────────────────────────────────────────
|
|
1105
2137
|
async toPNG(options) {
|
|
1106
|
-
this.
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
return
|
|
2138
|
+
return this.toRaster(options?.format ?? "png", options);
|
|
2139
|
+
}
|
|
2140
|
+
async toJPEG(options) {
|
|
2141
|
+
return this.toRaster("jpeg", options);
|
|
2142
|
+
}
|
|
2143
|
+
async toWebP(options) {
|
|
2144
|
+
return this.toRaster("webp", options);
|
|
2145
|
+
}
|
|
2146
|
+
/** Export one layer in document coordinates or at its native image resolution. */
|
|
2147
|
+
async exportLayer(id, options = {}) {
|
|
2148
|
+
const layer = this.layers.get(id);
|
|
2149
|
+
if (!layer) throw new Error(`Layer not found: ${id}`);
|
|
2150
|
+
try {
|
|
2151
|
+
if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
|
|
2152
|
+
const image = await layer.fabricObject.clone();
|
|
2153
|
+
image.set({
|
|
2154
|
+
left: 0,
|
|
2155
|
+
top: 0,
|
|
2156
|
+
originX: "left",
|
|
2157
|
+
originY: "top",
|
|
2158
|
+
scaleX: 1,
|
|
2159
|
+
scaleY: 1,
|
|
2160
|
+
angle: 0,
|
|
2161
|
+
flipX: false,
|
|
2162
|
+
flipY: false
|
|
2163
|
+
});
|
|
2164
|
+
return await exportIsolatedPNG(this.canvas, [image], {
|
|
2165
|
+
...options,
|
|
2166
|
+
width: image.width || 1,
|
|
2167
|
+
height: image.height || 1,
|
|
2168
|
+
cloneObjects: false
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
|
|
2174
|
+
throw error;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/** Export only the configured document background, excluding design layers. */
|
|
2178
|
+
async exportBackground(options = {}) {
|
|
2179
|
+
try {
|
|
2180
|
+
return await exportIsolatedPNG(this.canvas, [], {
|
|
2181
|
+
...options,
|
|
2182
|
+
backgroundColor: this.designBackground,
|
|
2183
|
+
backgroundImage: this.designBackgroundImage
|
|
2184
|
+
});
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
this.events.emit("error", { message: "Failed to export background", error });
|
|
2187
|
+
throw error;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
async toRaster(format, options) {
|
|
2191
|
+
this.events.emit("export:start", { format });
|
|
2192
|
+
try {
|
|
2193
|
+
await this.fonts.ready();
|
|
2194
|
+
const blob = await this.withDesignBackground(
|
|
2195
|
+
() => exportPNG(this.canvas, { ...options, format })
|
|
2196
|
+
);
|
|
2197
|
+
this.events.emit("export:complete", { format });
|
|
2198
|
+
this.licensing.track(`export:${format}`);
|
|
2199
|
+
return blob;
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
this.events.emit("error", {
|
|
2202
|
+
message: isTaintedCanvasError(error) ? "Canvas export was blocked by cross-origin image data; load remote images with CORS enabled" : `Failed to export ${format.toUpperCase()}`,
|
|
2203
|
+
error
|
|
2204
|
+
});
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
1110
2207
|
}
|
|
1111
2208
|
toSVG() {
|
|
1112
2209
|
this.events.emit("export:start", { format: "svg" });
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
2210
|
+
try {
|
|
2211
|
+
const svg = this.withDesignBackground(() => exportSVG(this.canvas));
|
|
2212
|
+
this.events.emit("export:complete", { format: "svg" });
|
|
2213
|
+
this.licensing.track("export:svg");
|
|
2214
|
+
return svg;
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
this.events.emit("error", { message: "Failed to export SVG", error });
|
|
2217
|
+
throw error;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
async toSVGAsync(options = {}) {
|
|
2221
|
+
await this.fonts.ready();
|
|
2222
|
+
const svg = this.toSVG();
|
|
2223
|
+
if (options.embedFonts === false || this.fonts.getAll().length === 0) return svg;
|
|
2224
|
+
try {
|
|
2225
|
+
const css = await this.fonts.getEmbeddedCss();
|
|
2226
|
+
return svg.replace(/(<svg\b[^>]*>)/i, `$1<defs><style>${css}</style></defs>`);
|
|
2227
|
+
} catch (error) {
|
|
2228
|
+
this.events.emit("error", { message: "Failed to embed fonts in SVG", error });
|
|
2229
|
+
throw error;
|
|
2230
|
+
}
|
|
1116
2231
|
}
|
|
1117
2232
|
toDataURL(format = "png", multiplier = 1) {
|
|
1118
2233
|
return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
|
|
1119
2234
|
}
|
|
2235
|
+
/** Export the current product-preview composite. Advanced warping is host-defined. */
|
|
2236
|
+
async toMockupImage(options = {}) {
|
|
2237
|
+
if (!this.mockup) throw new Error("No mockup is configured");
|
|
2238
|
+
await this.fonts.ready();
|
|
2239
|
+
try {
|
|
2240
|
+
const blob = await exportMockup(this.canvas, this.mockup, options);
|
|
2241
|
+
this.licensing.track(`export:mockup:${options.format ?? "png"}`);
|
|
2242
|
+
return blob;
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
this.events.emit("error", { message: "Failed to export mockup", error });
|
|
2245
|
+
throw error;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
1120
2248
|
/**
|
|
1121
2249
|
* Run an export with the configured design background applied, even when a
|
|
1122
2250
|
* mockup preview has forced the live canvas transparent — so exports reflect
|
|
@@ -1125,11 +2253,14 @@ var CanvasEditor = class {
|
|
|
1125
2253
|
withDesignBackground(fn) {
|
|
1126
2254
|
if (!this.mockup) return fn();
|
|
1127
2255
|
const previewBg = this.canvas.backgroundColor;
|
|
2256
|
+
const previewImage = this.canvas.backgroundImage;
|
|
1128
2257
|
this.canvas.backgroundColor = this.designBackground;
|
|
2258
|
+
this.canvas.backgroundImage = this.designBackgroundImage ?? void 0;
|
|
1129
2259
|
try {
|
|
1130
2260
|
return fn();
|
|
1131
2261
|
} finally {
|
|
1132
2262
|
this.canvas.backgroundColor = previewBg;
|
|
2263
|
+
this.canvas.backgroundImage = previewImage;
|
|
1133
2264
|
}
|
|
1134
2265
|
}
|
|
1135
2266
|
toPrintifyPositioning() {
|
|
@@ -1144,7 +2275,8 @@ var CanvasEditor = class {
|
|
|
1144
2275
|
for (const layer of imageLayers) {
|
|
1145
2276
|
const obj = layer.fabricObject;
|
|
1146
2277
|
const center = obj.getCenterPoint();
|
|
1147
|
-
result[layer.name]
|
|
2278
|
+
const key = result[layer.name] ? `${layer.name}-${layer.id}` : layer.name;
|
|
2279
|
+
result[key] = {
|
|
1148
2280
|
x: round2(center.x / canvasW),
|
|
1149
2281
|
y: round2(center.y / canvasH),
|
|
1150
2282
|
scale: round2(obj.scaleX ?? 1),
|
|
@@ -1153,26 +2285,92 @@ var CanvasEditor = class {
|
|
|
1153
2285
|
}
|
|
1154
2286
|
return result;
|
|
1155
2287
|
}
|
|
2288
|
+
/** Provider-neutral, ID-stable placement data in 0..1 canvas coordinates. */
|
|
2289
|
+
toNormalizedPositioning() {
|
|
2290
|
+
const canvasWidth = this.canvas.getWidth();
|
|
2291
|
+
const canvasHeight = this.canvas.getHeight();
|
|
2292
|
+
return this.layers.getAll().map((layer) => {
|
|
2293
|
+
const object = layer.fabricObject;
|
|
2294
|
+
const center = object.getCenterPoint();
|
|
2295
|
+
const bounds = object.getBoundingRect();
|
|
2296
|
+
return {
|
|
2297
|
+
layerId: layer.id,
|
|
2298
|
+
name: layer.name,
|
|
2299
|
+
type: layer.type,
|
|
2300
|
+
centerX: round2(center.x / canvasWidth),
|
|
2301
|
+
centerY: round2(center.y / canvasHeight),
|
|
2302
|
+
width: round2(bounds.width / canvasWidth),
|
|
2303
|
+
height: round2(bounds.height / canvasHeight),
|
|
2304
|
+
scaleX: round2(object.scaleX ?? 1),
|
|
2305
|
+
scaleY: round2(object.scaleY ?? 1),
|
|
2306
|
+
angle: round2(object.angle ?? 0)
|
|
2307
|
+
};
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
toProviderPositioning(adapter) {
|
|
2311
|
+
return adapter.map(this.toNormalizedPositioning(), {
|
|
2312
|
+
width: this.canvas.getWidth(),
|
|
2313
|
+
height: this.canvas.getHeight()
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
1156
2316
|
// ─── File Operations ────────────────────────────────
|
|
1157
2317
|
async save(filename, format) {
|
|
1158
2318
|
if (!this.fileAdapter) return void 0;
|
|
1159
2319
|
let data;
|
|
1160
2320
|
if (format === "png") {
|
|
1161
2321
|
data = await this.toPNG();
|
|
2322
|
+
} else if (format === "jpeg") {
|
|
2323
|
+
data = await this.toJPEG();
|
|
2324
|
+
} else if (format === "webp") {
|
|
2325
|
+
data = await this.toWebP();
|
|
1162
2326
|
} else if (format === "svg") {
|
|
1163
|
-
data = this.
|
|
2327
|
+
data = await this.toSVGAsync();
|
|
1164
2328
|
} else {
|
|
1165
2329
|
data = JSON.stringify(this.toJSON(), null, 2);
|
|
1166
2330
|
}
|
|
1167
|
-
|
|
2331
|
+
try {
|
|
2332
|
+
return await this.fileAdapter.save(data, filename, format);
|
|
2333
|
+
} catch (error) {
|
|
2334
|
+
this.events.emit("error", { message: `Failed to save ${format}`, error });
|
|
2335
|
+
throw error;
|
|
2336
|
+
}
|
|
1168
2337
|
}
|
|
1169
2338
|
async uploadImage(file) {
|
|
1170
|
-
if (!this.imageProvider) return void 0;
|
|
1171
|
-
|
|
2339
|
+
if (!this.imageProvider?.upload) return void 0;
|
|
2340
|
+
try {
|
|
2341
|
+
return await this.imageProvider.upload(file);
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
this.events.emit("error", { message: "Failed to upload image", error });
|
|
2344
|
+
throw error;
|
|
2345
|
+
}
|
|
1172
2346
|
}
|
|
1173
2347
|
async browseImages() {
|
|
1174
2348
|
if (!this.imageProvider?.browse) return null;
|
|
1175
|
-
|
|
2349
|
+
try {
|
|
2350
|
+
return await this.imageProvider.browse();
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
this.events.emit("error", { message: "Failed to browse images", error });
|
|
2353
|
+
throw error;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
async searchImages(query, options) {
|
|
2357
|
+
if (!this.imageProvider?.search) return null;
|
|
2358
|
+
try {
|
|
2359
|
+
return await this.imageProvider.search(query, options);
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
this.events.emit("error", { message: "Failed to search images", error });
|
|
2362
|
+
throw error;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
/** Track provider usage, then insert its hotlinked image as a normal layer. */
|
|
2366
|
+
async addProviderImage(image, options) {
|
|
2367
|
+
try {
|
|
2368
|
+
await this.imageProvider?.trackUse?.(image);
|
|
2369
|
+
} catch (error) {
|
|
2370
|
+
this.events.emit("error", { message: "Failed to record image use", error });
|
|
2371
|
+
throw error;
|
|
2372
|
+
}
|
|
2373
|
+
return this.addImage(image.url, options);
|
|
1176
2374
|
}
|
|
1177
2375
|
setFileAdapter(adapter) {
|
|
1178
2376
|
this.fileAdapter = adapter;
|
|
@@ -1188,15 +2386,140 @@ var CanvasEditor = class {
|
|
|
1188
2386
|
this.canvas.requestRenderAll();
|
|
1189
2387
|
}
|
|
1190
2388
|
this.history.save();
|
|
2389
|
+
this.events.emit("canvas:modified", {});
|
|
1191
2390
|
}
|
|
1192
2391
|
getDesignBackground() {
|
|
1193
2392
|
return this.designBackground;
|
|
1194
2393
|
}
|
|
1195
|
-
|
|
2394
|
+
getDesignBackgroundImage() {
|
|
2395
|
+
return this.designBackgroundImage;
|
|
2396
|
+
}
|
|
2397
|
+
getBackgroundImageOptions() {
|
|
2398
|
+
return this.backgroundImageOptions ? { ...this.backgroundImageOptions } : null;
|
|
2399
|
+
}
|
|
2400
|
+
async setBackgroundImage(url, options = {}) {
|
|
2401
|
+
if (url === null) {
|
|
2402
|
+
this.setBackgroundImageObject(null);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
try {
|
|
2406
|
+
const image = await FabricImage3.fromURL(
|
|
2407
|
+
url,
|
|
2408
|
+
{ crossOrigin: options.crossOrigin ?? null, signal: options.signal },
|
|
2409
|
+
{ originX: "left", originY: "top" }
|
|
2410
|
+
);
|
|
2411
|
+
const width = image.width || 1;
|
|
2412
|
+
const height = image.height || 1;
|
|
2413
|
+
const canvasWidth = this.canvas.getWidth();
|
|
2414
|
+
const canvasHeight = this.canvas.getHeight();
|
|
2415
|
+
const fit = options.fit ?? "cover";
|
|
2416
|
+
const sx = canvasWidth / width;
|
|
2417
|
+
const sy = canvasHeight / height;
|
|
2418
|
+
const scaleX = fit === "stretch" ? sx : fit === "contain" ? Math.min(sx, sy) : Math.max(sx, sy);
|
|
2419
|
+
const scaleY = fit === "stretch" ? sy : scaleX;
|
|
2420
|
+
image.set({
|
|
2421
|
+
left: (canvasWidth - width * scaleX) / 2,
|
|
2422
|
+
top: (canvasHeight - height * scaleY) / 2,
|
|
2423
|
+
scaleX,
|
|
2424
|
+
scaleY,
|
|
2425
|
+
opacity: clamp(options.opacity ?? 1, 0, 1),
|
|
2426
|
+
selectable: false,
|
|
2427
|
+
evented: false
|
|
2428
|
+
});
|
|
2429
|
+
const serializableOptions = { ...options };
|
|
2430
|
+
delete serializableOptions.signal;
|
|
2431
|
+
this.setBackgroundImageObject(image, true, serializableOptions);
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
this.events.emit("error", { message: "Failed to set background image", error });
|
|
2434
|
+
throw error;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
/** Used by state restoration and advanced integrations with an existing Fabric object. */
|
|
2438
|
+
setBackgroundImageObject(image, save = true, options = null) {
|
|
2439
|
+
this.designBackgroundImage = image;
|
|
2440
|
+
this.backgroundImageOptions = image ? options : null;
|
|
2441
|
+
this.canvas.backgroundImage = this.mockup ? void 0 : image ?? void 0;
|
|
2442
|
+
this.canvas.requestRenderAll();
|
|
2443
|
+
if (save) this.history.save();
|
|
2444
|
+
this.events.emit("canvas:modified", {});
|
|
2445
|
+
}
|
|
2446
|
+
setTransparentBackground() {
|
|
2447
|
+
this.setBackground("");
|
|
2448
|
+
}
|
|
2449
|
+
resize(width, height, options = {}) {
|
|
1196
2450
|
const widthPx = this.units.toPixels(width);
|
|
1197
2451
|
const heightPx = this.units.toPixels(height);
|
|
2452
|
+
const oldWidth = this.canvas.getWidth();
|
|
2453
|
+
const oldHeight = this.canvas.getHeight();
|
|
2454
|
+
const scaleContent = options.scaleContent ?? true;
|
|
2455
|
+
if (scaleContent && oldWidth > 0 && oldHeight > 0) {
|
|
2456
|
+
const sx = widthPx / oldWidth;
|
|
2457
|
+
const sy = heightPx / oldHeight;
|
|
2458
|
+
for (const layer of this.layers.getAll()) {
|
|
2459
|
+
if (layer.meta.pattern) continue;
|
|
2460
|
+
const object = layer.fabricObject;
|
|
2461
|
+
object.set({
|
|
2462
|
+
left: (object.left ?? 0) * sx,
|
|
2463
|
+
top: (object.top ?? 0) * sy,
|
|
2464
|
+
scaleX: (object.scaleX ?? 1) * sx,
|
|
2465
|
+
scaleY: (object.scaleY ?? 1) * sy
|
|
2466
|
+
});
|
|
2467
|
+
object.setCoords();
|
|
2468
|
+
}
|
|
2469
|
+
if (this.designBackgroundImage) {
|
|
2470
|
+
this.designBackgroundImage.set({
|
|
2471
|
+
left: (this.designBackgroundImage.left ?? 0) * sx,
|
|
2472
|
+
top: (this.designBackgroundImage.top ?? 0) * sy,
|
|
2473
|
+
scaleX: (this.designBackgroundImage.scaleX ?? 1) * sx,
|
|
2474
|
+
scaleY: (this.designBackgroundImage.scaleY ?? 1) * sy
|
|
2475
|
+
});
|
|
2476
|
+
this.designBackgroundImage.setCoords();
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
1198
2479
|
this.canvas.setDimensions({ width: widthPx, height: heightPx });
|
|
2480
|
+
this.patterns.repinAll();
|
|
1199
2481
|
this.canvas.requestRenderAll();
|
|
2482
|
+
this.history.save();
|
|
2483
|
+
this.events.emit("canvas:modified", {});
|
|
2484
|
+
}
|
|
2485
|
+
/** Effective source resolution for an image at its current physical size. */
|
|
2486
|
+
getImageDpi(layerId) {
|
|
2487
|
+
const layer = this.layers.get(layerId);
|
|
2488
|
+
if (!layer || layer.type !== "image") return null;
|
|
2489
|
+
const image = layer.fabricObject;
|
|
2490
|
+
const sourcePixels = image.width ?? 0;
|
|
2491
|
+
const displayedPixels = image.getScaledWidth();
|
|
2492
|
+
if (sourcePixels <= 0 || displayedPixels <= 0) return null;
|
|
2493
|
+
return round2(sourcePixels / (displayedPixels / this.units.getDpi()));
|
|
2494
|
+
}
|
|
2495
|
+
validateImageDpi(minimumDpi = 300) {
|
|
2496
|
+
return this.layers.getAll().filter((layer) => layer.type === "image" && !layer.meta.pattern).flatMap((layer) => {
|
|
2497
|
+
const effectiveDpi = this.getImageDpi(layer.id);
|
|
2498
|
+
return effectiveDpi !== null && effectiveDpi < minimumDpi ? [{ layerId: layer.id, layerName: layer.name, effectiveDpi, minimumDpi }] : [];
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
/** Render a small data-URL preview without changing document dimensions. */
|
|
2502
|
+
toThumbnail(maxWidth = 320, maxHeight = 320, format = "png") {
|
|
2503
|
+
const multiplier = Math.min(
|
|
2504
|
+
1,
|
|
2505
|
+
maxWidth / this.canvas.getWidth(),
|
|
2506
|
+
maxHeight / this.canvas.getHeight()
|
|
2507
|
+
);
|
|
2508
|
+
return this.toDataURL(format, multiplier);
|
|
2509
|
+
}
|
|
2510
|
+
bringToFront(id) {
|
|
2511
|
+
return this.layers.reorder(id, this.layers.count() - 1);
|
|
2512
|
+
}
|
|
2513
|
+
sendToBack(id) {
|
|
2514
|
+
return this.layers.reorder(id, 0);
|
|
2515
|
+
}
|
|
2516
|
+
bringForward(id) {
|
|
2517
|
+
const index = this.layers.getAll().findIndex((layer) => layer.id === id);
|
|
2518
|
+
return index >= 0 && this.layers.reorder(id, index + 1);
|
|
2519
|
+
}
|
|
2520
|
+
sendBackward(id) {
|
|
2521
|
+
const index = this.layers.getAll().findIndex((layer) => layer.id === id);
|
|
2522
|
+
return index >= 0 && this.layers.reorder(id, index - 1);
|
|
1200
2523
|
}
|
|
1201
2524
|
// ─── Zoom ───────────────────────────────────────────
|
|
1202
2525
|
//
|
|
@@ -1234,6 +2557,20 @@ var CanvasEditor = class {
|
|
|
1234
2557
|
const sy = (viewportHeight - padding * 2) / h;
|
|
1235
2558
|
this.setZoom(Math.min(sx, sy));
|
|
1236
2559
|
}
|
|
2560
|
+
/** Zoom until the current selection fills the artboard viewport. */
|
|
2561
|
+
zoomToSelection(padding = 24) {
|
|
2562
|
+
const active = this.canvas.getActiveObject();
|
|
2563
|
+
if (!active) return;
|
|
2564
|
+
active.setCoords();
|
|
2565
|
+
const bounds = active.getBoundingRect();
|
|
2566
|
+
if (bounds.width <= 0 || bounds.height <= 0) return;
|
|
2567
|
+
this.setZoom(
|
|
2568
|
+
Math.min(
|
|
2569
|
+
(this.canvas.getWidth() - padding * 2) / bounds.width,
|
|
2570
|
+
(this.canvas.getHeight() - padding * 2) / bounds.height
|
|
2571
|
+
)
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
1237
2574
|
// ─── Patterns ───────────────────────────────────────
|
|
1238
2575
|
applyPattern(layerId, config) {
|
|
1239
2576
|
return this.patterns.apply(layerId, config);
|
|
@@ -1245,8 +2582,10 @@ var CanvasEditor = class {
|
|
|
1245
2582
|
setMockup(mockup) {
|
|
1246
2583
|
this.mockup = mockup;
|
|
1247
2584
|
this.canvas.backgroundColor = mockup ? "" : this.designBackground;
|
|
2585
|
+
this.canvas.backgroundImage = mockup ? void 0 : this.designBackgroundImage ?? void 0;
|
|
1248
2586
|
this.canvas.requestRenderAll();
|
|
1249
2587
|
this.events.emit("mockup:changed", { mockup });
|
|
2588
|
+
this.history.save();
|
|
1250
2589
|
}
|
|
1251
2590
|
clearMockup() {
|
|
1252
2591
|
this.setMockup(null);
|
|
@@ -1256,9 +2595,11 @@ var CanvasEditor = class {
|
|
|
1256
2595
|
}
|
|
1257
2596
|
// ─── Cleanup ────────────────────────────────────────
|
|
1258
2597
|
dispose() {
|
|
2598
|
+
this.masks.dispose();
|
|
1259
2599
|
this.snapping.dispose();
|
|
1260
2600
|
this.crop.dispose();
|
|
1261
2601
|
this.history.dispose();
|
|
2602
|
+
clearPatternImageCache();
|
|
1262
2603
|
this.events.removeAllListeners();
|
|
1263
2604
|
this.canvas.dispose();
|
|
1264
2605
|
}
|
|
@@ -1299,15 +2640,73 @@ var DEFAULT_PATTERN_CONFIG = {
|
|
|
1299
2640
|
rotationStepH: 0,
|
|
1300
2641
|
rotationStepV: 0
|
|
1301
2642
|
};
|
|
2643
|
+
|
|
2644
|
+
// src/presets.ts
|
|
2645
|
+
var CANVAS_SIZE_PRESETS = [
|
|
2646
|
+
{ id: "a4-portrait", name: "A4 portrait", width: 210, height: 297, unit: "mm", dpi: 300 },
|
|
2647
|
+
{ id: "a4-landscape", name: "A4 landscape", width: 297, height: 210, unit: "mm", dpi: 300 },
|
|
2648
|
+
{ id: "us-letter", name: "US Letter", width: 8.5, height: 11, unit: "in", dpi: 300 },
|
|
2649
|
+
{ id: "shirt-front", name: "Garment front", width: 12, height: 16, unit: "in", dpi: 300 },
|
|
2650
|
+
{ id: "instagram-square", name: "Social square", width: 1080, height: 1080, unit: "px", dpi: 72 },
|
|
2651
|
+
{ id: "story", name: "Story", width: 1080, height: 1920, unit: "px", dpi: 72 }
|
|
2652
|
+
];
|
|
2653
|
+
|
|
2654
|
+
// src/annotations.ts
|
|
2655
|
+
var AnnotationOverlay = class {
|
|
2656
|
+
items = /* @__PURE__ */ new Map();
|
|
2657
|
+
transform = { zoom: 1, panX: 0, panY: 0, devicePixelRatio: 1 };
|
|
2658
|
+
set(annotation) {
|
|
2659
|
+
this.items.set(annotation.id, structuredClone(annotation));
|
|
2660
|
+
}
|
|
2661
|
+
remove(id) {
|
|
2662
|
+
return this.items.delete(id);
|
|
2663
|
+
}
|
|
2664
|
+
clear() {
|
|
2665
|
+
this.items.clear();
|
|
2666
|
+
}
|
|
2667
|
+
getAll() {
|
|
2668
|
+
return [...this.items.values()].map((item) => structuredClone(item));
|
|
2669
|
+
}
|
|
2670
|
+
setTransform(transform) {
|
|
2671
|
+
if (!Number.isFinite(transform.zoom) || transform.zoom <= 0) {
|
|
2672
|
+
throw new Error("Annotation zoom must be positive");
|
|
2673
|
+
}
|
|
2674
|
+
this.transform = { ...transform, devicePixelRatio: transform.devicePixelRatio ?? 1 };
|
|
2675
|
+
}
|
|
2676
|
+
documentToViewport(point) {
|
|
2677
|
+
return {
|
|
2678
|
+
x: point.x * this.transform.zoom + this.transform.panX,
|
|
2679
|
+
y: point.y * this.transform.zoom + this.transform.panY
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
viewportToDocument(point) {
|
|
2683
|
+
return {
|
|
2684
|
+
x: (point.x - this.transform.panX) / this.transform.zoom,
|
|
2685
|
+
y: (point.y - this.transform.panY) / this.transform.zoom
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
documentToDevice(point) {
|
|
2689
|
+
const viewport = this.documentToViewport(point);
|
|
2690
|
+
const ratio = this.transform.devicePixelRatio ?? 1;
|
|
2691
|
+
return { x: viewport.x * ratio, y: viewport.y * ratio };
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
1302
2694
|
export {
|
|
2695
|
+
AnnotationOverlay,
|
|
2696
|
+
CANVAS_SIZE_PRESETS,
|
|
1303
2697
|
CanvasEditor,
|
|
1304
2698
|
CropController,
|
|
1305
2699
|
DEFAULT_PATTERN_CONFIG,
|
|
1306
2700
|
EventEmitter,
|
|
2701
|
+
FontRegistry,
|
|
1307
2702
|
HistoryManager,
|
|
1308
2703
|
Layer,
|
|
1309
2704
|
LayerManager,
|
|
2705
|
+
LicenseManager,
|
|
2706
|
+
MaskController,
|
|
2707
|
+
MaskRefinementError,
|
|
1310
2708
|
PatternManager,
|
|
2709
|
+
ProjectManager,
|
|
1311
2710
|
SnapManager,
|
|
1312
2711
|
UnitConverter,
|
|
1313
2712
|
applyPatternLocks,
|
|
@@ -1315,15 +2714,23 @@ export {
|
|
|
1315
2714
|
captureLocks,
|
|
1316
2715
|
clamp,
|
|
1317
2716
|
clearPatternImageCache,
|
|
2717
|
+
computeCoverPlacement,
|
|
2718
|
+
computePrintAreaClip,
|
|
1318
2719
|
computeTilePositions,
|
|
1319
2720
|
deserializeEditor,
|
|
2721
|
+
displaceRgba,
|
|
1320
2722
|
drawTiles,
|
|
2723
|
+
escapeXml,
|
|
1321
2724
|
exportDataURL,
|
|
2725
|
+
exportMockup,
|
|
1322
2726
|
exportPNG,
|
|
1323
2727
|
exportSVG,
|
|
1324
2728
|
generateId,
|
|
2729
|
+
isCssColor,
|
|
2730
|
+
loadPatternImage,
|
|
1325
2731
|
restoreLocks,
|
|
1326
2732
|
round2,
|
|
2733
|
+
sanitizeSvg,
|
|
1327
2734
|
serializeEditor
|
|
1328
2735
|
};
|
|
1329
2736
|
//# sourceMappingURL=index.mjs.map
|