@overtone-art/canvas-editor-core 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +214 -1
- package/dist/index.d.ts +214 -1
- package/dist/index.js +664 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +651 -27
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -48,6 +48,8 @@ var Layer = class {
|
|
|
48
48
|
visible;
|
|
49
49
|
locked;
|
|
50
50
|
opacity;
|
|
51
|
+
/** Non-fabric data (e.g. pattern config) that must persist with the layer. */
|
|
52
|
+
meta;
|
|
51
53
|
fabricObject;
|
|
52
54
|
constructor(type, fabricObject, name, id) {
|
|
53
55
|
this.id = id ?? generateId();
|
|
@@ -56,9 +58,13 @@ var Layer = class {
|
|
|
56
58
|
this.visible = true;
|
|
57
59
|
this.locked = false;
|
|
58
60
|
this.opacity = 1;
|
|
61
|
+
this.meta = {};
|
|
59
62
|
this.fabricObject = fabricObject;
|
|
60
63
|
this.fabricObject._layerId = this.id;
|
|
61
64
|
}
|
|
65
|
+
hasMeta() {
|
|
66
|
+
return Object.keys(this.meta).length > 0;
|
|
67
|
+
}
|
|
62
68
|
toData() {
|
|
63
69
|
return {
|
|
64
70
|
id: this.id,
|
|
@@ -66,7 +72,8 @@ var Layer = class {
|
|
|
66
72
|
name: this.name,
|
|
67
73
|
visible: this.visible,
|
|
68
74
|
locked: this.locked,
|
|
69
|
-
opacity: this.opacity
|
|
75
|
+
opacity: this.opacity,
|
|
76
|
+
...this.hasMeta() ? { meta: this.meta } : {}
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
serialize() {
|
|
@@ -226,6 +233,7 @@ var HistoryManager = class {
|
|
|
226
233
|
this.emitChanged();
|
|
227
234
|
}
|
|
228
235
|
async undo() {
|
|
236
|
+
this.cancelPending();
|
|
229
237
|
const state = this.undoStack.pop();
|
|
230
238
|
if (!state) return;
|
|
231
239
|
this.redoStack.push(this.getState());
|
|
@@ -235,6 +243,7 @@ var HistoryManager = class {
|
|
|
235
243
|
this.emitChanged();
|
|
236
244
|
}
|
|
237
245
|
async redo() {
|
|
246
|
+
this.cancelPending();
|
|
238
247
|
const state = this.redoStack.pop();
|
|
239
248
|
if (!state) return;
|
|
240
249
|
this.undoStack.push(this.getState());
|
|
@@ -243,6 +252,11 @@ var HistoryManager = class {
|
|
|
243
252
|
this.paused = false;
|
|
244
253
|
this.emitChanged();
|
|
245
254
|
}
|
|
255
|
+
/** True while a restore (undo/redo/deserialize) is in flight. Managers that
|
|
256
|
+
* react to canvas events should skip mutating history during this window. */
|
|
257
|
+
isRestoring() {
|
|
258
|
+
return this.paused;
|
|
259
|
+
}
|
|
246
260
|
pause() {
|
|
247
261
|
this.paused = true;
|
|
248
262
|
}
|
|
@@ -260,6 +274,12 @@ var HistoryManager = class {
|
|
|
260
274
|
this.redoStack = [];
|
|
261
275
|
this.emitChanged();
|
|
262
276
|
}
|
|
277
|
+
cancelPending() {
|
|
278
|
+
if (this.debounceTimer) {
|
|
279
|
+
clearTimeout(this.debounceTimer);
|
|
280
|
+
this.debounceTimer = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
263
283
|
dispose() {
|
|
264
284
|
if (this.debounceTimer) {
|
|
265
285
|
clearTimeout(this.debounceTimer);
|
|
@@ -273,6 +293,466 @@ var HistoryManager = class {
|
|
|
273
293
|
}
|
|
274
294
|
};
|
|
275
295
|
|
|
296
|
+
// src/snapping.ts
|
|
297
|
+
var STROKE = "#22c55e";
|
|
298
|
+
var SnapManager = class {
|
|
299
|
+
constructor(canvas, events) {
|
|
300
|
+
this.canvas = canvas;
|
|
301
|
+
this.events = events;
|
|
302
|
+
this.onMoving = (e) => this.handleMoving(e.target);
|
|
303
|
+
this.onAfterRender = () => this.drawGuides();
|
|
304
|
+
this.onModified = () => this.clearGuides();
|
|
305
|
+
this.canvas.on("object:moving", this.onMoving);
|
|
306
|
+
this.canvas.on("after:render", this.onAfterRender);
|
|
307
|
+
this.canvas.on("mouse:up", this.onModified);
|
|
308
|
+
this.canvas.on("selection:cleared", this.onModified);
|
|
309
|
+
}
|
|
310
|
+
canvas;
|
|
311
|
+
events;
|
|
312
|
+
enabled = false;
|
|
313
|
+
threshold = 8;
|
|
314
|
+
guides = [];
|
|
315
|
+
onMoving;
|
|
316
|
+
onAfterRender;
|
|
317
|
+
onModified;
|
|
318
|
+
setEnabled(enabled) {
|
|
319
|
+
if (this.enabled === enabled) return;
|
|
320
|
+
this.enabled = enabled;
|
|
321
|
+
if (!enabled) this.clearGuides();
|
|
322
|
+
this.events.emit("snap:changed", { enabled });
|
|
323
|
+
}
|
|
324
|
+
isEnabled() {
|
|
325
|
+
return this.enabled;
|
|
326
|
+
}
|
|
327
|
+
setThreshold(px) {
|
|
328
|
+
this.threshold = Math.max(0, px);
|
|
329
|
+
}
|
|
330
|
+
dispose() {
|
|
331
|
+
this.canvas.off("object:moving", this.onMoving);
|
|
332
|
+
this.canvas.off("after:render", this.onAfterRender);
|
|
333
|
+
this.canvas.off("mouse:up", this.onModified);
|
|
334
|
+
this.canvas.off("selection:cleared", this.onModified);
|
|
335
|
+
this.guides = [];
|
|
336
|
+
}
|
|
337
|
+
handleMoving(target) {
|
|
338
|
+
if (!this.enabled || !target) return;
|
|
339
|
+
target.setCoords();
|
|
340
|
+
const r = target.getBoundingRect();
|
|
341
|
+
const cw = this.canvas.getWidth();
|
|
342
|
+
const ch = this.canvas.getHeight();
|
|
343
|
+
const { xs, ys } = collectCandidates(this.canvas, target, cw, ch);
|
|
344
|
+
this.guides = [];
|
|
345
|
+
const dx = bestSnap([r.left, r.left + r.width / 2, r.left + r.width], xs, this.threshold);
|
|
346
|
+
if (dx !== null) {
|
|
347
|
+
target.set("left", (target.left ?? 0) + dx.delta);
|
|
348
|
+
this.guides.push({ x: dx.line });
|
|
349
|
+
}
|
|
350
|
+
const dy = bestSnap([r.top, r.top + r.height / 2, r.top + r.height], ys, this.threshold);
|
|
351
|
+
if (dy !== null) {
|
|
352
|
+
target.set("top", (target.top ?? 0) + dy.delta);
|
|
353
|
+
this.guides.push({ y: dy.line });
|
|
354
|
+
}
|
|
355
|
+
target.setCoords();
|
|
356
|
+
}
|
|
357
|
+
drawGuides() {
|
|
358
|
+
if (!this.enabled || this.guides.length === 0) return;
|
|
359
|
+
const ctx = this.canvas.contextTop;
|
|
360
|
+
if (!ctx) return;
|
|
361
|
+
const retina = this.canvas.getRetinaScaling();
|
|
362
|
+
const cw = this.canvas.getWidth();
|
|
363
|
+
const ch = this.canvas.getHeight();
|
|
364
|
+
ctx.save();
|
|
365
|
+
ctx.setTransform(retina, 0, 0, retina, 0, 0);
|
|
366
|
+
ctx.lineWidth = 1;
|
|
367
|
+
ctx.strokeStyle = STROKE;
|
|
368
|
+
ctx.setLineDash([4, 4]);
|
|
369
|
+
ctx.beginPath();
|
|
370
|
+
for (const g of this.guides) {
|
|
371
|
+
if (g.x !== void 0) {
|
|
372
|
+
ctx.moveTo(g.x, 0);
|
|
373
|
+
ctx.lineTo(g.x, ch);
|
|
374
|
+
}
|
|
375
|
+
if (g.y !== void 0) {
|
|
376
|
+
ctx.moveTo(0, g.y);
|
|
377
|
+
ctx.lineTo(cw, g.y);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
ctx.stroke();
|
|
381
|
+
ctx.restore();
|
|
382
|
+
}
|
|
383
|
+
clearGuides() {
|
|
384
|
+
if (this.guides.length === 0) return;
|
|
385
|
+
this.guides = [];
|
|
386
|
+
this.canvas.clearContext(this.canvas.contextTop);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
function collectCandidates(canvas, moving, cw, ch) {
|
|
390
|
+
const xs = [0, cw / 2, cw];
|
|
391
|
+
const ys = [0, ch / 2, ch];
|
|
392
|
+
for (const obj of canvas.getObjects()) {
|
|
393
|
+
if (obj === moving || !obj.visible) continue;
|
|
394
|
+
obj.setCoords();
|
|
395
|
+
const r = obj.getBoundingRect();
|
|
396
|
+
xs.push(r.left, r.left + r.width / 2, r.left + r.width);
|
|
397
|
+
ys.push(r.top, r.top + r.height / 2, r.top + r.height);
|
|
398
|
+
}
|
|
399
|
+
return { xs, ys };
|
|
400
|
+
}
|
|
401
|
+
function bestSnap(edges, candidates, threshold) {
|
|
402
|
+
let best = null;
|
|
403
|
+
for (const edge of edges) {
|
|
404
|
+
for (const c of candidates) {
|
|
405
|
+
const dist = Math.abs(edge - c);
|
|
406
|
+
if (dist <= threshold && (best === null || dist < best.dist)) {
|
|
407
|
+
best = { delta: c - edge, line: c, dist };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return best ? { delta: best.delta, line: best.line } : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/crop.ts
|
|
415
|
+
import { Rect } from "fabric";
|
|
416
|
+
|
|
417
|
+
// src/utils/clamp.ts
|
|
418
|
+
function clamp(v, min, max) {
|
|
419
|
+
return Math.min(max, Math.max(min, v));
|
|
420
|
+
}
|
|
421
|
+
function round2(v) {
|
|
422
|
+
return Math.round(v * 100) / 100;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/crop.ts
|
|
426
|
+
var CropController = class {
|
|
427
|
+
constructor(canvas, history, events) {
|
|
428
|
+
this.canvas = canvas;
|
|
429
|
+
this.history = history;
|
|
430
|
+
this.events = events;
|
|
431
|
+
this.onSelectionCleared = () => {
|
|
432
|
+
if (this.session) this.cancel();
|
|
433
|
+
};
|
|
434
|
+
this.canvas.on("selection:cleared", this.onSelectionCleared);
|
|
435
|
+
}
|
|
436
|
+
canvas;
|
|
437
|
+
history;
|
|
438
|
+
events;
|
|
439
|
+
session = null;
|
|
440
|
+
onSelectionCleared;
|
|
441
|
+
isActive() {
|
|
442
|
+
return this.session !== null;
|
|
443
|
+
}
|
|
444
|
+
activeLayerId() {
|
|
445
|
+
return this.session?.layerId ?? null;
|
|
446
|
+
}
|
|
447
|
+
start(layer) {
|
|
448
|
+
if (layer.type !== "image") return;
|
|
449
|
+
if (this.session) this.cancel();
|
|
450
|
+
const image = layer.fabricObject;
|
|
451
|
+
const prevAngle = image.angle ?? 0;
|
|
452
|
+
if (prevAngle) image.rotate(0);
|
|
453
|
+
image.setCoords();
|
|
454
|
+
const b = image.getBoundingRect();
|
|
455
|
+
const rect = new Rect({
|
|
456
|
+
left: b.left,
|
|
457
|
+
top: b.top,
|
|
458
|
+
width: b.width,
|
|
459
|
+
height: b.height,
|
|
460
|
+
originX: "left",
|
|
461
|
+
originY: "top",
|
|
462
|
+
fill: "rgba(34,197,94,0.12)",
|
|
463
|
+
stroke: STROKE2,
|
|
464
|
+
strokeWidth: 1,
|
|
465
|
+
strokeDashArray: [4, 4],
|
|
466
|
+
strokeUniform: true,
|
|
467
|
+
lockRotation: true,
|
|
468
|
+
cornerColor: STROKE2,
|
|
469
|
+
transparentCorners: false
|
|
470
|
+
});
|
|
471
|
+
rect.setControlsVisibility({ mtr: false });
|
|
472
|
+
const prevSelectable = image.selectable ?? true;
|
|
473
|
+
const prevEvented = image.evented ?? true;
|
|
474
|
+
image.selectable = false;
|
|
475
|
+
image.evented = false;
|
|
476
|
+
this.canvas.add(rect);
|
|
477
|
+
this.canvas.setActiveObject(rect);
|
|
478
|
+
this.canvas.requestRenderAll();
|
|
479
|
+
this.session = { layerId: layer.id, image, rect, prevSelectable, prevEvented, prevAngle };
|
|
480
|
+
this.events.emit("crop:changed", { active: true, layerId: layer.id });
|
|
481
|
+
}
|
|
482
|
+
apply() {
|
|
483
|
+
const s = this.session;
|
|
484
|
+
if (!s) return;
|
|
485
|
+
const { image, rect } = s;
|
|
486
|
+
rect.setCoords();
|
|
487
|
+
image.setCoords();
|
|
488
|
+
const scaleX = image.scaleX ?? 1;
|
|
489
|
+
const scaleY = image.scaleY ?? 1;
|
|
490
|
+
const imgLeft = image.left ?? 0;
|
|
491
|
+
const imgTop = image.top ?? 0;
|
|
492
|
+
const el = image.getElement();
|
|
493
|
+
const naturalW = el.naturalWidth || image.width || 0;
|
|
494
|
+
const naturalH = el.naturalHeight || image.height || 0;
|
|
495
|
+
let cropX = (image.cropX ?? 0) + (rect.left - imgLeft) / scaleX;
|
|
496
|
+
let cropY = (image.cropY ?? 0) + (rect.top - imgTop) / scaleY;
|
|
497
|
+
let cropW = rect.getScaledWidth() / scaleX;
|
|
498
|
+
let cropH = rect.getScaledHeight() / scaleY;
|
|
499
|
+
cropX = clamp(cropX, 0, Math.max(0, naturalW - 1));
|
|
500
|
+
cropY = clamp(cropY, 0, Math.max(0, naturalH - 1));
|
|
501
|
+
cropW = clamp(cropW, 1, naturalW - cropX);
|
|
502
|
+
cropH = clamp(cropH, 1, naturalH - cropY);
|
|
503
|
+
image.set({
|
|
504
|
+
cropX,
|
|
505
|
+
cropY,
|
|
506
|
+
width: cropW,
|
|
507
|
+
height: cropH,
|
|
508
|
+
// Anchor the displayed result where the rect sits.
|
|
509
|
+
left: imgLeft + (cropX - (image.cropX ?? 0)) * scaleX,
|
|
510
|
+
top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
|
|
511
|
+
});
|
|
512
|
+
image.setCoords();
|
|
513
|
+
this.finish();
|
|
514
|
+
this.history.save();
|
|
515
|
+
}
|
|
516
|
+
cancel() {
|
|
517
|
+
const s = this.session;
|
|
518
|
+
if (!s) return;
|
|
519
|
+
if (s.prevAngle) {
|
|
520
|
+
s.image.rotate(s.prevAngle);
|
|
521
|
+
s.image.setCoords();
|
|
522
|
+
}
|
|
523
|
+
this.finish();
|
|
524
|
+
}
|
|
525
|
+
dispose() {
|
|
526
|
+
this.cancel();
|
|
527
|
+
this.canvas.off("selection:cleared", this.onSelectionCleared);
|
|
528
|
+
}
|
|
529
|
+
finish() {
|
|
530
|
+
const s = this.session;
|
|
531
|
+
if (!s) return;
|
|
532
|
+
this.session = null;
|
|
533
|
+
this.canvas.remove(s.rect);
|
|
534
|
+
s.image.selectable = s.prevSelectable;
|
|
535
|
+
s.image.evented = s.prevEvented;
|
|
536
|
+
this.canvas.setActiveObject(s.image);
|
|
537
|
+
this.canvas.requestRenderAll();
|
|
538
|
+
this.events.emit("crop:changed", { active: false, layerId: null });
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
var STROKE2 = "#22c55e";
|
|
542
|
+
|
|
543
|
+
// src/pattern.ts
|
|
544
|
+
var PatternManager = class {
|
|
545
|
+
constructor(canvas, layers, history) {
|
|
546
|
+
this.canvas = canvas;
|
|
547
|
+
this.layers = layers;
|
|
548
|
+
this.history = history;
|
|
549
|
+
}
|
|
550
|
+
canvas;
|
|
551
|
+
layers;
|
|
552
|
+
history;
|
|
553
|
+
// Per-layer task chain. apply()/disable() both await an async setSrc on the
|
|
554
|
+
// same fabric image; running two concurrently lets their setSrc resolutions
|
|
555
|
+
// interleave (wrong image installed, original lost). Serialising per layer
|
|
556
|
+
// guarantees the last-requested operation wins and state stays consistent.
|
|
557
|
+
chains = /* @__PURE__ */ new Map();
|
|
558
|
+
isPattern(layerId) {
|
|
559
|
+
return !!this.layers.get(layerId)?.meta.pattern;
|
|
560
|
+
}
|
|
561
|
+
getConfig(layerId) {
|
|
562
|
+
return this.layers.get(layerId)?.meta.pattern?.config ?? null;
|
|
563
|
+
}
|
|
564
|
+
/** Turn a plain image layer into a pattern, or update an existing one. */
|
|
565
|
+
apply(layerId, config) {
|
|
566
|
+
return this.enqueue(layerId, async () => {
|
|
567
|
+
const layer = this.layers.get(layerId);
|
|
568
|
+
if (!layer || layer.type !== "image") return;
|
|
569
|
+
const image = layer.fabricObject;
|
|
570
|
+
const firstEnable = !layer.meta.pattern;
|
|
571
|
+
if (!layer.meta.pattern) {
|
|
572
|
+
layer.meta.pattern = {
|
|
573
|
+
config,
|
|
574
|
+
originalSrc: elementToDataURL(image) ?? image.getSrc(),
|
|
575
|
+
original: {
|
|
576
|
+
left: image.left ?? 0,
|
|
577
|
+
top: image.top ?? 0,
|
|
578
|
+
scaleX: image.scaleX ?? 1,
|
|
579
|
+
scaleY: image.scaleY ?? 1,
|
|
580
|
+
width: image.width ?? 0,
|
|
581
|
+
height: image.height ?? 0,
|
|
582
|
+
angle: image.angle ?? 0,
|
|
583
|
+
cropX: image.cropX ?? 0,
|
|
584
|
+
cropY: image.cropY ?? 0
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
} else {
|
|
588
|
+
layer.meta.pattern.config = config;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await this.renderLayer(layer);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (firstEnable) delete layer.meta.pattern;
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
this.history.save();
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/** Restore the original image and drop the pattern. */
|
|
600
|
+
disable(layerId) {
|
|
601
|
+
return this.enqueue(layerId, async () => {
|
|
602
|
+
const layer = this.layers.get(layerId);
|
|
603
|
+
const state = layer?.meta.pattern;
|
|
604
|
+
if (!layer || !state) return;
|
|
605
|
+
const image = layer.fabricObject;
|
|
606
|
+
await image.setSrc(state.originalSrc);
|
|
607
|
+
image.set({
|
|
608
|
+
left: state.original.left,
|
|
609
|
+
top: state.original.top,
|
|
610
|
+
scaleX: state.original.scaleX,
|
|
611
|
+
scaleY: state.original.scaleY,
|
|
612
|
+
width: state.original.width,
|
|
613
|
+
height: state.original.height,
|
|
614
|
+
cropX: state.original.cropX,
|
|
615
|
+
cropY: state.original.cropY,
|
|
616
|
+
angle: state.original.angle
|
|
617
|
+
});
|
|
618
|
+
image.setCoords();
|
|
619
|
+
delete layer.meta.pattern;
|
|
620
|
+
this.canvas.requestRenderAll();
|
|
621
|
+
this.history.save();
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/** Run `task` after any in-flight work for this layer, regardless of outcome. */
|
|
625
|
+
enqueue(layerId, task) {
|
|
626
|
+
const prev = this.chains.get(layerId) ?? Promise.resolve();
|
|
627
|
+
const next = prev.then(task, task);
|
|
628
|
+
this.chains.set(
|
|
629
|
+
layerId,
|
|
630
|
+
next.catch(() => void 0)
|
|
631
|
+
);
|
|
632
|
+
return next;
|
|
633
|
+
}
|
|
634
|
+
async renderLayer(layer) {
|
|
635
|
+
const state = layer.meta.pattern;
|
|
636
|
+
if (!state) return;
|
|
637
|
+
const image = layer.fabricObject;
|
|
638
|
+
const cw = this.canvas.getWidth();
|
|
639
|
+
const ch = this.canvas.getHeight();
|
|
640
|
+
const tileW = state.original.width * state.original.scaleX;
|
|
641
|
+
const tileH = state.original.height * state.original.scaleY;
|
|
642
|
+
const dataUrl = await buildPatternDataURL(
|
|
643
|
+
state.originalSrc,
|
|
644
|
+
state.config,
|
|
645
|
+
cw,
|
|
646
|
+
ch,
|
|
647
|
+
tileW,
|
|
648
|
+
tileH
|
|
649
|
+
);
|
|
650
|
+
await image.setSrc(dataUrl);
|
|
651
|
+
image.set({ left: 0, top: 0, scaleX: 1, scaleY: 1, cropX: 0, cropY: 0, angle: 0 });
|
|
652
|
+
image.setCoords();
|
|
653
|
+
this.canvas.requestRenderAll();
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
function elementToDataURL(image) {
|
|
657
|
+
try {
|
|
658
|
+
const el = image.getElement();
|
|
659
|
+
const w = el.naturalWidth || el.width;
|
|
660
|
+
const h = el.naturalHeight || el.height;
|
|
661
|
+
if (!w || !h) return null;
|
|
662
|
+
const off = document.createElement("canvas");
|
|
663
|
+
off.width = w;
|
|
664
|
+
off.height = h;
|
|
665
|
+
const ctx = off.getContext("2d");
|
|
666
|
+
if (!ctx) return null;
|
|
667
|
+
ctx.drawImage(el, 0, 0);
|
|
668
|
+
return off.toDataURL("image/png");
|
|
669
|
+
} catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
var IMAGE_CACHE_MAX = 16;
|
|
674
|
+
var imageCache = /* @__PURE__ */ new Map();
|
|
675
|
+
function loadImage(src) {
|
|
676
|
+
const cached = imageCache.get(src);
|
|
677
|
+
if (cached) {
|
|
678
|
+
imageCache.delete(src);
|
|
679
|
+
imageCache.set(src, cached);
|
|
680
|
+
return cached;
|
|
681
|
+
}
|
|
682
|
+
const promise = decodeImage(src);
|
|
683
|
+
promise.catch(() => {
|
|
684
|
+
if (imageCache.get(src) === promise) imageCache.delete(src);
|
|
685
|
+
});
|
|
686
|
+
imageCache.set(src, promise);
|
|
687
|
+
if (imageCache.size > IMAGE_CACHE_MAX) {
|
|
688
|
+
const oldest = imageCache.keys().next().value;
|
|
689
|
+
if (oldest !== void 0) imageCache.delete(oldest);
|
|
690
|
+
}
|
|
691
|
+
return promise;
|
|
692
|
+
}
|
|
693
|
+
function decodeImage(src) {
|
|
694
|
+
return new Promise((resolve, reject) => {
|
|
695
|
+
const img = new Image();
|
|
696
|
+
img.crossOrigin = "anonymous";
|
|
697
|
+
img.onload = () => resolve(img);
|
|
698
|
+
img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
|
|
699
|
+
img.src = src;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function clearPatternImageCache() {
|
|
703
|
+
imageCache.clear();
|
|
704
|
+
}
|
|
705
|
+
async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
|
|
706
|
+
const img = await loadImage(src);
|
|
707
|
+
const off = document.createElement("canvas");
|
|
708
|
+
off.width = Math.max(1, Math.round(targetW));
|
|
709
|
+
off.height = Math.max(1, Math.round(targetH));
|
|
710
|
+
const ctx = off.getContext("2d");
|
|
711
|
+
if (!ctx) return off.toDataURL("image/png");
|
|
712
|
+
drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
|
|
713
|
+
return off.toDataURL("image/png");
|
|
714
|
+
}
|
|
715
|
+
function computeTilePositions(config, targetW, targetH, baseW, baseH) {
|
|
716
|
+
const tileW = Math.max(1, baseW * (1 + config.horizontalSpacing / 100));
|
|
717
|
+
const tileH = Math.max(1, baseH * (1 + config.verticalSpacing / 100));
|
|
718
|
+
const diag = Math.sqrt(targetW * targetW + targetH * targetH);
|
|
719
|
+
const cols = Math.ceil(diag / tileW) + 2;
|
|
720
|
+
const rows = Math.ceil(diag / tileH) + 2;
|
|
721
|
+
const halfCols = Math.ceil(cols / 2);
|
|
722
|
+
const halfRows = Math.ceil(rows / 2);
|
|
723
|
+
const placements = [];
|
|
724
|
+
for (let j = -halfRows; j <= halfRows; j++) {
|
|
725
|
+
for (let i = -halfCols; i <= halfCols; i++) {
|
|
726
|
+
let x = i * tileW;
|
|
727
|
+
let y = j * tileH;
|
|
728
|
+
if (config.mode === "brick-horizontal" && mod2(j) === 1) {
|
|
729
|
+
x += tileW * (config.horizontalOffset / 100);
|
|
730
|
+
} else if (config.mode === "brick-vertical" && mod2(i) === 1) {
|
|
731
|
+
y += tileH * (config.horizontalOffset / 100);
|
|
732
|
+
}
|
|
733
|
+
const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
|
|
734
|
+
placements.push({ x, y, rotation });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return placements;
|
|
738
|
+
}
|
|
739
|
+
function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
|
|
740
|
+
ctx.save();
|
|
741
|
+
ctx.translate(targetW / 2, targetH / 2);
|
|
742
|
+
ctx.rotate(config.angle * Math.PI / 180);
|
|
743
|
+
for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
|
|
744
|
+
ctx.save();
|
|
745
|
+
ctx.translate(tile.x, tile.y);
|
|
746
|
+
ctx.rotate(tile.rotation);
|
|
747
|
+
ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
|
|
748
|
+
ctx.restore();
|
|
749
|
+
}
|
|
750
|
+
ctx.restore();
|
|
751
|
+
}
|
|
752
|
+
function mod2(n) {
|
|
753
|
+
return (n % 2 + 2) % 2;
|
|
754
|
+
}
|
|
755
|
+
|
|
276
756
|
// src/utils/units.ts
|
|
277
757
|
var MM_PER_INCH = 25.4;
|
|
278
758
|
var UnitConverter = class {
|
|
@@ -329,14 +809,20 @@ function serializeEditor(editor) {
|
|
|
329
809
|
height: editor.canvas.getHeight()
|
|
330
810
|
},
|
|
331
811
|
layers: editor.layers.getAll().map((layer) => layer.serialize()),
|
|
332
|
-
background
|
|
812
|
+
// The configured design background, not the live canvas value (which is
|
|
813
|
+
// forced transparent while a mockup preview is active).
|
|
814
|
+
background: editor.getDesignBackground(),
|
|
815
|
+
mockup: editor.getMockup()
|
|
333
816
|
};
|
|
334
817
|
}
|
|
335
818
|
async function deserializeEditor(editor, state) {
|
|
336
819
|
editor.layers.clear();
|
|
337
820
|
editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
|
|
338
821
|
if (state.background) {
|
|
339
|
-
editor.
|
|
822
|
+
editor.setBackground(state.background);
|
|
823
|
+
}
|
|
824
|
+
if (state.mockup !== void 0) {
|
|
825
|
+
editor.setMockup(state.mockup);
|
|
340
826
|
}
|
|
341
827
|
for (const serializedLayer of state.layers) {
|
|
342
828
|
await restoreLayer(editor, serializedLayer);
|
|
@@ -346,12 +832,10 @@ async function deserializeEditor(editor, state) {
|
|
|
346
832
|
async function restoreLayer(editor, serialized) {
|
|
347
833
|
const objects = await util.enlivenObjects([serialized.fabricObject]);
|
|
348
834
|
const fabricObject = objects[0];
|
|
349
|
-
const layer = editor.layers.add(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
serialized.id
|
|
354
|
-
);
|
|
835
|
+
const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
|
|
836
|
+
if (serialized.meta) {
|
|
837
|
+
layer.meta = serialized.meta;
|
|
838
|
+
}
|
|
355
839
|
if (!serialized.visible) {
|
|
356
840
|
editor.layers.setVisibility(layer.id, false);
|
|
357
841
|
}
|
|
@@ -382,32 +866,36 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
|
|
|
382
866
|
return canvas.toDataURL({ format, multiplier });
|
|
383
867
|
}
|
|
384
868
|
|
|
385
|
-
// src/utils/clamp.ts
|
|
386
|
-
function clamp(v, min, max) {
|
|
387
|
-
return Math.min(max, Math.max(min, v));
|
|
388
|
-
}
|
|
389
|
-
function round2(v) {
|
|
390
|
-
return Math.round(v * 100) / 100;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
869
|
// src/editor.ts
|
|
870
|
+
var MIN_ZOOM = 0.1;
|
|
871
|
+
var MAX_ZOOM = 8;
|
|
394
872
|
var CanvasEditor = class {
|
|
395
873
|
canvas;
|
|
396
874
|
layers;
|
|
397
875
|
history;
|
|
398
876
|
events;
|
|
399
877
|
units;
|
|
878
|
+
snapping;
|
|
879
|
+
crop;
|
|
880
|
+
patterns;
|
|
400
881
|
fileAdapter;
|
|
401
882
|
imageProvider;
|
|
883
|
+
zoomLevel = 1;
|
|
884
|
+
mockup = null;
|
|
885
|
+
// The design's configured background. The live canvas background is forced
|
|
886
|
+
// transparent while a mockup preview is shown, so this is the source of truth
|
|
887
|
+
// for serialization and export — not the (possibly transient) canvas value.
|
|
888
|
+
designBackground;
|
|
402
889
|
constructor(canvasElement, config) {
|
|
403
890
|
this.events = new EventEmitter();
|
|
404
891
|
this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
|
|
405
892
|
const widthPx = this.units.toPixels(config.width);
|
|
406
893
|
const heightPx = this.units.toPixels(config.height);
|
|
894
|
+
this.designBackground = config.backgroundColor ?? "#ffffff";
|
|
407
895
|
this.canvas = new Canvas(canvasElement, {
|
|
408
896
|
width: widthPx,
|
|
409
897
|
height: heightPx,
|
|
410
|
-
backgroundColor:
|
|
898
|
+
backgroundColor: this.designBackground,
|
|
411
899
|
preserveObjectStacking: config.preserveObjectStacking ?? true,
|
|
412
900
|
selection: true
|
|
413
901
|
});
|
|
@@ -422,12 +910,15 @@ var CanvasEditor = class {
|
|
|
422
910
|
},
|
|
423
911
|
events: this.events
|
|
424
912
|
});
|
|
913
|
+
this.snapping = new SnapManager(this.canvas, this.events);
|
|
914
|
+
this.crop = new CropController(this.canvas, this.history, this.events);
|
|
915
|
+
this.patterns = new PatternManager(this.canvas, this.layers, this.history);
|
|
425
916
|
this.setupCanvasEvents();
|
|
426
917
|
this.history.saveImmediate();
|
|
427
918
|
}
|
|
428
919
|
// ─── Layer Operations ────────────────────────────────
|
|
429
920
|
async addImage(url, options) {
|
|
430
|
-
const img = await FabricImage.fromURL(url, {}, options);
|
|
921
|
+
const img = await FabricImage.fromURL(url, {}, { originX: "left", originY: "top", ...options });
|
|
431
922
|
const layer = this.layers.add("image", img);
|
|
432
923
|
this.history.save();
|
|
433
924
|
return layer;
|
|
@@ -438,6 +929,9 @@ var CanvasEditor = class {
|
|
|
438
929
|
fontFamily: "Arial",
|
|
439
930
|
fill: "#000000",
|
|
440
931
|
width: 200,
|
|
932
|
+
// v7 defaults origin to center; keep top-left placement.
|
|
933
|
+
originX: "left",
|
|
934
|
+
originY: "top",
|
|
441
935
|
...options
|
|
442
936
|
});
|
|
443
937
|
const layer = this.layers.add("text", textbox);
|
|
@@ -456,9 +950,15 @@ var CanvasEditor = class {
|
|
|
456
950
|
fontSize: 24,
|
|
457
951
|
fontFamily: "Arial",
|
|
458
952
|
fill: "#666666",
|
|
459
|
-
width: 300
|
|
953
|
+
width: 300,
|
|
954
|
+
originX: "left",
|
|
955
|
+
originY: "top"
|
|
460
956
|
});
|
|
461
|
-
const layer = this.layers.add(
|
|
957
|
+
const layer = this.layers.add(
|
|
958
|
+
"template",
|
|
959
|
+
placeholder,
|
|
960
|
+
template.name
|
|
961
|
+
);
|
|
462
962
|
this.history.save();
|
|
463
963
|
return layer;
|
|
464
964
|
}
|
|
@@ -474,6 +974,36 @@ var CanvasEditor = class {
|
|
|
474
974
|
if (!active) return null;
|
|
475
975
|
return this.layers.findByObject(active) ?? null;
|
|
476
976
|
}
|
|
977
|
+
/** Mirror a layer along the given axis. */
|
|
978
|
+
flipLayer(id, axis) {
|
|
979
|
+
const layer = this.layers.get(id);
|
|
980
|
+
if (!layer) return;
|
|
981
|
+
const obj = layer.fabricObject;
|
|
982
|
+
if (axis === "horizontal") {
|
|
983
|
+
obj.set("flipX", !obj.flipX);
|
|
984
|
+
} else {
|
|
985
|
+
obj.set("flipY", !obj.flipY);
|
|
986
|
+
}
|
|
987
|
+
obj.setCoords();
|
|
988
|
+
this.canvas.requestRenderAll();
|
|
989
|
+
this.history.save();
|
|
990
|
+
}
|
|
991
|
+
/** Clone a layer (offset slightly) and select the copy. */
|
|
992
|
+
async duplicateLayer(id) {
|
|
993
|
+
const layer = this.layers.get(id);
|
|
994
|
+
if (!layer) return null;
|
|
995
|
+
const clone = await layer.fabricObject.clone();
|
|
996
|
+
if (!layer.meta.pattern) {
|
|
997
|
+
clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
|
|
998
|
+
}
|
|
999
|
+
clone.setCoords();
|
|
1000
|
+
const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
|
|
1001
|
+
copy.meta = structuredClone(layer.meta);
|
|
1002
|
+
this.canvas.setActiveObject(clone);
|
|
1003
|
+
this.canvas.requestRenderAll();
|
|
1004
|
+
this.history.save();
|
|
1005
|
+
return copy;
|
|
1006
|
+
}
|
|
477
1007
|
// ─── Serialization ──────────────────────────────────
|
|
478
1008
|
toJSON() {
|
|
479
1009
|
return serializeEditor(this);
|
|
@@ -484,18 +1014,33 @@ var CanvasEditor = class {
|
|
|
484
1014
|
// ─── Export ──────────────────────────────────────────
|
|
485
1015
|
async toPNG(options) {
|
|
486
1016
|
this.events.emit("export:start", { format: "png" });
|
|
487
|
-
const blob = await exportPNG(this.canvas, options);
|
|
1017
|
+
const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
|
|
488
1018
|
this.events.emit("export:complete", { format: "png" });
|
|
489
1019
|
return blob;
|
|
490
1020
|
}
|
|
491
1021
|
toSVG() {
|
|
492
1022
|
this.events.emit("export:start", { format: "svg" });
|
|
493
|
-
const svg = exportSVG(this.canvas);
|
|
1023
|
+
const svg = this.withDesignBackground(() => exportSVG(this.canvas));
|
|
494
1024
|
this.events.emit("export:complete", { format: "svg" });
|
|
495
1025
|
return svg;
|
|
496
1026
|
}
|
|
497
1027
|
toDataURL(format = "png", multiplier = 1) {
|
|
498
|
-
return exportDataURL(this.canvas, format, multiplier);
|
|
1028
|
+
return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Run an export with the configured design background applied, even when a
|
|
1032
|
+
* mockup preview has forced the live canvas transparent — so exports reflect
|
|
1033
|
+
* the design, not the preview. Restores the preview state afterwards.
|
|
1034
|
+
*/
|
|
1035
|
+
withDesignBackground(fn) {
|
|
1036
|
+
if (!this.mockup) return fn();
|
|
1037
|
+
const previewBg = this.canvas.backgroundColor;
|
|
1038
|
+
this.canvas.backgroundColor = this.designBackground;
|
|
1039
|
+
try {
|
|
1040
|
+
return fn();
|
|
1041
|
+
} finally {
|
|
1042
|
+
this.canvas.backgroundColor = previewBg;
|
|
1043
|
+
}
|
|
499
1044
|
}
|
|
500
1045
|
toPrintifyPositioning() {
|
|
501
1046
|
const result = {};
|
|
@@ -547,22 +1092,82 @@ var CanvasEditor = class {
|
|
|
547
1092
|
}
|
|
548
1093
|
// ─── Canvas Operations ──────────────────────────────
|
|
549
1094
|
setBackground(color) {
|
|
550
|
-
this.
|
|
551
|
-
this.
|
|
1095
|
+
this.designBackground = color;
|
|
1096
|
+
if (!this.mockup) {
|
|
1097
|
+
this.canvas.backgroundColor = color;
|
|
1098
|
+
this.canvas.requestRenderAll();
|
|
1099
|
+
}
|
|
552
1100
|
this.history.save();
|
|
553
1101
|
}
|
|
1102
|
+
getDesignBackground() {
|
|
1103
|
+
return this.designBackground;
|
|
1104
|
+
}
|
|
554
1105
|
resize(width, height) {
|
|
555
1106
|
const widthPx = this.units.toPixels(width);
|
|
556
1107
|
const heightPx = this.units.toPixels(height);
|
|
557
1108
|
this.canvas.setDimensions({ width: widthPx, height: heightPx });
|
|
558
1109
|
this.canvas.requestRenderAll();
|
|
559
1110
|
}
|
|
1111
|
+
// ─── Zoom ───────────────────────────────────────────
|
|
1112
|
+
//
|
|
1113
|
+
// Zoom is a *display* transform (the view layer CSS-scales the canvas), not a
|
|
1114
|
+
// fabric viewport zoom — fabric's setZoom bakes into toDataURL/toSVG and would
|
|
1115
|
+
// corrupt exports. The editor only tracks the level and emits an event.
|
|
1116
|
+
setZoom(level) {
|
|
1117
|
+
const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
|
|
1118
|
+
if (next === this.zoomLevel) return;
|
|
1119
|
+
this.zoomLevel = next;
|
|
1120
|
+
this.events.emit("zoom:changed", { zoom: next });
|
|
1121
|
+
}
|
|
1122
|
+
/** @deprecated use setZoom — kept for backward compatibility. */
|
|
560
1123
|
zoom(level) {
|
|
561
|
-
this.
|
|
1124
|
+
this.setZoom(level);
|
|
1125
|
+
}
|
|
1126
|
+
getZoom() {
|
|
1127
|
+
return this.zoomLevel;
|
|
1128
|
+
}
|
|
1129
|
+
zoomIn(step = 0.1) {
|
|
1130
|
+
this.setZoom(this.zoomLevel + step);
|
|
1131
|
+
}
|
|
1132
|
+
zoomOut(step = 0.1) {
|
|
1133
|
+
this.setZoom(this.zoomLevel - step);
|
|
1134
|
+
}
|
|
1135
|
+
resetZoom() {
|
|
1136
|
+
this.setZoom(1);
|
|
1137
|
+
}
|
|
1138
|
+
/** Fit the canvas inside a viewport (in px), accounting for padding. */
|
|
1139
|
+
zoomToFit(viewportWidth, viewportHeight, padding = 0) {
|
|
1140
|
+
const w = this.canvas.getWidth();
|
|
1141
|
+
const h = this.canvas.getHeight();
|
|
1142
|
+
if (w <= 0 || h <= 0) return;
|
|
1143
|
+
const sx = (viewportWidth - padding * 2) / w;
|
|
1144
|
+
const sy = (viewportHeight - padding * 2) / h;
|
|
1145
|
+
this.setZoom(Math.min(sx, sy));
|
|
1146
|
+
}
|
|
1147
|
+
// ─── Patterns ───────────────────────────────────────
|
|
1148
|
+
applyPattern(layerId, config) {
|
|
1149
|
+
return this.patterns.apply(layerId, config);
|
|
1150
|
+
}
|
|
1151
|
+
clearPattern(layerId) {
|
|
1152
|
+
return this.patterns.disable(layerId);
|
|
1153
|
+
}
|
|
1154
|
+
// ─── Mockup (preview-only) ──────────────────────────
|
|
1155
|
+
setMockup(mockup) {
|
|
1156
|
+
this.mockup = mockup;
|
|
1157
|
+
this.canvas.backgroundColor = mockup ? "" : this.designBackground;
|
|
562
1158
|
this.canvas.requestRenderAll();
|
|
1159
|
+
this.events.emit("mockup:changed", { mockup });
|
|
1160
|
+
}
|
|
1161
|
+
clearMockup() {
|
|
1162
|
+
this.setMockup(null);
|
|
1163
|
+
}
|
|
1164
|
+
getMockup() {
|
|
1165
|
+
return this.mockup;
|
|
563
1166
|
}
|
|
564
1167
|
// ─── Cleanup ────────────────────────────────────────
|
|
565
1168
|
dispose() {
|
|
1169
|
+
this.snapping.dispose();
|
|
1170
|
+
this.crop.dispose();
|
|
566
1171
|
this.history.dispose();
|
|
567
1172
|
this.events.removeAllListeners();
|
|
568
1173
|
this.canvas.dispose();
|
|
@@ -590,15 +1195,34 @@ var CanvasEditor = class {
|
|
|
590
1195
|
});
|
|
591
1196
|
}
|
|
592
1197
|
};
|
|
1198
|
+
|
|
1199
|
+
// src/types.ts
|
|
1200
|
+
var DEFAULT_PATTERN_CONFIG = {
|
|
1201
|
+
mode: "grid",
|
|
1202
|
+
horizontalSpacing: 0,
|
|
1203
|
+
verticalSpacing: 0,
|
|
1204
|
+
angle: 0,
|
|
1205
|
+
horizontalOffset: 0,
|
|
1206
|
+
rotationStepH: 0,
|
|
1207
|
+
rotationStepV: 0
|
|
1208
|
+
};
|
|
593
1209
|
export {
|
|
594
1210
|
CanvasEditor,
|
|
1211
|
+
CropController,
|
|
1212
|
+
DEFAULT_PATTERN_CONFIG,
|
|
595
1213
|
EventEmitter,
|
|
596
1214
|
HistoryManager,
|
|
597
1215
|
Layer,
|
|
598
1216
|
LayerManager,
|
|
1217
|
+
PatternManager,
|
|
1218
|
+
SnapManager,
|
|
599
1219
|
UnitConverter,
|
|
1220
|
+
buildPatternDataURL,
|
|
600
1221
|
clamp,
|
|
1222
|
+
clearPatternImageCache,
|
|
1223
|
+
computeTilePositions,
|
|
601
1224
|
deserializeEditor,
|
|
1225
|
+
drawTiles,
|
|
602
1226
|
exportDataURL,
|
|
603
1227
|
exportPNG,
|
|
604
1228
|
exportSVG,
|