@overtone-art/canvas-editor-core 0.1.0 → 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.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: editor.canvas.backgroundColor
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.canvas.backgroundColor = state.background;
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
- serialized.type,
351
- fabricObject,
352
- serialized.name,
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: config.backgroundColor ?? "#ffffff",
898
+ backgroundColor: this.designBackground,
411
899
  preserveObjectStacking: config.preserveObjectStacking ?? true,
412
900
  selection: true
413
901
  });
@@ -422,6 +910,9 @@ 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
  }
@@ -458,7 +949,11 @@ var CanvasEditor = class {
458
949
  fill: "#666666",
459
950
  width: 300
460
951
  });
461
- const layer = this.layers.add("template", placeholder, template.name);
952
+ const layer = this.layers.add(
953
+ "template",
954
+ placeholder,
955
+ template.name
956
+ );
462
957
  this.history.save();
463
958
  return layer;
464
959
  }
@@ -474,6 +969,36 @@ var CanvasEditor = class {
474
969
  if (!active) return null;
475
970
  return this.layers.findByObject(active) ?? null;
476
971
  }
972
+ /** Mirror a layer along the given axis. */
973
+ flipLayer(id, axis) {
974
+ const layer = this.layers.get(id);
975
+ if (!layer) return;
976
+ const obj = layer.fabricObject;
977
+ if (axis === "horizontal") {
978
+ obj.set("flipX", !obj.flipX);
979
+ } else {
980
+ obj.set("flipY", !obj.flipY);
981
+ }
982
+ obj.setCoords();
983
+ this.canvas.requestRenderAll();
984
+ this.history.save();
985
+ }
986
+ /** Clone a layer (offset slightly) and select the copy. */
987
+ async duplicateLayer(id) {
988
+ const layer = this.layers.get(id);
989
+ if (!layer) return null;
990
+ const clone = await layer.fabricObject.clone();
991
+ if (!layer.meta.pattern) {
992
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
993
+ }
994
+ clone.setCoords();
995
+ const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
996
+ copy.meta = structuredClone(layer.meta);
997
+ this.canvas.setActiveObject(clone);
998
+ this.canvas.requestRenderAll();
999
+ this.history.save();
1000
+ return copy;
1001
+ }
477
1002
  // ─── Serialization ──────────────────────────────────
