@overtone-art/canvas-editor-core 0.8.5 → 0.9.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
@@ -1,11 +1,20 @@
1
1
  import {
2
+ ADJUST_KEYS,
3
+ ADJUST_RANGES,
4
+ IMAGE_FILTER_PRESETS,
5
+ IMAGE_PROXY_MAX_EDGE,
6
+ NEUTRAL_ADJUST,
2
7
  TEXT_FIT_SLACK,
3
8
  TextWrapManager,
9
+ alphaClipMatrix,
4
10
  applyMatrix,
5
11
  applyRelativeMatrix,
6
12
  applyTextWrapToObject,
7
13
  asObject,
8
14
  asText,
15
+ clamp,
16
+ clampAdjust,
17
+ clampAlphaClip,
9
18
  computeCoverPlacement,
10
19
  computePrintAreaClip,
11
20
  displaceRgba,
@@ -17,19 +26,34 @@ import {
17
26
  exportSVG,
18
27
  fitTextWidth,
19
28
  fitToBox,
29
+ imageEffectsFilterSpecs,
30
+ imageFilterPreset,
20
31
  matrixOf,
32
+ normalizeImageEffects,
21
33
  preWrapWordSplit,
22
34
  readTextWrap,
23
35
  relativeMatrix,
36
+ round2,
24
37
  textInk,
25
38
  toCanvasSpace,
26
39
  toHostSpace,
27
40
  toMatrix,
28
41
  unwrapGroup
29
- } from "./chunk-TP527WVQ.mjs";
42
+ } from "./chunk-QZUDGRRS.mjs";
30
43
 
31
44
  // src/editor.ts
32
- import { Canvas, FabricImage as FabricImage3, Group as Group7, Rect as Rect4, Textbox as Textbox2, filters } from "fabric";
45
+ import {
46
+ ActiveSelection as ActiveSelection3,
47
+ Canvas,
48
+ FabricImage as FabricImage3,
49
+ Group as Group7,
50
+ Rect as Rect4,
51
+ Textbox as Textbox2,
52
+ classRegistry as classRegistry2,
53
+ util as util7
54
+ } from "fabric";
55
+ import * as fabricModule from "fabric";
56
+ import { registerEffects } from "@overtone-art/canvas-editor-effects";
33
57
 
34
58
  // src/events.ts
35
59
  var EventEmitter = class {
@@ -67,7 +91,7 @@ var EventEmitter = class {
67
91
  };
68
92
 
69
93
  // src/layer.ts
70
- import { ActiveSelection } from "fabric";
94
+ import { ActiveSelection, util } from "fabric";
71
95
 
72
96
  // src/utils/id.ts
73
97
  import { nanoid } from "nanoid";
@@ -181,9 +205,31 @@ var Layer = class {
181
205
  ...this.toData(),
182
206
  // A group's own object already carries its children's geometry, so the
183
207
  // child records in `toData` stay identity-only — nothing is duplicated.
184
- fabricObject: this.fabricObject.toObject()
208
+ fabricObject: this.serializeObject()
185
209
  };
186
210
  }
211
+ /**
212
+ * A member of an ActiveSelection holds selection-relative coordinates and
213
+ * `toObject()` writes them verbatim. A snapshot taken mid-selection — which
214
+ * `HistoryManager.undo()` takes — then held positions no restore could use,
215
+ * and the redo scattered the layers. Realise the selection's transform for
216
+ * the write and put the object's own transform back.
217
+ */
218
+ serializeObject() {
219
+ const object = this.fabricObject;
220
+ const selection = object.group;
221
+ if (!(selection instanceof ActiveSelection)) {
222
+ return object.toObject();
223
+ }
224
+ const saved = util.saveObjectTransform(object);
225
+ util.addTransformToObject(object, selection.calcOwnMatrix());
226
+ try {
227
+ return object.toObject();
228
+ } finally {
229
+ object.set(saved);
230
+ object.setCoords();
231
+ }
232
+ }
187
233
  };
