@overtone-art/canvas-editor-core 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -31,13 +31,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CanvasEditor: () => CanvasEditor,
34
+ CropController: () => CropController,
35
+ DEFAULT_PATTERN_CONFIG: () => DEFAULT_PATTERN_CONFIG,
34
36
  EventEmitter: () => EventEmitter,
35
37
  HistoryManager: () => HistoryManager,
36
38
  Layer: () => Layer,
37
39
  LayerManager: () => LayerManager,
40
+ PatternManager: () => PatternManager,
41
+ SnapManager: () => SnapManager,
38
42
  UnitConverter: () => UnitConverter,
43
+ buildPatternDataURL: () => buildPatternDataURL,
39
44
  clamp: () => clamp,
45
+ clearPatternImageCache: () => clearPatternImageCache,
46
+ computeTilePositions: () => computeTilePositions,
40
47
  deserializeEditor: () => deserializeEditor,
48
+ drawTiles: () => drawTiles,
41
49
  exportDataURL: () => exportDataURL,
42
50
  exportPNG: () => exportPNG,
43
51
  exportSVG: () => exportSVG,
@@ -48,7 +56,7 @@ __export(index_exports, {
48
56
  module.exports = __toCommonJS(index_exports);
49
57
 
50
58
  // src/editor.ts
51
- var import_fabric2 = require("fabric");
59
+ var import_fabric3 = require("fabric");
52
60
 
53
61
  // src/events.ts
54
62
  var EventEmitter = class {
@@ -97,6 +105,8 @@ var Layer = class {
97
105
  visible;
98
106
  locked;
99
107
  opacity;
108
+ /** Non-fabric data (e.g. pattern config) that must persist with the layer. */
109
+ meta;
100
110
  fabricObject;
101
111
  constructor(type, fabricObject, name, id) {
102
112
  this.id = id ?? generateId();
@@ -105,9 +115,13 @@ var Layer = class {
105
115
  this.visible = true;
106
116
  this.locked = false;
107
117
  this.opacity = 1;
118
+ this.meta = {};
108
119
  this.fabricObject = fabricObject;
109
120
  this.fabricObject._layerId = this.id;
110
121
  }
122
+ hasMeta() {
123
+ return Object.keys(this.meta).length > 0;
124
+ }
111
125
  toData() {
112
126
  return {
113
127
  id: this.id,
@@ -115,7 +129,8 @@ var Layer = class {
115
129
  name: this.name,
116
130
  visible: this.visible,
117
131
  locked: this.locked,
118
- opacity: this.opacity
132
+ opacity: this.opacity,
133
+ ...this.hasMeta() ? { meta: this.meta } : {}
119
134
  };
120
135
  }
121
136
  serialize() {
@@ -175,6 +190,8 @@ var LayerManager = class {
175
190
  }
176
191
  this.canvas.requestRenderAll();
177
192
  this.events.emit("layer:selected", { layerId: id });
193
+ const selected = id && this.get(id) ? [id] : [];
194
+ this.events.emit("selection:changed", { selected });
178
195
  }
179
196
  get(id) {
180
197
  return this.layers.find((l) => l.id === id);
@@ -273,6 +290,7 @@ var HistoryManager = class {
273
290
  this.emitChanged();
274
291
  }
275
292
  async undo() {
293
+ this.cancelPending();
276
294
  const state = this.undoStack.pop();
277
295
  if (!state) return;
278
296
  this.redoStack.push(this.getState());
@@ -282,6 +300,7 @@ var HistoryManager = class {
282
300
  this.emitChanged();
283
301
  }
284
302
  async redo() {
303
+ this.cancelPending();
285
304
  const state = this.redoStack.pop();
286
305
  if (!state) return;
287
306
  this.undoStack.push(this.getState());
@@ -290,6 +309,11 @@ var HistoryManager = class {
290
309
  this.paused = false;
291
310
  this.emitChanged();
292
311
  }
312
+ /** True while a restore (undo/redo/deserialize) is in flight. Managers that
313
+ * react to canvas events should skip mutating history during this window. */
314
+ isRestoring() {
315
+ return this.paused;
316
+ }
293
317
  pause() {
294
318
  this.paused = true;
295
319
  }
@@ -307,6 +331,12 @@ var HistoryManager = class {
307
331
  this.redoStack = [];
308
332
  this.emitChanged();
309
333
  }
334
+ cancelPending() {
335
+ if (this.debounceTimer) {
336
+ clearTimeout(this.debounceTimer);
337
+ this.debounceTimer = null;
338
+ }
339
+ }
310
340
  dispose() {
311
341
  if (this.debounceTimer) {
312
342
  clearTimeout(this.debounceTimer);
@@ -320,6 +350,466 @@ var HistoryManager = class {
320
350
  }
321
351
  };
322
352
 
353
+ // src/snapping.ts
354
+ var STROKE = "#22c55e";
355
+ var SnapManager = class {
356
+ constructor(canvas, events) {
357
+ this.canvas = canvas;
358
+ this.events = events;
359
+ this.onMoving = (e) => this.handleMoving(e.target);
360
+ this.onAfterRender = () => this.drawGuides();
361
+ this.onModified = () => this.clearGuides();
362
+ this.canvas.on("object:moving", this.onMoving);
363
+ this.canvas.on("after:render", this.onAfterRender);
364
+ this.canvas.on("mouse:up", this.onModified);
365
+ this.canvas.on("selection:cleared", this.onModified);
366
+ }
367
+ canvas;
368
+ events;
369
+ enabled = false;
370
+ threshold = 8;
371
+ guides = [];
372
+ onMoving;
373
+ onAfterRender;
374
+ onModified;
375
+ setEnabled(enabled) {
376
+ if (this.enabled === enabled) return;
377
+ this.enabled = enabled;
378
+ if (!enabled) this.clearGuides();
379
+ this.events.emit("snap:changed", { enabled });
380
+ }
381
+ isEnabled() {
382
+ return this.enabled;
383
+ }
384
+ setThreshold(px) {
385
+ this.threshold = Math.max(0, px);
386
+ }
387
+ dispose() {
388
+ this.canvas.off("object:moving", this.onMoving);
389
+ this.canvas.off("after:render", this.onAfterRender);
390
+ this.canvas.off("mouse:up", this.onModified);
391
+ this.canvas.off("selection:cleared", this.onModified);
392
+ this.guides = [];
393
+ }
394
+ handleMoving(target) {
395
+ if (!this.enabled || !target) return;
396
+ target.setCoords();
397
+ const r = target.getBoundingRect();
398
+ const cw = this.canvas.getWidth();
399
+ const ch = this.canvas.getHeight();
400
+ const { xs, ys } = collectCandidates(this.canvas, target, cw, ch);
401
+ this.guides = [];
402
+ const dx = bestSnap([r.left, r.left + r.width / 2, r.left + r.width], xs, this.threshold);
403
+ if (dx !== null) {
404
+ target.set("left", (target.left ?? 0) + dx.delta);
405
+ this.guides.push({ x: dx.line });
406
+ }
407
+ const dy = bestSnap([r.top, r.top + r.height / 2, r.top + r.height], ys, this.threshold);
408
+ if (dy !== null) {
409
+ target.set("top", (target.top ?? 0) + dy.delta);
410
+ this.guides.push({ y: dy.line });
411
+ }
412
+ target.setCoords();
413
+ }
414
+ drawGuides() {
415
+ if (!this.enabled || this.guides.length === 0) return;
416
+ const ctx = this.canvas.contextTop;
417
+ if (!ctx) return;
418
+ const retina = this.canvas.getRetinaScaling();
419
+ const cw = this.canvas.getWidth();
420
+ const ch = this.canvas.getHeight();
421
+ ctx.save();
422
+ ctx.setTransform(retina, 0, 0, retina, 0, 0);
423
+ ctx.lineWidth = 1;
424
+ ctx.strokeStyle = STROKE;
425
+ ctx.setLineDash([4, 4]);
426
+ ctx.beginPath();
427
+ for (const g of this.guides) {
428
+ if (g.x !== void 0) {
429
+ ctx.moveTo(g.x, 0);
430
+ ctx.lineTo(g.x, ch);
431
+ }
432
+ if (g.y !== void 0) {
433
+ ctx.moveTo(0, g.y);
434
+ ctx.lineTo(cw, g.y);
435
+ }
436
+ }
437
+ ctx.stroke();
438
+ ctx.restore();
439
+ }
440
+ clearGuides() {
441
+ if (this.guides.length === 0) return;
442
+ this.guides = [];
443
+ this.canvas.clearContext(this.canvas.contextTop);
444
+ }
445
+ };
446
+ function collectCandidates(canvas, moving, cw, ch) {
447
+ const xs = [0, cw / 2, cw];
448
+ const ys = [0, ch / 2, ch];
449
+ for (const obj of canvas.getObjects()) {
450
+ if (obj === moving || !obj.visible) continue;
451
+ obj.setCoords();
452
+ const r = obj.getBoundingRect();
453
+ xs.push(r.left, r.left + r.width / 2, r.left + r.width);
454
+ ys.push(r.top, r.top + r.height / 2, r.top + r.height);
455
+ }
456
+ return { xs, ys };
457
+ }
458
+ function bestSnap(edges, candidates, threshold) {
459
+ let best = null;
460
+ for (const edge of edges) {
461
+ for (const c of candidates) {
462
+ const dist = Math.abs(edge - c);
463
+ if (dist <= threshold && (best === null || dist < best.dist)) {
464
+ best = { delta: c - edge, line: c, dist };
465
+ }
466
+ }
467
+ }
468
+ return best ? { delta: best.delta, line: best.line } : null;
469
+ }
470
+
471
+ // src/crop.ts
472
+ var import_fabric = require("fabric");
473
+
474
+ // src/utils/clamp.ts
475
+ function clamp(v, min, max) {
476
+ return Math.min(max, Math.max(min, v));
477
+ }
478
+ function round2(v) {
479
+ return Math.round(v * 100) / 100;
480
+ }
481
+
482
+ // src/crop.ts
483
+ var CropController = class {
484
+ constructor(canvas, history, events) {
485
+ this.canvas = canvas;
486
+ this.history = history;
487
+ this.events = events;
488
+ this.onSelectionCleared = () => {
489
+ if (this.session) this.cancel();
490
+ };
491
+ this.canvas.on("selection:cleared", this.onSelectionCleared);
492
+ }
493
+ canvas;
494
+ history;
495
+ events;
496
+ session = null;
497
+ onSelectionCleared;
498
+ isActive() {
499
+ return this.session !== null;
500
+ }
501
+ activeLayerId() {
502
+ return this.session?.layerId ?? null;
503
+ }
504
+ start(layer) {
505
+ if (layer.type !== "image") return;
506
+ if (this.session) this.cancel();
507
+ const image = layer.fabricObject;
508
+ const prevAngle = image.angle ?? 0;
509
+ if (prevAngle) image.rotate(0);
510
+ image.setCoords();
511
+ const b = image.getBoundingRect();
512
+ const rect = new import_fabric.Rect({
513
+ left: b.left,
514
+ top: b.top,
515
+ width: b.width,
516
+ height: b.height,
517
+ originX: "left",
518
+ originY: "top",
519
+ fill: "rgba(34,197,94,0.12)",
520
+ stroke: STROKE2,
521
+ strokeWidth: 1,
522
+ strokeDashArray: [4, 4],
523
+ strokeUniform: true,
524
+ lockRotation: true,
525
+ cornerColor: STROKE2,
526
+ transparentCorners: false
527
+ });
528
+ rect.setControlsVisibility({ mtr: false });
529
+ const prevSelectable = image.selectable ?? true;
530
+ const prevEvented = image.evented ?? true;
531
+ image.selectable = false;
532
+ image.evented = false;
533
+ this.canvas.add(rect);
534
+ this.canvas.setActiveObject(rect);
535
+ this.canvas.requestRenderAll();
536
+ this.session = { layerId: layer.id, image, rect, prevSelectable, prevEvented, prevAngle };
537
+ this.events.emit("crop:changed", { active: true, layerId: layer.id });
538
+ }
539
+ apply() {
540
+ const s = this.session;
541
+ if (!s) return;
542
+ const { image, rect } = s;
543
+ rect.setCoords();
544
+ image.setCoords();
545
+ const scaleX = image.scaleX ?? 1;
546
+ const scaleY = image.scaleY ?? 1;
547
+ const imgLeft = image.left ?? 0;
548
+ const imgTop = image.top ?? 0;
549
+ const el = image.getElement();
550
+ const naturalW = el.naturalWidth || image.width || 0;
551
+ const naturalH = el.naturalHeight || image.height || 0;
552
+ let cropX = (image.cropX ?? 0) + (rect.left - imgLeft) / scaleX;
553
+ let cropY = (image.cropY ?? 0) + (rect.top - imgTop) / scaleY;
554
+ let cropW = rect.getScaledWidth() / scaleX;
555
+ let cropH = rect.getScaledHeight() / scaleY;
556
+ cropX = clamp(cropX, 0, Math.max(0, naturalW - 1));
557
+ cropY = clamp(cropY, 0, Math.max(0, naturalH - 1));
558
+ cropW = clamp(cropW, 1, naturalW - cropX);
559
+ cropH = clamp(cropH, 1, naturalH - cropY);
560
+ image.set({
561
+ cropX,
562
+ cropY,
563
+ width: cropW,
564
+ height: cropH,
565
+ // Anchor the displayed result where the rect sits.
566
+ left: imgLeft + (cropX - (image.cropX ?? 0)) * scaleX,
567
+ top: imgTop + (cropY - (image.cropY ?? 0)) * scaleY
568
+ });
569
+ image.setCoords();
570
+ this.finish();
571
+ this.history.save();
572
+ }
573
+ cancel() {
574
+ const s = this.session;
575
+ if (!s) return;
576
+ if (s.prevAngle) {
577
+ s.image.rotate(s.prevAngle);
578
+ s.image.setCoords();
579
+ }
580
+ this.finish();
581
+ }
582
+ dispose() {
583
+ this.cancel();
584
+ this.canvas.off("selection:cleared", this.onSelectionCleared);
585
+ }
586
+ finish() {
587
+ const s = this.session;
588
+ if (!s) return;
589
+ this.session = null;
590
+ this.canvas.remove(s.rect);
591
+ s.image.selectable = s.prevSelectable;
592
+ s.image.evented = s.prevEvented;
593
+ this.canvas.setActiveObject(s.image);
594
+ this.canvas.requestRenderAll();
595
+ this.events.emit("crop:changed", { active: false, layerId: null });
596
+ }
597
+ };
598
+ var STROKE2 = "#22c55e";
599
+
600
+ // src/pattern.ts
601
+ var PatternManager = class {
602
+ constructor(canvas, layers, history) {
603
+ this.canvas = canvas;
604
+ this.layers = layers;
605
+ this.history = history;
606
+ }
607
+ canvas;
608
+ layers;
609
+ history;
610
+ // Per-layer task chain. apply()/disable() both await an async setSrc on the
611
+ // same fabric image; running two concurrently lets their setSrc resolutions
612
+ // interleave (wrong image installed, original lost). Serialising per layer
613
+ // guarantees the last-requested operation wins and state stays consistent.
614
+ chains = /* @__PURE__ */ new Map();
615
+ isPattern(layerId) {
616
+ return !!this.layers.get(layerId)?.meta.pattern;
617
+ }
618
+ getConfig(layerId) {
619
+ return this.layers.get(layerId)?.meta.pattern?.config ?? null;
620
+ }
621
+ /** Turn a plain image layer into a pattern, or update an existing one. */
622
+ apply(layerId, config) {
623
+ return this.enqueue(layerId, async () => {
624
+ const layer = this.layers.get(layerId);
625
+ if (!layer || layer.type !== "image") return;
626
+ const image = layer.fabricObject;
627
+ const firstEnable = !layer.meta.pattern;
628
+ if (!layer.meta.pattern) {
629
+ layer.meta.pattern = {
630
+ config,
631
+ originalSrc: elementToDataURL(image) ?? image.getSrc(),
632
+ original: {
633
+ left: image.left ?? 0,
634
+ top: image.top ?? 0,
635
+ scaleX: image.scaleX ?? 1,
636
+ scaleY: image.scaleY ?? 1,
637
+ width: image.width ?? 0,
638
+ height: image.height ?? 0,
639
+ angle: image.angle ?? 0,
640
+ cropX: image.cropX ?? 0,
641
+ cropY: image.cropY ?? 0
642
+ }
643
+ };
644
+ } else {
645
+ layer.meta.pattern.config = config;
646
+ }
647
+ try {
648
+ await this.renderLayer(layer);
649
+ } catch (err) {
650
+ if (firstEnable) delete layer.meta.pattern;
651
+ throw err;
652
+ }
653
+ this.history.save();
654
+ });
655
+ }
656
+ /** Restore the original image and drop the pattern. */
657
+ disable(layerId) {
658
+ return this.enqueue(layerId, async () => {
659
+ const layer = this.layers.get(layerId);
660
+ const state = layer?.meta.pattern;
661
+ if (!layer || !state) return;
662
+ const image = layer.fabricObject;
663
+ await image.setSrc(state.originalSrc);
664
+ image.set({
665
+ left: state.original.left,
666
+ top: state.original.top,
667
+ scaleX: state.original.scaleX,
668
+ scaleY: state.original.scaleY,
669
+ width: state.original.width,
670
+ height: state.original.height,
671
+ cropX: state.original.cropX,
672
+ cropY: state.original.cropY,
673
+ angle: state.original.angle
674
+ });
675
+ image.setCoords();
676
+ delete layer.meta.pattern;
677
+ this.canvas.requestRenderAll();
678
+ this.history.save();
679
+ });
680
+ }
681
+ /** Run `task` after any in-flight work for this layer, regardless of outcome. */
682
+ enqueue(layerId, task) {
683
+ const prev = this.chains.get(layerId) ?? Promise.resolve();
684
+ const next = prev.then(task, task);
685
+ this.chains.set(
686
+ layerId,
687
+ next.catch(() => void 0)
688
+ );
689
+ return next;
690
+ }
691
+ async renderLayer(layer) {
692
+ const state = layer.meta.pattern;
693
+ if (!state) return;
694
+ const image = layer.fabricObject;
695
+ const cw = this.canvas.getWidth();
696
+ const ch = this.canvas.getHeight();
697
+ const tileW = state.original.width * state.original.scaleX;
698
+ const tileH = state.original.height * state.original.scaleY;
699
+ const dataUrl = await buildPatternDataURL(
700
+ state.originalSrc,
701
+ state.config,
702
+ cw,
703
+ ch,
704
+ tileW,
705
+ tileH
706
+ );
707
+ await image.setSrc(dataUrl);
708
+ image.set({ left: 0, top: 0, scaleX: 1, scaleY: 1, cropX: 0, cropY: 0, angle: 0 });
709
+ image.setCoords();
710
+ this.canvas.requestRenderAll();
711
+ }
712
+ };
713
+ function elementToDataURL(image) {
714
+ try {
715
+ const el = image.getElement();
716
+ const w = el.naturalWidth || el.width;
717
+ const h = el.naturalHeight || el.height;
718
+ if (!w || !h) return null;
719
+ const off = document.createElement("canvas");
720
+ off.width = w;
721
+ off.height = h;
722
+ const ctx = off.getContext("2d");
723
+ if (!ctx) return null;
724
+ ctx.drawImage(el, 0, 0);
725
+ return off.toDataURL("image/png");
726
+ } catch {
727
+ return null;
728
+ }
729
+ }
730
+ var IMAGE_CACHE_MAX = 16;
731
+ var imageCache = /* @__PURE__ */ new Map();
732
+ function loadImage(src) {
733
+ const cached = imageCache.get(src);
734
+ if (cached) {
735
+ imageCache.delete(src);
736
+ imageCache.set(src, cached);
737
+ return cached;
738
+ }
739
+ const promise = decodeImage(src);
740
+ promise.catch(() => {
741
+ if (imageCache.get(src) === promise) imageCache.delete(src);
742
+ });
743
+ imageCache.set(src, promise);
744
+ if (imageCache.size > IMAGE_CACHE_MAX) {
745
+ const oldest = imageCache.keys().next().value;
746
+ if (oldest !== void 0) imageCache.delete(oldest);
747
+ }
748
+ return promise;
749
+ }
750
+ function decodeImage(src) {
751
+ return new Promise((resolve, reject) => {
752
+ const img = new Image();
753
+ img.crossOrigin = "anonymous";
754
+ img.onload = () => resolve(img);
755
+ img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
756
+ img.src = src;
757
+ });
758
+ }
759
+ function clearPatternImageCache() {
760
+ imageCache.clear();
761
+ }
762
+ async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH) {
763
+ const img = await loadImage(src);
764
+ const off = document.createElement("canvas");
765
+ off.width = Math.max(1, Math.round(targetW));
766
+ off.height = Math.max(1, Math.round(targetH));
767
+ const ctx = off.getContext("2d");
768
+ if (!ctx) return off.toDataURL("image/png");
769
+ drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
770
+ return off.toDataURL("image/png");
771
+ }
772
+ function computeTilePositions(config, targetW, targetH, baseW, baseH) {
773
+ const tileW = Math.max(1, baseW * (1 + config.horizontalSpacing / 100));
774
+ const tileH = Math.max(1, baseH * (1 + config.verticalSpacing / 100));
775
+ const diag = Math.sqrt(targetW * targetW + targetH * targetH);
776
+ const cols = Math.ceil(diag / tileW) + 2;
777
+ const rows = Math.ceil(diag / tileH) + 2;
778
+ const halfCols = Math.ceil(cols / 2);
779
+ const halfRows = Math.ceil(rows / 2);
780
+ const placements = [];
781
+ for (let j = -halfRows; j <= halfRows; j++) {
782
+ for (let i = -halfCols; i <= halfCols; i++) {
783
+ let x = i * tileW;
784
+ let y = j * tileH;
785
+ if (config.mode === "brick-horizontal" && mod2(j) === 1) {
786
+ x += tileW * (config.horizontalOffset / 100);
787
+ } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
788
+ y += tileH * (config.horizontalOffset / 100);
789
+ }
790
+ const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
791
+ placements.push({ x, y, rotation });
792
+ }
793
+ }
794
+ return placements;
795
+ }
796
+ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
797
+ ctx.save();
798
+ ctx.translate(targetW / 2, targetH / 2);
799
+ ctx.rotate(config.angle * Math.PI / 180);
800
+ for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
801
+ ctx.save();
802
+ ctx.translate(tile.x, tile.y);
803
+ ctx.rotate(tile.rotation);
804
+ ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
805
+ ctx.restore();
806
+ }
807
+ ctx.restore();
808
+ }
809
+ function mod2(n) {
810
+ return (n % 2 + 2) % 2;
811
+ }
812
+
323
813
  // src/utils/units.ts
324
814
  var MM_PER_INCH = 25.4;
325
815
  var UnitConverter = class {
@@ -366,7 +856,7 @@ var UnitConverter = class {
366
856
  };
367
857
 
368
858
  // src/serialization.ts
369
- var import_fabric = require("fabric");
859
+ var import_fabric2 = require("fabric");
370
860
  var VERSION = "1.0.0";
371
861
  function serializeEditor(editor) {
372
862
  return {
@@ -376,14 +866,20 @@ function serializeEditor(editor) {
376
866
  height: editor.canvas.getHeight()
377
867
  },
378
868
  layers: editor.layers.getAll().map((layer) => layer.serialize()),
379
- background: editor.canvas.backgroundColor
869
+ // The configured design background, not the live canvas value (which is
870
+ // forced transparent while a mockup preview is active).
871
+ background: editor.getDesignBackground(),
872
+ mockup: editor.getMockup()
380
873
  };
381
874
  }
382
875
  async function deserializeEditor(editor, state) {
383
876
  editor.layers.clear();
384
877
  editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
385
878
  if (state.background) {
386
- editor.canvas.backgroundColor = state.background;
879
+ editor.setBackground(state.background);
880
+ }
881
+ if (state.mockup !== void 0) {
882
+ editor.setMockup(state.mockup);
387
883
  }
388
884
  for (const serializedLayer of state.layers) {
389
885
  await restoreLayer(editor, serializedLayer);
@@ -391,14 +887,12 @@ async function deserializeEditor(editor, state) {
391
887
  editor.canvas.requestRenderAll();
392
888
  }
393
889
  async function restoreLayer(editor, serialized) {
394
- const objects = await import_fabric.util.enlivenObjects([serialized.fabricObject]);
890
+ const objects = await import_fabric2.util.enlivenObjects([serialized.fabricObject]);
395
891
  const fabricObject = objects[0];
396
- const layer = editor.layers.add(
397
- serialized.type,
398
- fabricObject,
399
- serialized.name,
400
- serialized.id
401
- );
892
+ const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
893
+ if (serialized.meta) {
894
+ layer.meta = serialized.meta;
895
+ }
402
896
  if (!serialized.visible) {
403
897
  editor.layers.setVisibility(layer.id, false);
404
898
  }
@@ -429,32 +923,36 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
429
923
  return canvas.toDataURL({ format, multiplier });
430
924
  }
431
925
 
432
- // src/utils/clamp.ts
433
- function clamp(v, min, max) {
434
- return Math.min(max, Math.max(min, v));
435
- }
436
- function round2(v) {
437
- return Math.round(v * 100) / 100;
438
- }
439
-
440
926
  // src/editor.ts
927
+ var MIN_ZOOM = 0.1;
928
+ var MAX_ZOOM = 8;
441
929
  var CanvasEditor = class {
442
930
  canvas;
443
931
  layers;
444
932
  history;
445
933
  events;
446
934
  units;
935
+ snapping;
936
+ crop;
937
+ patterns;
447
938
  fileAdapter;
448
939
  imageProvider;
940
+ zoomLevel = 1;
941
+ mockup = null;
942
+ // The design's configured background. The live canvas background is forced
943
+ // transparent while a mockup preview is shown, so this is the source of truth
944
+ // for serialization and export — not the (possibly transient) canvas value.
945
+ designBackground;
449
946
  constructor(canvasElement, config) {
450
947
  this.events = new EventEmitter();
451
948
  this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
452
949
  const widthPx = this.units.toPixels(config.width);
453
950
  const heightPx = this.units.toPixels(config.height);
454
- this.canvas = new import_fabric2.Canvas(canvasElement, {
951
+ this.designBackground = config.backgroundColor ?? "#ffffff";
952
+ this.canvas = new import_fabric3.Canvas(canvasElement, {
455
953
  width: widthPx,
456
954
  height: heightPx,
457
- backgroundColor: config.backgroundColor ?? "#ffffff",
955
+ backgroundColor: this.designBackground,
458
956
  preserveObjectStacking: config.preserveObjectStacking ?? true,
459
957
  selection: true
460
958
  });
@@ -469,18 +967,21 @@ var CanvasEditor = class {
469
967
  },
470
968
  events: this.events
471
969
  });
970
+ this.snapping = new SnapManager(this.canvas, this.events);
971
+ this.crop = new CropController(this.canvas, this.history, this.events);
972
+ this.patterns = new PatternManager(this.canvas, this.layers, this.history);
472
973
  this.setupCanvasEvents();
473
974
  this.history.saveImmediate();
474
975
  }
475
976
  // ─── Layer Operations ────────────────────────────────
476
977
  async addImage(url, options) {
477
- const img = await import_fabric2.FabricImage.fromURL(url, {}, options);
978
+ const img = await import_fabric3.FabricImage.fromURL(url, {}, options);
478
979
  const layer = this.layers.add("image", img);
479
980
  this.history.save();
480
981
  return layer;
481
982
  }
482
983
  addText(text, options) {
483
- const textbox = new import_fabric2.Textbox(text, {
984
+ const textbox = new import_fabric3.Textbox(text, {
484
985
  fontSize: 32,
485
986
  fontFamily: "Arial",
486
987
  fill: "#000000",
@@ -505,7 +1006,11 @@ var CanvasEditor = class {
505
1006
  fill: "#666666",
506
1007
  width: 300
507
1008
  });
508
- const layer = this.layers.add("template", placeholder, template.name);
1009
+ const layer = this.layers.add(
1010
+ "template",
1011
+ placeholder,
1012
+ template.name
1013
+ );
509
1014
  this.history.save();
510
1015
  return layer;
511
1016
  }
@@ -521,6 +1026,36 @@ var CanvasEditor = class {
521
1026
  if (!active) return null;
522
1027
  return this.layers.findByObject(active) ?? null;
523
1028
  }
1029
+ /** Mirror a layer along the given axis. */
1030
+ flipLayer(id, axis) {
1031
+ const layer = this.layers.get(id);
1032
+ if (!layer) return;
1033
+ const obj = layer.fabricObject;
1034
+ if (axis === "horizontal") {
1035
+ obj.set("flipX", !obj.flipX);
1036
+ } else {
1037
+ obj.set("flipY", !obj.flipY);
1038
+ }
1039
+ obj.setCoords();
1040
+ this.canvas.requestRenderAll();
1041
+ this.history.save();
1042
+ }
1043
+ /** Clone a layer (offset slightly) and select the copy. */
1044
+ async duplicateLayer(id) {
1045
+ const layer = this.layers.get(id);
1046
+ if (!layer) return null;
1047
+ const clone = await layer.fabricObject.clone();
1048
+ if (!layer.meta.pattern) {
1049
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
1050
+ }
1051
+ clone.setCoords();
1052
+ const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
1053
+ copy.meta = structuredClone(layer.meta);
1054
+ this.canvas.setActiveObject(clone);
1055
+ this.canvas.requestRenderAll();
1056
+ this.history.save();
1057
+ return copy;
1058
+ }
524
1059
  // ─── Serialization ──────────────────────────────────
525
1060
  toJSON() {
526
1061
  return serializeEditor(this);
@@ -531,18 +1066,33 @@ var CanvasEditor = class {
531
1066
  // ─── Export ──────────────────────────────────────────
532
1067
  async toPNG(options) {
533
1068
  this.events.emit("export:start", { format: "png" });
534
- const blob = await exportPNG(this.canvas, options);
1069
+ const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
535
1070
  this.events.emit("export:complete", { format: "png" });
536
1071
  return blob;
537
1072
  }
538
1073
  toSVG() {
539
1074
  this.events.emit("export:start", { format: "svg" });
540
- const svg = exportSVG(this.canvas);
1075
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
541
1076
  this.events.emit("export:complete", { format: "svg" });
542
1077
  return svg;
543
1078
  }
544
1079
  toDataURL(format = "png", multiplier = 1) {
545
- return exportDataURL(this.canvas, format, multiplier);
1080
+ return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1081
+ }
1082
+ /**
1083
+ * Run an export with the configured design background applied, even when a
1084
+ * mockup preview has forced the live canvas transparent — so exports reflect
1085
+ * the design, not the preview. Restores the preview state afterwards.
1086
+ */
1087
+ withDesignBackground(fn) {
1088
+ if (!this.mockup) return fn();
1089
+ const previewBg = this.canvas.backgroundColor;
1090
+ this.canvas.backgroundColor = this.designBackground;
1091
+ try {
1092
+ return fn();
1093
+ } finally {
1094
+ this.canvas.backgroundColor = previewBg;
1095
+ }
546
1096
  }
547
1097
  toPrintifyPositioning() {
548
1098
  const result = {};
@@ -594,22 +1144,82 @@ var CanvasEditor = class {
594
1144
  }
595
1145
  // ─── Canvas Operations ──────────────────────────────
596
1146
  setBackground(color) {
597
- this.canvas.backgroundColor = color;
598
- this.canvas.requestRenderAll();
1147
+ this.designBackground = color;
1148
+ if (!this.mockup) {
1149
+ this.canvas.backgroundColor = color;
1150
+ this.canvas.requestRenderAll();
1151
+ }
599
1152
  this.history.save();
600
1153
  }
1154
+ getDesignBackground() {
1155
+ return this.designBackground;
1156
+ }
601
1157
  resize(width, height) {
602
1158
  const widthPx = this.units.toPixels(width);
603
1159
  const heightPx = this.units.toPixels(height);
604
1160
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
605
1161
  this.canvas.requestRenderAll();
606
1162
  }
1163
+ // ─── Zoom ───────────────────────────────────────────
1164
+ //
1165
+ // Zoom is a *display* transform (the view layer CSS-scales the canvas), not a
1166
+ // fabric viewport zoom — fabric's setZoom bakes into toDataURL/toSVG and would
1167
+ // corrupt exports. The editor only tracks the level and emits an event.
1168
+ setZoom(level) {
1169
+ const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
1170
+ if (next === this.zoomLevel) return;
1171
+ this.zoomLevel = next;
1172
+ this.events.emit("zoom:changed", { zoom: next });
1173
+ }
1174
+ /** @deprecated use setZoom — kept for backward compatibility. */
607
1175
  zoom(level) {
608
- this.canvas.setZoom(level);
1176
+ this.setZoom(level);
1177
+ }
1178
+ getZoom() {
1179
+ return this.zoomLevel;
1180
+ }
1181
+ zoomIn(step = 0.1) {
1182
+ this.setZoom(this.zoomLevel + step);
1183
+ }
1184
+ zoomOut(step = 0.1) {
1185
+ this.setZoom(this.zoomLevel - step);
1186
+ }
1187
+ resetZoom() {
1188
+ this.setZoom(1);
1189
+ }
1190
+ /** Fit the canvas inside a viewport (in px), accounting for padding. */
1191
+ zoomToFit(viewportWidth, viewportHeight, padding = 0) {
1192
+ const w = this.canvas.getWidth();
1193
+ const h = this.canvas.getHeight();
1194
+ if (w <= 0 || h <= 0) return;
1195
+ const sx = (viewportWidth - padding * 2) / w;
1196
+ const sy = (viewportHeight - padding * 2) / h;
1197
+ this.setZoom(Math.min(sx, sy));
1198
+ }
1199
+ // ─── Patterns ───────────────────────────────────────
1200
+ applyPattern(layerId, config) {
1201
+ return this.patterns.apply(layerId, config);
1202
+ }
1203
+ clearPattern(layerId) {
1204
+ return this.patterns.disable(layerId);
1205
+ }
1206
+ // ─── Mockup (preview-only) ──────────────────────────
1207
+ setMockup(mockup) {
1208
+ this.mockup = mockup;
1209
+ this.canvas.backgroundColor = mockup ? "" : this.designBackground;
609
1210
  this.canvas.requestRenderAll();
1211
+ this.events.emit("mockup:changed", { mockup });
1212
+ }
1213
+ clearMockup() {
1214
+ this.setMockup(null);
1215
+ }
1216
+ getMockup() {
1217
+ return this.mockup;
610
1218
  }
611
1219
  // ─── Cleanup ────────────────────────────────────────
612
1220
  dispose() {
1221
+ this.snapping.dispose();
1222
+ this.crop.dispose();
613
1223
  this.history.dispose();
614
1224
  this.events.removeAllListeners();
615
1225
  this.canvas.dispose();
@@ -637,16 +1247,35 @@ var CanvasEditor = class {
637
1247
  });
638
1248
  }
639
1249
  };
1250
+
1251
+ // src/types.ts
1252
+ var DEFAULT_PATTERN_CONFIG = {
1253
+ mode: "grid",
1254
+ horizontalSpacing: 0,
1255
+ verticalSpacing: 0,
1256
+ angle: 0,
1257
+ horizontalOffset: 0,
1258
+ rotationStepH: 0,
1259
+ rotationStepV: 0
1260
+ };
640
1261
  // Annotate the CommonJS export names for ESM import in node:
641
1262
  0 && (module.exports = {
642
1263
  CanvasEditor,
1264
+ CropController,
1265
+ DEFAULT_PATTERN_CONFIG,
643
1266
  EventEmitter,
644
1267
  HistoryManager,
645
1268
  Layer,
646
1269
  LayerManager,
1270
+ PatternManager,
1271
+ SnapManager,
647
1272
  UnitConverter,
1273
+ buildPatternDataURL,
648
1274
  clamp,
1275
+ clearPatternImageCache,
1276
+ computeTilePositions,
649
1277
  deserializeEditor,
1278
+ drawTiles,
650
1279
  exportDataURL,
651
1280
  exportPNG,
652
1281
  exportSVG,