478
1003
  toJSON() {
479
1004
  return serializeEditor(this);
@@ -484,18 +1009,33 @@ var CanvasEditor = class {
484
1009
  // ─── Export ──────────────────────────────────────────
485
1010
  async toPNG(options) {
486
1011
  this.events.emit("export:start", { format: "png" });
487
- const blob = await exportPNG(this.canvas, options);
1012
+ const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
488
1013
  this.events.emit("export:complete", { format: "png" });
489
1014
  return blob;
490
1015
  }
491
1016
  toSVG() {
492
1017
  this.events.emit("export:start", { format: "svg" });
493
- const svg = exportSVG(this.canvas);
1018
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
494
1019
  this.events.emit("export:complete", { format: "svg" });
495
1020
  return svg;
496
1021
  }
497
1022
  toDataURL(format = "png", multiplier = 1) {
498
- return exportDataURL(this.canvas, format, multiplier);
1023
+ return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
1024
+ }
1025
+ /**
1026
+ * Run an export with the configured design background applied, even when a
1027
+ * mockup preview has forced the live canvas transparent — so exports reflect
1028
+ * the design, not the preview. Restores the preview state afterwards.
1029
+ */
1030
+ withDesignBackground(fn) {
1031
+ if (!this.mockup) return fn();
1032
+ const previewBg = this.canvas.backgroundColor;
1033
+ this.canvas.backgroundColor = this.designBackground;
1034
+ try {
1035
+ return fn();
1036
+ } finally {
1037
+ this.canvas.backgroundColor = previewBg;
1038
+ }
499
1039
  }
500
1040
  toPrintifyPositioning() {
501
1041
  const result = {};
@@ -547,22 +1087,82 @@ var CanvasEditor = class {
547
1087
  }
548
1088
  // ─── Canvas Operations ──────────────────────────────
549
1089
  setBackground(color) {
550
- this.canvas.backgroundColor = color;
551
- this.canvas.requestRenderAll();
1090
+ this.designBackground = color;
1091
+ if (!this.mockup) {
1092
+ this.canvas.backgroundColor = color;
1093
+ this.canvas.requestRenderAll();
1094
+ }
552
1095
  this.history.save();
553
1096
  }
1097
+ getDesignBackground() {
1098
+ return this.designBackground;
1099
+ }
554
1100
  resize(width, height) {
555
1101
  const widthPx = this.units.toPixels(width);
556
1102
  const heightPx = this.units.toPixels(height);
557
1103
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
558
1104
  this.canvas.requestRenderAll();
559
1105
  }
1106
+ // ─── Zoom ───────────────────────────────────────────
1107
+ //
1108
+ // Zoom is a *display* transform (the view layer CSS-scales the canvas), not a
1109
+ // fabric viewport zoom — fabric's setZoom bakes into toDataURL/toSVG and would
1110
+ // corrupt exports. The editor only tracks the level and emits an event.
1111
+ setZoom(level) {
1112
+ const next = clamp(round2(level), MIN_ZOOM, MAX_ZOOM);
1113
+ if (next === this.zoomLevel) return;
1114
+ this.zoomLevel = next;
1115
+ this.events.emit("zoom:changed", { zoom: next });
1116
+ }
1117
+ /** @deprecated use setZoom — kept for backward compatibility. */
560
1118
  zoom(level) {
561
- this.canvas.setZoom(level);
1119
+ this.setZoom(level);
1120
+ }
1121
+ getZoom() {
1122
+ return this.zoomLevel;
1123
+ }
1124
+ zoomIn(step = 0.1) {
1125
+ this.setZoom(this.zoomLevel + step);
1126
+ }
1127
+ zoomOut(step = 0.1) {
1128
+ this.setZoom(this.zoomLevel - step);
1129
+ }
1130
+ resetZoom() {
1131
+ this.setZoom(1);
1132
+ }
1133
+ /** Fit the canvas inside a viewport (in px), accounting for padding. */
1134
+ zoomToFit(viewportWidth, viewportHeight, padding = 0) {
1135
+ const w = this.canvas.getWidth();
1136
+ const h = this.canvas.getHeight();
1137
+ if (w <= 0 || h <= 0) return;
1138
+ const sx = (viewportWidth - padding * 2) / w;
1139
+ const sy = (viewportHeight - padding * 2) / h;
1140
+ this.setZoom(Math.min(sx, sy));
1141
+ }
1142
+ // ─── Patterns ───────────────────────────────────────
1143
+ applyPattern(layerId, config) {
1144
+ return this.patterns.apply(layerId, config);
1145
+ }
1146
+ clearPattern(layerId) {
1147
+ return this.patterns.disable(layerId);
1148
+ }
1149
+ // ─── Mockup (preview-only) ──────────────────────────
1150
+ setMockup(mockup) {
1151
+ this.mockup = mockup;
1152
+ this.canvas.backgroundColor = mockup ? "" : this.designBackground;
562
1153
  this.canvas.requestRenderAll();
1154
+ this.events.emit("mockup:changed", { mockup });
1155
+ }
1156
+ clearMockup() {
1157
+ this.setMockup(null);
1158
+ }
1159
+ getMockup() {
1160
+ return this.mockup;
563
1161
  }
564
1162
  // ─── Cleanup ────────────────────────────────────────
565
1163
  dispose() {
1164
+ this.snapping.dispose();
1165
+ this.crop.dispose();
566
1166
  this.history.dispose();
567
1167
  this.events.removeAllListeners();
568
1168
  this.canvas.dispose();
@@ -590,15 +1190,34 @@ var CanvasEditor = class {
590
1190
  });
591
1191
  }
592
1192
  };
1193
+
1194
+ // src/types.ts
1195
+ var DEFAULT_PATTERN_CONFIG = {
1196
+ mode: "grid",
1197
+ horizontalSpacing: 0,
1198
+ verticalSpacing: 0,
1199
+ angle: 0,
1200
+ horizontalOffset: 0,
1201
+ rotationStepH: 0,
1202
+ rotationStepV: 0
1203
+ };
593
1204
  export {
594
1205
  CanvasEditor,
1206
+ CropController,
1207
+ DEFAULT_PATTERN_CONFIG,
595
1208
  EventEmitter,
596
1209
  HistoryManager,
597
1210
  Layer,
598
1211
  LayerManager,
1212
+ PatternManager,
1213
+ SnapManager,
599
1214
  UnitConverter,
1215
+ buildPatternDataURL,
600
1216
  clamp,
1217
+ clearPatternImageCache,
1218
+ computeTilePositions,
601
1219
  deserializeEditor,
1220
+ drawTiles,
602
1221
  exportDataURL,
603
1222
  exportPNG,
604
1223
  exportSVG,