188
234
  var LayerManager = class {
189
235
  constructor(canvas, events) {
@@ -1084,16 +1130,6 @@ function bestSnap(edges, candidates, threshold) {
1084
1130
 
1085
1131
  // src/crop.ts
1086
1132
  import { Rect } from "fabric";
1087
-
1088
- // src/utils/clamp.ts
1089
- function clamp(v, min, max) {
1090
- return Math.min(max, Math.max(min, v));
1091
- }
1092
- function round2(v) {
1093
- return Math.round(v * 100) / 100;
1094
- }
1095
-
1096
- // src/crop.ts
1097
1133
  var CropController = class {
1098
1134
  constructor(canvas, history, events) {
1099
1135
  this.canvas = canvas;
@@ -1115,7 +1151,7 @@ var CropController = class {
1115
1151
  activeLayerId() {
1116
1152
  return this.session?.layerId ?? null;
1117
1153
  }
1118
- start(layer) {
1154
+ start(layer, options = {}) {
1119
1155
  if (layer.type !== "image") return;
1120
1156
  if (this.session) this.cancel();
1121
1157
  const image = layer.fabricObject;
@@ -1123,6 +1159,12 @@ var CropController = class {
1123
1159
  if (prevAngle) image.rotate(0);
1124
1160
  image.setCoords();
1125
1161
  const b = image.getBoundingRect();
1162
+ const prevTransform = {
1163
+ scaleX: image.scaleX,
1164
+ scaleY: image.scaleY,
1165
+ left: image.left,
1166
+ top: image.top
1167
+ };
1126
1168
  const rect = new Rect({
1127
1169
  left: b.left,
1128
1170
  top: b.top,
@@ -1147,8 +1189,95 @@ var CropController = class {
1147
1189
  this.canvas.add(rect);
1148
1190
  this.canvas.setActiveObject(rect);
1149
1191
  this.canvas.requestRenderAll();
1150
- this.session = { layerId: layer.id, image, rect, prevSelectable, prevEvented, prevAngle };
1192
+ this.session = {
1193
+ layerId: layer.id,
1194
+ image,
1195
+ rect,
1196
+ prevSelectable,
1197
+ prevEvented,
1198
+ prevAngle,
1199
+ prevTransform,
1200
+ startRect: { left: b.left, top: b.top, width: b.width, height: b.height },
1201
+ aspect: null
1202
+ };
1151
1203
  this.events.emit("crop:changed", { active: true, layerId: layer.id });
1204
+ if (options.aspect) this.setAspect(options.aspect);
1205
+ }
1206
+ /**
1207
+ * The frame's own geometry, without its stroke.
1208
+ *
1209
+ * The rect draws a 1px dashed outline, and `getBoundingRect` /
1210
+ * `getScaledWidth` both count it — so reading the frame through them made the
1211
+ * aspect box, the zoom centre and the applied crop each about a pixel larger
1212
+ * than the box the user drew. The rect is axis-aligned (rotation is locked),
1213
+ * so its own left/top and scaled dimensions are exact.
1214
+ */
1215
+ frameBox(rect) {
1216
+ return {
1217
+ left: rect.left,
1218
+ top: rect.top,
1219
+ width: rect.width * rect.scaleX,
1220
+ height: rect.height * rect.scaleY
1221
+ };
1222
+ }
1223
+ /**
1224
+ * Lock the frame to a ratio: it becomes the largest box of that ratio inside
1225
+ * the current frame, centred, and only the corner handles stay — fabric's
1226
+ * default `uniformScaling` then keeps the ratio through a drag. `null` frees it.
1227
+ */
1228
+ setAspect(ratio) {
1229
+ const s = this.session;
1230
+ if (!s) return;
1231
+ s.aspect = ratio && ratio > 0 ? ratio : null;
1232
+ const { rect } = s;
1233
+ const sides = s.aspect === null;
1234
+ rect.setControlsVisibility({ ml: sides, mr: sides, mt: sides, mb: sides, mtr: false });
1235
+ if (s.aspect !== null) {
1236
+ rect.setCoords();
1237
+ const current = this.frameBox(rect);
1238
+ const width = Math.min(current.width, current.height * s.aspect);
1239
+ const height = width / s.aspect;
1240
+ rect.set({
1241
+ left: current.left + (current.width - width) / 2,
1242
+ top: current.top + (current.height - height) / 2,
1243
+ width,
1244
+ height,
1245
+ scaleX: 1,
1246
+ scaleY: 1
1247
+ });
1248
+ rect.setCoords();
1249
+ }
1250
+ this.canvas.requestRenderAll();
1251
+ }
1252
+ /** Zoom the image under a fixed frame, about the frame's centre. */
1253
+ setScale(factor) {
1254
+ const s = this.session;
1255
+ if (!s || !Number.isFinite(factor) || factor <= 0) return;
1256
+ const { image, rect } = s;
1257
+ rect.setCoords();
1258
+ const frame = this.frameBox(rect);
1259
+ const centre = { x: frame.left + frame.width / 2, y: frame.top + frame.height / 2 };
1260
+ const scaleX = Math.max(0.01, image.scaleX * factor);
1261
+ const scaleY = Math.max(0.01, image.scaleY * factor);
1262
+ const applied = scaleX / image.scaleX;
1263
+ image.set({
1264
+ scaleX,
1265
+ scaleY,
1266
+ left: centre.x - (centre.x - image.left) * applied,
1267
+ top: centre.y - (centre.y - image.top) * applied
1268
+ });
1269
+ image.setCoords();
1270
+ this.canvas.requestRenderAll();
1271
+ }
1272
+ /** Back to the frame and image transform the session started with; aspect freed. */
1273
+ reset() {
1274
+ const s = this.session;
1275
+ if (!s) return;
1276
+ s.image.set(s.prevTransform);
1277
+ s.image.setCoords();
1278
+ s.rect.set({ ...s.startRect, scaleX: 1, scaleY: 1 });
1279
+ s.rect.setCoords();
1280
+ this.setAspect(null);
1152
1281
  }
1153
1282
  apply() {
1154
1283
  const s = this.session;
@@ -1163,10 +1292,11 @@ var CropController = class {
1163
1292
  const el = image.getElement();
1164
1293
  const naturalW = el.naturalWidth || image.width || 0;
1165
1294
  const naturalH = el.naturalHeight || image.height || 0;
1166
- let cropX = (image.cropX ?? 0) + (rect.left - imgLeft) / scaleX;
1167
- let cropY = (image.cropY ?? 0) + (rect.top - imgTop) / scaleY;
1168
- let cropW = rect.getScaledWidth() / scaleX;
1169
- let cropH = rect.getScaledHeight() / scaleY;
1295
+ const frame = this.frameBox(rect);
1296
+ let cropX = (image.cropX ?? 0) + (frame.left - imgLeft) / scaleX;
1297
+ let cropY = (image.cropY ?? 0) + (frame.top - imgTop) / scaleY;
1298
+ let cropW = frame.width / scaleX;
1299
+ let cropH = frame.height / scaleY;
1170
1300
  cropX = clamp(cropX, 0, Math.max(0, naturalW - 1));
1171
1301
  cropY = clamp(cropY, 0, Math.max(0, naturalH - 1));
1172
1302
  cropW = clamp(cropW, 1, naturalW - cropX);
@@ -1191,6 +1321,8 @@ var CropController = class {
1191
1321
  cancel() {
1192
1322
  const s = this.session;
1193
1323
  if (!s) return;
1324
+ s.image.set(s.prevTransform);
1325
+ s.image.setCoords();
1194
1326
  if (s.prevAngle) {
1195
1327
  s.image.rotate(s.prevAngle);
1196
1328
  s.image.setCoords();
@@ -1215,11 +1347,67 @@ var CropController = class {
1215
1347
  };
1216
1348
  var STROKE2 = "#22c55e";
1217
1349
 
1350
+ // src/align.ts
1351
+ import { ActiveSelection as ActiveSelection2, Point, util as util2 } from "fabric";
1352
+ function unionBox(boxes) {
1353
+ const left = Math.min(...boxes.map((b) => b.left));
1354
+ const top = Math.min(...boxes.map((b) => b.top));
1355
+ const right = Math.max(...boxes.map((b) => b.left + b.width));
1356
+ const bottom = Math.max(...boxes.map((b) => b.top + b.height));
1357
+ return { left, top, width: right - left, height: bottom - top };
1358
+ }
1359
+ function boxOf(object) {
1360
+ object.setCoords();
1361
+ return object.getBoundingRect();
1362
+ }
1363
+ function moveBy(object, dx, dy) {
1364
+ const parent = object.group;
1365
+ const vector = parent ? util2.sendVectorToPlane(new Point(dx, dy), void 0, parent.calcTransformMatrix()) : new Point(dx, dy);
1366
+ object.set({ left: (object.left ?? 0) + vector.x, top: (object.top ?? 0) + vector.y });
1367
+ object.setCoords();
1368
+ }
1369
+ function alignObjects(objects, target, frame) {
1370
+ for (const object of objects) {
1371
+ const b = boxOf(object);
1372
+ if (target === "left") moveBy(object, frame.left - b.left, 0);
1373
+ if (target === "center-h") moveBy(object, frame.left + (frame.width - b.width) / 2 - b.left, 0);
1374
+ if (target === "right") moveBy(object, frame.left + frame.width - b.width - b.left, 0);
1375
+ if (target === "top") moveBy(object, 0, frame.top - b.top);
1376
+ if (target === "middle") moveBy(object, 0, frame.top + (frame.height - b.height) / 2 - b.top);
1377
+ if (target === "bottom") moveBy(object, 0, frame.top + frame.height - b.height - b.top);
1378
+ }
1379
+ }
1380
+ function distributeObjects(objects, axis) {
1381
+ if (objects.length < 3) return false;
1382
+ const measured = objects.map((object) => ({ object, box: boxOf(object) }));
1383
+ const pos = (b) => axis === "h" ? b.left : b.top;
1384
+ const size = (b) => axis === "h" ? b.width : b.height;
1385
+ const ordered = [...measured].sort((a, b) => pos(a.box) - pos(b.box));
1386
+ const span = unionBox(measured.map((m) => m.box));
1387
+ const total = ordered.reduce((sum, m) => sum + size(m.box), 0);
1388
+ const gap = (size(span) - total) / (ordered.length - 1);
1389
+ let cursor = pos(span);
1390
+ for (const { object, box } of ordered) {
1391
+ const delta = cursor - pos(box);
1392
+ moveBy(object, axis === "h" ? delta : 0, axis === "h" ? 0 : delta);
1393
+ cursor += size(box) + gap;
1394
+ }
1395
+ return true;
1396
+ }
1397
+ function relayoutSelection(active) {
1398
+ if (!(active instanceof ActiveSelection2)) return;
1399
+ active.triggerLayout();
1400
+ active.setCoords();
1401
+ }
1402
+ function canvasBox(width, height) {
1403
+ return { left: 0, top: 0, width, height };
1404
+ }
1405
+
1218
1406
  // src/pattern/pattern-manager.ts
1219
- import { util } from "fabric";
1407
+ import { util as util3 } from "fabric";
1220
1408
 
1221
1409
  // src/pattern/tiled-pattern-object.ts
1222
- import { FabricObject, Point } from "fabric";
1410
+ import { FabricObject, Point as Point2 } from "fabric";
1223
1411
 
1224
1412
  // src/pattern/tile-geometry.ts
1225
1413
  var MAX_TILES_PER_AXIS = 200;
@@ -1376,7 +1564,7 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1376
1564
  const centre = this.getCenterPoint();
1377
1565
  const shift = this.shiftVector(tileW, tileH, this.liveAngle());
1378
1566
  const ink = this.inkOffset();
1379
- return new Point(centre.x - shift.x - ink.x, centre.y - shift.y - ink.y);
1567
+ return new Point2(centre.x - shift.x - ink.x, centre.y - shift.y - ink.y);
1380
1568
  }
1381
1569
  _render(ctx) {
1382
1570
  const { width, height } = this.area;
@@ -1463,6 +1651,10 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1463
1651
  angle: this.angle,
1464
1652
  scaleX: this.scaleX,
1465
1653
  scaleY: this.scaleY,
1654
+ skewX: this.skewX,
1655
+ skewY: this.skewY,
1656
+ flipX: this.flipX,
1657
+ flipY: this.flipY,
1466
1658
  width: this.width,
1467
1659
  height: this.height,
1468
1660
  visible: this.visible,
@@ -1508,12 +1700,12 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1508
1700
  */
1509
1701
  inkOffset() {
1510
1702
  const text = asText(this.source);
1511
- if (!text) return new Point(0, 0);
1703
+ if (!text) return new Point2(0, 0);
1512
1704
  const { dx } = textInk(text);
1513
- if (!dx) return new Point(0, 0);
1705
+ if (!dx) return new Point2(0, 0);
1514
1706
  const scaled = dx * (this.source.scaleX ?? 1);
1515
1707
  const radians = (this.source.angle ?? 0) * Math.PI / 180;
1516
- return new Point(scaled * Math.cos(radians), scaled * Math.sin(radians));
1708
+ return new Point2(scaled * Math.cos(radians), scaled * Math.sin(radians));
1517
1709
  }
1518
1710
  /** The source's on-canvas width, before the tile scale. */
1519
1711
  baseW() {
@@ -1574,11 +1766,11 @@ var TiledPatternObject = class _TiledPatternObject extends FabricObject {
1574
1766
  const radians = angle * Math.PI / 180;
1575
1767
  const cos = Math.cos(radians);
1576
1768
  const sin = Math.sin(radians);
1577
- return new Point(dx * cos - dy * sin, dx * sin + dy * cos);
1769
+ return new Point2(dx * cos - dy * sin, dx * sin + dy * cos);
1578
1770
  }
1579
1771
  anchorFromOrigin(origin, tileW, tileH, angle) {
1580
1772
  const shift = this.shiftVector(tileW, tileH, angle);
1581
- return new Point(origin.x + shift.x, origin.y + shift.y);
1773
+ return new Point2(origin.x + shift.x, origin.y + shift.y);
1582
1774
  }
1583
1775
  /**
1584
1776
  * Snapshot the source at (at least) `scale`, reusing the cached one while it
@@ -1783,6 +1975,11 @@ var PatternManager = class {
1783
1975
  if (wasActive) this.canvas.setActiveObject(proxy);
1784
1976
  this.canvas.requestRenderAll();
1785
1977
  }
1978
+ /** Fold a programmatic move of the proxy into the config and the source, as a drop does. */
1979
+ commitProxy(layerId) {
1980
+ const proxy = this.proxies.get(layerId);
1981
+ if (proxy) this.commitGesture(proxy);
1982
+ }
1786
1983
  /**
1787
1984
  * Fold a finished drag / scale / rotate on the proxy back into the pattern:
1788
1985
  * position becomes the grid origin (carried by the source), scale becomes the
@@ -1850,7 +2047,7 @@ async function unbakeLegacyLayer(layer, state) {
1850
2047
  cropY: state.original.cropY,
1851
2048
  angle: state.original.angle
1852
2049
  });
1853
- image.clipPath = state.originalClip ? (await util.enlivenObjects([state.originalClip]))[0] : void 0;
2050
+ image.clipPath = state.originalClip ? (await util3.enlivenObjects([state.originalClip]))[0] : void 0;
1854
2051
  restoreLocks(image, state.originalLocks);
1855
2052
  image.setCoords();
1856
2053
  }
@@ -1867,6 +2064,530 @@ function restoreLocks(obj, locks) {
1867
2064
  );
1868
2065
  }
1869
2066
 
2067
+ // src/shapes.ts
2068
+ function resolveShapeParams(plugin, overrides = {}) {
2069
+ const params = {};
2070
+ for (const def of plugin.params ?? []) {
2071
+ const value = overrides[def.key];
2072
+ const wanted = typeof value === "number" && Number.isFinite(value) ? value : def.default;
2073
+ params[def.key] = clamp(wanted, def.min, def.max);
2074
+ }
2075
+ return params;
2076
+ }
2077
+ var ShapeManager = class {
2078
+ constructor(canvas, layers, history, events) {
2079
+ this.canvas = canvas;
2080
+ this.layers = layers;
2081
+ this.history = history;
2082
+ this.events = events;
2083
+ this.canvas.on("object:modified", this.onObjectModified);
2084
+ }
2085
+ canvas;
2086
+ layers;
2087
+ history;
2088
+ events;
2089
+ plugins = /* @__PURE__ */ new Map();
2090
+ /**
2091
+ * An arrow scaled 2×1 must not stretch its head: the new box is baked into
2092
+ * the points and the scale returns to 1. Runs before the editor's own
2093
+ * `object:modified` handler (constructed earlier), so history records the
2094
+ * baked state.
2095
+ */
2096
+ onObjectModified = (event) => {
2097
+ const target = event.target;
2098
+ if (!target) return;
2099
+ const layer = this.layers.findByObject(target);
2100
+ const state = layer?.meta.shape;
2101
+ if (!layer || !state || target !== layer.fabricObject) return;
2102
+ const plugin = this.plugins.get(state.id);
2103
+ if (!plugin?.bakesScale || !plugin.regenerate) return;
2104
+ const { scaleX = 1, scaleY = 1 } = target;
2105
+ if (Math.abs(scaleX - scaleY) < 1e-6) return;
2106
+ target.set({
2107
+ width: target.width * scaleX,
2108
+ height: target.height * scaleY,
2109
+ scaleX: 1,
2110
+ scaleY: 1
2111
+ });
2112
+ plugin.regenerate(target, state.params);
2113
+ target.setCoords();
2114
+ target.dirty = true;
2115
+ this.canvas.requestRenderAll();
2116
+ };
2117
+ dispose() {
2118
+ this.canvas.off("object:modified", this.onObjectModified);
2119
+ }
2120
+ /** A restored layer names its plugin by id; only a registered one can regenerate. */
2121
+ register(plugin) {
2122
+ this.plugins.set(plugin.name, plugin);
2123
+ }
2124
+ read(layerId) {
2125
+ return this.layers.get(layerId)?.meta.shape ?? null;
2126
+ }
2127
+ /**
2128
+ * `commit: false` is a slider drag: geometry and `meta.shape` are written and
2129
+ * `layer:modified` is emitted so the Shape panel re-reads, but nothing reaches
2130
+ * history. The default commits one entry.
2131
+ */
2132
+ setParams(layerId, patch, commit = true) {
2133
+ const layer = this.layers.get(layerId);
2134
+ const state = layer?.meta.shape;
2135
+ if (!layer || !state) return;
2136
+ const plugin = this.plugins.get(state.id);
2137
+ if (!plugin?.regenerate) return;
2138
+ const params = resolveShapeParams(plugin, { ...state.params, ...patch });
2139
+ plugin.regenerate(layer.fabricObject, params);
2140
+ layer.fabricObject.setCoords();
2141
+ layer.fabricObject.dirty = true;
2142
+ const next = { ...state, params };
2143
+ if (commit) this.layers.setMeta(layerId, { shape: next });
2144
+ else layer.meta.shape = next;
2145
+ this.canvas.requestRenderAll();
2146
+ this.events.emit("layer:modified", { layerId });
2147
+ if (commit) this.history.save();
2148
+ }
2149
+ };
2150
+
2151
+ // src/image-effects/manager.ts
2152
+ import { classRegistry, getFilterBackend } from "fabric";
2153
+
2154
+ // src/image-effects/proxy.ts
2155
+ import { util as util4 } from "fabric";
2156
+ function sourceSize(source) {
2157
+ const el = source;
2158
+ return { width: el.naturalWidth || el.width, height: el.naturalHeight || el.height };
2159
+ }
2160
+ function proxyScale(width, height, maxEdge) {
2161
+ const edge = Math.max(width, height);
2162
+ return edge > maxEdge ? maxEdge / edge : 1;
2163
+ }
2164
+ function buildProxySource(source, maxEdge) {
2165
+ const { width, height } = sourceSize(source);
2166
+ const scale = proxyScale(width, height, maxEdge);
2167
+ if (scale === 1) return null;
2168
+ const element = util4.createCanvasElement();
2169
+ element.width = Math.max(1, Math.round(width * scale));
2170
+ element.height = Math.max(1, Math.round(height * scale));
2171
+ const context = element.getContext("2d");
2172
+ if (!context) throw new Error("2D canvas context is unavailable");
2173
+ context.drawImage(source, 0, 0, element.width, element.height);
2174
+ return { element, width: element.width, height: element.height };
2175
+ }
2176
+
2177
+ // src/image-effects/manager.ts
2178
+ var stable = (value) => JSON.stringify(
2179
+ value,
2180
+ (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort()) : v
2181
+ );
2182
+ var ImageEffectsManager = class {
2183
+ constructor(canvas, layers, history, events) {
2184
+ this.canvas = canvas;
2185
+ this.layers = layers;
2186
+ this.history = history;
2187
+ this.events = events;
2188
+ this.events.on("layer:removed", this.onRemoved);
2189
+ }
2190
+ canvas;
2191
+ layers;
2192
+ history;
2193
+ events;
2194
+ proxies = /* @__PURE__ */ new Map();
2195
+ lastSpecs = /* @__PURE__ */ new Map();
2196
+ onRemoved = ({ layerId }) => this.drop(layerId);
2197
+ read(layerId) {
2198
+ const layer = this.imageLayer(layerId);
2199
+ return layer ? normalizeImageEffects(layer.meta) : { adjust: {} };
2200
+ }
2201
+ /** The filter instances a state derives to, in the D5 order, with no layer involved. */
2202
+ filtersFor(state) {
2203
+ return imageEffectsFilterSpecs(state).map(
2204
+ (spec) => new (classRegistry.getClass(spec.type))(spec)
2205
+ );
2206
+ }
2207
+ preview(layerId, patch) {
2208
+ this.write(layerId, patch);
2209
+ }
2210
+ commit(layerId, patch) {
2211
+ if (this.write(layerId, patch)) this.history.save();
2212
+ }
2213
+ reset(layerId) {
2214
+ if (this.write(layerId, { preset: void 0, adjust: {} }, true)) this.history.save();
2215
+ }
2216
+ /** Re-derive after a source swap or a load. */
2217
+ refresh(layerId) {
2218
+ const layer = this.imageLayer(layerId);
2219
+ if (layer) this.apply(layer, this.read(layerId));
2220
+ }
2221
+ refreshAll() {
2222
+ for (const id of [...this.proxies.keys()]) if (!this.layers.get(id)) this.drop(id);
2223
+ for (const layer of this.layers.getAll()) {
2224
+ if (layer.type !== "image") continue;
2225
+ if ("imageAdjustments" in layer.meta) {
2226
+ const migrated = normalizeImageEffects(layer.meta);
2227
+ layer.meta.imageEffects = {
2228
+ ...migrated.preset ? { preset: migrated.preset } : {},
2229
+ adjust: migrated.adjust
2230
+ };
2231
+ delete layer.meta.imageAdjustments;
2232
+ }
2233
+ this.apply(layer, this.read(layer.id));
2234
+ }
2235
+ }
2236
+ hasProxy(layerId) {
2237
+ return this.proxies.has(layerId);
2238
+ }
2239
+ /** Run `fn` with every proxied image filtered at source resolution (export), then restore the proxies. */
2240
+ withSourceResolution(fn) {
2241
+ const swapped = [];
2242
+ for (const id of this.proxies.keys()) {
2243
+ const layer = this.imageLayer(id);
2244
+ if (!layer) continue;
2245
+ const image = layer.fabricObject;
2246
+ image._element = image._originalElement;
2247
+ image._filteredEl = void 0;
2248
+ image._filterScalingX = 1;
2249
+ image._filterScalingY = 1;
2250
+ image.applyFilters();
2251
+ swapped.push(layer);
2252
+ }
2253
+ try {
2254
+ return fn();
2255
+ } finally {
2256
+ for (const layer of swapped) {
2257
+ const image = layer.fabricObject;
2258
+ if (image._filteredEl) image._filteredEl.width = 0;
2259
+ image._filteredEl = void 0;
2260
+ image._element = image._originalElement;
2261
+ this.render(layer);
2262
+ }
2263
+ }
2264
+ }
2265
+ dispose() {
2266
+ this.events.off("layer:removed", this.onRemoved);
2267
+ for (const id of [...this.proxies.keys()]) this.drop(id);
2268
+ this.lastSpecs.clear();
2269
+ }
2270
+ imageLayer(layerId) {
2271
+ const layer = this.layers.get(layerId);
2272
+ return layer && layer.type === "image" ? layer : void 0;
2273
+ }
2274
+ write(layerId, patch, replace = false) {
2275
+ const layer = this.imageLayer(layerId);
2276
+ if (!layer) return false;
2277
+ const current = replace ? { adjust: {} } : this.read(layerId);
2278
+ const presetChanged = "preset" in patch && stable(patch.preset) !== stable(current.preset);
2279
+ const seed = presetChanged && patch.preset ? imageFilterPreset(patch.preset.id)?.adjust ?? {} : current.adjust;
2280
+ const preset = "preset" in patch ? patch.preset : current.preset;
2281
+ const alphaClip = clampAlphaClip("alphaClip" in patch ? patch.alphaClip : current.alphaClip);
2282
+ const next = {
2283
+ ...preset ? { preset } : {},
2284
+ adjust: { ...seed, ...patch.adjust ?? {} },
2285
+ ...alphaClip ? { alphaClip } : {}
2286
+ };
2287
+ layer.meta.imageEffects = { ...preset ? { preset } : {}, adjust: next.adjust };
2288
+ if (alphaClip) layer.meta.imageAlphaClip = alphaClip;
2289
+ else delete layer.meta.imageAlphaClip;
2290
+ delete layer.meta.imageAdjustments;
2291
+ this.apply(layer, next);
2292
+ this.events.emit("layer:modified", { layerId });
2293
+ return true;
2294
+ }
2295
+ apply(layer, state) {
2296
+ const image = layer.fabricObject;
2297
+ const specs = imageEffectsFilterSpecs(state);
2298
+ const previous = this.lastSpecs.get(layer.id) ?? specs;
2299
+ const clip = clampAlphaClip(state.alphaClip);
2300
+ const legacyClip = clip ? stable(alphaClipMatrix(clip)) : null;
2301
+ const foreign = image.filters.filter((f) => {
2302
+ if (f.isNeutralState()) return false;
2303
+ const plain = f.toObject();
2304
+ if (legacyClip && plain.type === "ColorMatrix" && stable(plain.matrix) === legacyClip)
2305
+ return false;
2306
+ return !previous.some((s) => stable(s) === stable(plain));
2307
+ });
2308
+ const managed = specs.map((spec) => new (classRegistry.getClass(spec.type))(spec));
2309
+ image.filters = [...managed, ...foreign];
2310
+ this.lastSpecs.set(layer.id, specs);
2311
+ this.render(layer);
2312
+ }
2313
+ render(layer) {
2314
+ const image = layer.fabricObject;
2315
+ const record = this.proxyFor(layer.id, image);
2316
+ if (!record) {
2317
+ image.applyFilters();
2318
+ } else {
2319
+ const active = image.filters.filter((f) => !f.isNeutralState());
2320
+ if (active.length === 0) {
2321
+ image._element = image._originalElement;
2322
+ image._filterScalingX = 1;
2323
+ image._filterScalingY = 1;
2324
+ } else {
2325
+ getFilterBackend().applyFilters(
2326
+ active,
2327
+ record.source.element,
2328
+ record.source.width,
2329
+ record.source.height,
2330
+ record.target,
2331
+ `${image.cacheKey}_proxy`
2332
+ );
2333
+ const { width, height } = sourceSize(image._originalElement);
2334
+ image._element = record.target;
2335
+ image._filterScalingX = record.source.width / width;
2336
+ image._filterScalingY = record.source.height / height;
2337
+ }
2338
+ image.set("dirty", true);
2339
+ }
2340
+ this.canvas.requestRenderAll();
2341
+ }
2342
+ proxyFor(layerId, image) {
2343
+ const existing = this.proxies.get(layerId);
2344
+ if (existing && existing.original === image._originalElement) return existing;
2345
+ if (existing) this.drop(layerId);
2346
+ const source = buildProxySource(image._originalElement, IMAGE_PROXY_MAX_EDGE);
2347
+ if (!source) return null;
2348
+ const target = source.element.ownerDocument.createElement("canvas");
2349
+ target.width = source.width;
2350
+ target.height = source.height;
2351
+ const record = { original: image._originalElement, source, target };
2352
+ this.proxies.set(layerId, record);
2353
+ return record;
2354
+ }
2355
+ drop(layerId) {
2356
+ const record = this.proxies.get(layerId);
2357
+ if (!record) return;
2358
+ const layer = this.layers.get(layerId);
2359
+ if (layer)
2360
+ layer.fabricObject.removeTexture(
2361
+ `${layer.fabricObject.cacheKey}_proxy`
2362
+ );
2363
+ record.source.element.width = 0;
2364
+ record.target.width = 0;
2365
+ this.proxies.delete(layerId);
2366
+ this.lastSpecs.delete(layerId);
2367
+ }
2368
+ };
2369
+
2370
+ // src/layer-effects.ts
2371
+ import { ECHO_MAX_COUNT } from "@overtone-art/canvas-editor-effects";
2372
+
2373
+ // src/shadow.ts
2374
+ import { Shadow } from "fabric";
2375
+
2376
+ // src/utils/color.ts
2377
+ var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
2378
+ function isCssColor(value, allowEmpty = false) {
2379
+ if (typeof value !== "string") return false;
2380
+ const trimmed = value.trim();
2381
+ if (!trimmed) return allowEmpty;
2382
+ return CSS_COLOR.test(trimmed);
2383
+ }
2384
+
2385
+ // src/shadow.ts
2386
+ var DEFAULT_LAYER_SHADOW = {
2387
+ enabled: false,
2388
+ color: "#000000",
2389
+ blur: 12,
2390
+ offsetX: 6,
2391
+ offsetY: 6
2392
+ };
2393
+ function readLayerShadow(object) {
2394
+ const shadow = object.shadow;
2395
+ if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
2396
+ return {
2397
+ enabled: true,
2398
+ color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
2399
+ blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
2400
+ offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
2401
+ offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
2402
+ };
2403
+ }
2404
+ function applyLayerShadow(object, config) {
2405
+ const next = { ...readLayerShadow(object), ...config };
2406
+ if (!next.enabled) {
2407
+ object.set({ shadow: null });
2408
+ return;
2409
+ }
2410
+ const color2 = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
2411
+ object.set({
2412
+ shadow: new Shadow({
2413
+ color: color2,
2414
+ blur: Math.max(0, next.blur),
2415
+ offsetX: next.offsetX,
2416
+ offsetY: next.offsetY
2417
+ })
2418
+ });
2419
+ }
2420
+
2421
+ // src/layer-effects.ts
2422
+ var LAYER_EFFECT_DEFAULTS = {
2423
+ outline: { color: "#000000", width: 4 },
2424
+ hollow: { color: "#000000", width: 4 },
2425
+ drop: { color: "#000000", blur: 0, offsetX: 6, offsetY: 6 },
2426
+ splice: { color: "#ff4d6d", offsetX: 8, offsetY: 8 },
2427
+ echo: { color: "#000000", count: 3, stepX: 6, stepY: 6, fade: 0.5 },
2428
+ background: { color: "#ffe600", padding: 12, radius: 8 }
2429
+ };
2430
+ var num = (p, key, fallback) => {
2431
+ const value = p[key];
2432
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2433
+ };
2434
+ var color = (p, key, fallback) => {
2435
+ const value = p[key];
2436
+ return typeof value === "string" && isCssColor(value) ? value : fallback;
2437
+ };
2438
+ var outlineProps = (p) => ({
2439
+ stroke: color(p, "color", "#000000"),
2440
+ strokeWidth: clamp(num(p, "width", 4), 0, 200),
2441
+ paintFirst: "stroke",
2442
+ strokeUniform: true
2443
+ });
2444
+ var LayerEffectsManager = class {
2445
+ constructor(canvas, layers, history, events) {
2446
+ this.canvas = canvas;
2447
+ this.layers = layers;
2448
+ this.history = history;
2449
+ this.events = events;
2450
+ }
2451
+ canvas;
2452
+ layers;
2453
+ history;
2454
+ events;
2455
+ read(layerId) {
2456
+ return this.layers.get(layerId)?.meta.effect ?? null;
2457
+ }
2458
+ /**
2459
+ * `commit: false` is a slider drag: props are written and `layer:modified`
2460
+ * is emitted so the panels re-read, but nothing reaches history. The default
2461
+ * commits ONE entry, setMeta's own save folded into the same transaction.
2462
+ */
2463
+ apply(layerId, kind, params = {}, commit = true) {
2464
+ const layer = this.layers.get(layerId);
2465
+ if (!layer || layer.type === "group") return;
2466
+ const object = layer.fabricObject;
2467
+ const write = () => {
2468
+ this.clear(object, layer.meta.effect);
2469
+ const next = kind === "none" ? void 0 : {
2470
+ kind,
2471
+ params: this.install(object, kind, { ...LAYER_EFFECT_DEFAULTS[kind], ...params }),
2472
+ version: 1
2473
+ };
2474
+ if (commit) this.layers.setMeta(layerId, { effect: next });
2475
+ else if (next) layer.meta.effect = next;
2476
+ else delete layer.meta.effect;
2477
+ object.dirty = true;
2478
+ object.setCoords();
2479
+ this.canvas.requestRenderAll();
2480
+ this.events.emit("layer:modified", { layerId });
2481
+ };
2482
+ if (!commit) {
2483
+ write();
2484
+ return;
2485
+ }
2486
+ this.history.beginTransaction();
2487
+ try {
2488
+ write();
2489
+ this.history.save();
2490
+ } finally {
2491
+ this.history.endTransaction();
2492
+ }
2493
+ }
2494
+ /** Undo what the previous kind wrote, so kinds never stack. */
2495
+ clear(object, previous) {
2496
+ if (!previous) return;
2497
+ switch (previous.kind) {
2498
+ case "outline":
2499
+ object.set({ strokeWidth: 0, paintFirst: "fill", strokeUniform: false });
2500
+ return;
2501
+ case "hollow": {
2502
+ const fill = previous.params.previousFill;
2503
+ object.set({
2504
+ strokeWidth: 0,
2505
+ paintFirst: "fill",
2506
+ strokeUniform: false,
2507
+ fill: typeof fill === "string" ? fill : "#000000"
2508
+ });
2509
+ return;
2510
+ }
2511
+ case "drop":
2512
+ applyLayerShadow(object, { enabled: false });
2513
+ return;
2514
+ case "splice":
2515
+ case "echo":
2516
+ case "background":
2517
+ object.set({ effect: void 0 });
2518
+ return;
2519
+ case "none":
2520
+ return;
2521
+ }
2522
+ }
2523
+ install(object, kind, p) {
2524
+ switch (kind) {
2525
+ case "outline": {
2526
+ const props = outlineProps(p);
2527
+ object.set(props);
2528
+ return { color: props.stroke, width: props.strokeWidth };
2529
+ }
2530
+ case "hollow": {
2531
+ const previousFill = typeof object.fill === "string" ? object.fill : "#000000";
2532
+ const props = outlineProps(p);
2533
+ object.set({ ...props, fill: "transparent" });
2534
+ return { color: props.stroke, width: props.strokeWidth, previousFill };
2535
+ }
2536
+ case "drop": {
2537
+ const shadow = {
2538
+ color: color(p, "color", "#000000"),
2539
+ blur: clamp(num(p, "blur", 0), 0, 200),
2540
+ offsetX: num(p, "offsetX", 6),
2541
+ offsetY: num(p, "offsetY", 6)
2542
+ };
2543
+ applyLayerShadow(object, { enabled: true, ...shadow });
2544
+ return shadow;
2545
+ }
2546
+ case "splice": {
2547
+ const effect = {
2548
+ kind: "splice",
2549
+ offsetX: num(p, "offsetX", 8),
2550
+ offsetY: num(p, "offsetY", 8),
2551
+ color: color(p, "color", "#ff4d6d"),
2552
+ version: 1
2553
+ };
2554
+ object.set({ effect });
2555
+ return { offsetX: effect.offsetX, offsetY: effect.offsetY, color: effect.color };
2556
+ }
2557
+ case "echo": {
2558
+ const effect = {
2559
+ kind: "echo",
2560
+ count: clamp(Math.round(num(p, "count", 3)), 1, ECHO_MAX_COUNT),
2561
+ stepX: num(p, "stepX", 6),
2562
+ stepY: num(p, "stepY", 6),
2563
+ color: color(p, "color", "#000000"),
2564
+ fade: clamp(num(p, "fade", 0.5), 0, 1),
2565
+ version: 1
2566
+ };
2567
+ object.set({ effect });
2568
+ return {
2569
+ count: effect.count,
2570
+ stepX: effect.stepX,
2571
+ stepY: effect.stepY,
2572
+ color: effect.color,
2573
+ fade: effect.fade
2574
+ };
2575
+ }
2576
+ case "background": {
2577
+ const effect = {
2578
+ kind: "background",
2579
+ padding: clamp(num(p, "padding", 12), 0, 400),
2580
+ radius: clamp(num(p, "radius", 8), 0, 400),
2581
+ color: color(p, "color", "#ffe600"),
2582
+ version: 1
2583
+ };
2584
+ object.set({ effect });
2585
+ return { padding: effect.padding, radius: effect.radius, color: effect.color };
2586
+ }
2587
+ }
2588
+ }
2589
+ };
2590
+
1870
2591
  // src/text-curve.ts
1871
2592
  import { Path } from "fabric";
1872
2593
 
@@ -1877,9 +2598,12 @@ var DEFAULT_TEXT_CURVE = {
1877
2598
  wave: 0,
1878
2599
  waveLength: 4.1,
1879
2600
  offset: 0,
1880
- centerOffset: 0
2601
+ centerOffset: 0,
2602
+ angle: 0
1881
2603
  };
1882
2604
  var MIN_ARC = 0.5;
2605
+ var MIN_ANGLE = 0.5;
2606
+ var MAX_ANGLE = 80;
1883
2607
  var FULL_CIRCLE_ARC = 99.5;
1884
2608
  var MIN_SWEEP = 0.12;
1885
2609
  var WAVE_AMPLITUDE_EM = 0.9;
@@ -1904,10 +2628,12 @@ function normalizeTextCurve(config) {
1904
2628
  MAX_WAVE_LENGTH
1905
2629
  ),
1906
2630
  offset: clamp(config.offset ?? 0, -100, 100),
1907
- centerOffset: clamp(config.centerOffset ?? 0, -100, 100)
2631
+ centerOffset: clamp(config.centerOffset ?? 0, -100, 100),
2632
+ angle: clamp(config.angle ?? 0, -MAX_ANGLE, MAX_ANGLE)
1908
2633
  };
1909
2634
  }
1910
2635
  function bendsBaseline(config) {
2636
+ if (config.shape === "angle") return Math.abs(config.angle) >= MIN_ANGLE;
1911
2637
  return config.shape === "arc" ? Math.abs(config.arc) >= MIN_ARC : config.wave > 0;
1912
2638
  }
1913
2639
  function arcGeometry(width, config) {
@@ -1968,7 +2694,18 @@ function waveLinePath(width, fontSize, config, dy) {
1968
2694
  }
1969
2695
  return { data: commands.join(" "), length };
1970
2696
  }
1971
- function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight = 0) {
2697
+ function angleLinePath(width, config, dy) {
2698
+ const radians = config.angle * Math.PI / 180;
2699
+ const half = width / 2;
2700
+ const dx = half * Math.cos(radians);
2701
+ const rise = half * Math.sin(radians);
2702
+ const format = ([x, y]) => `${round(x)} ${round(y)}`;
2703
+ return {
2704
+ data: `M ${format([half - dx, dy + rise])} L ${format([half + dx, dy - rise])}`,
2705
+ length: width
2706
+ };
2707
+ }
2708
+ function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight = 0) {
1972
2709
  const curve = normalizeTextCurve(config);
1973
2710
  if (!bendsBaseline(curve)) return null;
1974
2711
  const lines = Math.max(1, Math.floor(lineCount));
@@ -1979,6 +2716,12 @@ function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight
1979
2716
  }
1980
2717
  return paths;
1981
2718
  }
2719
+ if (curve.shape === "angle") {
2720
+ for (let index = 0; index < lines; index++) {
2721
+ paths.push(angleLinePath(width, curve, (index - (lines - 1) / 2) * lineHeight));
2722
+ }
2723
+ return paths;
2724
+ }
1982
2725
  const geometry = arcGeometry(width, curve);
1983
2726
  const rotation = curve.offset / 100 * geometry.sweep;
1984
2727
  for (let index = 0; index < lines; index++) {
@@ -2559,54 +3302,6 @@ var MaskPresetManager = class {
2559
3302
  }
2560
3303
  };
2561
3304
 
2562
- // src/shadow.ts
2563
- import { Shadow } from "fabric";
2564
-
2565
- // src/utils/color.ts
2566
- var CSS_COLOR = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\([\d.%+\-\s,/]+\)|[a-z]+)$/i;
2567
- function isCssColor(value, allowEmpty = false) {
2568
- if (typeof value !== "string") return false;
2569
- const trimmed = value.trim();
2570
- if (!trimmed) return allowEmpty;
2571
- return CSS_COLOR.test(trimmed);
2572
- }
2573
-
2574
- // src/shadow.ts
2575
- var DEFAULT_LAYER_SHADOW = {
2576
- enabled: false,
2577
- color: "#000000",
2578
- blur: 12,
2579
- offsetX: 6,
2580
- offsetY: 6
2581
- };
2582
- function readLayerShadow(object) {
2583
- const shadow = object.shadow;
2584
- if (!shadow || typeof shadow === "string") return { ...DEFAULT_LAYER_SHADOW };
2585
- return {
2586
- enabled: true,
2587
- color: typeof shadow.color === "string" ? shadow.color : DEFAULT_LAYER_SHADOW.color,
2588
- blur: shadow.blur ?? DEFAULT_LAYER_SHADOW.blur,
2589
- offsetX: shadow.offsetX ?? DEFAULT_LAYER_SHADOW.offsetX,
2590
- offsetY: shadow.offsetY ?? DEFAULT_LAYER_SHADOW.offsetY
2591
- };
2592
- }
2593
- function applyLayerShadow(object, config) {
2594
- const next = { ...readLayerShadow(object), ...config };
2595
- if (!next.enabled) {
2596
- object.set({ shadow: null });
2597
- return;
2598
- }
2599
- const color = isCssColor(next.color) ? next.color : DEFAULT_LAYER_SHADOW.color;
2600
- object.set({
2601
- shadow: new Shadow({
2602
- color,
2603
- blur: Math.max(0, next.blur),
2604
- offsetX: next.offsetX,
2605
- offsetY: next.offsetY
2606
- })
2607
- });
2608
- }
2609
-
2610
3305
  // src/selection-style.ts
2611
3306
  var DEFAULT_SELECTION_STYLE = {
2612
3307
  borderColor: "#c9a96e",
@@ -2712,7 +3407,7 @@ var UnitConverter = class {
2712
3407
  };
2713
3408
 
2714
3409
  // src/serialization.ts
2715
- import { util as util2 } from "fabric";
3410
+ import { util as util5 } from "fabric";
2716
3411
  var VERSION = "2.0.0";
2717
3412
  function serializeEditor(editor) {
2718
3413
  return {
@@ -2748,7 +3443,7 @@ async function deserializeEditor(editor, state) {
2748
3443
  }
2749
3444
  const staged = await Promise.all(
2750
3445
  state.layers.map(async (serialized) => {
2751
- const fabricObject = (await util2.enlivenObjects([serialized.fabricObject]))[0];
3446
+ const fabricObject = (await util5.enlivenObjects([serialized.fabricObject]))[0];
2752
3447
  if (!fabricObject) {
2753
3448
  const source = serialized.fabricObject.src;
2754
3449
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -2759,7 +3454,7 @@ async function deserializeEditor(editor, state) {
2759
3454
  return { serialized, fabricObject };
2760
3455
  })
2761
3456
  );
2762
- const stagedBackground = state.backgroundImage ? (await util2.enlivenObjects([state.backgroundImage]))[0] : null;
3457
+ const stagedBackground = state.backgroundImage ? (await util5.enlivenObjects([state.backgroundImage]))[0] : null;
2763
3458
  if (state.backgroundImage && !stagedBackground) {
2764
3459
  const source = state.backgroundImage.src;
2765
3460
  if (typeof source === "string" && source.startsWith("blob:")) {
@@ -3350,8 +4045,8 @@ var MaskController = class {
3350
4045
  point.y,
3351
4046
  radius
3352
4047
  );
3353
- const color = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
3354
- gradient.addColorStop(0, color);
4048
+ const color2 = brush.mode === "subtract" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
4049
+ gradient.addColorStop(0, color2);
3355
4050
  gradient.addColorStop(1, "rgba(255,255,255,0)");
3356
4051
  context.fillStyle = gradient;
3357
4052
  context.beginPath();
@@ -3387,6 +4082,123 @@ import { Group as Group6 } from "fabric";
3387
4082
 
3388
4083
  // src/masks/compose.ts
3389
4084
  import { Group as Group2, Rect as Rect2 } from "fabric";
4085
+
4086
+ // src/gradient.ts
4087
+ import { Color, Gradient } from "fabric";
4088
+ var DEFAULT_GRADIENT_CONFIG = {
4089
+ kind: "linear",
4090
+ angle: 90,
4091
+ center: { x: 0.5, y: 0.5 },
4092
+ radius: 0.5,
4093
+ stops: [
4094
+ { color: "#ffffff", opacity: 1, position: 0 },
4095
+ { color: "#000000", opacity: 1, position: 1 }
4096
+ ]
4097
+ };
4098
+ var MIN_RADIUS = 1e-3;
4099
+ var MAX_GRADIENT_STOPS = 256;
4100
+ var FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
4101
+ function linearCoords(angle, box) {
4102
+ const radians = angle * Math.PI / 180;
4103
+ const sin = Math.sin(radians);
4104
+ const cos = Math.cos(radians);
4105
+ const vx = box.width * sin;
4106
+ const vy = -box.height * cos;
4107
+ const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
4108
+ const scale = length / (vx * vx + vy * vy || 1);
4109
+ return {
4110
+ x1: 0.5 - scale * vx / 2,
4111
+ y1: 0.5 - scale * vy / 2,
4112
+ x2: 0.5 + scale * vx / 2,
4113
+ y2: 0.5 + scale * vy / 2
4114
+ };
4115
+ }
4116
+ function angleFromCoords(coords, box) {
4117
+ const dx = (coords.x2 - coords.x1) / (box.width || 1);
4118
+ const dy = (coords.y2 - coords.y1) / (box.height || 1);
4119
+ const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
4120
+ return (degrees + 360) % 360;
4121
+ }
4122
+ function stopColor(stop) {
4123
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4124
+ const color2 = new Color(source);
4125
+ const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
4126
+ color2.setAlpha(clamp(opacity, 0, 1));
4127
+ return color2.toRgba();
4128
+ }
4129
+ function toFabricGradient(config, box) {
4130
+ const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
4131
+ const position = Number.isFinite(stop.position) ? stop.position : 0;
4132
+ return { offset: clamp(position, 0, 1), color: stopColor(stop) };
4133
+ });
4134
+ if (config.kind === "radial") {
4135
+ const radius = Math.max(MIN_RADIUS, config.radius);
4136
+ return new Gradient({
4137
+ type: "radial",
4138
+ gradientUnits: "percentage",
4139
+ coords: {
4140
+ x1: config.center.x,
4141
+ y1: config.center.y,
4142
+ r1: 0,
4143
+ x2: config.center.x,
4144
+ y2: config.center.y,
4145
+ r2: radius
4146
+ },
4147
+ colorStops
4148
+ });
4149
+ }
4150
+ return new Gradient({
4151
+ type: "linear",
4152
+ gradientUnits: "percentage",
4153
+ coords: linearCoords(config.angle, box),
4154
+ colorStops
4155
+ });
4156
+ }
4157
+ function toFabricMaskGradient(config, box) {
4158
+ const gradient = toFabricGradient(config, box);
4159
+ gradient.colorStops = gradient.colorStops.map((stop) => {
4160
+ const color2 = new Color(stop.color);
4161
+ const [red, green, blue] = color2.getSource();
4162
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
4163
+ color2.setSource([255, 255, 255, clamp(luminance * color2.getAlpha(), 0, 1)]);
4164
+ return { offset: stop.offset, color: color2.toRgba() };
4165
+ });
4166
+ return gradient;
4167
+ }
4168
+ function readGradientConfig(object) {
4169
+ const fill = object.fill;
4170
+ if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
4171
+ const stops = fill.colorStops.map((stop) => {
4172
+ const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4173
+ const color2 = new Color(source);
4174
+ const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
4175
+ return {
4176
+ color: `#${color2.toHex().toLowerCase()}`,
4177
+ opacity: color2.getAlpha(),
4178
+ position: clamp(offset, 0, 1)
4179
+ };
4180
+ });
4181
+ if (fill.type === "radial") {
4182
+ const { x1, y1, r2 } = fill.coords;
4183
+ return {
4184
+ kind: "radial",
4185
+ angle: DEFAULT_GRADIENT_CONFIG.angle,
4186
+ center: { x: x1, y: y1 },
4187
+ radius: r2,
4188
+ stops
4189
+ };
4190
+ }
4191
+ const box = { width: object.width || 1, height: object.height || 1 };
4192
+ return {
4193
+ kind: "linear",
4194
+ angle: angleFromCoords(fill.coords, box),
4195
+ center: { ...DEFAULT_GRADIENT_CONFIG.center },
4196
+ radius: DEFAULT_GRADIENT_CONFIG.radius,
4197
+ stops
4198
+ };
4199
+ }
4200
+
4201
+ // src/masks/compose.ts
3390
4202
  var MODE_OPERATION = {
3391
4203
  add: "source-over",
3392
4204
  subtract: "destination-out",
@@ -3416,6 +4228,14 @@ function composeMaskGroup(children, entries, options) {
3416
4228
  neutralize(child);
3417
4229
  return;
3418
4230
  }
4231
+ if (entry.gradient) {
4232
+ child.set({
4233
+ fill: toFabricMaskGradient(entry.gradient, {
4234
+ width: Math.max(1, child.width ?? 1),
4235
+ height: Math.max(1, child.height ?? 1)
4236
+ })
4237
+ });
4238
+ }
3419
4239
  child.set({
3420
4240
  opacity: entry.opacity,
3421
4241
  globalCompositeOperation: MODE_OPERATION[entry.mode] ?? "source-over"
@@ -3437,11 +4257,105 @@ function needsAbsoluteSpace(entries) {
3437
4257
  }
3438
4258
 
3439
4259
  // src/masks/edit.ts
3440
- import { util as util3 } from "fabric";
4260
+ import { util as util6 } from "fabric";
4261
+
4262
+ // src/masks/edit-viewport.ts
4263
+ var MIN_PADDING = 48;
4264
+ var MAX_PADDING = 512;
4265
+ var PADDING_FRACTION = 0.35;
4266
+ var OUTSIDE_SHADE = "rgba(15, 15, 15, 0.55)";
4267
+ var MaskEditViewport = class {
4268
+ constructor(canvas) {
4269
+ this.canvas = canvas;
4270
+ }
4271
+ canvas;
4272
+ state = null;
4273
+ open(handle) {
4274
+ if (this.state) return;
4275
+ const width = this.canvas.getWidth();
4276
+ const height = this.canvas.getHeight();
4277
+ const base = Math.min(
4278
+ MAX_PADDING,
4279
+ Math.max(MIN_PADDING, Math.max(width, height) * PADDING_FRACTION)
4280
+ );
4281
+ handle.setCoords();
4282
+ const bounds = handle.getBoundingRect();
4283
+ const left = Math.min(MAX_PADDING, Math.max(base, -bounds.left + MIN_PADDING));
4284
+ const top = Math.min(MAX_PADDING, Math.max(base, -bounds.top + MIN_PADDING));
4285
+ const right = Math.min(
4286
+ MAX_PADDING,
4287
+ Math.max(base, bounds.left + bounds.width - width + MIN_PADDING)
4288
+ );
4289
+ const bottom = Math.min(
4290
+ MAX_PADDING,
4291
+ Math.max(base, bounds.top + bounds.height - height + MIN_PADDING)
4292
+ );
4293
+ const viewport = [...this.canvas.viewportTransform];
4294
+ const wrapper = this.canvas.wrapperEl;
4295
+ this.state = {
4296
+ width,
4297
+ height,
4298
+ left,
4299
+ top,
4300
+ viewport,
4301
+ wrapperCss: wrapper.style.cssText
4302
+ };
4303
+ this.canvas.setDimensions({ width: width + left + right, height: height + top + bottom });
4304
+ const paddedViewport = [...viewport];
4305
+ paddedViewport[4] += left;
4306
+ paddedViewport[5] += top;
4307
+ this.canvas.setViewportTransform(paddedViewport);
4308
+ wrapper.style.position = "absolute";
4309
+ wrapper.style.left = `${-left}px`;
4310
+ wrapper.style.top = `${-top}px`;
4311
+ wrapper.style.zIndex = "1";
4312
+ this.canvas.on("after:render", this.onAfterRender);
4313
+ this.canvas.calcOffset();
4314
+ this.canvas.requestRenderAll();
4315
+ }
4316
+ close() {
4317
+ const state = this.state;
4318
+ if (!state) return;
4319
+ this.canvas.off("after:render", this.onAfterRender);
4320
+ this.canvas.setViewportTransform(state.viewport);
4321
+ this.canvas.setDimensions({ width: state.width, height: state.height });
4322
+ this.canvas.wrapperEl.style.cssText = state.wrapperCss;
4323
+ this.canvas.calcOffset();
4324
+ this.state = null;
4325
+ }
4326
+ /** Save with real document dimensions, then reopen the same editing view. */
4327
+ checkpoint(handle, save) {
4328
+ this.close();
4329
+ try {
4330
+ save();
4331
+ } finally {
4332
+ this.open(handle);
4333
+ }
4334
+ }
4335
+ onAfterRender = ({ ctx }) => {
4336
+ const state = this.state;
4337
+ if (!state || ctx !== this.canvas.getContext()) return;
4338
+ const fullWidth = this.canvas.getWidth();
4339
+ const fullHeight = this.canvas.getHeight();
4340
+ const right = state.left + state.width;
4341
+ const bottom = state.top + state.height;
4342
+ ctx.save();
4343
+ ctx.fillStyle = OUTSIDE_SHADE;
4344
+ ctx.fillRect(0, 0, fullWidth, state.top);
4345
+ ctx.fillRect(0, bottom, fullWidth, fullHeight - bottom);
4346
+ ctx.fillRect(0, state.top, state.left, state.height);
4347
+ ctx.fillRect(right, state.top, fullWidth - right, state.height);
4348
+ ctx.restore();
4349
+ this.canvas.drawControls(ctx);
4350
+ };
4351
+ };
4352
+
4353
+ // src/masks/edit.ts
3441
4354
  var MaskEditController = class {
3442
4355
  constructor(canvas, onCommit) {
3443
4356
  this.canvas = canvas;
3444
4357
  this.onCommit = onCommit;
4358
+ this.viewport = new MaskEditViewport(canvas);
3445
4359
  }
3446
4360
  canvas;
3447
4361
  onCommit;
@@ -3449,6 +4363,7 @@ var MaskEditController = class {
3449
4363
  editing = null;
3450
4364
  child = null;
3451
4365
  group = null;
4366
+ viewport;
3452
4367
  active() {
3453
4368
  return this.editing;
3454
4369
  }
@@ -3479,6 +4394,7 @@ var MaskEditController = class {
3479
4394
  this.group = group;
3480
4395
  this.canvas.add(handle);
3481
4396
  this.canvas.setActiveObject(handle);
4397
+ this.viewport.open(handle);
3482
4398
  this.canvas.on("object:moving", this.onTransform);
3483
4399
  this.canvas.on("object:scaling", this.onTransform);
3484
4400
  this.canvas.on("object:rotating", this.onTransform);
@@ -3493,6 +4409,7 @@ var MaskEditController = class {
3493
4409
  this.canvas.off("object:scaling", this.onTransform);
3494
4410
  this.canvas.off("object:rotating", this.onTransform);
3495
4411
  this.canvas.off("object:modified", this.onModified);
4412
+ this.viewport.close();
3496
4413
  if (this.canvas.getActiveObject() === this.handle) this.canvas.discardActiveObject();
3497
4414
  this.canvas.remove(this.handle);
3498
4415
  this.handle = null;
@@ -3511,15 +4428,15 @@ var MaskEditController = class {
3511
4428
  onModified = (event) => {
3512
4429
  if (!this.handle || event.target !== this.handle) return;
3513
4430
  this.write();
3514
- this.onCommit();
4431
+ this.viewport.checkpoint(this.handle, this.onCommit);
3515
4432
  };
3516
4433
  /** Handle transform (canvas space) → clip child transform (group-relative). */
3517
4434
  write() {
3518
4435
  if (!this.handle || !this.child || !this.group) return;
3519
4436
  applyMatrix(
3520
4437
  this.child,
3521
- util3.multiplyTransformMatrices(
3522
- util3.invertTransform(matrixOf(this.group)),
4438
+ util6.multiplyTransformMatrices(
4439
+ util6.invertTransform(matrixOf(this.group)),
3523
4440
  matrixOf(this.handle)
3524
4441
  )
3525
4442
  );
@@ -3530,7 +4447,7 @@ var MaskEditController = class {
3530
4447
  };
3531
4448
 
3532
4449
  // src/masks/store.ts
3533
- import { Group as Group5 } from "fabric";
4450
+ import { Gradient as Gradient2, Group as Group5 } from "fabric";
3534
4451
 
3535
4452
  // src/masks/host.ts
3536
4453
  import { Rect as Rect3 } from "fabric";
@@ -3642,6 +4559,29 @@ var MaskStackStore = class {
3642
4559
  get(target, maskId) {
3643
4560
  return this.list(target).find((entry) => entry.id === maskId);
3644
4561
  }
4562
+ /** Repaint a gradient mask without replacing or re-fitting its geometry. */
4563
+ setGradient(target, maskId, gradient, options = {}) {
4564
+ const host = this.host(target);
4565
+ if (!host) return false;
4566
+ const entries = [...this.list(target)];
4567
+ const index = entries.findIndex((entry) => entry.id === maskId);
4568
+ if (index === -1) return false;
4569
+ const sources = this.unwrap(target, host);
4570
+ const source = sources[index];
4571
+ if (!source) return false;
4572
+ source.set({
4573
+ fill: toFabricGradient(gradient, {
4574
+ width: Math.max(1, source.width ?? 1),
4575
+ height: Math.max(1, source.height ?? 1)
4576
+ })
4577
+ });
4578
+ entries[index] = {
4579
+ ...entries[index],
4580
+ ...options.meta ? { meta: options.meta } : {}
4581
+ };
4582
+ this.commit(target, host, entries, sources, options.save ?? true);
4583
+ return true;
4584
+ }
3645
4585
  /** Every target that currently carries at least one mask. */
3646
4586
  targets() {
3647
4587
  return this.layers.getAll().filter((layer) => (layer.meta.maskStack ?? []).length > 0).map((layer) => layer.meta.canvasMask ? CANVAS_MASK_TARGET : layer.id);
@@ -3705,11 +4645,11 @@ var MaskStackStore = class {
3705
4645
  if (entries.length === 0 || !(clip instanceof Group5)) {
3706
4646
  const source = asObject(clip);
3707
4647
  if (meta?.overflow === "hidden") source.clipPath = void 0;
3708
- return [source];
4648
+ return restoreGradientPaint(entries, [source]);
3709
4649
  }
3710
4650
  const children = unwrapGroup(clip);
3711
4651
  const extra = children.length - entries.length;
3712
- return extra > 0 ? children.slice(extra) : children;
4652
+ return restoreGradientPaint(entries, extra > 0 ? children.slice(extra) : children);
3713
4653
  }
3714
4654
  /**
3715
4655
  * Entries for a stack, adopting a pre-stack clip as the first one. Without
@@ -3742,7 +4682,8 @@ var MaskStackStore = class {
3742
4682
  if (!layer) return;
3743
4683
  const absolute = forceAbsolute || target === CANVAS_MASK_TARGET || needsAbsoluteSpace(entries);
3744
4684
  convertSpace(host, sources, absolute);
3745
- const next = withRelativeTransforms(entries, sources, host, absolute);
4685
+ const painted = captureGradientPaint(entries, sources);
4686
+ const next = withRelativeTransforms(painted, sources, host, absolute);
3746
4687
  installClip(host, next, sources, this.hostBox(target, absolute), absolute);
3747
4688
  if (next.length === 0) {
3748
4689
  delete layer.meta.maskStack;
@@ -3756,12 +4697,41 @@ var MaskStackStore = class {
3756
4697
  if (save) this.history.save();
3757
4698
  }
3758
4699
  };
4700
+ function restoreGradientPaint(entries, sources) {
4701
+ entries.forEach((entry, index) => {
4702
+ const source = sources[index];
4703
+ if (!source || !entry.gradient) return;
4704
+ const box = {
4705
+ width: Math.max(1, source.width ?? 1),
4706
+ height: Math.max(1, source.height ?? 1)
4707
+ };
4708
+ const fill = source.fill;
4709
+ const rendered = toFabricMaskGradient(entry.gradient, box);
4710
+ if (fill instanceof Gradient2 && JSON.stringify(fill.toObject()) !== JSON.stringify(rendered.toObject())) {
4711
+ return;
4712
+ }
4713
+ source.set({
4714
+ fill: toFabricGradient(entry.gradient, box)
4715
+ });
4716
+ });
4717
+ return sources;
4718
+ }
4719
+ function captureGradientPaint(entries, sources) {
4720
+ return entries.map((entry, index) => {
4721
+ const gradient = sources[index] ? readGradientConfig(sources[index]) : null;
4722
+ if (gradient) return { ...entry, gradient };
4723
+ if (!entry.gradient) return entry;
4724
+ const next = { ...entry };
4725
+ delete next.gradient;
4726
+ return next;
4727
+ });
4728
+ }
3759
4729
 
3760
4730
  // src/masks/manager.ts
3761
4731
  var LayerMaskManager = class extends MaskStackStore {
3762
4732
  // Committing mid-drag would recompose the group the handle writes into and
3763
4733
  // leave it pointing at a discarded object; the geometry is settled on endEdit.
3764
- edits = new MaskEditController(this.canvas, () => this.history.save());
4734
+ edits = new MaskEditController(this.canvas, () => this.history.saveImmediate());
3765
4735
  onLayersChanged = () => this.pin();
3766
4736
  // Selecting a layer means the user has moved on from the mask they had open;
3767
4737
  // leaving both selected would show mask controls for an unrelated layer.
@@ -4148,110 +5118,6 @@ function layerTypeOf(object) {
4148
5118
  return "shape";
4149
5119
  }
4150
5120
 
4151
- // src/gradient.ts
4152
- import { Color, Gradient } from "fabric";
4153
- var DEFAULT_GRADIENT_CONFIG = {
4154
- kind: "linear",
4155
- angle: 90,
4156
- center: { x: 0.5, y: 0.5 },
4157
- radius: 0.5,
4158
- stops: [
4159
- { color: "#ffffff", opacity: 1, position: 0 },
4160
- { color: "#000000", opacity: 1, position: 1 }
4161
- ]
4162
- };
4163
- var MIN_RADIUS = 1e-3;
4164
- var MAX_GRADIENT_STOPS = 256;
4165
- var FALLBACK_STOP_COLOR = DEFAULT_GRADIENT_CONFIG.stops[0].color;
4166
- function linearCoords(angle, box) {
4167
- const radians = angle * Math.PI / 180;
4168
- const sin = Math.sin(radians);
4169
- const cos = Math.cos(radians);
4170
- const vx = box.width * sin;
4171
- const vy = -box.height * cos;
4172
- const length = Math.abs(box.width * sin) + Math.abs(box.height * cos);
4173
- const scale = length / (vx * vx + vy * vy || 1);
4174
- return {
4175
- x1: 0.5 - scale * vx / 2,
4176
- y1: 0.5 - scale * vy / 2,
4177
- x2: 0.5 + scale * vx / 2,
4178
- y2: 0.5 + scale * vy / 2
4179
- };
4180
- }
4181
- function angleFromCoords(coords, box) {
4182
- const dx = (coords.x2 - coords.x1) / (box.width || 1);
4183
- const dy = (coords.y2 - coords.y1) / (box.height || 1);
4184
- const degrees = Math.atan2(dx, -dy) * 180 / Math.PI;
4185
- return (degrees + 360) % 360;
4186
- }
4187
- function stopColor(stop) {
4188
- const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4189
- const color = new Color(source);
4190
- const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
4191
- color.setAlpha(clamp(opacity, 0, 1));
4192
- return color.toRgba();
4193
- }
4194
- function toFabricGradient(config, box) {
4195
- const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
4196
- const position = Number.isFinite(stop.position) ? stop.position : 0;
4197
- return { offset: clamp(position, 0, 1), color: stopColor(stop) };
4198
- });
4199
- if (config.kind === "radial") {
4200
- const radius = Math.max(MIN_RADIUS, config.radius);
4201
- return new Gradient({
4202
- type: "radial",
4203
- gradientUnits: "percentage",
4204
- coords: {
4205
- x1: config.center.x,
4206
- y1: config.center.y,
4207
- r1: 0,
4208
- x2: config.center.x,
4209
- y2: config.center.y,
4210
- r2: radius
4211
- },
4212
- colorStops
4213
- });
4214
- }
4215
- return new Gradient({
4216
- type: "linear",
4217
- gradientUnits: "percentage",
4218
- coords: linearCoords(config.angle, box),
4219
- colorStops
4220
- });
4221
- }
4222
- function readGradientConfig(object) {
4223
- const fill = object.fill;
4224
- if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
4225
- const stops = fill.colorStops.map((stop) => {
4226
- const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
4227
- const color = new Color(source);
4228
- const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
4229
- return {
4230
- color: `#${color.toHex().toLowerCase()}`,
4231
- opacity: color.getAlpha(),
4232
- position: clamp(offset, 0, 1)
4233
- };
4234
- });
4235
- if (fill.type === "radial") {
4236
- const { x1, y1, r2 } = fill.coords;
4237
- return {
4238
- kind: "radial",
4239
- angle: DEFAULT_GRADIENT_CONFIG.angle,
4240
- center: { x: x1, y: y1 },
4241
- radius: r2,
4242
- stops
4243
- };
4244
- }
4245
- const box = { width: object.width || 1, height: object.height || 1 };
4246
- return {
4247
- kind: "linear",
4248
- angle: angleFromCoords(fill.coords, box),
4249
- center: { ...DEFAULT_GRADIENT_CONFIG.center },
4250
- radius: DEFAULT_GRADIENT_CONFIG.radius,
4251
- stops
4252
- };
4253
- }
4254
-
4255
5121
  // src/editor.ts
4256
5122
  var MIN_ZOOM = 0.01;
4257
5123
  var MAX_ZOOM = 8;
@@ -4267,11 +5133,17 @@ var CanvasEditor = class {
4267
5133
  snapping;
4268
5134
  crop;
4269
5135
  patterns;
5136
+ /** Parametric shape layers: `meta.shape` and in-place regeneration. */
5137
+ shapes;
4270
5138
  curves;
4271
5139
  wrap;
4272
5140
  maskPresets;
5141
+ /** Owner of every image layer's filter array (D5). */
5142
+ imageEffects;
4273
5143
  /** Stacked boolean masks, per layer and for the design as a whole. */
4274
5144
  layerMasks;
5145
+ /** One effect per text or shape layer (D6). */
5146
+ effects;
4275
5147
  fonts;
4276
5148
  licensing;
4277
5149
  pages;
@@ -4281,6 +5153,8 @@ var CanvasEditor = class {
4281
5153
  zoomLevel = 1;
4282
5154
  selectionStyle = { ...DEFAULT_SELECTION_STYLE };
4283
5155
  mockup = null;
5156
+ /** Alt was held when a drag began on this layer; the drop leaves a copy behind. */
5157
+ altDrag = null;
4284
5158
  // The design's configured background. The live canvas background is forced
4285
5159
  // transparent while a mockup preview is shown, so this is the source of truth
4286
5160
  // for serialization and export — not the (possibly transient) canvas value.
@@ -4288,6 +5162,7 @@ var CanvasEditor = class {
4288
5162
  designBackgroundImage = null;
4289
5163
  backgroundImageOptions = null;
4290
5164
  constructor(canvasElement, config) {
5165
+ registerEffects(fabricModule);
4291
5166
  this.events = new EventEmitter();
4292
5167
  this.fonts = new FontRegistry();
4293
5168
  config.fonts?.forEach((font) => this.fonts.register(font));
@@ -4322,6 +5197,15 @@ var CanvasEditor = class {
4322
5197
  this.wrap = new TextWrapManager(this.canvas, this.layers, this.history, this.events);
4323
5198
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
4324
5199
  this.layerMasks = new LayerMaskManager(this.canvas, this.layers, this.history, this.events);
5200
+ this.effects = new LayerEffectsManager(this.canvas, this.layers, this.history, this.events);
5201
+ this.imageEffects = new ImageEffectsManager(
5202
+ this.canvas,
5203
+ this.layers,
5204
+ this.history,
5205
+ this.events
5206
+ );
5207
+ this.shapes = new ShapeManager(this.canvas, this.layers, this.history, this.events);
5208
+ config.shapes?.forEach((plugin) => this.shapes.register(plugin));
4325
5209
  this.setupCanvasEvents();
4326
5210
  this.refreshSelectionStyle();
4327
5211
  this.history.saveImmediate();
@@ -4346,7 +5230,7 @@ var CanvasEditor = class {
4346
5230
  }
4347
5231
  }
4348
5232
  /** Replace an image source without changing its layer identity or visual transform. */
4349
- async replaceImageSource(layerId, url) {
5233
+ async replaceImageSource(layerId, url, options = {}) {
4350
5234
  const layer = this.layers.get(layerId);
4351
5235
  if (!layer || layer.type !== "image" && layer.type !== "mask") {
4352
5236
  throw new Error(`Image or mask layer not found: ${layerId}`);
@@ -4357,7 +5241,11 @@ var CanvasEditor = class {
4357
5241
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
4358
5242
  const previous = layer.fabricObject;
4359
5243
  try {
4360
- const replacement = await FabricImage3.fromURL(url, {}, { originX: "left", originY: "top" });
5244
+ const replacement = await FabricImage3.fromURL(
5245
+ url,
5246
+ { crossOrigin: options.crossOrigin },
5247
+ { originX: "left", originY: "top" }
5248
+ );
4361
5249
  replacement.set({
4362
5250
  left: previous.left,
4363
5251
  top: previous.top,
@@ -4379,14 +5267,28 @@ var CanvasEditor = class {
4379
5267
  replacement.applyFilters();
4380
5268
  replacement.setCoords();
4381
5269
  this.layers.replaceObject(layerId, replacement);
5270
+ this.imageEffects.refresh(layerId);
4382
5271
  return layer;
4383
5272
  } catch (error) {
4384
5273
  this.events.emit("error", { message: "Failed to replace image source", error });
4385
5274
  throw error;
4386
5275
  }
4387
5276
  }
5277
+ /**
5278
+ * The class the registry holds for a stock name.
5279
+ *
5280
+ * `registerEffects` (run in the constructor) overrides the stock names, but
5281
+ * registration only decides what an ENLIVEN produces — a `new Textbox(...)`
5282
+ * here would still be the stock class, which carries no `effect` in its
5283
+ * `customProperties` and so could never serialize one. Authoring and
5284
+ * restoring have to yield the same class.
5285
+ */
5286
+ registered(name, fallback) {
5287
+ return classRegistry2.getClass(name) ?? fallback;
5288
+ }
4388
5289
  addText(text, options) {
4389
- const textbox = new Textbox2(text, {
5290
+ const TextboxClass = this.registered("Textbox", Textbox2);
5291
+ const textbox = new TextboxClass(text, {
4390
5292
  fontSize: 32,
4391
5293
  fontFamily: "Arial",
4392
5294
  fill: "#000000",
@@ -4404,6 +5306,15 @@ var CanvasEditor = class {
4404
5306
  addShape(plugin, options) {
4405
5307
  const obj = plugin.create(options);
4406
5308
  const layer = this.layers.add("shape", obj, plugin.name);
5309
+ if (plugin.params) {
5310
+ this.shapes.register(plugin);
5311
+ const overrides = options?.params ?? {};
5312
+ layer.meta.shape = {
5313
+ id: plugin.name,
5314
+ params: resolveShapeParams(plugin, overrides),
5315
+ version: 1
5316
+ };
5317
+ }
4407
5318
  this.layers.select(layer.id);
4408
5319
  this.history.save();
4409
5320
  return layer;
@@ -4415,7 +5326,8 @@ var CanvasEditor = class {
4415
5326
  addGradient(config = DEFAULT_GRADIENT_CONFIG, box) {
4416
5327
  const width = box?.width ?? this.canvas.getWidth();
4417
5328
  const height = box?.height ?? this.canvas.getHeight();
4418
- const object = new Rect4({
5329
+ const RectClass = this.registered("Rect", Rect4);
5330
+ const object = new RectClass({
4419
5331
  left: box?.left ?? 0,
4420
5332
  top: box?.top ?? 0,
4421
5333
  originX: "left",
@@ -4544,6 +5456,49 @@ var CanvasEditor = class {
4544
5456
  this.canvas.requestRenderAll();
4545
5457
  this.history.save();
4546
5458
  }
5459
+ // ─── Align ──────────────────────────────────────────
5460
+ /** What the user is acting on: the proxy where a layer has one. */
5461
+ selectedTargets() {
5462
+ return this.canvas.getActiveObjects().filter((object) => this.layers.findByObject(object) !== void 0);
5463
+ }
5464
+ afterMove(objects) {
5465
+ relayoutSelection(this.canvas.getActiveObject());
5466
+ for (const object of objects) {
5467
+ const layer = this.layers.findByObject(object);
5468
+ if (!layer) continue;
5469
+ if (layer.renderProxy === object) this.patterns.commitProxy(layer.id);
5470
+ this.events.emit("layer:modified", { layerId: layer.id });
5471
+ }
5472
+ this.canvas.requestRenderAll();
5473
+ this.history.save();
5474
+ }
5475
+ /** One layer aligns to the canvas; several align to their common box. One history entry. */
5476
+ alignLayers(target) {
5477
+ const objects = this.selectedTargets();
5478
+ if (objects.length === 0) return false;
5479
+ const frame = objects.length === 1 ? canvasBox(this.canvas.getWidth(), this.canvas.getHeight()) : unionBox(objects.map((o) => (o.setCoords(), o.getBoundingRect())));
5480
+ this.history.beginTransaction();
5481
+ try {
5482
+ alignObjects(objects, target, frame);
5483
+ this.afterMove(objects);
5484
+ } finally {
5485
+ this.history.endTransaction();
5486
+ }
5487
+ return true;
5488
+ }
5489
+ /** Equal gaps along an axis. Needs three selected; returns false otherwise. */
5490
+ distributeLayers(axis) {
5491
+ const objects = this.selectedTargets();
5492
+ if (objects.length < 3) return false;
5493
+ this.history.beginTransaction();
5494
+ try {
5495
+ distributeObjects(objects, axis);
5496
+ this.afterMove(objects);
5497
+ } finally {
5498
+ this.history.endTransaction();
5499
+ }
5500
+ return true;
5501
+ }
4547
5502
  /** Clone a layer (offset slightly) and select the copy. */
4548
5503
  async duplicateLayer(id) {
4549
5504
  const layer = this.layers.get(id);
@@ -4568,24 +5523,19 @@ var CanvasEditor = class {
4568
5523
  this.history.save();
4569
5524
  return copy;
4570
5525
  }
4571
- applyImageAdjustments(layerId, adjustments) {
4572
- const layer = this.layers.get(layerId);
4573
- if (!layer || layer.type !== "image") return false;
4574
- const image = layer.fabricObject;
4575
- const previous = layer.meta.imageAdjustments ?? {};
4576
- const next = { ...previous, ...adjustments };
4577
- const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
4578
- image.filters = [
4579
- new filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
4580
- new filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
4581
- new filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
4582
- new filters.Blur({ blur: clampAdjustment(next.blur, 0) })
4583
- ];
4584
- layer.meta.imageAdjustments = next;
4585
- image.applyFilters();
4586
- this.canvas.requestRenderAll();
4587
- this.history.save();
4588
- return true;
5526
+ /** The drag moved the original; a copy takes the start position, just beneath it. */
5527
+ async leaveCopyBehind(layer, transform) {
5528
+ const now = util7.saveObjectTransform(layer.fabricObject);
5529
+ if (now.left === transform.left && now.top === transform.top) return;
5530
+ await this.history.transaction(async () => {
5531
+ const copy = await this.duplicateLayer(layer.id);
5532
+ if (!copy) return;
5533
+ copy.fabricObject.set(transform);
5534
+ copy.fabricObject.setCoords();
5535
+ this.layers.reorder(copy.id, this.layers.getAll().indexOf(layer));
5536
+ this.layers.select(layer.id);
5537
+ this.canvas.requestRenderAll();
5538
+ });
4589
5539
  }
4590
5540
  /**
4591
5541
  * Combine two or more layers into one group layer. Picking a group among them
@@ -4654,6 +5604,7 @@ var CanvasEditor = class {
4654
5604
  await deserializeEditor(this, state);
4655
5605
  await this.patterns.rehydrateAll();
4656
5606
  this.layerMasks.refreshAll();
5607
+ this.imageEffects.refreshAll();
4657
5608
  } catch (error) {
4658
5609
  if (!managedByHistory) {
4659
5610
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -4730,7 +5681,9 @@ var CanvasEditor = class {
4730
5681
  try {
4731
5682
  await this.fonts.ready();
4732
5683
  const blob = await this.withDesignBackground(
4733
- () => exportPNG(this.canvas, { ...options, format })
5684
+ () => this.imageEffects.withSourceResolution(
5685
+ () => exportPNG(this.canvas, { ...options, format })
5686
+ )
4734
5687
  );
4735
5688
  this.events.emit("export:complete", { format });
4736
5689
  this.licensing.track(`export:${format}`);
@@ -4768,7 +5721,9 @@ var CanvasEditor = class {
4768
5721
  }
4769
5722
  }
4770
5723
  toDataURL(format = "png", multiplier = 1) {
4771
- return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
5724
+ return this.withDesignBackground(
5725
+ () => this.imageEffects.withSourceResolution(() => exportDataURL(this.canvas, format, multiplier))
5726
+ );
4772
5727
  }
4773
5728
  /**
4774
5729
  * Export the print file: the design cropped to the mockup's print area, on
@@ -4792,7 +5747,9 @@ var CanvasEditor = class {
4792
5747
  if (!this.mockup) throw new Error("No mockup is configured");
4793
5748
  await this.fonts.ready();
4794
5749
  try {
4795
- const blob = await exportMockup(this.canvas, this.mockup, options);
5750
+ const blob = await this.imageEffects.withSourceResolution(
5751
+ () => exportMockup(this.canvas, this.mockup, options)
5752
+ );
4796
5753
  this.licensing.track(`export:mockup:${options.format ?? "png"}`);
4797
5754
  return blob;
4798
5755
  } catch (error) {
@@ -4934,10 +5891,10 @@ var CanvasEditor = class {
4934
5891
  this.imageProvider = provider;
4935
5892
  }
4936
5893
  // ─── Canvas Operations ──────────────────────────────
4937
- setBackground(color) {
4938
- this.designBackground = color;
5894
+ setBackground(color2) {
5895
+ this.designBackground = color2;
4939
5896
  if (!this.mockup) {
4940
- this.canvas.backgroundColor = color;
5897
+ this.canvas.backgroundColor = color2;
4941
5898
  this.canvas.requestRenderAll();
4942
5899
  }
4943
5900
  this.history.save();
@@ -5244,9 +6201,11 @@ var CanvasEditor = class {
5244
6201
  }
5245
6202
  // ─── Cleanup ────────────────────────────────────────
5246
6203
  dispose() {
6204
+ this.imageEffects.dispose();
5247
6205
  this.masks.dispose();
5248
6206
  this.layerMasks.dispose();
5249
6207
  this.snapping.dispose();
6208
+ this.shapes.dispose();
5250
6209
  this.crop.dispose();
5251
6210
  this.history.dispose();
5252
6211
  this.patterns.dispose();
@@ -5280,12 +6239,26 @@ var CanvasEditor = class {
5280
6239
  const active = this.canvas.getActiveObject();
5281
6240
  if (active) applyObjectSelectionStyle(active, this.selectionStyle, this.zoomLevel);
5282
6241
  };
6242
+ this.canvas.on("mouse:down", (e) => {
6243
+ this.altDrag = null;
6244
+ const target = e.target;
6245
+ const pointer = e.e;
6246
+ if (!target || !pointer?.altKey || target instanceof ActiveSelection3) return;
6247
+ const layer = this.layers.findByObject(target);
6248
+ if (!layer || layer.locked || layer.renderProxy) return;
6249
+ this.altDrag = { layerId: layer.id, transform: util7.saveObjectTransform(target) };
6250
+ });
5283
6251
  this.canvas.on("object:modified", (e) => {
5284
6252
  if (!e.target) return;
5285
6253
  const layer = this.layers.findByObject(e.target);
5286
6254
  if (layer) {
5287
6255
  this.events.emit("layer:modified", { layerId: layer.id });
5288
6256
  this.history.save();
6257
+ const pending = this.altDrag;
6258
+ this.altDrag = null;
6259
+ if (pending && pending.layerId === layer.id) {
6260
+ void this.leaveCopyBehind(layer, pending.transform);
6261
+ }
5289
6262
  }
5290
6263
  });
5291
6264
  this.canvas.on("selection:created", (e) => {
@@ -5373,6 +6346,8 @@ var AnnotationOverlay = class {
5373
6346
  }
5374
6347
  };
5375
6348
  export {
6349
+ ADJUST_KEYS,
6350
+ ADJUST_RANGES,
5376
6351
  AnnotationOverlay,
5377
6352
  BLEND_GROUPS,
5378
6353
  BLEND_MODES,
@@ -5388,7 +6363,12 @@ export {
5388
6363
  EventEmitter,
5389
6364
  FontRegistry,
5390
6365
  HistoryManager,
6366
+ IMAGE_FILTER_PRESETS,
6367
+ IMAGE_PROXY_MAX_EDGE,
6368
+ ImageEffectsManager,
6369
+ LAYER_EFFECT_DEFAULTS,
5391
6370
  Layer,
6371
+ LayerEffectsManager,
5392
6372
  LayerManager,
5393
6373
  LayerMaskManager,
5394
6374
  LicenseManager,
@@ -5396,10 +6376,12 @@ export {
5396
6376
  MaskController,
5397
6377
  MaskPresetManager,
5398
6378
  MaskRefinementError,
6379
+ NEUTRAL_ADJUST,
5399
6380
  PatternManager,
5400
6381
  ProjectManager,
5401
6382
  SHAPE_MASK_BOX,
5402
6383
  SHAPE_MASK_IDS,
6384
+ ShapeManager,
5403
6385
  SnapManager,
5404
6386
  TEXTURE_MASK_IDS,
5405
6387
  TEXTURE_MASK_SIZE,
@@ -5407,6 +6389,8 @@ export {
5407
6389
  TextWrapManager,
5408
6390
  TiledPatternObject,
5409
6391
  UnitConverter,
6392
+ alignObjects,
6393
+ alphaClipMatrix,
5410
6394
  angleFromCoords,
5411
6395
  applyAspectLock,
5412
6396
  applyLayerShadow,
@@ -5419,6 +6403,8 @@ export {
5419
6403
  buildCurveLinePaths,
5420
6404
  buildCurvePathData,
5421
6405
  clamp,
6406
+ clampAdjust,
6407
+ clampAlphaClip,
5422
6408
  clearTextureMaskCache,
5423
6409
  composeMaskGroup,
5424
6410
  computeCoverPlacement,
@@ -5426,6 +6412,7 @@ export {
5426
6412
  computeTilePositions,
5427
6413
  deserializeEditor,
5428
6414
  displaceRgba,
6415
+ distributeObjects,
5429
6416
  drawTiles,
5430
6417
  escapeXml,
5431
6418
  explodeTemplateSvg,
@@ -5437,12 +6424,15 @@ export {
5437
6424
  fitTextWidth,
5438
6425
  fitToBox,
5439
6426
  generateId,
6427
+ imageEffectsFilterSpecs,
6428
+ imageFilterPreset,
5440
6429
  isCssColor,
5441
6430
  isMaskPresetId,
5442
6431
  isShapeMaskId,
5443
6432
  isTextureMaskId,
5444
6433
  linearCoords,
5445
6434
  needsAbsoluteSpace,
6435
+ normalizeImageEffects,
5446
6436
  normalizeTextCurve,
5447
6437
  preWrapWordSplit,
5448
6438
  readGradientConfig,
@@ -5450,6 +6440,7 @@ export {
5450
6440
  readTextWrap,
5451
6441
  renderTextureMask,
5452
6442
  resetTransform,
6443
+ resolveShapeParams,
5453
6444
  restoreLocks,
5454
6445
  round2,
5455
6446
  sanitizeSvg,
@@ -5460,6 +6451,7 @@ export {
5460
6451
  toCanvasSpace,
5461
6452
  toFabricGradient,
5462
6453
  toHostSpace,
6454
+ unionBox,
5463
6455
  unwrapGroup
5464
6456
  };
5465
6457
  //# sourceMappingURL=index.mjs.map