@overtone-art/canvas-editor-core 0.3.2 → 0.4.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
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-MCBRZQ4M.mjs";
12
12
 
13
13
  // src/editor.ts
14
- import { Canvas, FabricImage as FabricImage4, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
14
+ import { Canvas, FabricImage as FabricImage3, Group, Textbox, filters, loadSVGFromString, util as util3 } from "fabric";
15
15
 
16
16
  // src/events.ts
17
17
  var EventEmitter = class {
@@ -63,6 +63,12 @@ var Layer = class {
63
63
  /** Non-fabric data (e.g. pattern config) that must persist with the layer. */
64
64
  meta;
65
65
  fabricObject;
66
+ /**
67
+ * Stand-in object drawn in place of `fabricObject` (the tiled pattern). It is
68
+ * part of the canvas but never part of the document: it is not serialized, and
69
+ * the layer's real object stays the one every editor API talks to.
70
+ */
71
+ renderProxy;
66
72
  constructor(type, fabricObject, name, id) {
67
73
  this.id = id ?? generateId();
68
74
  this.type = type;
@@ -72,6 +78,7 @@ var Layer = class {
72
78
  this.opacity = 1;
73
79
  this.meta = {};
74
80
  this.fabricObject = fabricObject;
81
+ this.renderProxy = null;
75
82
  this.fabricObject._layerId = this.id;
76
83
  }
77
84
  hasMeta() {
@@ -120,6 +127,7 @@ var LayerManager = class {
120
127
  if (index === -1) return false;
121
128
  const layer = this.layers[index];
122
129
  this.canvas.remove(layer.fabricObject);
130
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
123
131
  this.layers.splice(index, 1);
124
132
  this.events.emit("layer:removed", { layerId: id });
125
133
  this.emitChanged();
@@ -143,6 +151,10 @@ var LayerManager = class {
143
151
  evented: !layer.locked
144
152
  });
145
153
  this.canvas.insertAt(Math.max(0, stackIndex), fabricObject);
154
+ if (layer.renderProxy) {
155
+ fabricObject.set({ opacity: 0 });
156
+ this.syncZOrder();
157
+ }
146
158
  if (wasActive) this.canvas.setActiveObject(fabricObject);
147
159
  this.canvas.requestRenderAll();
148
160
  this.events.emit("layer:modified", { layerId: id });
@@ -150,6 +162,36 @@ var LayerManager = class {
150
162
  this.onPropertyChanged?.();
151
163
  return true;
152
164
  }
165
+ /**
166
+ * Attach (or clear) the object drawn in place of a layer's own object. The
167
+ * proxy tracks the layer's stacking position, visibility and opacity, and is
168
+ * removed with the layer — it must never outlive or drift from its source.
169
+ */
170
+ setRenderProxy(id, proxy) {
171
+ const layer = this.get(id);
172
+ if (!layer || layer.renderProxy === proxy) return;
173
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
174
+ layer.renderProxy = proxy;
175
+ if (proxy) {
176
+ proxy.set({ visible: layer.visible, opacity: layer.opacity });
177
+ this.canvas.add(proxy);
178
+ this.syncZOrder();
179
+ }
180
+ this.canvas.requestRenderAll();
181
+ this.emitChanged();
182
+ }
183
+ /**
184
+ * Re-stack every canvas object to match layer order, keeping each proxy
185
+ * directly above the source it stands in for. Canvas indices can't be derived
186
+ * from layer indices once proxies are in the array, so the order is rebuilt
187
+ * front-to-back instead of computed.
188
+ */
189
+ syncZOrder() {
190
+ for (const layer of this.layers) {
191
+ this.canvas.bringObjectToFront(layer.fabricObject);
192
+ if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
193
+ }
194
+ }
153
195
  reorder(id, newIndex) {
154
196
  const oldIndex = this.layers.findIndex((l) => l.id === id);
155
197
  if (oldIndex === -1) return false;
@@ -158,9 +200,7 @@ var LayerManager = class {
158
200
  if (oldIndex === clamped) return false;
159
201
  const [layer] = this.layers.splice(oldIndex, 1);
160
202
  this.layers.splice(clamped, 0, layer);
161
- this.layers.forEach((l, i) => {
162
- this.canvas.moveObjectTo(l.fabricObject, i);
163
- });
203
+ this.syncZOrder();
164
204
  this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
165
205
  this.emitChanged();
166
206
  this.onPropertyChanged?.();
@@ -197,6 +237,7 @@ var LayerManager = class {
197
237
  if (layer.visible === visible) return;
198
238
  layer.visible = visible;
199
239
  layer.fabricObject.visible = visible;
240
+ if (layer.renderProxy) layer.renderProxy.visible = visible;
200
241
  this.canvas.requestRenderAll();
201
242
  this.emitChanged();
202
243
  this.onPropertyChanged?.();
@@ -218,7 +259,8 @@ var LayerManager = class {
218
259
  const next = Math.max(0, Math.min(1, opacity));
219
260
  if (layer.opacity === next) return;
220
261
  layer.opacity = next;
221
- layer.fabricObject.opacity = next;
262
+ if (layer.renderProxy) layer.renderProxy.opacity = next;
263
+ else layer.fabricObject.opacity = next;
222
264
  this.canvas.requestRenderAll();
223
265
  this.emitChanged();
224
266
  this.onPropertyChanged?.();
@@ -266,6 +308,7 @@ var LayerManager = class {
266
308
  clear() {
267
309
  for (const layer of this.layers) {
268
310
  this.canvas.remove(layer.fabricObject);
311
+ if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
269
312
  }
270
313
  this.layers = [];
271
314
  this.emitChanged();
@@ -799,199 +842,415 @@ var CropController = class {
799
842
  };
800
843
  var STROKE2 = "#22c55e";
801
844
 
802
- // src/pattern.ts
845
+ // src/pattern/pattern-manager.ts
803
846
  import { util } from "fabric";
847
+
848
+ // src/pattern/tiled-pattern-object.ts
849
+ import { FabricObject } from "fabric";
850
+
851
+ // src/pattern/tile-geometry.ts
804
852
  var MAX_TILES_PER_AXIS = 200;
853
+ function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
854
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
855
+ const radius = cornerRadius(anchor, targetW, targetH);
856
+ const span = radius * 2;
857
+ const minTile = Math.max(1, span / MAX_TILES_PER_AXIS);
858
+ const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
859
+ const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
860
+ const cols = Math.ceil(span / tileW) + 2;
861
+ const rows = Math.ceil(span / tileH) + 2;
862
+ const halfCols = Math.ceil(cols / 2);
863
+ const halfRows = Math.ceil(rows / 2);
864
+ const shiftX = tileW * (clampOffset(config.offsetX) / 100);
865
+ const shiftY = tileH * (clampOffset(config.offsetY) / 100);
866
+ const placements = [];
867
+ for (let j = -halfRows; j <= halfRows; j++) {
868
+ for (let i = -halfCols; i <= halfCols; i++) {
869
+ let x = i * tileW + shiftX;
870
+ let y = j * tileH + shiftY;
871
+ if (config.mode === "brick-horizontal" && mod2(j) === 1) {
872
+ x += tileW * (config.horizontalOffset / 100);
873
+ } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
874
+ y += tileH * (config.horizontalOffset / 100);
875
+ }
876
+ const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
877
+ placements.push({ x, y, rotation });
878
+ }
879
+ }
880
+ return placements;
881
+ }
882
+ function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
883
+ const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
884
+ ctx.save();
885
+ ctx.translate(anchor.x, anchor.y);
886
+ ctx.rotate(config.angle * Math.PI / 180);
887
+ for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH, anchor)) {
888
+ ctx.save();
889
+ ctx.translate(tile.x, tile.y);
890
+ ctx.rotate(tile.rotation);
891
+ ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
892
+ ctx.restore();
893
+ }
894
+ ctx.restore();
895
+ }
896
+ function cornerRadius(origin, w, h) {
897
+ const dx = Math.max(Math.abs(origin.x), Math.abs(w - origin.x));
898
+ const dy = Math.max(Math.abs(origin.y), Math.abs(h - origin.y));
899
+ return Math.sqrt(dx * dx + dy * dy);
900
+ }
901
+ function clampOffset(value) {
902
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
903
+ return Math.max(-100, Math.min(100, value));
904
+ }
905
+ function mod2(n) {
906
+ return (n % 2 + 2) % 2;
907
+ }
908
+
909
+ // src/pattern/tiled-pattern-object.ts
910
+ var MAX_SNAPSHOT_PIXELS = 16e6;
911
+ var MAX_SNAPSHOT_SCALE = 8;
912
+ var SNAPSHOT_SHRINK_FACTOR = 2;
913
+ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
914
+ static type = "TiledPattern";
915
+ /** The layer's real object. Never rendered directly (it is kept at opacity 0). */
916
+ source;
917
+ config;
918
+ snapshotEl = null;
919
+ snapshotScale = 0;
920
+ constructor(source, config, width, height) {
921
+ super({
922
+ // Explicit origin: fabric v7 defaults to `center`, and a centre-origin
923
+ // object placed at 0,0 covers a quarter of the canvas. That exact bug is
924
+ // why the old bake-into-the-layer pattern landed in the top-left corner.
925
+ originX: "left",
926
+ originY: "top",
927
+ left: 0,
928
+ top: 0,
929
+ width,
930
+ height,
931
+ // Pointer events belong to the source underneath, and fabric's object
932
+ // cache would freeze the tiling at screen resolution.
933
+ selectable: false,
934
+ evented: false,
935
+ objectCaching: false,
936
+ hasControls: false,
937
+ hasBorders: false
938
+ });
939
+ this.source = source;
940
+ this.config = config;
941
+ }
942
+ /** Swap in a new config; the next render picks it up. */
943
+ setConfig(config) {
944
+ this.config = config;
945
+ this.dirty = true;
946
+ }
947
+ /** Resize to a new print area. */
948
+ setArea(width, height) {
949
+ this.set({ width, height });
950
+ this.setCoords();
951
+ }
952
+ /** Drop the cached source snapshot (e.g. the source was edited). */
953
+ invalidate() {
954
+ this.snapshotEl = null;
955
+ this.snapshotScale = 0;
956
+ this.dirty = true;
957
+ }
958
+ /** Free the offscreen snapshot. */
959
+ dispose() {
960
+ this.snapshotEl = null;
961
+ this.snapshotScale = 0;
962
+ }
963
+ _render(ctx) {
964
+ const width = this.width ?? 0;
965
+ const height = this.height ?? 0;
966
+ if (width <= 0 || height <= 0) return;
967
+ const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
968
+ const snapshot = this.ensureSnapshot(contextScale(ctx) * tileScale);
969
+ if (!snapshot) return;
970
+ const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
971
+ ctx.save();
972
+ ctx.translate(-width / 2, -height / 2);
973
+ drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
974
+ ctx.restore();
975
+ }
976
+ /** Raster fallback for SVG export — one `<image>` covering the print area. */
977
+ _toSVG() {
978
+ const width = this.width ?? 0;
979
+ const height = this.height ?? 0;
980
+ if (width <= 0 || height <= 0) return [];
981
+ const el = document.createElement("canvas");
982
+ el.width = Math.max(1, Math.round(width));
983
+ el.height = Math.max(1, Math.round(height));
984
+ const ctx = el.getContext("2d");
985
+ if (!ctx) return [];
986
+ const tileScale = Math.max(1, this.config.scale ?? 100) / 100;
987
+ const snapshot = this.ensureSnapshot(tileScale);
988
+ if (!snapshot) return [];
989
+ const { tileW, tileH } = this.tileSize(snapshot, tileScale, width, height);
990
+ drawTiles(ctx, snapshot, this.config, width, height, tileW, tileH, this.gridOrigin());
991
+ return [
992
+ `<image x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" `,
993
+ `xlink:href="${el.toDataURL("image/png")}"></image>
994
+ `
995
+ ];
996
+ }
997
+ /**
998
+ * Fabric's generic clone round-trips through `toObject()` + the class
999
+ * registry, which cannot carry a live source reference. Export paths clone
1000
+ * every canvas object, so without this a print export would silently lose the
1001
+ * tiling. The copy shares the source (it only ever reads from it).
1002
+ */
1003
+ clone() {
1004
+ const copy = new _TiledPatternObject(
1005
+ this.source,
1006
+ this.config,
1007
+ this.width ?? 0,
1008
+ this.height ?? 0
1009
+ );
1010
+ copy.set({
1011
+ left: this.left,
1012
+ top: this.top,
1013
+ visible: this.visible,
1014
+ opacity: this.opacity
1015
+ });
1016
+ return Promise.resolve(copy);
1017
+ }
1018
+ /**
1019
+ * The proxy holds a live reference to its source, which would make a
1020
+ * serialized canvas circular. Nothing persists this object (only layers are
1021
+ * serialized) — this keeps an accidental `canvas.toObject()` from throwing.
1022
+ */
1023
+ toObject() {
1024
+ const plain = super.toObject();
1025
+ delete plain.source;
1026
+ return plain;
1027
+ }
1028
+ /** Tile size in canvas units, floored so a tiny tile can't spawn a huge loop. */
1029
+ tileSize(snapshot, tileScale, width, height) {
1030
+ const floor = Math.max(2, Math.sqrt(width * width + height * height) / 200);
1031
+ return {
1032
+ tileW: Math.max(floor, snapshot.width / this.snapshotScale * tileScale),
1033
+ tileH: Math.max(floor, snapshot.height / this.snapshotScale * tileScale)
1034
+ };
1035
+ }
1036
+ /** Grid anchor: the source's centre, in this object's coordinates. */
1037
+ gridOrigin() {
1038
+ const centre = this.source.getCenterPoint();
1039
+ return { x: centre.x - (this.left ?? 0), y: centre.y - (this.top ?? 0) };
1040
+ }
1041
+ /**
1042
+ * Snapshot the source at (at least) `scale`, reusing the cached one when it is
1043
+ * still sharp enough and the source has not changed.
1044
+ */
1045
+ ensureSnapshot(scale) {
1046
+ const wanted = this.clampScale(scale);
1047
+ const stale = this.source.dirty || !this.snapshotEl || this.snapshotScale < wanted || this.snapshotScale > wanted * SNAPSHOT_SHRINK_FACTOR;
1048
+ if (!stale) return this.snapshotEl;
1049
+ const source = this.source;
1050
+ const opacity = source.opacity;
1051
+ source.opacity = 1;
1052
+ try {
1053
+ const el = source.toCanvasElement({ multiplier: wanted, enableRetinaScaling: false });
1054
+ if (!el.width || !el.height) return null;
1055
+ this.snapshotEl = el;
1056
+ this.snapshotScale = wanted;
1057
+ } catch {
1058
+ return this.snapshotEl;
1059
+ } finally {
1060
+ source.opacity = opacity;
1061
+ source.dirty = false;
1062
+ }
1063
+ return this.snapshotEl;
1064
+ }
1065
+ /** Bound the snapshot by both a linear scale and a total pixel budget. */
1066
+ clampScale(scale) {
1067
+ const requested = Math.min(MAX_SNAPSHOT_SCALE, Math.max(0.05, scale));
1068
+ const rect = this.source.getBoundingRect();
1069
+ const area = Math.max(1, rect.width * rect.height);
1070
+ const budgeted = Math.sqrt(MAX_SNAPSHOT_PIXELS / area);
1071
+ return Math.max(0.05, Math.min(requested, budgeted));
1072
+ }
1073
+ };
1074
+ function contextScale(ctx) {
1075
+ if (typeof ctx.getTransform !== "function") return 1;
1076
+ try {
1077
+ const t = ctx.getTransform();
1078
+ return Math.max(Math.hypot(t.a, t.b), Math.hypot(t.c, t.d), 0.05);
1079
+ } catch {
1080
+ return 1;
1081
+ }
1082
+ }
1083
+
1084
+ // src/pattern/pattern-manager.ts
805
1085
  var PatternManager = class {
806
- constructor(canvas, layers, history, events, sourceResolver) {
1086
+ constructor(canvas, layers, history, events) {
807
1087
  this.canvas = canvas;
808
1088
  this.layers = layers;
809
1089
  this.history = history;
810
1090
  this.events = events;
811
- this.sourceResolver = sourceResolver;
1091
+ this.canvas.on("object:modified", this.onSourceModified);
1092
+ this.canvas.on("text:changed", this.onSourceModified);
1093
+ this.events.on("layer:removed", this.onLayerRemoved);
812
1094
  }
813
1095
  canvas;
814
1096
  layers;
815
1097
  history;
816
1098
  events;
817
- sourceResolver;
818
- // Per-layer task chain. apply()/disable() both await an async setSrc on the
819
- // same fabric image; running two concurrently lets their setSrc resolutions
820
- // interleave (wrong image installed, original lost). Serialising per layer
821
- // guarantees the last-requested operation wins and state stays consistent.
822
- chains = /* @__PURE__ */ new Map();
1099
+ proxies = /* @__PURE__ */ new Map();
1100
+ onSourceModified = (event) => {
1101
+ this.invalidateFor(event.target);
1102
+ };
1103
+ onLayerRemoved = ({ layerId }) => {
1104
+ const proxy = this.proxies.get(layerId);
1105
+ if (!proxy) return;
1106
+ proxy.dispose();
1107
+ this.proxies.delete(layerId);
1108
+ };
823
1109
  isPattern(layerId) {
824
1110
  return !!this.layers.get(layerId)?.meta.pattern;
825
1111
  }
826
1112
  getConfig(layerId) {
827
1113
  return this.layers.get(layerId)?.meta.pattern?.config ?? null;
828
1114
  }
829
- /** Turn a plain image layer into a pattern, or update an existing one. */
830
- apply(layerId, config) {
831
- return this.enqueue(layerId, async () => {
832
- const layer = this.layers.get(layerId);
833
- if (!layer || layer.type !== "image") return;
834
- const image = layer.fabricObject;
835
- const firstEnable = !layer.meta.pattern;
836
- if (!layer.meta.pattern) {
837
- const clip = image.clipPath;
838
- layer.meta.pattern = {
839
- config,
840
- originalSrc: elementToDataURL(image) ?? image.getSrc(),
841
- originalClip: clip ? clip.toObject() : null,
842
- originalLocks: captureLocks(image),
843
- original: {
844
- left: image.left ?? 0,
845
- top: image.top ?? 0,
846
- scaleX: image.scaleX ?? 1,
847
- scaleY: image.scaleY ?? 1,
848
- width: image.width ?? 0,
849
- height: image.height ?? 0,
850
- angle: image.angle ?? 0,
851
- cropX: image.cropX ?? 0,
852
- cropY: image.cropY ?? 0
853
- }
854
- };
1115
+ /** Turn a layer into a repeating pattern, or update an existing one. */
1116
+ async apply(layerId, config) {
1117
+ const layer = this.layers.get(layerId);
1118
+ if (!layer) return;
1119
+ try {
1120
+ this.layers.setMeta(layerId, { pattern: { config } });
1121
+ const proxy = this.proxies.get(layerId);
1122
+ if (proxy) {
1123
+ proxy.setConfig(config);
1124
+ this.canvas.requestRenderAll();
855
1125
  } else {
856
- layer.meta.pattern.config = config;
857
- }
858
- try {
859
- await this.renderLayer(layer);
860
- } catch (err) {
861
- if (firstEnable) delete layer.meta.pattern;
862
- throw err;
1126
+ this.attach(layer, config);
863
1127
  }
864
1128
  this.history.save();
865
- }).catch((error) => {
866
- this.events.emit("error", { message: "Failed to apply image pattern", error });
1129
+ } catch (error) {
1130
+ this.events.emit("error", { message: "Failed to apply pattern", error });
867
1131
  throw error;
868
- });
869
- }
870
- /**
871
- * Stretch every restored pattern layer back over the full print area and
872
- * re-freeze it, without re-rasterising: the baked bitmap keeps whatever size
873
- * it was saved at, so it is scaled (not re-tiled) to the current canvas. Used
874
- * after a state restore, where the canvas may be a different display size
875
- * than when the pattern was baked — and to repair states saved while a
876
- * pattern could still be dragged out of the print area.
877
- */
878
- repinAll() {
879
- const cw = this.canvas.getWidth();
880
- const ch = this.canvas.getHeight();
881
- let changed = false;
882
- for (const layer of this.layers.getAll()) {
883
- if (!layer.meta.pattern || layer.type !== "image") continue;
884
- const image = layer.fabricObject;
885
- image.set({
886
- left: 0,
887
- top: 0,
888
- angle: 0,
889
- scaleX: cw / (image.width || cw),
890
- scaleY: ch / (image.height || ch)
891
- });
892
- applyPatternLocks(image);
893
- image.setCoords();
894
- changed = true;
895
1132
  }
896
- if (changed) this.canvas.requestRenderAll();
897
1133
  }
898
- /** Restore the original image and drop the pattern. */
899
- disable(layerId) {
900
- return this.enqueue(layerId, async () => {
901
- const layer = this.layers.get(layerId);
902
- const state = layer?.meta.pattern;
903
- if (!layer || !state) return;
904
- const image = layer.fabricObject;
905
- await image.setSrc(state.originalSrc);
906
- image.set({
907
- left: state.original.left,
908
- top: state.original.top,
909
- scaleX: state.original.scaleX,
910
- scaleY: state.original.scaleY,
911
- width: state.original.width,
912
- height: state.original.height,
913
- cropX: state.original.cropX,
914
- cropY: state.original.cropY,
915
- angle: state.original.angle
916
- });
917
- image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
918
- restoreLocks(image, state.originalLocks);
919
- image.setCoords();
920
- delete layer.meta.pattern;
1134
+ /** Drop the tiling and show the source again. */
1135
+ async disable(layerId) {
1136
+ const layer = this.layers.get(layerId);
1137
+ if (!layer?.meta.pattern) return;
1138
+ try {
1139
+ this.detach(layerId);
1140
+ restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
1141
+ layer.fabricObject.set({ opacity: layer.opacity });
1142
+ this.layers.setMeta(layerId, { pattern: void 0 });
921
1143
  this.canvas.requestRenderAll();
922
1144
  this.history.save();
923
- }).catch((error) => {
924
- this.events.emit("error", { message: "Failed to clear image pattern", error });
1145
+ } catch (error) {
1146
+ this.events.emit("error", { message: "Failed to clear pattern", error });
925
1147
  throw error;
926
- });
1148
+ }
927
1149
  }
928
- /** Run `task` after any in-flight work for this layer, regardless of outcome. */
929
- enqueue(layerId, task) {
930
- const prev = this.chains.get(layerId) ?? Promise.resolve();
931
- const next = prev.then(task, task);
932
- this.chains.set(
933
- layerId,
934
- next.catch(() => void 0)
935
- );
936
- return next;
1150
+ /**
1151
+ * Rebuild every proxy after a state restore, migrating any layer that was
1152
+ * saved by the old bake-into-the-layer engine.
1153
+ */
1154
+ async rehydrateAll() {
1155
+ this.clearProxies();
1156
+ for (const layer of this.layers.getAll()) {
1157
+ const state = layer.meta.pattern;
1158
+ if (!state) continue;
1159
+ try {
1160
+ if (isLegacyState(state)) {
1161
+ await unbakeLegacyLayer(layer, state);
1162
+ this.layers.setMeta(layer.id, { pattern: { config: state.config } });
1163
+ }
1164
+ this.attach(layer, state.config);
1165
+ } catch (error) {
1166
+ this.events.emit("error", { message: "Failed to restore pattern layer", error });
1167
+ }
1168
+ }
1169
+ this.canvas.requestRenderAll();
1170
+ }
1171
+ /** Re-fit every proxy to the print area (canvas resize). */
1172
+ syncArea() {
1173
+ const width = this.canvas.getWidth();
1174
+ const height = this.canvas.getHeight();
1175
+ for (const proxy of this.proxies.values()) {
1176
+ proxy.setArea(width, height);
1177
+ proxy.invalidate();
1178
+ }
1179
+ if (this.proxies.size > 0) this.canvas.requestRenderAll();
1180
+ }
1181
+ /** Drop a layer's cached source snapshot (its content changed). */
1182
+ invalidate(layerId) {
1183
+ const proxy = this.proxies.get(layerId);
1184
+ if (!proxy) return;
1185
+ proxy.invalidate();
1186
+ this.canvas.requestRenderAll();
937
1187
  }
938
- async renderLayer(layer) {
1188
+ /** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
1189
+ attachTo(layer) {
939
1190
  const state = layer.meta.pattern;
940
- if (!state) return;
941
- const image = layer.fabricObject;
942
- const cw = this.canvas.getWidth();
943
- const ch = this.canvas.getHeight();
944
- const scale = Math.max(1, state.config.scale ?? 100) / 100;
945
- const diag = Math.sqrt(cw * cw + ch * ch);
946
- const minTile = Math.max(2, diag / MAX_TILES_PER_AXIS);
947
- const tileW = Math.max(minTile, state.original.width * state.original.scaleX * scale);
948
- const tileH = Math.max(minTile, state.original.height * state.original.scaleY * scale);
949
- const dataUrl = await buildPatternDataURL(
950
- state.originalSrc,
951
- state.config,
952
- cw,
953
- ch,
954
- tileW,
955
- tileH,
956
- this.sourceResolver
1191
+ if (!state || this.proxies.has(layer.id)) return;
1192
+ this.attach(layer, state.config);
1193
+ }
1194
+ dispose() {
1195
+ this.canvas.off("object:modified", this.onSourceModified);
1196
+ this.canvas.off("text:changed", this.onSourceModified);
1197
+ this.events.off("layer:removed", this.onLayerRemoved);
1198
+ this.clearProxies();
1199
+ }
1200
+ /** Release every proxy and the offscreen snapshot it holds. */
1201
+ clearProxies() {
1202
+ for (const proxy of this.proxies.values()) proxy.dispose();
1203
+ this.proxies.clear();
1204
+ }
1205
+ attach(layer, config) {
1206
+ const proxy = new TiledPatternObject(
1207
+ layer.fabricObject,
1208
+ config,
1209
+ this.canvas.getWidth(),
1210
+ this.canvas.getHeight()
957
1211
  );
958
- await image.setSrc(dataUrl);
959
- image.set({
960
- left: 0,
961
- top: 0,
962
- scaleX: 1,
963
- scaleY: 1,
964
- width: cw,
965
- height: ch,
966
- cropX: 0,
967
- cropY: 0,
968
- angle: 0
969
- });
970
- image.clipPath = void 0;
971
- applyPatternLocks(image);
972
- image.setCoords();
1212
+ layer.fabricObject.set({ opacity: 0 });
1213
+ this.proxies.set(layer.id, proxy);
1214
+ this.layers.setRenderProxy(layer.id, proxy);
973
1215
  this.canvas.requestRenderAll();
974
1216
  }
1217
+ detach(layerId) {
1218
+ const proxy = this.proxies.get(layerId);
1219
+ if (!proxy) {
1220
+ this.layers.setRenderProxy(layerId, null);
1221
+ return;
1222
+ }
1223
+ this.layers.setRenderProxy(layerId, null);
1224
+ proxy.dispose();
1225
+ this.proxies.delete(layerId);
1226
+ }
1227
+ invalidateFor(target) {
1228
+ if (!target) return;
1229
+ const layer = this.layers.findByObject(target);
1230
+ if (layer) this.invalidate(layer.id);
1231
+ }
975
1232
  };
976
- function captureLocks(obj) {
977
- return {
978
- lockMovementX: obj.lockMovementX ?? false,
979
- lockMovementY: obj.lockMovementY ?? false,
980
- lockScalingX: obj.lockScalingX ?? false,
981
- lockScalingY: obj.lockScalingY ?? false,
982
- lockRotation: obj.lockRotation ?? false,
983
- hasControls: obj.hasControls ?? true
984
- };
1233
+ function isLegacyState(state) {
1234
+ return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
985
1235
  }
986
- function applyPatternLocks(obj) {
987
- obj.set({
988
- lockMovementX: true,
989
- lockMovementY: true,
990
- lockScalingX: true,
991
- lockScalingY: true,
992
- lockRotation: true,
993
- hasControls: false
1236
+ async function unbakeLegacyLayer(layer, state) {
1237
+ const image = layer.fabricObject;
1238
+ if (typeof image.setSrc !== "function" || !state.original) return;
1239
+ await image.setSrc(state.originalSrc);
1240
+ image.set({
1241
+ left: state.original.left,
1242
+ top: state.original.top,
1243
+ scaleX: state.original.scaleX,
1244
+ scaleY: state.original.scaleY,
1245
+ width: state.original.width,
1246
+ height: state.original.height,
1247
+ cropX: state.original.cropX,
1248
+ cropY: state.original.cropY,
1249
+ angle: state.original.angle
994
1250
  });
1251
+ image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
1252
+ restoreLocks(image, state.originalLocks);
1253
+ image.setCoords();
995
1254
  }
996
1255
  function restoreLocks(obj, locks) {
997
1256
  obj.set(
@@ -1005,121 +1264,6 @@ function restoreLocks(obj, locks) {
1005
1264
  }
1006
1265
  );
1007
1266
  }
1008
- function elementToDataURL(image) {
1009
- try {
1010
- const el = image.getElement();
1011
- const w = el.naturalWidth || el.width;
1012
- const h = el.naturalHeight || el.height;
1013
- if (!w || !h) return null;
1014
- const off = document.createElement("canvas");
1015
- off.width = w;
1016
- off.height = h;
1017
- const ctx = off.getContext("2d");
1018
- if (!ctx) return null;
1019
- ctx.drawImage(el, 0, 0);
1020
- return off.toDataURL("image/png");
1021
- } catch {
1022
- return null;
1023
- }
1024
- }
1025
- var IMAGE_CACHE_MAX = 16;
1026
- var imageCache = /* @__PURE__ */ new Map();
1027
- function loadPatternImage(src, resolver) {
1028
- const cached = imageCache.get(src);
1029
- if (cached) {
1030
- imageCache.delete(src);
1031
- imageCache.set(src, cached);
1032
- return cached;
1033
- }
1034
- const promise = decodeImage(src).catch(async (originalError) => {
1035
- if (!resolver) throw originalError;
1036
- const resolved = await resolver(src);
1037
- if (!resolved || resolved === src) {
1038
- throw new Error("Pattern source resolver did not return a usable alternate URL", {
1039
- cause: originalError
1040
- });
1041
- }
1042
- return decodeImage(resolved);
1043
- });
1044
- promise.catch(() => {
1045
- if (imageCache.get(src) === promise) imageCache.delete(src);
1046
- });
1047
- imageCache.set(src, promise);
1048
- if (imageCache.size > IMAGE_CACHE_MAX) {
1049
- const oldest = imageCache.keys().next().value;
1050
- if (oldest !== void 0) imageCache.delete(oldest);
1051
- }
1052
- return promise;
1053
- }
1054
- function decodeImage(src) {
1055
- return new Promise((resolve, reject) => {
1056
- const img = new Image();
1057
- img.crossOrigin = "anonymous";
1058
- img.onload = () => resolve(img);
1059
- img.onerror = () => reject(new Error(`Failed to load pattern source: ${src}`));
1060
- img.src = src;
1061
- });
1062
- }
1063
- function clearPatternImageCache() {
1064
- imageCache.clear();
1065
- }
1066
- async function buildPatternDataURL(src, config, targetW, targetH, baseW, baseH, sourceResolver) {
1067
- const img = await loadPatternImage(src, sourceResolver);
1068
- const off = document.createElement("canvas");
1069
- off.width = Math.max(1, Math.round(targetW));
1070
- off.height = Math.max(1, Math.round(targetH));
1071
- const ctx = off.getContext("2d");
1072
- if (!ctx) return off.toDataURL("image/png");
1073
- drawTiles(ctx, img, config, targetW, targetH, baseW, baseH);
1074
- return off.toDataURL("image/png");
1075
- }
1076
- function computeTilePositions(config, targetW, targetH, baseW, baseH) {
1077
- const diag = Math.sqrt(targetW * targetW + targetH * targetH);
1078
- const minTile = Math.max(1, diag / MAX_TILES_PER_AXIS);
1079
- const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
1080
- const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
1081
- const cols = Math.ceil(diag / tileW) + 2;
1082
- const rows = Math.ceil(diag / tileH) + 2;
1083
- const halfCols = Math.ceil(cols / 2);
1084
- const halfRows = Math.ceil(rows / 2);
1085
- const shiftX = tileW * (clampOffset(config.offsetX) / 100);
1086
- const shiftY = tileH * (clampOffset(config.offsetY) / 100);
1087
- const placements = [];
1088
- for (let j = -halfRows; j <= halfRows; j++) {
1089
- for (let i = -halfCols; i <= halfCols; i++) {
1090
- let x = i * tileW + shiftX;
1091
- let y = j * tileH + shiftY;
1092
- if (config.mode === "brick-horizontal" && mod2(j) === 1) {
1093
- x += tileW * (config.horizontalOffset / 100);
1094
- } else if (config.mode === "brick-vertical" && mod2(i) === 1) {
1095
- y += tileH * (config.horizontalOffset / 100);
1096
- }
1097
- const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
1098
- placements.push({ x, y, rotation });
1099
- }
1100
- }
1101
- return placements;
1102
- }
1103
- function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH) {
1104
- ctx.save();
1105
- ctx.translate(targetW / 2, targetH / 2);
1106
- ctx.rotate(config.angle * Math.PI / 180);
1107
- for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH)) {
1108
- ctx.save();
1109
- ctx.translate(tile.x, tile.y);
1110
- ctx.rotate(tile.rotation);
1111
- ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
1112
- ctx.restore();
1113
- }
1114
- ctx.restore();
1115
- }
1116
- function clampOffset(value) {
1117
- if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1118
- return Math.max(-100, Math.min(100, value));
1119
- }
1120
- function mod2(n) {
1121
- return (n % 2 + 2) % 2;
1122
- }
1123
1267
 
1124
1268
  // src/text-curve.ts
1125
1269
  import { Path } from "fabric";
@@ -1287,7 +1431,7 @@ var TextCurveManager = class {
1287
1431
  };
1288
1432
 
1289
1433
  // src/mask-presets/manager.ts
1290
- import { FabricImage as FabricImage2, Path as Path2 } from "fabric";
1434
+ import { FabricImage, Path as Path2 } from "fabric";
1291
1435
 
1292
1436
  // src/mask-presets/shapes.ts
1293
1437
  var SHAPE_MASK_IDS = [
@@ -1560,7 +1704,7 @@ var MaskPresetManager = class {
1560
1704
  scaleY: height / SHAPE_MASK_BOX
1561
1705
  });
1562
1706
  }
1563
- return new FabricImage2(renderTextureMask(id), {
1707
+ return new FabricImage(renderTextureMask(id), {
1564
1708
  ...shared,
1565
1709
  scaleX: width / TEXTURE_MASK_SIZE,
1566
1710
  scaleY: height / TEXTURE_MASK_SIZE
@@ -2128,7 +2272,7 @@ var ProjectManager = class {
2128
2272
  };
2129
2273
 
2130
2274
  // src/mask.ts
2131
- import { FabricImage as FabricImage3 } from "fabric";
2275
+ import { FabricImage as FabricImage2 } from "fabric";
2132
2276
  var MaskRefinementError = class extends Error {
2133
2277
  constructor(code, message, cause) {
2134
2278
  super(message);
@@ -2160,7 +2304,7 @@ var MaskController = class {
2160
2304
  throw new Error("Mask dimensions must be positive integers");
2161
2305
  }
2162
2306
  const backing = this.makeCanvas(width, height);
2163
- const image = new FabricImage3(backing, {
2307
+ const image = new FabricImage2(backing, {
2164
2308
  left: 0,
2165
2309
  top: 0,
2166
2310
  originX: "left",
@@ -2447,13 +2591,7 @@ var CanvasEditor = class {
2447
2591
  this.layers.setHistoryCallback(() => this.history.save());
2448
2592
  this.snapping = new SnapManager(this.canvas, this.events);
2449
2593
  this.crop = new CropController(this.canvas, this.history, this.events);
2450
- this.patterns = new PatternManager(
2451
- this.canvas,
2452
- this.layers,
2453
- this.history,
2454
- this.events,
2455
- config.patternSourceResolver
2456
- );
2594
+ this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
2457
2595
  this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
2458
2596
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
2459
2597
  this.setupCanvasEvents();
@@ -2465,7 +2603,7 @@ var CanvasEditor = class {
2465
2603
  // ─── Layer Operations ────────────────────────────────
2466
2604
  async addImage(url, options) {
2467
2605
  try {
2468
- const img = await FabricImage4.fromURL(
2606
+ const img = await FabricImage3.fromURL(
2469
2607
  url,
2470
2608
  {},
2471
2609
  { originX: "left", originY: "top", ...options }
@@ -2490,7 +2628,7 @@ var CanvasEditor = class {
2490
2628
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
2491
2629
  const previous = layer.fabricObject;
2492
2630
  try {
2493
- const replacement = await FabricImage4.fromURL(url, {}, { originX: "left", originY: "top" });
2631
+ const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
2494
2632
  replacement.set({
2495
2633
  left: previous.left,
2496
2634
  top: previous.top,
@@ -2606,9 +2744,7 @@ var CanvasEditor = class {
2606
2744
  const layer = this.layers.get(id);
2607
2745
  if (!layer) return null;
2608
2746
  const clone = await layer.fabricObject.clone();
2609
- if (!layer.meta.pattern) {
2610
- clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2611
- }
2747
+ clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
2612
2748
  clone.setCoords();
2613
2749
  const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
2614
2750
  copy.meta = structuredClone(layer.meta);
@@ -2621,6 +2757,7 @@ var CanvasEditor = class {
2621
2757
  evented: !layer.locked,
2622
2758
  opacity: layer.opacity
2623
2759
  });
2760
+ this.patterns.attachTo(copy);
2624
2761
  this.canvas.setActiveObject(clone);
2625
2762
  this.canvas.requestRenderAll();
2626
2763
  this.history.save();
@@ -2700,7 +2837,7 @@ var CanvasEditor = class {
2700
2837
  }
2701
2838
  try {
2702
2839
  await deserializeEditor(this, state);
2703
- this.patterns.repinAll();
2840
+ await this.patterns.rehydrateAll();
2704
2841
  } catch (error) {
2705
2842
  if (!managedByHistory) {
2706
2843
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -2729,7 +2866,7 @@ var CanvasEditor = class {
2729
2866
  const layer = this.layers.get(id);
2730
2867
  if (!layer) throw new Error(`Layer not found: ${id}`);
2731
2868
  try {
2732
- if (options.resolution === "source" && layer.fabricObject instanceof FabricImage4) {
2869
+ if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
2733
2870
  const image = await layer.fabricObject.clone();
2734
2871
  image.set({
2735
2872
  left: 0,
@@ -2749,7 +2886,11 @@ var CanvasEditor = class {
2749
2886
  cloneObjects: false
2750
2887
  });
2751
2888
  }
2752
- return await exportIsolatedPNG(this.canvas, [layer.fabricObject], options);
2889
+ return await exportIsolatedPNG(
2890
+ this.canvas,
2891
+ [layer.renderProxy ?? layer.fabricObject],
2892
+ options
2893
+ );
2753
2894
  } catch (error) {
2754
2895
  this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
2755
2896
  throw error;
@@ -3001,7 +3142,7 @@ var CanvasEditor = class {
3001
3142
  return;
3002
3143
  }
3003
3144
  try {
3004
- const image = await FabricImage4.fromURL(
3145
+ const image = await FabricImage3.fromURL(
3005
3146
  url,
3006
3147
  { crossOrigin: options.crossOrigin ?? null, signal: options.signal },
3007
3148
  { originX: "left", originY: "top" }
@@ -3054,7 +3195,6 @@ var CanvasEditor = class {
3054
3195
  const sx = widthPx / oldWidth;
3055
3196
  const sy = heightPx / oldHeight;
3056
3197
  for (const layer of this.layers.getAll()) {
3057
- if (layer.meta.pattern) continue;
3058
3198
  const object = layer.fabricObject;
3059
3199
  object.set({
3060
3200
  left: (object.left ?? 0) * sx,
@@ -3075,7 +3215,7 @@ var CanvasEditor = class {
3075
3215
  }
3076
3216
  }
3077
3217
  this.canvas.setDimensions({ width: widthPx, height: heightPx });
3078
- this.patterns.repinAll();
3218
+ this.patterns.syncArea();
3079
3219
  this.canvas.requestRenderAll();
3080
3220
  this.history.save();
3081
3221
  this.events.emit("canvas:modified", {});
@@ -3273,7 +3413,7 @@ var CanvasEditor = class {
3273
3413
  this.snapping.dispose();
3274
3414
  this.crop.dispose();
3275
3415
  this.history.dispose();
3276
- clearPatternImageCache();
3416
+ this.patterns.dispose();
3277
3417
  this.events.removeAllListeners();
3278
3418
  this.canvas.dispose();
3279
3419
  }
@@ -3416,17 +3556,14 @@ export {
3416
3556
  TEXTURE_MASK_IDS,
3417
3557
  TEXTURE_MASK_SIZE,
3418
3558
  TextCurveManager,
3559
+ TiledPatternObject,
3419
3560
  UnitConverter,
3420
3561
  applyAspectLock,
3421
3562
  applyLayerShadow,
3422
3563
  applyObjectSelectionStyle,
3423
- applyPatternLocks,
3424
3564
  applySelectionStyle,
3425
3565
  buildCurvePathData,
3426
- buildPatternDataURL,
3427
- captureLocks,
3428
3566
  clamp,
3429
- clearPatternImageCache,
3430
3567
  clearTextureMaskCache,
3431
3568
  computeCoverPlacement,
3432
3569
  computePrintAreaClip,
@@ -3445,7 +3582,6 @@ export {
3445
3582
  isMaskPresetId,
3446
3583
  isShapeMaskId,
3447
3584
  isTextureMaskId,
3448
- loadPatternImage,
3449
3585
  readLayerShadow,
3450
3586
  renderTextureMask,
3451
3587
  resetTransform,