@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.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() {
@@ -275,6 +290,7 @@ var HistoryManager = class {
275
290
  this.emitChanged();
276
291
  }
277
292
  async undo() {
293
+ this.cancelPending();
278
294
  const state = this.undoStack.pop();
279
295
  if (!state) return;
280
296
  this.redoStack.push(this.getState());
@@ -284,6 +300,7 @@ var HistoryManager = class {
284
300
  this.emitChanged();
285
301
  }
286
302
  async redo() {
303
+ this.cancelPending();
287
304
  const state = this.redoStack.pop();
288
305
  if (!state) return;
289
306
  this.undoStack.push(this.getState());
@@ -292,6 +309,11 @@ var HistoryManager = class {
292
309
  this.paused = false;
293
310
  this.emitChanged();
294
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
+ }
295
317
  pause() {
296
318
  this.paused = true;
297
319
  }
@@ -309,6 +331,12 @@ var HistoryManager = class {
309
331
  this.redoStack = [];
310
332
  this.emitChanged();
311
333
  }
334
+ cancelPending() {
335
+ if (this.debounceTimer) {
336
+ clearTimeout(this.debounceTimer);
337
+ this.debounceTimer = null;
338
+ }
339
+ }
312
340
  dispose() {
313
341
  if (this.debounceTimer) {
314
342
  clearTimeout(this.debounceTimer);
@@ -322,6 +350,466 @@ var HistoryManager = class {
322
350
  }
323
351
  };
324
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
+
325
813
  // src/utils/units.ts
326
814
  var MM_PER_INCH = 25.4;
327
815
  var UnitConverter = class {
@@ -368,7 +856,7 @@ var UnitConverter = class {
368
856
  };
369
857
 
370
858
  // src/serialization.ts
371
- var import_fabric = require("fabric");
859
+ var import_fabric2 = require("fabric");
372
860
  var VERSION = "1.0.0";
373
861
  function serializeEditor(editor) {
374
862
  return {
@@ -378,14 +866,20 @@ function serializeEditor(editor) {
378
866
  height: editor.canvas.getHeight()
379
867
  },
380
868
  layers: editor.layers.getAll().map((layer) => layer.serialize()),
381
- 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()
382
873
  };
383
874
  }
384
875
  async function deserializeEditor(editor, state) {
385
876
  editor.layers.clear();
386
877
  editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
387
878
  if (state.background) {
388
- editor.canvas.backgroundColor = state.background;
879
+ editor.setBackground(state.background);
880
+ }
881
+ if (state.mockup !== void 0) {
882
+ editor.setMockup(state.mockup);
389
883
  }
390
884
  for (const serializedLayer of state.layers) {
391
885
  await restoreLayer(editor, serializedLayer);
@@ -393,14 +887,12 @@ async function deserializeEditor(editor, state) {
393
887
  editor.canvas.requestRenderAll();
394
888
  }
395
889
  async function restoreLayer(editor, serialized) {
396
- const objects = await import_fabric.util.enlivenObjects([serialized.fabricObject]);
890
+ const objects = await import_fabric2.util.enlivenObjects([serialized.fabricObject]);
397
891
  const fabricObject = objects[0];
398
- const layer = editor.layers.add(
399
- serialized.type,
400
- fabricObject,
401
- serialized.name,
402
- serialized.id
403
- );
892
+ const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
893
+ if (serialized.meta) {
894
+ layer.meta = serialized.meta;
895
+ }
404
896
  if (!serialized.visible) {
405
897
  editor.layers.setVisibility(layer.id, false);
406
898
  }
@@ -431,32 +923,36 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
431
923
  return canvas.toDataURL({ format, multiplier });
432
924
  }
433
925
 
434
- // src/utils/clamp.ts
435
- function clamp(v, min, max) {
436
- return Math.min(max, Math.max(min, v));
437
- }
438
- function round2(v) {
439
- return Math.round(v * 100) / 100;
440
- }
441
-
442
926
  // src/editor.ts
927
+ var MIN_ZOOM = 0.1;
928
+ var MAX_ZOOM = 8;
443
929
  var CanvasEditor = class {
444
930
  canvas;
445
931
  layers;
446
932
  history;
447
933
  events;
448
934
  units;
935
+ snapping;
936
+ crop;
937
+ patterns;
449
938
  fileAdapter;
450
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;
451
946
  constructor(canvasElement, config) {
452
947
  this.events = new EventEmitter();
453
948
  this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
454
949
  const widthPx = this.units.toPixels(config.width);
455
950
  const heightPx = this.units.toPixels(config.height);
456
- this.canvas = new import_fabric2.Canvas(canvasElement, {
951
+ this.designBackground = config.backgroundColor ?? "#ffffff";
952
+ this.canvas = new import_fabric3.Canvas(canvasElement, {
457
953
  width: widthPx,
458
954
  height: heightPx,
459
- backgroundColor: config.backgroundColor ?? "#ffffff",
955
+ backgroundColor: this.designBackground,
460
956
  preserveObjectStacking: config.preserveObjectStacking ?? true,
461
957
  selection: true
462
958
  });
@@ -471,22 +967,28 @@ var CanvasEditor = class {
471
967
  },
472
968
  events: this.events
473
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);
474
973
  this.setupCanvasEvents();
475
974
  this.history.saveImmediate();
476
975
  }
477
976
  // ─── Layer Operations ────────────────────────────────
478
977
  async addImage(url, options) {
479
- const img = await import_fabric2.FabricImage.fromURL(url, {}, options);
978
+ const img = await import_fabric3.FabricImage.fromURL(url, {}, { originX: "left", originY: "top", ...options });
480
979
  const layer = this.layers.add("image", img);
481
980
  this.history.save();
482
981
  return layer;
483
982
  }
484
983
  addText(text, options) {
485
- const textbox = new import_fabric2.Textbox(text, {
984
+ const textbox = new import_fabric3.Textbox(text, {
486
985
  fontSize: 32,
487
986
  fontFamily: "Arial",
488
987
  fill: "#000000",
489
988
  width: 200,
989
+ // v7 defaults origin to center; keep top-left placement.
990
+ originX: "left",
991
+ originY: "top",
490
992
  ...options
491
993
  });
492
994
  const layer = this.layers.add("text", textbox);
@@ -505,9 +1007,15 @@ var CanvasEditor = class {
505
1007
  fontSize: 24,
506
1008
  fontFamily: "Arial",
507
1009
  fill: "#666666",
508
- width: 300
1010
+ width: 300,
1011
+ originX: "left",
1012
+ originY: "top"
509
1013
  });
510
- const layer = this.layers.add("template", placeholder, template.name);
1014
+ const layer = this.layers.add(
1015
+ "template",
1016
+ placeholder,
1017
+ template.name
1018
+ );
511
1019
  this.history.save();
512
1020
  return layer;
513
1021
  }
@@ -523,6 +1031,36 @@ var CanvasEditor = class {
523
1031
  if (!active) return null;
524
1032
  return this.layers.findByObject(active) ?? null;
525
1033
  }
1034
+ /** Mirror a layer along the given axis. */
1035
+ flipLayer(id, axis) {
1036
+ const layer = this.layers.get(id);
1037
+ if (!layer) return;
1038
+ const obj = layer.fabricObject;
1039
+ if (axis === "horizontal") {
1040
+ obj.set("flipX", !obj.flipX);
1041
+ } else {
1042
+ obj.set("flipY", !obj.flipY);
1043
+ }
1044
+ obj.setCoords();
1045
+ this.canvas.requestRenderAll();
1046
+ this.history.save();
1047
+ }
1048
+ /** Clone a layer (offset slightly) and select the copy. */
1049
+ async duplicateLayer(id) {
1050
+ const layer = this.layers.get(id);
1051
+ if (!layer) return null;
1052
+ const clone = await layer.fabricObject.clone();
1053
+ if (!layer.meta.pattern) {
1054
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
1055
+ }
1056
+ clone.setCoords();
1057
+ const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
1058
+ copy.meta = structuredClone(layer.meta);
1059
+ this.canvas.setActiveObject(clone);
1060
+ this.canvas.requestRenderAll();
1061
+ this.history.save();
1062
+ return copy;
1063
+ }
526
1064
  // ─── Serialization ──────────────────────────────────
527
1065
  toJSON() {
528
1066
  return serializeEditor(this);
@@ -533,18 +1071,33 @@ var CanvasEditor = class {
533
1071
  // ─── Export ──────────────────────────────────────────
534
1072
  async toPNG(options) {
535
1073
  this.events.emit("export:start", { format: "png" });
536
- const blob = await exportPNG(this.canvas, options);
1074
+ const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
537
1075
  this.events.emit("export:complete", { format: "png" });
538
1076
  return blob;
539
1077
  }
540
1078
  toSVG() {
541
1079
  this.events.emit("export:start", { format: "svg" });
542
- const svg = exportSVG(this.canvas);
1080
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
543
1081
  this.events.emit("export:complete", { format: "svg" });
544
1082
  return svg;
545
1083
  }
546
1084
  toDataURL(format = "png", multiplier = 1) {
547
- return exportDataURL(this.canvas, format, multiplier);
1085
+ return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1086
+ }
1087
+ /**
1088
+ * Run an export with the configured design background applied, even when a
1089
+ * mockup preview has forced the live canvas transparent — so exports reflect
1090
+ * the design, not the preview. Restores the preview state afterwards.
1091
+ */
1092
+ withDesignBackground(fn) {
1093
+ if (!this.mockup) return fn();
1094
+ const previewBg = this.canvas.backgroundColor;
1095
+ this.canvas.backgroundColor = this.designBackground;
1096
+ try {
1097
+ return fn();
1098
+ } finally {
1099
+ this.canvas.backgroundColor = previewBg;
1100
+ }
548
1101
  }
549
1102
  toPrintifyPositioning() {
550
1103
  const result = {};
@@ -596,22 +1149,82 @@ var CanvasEditor = class {
596
1149
  }
597
1150
  // ─── Canvas Operations ──────────────────────────────
598
1151
  setBackground(color) {
599
- this.canvas.backgroundColor = color;
600
- this.canvas.requestRenderAll();
1152
+ this.designBackground = color;
1153
+ if (!this.mockup) {
1154
+ this.canvas.backgroundColor = color;
1155
+ this.canvas.requestRenderAll();
1156
+ }
601
1157
  this.history.save();
602
1158
  }
1159
+ getDesignBackground() {
1160
+ return this.designBackground;
1161
+ }
603
1162
  resize(width, height) {
604
1163
  const widthPx = this.units.toPixels(width);
605
1164
  const heightPx = this.units.toPixels(height);
606
1165
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
607
1166
  this.canvas.requestRenderAll();
608
1167
  }
1168
+ // ─── Zoom ───────────────────────────────────────────
1169
+ //
1170
+ // Zoom is a *display* transform (the view layer CSS-scales the canvas), not a
1171
+ // fabric viewport zoom — fabric's setZoom bakes into toDataURL/toSVG and would
1172
+ // corrupt exports. The editor only tracks the level and emits an event.
1173
+ setZoom(level) {
1174
+ const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
1175
+ if (next === this.zoomLevel) return;
1176
+ this.zoomLevel = next;
1177
+ this.events.emit("zoom:changed", { zoom: next });
1178
+ }
1179
+ /** @deprecated use setZoom — kept for backward compatibility. */
609
1180
  zoom(level) {
610
- this.canvas.setZoom(level);
1181
+ this.setZoom(level);
1182
+ }
1183
+ getZoom() {
1184
+ return this.zoomLevel;
1185
+ }
1186
+ zoomIn(step = 0.1) {
1187
+ this.setZoom(this.zoomLevel + step);
1188
+ }
1189
+ zoomOut(step = 0.1) {
1190
+ this.setZoom(this.zoomLevel - step);
1191
+ }
1192
+ resetZoom() {
1193
+ this.setZoom(1);
1194
+ }
1195
+ /** Fit the canvas inside a viewport (in px), accounting for padding. */
1196
+ zoomToFit(viewportWidth, viewportHeight, padding = 0) {
1197
+ const w = this.canvas.getWidth();
1198
+ const h = this.canvas.getHeight();
1199
+ if (w <= 0 || h <= 0) return;
1200
+ const sx = (viewportWidth - padding * 2) / w;
1201
+ const sy = (viewportHeight - padding * 2) / h;
1202
+ this.setZoom(Math.min(sx, sy));
1203
+ }
1204
+ // ─── Patterns ───────────────────────────────────────
1205
+ applyPattern(layerId, config) {
1206
+ return this.patterns.apply(layerId, config);
1207
+ }
1208
+ clearPattern(layerId) {
1209
+ return this.patterns.disable(layerId);
1210
+ }
1211
+ // ─── Mockup (preview-only) ──────────────────────────
1212
+ setMockup(mockup) {
1213
+ this.mockup = mockup;
1214
+ this.canvas.backgroundColor = mockup ? "" : this.designBackground;
611
1215
  this.canvas.requestRenderAll();
1216
+ this.events.emit("mockup:changed", { mockup });
1217
+ }
1218
+ clearMockup() {
1219
+ this.setMockup(null);
1220
+ }
1221
+ getMockup() {
1222
+ return this.mockup;
612
1223
  }
613
1224
  // ─── Cleanup ────────────────────────────────────────
614
1225
  dispose() {
1226
+ this.snapping.dispose();
1227
+ this.crop.dispose();
615
1228
  this.history.dispose();
616
1229
  this.events.removeAllListeners();
617
1230
  this.canvas.dispose();
@@ -639,16 +1252,35 @@ var CanvasEditor = class {
639
1252
  });
640
1253
  }
641
1254
  };
1255
+
1256
+ // src/types.ts
1257
+ var DEFAULT_PATTERN_CONFIG = {
1258
+ mode: "grid",
1259
+ horizontalSpacing: 0,
1260
+ verticalSpacing: 0,
1261
+ angle: 0,
1262
+ horizontalOffset: 0,
1263
+ rotationStepH: 0,
1264
+ rotationStepV: 0
1265
+ };
642
1266
  // Annotate the CommonJS export names for ESM import in node:
643
1267
  0 && (module.exports = {
644
1268
  CanvasEditor,
1269
+ CropController,
1270
+ DEFAULT_PATTERN_CONFIG,
645
1271
  EventEmitter,
646
1272
  HistoryManager,
647
1273
  Layer,
648
1274
  LayerManager,
1275
+ PatternManager,
1276
+ SnapManager,
649
1277
  UnitConverter,
1278
+ buildPatternDataURL,
650
1279
  clamp,
1280
+ clearPatternImageCache,
1281
+ computeTilePositions,
651
1282
  deserializeEditor,
1283
+ drawTiles,
652
1284
  exportDataURL,
653
1285
  exportPNG,
654
1286
  exportSVG,