@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.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() {
@@ -126,6 +133,8 @@ var LayerManager = class {
126
133
  }
127
134
  this.canvas.requestRenderAll();
128
135
  this.events.emit("layer:selected", { layerId: id });
136
+ const selected = id && this.get(id) ? [id] : [];
137
+ this.events.emit("selection:changed", { selected });
129
138
  }
130
139
  get(id) {
131
140
  return this.layers.find((l) => l.id === id);
@@ -224,6 +233,7 @@ var HistoryManager = class {
224
233
  this.emitChanged();
225
234
  }
226
235
  async undo() {
236
+ this.cancelPending();
227
237
  const state = this.undoStack.pop();
228
238
  if (!state) return;
229
239
  this.redoStack.push(this.getState());
@@ -233,6 +243,7 @@ var HistoryManager = class {
233
243
  this.emitChanged();
234
244
  }
235
245
  async redo() {
246
+ this.cancelPending();
236
247
  const state = this.redoStack.pop();
237
248
  if (!state) return;
238
249
  this.undoStack.push(this.getState());
@@ -241,6 +252,11 @@ var HistoryManager = class {
241
252
  this.paused = false;
242
253
  this.emitChanged();
243
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
+ }
244
260
  pause() {
245
261
  this.paused = true;
246
262
  }
@@ -258,6 +274,12 @@ var HistoryManager = class {
258
274
  this.redoStack = [];
259
275
  this.emitChanged();
260
276
  }
277
+ cancelPending() {
278
+ if (this.debounceTimer) {
279
+ clearTimeout(this.debounceTimer);
280
+ this.debounceTimer = null;
281
+ }
282
+ }
261
283
  dispose() {
262
284
  if (this.debounceTimer) {
263
285
  clearTimeout(this.debounceTimer);
@@ -271,6 +293,466 @@ var HistoryManager = class {
271
293
  }
272
294
  };
273
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
+
274
756
  // src/utils/units.ts
275
757
  var MM_PER_INCH = 25.4;
276
758
  var UnitConverter = class {
@@ -327,14 +809,20 @@ function serializeEditor(editor) {
327
809
  height: editor.canvas.getHeight()
328
810
  },
329
811
  layers: editor.layers.getAll().map((layer) => layer.serialize()),
330
- 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()
331
816
  };
332
817
  }
333
818
  async function deserializeEditor(editor, state) {
334
819
  editor.layers.clear();
335
820
  editor.canvas.setDimensions({ width: state.canvas.width, height: state.canvas.height });
336
821
  if (state.background) {
337
- editor.canvas.backgroundColor = state.background;
822
+ editor.setBackground(state.background);
823
+ }
824
+ if (state.mockup !== void 0) {
825
+ editor.setMockup(state.mockup);
338
826
  }
339
827
  for (const serializedLayer of state.layers) {
340
828
  await restoreLayer(editor, serializedLayer);
@@ -344,12 +832,10 @@ async function deserializeEditor(editor, state) {
344
832
  async function restoreLayer(editor, serialized) {
345
833
  const objects = await util.enlivenObjects([serialized.fabricObject]);
346
834
  const fabricObject = objects[0];
347
- const layer = editor.layers.add(
348
- serialized.type,
349
- fabricObject,
350
- serialized.name,
351
- serialized.id
352
- );
835
+ const layer = editor.layers.add(serialized.type, fabricObject, serialized.name, serialized.id);
836
+ if (serialized.meta) {
837
+ layer.meta = serialized.meta;
838
+ }
353
839
  if (!serialized.visible) {
354
840
  editor.layers.setVisibility(layer.id, false);
355
841
  }
@@ -380,32 +866,36 @@ function exportDataURL(canvas, format = "png", multiplier = 1) {
380
866
  return canvas.toDataURL({ format, multiplier });
381
867
  }
382
868
 
383
- // src/utils/clamp.ts
384
- function clamp(v, min, max) {
385
- return Math.min(max, Math.max(min, v));
386
- }
387
- function round2(v) {
388
- return Math.round(v * 100) / 100;
389
- }
390
-
391
869
  // src/editor.ts
870
+ var MIN_ZOOM = 0.1;
871
+ var MAX_ZOOM = 8;
392
872
  var CanvasEditor = class {
393
873
  canvas;
394
874
  layers;
395
875
  history;
396
876
  events;
397
877
  units;
878
+ snapping;
879
+ crop;
880
+ patterns;
398
881
  fileAdapter;
399
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;
400
889
  constructor(canvasElement, config) {
401
890
  this.events = new EventEmitter();
402
891
  this.units = new UnitConverter(config.unit ?? "px", config.dpi ?? 72);
403
892
  const widthPx = this.units.toPixels(config.width);
404
893
  const heightPx = this.units.toPixels(config.height);
894
+ this.designBackground = config.backgroundColor ?? "#ffffff";
405
895
  this.canvas = new Canvas(canvasElement, {
406
896
  width: widthPx,
407
897
  height: heightPx,
408
- backgroundColor: config.backgroundColor ?? "#ffffff",
898
+ backgroundColor: this.designBackground,
409
899
  preserveObjectStacking: config.preserveObjectStacking ?? true,
410
900
  selection: true
411
901
  });
@@ -420,6 +910,9 @@ var CanvasEditor = class {
420
910
  },
421
911
  events: this.events
422
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);
423
916
  this.setupCanvasEvents();
424
917
  this.history.saveImmediate();
425
918
  }
@@ -456,7 +949,11 @@ var CanvasEditor = class {
456
949
  fill: "#666666",
457
950
  width: 300
458
951
  });
459
- const layer = this.layers.add("template", placeholder, template.name);
952
+ const layer = this.layers.add(
953
+ "template",
954
+ placeholder,
955
+ template.name
956
+ );
460
957
  this.history.save();
461
958
  return layer;
462
959
  }
@@ -472,6 +969,36 @@ var CanvasEditor = class {
472
969
  if (!active) return null;
473
970
  return this.layers.findByObject(active) ?? null;
474
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
+ }
475
1002
  // ─── Serialization ──────────────────────────────────
476
1003
  toJSON() {
477
1004
  return serializeEditor(this);
@@ -482,18 +1009,33 @@ var CanvasEditor = class {
482
1009
  // ─── Export ──────────────────────────────────────────
483
1010
  async toPNG(options) {
484
1011
  this.events.emit("export:start", { format: "png" });
485
- const blob = await exportPNG(this.canvas, options);
1012
+ const blob = await this.withDesignBackground(() => exportPNG(this.canvas, options));
486
1013
  this.events.emit("export:complete", { format: "png" });
487
1014
  return blob;
488
1015
  }
489
1016
  toSVG() {
490
1017
  this.events.emit("export:start", { format: "svg" });
491
- const svg = exportSVG(this.canvas);
1018
+ const svg = this.withDesignBackground(() => exportSVG(this.canvas));
492
1019
  this.events.emit("export:complete", { format: "svg" });
493
1020
  return svg;
494
1021
  }
495
1022
  toDataURL(format = "png", multiplier = 1) {
496
- 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
+ }
497
1039
  }
498
1040
  toPrintifyPositioning() {
499
1041
  const result = {};
@@ -545,22 +1087,82 @@ var CanvasEditor = class {
545
1087
  }
546
1088
  // ─── Canvas Operations ──────────────────────────────
547
1089
  setBackground(color) {
548
- this.canvas.backgroundColor = color;
549
- this.canvas.requestRenderAll();
1090
+ this.designBackground = color;
1091
+ if (!this.mockup) {
1092
+ this.canvas.backgroundColor = color;
1093
+ this.canvas.requestRenderAll();
1094
+ }
550
1095
  this.history.save();
551
1096
  }
1097
+ getDesignBackground() {
1098
+ return this.designBackground;
1099
+ }
552
1100
  resize(width, height) {
553
1101
  const widthPx = this.units.toPixels(width);
554
1102
  const heightPx = this.units.toPixels(height);
555
1103
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
556
1104
  this.canvas.requestRenderAll();
557
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. */
558
1118
  zoom(level) {
559
- 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;
560
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;
561
1161
  }
562
1162
  // ─── Cleanup ────────────────────────────────────────
563
1163
  dispose() {
1164
+ this.snapping.dispose();
1165
+ this.crop.dispose();
564
1166
  this.history.dispose();
565
1167
  this.events.removeAllListeners();
566
1168
  this.canvas.dispose();
@@ -588,15 +1190,34 @@ var CanvasEditor = class {
588
1190
  });
589
1191
  }
590
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
+ };
591
1204
  export {
592
1205
  CanvasEditor,
1206
+ CropController,
1207
+ DEFAULT_PATTERN_CONFIG,
593
1208
  EventEmitter,
594
1209
  HistoryManager,
595
1210
  Layer,
596
1211
  LayerManager,
1212
+ PatternManager,
1213
+ SnapManager,
597
1214
  UnitConverter,
1215
+ buildPatternDataURL,
598
1216
  clamp,
1217
+ clearPatternImageCache,
1218
+ computeTilePositions,
599
1219
  deserializeEditor,
1220
+ drawTiles,
600
1221
  exportDataURL,
601
1222
  exportPNG,
602
1223
  exportSVG,