@overtone-art/canvas-editor-core 0.3.2 → 0.5.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.d.mts +403 -48
- package/dist/index.d.ts +403 -48
- package/dist/index.global.js +55 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +1352 -338
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1324 -314
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/{types-Dh1unrzT.d.mts → types-JP8MZsIs.d.mts} +57 -22
- package/dist/{types-Dh1unrzT.d.ts → types-JP8MZsIs.d.ts} +57 -22
- package/package.json +1 -1
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
|
|
14
|
+
import { Canvas, FabricImage as FabricImage3, Group as Group6, Textbox, filters, loadSVGFromString, util as util5 } 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,42 @@ 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._layerId = id;
|
|
177
|
+
proxy.set({
|
|
178
|
+
visible: layer.visible,
|
|
179
|
+
opacity: layer.opacity,
|
|
180
|
+
selectable: !layer.locked,
|
|
181
|
+
evented: !layer.locked
|
|
182
|
+
});
|
|
183
|
+
this.canvas.add(proxy);
|
|
184
|
+
this.syncZOrder();
|
|
185
|
+
}
|
|
186
|
+
this.canvas.requestRenderAll();
|
|
187
|
+
this.emitChanged();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Re-stack every canvas object to match layer order, keeping each proxy
|
|
191
|
+
* directly above the source it stands in for. Canvas indices can't be derived
|
|
192
|
+
* from layer indices once proxies are in the array, so the order is rebuilt
|
|
193
|
+
* front-to-back instead of computed.
|
|
194
|
+
*/
|
|
195
|
+
syncZOrder() {
|
|
196
|
+
for (const layer of this.layers) {
|
|
197
|
+
this.canvas.bringObjectToFront(layer.fabricObject);
|
|
198
|
+
if (layer.renderProxy) this.canvas.bringObjectToFront(layer.renderProxy);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
153
201
|
reorder(id, newIndex) {
|
|
154
202
|
const oldIndex = this.layers.findIndex((l) => l.id === id);
|
|
155
203
|
if (oldIndex === -1) return false;
|
|
@@ -158,9 +206,7 @@ var LayerManager = class {
|
|
|
158
206
|
if (oldIndex === clamped) return false;
|
|
159
207
|
const [layer] = this.layers.splice(oldIndex, 1);
|
|
160
208
|
this.layers.splice(clamped, 0, layer);
|
|
161
|
-
this.
|
|
162
|
-
this.canvas.moveObjectTo(l.fabricObject, i);
|
|
163
|
-
});
|
|
209
|
+
this.syncZOrder();
|
|
164
210
|
this.events.emit("layer:reordered", { layerIds: this.layers.map((l) => l.id) });
|
|
165
211
|
this.emitChanged();
|
|
166
212
|
this.onPropertyChanged?.();
|
|
@@ -172,7 +218,7 @@ var LayerManager = class {
|
|
|
172
218
|
} else {
|
|
173
219
|
const layer = this.get(id);
|
|
174
220
|
if (layer) {
|
|
175
|
-
this.canvas.setActiveObject(layer.fabricObject);
|
|
221
|
+
this.canvas.setActiveObject(layer.renderProxy ?? layer.fabricObject);
|
|
176
222
|
}
|
|
177
223
|
}
|
|
178
224
|
this.canvas.requestRenderAll();
|
|
@@ -197,6 +243,7 @@ var LayerManager = class {
|
|
|
197
243
|
if (layer.visible === visible) return;
|
|
198
244
|
layer.visible = visible;
|
|
199
245
|
layer.fabricObject.visible = visible;
|
|
246
|
+
if (layer.renderProxy) layer.renderProxy.visible = visible;
|
|
200
247
|
this.canvas.requestRenderAll();
|
|
201
248
|
this.emitChanged();
|
|
202
249
|
this.onPropertyChanged?.();
|
|
@@ -206,8 +253,9 @@ var LayerManager = class {
|
|
|
206
253
|
if (!layer) return;
|
|
207
254
|
if (layer.locked === locked) return;
|
|
208
255
|
layer.locked = locked;
|
|
209
|
-
layer.
|
|
210
|
-
|
|
256
|
+
const target = layer.renderProxy ?? layer.fabricObject;
|
|
257
|
+
target.selectable = !locked;
|
|
258
|
+
target.evented = !locked;
|
|
211
259
|
this.canvas.requestRenderAll();
|
|
212
260
|
this.emitChanged();
|
|
213
261
|
this.onPropertyChanged?.();
|
|
@@ -218,7 +266,8 @@ var LayerManager = class {
|
|
|
218
266
|
const next = Math.max(0, Math.min(1, opacity));
|
|
219
267
|
if (layer.opacity === next) return;
|
|
220
268
|
layer.opacity = next;
|
|
221
|
-
layer.
|
|
269
|
+
if (layer.renderProxy) layer.renderProxy.opacity = next;
|
|
270
|
+
else layer.fabricObject.opacity = next;
|
|
222
271
|
this.canvas.requestRenderAll();
|
|
223
272
|
this.emitChanged();
|
|
224
273
|
this.onPropertyChanged?.();
|
|
@@ -266,6 +315,7 @@ var LayerManager = class {
|
|
|
266
315
|
clear() {
|
|
267
316
|
for (const layer of this.layers) {
|
|
268
317
|
this.canvas.remove(layer.fabricObject);
|
|
318
|
+
if (layer.renderProxy) this.canvas.remove(layer.renderProxy);
|
|
269
319
|
}
|
|
270
320
|
this.layers = [];
|
|
271
321
|
this.emitChanged();
|
|
@@ -799,199 +849,536 @@ var CropController = class {
|
|
|
799
849
|
};
|
|
800
850
|
var STROKE2 = "#22c55e";
|
|
801
851
|
|
|
802
|
-
// src/pattern.ts
|
|
852
|
+
// src/pattern/pattern-manager.ts
|
|
803
853
|
import { util } from "fabric";
|
|
854
|
+
|
|
855
|
+
// src/pattern/tiled-pattern-object.ts
|
|
856
|
+
import { FabricObject, Point } from "fabric";
|
|
857
|
+
|
|
858
|
+
// src/pattern/tile-geometry.ts
|
|
804
859
|
var MAX_TILES_PER_AXIS = 200;
|
|
860
|
+
function computeTilePositions(config, targetW, targetH, baseW, baseH, origin) {
|
|
861
|
+
const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
|
|
862
|
+
const radius = cornerRadius(anchor, targetW, targetH);
|
|
863
|
+
const span = radius * 2;
|
|
864
|
+
const minTile = Math.max(1, span / MAX_TILES_PER_AXIS);
|
|
865
|
+
const tileW = Math.max(minTile, baseW * (1 + config.horizontalSpacing / 100));
|
|
866
|
+
const tileH = Math.max(minTile, baseH * (1 + config.verticalSpacing / 100));
|
|
867
|
+
const cols = Math.ceil(span / tileW) + 2;
|
|
868
|
+
const rows = Math.ceil(span / tileH) + 2;
|
|
869
|
+
const halfCols = Math.ceil(cols / 2);
|
|
870
|
+
const halfRows = Math.ceil(rows / 2);
|
|
871
|
+
const shiftX = tileW * (clampOffset(config.offsetX) / 100);
|
|
872
|
+
const shiftY = tileH * (clampOffset(config.offsetY) / 100);
|
|
873
|
+
const placements = [];
|
|
874
|
+
for (let j = -halfRows; j <= halfRows; j++) {
|
|
875
|
+
for (let i = -halfCols; i <= halfCols; i++) {
|
|
876
|
+
let x = i * tileW + shiftX;
|
|
877
|
+
let y = j * tileH + shiftY;
|
|
878
|
+
if (config.mode === "brick-horizontal" && mod2(j) === 1) {
|
|
879
|
+
x += tileW * (config.horizontalOffset / 100);
|
|
880
|
+
} else if (config.mode === "brick-vertical" && mod2(i) === 1) {
|
|
881
|
+
y += tileH * (config.horizontalOffset / 100);
|
|
882
|
+
}
|
|
883
|
+
const rotation = (i * config.rotationStepH + j * config.rotationStepV) * Math.PI / 180;
|
|
884
|
+
placements.push({ x, y, rotation });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return placements;
|
|
888
|
+
}
|
|
889
|
+
function drawTiles(ctx, img, config, targetW, targetH, baseW, baseH, origin) {
|
|
890
|
+
const anchor = origin ?? { x: targetW / 2, y: targetH / 2 };
|
|
891
|
+
ctx.save();
|
|
892
|
+
ctx.translate(anchor.x, anchor.y);
|
|
893
|
+
ctx.rotate(config.angle * Math.PI / 180);
|
|
894
|
+
for (const tile of computeTilePositions(config, targetW, targetH, baseW, baseH, anchor)) {
|
|
895
|
+
ctx.save();
|
|
896
|
+
ctx.translate(tile.x, tile.y);
|
|
897
|
+
ctx.rotate(tile.rotation);
|
|
898
|
+
ctx.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
|
|
899
|
+
ctx.restore();
|
|
900
|
+
}
|
|
901
|
+
ctx.restore();
|
|
902
|
+
}
|
|
903
|
+
function cornerRadius(origin, w, h) {
|
|
904
|
+
const dx = Math.max(Math.abs(origin.x), Math.abs(w - origin.x));
|
|
905
|
+
const dy = Math.max(Math.abs(origin.y), Math.abs(h - origin.y));
|
|
906
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
907
|
+
}
|
|
908
|
+
function clampOffset(value) {
|
|
909
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
910
|
+
return Math.max(-100, Math.min(100, value));
|
|
911
|
+
}
|
|
912
|
+
function mod2(n) {
|
|
913
|
+
return (n % 2 + 2) % 2;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/pattern/tiled-pattern-object.ts
|
|
917
|
+
var MAX_SNAPSHOT_PIXELS = 16e6;
|
|
918
|
+
var MAX_SNAPSHOT_SCALE = 8;
|
|
919
|
+
var SNAPSHOT_SHRINK_FACTOR = 2;
|
|
920
|
+
var TiledPatternObject = class _TiledPatternObject extends FabricObject {
|
|
921
|
+
static type = "TiledPattern";
|
|
922
|
+
/** The layer's real object. Never rendered directly (it is kept at opacity 0). */
|
|
923
|
+
source;
|
|
924
|
+
config;
|
|
925
|
+
/** The print area this pattern fills, in canvas units. */
|
|
926
|
+
area;
|
|
927
|
+
snapshotEl = null;
|
|
928
|
+
snapshotScale = 0;
|
|
929
|
+
constructor(source, config, area) {
|
|
930
|
+
super({
|
|
931
|
+
// Centre origin: the box tracks a tile, and a tile is placed by its centre.
|
|
932
|
+
originX: "center",
|
|
933
|
+
originY: "center",
|
|
934
|
+
objectCaching: false,
|
|
935
|
+
// Side handles would imply a non-uniform tile scale; the config has one.
|
|
936
|
+
lockScalingFlip: true
|
|
937
|
+
});
|
|
938
|
+
this.source = source;
|
|
939
|
+
this.config = config;
|
|
940
|
+
this.area = area;
|
|
941
|
+
this.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false });
|
|
942
|
+
this.syncBox();
|
|
943
|
+
}
|
|
944
|
+
/** Swap in a new config and re-fit the box to the tile it now describes. */
|
|
945
|
+
setConfig(config) {
|
|
946
|
+
this.config = config;
|
|
947
|
+
this.syncBox();
|
|
948
|
+
}
|
|
949
|
+
/** Re-fit to a new print area (canvas resize). */
|
|
950
|
+
setArea(area) {
|
|
951
|
+
this.area = area;
|
|
952
|
+
this.invalidate();
|
|
953
|
+
this.syncBox();
|
|
954
|
+
}
|
|
955
|
+
/** Drop the cached source snapshot (the source was edited). */
|
|
956
|
+
invalidate() {
|
|
957
|
+
this.snapshotEl = null;
|
|
958
|
+
this.snapshotScale = 0;
|
|
959
|
+
this.dirty = true;
|
|
960
|
+
}
|
|
961
|
+
/** Free the offscreen snapshot. */
|
|
962
|
+
dispose() {
|
|
963
|
+
this.snapshotEl = null;
|
|
964
|
+
this.snapshotScale = 0;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Put the box back on the anchor tile: the tile's size, the pattern angle, and
|
|
968
|
+
* the grid origin displaced by the configured phase shift. Resets any live
|
|
969
|
+
* gesture scale, so it must run only once that gesture has been folded in.
|
|
970
|
+
*/
|
|
971
|
+
syncBox() {
|
|
972
|
+
const { tileW, tileH } = this.baseTile();
|
|
973
|
+
const origin = this.source.getCenterPoint();
|
|
974
|
+
const anchor = this.anchorFromOrigin(origin, tileW, tileH, this.config.angle);
|
|
975
|
+
this.set({
|
|
976
|
+
left: anchor.x,
|
|
977
|
+
top: anchor.y,
|
|
978
|
+
width: tileW,
|
|
979
|
+
height: tileH,
|
|
980
|
+
scaleX: 1,
|
|
981
|
+
scaleY: 1,
|
|
982
|
+
angle: this.config.angle
|
|
983
|
+
});
|
|
984
|
+
this.setCoords();
|
|
985
|
+
this.dirty = true;
|
|
986
|
+
}
|
|
987
|
+
/** Grid rotation in play right now — the live handle during a rotate gesture. */
|
|
988
|
+
liveAngle() {
|
|
989
|
+
return this.angle ?? 0;
|
|
990
|
+
}
|
|
991
|
+
/** Tile scale in play right now, as a config percentage. */
|
|
992
|
+
liveScale() {
|
|
993
|
+
return (this.config.scale ?? 100) * Math.abs(this.scaleX ?? 1);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Where the source's centre would have to sit for the box to stay put — i.e.
|
|
997
|
+
* the grid origin implied by a drag. `PatternManager` moves the source there.
|
|
998
|
+
*/
|
|
999
|
+
liveOrigin() {
|
|
1000
|
+
const { tileW, tileH } = this.liveTile();
|
|
1001
|
+
const centre = this.getCenterPoint();
|
|
1002
|
+
const shift = this.shiftVector(tileW, tileH, this.liveAngle());
|
|
1003
|
+
return new Point(centre.x - shift.x, centre.y - shift.y);
|
|
1004
|
+
}
|
|
1005
|
+
_render(ctx) {
|
|
1006
|
+
const { width, height } = this.area;
|
|
1007
|
+
if (width <= 0 || height <= 0) return;
|
|
1008
|
+
const { tileW, tileH } = this.liveTile();
|
|
1009
|
+
const snapshot = this.ensureSnapshot(contextScale(ctx) * (tileW / Math.max(1, this.baseW())));
|
|
1010
|
+
if (!snapshot) return;
|
|
1011
|
+
const centre = this.getCenterPoint();
|
|
1012
|
+
const angle = this.liveAngle();
|
|
1013
|
+
ctx.save();
|
|
1014
|
+
ctx.scale(1 / (this.scaleX || 1), 1 / (this.scaleY || 1));
|
|
1015
|
+
ctx.rotate(-angle * Math.PI / 180);
|
|
1016
|
+
ctx.translate(-centre.x, -centre.y);
|
|
1017
|
+
const shift = this.shiftVector(tileW, tileH, angle);
|
|
1018
|
+
const origin = { x: centre.x - shift.x, y: centre.y - shift.y };
|
|
1019
|
+
const config = { ...this.config, angle, offsetX: 0, offsetY: 0 };
|
|
1020
|
+
drawTiles(ctx, snapshot, config, width, height, tileW, tileH, origin);
|
|
1021
|
+
ctx.restore();
|
|
1022
|
+
}
|
|
1023
|
+
/** Raster fallback for SVG export — one `<image>` covering the print area. */
|
|
1024
|
+
_toSVG() {
|
|
1025
|
+
const { width, height } = this.area;
|
|
1026
|
+
if (width <= 0 || height <= 0) return [];
|
|
1027
|
+
const el = document.createElement("canvas");
|
|
1028
|
+
el.width = Math.max(1, Math.round(width));
|
|
1029
|
+
el.height = Math.max(1, Math.round(height));
|
|
1030
|
+
const ctx = el.getContext("2d");
|
|
1031
|
+
if (!ctx) return [];
|
|
1032
|
+
const { tileW, tileH } = this.liveTile();
|
|
1033
|
+
const snapshot = this.ensureSnapshot(tileW / Math.max(1, this.baseW()));
|
|
1034
|
+
if (!snapshot) return [];
|
|
1035
|
+
const centre = this.getCenterPoint();
|
|
1036
|
+
const angle = this.liveAngle();
|
|
1037
|
+
const shift = this.shiftVector(tileW, tileH, angle);
|
|
1038
|
+
drawTiles(
|
|
1039
|
+
ctx,
|
|
1040
|
+
snapshot,
|
|
1041
|
+
{ ...this.config, angle, offsetX: 0, offsetY: 0 },
|
|
1042
|
+
width,
|
|
1043
|
+
height,
|
|
1044
|
+
tileW,
|
|
1045
|
+
tileH,
|
|
1046
|
+
{ x: centre.x - shift.x, y: centre.y - shift.y }
|
|
1047
|
+
);
|
|
1048
|
+
return [
|
|
1049
|
+
`<g transform="rotate(${-angle}) translate(${-centre.x} ${-centre.y})">`,
|
|
1050
|
+
`<image x="0" y="0" width="${width}" height="${height}" `,
|
|
1051
|
+
`xlink:href="${el.toDataURL("image/png")}"></image></g>
|
|
1052
|
+
`
|
|
1053
|
+
];
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* The proxy holds a live reference to its source, which would make a
|
|
1057
|
+
* serialized canvas circular. Nothing persists this object (only layers are
|
|
1058
|
+
* serialized) — this keeps an accidental `canvas.toObject()` from throwing.
|
|
1059
|
+
*/
|
|
1060
|
+
toObject() {
|
|
1061
|
+
const plain = super.toObject();
|
|
1062
|
+
delete plain.source;
|
|
1063
|
+
return plain;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Fabric's generic clone round-trips through `toObject()` + the class
|
|
1067
|
+
* registry, which cannot carry a live source reference. Export paths clone
|
|
1068
|
+
* every canvas object, so without this a print export would silently lose the
|
|
1069
|
+
* tiling. The copy shares the source (it only ever reads from it).
|
|
1070
|
+
*/
|
|
1071
|
+
clone() {
|
|
1072
|
+
const copy = new _TiledPatternObject(this.source, this.config, this.area);
|
|
1073
|
+
copy.set({
|
|
1074
|
+
left: this.left,
|
|
1075
|
+
top: this.top,
|
|
1076
|
+
angle: this.angle,
|
|
1077
|
+
scaleX: this.scaleX,
|
|
1078
|
+
scaleY: this.scaleY,
|
|
1079
|
+
width: this.width,
|
|
1080
|
+
height: this.height,
|
|
1081
|
+
visible: this.visible,
|
|
1082
|
+
opacity: this.opacity
|
|
1083
|
+
});
|
|
1084
|
+
return Promise.resolve(copy);
|
|
1085
|
+
}
|
|
1086
|
+
/** The source's on-canvas width, before the tile scale. */
|
|
1087
|
+
baseW() {
|
|
1088
|
+
return Math.max(1, this.source.getBoundingRect().width);
|
|
1089
|
+
}
|
|
1090
|
+
/** Tile size from the config alone, ignoring any in-flight gesture. */
|
|
1091
|
+
baseTile() {
|
|
1092
|
+
const rect = this.source.getBoundingRect();
|
|
1093
|
+
const scale = Math.max(1, this.config.scale ?? 100) / 100;
|
|
1094
|
+
const floor = this.tileFloor();
|
|
1095
|
+
return {
|
|
1096
|
+
tileW: Math.max(floor, rect.width * scale),
|
|
1097
|
+
tileH: Math.max(floor, rect.height * scale)
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
/** Tile size as drawn right now, including a live scale gesture. */
|
|
1101
|
+
liveTile() {
|
|
1102
|
+
const base = this.baseTile();
|
|
1103
|
+
const floor = this.tileFloor();
|
|
1104
|
+
return {
|
|
1105
|
+
tileW: Math.max(floor, base.tileW * Math.abs(this.scaleX ?? 1)),
|
|
1106
|
+
tileH: Math.max(floor, base.tileH * Math.abs(this.scaleY ?? 1))
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/** Smallest tile the draw loop may use, so a tiny tile can't flood the area. */
|
|
1110
|
+
tileFloor() {
|
|
1111
|
+
const { width, height } = this.area;
|
|
1112
|
+
return Math.max(2, Math.sqrt(width * width + height * height) / 200);
|
|
1113
|
+
}
|
|
1114
|
+
/** Phase shift (`offsetX`/`offsetY`) as a canvas-space vector. */
|
|
1115
|
+
shiftVector(tileW, tileH, angle) {
|
|
1116
|
+
const dx = tileW * (clampPercent(this.config.offsetX) / 100);
|
|
1117
|
+
const dy = tileH * (clampPercent(this.config.offsetY) / 100);
|
|
1118
|
+
const radians = angle * Math.PI / 180;
|
|
1119
|
+
const cos = Math.cos(radians);
|
|
1120
|
+
const sin = Math.sin(radians);
|
|
1121
|
+
return new Point(dx * cos - dy * sin, dx * sin + dy * cos);
|
|
1122
|
+
}
|
|
1123
|
+
anchorFromOrigin(origin, tileW, tileH, angle) {
|
|
1124
|
+
const shift = this.shiftVector(tileW, tileH, angle);
|
|
1125
|
+
return new Point(origin.x + shift.x, origin.y + shift.y);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Snapshot the source at (at least) `scale`, reusing the cached one while it
|
|
1129
|
+
* is still sharp enough and the source has not changed.
|
|
1130
|
+
*/
|
|
1131
|
+
ensureSnapshot(scale) {
|
|
1132
|
+
const wanted = this.clampScale(scale);
|
|
1133
|
+
const stale = this.source.dirty || !this.snapshotEl || this.snapshotScale < wanted || this.snapshotScale > wanted * SNAPSHOT_SHRINK_FACTOR;
|
|
1134
|
+
if (!stale) return this.snapshotEl;
|
|
1135
|
+
const source = this.source;
|
|
1136
|
+
const opacity = source.opacity;
|
|
1137
|
+
source.opacity = 1;
|
|
1138
|
+
try {
|
|
1139
|
+
const el = source.toCanvasElement({ multiplier: wanted, enableRetinaScaling: false });
|
|
1140
|
+
if (!el.width || !el.height) return null;
|
|
1141
|
+
this.snapshotEl = el;
|
|
1142
|
+
this.snapshotScale = wanted;
|
|
1143
|
+
} catch {
|
|
1144
|
+
return this.snapshotEl;
|
|
1145
|
+
} finally {
|
|
1146
|
+
source.opacity = opacity;
|
|
1147
|
+
source.dirty = false;
|
|
1148
|
+
}
|
|
1149
|
+
return this.snapshotEl;
|
|
1150
|
+
}
|
|
1151
|
+
/** Bound the snapshot by both a linear scale and a total pixel budget. */
|
|
1152
|
+
clampScale(scale) {
|
|
1153
|
+
const requested = Math.min(MAX_SNAPSHOT_SCALE, Math.max(0.05, scale));
|
|
1154
|
+
const rect = this.source.getBoundingRect();
|
|
1155
|
+
const area = Math.max(1, rect.width * rect.height);
|
|
1156
|
+
const budgeted = Math.sqrt(MAX_SNAPSHOT_PIXELS / area);
|
|
1157
|
+
return Math.max(0.05, Math.min(requested, budgeted));
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
function clampPercent(value) {
|
|
1161
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
1162
|
+
return Math.max(-100, Math.min(100, value));
|
|
1163
|
+
}
|
|
1164
|
+
function contextScale(ctx) {
|
|
1165
|
+
if (typeof ctx.getTransform !== "function") return 1;
|
|
1166
|
+
try {
|
|
1167
|
+
const t = ctx.getTransform();
|
|
1168
|
+
return Math.max(Math.hypot(t.a, t.b), Math.hypot(t.c, t.d), 0.05);
|
|
1169
|
+
} catch {
|
|
1170
|
+
return 1;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/pattern/pattern-manager.ts
|
|
1175
|
+
var MIN_TILE_SCALE = 10;
|
|
1176
|
+
var MAX_TILE_SCALE = 300;
|
|
805
1177
|
var PatternManager = class {
|
|
806
|
-
constructor(canvas, layers, history, events
|
|
1178
|
+
constructor(canvas, layers, history, events) {
|
|
807
1179
|
this.canvas = canvas;
|
|
808
1180
|
this.layers = layers;
|
|
809
1181
|
this.history = history;
|
|
810
1182
|
this.events = events;
|
|
811
|
-
this.
|
|
1183
|
+
this.canvas.on("object:modified", this.onObjectModified);
|
|
1184
|
+
this.canvas.on("text:changed", this.onSourceModified);
|
|
1185
|
+
this.events.on("layer:removed", this.onLayerRemoved);
|
|
812
1186
|
}
|
|
813
1187
|
canvas;
|
|
814
1188
|
layers;
|
|
815
1189
|
history;
|
|
816
1190
|
events;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1191
|
+
proxies = /* @__PURE__ */ new Map();
|
|
1192
|
+
onObjectModified = (event) => {
|
|
1193
|
+
const target = event.target;
|
|
1194
|
+
if (target instanceof TiledPatternObject) this.commitGesture(target);
|
|
1195
|
+
else this.invalidateFor(target);
|
|
1196
|
+
};
|
|
1197
|
+
onSourceModified = (event) => {
|
|
1198
|
+
this.invalidateFor(event.target);
|
|
1199
|
+
};
|
|
1200
|
+
onLayerRemoved = ({ layerId }) => {
|
|
1201
|
+
const proxy = this.proxies.get(layerId);
|
|
1202
|
+
if (!proxy) return;
|
|
1203
|
+
proxy.dispose();
|
|
1204
|
+
this.proxies.delete(layerId);
|
|
1205
|
+
};
|
|
823
1206
|
isPattern(layerId) {
|
|
824
1207
|
return !!this.layers.get(layerId)?.meta.pattern;
|
|
825
1208
|
}
|
|
826
1209
|
getConfig(layerId) {
|
|
827
1210
|
return this.layers.get(layerId)?.meta.pattern?.config ?? null;
|
|
828
1211
|
}
|
|
829
|
-
/** Turn a
|
|
830
|
-
apply(layerId, config) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
const
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
};
|
|
1212
|
+
/** Turn a layer into a repeating pattern, or update an existing one. */
|
|
1213
|
+
async apply(layerId, config) {
|
|
1214
|
+
const layer = this.layers.get(layerId);
|
|
1215
|
+
if (!layer) return;
|
|
1216
|
+
try {
|
|
1217
|
+
this.layers.setMeta(layerId, { pattern: { config } });
|
|
1218
|
+
const proxy = this.proxies.get(layerId);
|
|
1219
|
+
if (proxy) {
|
|
1220
|
+
proxy.setConfig(config);
|
|
1221
|
+
this.canvas.requestRenderAll();
|
|
855
1222
|
} else {
|
|
856
|
-
layer
|
|
857
|
-
}
|
|
858
|
-
try {
|
|
859
|
-
await this.renderLayer(layer);
|
|
860
|
-
} catch (err) {
|
|
861
|
-
if (firstEnable) delete layer.meta.pattern;
|
|
862
|
-
throw err;
|
|
1223
|
+
this.attach(layer, config);
|
|
863
1224
|
}
|
|
864
1225
|
this.history.save();
|
|
865
|
-
}
|
|
866
|
-
this.events.emit("error", { message: "Failed to apply
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
this.events.emit("error", { message: "Failed to apply pattern", error });
|
|
867
1228
|
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
1229
|
}
|
|
896
|
-
if (changed) this.canvas.requestRenderAll();
|
|
897
1230
|
}
|
|
898
|
-
/**
|
|
899
|
-
disable(layerId) {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
1231
|
+
/** Drop the tiling and show the source again. */
|
|
1232
|
+
async disable(layerId) {
|
|
1233
|
+
const layer = this.layers.get(layerId);
|
|
1234
|
+
if (!layer?.meta.pattern) return;
|
|
1235
|
+
try {
|
|
1236
|
+
this.detach(layerId);
|
|
1237
|
+
restoreLocks(layer.fabricObject, layer.meta.pattern.originalLocks);
|
|
1238
|
+
layer.fabricObject.set({
|
|
1239
|
+
opacity: layer.opacity,
|
|
1240
|
+
selectable: !layer.locked,
|
|
1241
|
+
evented: !layer.locked
|
|
916
1242
|
});
|
|
917
|
-
|
|
918
|
-
restoreLocks(image, state.originalLocks);
|
|
919
|
-
image.setCoords();
|
|
920
|
-
delete layer.meta.pattern;
|
|
1243
|
+
this.layers.setMeta(layerId, { pattern: void 0 });
|
|
921
1244
|
this.canvas.requestRenderAll();
|
|
922
1245
|
this.history.save();
|
|
923
|
-
}
|
|
924
|
-
this.events.emit("error", { message: "Failed to clear
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
this.events.emit("error", { message: "Failed to clear pattern", error });
|
|
925
1248
|
throw error;
|
|
926
|
-
}
|
|
1249
|
+
}
|
|
927
1250
|
}
|
|
928
|
-
/**
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1251
|
+
/**
|
|
1252
|
+
* Rebuild every proxy after a state restore, migrating any layer that was
|
|
1253
|
+
* saved by the old bake-into-the-layer engine.
|
|
1254
|
+
*/
|
|
1255
|
+
async rehydrateAll() {
|
|
1256
|
+
this.clearProxies();
|
|
1257
|
+
for (const layer of this.layers.getAll()) {
|
|
1258
|
+
const state = layer.meta.pattern;
|
|
1259
|
+
if (!state) continue;
|
|
1260
|
+
try {
|
|
1261
|
+
if (isLegacyState(state)) {
|
|
1262
|
+
await unbakeLegacyLayer(layer, state);
|
|
1263
|
+
this.layers.setMeta(layer.id, { pattern: { config: state.config } });
|
|
1264
|
+
}
|
|
1265
|
+
this.attach(layer, state.config);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
this.events.emit("error", { message: "Failed to restore pattern layer", error });
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
this.canvas.requestRenderAll();
|
|
937
1271
|
}
|
|
938
|
-
|
|
1272
|
+
/** Re-fit every proxy to the print area (canvas resize). */
|
|
1273
|
+
syncArea() {
|
|
1274
|
+
const area = this.area();
|
|
1275
|
+
for (const proxy of this.proxies.values()) proxy.setArea(area);
|
|
1276
|
+
if (this.proxies.size > 0) this.canvas.requestRenderAll();
|
|
1277
|
+
}
|
|
1278
|
+
/** Drop a layer's cached source snapshot (its content changed). */
|
|
1279
|
+
invalidate(layerId) {
|
|
1280
|
+
const proxy = this.proxies.get(layerId);
|
|
1281
|
+
if (!proxy) return;
|
|
1282
|
+
proxy.invalidate();
|
|
1283
|
+
proxy.syncBox();
|
|
1284
|
+
this.canvas.requestRenderAll();
|
|
1285
|
+
}
|
|
1286
|
+
/** Give a freshly cloned layer its own proxy (duplicating a pattern layer). */
|
|
1287
|
+
attachTo(layer) {
|
|
939
1288
|
const state = layer.meta.pattern;
|
|
940
|
-
if (!state) return;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1289
|
+
if (!state || this.proxies.has(layer.id)) return;
|
|
1290
|
+
this.attach(layer, state.config);
|
|
1291
|
+
}
|
|
1292
|
+
dispose() {
|
|
1293
|
+
this.canvas.off("object:modified", this.onObjectModified);
|
|
1294
|
+
this.canvas.off("text:changed", this.onSourceModified);
|
|
1295
|
+
this.events.off("layer:removed", this.onLayerRemoved);
|
|
1296
|
+
this.clearProxies();
|
|
1297
|
+
}
|
|
1298
|
+
/** Release every proxy and the offscreen snapshot it holds. */
|
|
1299
|
+
clearProxies() {
|
|
1300
|
+
for (const proxy of this.proxies.values()) proxy.dispose();
|
|
1301
|
+
this.proxies.clear();
|
|
1302
|
+
}
|
|
1303
|
+
attach(layer, config) {
|
|
1304
|
+
const proxy = new TiledPatternObject(layer.fabricObject, config, this.area());
|
|
1305
|
+
const wasActive = this.canvas.getActiveObject() === layer.fabricObject;
|
|
1306
|
+
layer.fabricObject.set({ opacity: 0, selectable: false, evented: false });
|
|
1307
|
+
this.proxies.set(layer.id, proxy);
|
|
1308
|
+
this.layers.setRenderProxy(layer.id, proxy);
|
|
1309
|
+
if (wasActive) this.canvas.setActiveObject(proxy);
|
|
1310
|
+
this.canvas.requestRenderAll();
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Fold a finished drag / scale / rotate on the proxy back into the pattern:
|
|
1314
|
+
* position becomes the grid origin (carried by the source), scale becomes the
|
|
1315
|
+
* tile scale, rotation becomes the pattern angle. Read the live transform
|
|
1316
|
+
* first — writing the config re-fits the box and destroys it.
|
|
1317
|
+
*/
|
|
1318
|
+
commitGesture(proxy) {
|
|
1319
|
+
const layer = this.layers.findByObject(proxy);
|
|
1320
|
+
const state = layer?.meta.pattern;
|
|
1321
|
+
if (!layer || !state) return;
|
|
1322
|
+
const origin = proxy.liveOrigin();
|
|
1323
|
+
const scale = clamp2(proxy.liveScale(), MIN_TILE_SCALE, MAX_TILE_SCALE);
|
|
1324
|
+
const angle = normalizeAngle(proxy.liveAngle());
|
|
1325
|
+
layer.fabricObject.setPositionByOrigin(origin, "center", "center");
|
|
1326
|
+
layer.fabricObject.setCoords();
|
|
1327
|
+
const config = { ...state.config, scale, angle };
|
|
1328
|
+
this.layers.setMeta(layer.id, { pattern: { ...state, config } });
|
|
1329
|
+
proxy.setConfig(config);
|
|
973
1330
|
this.canvas.requestRenderAll();
|
|
1331
|
+
this.history.save();
|
|
1332
|
+
}
|
|
1333
|
+
area() {
|
|
1334
|
+
return { width: this.canvas.getWidth(), height: this.canvas.getHeight() };
|
|
1335
|
+
}
|
|
1336
|
+
detach(layerId) {
|
|
1337
|
+
const proxy = this.proxies.get(layerId);
|
|
1338
|
+
if (!proxy) {
|
|
1339
|
+
this.layers.setRenderProxy(layerId, null);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
this.layers.setRenderProxy(layerId, null);
|
|
1343
|
+
proxy.dispose();
|
|
1344
|
+
this.proxies.delete(layerId);
|
|
1345
|
+
}
|
|
1346
|
+
invalidateFor(target) {
|
|
1347
|
+
if (!target) return;
|
|
1348
|
+
const layer = this.layers.findByObject(target);
|
|
1349
|
+
if (layer) this.invalidate(layer.id);
|
|
974
1350
|
}
|
|
975
1351
|
};
|
|
976
|
-
function
|
|
977
|
-
return
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1352
|
+
function clamp2(value, min, max) {
|
|
1353
|
+
if (!Number.isFinite(value)) return min;
|
|
1354
|
+
return Math.max(min, Math.min(max, value));
|
|
1355
|
+
}
|
|
1356
|
+
function normalizeAngle(angle) {
|
|
1357
|
+
if (!Number.isFinite(angle)) return 0;
|
|
1358
|
+
const wrapped = (angle % 360 + 360) % 360;
|
|
1359
|
+
return Math.round(wrapped > 180 ? wrapped - 360 : wrapped);
|
|
1360
|
+
}
|
|
1361
|
+
function isLegacyState(state) {
|
|
1362
|
+
return typeof state.originalSrc === "string" && state.originalSrc.length > 0;
|
|
985
1363
|
}
|
|
986
|
-
function
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1364
|
+
async function unbakeLegacyLayer(layer, state) {
|
|
1365
|
+
const image = layer.fabricObject;
|
|
1366
|
+
if (typeof image.setSrc !== "function" || !state.original) return;
|
|
1367
|
+
await image.setSrc(state.originalSrc);
|
|
1368
|
+
image.set({
|
|
1369
|
+
left: state.original.left,
|
|
1370
|
+
top: state.original.top,
|
|
1371
|
+
scaleX: state.original.scaleX,
|
|
1372
|
+
scaleY: state.original.scaleY,
|
|
1373
|
+
width: state.original.width,
|
|
1374
|
+
height: state.original.height,
|
|
1375
|
+
cropX: state.original.cropX,
|
|
1376
|
+
cropY: state.original.cropY,
|
|
1377
|
+
angle: state.original.angle
|
|
994
1378
|
});
|
|
1379
|
+
image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
|
|
1380
|
+
restoreLocks(image, state.originalLocks);
|
|
1381
|
+
image.setCoords();
|
|
995
1382
|
}
|
|
996
1383
|
function restoreLocks(obj, locks) {
|
|
997
1384
|
obj.set(
|
|
@@ -1005,121 +1392,6 @@ function restoreLocks(obj, locks) {
|
|
|
1005
1392
|
}
|
|
1006
1393
|
);
|
|
1007
1394
|
}
|
|
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
1395
|
|
|
1124
1396
|
// src/text-curve.ts
|
|
1125
1397
|
import { Path } from "fabric";
|
|
@@ -1287,7 +1559,7 @@ var TextCurveManager = class {
|
|
|
1287
1559
|
};
|
|
1288
1560
|
|
|
1289
1561
|
// src/mask-presets/manager.ts
|
|
1290
|
-
import { FabricImage
|
|
1562
|
+
import { FabricImage, Path as Path2 } from "fabric";
|
|
1291
1563
|
|
|
1292
1564
|
// src/mask-presets/shapes.ts
|
|
1293
1565
|
var SHAPE_MASK_IDS = [
|
|
@@ -1560,7 +1832,7 @@ var MaskPresetManager = class {
|
|
|
1560
1832
|
scaleY: height / SHAPE_MASK_BOX
|
|
1561
1833
|
});
|
|
1562
1834
|
}
|
|
1563
|
-
return new
|
|
1835
|
+
return new FabricImage(renderTextureMask(id), {
|
|
1564
1836
|
...shared,
|
|
1565
1837
|
scaleX: width / TEXTURE_MASK_SIZE,
|
|
1566
1838
|
scaleY: height / TEXTURE_MASK_SIZE
|
|
@@ -2128,7 +2400,7 @@ var ProjectManager = class {
|
|
|
2128
2400
|
};
|
|
2129
2401
|
|
|
2130
2402
|
// src/mask.ts
|
|
2131
|
-
import { FabricImage as
|
|
2403
|
+
import { FabricImage as FabricImage2 } from "fabric";
|
|
2132
2404
|
var MaskRefinementError = class extends Error {
|
|
2133
2405
|
constructor(code, message, cause) {
|
|
2134
2406
|
super(message);
|
|
@@ -2160,7 +2432,7 @@ var MaskController = class {
|
|
|
2160
2432
|
throw new Error("Mask dimensions must be positive integers");
|
|
2161
2433
|
}
|
|
2162
2434
|
const backing = this.makeCanvas(width, height);
|
|
2163
|
-
const image = new
|
|
2435
|
+
const image = new FabricImage2(backing, {
|
|
2164
2436
|
left: 0,
|
|
2165
2437
|
top: 0,
|
|
2166
2438
|
originX: "left",
|
|
@@ -2385,6 +2657,737 @@ var MaskController = class {
|
|
|
2385
2657
|
}
|
|
2386
2658
|
};
|
|
2387
2659
|
|
|
2660
|
+
// src/masks/manager.ts
|
|
2661
|
+
import { Group as Group5 } from "fabric";
|
|
2662
|
+
|
|
2663
|
+
// src/masks/compose.ts
|
|
2664
|
+
import { Group, Rect as Rect2 } from "fabric";
|
|
2665
|
+
var MODE_OPERATION = {
|
|
2666
|
+
add: "source-over",
|
|
2667
|
+
subtract: "destination-out",
|
|
2668
|
+
intersect: "destination-in"
|
|
2669
|
+
};
|
|
2670
|
+
function neutralize(child) {
|
|
2671
|
+
child.set({ opacity: 0, globalCompositeOperation: "source-over" });
|
|
2672
|
+
}
|
|
2673
|
+
function baseRect(box) {
|
|
2674
|
+
return new Rect2({
|
|
2675
|
+
left: box.left,
|
|
2676
|
+
top: box.top,
|
|
2677
|
+
width: Math.max(1, box.width),
|
|
2678
|
+
height: Math.max(1, box.height),
|
|
2679
|
+
originX: "left",
|
|
2680
|
+
originY: "top",
|
|
2681
|
+
fill: "#000000",
|
|
2682
|
+
objectCaching: false
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
function composeMaskGroup(children, entries, options) {
|
|
2686
|
+
if (children.length === 0) return void 0;
|
|
2687
|
+
children.forEach((child, index) => {
|
|
2688
|
+
const entry = entries[index];
|
|
2689
|
+
child.set({ objectCaching: false });
|
|
2690
|
+
if (!entry || !entry.visible) {
|
|
2691
|
+
neutralize(child);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
child.set({
|
|
2695
|
+
opacity: entry.opacity,
|
|
2696
|
+
globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
|
|
2697
|
+
});
|
|
2698
|
+
});
|
|
2699
|
+
const first = entries.find((entry) => entry.visible);
|
|
2700
|
+
const withBase = first && first.mode !== "add" ? [baseRect(options.box), ...children] : [...children];
|
|
2701
|
+
return new Group(withBase, {
|
|
2702
|
+
absolutePositioned: options.absolute,
|
|
2703
|
+
// Cached, so the children's compositing operations resolve against each
|
|
2704
|
+
// other instead of against the page underneath the mask.
|
|
2705
|
+
objectCaching: true,
|
|
2706
|
+
subTargetCheck: false,
|
|
2707
|
+
interactive: false
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
function needsAbsoluteSpace(entries) {
|
|
2711
|
+
return entries.some((entry) => !entry.linked);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/masks/edit.ts
|
|
2715
|
+
import { util as util4 } from "fabric";
|
|
2716
|
+
|
|
2717
|
+
// src/masks/space.ts
|
|
2718
|
+
import { util as util3 } from "fabric";
|
|
2719
|
+
function matrixOf(object) {
|
|
2720
|
+
return object.calcTransformMatrix();
|
|
2721
|
+
}
|
|
2722
|
+
function applyMatrix(object, matrix) {
|
|
2723
|
+
const decomposed = util3.qrDecompose(matrix);
|
|
2724
|
+
object.set({
|
|
2725
|
+
flipX: false,
|
|
2726
|
+
flipY: false,
|
|
2727
|
+
originX: "center",
|
|
2728
|
+
originY: "center",
|
|
2729
|
+
left: decomposed.translateX,
|
|
2730
|
+
top: decomposed.translateY,
|
|
2731
|
+
scaleX: decomposed.scaleX,
|
|
2732
|
+
scaleY: decomposed.scaleY,
|
|
2733
|
+
angle: decomposed.angle,
|
|
2734
|
+
skewX: decomposed.skewX,
|
|
2735
|
+
skewY: 0
|
|
2736
|
+
});
|
|
2737
|
+
object.setCoords();
|
|
2738
|
+
}
|
|
2739
|
+
function toCanvasSpace(object, host) {
|
|
2740
|
+
applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), matrixOf(object)));
|
|
2741
|
+
}
|
|
2742
|
+
function toHostSpace(object, host) {
|
|
2743
|
+
applyMatrix(
|
|
2744
|
+
object,
|
|
2745
|
+
util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object))
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
function relativeMatrix(object, host) {
|
|
2749
|
+
return util3.multiplyTransformMatrices(util3.invertTransform(matrixOf(host)), matrixOf(object));
|
|
2750
|
+
}
|
|
2751
|
+
function applyRelativeMatrix(object, host, rel) {
|
|
2752
|
+
applyMatrix(object, util3.multiplyTransformMatrices(matrixOf(host), rel));
|
|
2753
|
+
}
|
|
2754
|
+
function asObject(clip) {
|
|
2755
|
+
return clip;
|
|
2756
|
+
}
|
|
2757
|
+
function toMatrix(values) {
|
|
2758
|
+
if (!values || values.length !== 6 || values.some((value) => !Number.isFinite(value)))
|
|
2759
|
+
return null;
|
|
2760
|
+
return [values[0], values[1], values[2], values[3], values[4], values[5]];
|
|
2761
|
+
}
|
|
2762
|
+
function fitToBox(object, box, zoom = 1) {
|
|
2763
|
+
const width = Math.max(1, box.width) * zoom;
|
|
2764
|
+
const height = Math.max(1, box.height) * zoom;
|
|
2765
|
+
object.set({
|
|
2766
|
+
originX: "center",
|
|
2767
|
+
originY: "center",
|
|
2768
|
+
angle: 0,
|
|
2769
|
+
skewX: 0,
|
|
2770
|
+
skewY: 0,
|
|
2771
|
+
left: box.left + box.width / 2,
|
|
2772
|
+
top: box.top + box.height / 2,
|
|
2773
|
+
scaleX: width / Math.max(1, object.width ?? 1),
|
|
2774
|
+
scaleY: height / Math.max(1, object.height ?? 1)
|
|
2775
|
+
});
|
|
2776
|
+
object.setCoords();
|
|
2777
|
+
}
|
|
2778
|
+
function unwrapGroup(group) {
|
|
2779
|
+
const children = group.removeAll();
|
|
2780
|
+
for (const child of children) child.setCoords();
|
|
2781
|
+
return children;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// src/masks/edit.ts
|
|
2785
|
+
var MaskEditController = class {
|
|
2786
|
+
constructor(canvas, onCommit) {
|
|
2787
|
+
this.canvas = canvas;
|
|
2788
|
+
this.onCommit = onCommit;
|
|
2789
|
+
}
|
|
2790
|
+
canvas;
|
|
2791
|
+
onCommit;
|
|
2792
|
+
handle = null;
|
|
2793
|
+
editing = null;
|
|
2794
|
+
child = null;
|
|
2795
|
+
group = null;
|
|
2796
|
+
active() {
|
|
2797
|
+
return this.editing;
|
|
2798
|
+
}
|
|
2799
|
+
/** Put a handle on the canvas for `child`, an object inside `group`. */
|
|
2800
|
+
async begin(edit, group, child) {
|
|
2801
|
+
this.end();
|
|
2802
|
+
const handle = await child.clone();
|
|
2803
|
+
applyMatrix(handle, matrixOf(child));
|
|
2804
|
+
handle.set({
|
|
2805
|
+
// Outline only: the mask's own fill would sit over the artwork it is
|
|
2806
|
+
// supposed to be revealing.
|
|
2807
|
+
fill: "rgba(0,0,0,0.001)",
|
|
2808
|
+
stroke: "#e0b055",
|
|
2809
|
+
strokeWidth: 2,
|
|
2810
|
+
strokeDashArray: [6, 4],
|
|
2811
|
+
strokeUniform: true,
|
|
2812
|
+
opacity: 1,
|
|
2813
|
+
selectable: true,
|
|
2814
|
+
evented: true,
|
|
2815
|
+
hasControls: true,
|
|
2816
|
+
hasBorders: true,
|
|
2817
|
+
objectCaching: false,
|
|
2818
|
+
excludeFromExport: true
|
|
2819
|
+
});
|
|
2820
|
+
this.handle = handle;
|
|
2821
|
+
this.editing = edit;
|
|
2822
|
+
this.child = child;
|
|
2823
|
+
this.group = group;
|
|
2824
|
+
this.canvas.add(handle);
|
|
2825
|
+
this.canvas.setActiveObject(handle);
|
|
2826
|
+
this.canvas.on("object:moving", this.onTransform);
|
|
2827
|
+
this.canvas.on("object:scaling", this.onTransform);
|
|
2828
|
+
this.canvas.on("object:rotating", this.onTransform);
|
|
2829
|
+
this.canvas.on("object:modified", this.onModified);
|
|
2830
|
+
this.canvas.requestRenderAll();
|
|
2831
|
+
return true;
|
|
2832
|
+
}
|
|
2833
|
+
/** Take the handle down. The geometry it wrote is already in the clip. */
|
|
2834
|
+
end() {
|
|
2835
|
+
if (!this.handle) return;
|
|
2836
|
+
this.canvas.off("object:moving", this.onTransform);
|
|
2837
|
+
this.canvas.off("object:scaling", this.onTransform);
|
|
2838
|
+
this.canvas.off("object:rotating", this.onTransform);
|
|
2839
|
+
this.canvas.off("object:modified", this.onModified);
|
|
2840
|
+
if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
|
|
2841
|
+
this.canvas.remove(this.handle);
|
|
2842
|
+
this.handle = null;
|
|
2843
|
+
this.editing = null;
|
|
2844
|
+
this.child = null;
|
|
2845
|
+
this.group = null;
|
|
2846
|
+
this.canvas.requestRenderAll();
|
|
2847
|
+
}
|
|
2848
|
+
dispose() {
|
|
2849
|
+
this.end();
|
|
2850
|
+
}
|
|
2851
|
+
onTransform = (event) => {
|
|
2852
|
+
if (!this.handle || event.target !== this.handle) return;
|
|
2853
|
+
this.write();
|
|
2854
|
+
};
|
|
2855
|
+
onModified = (event) => {
|
|
2856
|
+
if (!this.handle || event.target !== this.handle) return;
|
|
2857
|
+
this.write();
|
|
2858
|
+
this.onCommit();
|
|
2859
|
+
};
|
|
2860
|
+
/** Handle transform (canvas space) → clip child transform (group-relative). */
|
|
2861
|
+
write() {
|
|
2862
|
+
if (!this.handle || !this.child || !this.group) return;
|
|
2863
|
+
applyMatrix(
|
|
2864
|
+
this.child,
|
|
2865
|
+
util4.multiplyTransformMatrices(
|
|
2866
|
+
util4.invertTransform(matrixOf(this.group)),
|
|
2867
|
+
matrixOf(this.handle)
|
|
2868
|
+
)
|
|
2869
|
+
);
|
|
2870
|
+
this.group.dirty = true;
|
|
2871
|
+
this.group.set({ dirty: true });
|
|
2872
|
+
this.canvas.requestRenderAll();
|
|
2873
|
+
}
|
|
2874
|
+
};
|
|
2875
|
+
|
|
2876
|
+
// src/masks/store.ts
|
|
2877
|
+
import { Group as Group4 } from "fabric";
|
|
2878
|
+
|
|
2879
|
+
// src/masks/host.ts
|
|
2880
|
+
import { Rect as Rect3 } from "fabric";
|
|
2881
|
+
function findCanvasHost(layers) {
|
|
2882
|
+
return layers.getAll().find((layer) => layer.meta.canvasMask);
|
|
2883
|
+
}
|
|
2884
|
+
function createCanvasHost(canvas, layers) {
|
|
2885
|
+
const rect = new Rect3({
|
|
2886
|
+
left: 0,
|
|
2887
|
+
top: 0,
|
|
2888
|
+
width: canvas.getWidth(),
|
|
2889
|
+
height: canvas.getHeight(),
|
|
2890
|
+
originX: "left",
|
|
2891
|
+
originY: "top",
|
|
2892
|
+
fill: "#000000",
|
|
2893
|
+
globalCompositeOperation: "destination-in",
|
|
2894
|
+
// It is edited from the layer list, never on the canvas: a drag box on
|
|
2895
|
+
// something that cannot be dragged only reads as broken.
|
|
2896
|
+
selectable: false,
|
|
2897
|
+
evented: false,
|
|
2898
|
+
hasControls: false,
|
|
2899
|
+
hasBorders: false,
|
|
2900
|
+
objectCaching: false
|
|
2901
|
+
});
|
|
2902
|
+
const layer = layers.add("mask", rect, "Design mask");
|
|
2903
|
+
layer.meta.canvasMask = true;
|
|
2904
|
+
layers.setLocked(layer.id, true);
|
|
2905
|
+
return layer;
|
|
2906
|
+
}
|
|
2907
|
+
function pinCanvasHost(layers) {
|
|
2908
|
+
const all = layers.getAll();
|
|
2909
|
+
const hostIndex = all.findIndex((layer) => layer.meta.canvasMask);
|
|
2910
|
+
if (hostIndex === -1) return false;
|
|
2911
|
+
let lastContent = -1;
|
|
2912
|
+
all.forEach((layer, index) => {
|
|
2913
|
+
if (layer.type !== "mask") lastContent = index;
|
|
2914
|
+
});
|
|
2915
|
+
if (hostIndex >= lastContent) return false;
|
|
2916
|
+
layers.reorder(all[hostIndex].id, all.length - 1);
|
|
2917
|
+
return true;
|
|
2918
|
+
}
|
|
2919
|
+
function hostBoxOf(canvas, host, absolute) {
|
|
2920
|
+
if (!host) {
|
|
2921
|
+
return { left: 0, top: 0, width: canvas.getWidth(), height: canvas.getHeight() };
|
|
2922
|
+
}
|
|
2923
|
+
if (absolute) {
|
|
2924
|
+
host.setCoords();
|
|
2925
|
+
const rect = host.getBoundingRect();
|
|
2926
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
2927
|
+
}
|
|
2928
|
+
const width = Math.max(1, host.width ?? 1);
|
|
2929
|
+
const height = Math.max(1, host.height ?? 1);
|
|
2930
|
+
return { left: -width / 2, top: -height / 2, width, height };
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// src/masks/install.ts
|
|
2934
|
+
import { Group as Group3 } from "fabric";
|
|
2935
|
+
function convertSpace(host, sources, absolute) {
|
|
2936
|
+
const wasAbsolute = host.clipPath instanceof Group3 ? host.clipPath.absolutePositioned : absolute;
|
|
2937
|
+
if (absolute === wasAbsolute) return;
|
|
2938
|
+
for (const source of sources) {
|
|
2939
|
+
if (absolute) toCanvasSpace(source, host);
|
|
2940
|
+
else toHostSpace(source, host);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
function withRelativeTransforms(entries, sources, host, absolute) {
|
|
2944
|
+
return entries.map((entry, index) => {
|
|
2945
|
+
const source = sources[index];
|
|
2946
|
+
if (absolute && entry.linked && source) {
|
|
2947
|
+
return { ...entry, rel: [...relativeMatrix(source, host)] };
|
|
2948
|
+
}
|
|
2949
|
+
if (!entry.rel) return entry;
|
|
2950
|
+
const rest = { ...entry };
|
|
2951
|
+
delete rest.rel;
|
|
2952
|
+
return rest;
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
function installClip(host, entries, sources, box, absolute) {
|
|
2956
|
+
host.clipPath = composeMaskGroup(sources, entries, { box, absolute });
|
|
2957
|
+
host.dirty = true;
|
|
2958
|
+
host.setCoords();
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// src/masks/store.ts
|
|
2962
|
+
var CANVAS_MASK_TARGET = "canvas";
|
|
2963
|
+
var DEFAULT_ENTRY = {
|
|
2964
|
+
mode: "add",
|
|
2965
|
+
linked: true,
|
|
2966
|
+
visible: true,
|
|
2967
|
+
opacity: 1
|
|
2968
|
+
};
|
|
2969
|
+
var MaskStackStore = class {
|
|
2970
|
+
constructor(canvas, layers, history, events) {
|
|
2971
|
+
this.canvas = canvas;
|
|
2972
|
+
this.layers = layers;
|
|
2973
|
+
this.history = history;
|
|
2974
|
+
this.events = events;
|
|
2975
|
+
}
|
|
2976
|
+
canvas;
|
|
2977
|
+
layers;
|
|
2978
|
+
history;
|
|
2979
|
+
events;
|
|
2980
|
+
selected = null;
|
|
2981
|
+
pinning = false;
|
|
2982
|
+
list(target) {
|
|
2983
|
+
return this.hostLayer(target)?.meta.maskStack ?? [];
|
|
2984
|
+
}
|
|
2985
|
+
get(target, maskId) {
|
|
2986
|
+
return this.list(target).find((entry) => entry.id === maskId);
|
|
2987
|
+
}
|
|
2988
|
+
/** Every target that currently carries at least one mask. */
|
|
2989
|
+
targets() {
|
|
2990
|
+
return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
|
|
2991
|
+
}
|
|
2992
|
+
/** The box a mask is fitted to, in the space the stack is composed in. */
|
|
2993
|
+
hostBox(target, absolute = target === CANVAS_MASK_TARGET || needsAbsoluteSpace(this.list(target))) {
|
|
2994
|
+
return hostBoxOf(this.canvas, this.host(target), absolute);
|
|
2995
|
+
}
|
|
2996
|
+
/** The host layer of a target, optionally creating the design overlay. */
|
|
2997
|
+
hostLayer(target, create = false) {
|
|
2998
|
+
if (target !== CANVAS_MASK_TARGET) return this.layers.get(target);
|
|
2999
|
+
const existing = findCanvasHost(this.layers);
|
|
3000
|
+
if (existing || !create) return existing;
|
|
3001
|
+
return createCanvasHost(this.canvas, this.layers);
|
|
3002
|
+
}
|
|
3003
|
+
host(target, create = false) {
|
|
3004
|
+
return this.hostLayer(target, create)?.fabricObject ?? null;
|
|
3005
|
+
}
|
|
3006
|
+
/** Re-pin the design overlay, guarding the reorder that re-triggers this. */
|
|
3007
|
+
pin() {
|
|
3008
|
+
if (this.pinning) return;
|
|
3009
|
+
this.pinning = true;
|
|
3010
|
+
try {
|
|
3011
|
+
pinCanvasHost(this.layers);
|
|
3012
|
+
} finally {
|
|
3013
|
+
this.pinning = false;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Take the current geometry back out of the composed clip, entry-aligned.
|
|
3018
|
+
*
|
|
3019
|
+
* The entry list is the authority on how to read the clip: with no entries the
|
|
3020
|
+
* clip predates the stack (a mask preset, or a single `clipPath` an older host
|
|
3021
|
+
* installed) and is one mask whole — including when it happens to be a group,
|
|
3022
|
+
* which is why this cannot just unwrap anything group-shaped.
|
|
3023
|
+
*/
|
|
3024
|
+
unwrap(target, host) {
|
|
3025
|
+
const clip = host.clipPath;
|
|
3026
|
+
if (!clip) return [];
|
|
3027
|
+
const entries = this.list(target);
|
|
3028
|
+
if (entries.length === 0 || !(clip instanceof Group4)) return [asObject(clip)];
|
|
3029
|
+
const children = unwrapGroup(clip);
|
|
3030
|
+
const extra = children.length - entries.length;
|
|
3031
|
+
return extra > 0 ? children.slice(extra) : children;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Entries for a stack, adopting a pre-stack clip as the first one. Without
|
|
3035
|
+
* this, the first `add()` on an already-masked layer would compose a clip it
|
|
3036
|
+
* has no entry for and silently throw that mask away.
|
|
3037
|
+
*/
|
|
3038
|
+
entriesFor(target, sources) {
|
|
3039
|
+
const existing = this.list(target);
|
|
3040
|
+
if (existing.length > 0 || sources.length !== 1) return [...existing];
|
|
3041
|
+
const layer = this.hostLayer(target);
|
|
3042
|
+
const preset = layer?.meta.maskPreset;
|
|
3043
|
+
if (layer) {
|
|
3044
|
+
delete layer.meta.maskPreset;
|
|
3045
|
+
}
|
|
3046
|
+
return [
|
|
3047
|
+
{
|
|
3048
|
+
...DEFAULT_ENTRY,
|
|
3049
|
+
id: generateId(),
|
|
3050
|
+
name: typeof preset === "string" ? preset : "Mask",
|
|
3051
|
+
linked: target === CANVAS_MASK_TARGET ? false : !sources[0].absolutePositioned
|
|
3052
|
+
}
|
|
3053
|
+
];
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Install a stack: convert geometry into the space the stack needs, compose the
|
|
3057
|
+
* clip, store the entries, and commit one history checkpoint for the lot.
|
|
3058
|
+
*/
|
|
3059
|
+
commit(target, host, entries, sources, save = true, forceAbsolute = false) {
|
|
3060
|
+
const layer = this.hostLayer(target);
|
|
3061
|
+
if (!layer) return;
|
|
3062
|
+
const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
|
|
3063
|
+
convertSpace(host, sources, absolute);
|
|
3064
|
+
const next = withRelativeTransforms(entries, sources, host, absolute);
|
|
3065
|
+
installClip(host, next, sources, this.hostBox(target, absolute), absolute);
|
|
3066
|
+
if (next.length === 0) {
|
|
3067
|
+
delete layer.meta.maskStack;
|
|
3068
|
+
if (layer.meta.canvasMask) this.layers.remove(layer.id);
|
|
3069
|
+
} else {
|
|
3070
|
+
layer.meta.maskStack = next;
|
|
3071
|
+
}
|
|
3072
|
+
this.canvas.requestRenderAll();
|
|
3073
|
+
this.events.emit("masks:changed", { target });
|
|
3074
|
+
this.events.emit("layer:modified", { layerId: layer.id });
|
|
3075
|
+
if (save) this.history.save();
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
|
|
3079
|
+
// src/masks/manager.ts
|
|
3080
|
+
var LayerMaskManager = class extends MaskStackStore {
|
|
3081
|
+
// Committing mid-drag would recompose the group the handle writes into and
|
|
3082
|
+
// leave it pointing at a discarded object; the geometry is settled on endEdit.
|
|
3083
|
+
edits = new MaskEditController(this.canvas, () => this.history.save());
|
|
3084
|
+
onLayersChanged = () => this.pin();
|
|
3085
|
+
// Selecting a layer means the user has moved on from the mask they had open;
|
|
3086
|
+
// leaving both selected would show mask controls for an unrelated layer.
|
|
3087
|
+
onLayerSelected = () => {
|
|
3088
|
+
if (this.selected) this.select(null, null);
|
|
3089
|
+
};
|
|
3090
|
+
onObjectModified = (event) => {
|
|
3091
|
+
const object = event.target;
|
|
3092
|
+
if (!object) return;
|
|
3093
|
+
const layer = this.layers.findByObject(object);
|
|
3094
|
+
if (layer) this.reflow(layer.id);
|
|
3095
|
+
};
|
|
3096
|
+
constructor(canvas, layers, history, events) {
|
|
3097
|
+
super(canvas, layers, history, events);
|
|
3098
|
+
this.events.on("layers:changed", this.onLayersChanged);
|
|
3099
|
+
this.events.on("layer:selected", this.onLayerSelected);
|
|
3100
|
+
this.canvas.on("object:modified", this.onObjectModified);
|
|
3101
|
+
}
|
|
3102
|
+
dispose() {
|
|
3103
|
+
this.edits.dispose();
|
|
3104
|
+
this.events.off("layers:changed", this.onLayersChanged);
|
|
3105
|
+
this.events.off("layer:selected", this.onLayerSelected);
|
|
3106
|
+
this.canvas.off("object:modified", this.onObjectModified);
|
|
3107
|
+
this.selected = null;
|
|
3108
|
+
}
|
|
3109
|
+
// ─── Geometry editing ────────────────────────────────
|
|
3110
|
+
/** The mask currently being dragged on the canvas, if any. */
|
|
3111
|
+
editing() {
|
|
3112
|
+
return this.edits.active();
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* Put drag handles on one mask. The stack is composed in canvas space for the
|
|
3116
|
+
* duration — a linked mask would otherwise sit in the host's space, where the
|
|
3117
|
+
* handle's own canvas coordinates mean something else entirely. `endEdit`
|
|
3118
|
+
* returns it to whichever space its entries call for.
|
|
3119
|
+
*/
|
|
3120
|
+
async beginEdit(target, maskId) {
|
|
3121
|
+
const host = this.host(target);
|
|
3122
|
+
if (!host) return false;
|
|
3123
|
+
const entries = this.list(target);
|
|
3124
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3125
|
+
if (index === -1) return false;
|
|
3126
|
+
this.endEdit(false);
|
|
3127
|
+
this.commit(target, host, [...entries], this.unwrap(target, host), false, true);
|
|
3128
|
+
const group = host.clipPath instanceof Group5 ? host.clipPath : null;
|
|
3129
|
+
if (!group) return false;
|
|
3130
|
+
const children = group.getObjects();
|
|
3131
|
+
const child = children[children.length - entries.length + index];
|
|
3132
|
+
if (!child) return false;
|
|
3133
|
+
this.select(target, maskId);
|
|
3134
|
+
return this.edits.begin({ target, maskId }, group, child);
|
|
3135
|
+
}
|
|
3136
|
+
/** Take the handles down and settle the stack back into its own space. */
|
|
3137
|
+
endEdit(save = true) {
|
|
3138
|
+
const editing = this.edits.active();
|
|
3139
|
+
this.edits.end();
|
|
3140
|
+
if (editing) this.rebuild(editing.target, save);
|
|
3141
|
+
}
|
|
3142
|
+
// ─── Selection ───────────────────────────────────────
|
|
3143
|
+
getSelected() {
|
|
3144
|
+
return this.selected;
|
|
3145
|
+
}
|
|
3146
|
+
select(target, maskId) {
|
|
3147
|
+
this.selected = target && maskId ? { target, maskId } : null;
|
|
3148
|
+
this.events.emit("mask:selected", { target, maskId: this.selected ? maskId : null });
|
|
3149
|
+
}
|
|
3150
|
+
// ─── Mutations ───────────────────────────────────────
|
|
3151
|
+
add(target, object, options = {}) {
|
|
3152
|
+
const host = this.host(target, true);
|
|
3153
|
+
if (!host) return null;
|
|
3154
|
+
const sources = this.unwrap(target, host);
|
|
3155
|
+
const entries = this.entriesFor(target, sources);
|
|
3156
|
+
const entry = {
|
|
3157
|
+
...DEFAULT_ENTRY,
|
|
3158
|
+
id: generateId(),
|
|
3159
|
+
name: options.name ?? "Mask",
|
|
3160
|
+
...options.mode ? { mode: options.mode } : {},
|
|
3161
|
+
...options.linked === void 0 ? {} : { linked: options.linked },
|
|
3162
|
+
...options.meta ? { meta: options.meta } : {}
|
|
3163
|
+
};
|
|
3164
|
+
if (target === CANVAS_MASK_TARGET) entry.linked = false;
|
|
3165
|
+
if (options.fit !== false) {
|
|
3166
|
+
fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
|
|
3167
|
+
}
|
|
3168
|
+
sources.push(object);
|
|
3169
|
+
entries.push(entry);
|
|
3170
|
+
this.commit(target, host, entries, sources);
|
|
3171
|
+
return entry;
|
|
3172
|
+
}
|
|
3173
|
+
/** Swap one mask's geometry, keeping its identity, mode and position in the stack. */
|
|
3174
|
+
replaceGeometry(target, maskId, object, options = {}) {
|
|
3175
|
+
const host = this.host(target);
|
|
3176
|
+
if (!host) return false;
|
|
3177
|
+
const entries = [...this.list(target)];
|
|
3178
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3179
|
+
if (index === -1) return false;
|
|
3180
|
+
const sources = this.unwrap(target, host);
|
|
3181
|
+
if (options.fit !== false) {
|
|
3182
|
+
fitToBox(object, options.box ?? this.hostBox(target), options.fit ?? 1);
|
|
3183
|
+
}
|
|
3184
|
+
sources[index] = object;
|
|
3185
|
+
entries[index] = {
|
|
3186
|
+
...entries[index],
|
|
3187
|
+
...options.name ? { name: options.name } : {},
|
|
3188
|
+
...options.meta ? { meta: options.meta } : {}
|
|
3189
|
+
};
|
|
3190
|
+
this.commit(target, host, entries, sources);
|
|
3191
|
+
return true;
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Re-fit one mask to its host's box (or `box`) at `fit` scale, keeping the
|
|
3195
|
+
* geometry it already has. This is what a zoom control drives: re-picking the
|
|
3196
|
+
* mask would cost another render of a generated alpha map just to resize it.
|
|
3197
|
+
*/
|
|
3198
|
+
refit(target, maskId, options = {}) {
|
|
3199
|
+
const host = this.host(target);
|
|
3200
|
+
if (!host) return false;
|
|
3201
|
+
const entries = [...this.list(target)];
|
|
3202
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3203
|
+
if (index === -1) return false;
|
|
3204
|
+
const sources = this.unwrap(target, host);
|
|
3205
|
+
const source = sources[index];
|
|
3206
|
+
if (!source) return false;
|
|
3207
|
+
fitToBox(source, options.box ?? this.hostBox(target), options.fit ?? 1);
|
|
3208
|
+
this.commit(target, host, entries, sources, options.save ?? true);
|
|
3209
|
+
return true;
|
|
3210
|
+
}
|
|
3211
|
+
remove(target, maskId) {
|
|
3212
|
+
const host = this.host(target);
|
|
3213
|
+
if (!host) return false;
|
|
3214
|
+
const entries = [...this.list(target)];
|
|
3215
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3216
|
+
if (index === -1) return false;
|
|
3217
|
+
const sources = this.unwrap(target, host);
|
|
3218
|
+
entries.splice(index, 1);
|
|
3219
|
+
sources.splice(index, 1);
|
|
3220
|
+
if (this.selected?.maskId === maskId) this.select(null, null);
|
|
3221
|
+
this.commit(target, host, entries, sources);
|
|
3222
|
+
return true;
|
|
3223
|
+
}
|
|
3224
|
+
clear(target) {
|
|
3225
|
+
const host = this.host(target);
|
|
3226
|
+
if (!host) return false;
|
|
3227
|
+
if (this.list(target).length === 0) return false;
|
|
3228
|
+
if (this.selected?.target === target) this.select(null, null);
|
|
3229
|
+
this.commit(target, host, [], []);
|
|
3230
|
+
return true;
|
|
3231
|
+
}
|
|
3232
|
+
reorder(target, maskId, index) {
|
|
3233
|
+
const host = this.host(target);
|
|
3234
|
+
if (!host) return false;
|
|
3235
|
+
const entries = [...this.list(target)];
|
|
3236
|
+
const from = entries.findIndex((entry2) => entry2.id === maskId);
|
|
3237
|
+
if (from === -1) return false;
|
|
3238
|
+
const to = Math.max(0, Math.min(entries.length - 1, Math.round(index)));
|
|
3239
|
+
if (from === to) return false;
|
|
3240
|
+
const sources = this.unwrap(target, host);
|
|
3241
|
+
const [entry] = entries.splice(from, 1);
|
|
3242
|
+
const [source] = sources.splice(from, 1);
|
|
3243
|
+
entries.splice(to, 0, entry);
|
|
3244
|
+
sources.splice(to, 0, source);
|
|
3245
|
+
this.commit(target, host, entries, sources);
|
|
3246
|
+
return true;
|
|
3247
|
+
}
|
|
3248
|
+
/** Replace a mask's host metadata (which preset or generator produced it). */
|
|
3249
|
+
setMeta(target, maskId, meta) {
|
|
3250
|
+
return this.patch(target, maskId, () => ({ meta }));
|
|
3251
|
+
}
|
|
3252
|
+
setMode(target, maskId, mode) {
|
|
3253
|
+
return this.patch(target, maskId, (entry) => entry.mode === mode ? null : { mode });
|
|
3254
|
+
}
|
|
3255
|
+
setVisible(target, maskId, visible) {
|
|
3256
|
+
return this.patch(target, maskId, (entry) => entry.visible === visible ? null : { visible });
|
|
3257
|
+
}
|
|
3258
|
+
setOpacity(target, maskId, opacity) {
|
|
3259
|
+
if (!Number.isFinite(opacity)) return false;
|
|
3260
|
+
const next = Math.max(0, Math.min(1, opacity));
|
|
3261
|
+
return this.patch(
|
|
3262
|
+
target,
|
|
3263
|
+
maskId,
|
|
3264
|
+
(entry) => entry.opacity === next ? null : { opacity: next }
|
|
3265
|
+
);
|
|
3266
|
+
}
|
|
3267
|
+
setName(target, maskId, name) {
|
|
3268
|
+
const trimmed = name.trim();
|
|
3269
|
+
if (!trimmed) return false;
|
|
3270
|
+
return this.patch(
|
|
3271
|
+
target,
|
|
3272
|
+
maskId,
|
|
3273
|
+
(entry) => entry.name === trimmed ? null : { name: trimmed }
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
/**
|
|
3277
|
+
* Link or unlink one mask. Unlinking pins it where it currently appears;
|
|
3278
|
+
* re-linking records its position relative to the host so later host moves
|
|
3279
|
+
* carry it along.
|
|
3280
|
+
*/
|
|
3281
|
+
setLinked(target, maskId, linked) {
|
|
3282
|
+
if (target === CANVAS_MASK_TARGET) return false;
|
|
3283
|
+
const host = this.host(target);
|
|
3284
|
+
if (!host) return false;
|
|
3285
|
+
const entries = [...this.list(target)];
|
|
3286
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3287
|
+
if (index === -1 || entries[index].linked === linked) return false;
|
|
3288
|
+
const sources = this.unwrap(target, host);
|
|
3289
|
+
entries[index] = { ...entries[index], linked };
|
|
3290
|
+
this.commit(target, host, entries, sources);
|
|
3291
|
+
return true;
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Consume a layer, turning its artwork into a mask. Without an explicit target
|
|
3295
|
+
* it masks the layer directly beneath it, and the bottom layer masks the whole
|
|
3296
|
+
* design — there is nothing under it to clip.
|
|
3297
|
+
*/
|
|
3298
|
+
convertLayer(layerId, target) {
|
|
3299
|
+
const layer = this.layers.get(layerId);
|
|
3300
|
+
if (!layer || layer.meta.canvasMask) return null;
|
|
3301
|
+
const ordered = this.layers.getAll();
|
|
3302
|
+
const index = ordered.findIndex((candidate) => candidate.id === layerId);
|
|
3303
|
+
const below = index > 0 ? ordered[index - 1] : void 0;
|
|
3304
|
+
const resolved = target ?? (below && !below.meta.canvasMask ? below.id : CANVAS_MASK_TARGET);
|
|
3305
|
+
if (resolved === layerId) return null;
|
|
3306
|
+
const object = layer.fabricObject;
|
|
3307
|
+
if (!object.fill && object.type !== "image") object.set({ fill: "#000000" });
|
|
3308
|
+
this.history.beginTransaction();
|
|
3309
|
+
try {
|
|
3310
|
+
if (this.canvas.getActiveObject() === object) this.canvas.discardActiveObject();
|
|
3311
|
+
this.canvas.remove(object);
|
|
3312
|
+
this.layers.remove(layerId);
|
|
3313
|
+
const entry = this.add(resolved, object, { name: layer.name, fit: false });
|
|
3314
|
+
return entry ? { target: resolved, entry } : null;
|
|
3315
|
+
} finally {
|
|
3316
|
+
this.history.endTransaction();
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
// ─── Rebuild / restore ───────────────────────────────
|
|
3320
|
+
/** Recompose one stack's clip from the geometry it already holds. */
|
|
3321
|
+
rebuild(target, save = false) {
|
|
3322
|
+
const host = this.host(target);
|
|
3323
|
+
if (!host) return;
|
|
3324
|
+
const sources = this.unwrap(target, host);
|
|
3325
|
+
if (sources.length === 0) return;
|
|
3326
|
+
this.commit(target, host, this.entriesFor(target, sources), sources, save);
|
|
3327
|
+
}
|
|
3328
|
+
/** Re-derive linked masks after the host moved (canvas-space stacks only). */
|
|
3329
|
+
reflow(target) {
|
|
3330
|
+
const host = this.host(target);
|
|
3331
|
+
if (!host) return;
|
|
3332
|
+
const entries = this.list(target);
|
|
3333
|
+
if (entries.length === 0 || !needsAbsoluteSpace(entries)) return;
|
|
3334
|
+
if (!entries.some((entry) => entry.linked && entry.rel)) return;
|
|
3335
|
+
const sources = this.unwrap(target, host);
|
|
3336
|
+
entries.forEach((entry, index) => {
|
|
3337
|
+
const source = sources[index];
|
|
3338
|
+
const rel = toMatrix(entry.rel);
|
|
3339
|
+
if (!source || !entry.linked || !rel) return;
|
|
3340
|
+
applyRelativeMatrix(source, host, rel);
|
|
3341
|
+
});
|
|
3342
|
+
this.commit(target, host, [...entries], sources);
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Adopt a clip this manager did not install — a host's own single `clipPath` —
|
|
3346
|
+
* as a one-entry stack, so it shows up in the UI as a mask row before anything
|
|
3347
|
+
* is added to it. A mask preset is left alone unless asked for by name: it is
|
|
3348
|
+
* still owned by `MaskPresetManager` until a stack operation takes it over.
|
|
3349
|
+
*/
|
|
3350
|
+
adopt(target, name) {
|
|
3351
|
+
const layer = this.hostLayer(target);
|
|
3352
|
+
const clip = layer?.fabricObject.clipPath;
|
|
3353
|
+
if (!layer || !clip || layer.meta.pattern) return null;
|
|
3354
|
+
if ((layer.meta.maskStack ?? []).length > 0) return null;
|
|
3355
|
+
const entry = {
|
|
3356
|
+
...DEFAULT_ENTRY,
|
|
3357
|
+
id: generateId(),
|
|
3358
|
+
name: name ?? "Mask",
|
|
3359
|
+
linked: target === CANVAS_MASK_TARGET ? false : !clip.absolutePositioned
|
|
3360
|
+
};
|
|
3361
|
+
layer.meta.maskStack = [entry];
|
|
3362
|
+
delete layer.meta.maskPreset;
|
|
3363
|
+
this.rebuild(target);
|
|
3364
|
+
return entry;
|
|
3365
|
+
}
|
|
3366
|
+
/** After a state restore: re-pin the design overlay and recompose every stack. */
|
|
3367
|
+
refreshAll() {
|
|
3368
|
+
this.selected = null;
|
|
3369
|
+
this.pin();
|
|
3370
|
+
for (const layer of this.layers.getAll()) {
|
|
3371
|
+
if ((layer.meta.maskStack ?? []).length === 0) continue;
|
|
3372
|
+
this.rebuild(layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
// ─── Internals ───────────────────────────────────────
|
|
3376
|
+
patch(target, maskId, change) {
|
|
3377
|
+
const host = this.host(target);
|
|
3378
|
+
if (!host) return false;
|
|
3379
|
+
const entries = [...this.list(target)];
|
|
3380
|
+
const index = entries.findIndex((entry) => entry.id === maskId);
|
|
3381
|
+
if (index === -1) return false;
|
|
3382
|
+
const patch = change(entries[index]);
|
|
3383
|
+
if (!patch) return false;
|
|
3384
|
+
const sources = this.unwrap(target, host);
|
|
3385
|
+
entries[index] = { ...entries[index], ...patch };
|
|
3386
|
+
this.commit(target, host, entries, sources);
|
|
3387
|
+
return true;
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
3390
|
+
|
|
2388
3391
|
// src/editor.ts
|
|
2389
3392
|
var MIN_ZOOM = 0.1;
|
|
2390
3393
|
var MAX_ZOOM = 8;
|
|
@@ -2402,6 +3405,8 @@ var CanvasEditor = class {
|
|
|
2402
3405
|
patterns;
|
|
2403
3406
|
curves;
|
|
2404
3407
|
maskPresets;
|
|
3408
|
+
/** Stacked boolean masks, per layer and for the design as a whole. */
|
|
3409
|
+
layerMasks;
|
|
2405
3410
|
fonts;
|
|
2406
3411
|
licensing;
|
|
2407
3412
|
pages;
|
|
@@ -2447,15 +3452,10 @@ var CanvasEditor = class {
|
|
|
2447
3452
|
this.layers.setHistoryCallback(() => this.history.save());
|
|
2448
3453
|
this.snapping = new SnapManager(this.canvas, this.events);
|
|
2449
3454
|
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
|
-
);
|
|
3455
|
+
this.patterns = new PatternManager(this.canvas, this.layers, this.history, this.events);
|
|
2457
3456
|
this.curves = new TextCurveManager(this.canvas, this.layers, this.history, this.events);
|
|
2458
3457
|
this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
|
|
3458
|
+
this.layerMasks = new LayerMaskManager(this.canvas, this.layers, this.history, this.events);
|
|
2459
3459
|
this.setupCanvasEvents();
|
|
2460
3460
|
this.refreshSelectionStyle();
|
|
2461
3461
|
this.history.saveImmediate();
|
|
@@ -2465,12 +3465,13 @@ var CanvasEditor = class {
|
|
|
2465
3465
|
// ─── Layer Operations ────────────────────────────────
|
|
2466
3466
|
async addImage(url, options) {
|
|
2467
3467
|
try {
|
|
2468
|
-
const img = await
|
|
3468
|
+
const img = await FabricImage3.fromURL(
|
|
2469
3469
|
url,
|
|
2470
3470
|
{},
|
|
2471
3471
|
{ originX: "left", originY: "top", ...options }
|
|
2472
3472
|
);
|
|
2473
3473
|
const layer = this.layers.add("image", img);
|
|
3474
|
+
this.layers.select(layer.id);
|
|
2474
3475
|
this.history.save();
|
|
2475
3476
|
return layer;
|
|
2476
3477
|
} catch (error) {
|
|
@@ -2490,7 +3491,7 @@ var CanvasEditor = class {
|
|
|
2490
3491
|
if (this.crop.activeLayerId() === layerId) this.crop.cancel();
|
|
2491
3492
|
const previous = layer.fabricObject;
|
|
2492
3493
|
try {
|
|
2493
|
-
const replacement = await
|
|
3494
|
+
const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
|
|
2494
3495
|
replacement.set({
|
|
2495
3496
|
left: previous.left,
|
|
2496
3497
|
top: previous.top,
|
|
@@ -2530,12 +3531,14 @@ var CanvasEditor = class {
|
|
|
2530
3531
|
...options
|
|
2531
3532
|
});
|
|
2532
3533
|
const layer = this.layers.add("text", textbox);
|
|
3534
|
+
this.layers.select(layer.id);
|
|
2533
3535
|
this.history.save();
|
|
2534
3536
|
return layer;
|
|
2535
3537
|
}
|
|
2536
3538
|
addShape(plugin, options) {
|
|
2537
3539
|
const obj = plugin.create(options);
|
|
2538
3540
|
const layer = this.layers.add("shape", obj, plugin.name);
|
|
3541
|
+
this.layers.select(layer.id);
|
|
2539
3542
|
this.history.save();
|
|
2540
3543
|
return layer;
|
|
2541
3544
|
}
|
|
@@ -2561,7 +3564,7 @@ var CanvasEditor = class {
|
|
|
2561
3564
|
const { objects, options } = await loadSVGFromString(resolved);
|
|
2562
3565
|
const validObjects = objects.filter((object) => object !== null);
|
|
2563
3566
|
if (validObjects.length === 0) throw new Error("Template SVG contains no renderable objects");
|
|
2564
|
-
const group =
|
|
3567
|
+
const group = util5.groupSVGElements(validObjects, options);
|
|
2565
3568
|
group.set({
|
|
2566
3569
|
left: this.canvas.getWidth() / 2,
|
|
2567
3570
|
top: this.canvas.getHeight() / 2,
|
|
@@ -2569,6 +3572,7 @@ var CanvasEditor = class {
|
|
|
2569
3572
|
originY: "center"
|
|
2570
3573
|
});
|
|
2571
3574
|
const layer = this.layers.add("template", group, template.name);
|
|
3575
|
+
this.layers.select(layer.id);
|
|
2572
3576
|
this.history.save();
|
|
2573
3577
|
return layer;
|
|
2574
3578
|
}
|
|
@@ -2606,9 +3610,7 @@ var CanvasEditor = class {
|
|
|
2606
3610
|
const layer = this.layers.get(id);
|
|
2607
3611
|
if (!layer) return null;
|
|
2608
3612
|
const clone = await layer.fabricObject.clone();
|
|
2609
|
-
|
|
2610
|
-
clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
|
|
2611
|
-
}
|
|
3613
|
+
clone.set({ left: (clone.left ?? 0) + 15, top: (clone.top ?? 0) + 15 });
|
|
2612
3614
|
clone.setCoords();
|
|
2613
3615
|
const copy = this.layers.add(layer.type, clone, `${layer.name} copy`);
|
|
2614
3616
|
copy.meta = structuredClone(layer.meta);
|
|
@@ -2621,7 +3623,8 @@ var CanvasEditor = class {
|
|
|
2621
3623
|
evented: !layer.locked,
|
|
2622
3624
|
opacity: layer.opacity
|
|
2623
3625
|
});
|
|
2624
|
-
this.
|
|
3626
|
+
this.patterns.attachTo(copy);
|
|
3627
|
+
this.layers.select(copy.id);
|
|
2625
3628
|
this.canvas.requestRenderAll();
|
|
2626
3629
|
this.history.save();
|
|
2627
3630
|
return copy;
|
|
@@ -2654,7 +3657,7 @@ var CanvasEditor = class {
|
|
|
2654
3657
|
const childData = children.map((layer) => structuredClone(layer.toData()));
|
|
2655
3658
|
const objects = children.map((layer) => layer.fabricObject);
|
|
2656
3659
|
for (const layer of children) this.layers.remove(layer.id);
|
|
2657
|
-
const group = new
|
|
3660
|
+
const group = new Group6(objects);
|
|
2658
3661
|
const grouped = this.layers.add("group", group, name);
|
|
2659
3662
|
grouped.meta.groupChildren = childData;
|
|
2660
3663
|
this.layers.select(grouped.id);
|
|
@@ -2669,11 +3672,9 @@ var CanvasEditor = class {
|
|
|
2669
3672
|
if (!grouped || grouped.type !== "group" || !Array.isArray(childData)) return [];
|
|
2670
3673
|
const group = grouped.fabricObject;
|
|
2671
3674
|
return this.history.transaction(() => {
|
|
2672
|
-
const transform = group.calcTransformMatrix();
|
|
2673
3675
|
const objects = group.removeAll();
|
|
2674
3676
|
this.layers.remove(id);
|
|
2675
3677
|
const restored = objects.map((object, index) => {
|
|
2676
|
-
util3.addTransformToObject(object, transform);
|
|
2677
3678
|
object.setCoords();
|
|
2678
3679
|
const data = childData[index];
|
|
2679
3680
|
const layer = this.layers.add(data?.type ?? "group", object, data?.name, data?.id);
|
|
@@ -2700,7 +3701,8 @@ var CanvasEditor = class {
|
|
|
2700
3701
|
}
|
|
2701
3702
|
try {
|
|
2702
3703
|
await deserializeEditor(this, state);
|
|
2703
|
-
this.patterns.
|
|
3704
|
+
await this.patterns.rehydrateAll();
|
|
3705
|
+
this.layerMasks.refreshAll();
|
|
2704
3706
|
} catch (error) {
|
|
2705
3707
|
if (!managedByHistory) {
|
|
2706
3708
|
this.events.emit("error", { message: "Failed to load editor state", error });
|
|
@@ -2729,7 +3731,7 @@ var CanvasEditor = class {
|
|
|
2729
3731
|
const layer = this.layers.get(id);
|
|
2730
3732
|
if (!layer) throw new Error(`Layer not found: ${id}`);
|
|
2731
3733
|
try {
|
|
2732
|
-
if (options.resolution === "source" && layer.fabricObject instanceof
|
|
3734
|
+
if (options.resolution === "source" && layer.fabricObject instanceof FabricImage3) {
|
|
2733
3735
|
const image = await layer.fabricObject.clone();
|
|
2734
3736
|
image.set({
|
|
2735
3737
|
left: 0,
|
|
@@ -2749,7 +3751,11 @@ var CanvasEditor = class {
|
|
|
2749
3751
|
cloneObjects: false
|
|
2750
3752
|
});
|
|
2751
3753
|
}
|
|
2752
|
-
return await exportIsolatedPNG(
|
|
3754
|
+
return await exportIsolatedPNG(
|
|
3755
|
+
this.canvas,
|
|
3756
|
+
[layer.renderProxy ?? layer.fabricObject],
|
|
3757
|
+
options
|
|
3758
|
+
);
|
|
2753
3759
|
} catch (error) {
|
|
2754
3760
|
this.events.emit("error", { message: `Failed to export layer: ${id}`, error });
|
|
2755
3761
|
throw error;
|
|
@@ -3001,7 +4007,7 @@ var CanvasEditor = class {
|
|
|
3001
4007
|
return;
|
|
3002
4008
|
}
|
|
3003
4009
|
try {
|
|
3004
|
-
const image = await
|
|
4010
|
+
const image = await FabricImage3.fromURL(
|
|
3005
4011
|
url,
|
|
3006
4012
|
{ crossOrigin: options.crossOrigin ?? null, signal: options.signal },
|
|
3007
4013
|
{ originX: "left", originY: "top" }
|
|
@@ -3054,7 +4060,6 @@ var CanvasEditor = class {
|
|
|
3054
4060
|
const sx = widthPx / oldWidth;
|
|
3055
4061
|
const sy = heightPx / oldHeight;
|
|
3056
4062
|
for (const layer of this.layers.getAll()) {
|
|
3057
|
-
if (layer.meta.pattern) continue;
|
|
3058
4063
|
const object = layer.fabricObject;
|
|
3059
4064
|
object.set({
|
|
3060
4065
|
left: (object.left ?? 0) * sx,
|
|
@@ -3075,7 +4080,7 @@ var CanvasEditor = class {
|
|
|
3075
4080
|
}
|
|
3076
4081
|
}
|
|
3077
4082
|
this.canvas.setDimensions({ width: widthPx, height: heightPx });
|
|
3078
|
-
this.patterns.
|
|
4083
|
+
this.patterns.syncArea();
|
|
3079
4084
|
this.canvas.requestRenderAll();
|
|
3080
4085
|
this.history.save();
|
|
3081
4086
|
this.events.emit("canvas:modified", {});
|
|
@@ -3270,10 +4275,11 @@ var CanvasEditor = class {
|
|
|
3270
4275
|
// ─── Cleanup ────────────────────────────────────────
|
|
3271
4276
|
dispose() {
|
|
3272
4277
|
this.masks.dispose();
|
|
4278
|
+
this.layerMasks.dispose();
|
|
3273
4279
|
this.snapping.dispose();
|
|
3274
4280
|
this.crop.dispose();
|
|
3275
4281
|
this.history.dispose();
|
|
3276
|
-
|
|
4282
|
+
this.patterns.dispose();
|
|
3277
4283
|
this.events.removeAllListeners();
|
|
3278
4284
|
this.canvas.dispose();
|
|
3279
4285
|
}
|
|
@@ -3392,6 +4398,7 @@ var AnnotationOverlay = class {
|
|
|
3392
4398
|
};
|
|
3393
4399
|
export {
|
|
3394
4400
|
AnnotationOverlay,
|
|
4401
|
+
CANVAS_MASK_TARGET,
|
|
3395
4402
|
CANVAS_SIZE_PRESETS,
|
|
3396
4403
|
CanvasEditor,
|
|
3397
4404
|
CropController,
|
|
@@ -3404,6 +4411,7 @@ export {
|
|
|
3404
4411
|
HistoryManager,
|
|
3405
4412
|
Layer,
|
|
3406
4413
|
LayerManager,
|
|
4414
|
+
LayerMaskManager,
|
|
3407
4415
|
LicenseManager,
|
|
3408
4416
|
MaskController,
|
|
3409
4417
|
MaskPresetManager,
|
|
@@ -3416,18 +4424,16 @@ export {
|
|
|
3416
4424
|
TEXTURE_MASK_IDS,
|
|
3417
4425
|
TEXTURE_MASK_SIZE,
|
|
3418
4426
|
TextCurveManager,
|
|
4427
|
+
TiledPatternObject,
|
|
3419
4428
|
UnitConverter,
|
|
3420
4429
|
applyAspectLock,
|
|
3421
4430
|
applyLayerShadow,
|
|
3422
4431
|
applyObjectSelectionStyle,
|
|
3423
|
-
applyPatternLocks,
|
|
3424
4432
|
applySelectionStyle,
|
|
3425
4433
|
buildCurvePathData,
|
|
3426
|
-
buildPatternDataURL,
|
|
3427
|
-
captureLocks,
|
|
3428
4434
|
clamp,
|
|
3429
|
-
clearPatternImageCache,
|
|
3430
4435
|
clearTextureMaskCache,
|
|
4436
|
+
composeMaskGroup,
|
|
3431
4437
|
computeCoverPlacement,
|
|
3432
4438
|
computePrintAreaClip,
|
|
3433
4439
|
computeTilePositions,
|
|
@@ -3440,12 +4446,13 @@ export {
|
|
|
3440
4446
|
exportPNG,
|
|
3441
4447
|
exportPrintArea,
|
|
3442
4448
|
exportSVG,
|
|
4449
|
+
fitToBox,
|
|
3443
4450
|
generateId,
|
|
3444
4451
|
isCssColor,
|
|
3445
4452
|
isMaskPresetId,
|
|
3446
4453
|
isShapeMaskId,
|
|
3447
4454
|
isTextureMaskId,
|
|
3448
|
-
|
|
4455
|
+
needsAbsoluteSpace,
|
|
3449
4456
|
readLayerShadow,
|
|
3450
4457
|
renderTextureMask,
|
|
3451
4458
|
resetTransform,
|
|
@@ -3453,6 +4460,9 @@ export {
|
|
|
3453
4460
|
round2,
|
|
3454
4461
|
sanitizeSvg,
|
|
3455
4462
|
serializeEditor,
|
|
3456
|
-
shapeMaskPathData
|
|
4463
|
+
shapeMaskPathData,
|
|
4464
|
+
toCanvasSpace,
|
|
4465
|
+
toHostSpace,
|
|
4466
|
+
unwrapGroup
|
|
3457
4467
|
};
|
|
3458
4468
|
//# sourceMappingURL=index.mjs.map
|