@overtone-art/canvas-editor-core 0.8.6 → 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,6 +2694,17 @@ function waveLinePath(width, fontSize, config, dy) {
1968
2694
  }
1969
2695
  return { data: commands.join(" "), length };
1970
2696
  }
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
+ }
1971
2708
  function buildCurveLinePaths(config, width, fontSize, lineCount = 1, lineHeight = 0) {
1972
2709
  const curve = normalizeTextCurve(config);
1973
2710
  if (!bendsBaseline(curve)) return null;
@@ -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();
@@ -3426,10 +4121,10 @@ function angleFromCoords(coords, box) {
3426
4121
  }
3427
4122
  function stopColor(stop) {
3428
4123
  const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
3429
- const color = new Color(source);
4124
+ const color2 = new Color(source);
3430
4125
  const opacity = Number.isFinite(stop.opacity) ? stop.opacity : 1;
3431
- color.setAlpha(clamp(opacity, 0, 1));
3432
- return color.toRgba();
4126
+ color2.setAlpha(clamp(opacity, 0, 1));
4127
+ return color2.toRgba();
3433
4128
  }
3434
4129
  function toFabricGradient(config, box) {
3435
4130
  const colorStops = [...config.stops].sort((a, b) => a.position - b.position).slice(0, MAX_GRADIENT_STOPS).map((stop) => {
@@ -3462,11 +4157,11 @@ function toFabricGradient(config, box) {
3462
4157
  function toFabricMaskGradient(config, box) {
3463
4158
  const gradient = toFabricGradient(config, box);
3464
4159
  gradient.colorStops = gradient.colorStops.map((stop) => {
3465
- const color = new Color(stop.color);
3466
- const [red, green, blue] = color.getSource();
4160
+ const color2 = new Color(stop.color);
4161
+ const [red, green, blue] = color2.getSource();
3467
4162
  const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
3468
- color.setSource([255, 255, 255, clamp(luminance * color.getAlpha(), 0, 1)]);
3469
- return { offset: stop.offset, color: color.toRgba() };
4163
+ color2.setSource([255, 255, 255, clamp(luminance * color2.getAlpha(), 0, 1)]);
4164
+ return { offset: stop.offset, color: color2.toRgba() };
3470
4165
  });
3471
4166
  return gradient;
3472
4167
  }
@@ -3475,11 +4170,11 @@ function readGradientConfig(object) {
3475
4170
  if (!fill || typeof fill === "string" || !(fill instanceof Gradient)) return null;
3476
4171
  const stops = fill.colorStops.map((stop) => {
3477
4172
  const source = isCssColor(stop.color) ? stop.color : FALLBACK_STOP_COLOR;
3478
- const color = new Color(source);
4173
+ const color2 = new Color(source);
3479
4174
  const offset = Number.isFinite(stop.offset) ? stop.offset : 0;
3480
4175
  return {
3481
- color: `#${color.toHex().toLowerCase()}`,
3482
- opacity: color.getAlpha(),
4176
+ color: `#${color2.toHex().toLowerCase()}`,
4177
+ opacity: color2.getAlpha(),
3483
4178
  position: clamp(offset, 0, 1)
3484
4179
  };
3485
4180
  });
@@ -3562,7 +4257,7 @@ function needsAbsoluteSpace(entries) {
3562
4257
  }
3563
4258
 
3564
4259
  // src/masks/edit.ts
3565
- import { util as util3 } from "fabric";
4260
+ import { util as util6 } from "fabric";
3566
4261
 
3567
4262
  // src/masks/edit-viewport.ts
3568
4263
  var MIN_PADDING = 48;
@@ -3740,8 +4435,8 @@ var MaskEditController = class {
3740
4435
  if (!this.handle || !this.child || !this.group) return;
3741
4436
  applyMatrix(
3742
4437
  this.child,
3743
- util3.multiplyTransformMatrices(
3744
- util3.invertTransform(matrixOf(this.group)),
4438
+ util6.multiplyTransformMatrices(
4439
+ util6.invertTransform(matrixOf(this.group)),
3745
4440
  matrixOf(this.handle)
3746
4441
  )
3747
4442
  );
@@ -4438,11 +5133,17 @@ var CanvasEditor = class {
4438
5133
  snapping;
4439
5134
  crop;
4440
5135
  patterns;
5136
+ /** Parametric shape layers: `meta.shape` and in-place regeneration. */
5137
+ shapes;
4441
5138
  curves;
4442
5139
  wrap;
4443
5140
  maskPresets;
5141
+ /** Owner of every image layer's filter array (D5). */
5142
+ imageEffects;
4444
5143
  /** Stacked boolean masks, per layer and for the design as a whole. */
4445
5144
  layerMasks;
5145
+ /** One effect per text or shape layer (D6). */
5146
+ effects;
4446
5147
  fonts;
4447
5148
  licensing;
4448
5149
  pages;
@@ -4452,6 +5153,8 @@ var CanvasEditor = class {
4452
5153
  zoomLevel = 1;
4453
5154
  selectionStyle = { ...DEFAULT_SELECTION_STYLE };
4454
5155
  mockup = null;
5156
+ /** Alt was held when a drag began on this layer; the drop leaves a copy behind. */
5157
+ altDrag = null;
4455
5158
  // The design's configured background. The live canvas background is forced
4456
5159
  // transparent while a mockup preview is shown, so this is the source of truth
4457
5160
  // for serialization and export — not the (possibly transient) canvas value.
@@ -4459,6 +5162,7 @@ var CanvasEditor = class {
4459
5162
  designBackgroundImage = null;
4460
5163
  backgroundImageOptions = null;
4461
5164
  constructor(canvasElement, config) {
5165
+ registerEffects(fabricModule);
4462
5166
  this.events = new EventEmitter();
4463
5167
  this.fonts = new FontRegistry();
4464
5168
  config.fonts?.forEach((font) => this.fonts.register(font));
@@ -4493,6 +5197,15 @@ var CanvasEditor = class {
4493
5197
  this.wrap = new TextWrapManager(this.canvas, this.layers, this.history, this.events);
4494
5198
  this.maskPresets = new MaskPresetManager(this.canvas, this.layers, this.history, this.events);
4495
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));
4496
5209
  this.setupCanvasEvents();
4497
5210
  this.refreshSelectionStyle();
4498
5211
  this.history.saveImmediate();
@@ -4517,7 +5230,7 @@ var CanvasEditor = class {
4517
5230
  }
4518
5231
  }
4519
5232
  /** Replace an image source without changing its layer identity or visual transform. */
4520
- async replaceImageSource(layerId, url) {
5233
+ async replaceImageSource(layerId, url, options = {}) {
4521
5234
  const layer = this.layers.get(layerId);
4522
5235
  if (!layer || layer.type !== "image" && layer.type !== "mask") {
4523
5236
  throw new Error(`Image or mask layer not found: ${layerId}`);
@@ -4528,7 +5241,11 @@ var CanvasEditor = class {
4528
5241
  if (this.crop.activeLayerId() === layerId) this.crop.cancel();
4529
5242
  const previous = layer.fabricObject;
4530
5243
  try {
4531
- 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
+ );
4532
5249
  replacement.set({
4533
5250
  left: previous.left,
4534
5251
  top: previous.top,
@@ -4550,14 +5267,28 @@ var CanvasEditor = class {
4550
5267
  replacement.applyFilters();
4551
5268
  replacement.setCoords();
4552
5269
  this.layers.replaceObject(layerId, replacement);
5270
+ this.imageEffects.refresh(layerId);
4553
5271
  return layer;
4554
5272
  } catch (error) {
4555
5273
  this.events.emit("error", { message: "Failed to replace image source", error });
4556
5274
  throw error;
4557
5275
  }
4558
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
+ }
4559
5289
  addText(text, options) {
4560
- const textbox = new Textbox2(text, {
5290
+ const TextboxClass = this.registered("Textbox", Textbox2);
5291
+ const textbox = new TextboxClass(text, {
4561
5292
  fontSize: 32,
4562
5293
  fontFamily: "Arial",
4563
5294
  fill: "#000000",
@@ -4575,6 +5306,15 @@ var CanvasEditor = class {
4575
5306
  addShape(plugin, options) {
4576
5307
  const obj = plugin.create(options);
4577
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
+ }
4578
5318
  this.layers.select(layer.id);
4579
5319
  this.history.save();
4580
5320
  return layer;
@@ -4586,7 +5326,8 @@ var CanvasEditor = class {
4586
5326
  addGradient(config = DEFAULT_GRADIENT_CONFIG, box) {
4587
5327
  const width = box?.width ?? this.canvas.getWidth();
4588
5328
  const height = box?.height ?? this.canvas.getHeight();
4589
- const object = new Rect4({
5329
+ const RectClass = this.registered("Rect", Rect4);
5330
+ const object = new RectClass({
4590
5331
  left: box?.left ?? 0,
4591
5332
  top: box?.top ?? 0,
4592
5333
  originX: "left",
@@ -4715,6 +5456,49 @@ var CanvasEditor = class {
4715
5456
  this.canvas.requestRenderAll();
4716
5457
  this.history.save();
4717
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
+ }
4718
5502
  /** Clone a layer (offset slightly) and select the copy. */
4719
5503
  async duplicateLayer(id) {
4720
5504
  const layer = this.layers.get(id);
@@ -4739,24 +5523,19 @@ var CanvasEditor = class {
4739
5523
  this.history.save();
4740
5524
  return copy;
4741
5525
  }
4742
- applyImageAdjustments(layerId, adjustments) {
4743
- const layer = this.layers.get(layerId);
4744
- if (!layer || layer.type !== "image") return false;
4745
- const image = layer.fabricObject;
4746
- const previous = layer.meta.imageAdjustments ?? {};
4747
- const next = { ...previous, ...adjustments };
4748
- const clampAdjustment = (value, min = -1) => clamp(value ?? 0, min, 1);
4749
- image.filters = [
4750
- new filters.Brightness({ brightness: clampAdjustment(next.brightness) }),
4751
- new filters.Contrast({ contrast: clampAdjustment(next.contrast) }),
4752
- new filters.Saturation({ saturation: clampAdjustment(next.saturation) }),
4753
- new filters.Blur({ blur: clampAdjustment(next.blur, 0) })
4754
- ];
4755
- layer.meta.imageAdjustments = next;
4756
- image.applyFilters();
4757
- this.canvas.requestRenderAll();
4758
- this.history.save();
4759
- 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
+ });
4760
5539
  }
4761
5540
  /**
4762
5541
  * Combine two or more layers into one group layer. Picking a group among them
@@ -4825,6 +5604,7 @@ var CanvasEditor = class {
4825
5604
  await deserializeEditor(this, state);
4826
5605
  await this.patterns.rehydrateAll();
4827
5606
  this.layerMasks.refreshAll();
5607
+ this.imageEffects.refreshAll();
4828
5608
  } catch (error) {
4829
5609
  if (!managedByHistory) {
4830
5610
  this.events.emit("error", { message: "Failed to load editor state", error });
@@ -4901,7 +5681,9 @@ var CanvasEditor = class {
4901
5681
  try {
4902
5682
  await this.fonts.ready();
4903
5683
  const blob = await this.withDesignBackground(
4904
- () => exportPNG(this.canvas, { ...options, format })
5684
+ () => this.imageEffects.withSourceResolution(
5685
+ () => exportPNG(this.canvas, { ...options, format })
5686
+ )
4905
5687
  );
4906
5688
  this.events.emit("export:complete", { format });
4907
5689
  this.licensing.track(`export:${format}`);
@@ -4939,7 +5721,9 @@ var CanvasEditor = class {
4939
5721
  }
4940
5722
  }
4941
5723
  toDataURL(format = "png", multiplier = 1) {
4942
- return this.withDesignBackground(() => exportDataURL(this.canvas, format, multiplier));
5724
+ return this.withDesignBackground(
5725
+ () => this.imageEffects.withSourceResolution(() => exportDataURL(this.canvas, format, multiplier))
5726
+ );
4943
5727
  }
4944
5728
  /**
4945
5729
  * Export the print file: the design cropped to the mockup's print area, on
@@ -4963,7 +5747,9 @@ var CanvasEditor = class {
4963
5747
  if (!this.mockup) throw new Error("No mockup is configured");
4964
5748
  await this.fonts.ready();
4965
5749
  try {
4966
- const blob = await exportMockup(this.canvas, this.mockup, options);
5750
+ const blob = await this.imageEffects.withSourceResolution(
5751
+ () => exportMockup(this.canvas, this.mockup, options)
5752
+ );
4967
5753
  this.licensing.track(`export:mockup:${options.format ?? "png"}`);
4968
5754
  return blob;
4969
5755
  } catch (error) {
@@ -5105,10 +5891,10 @@ var CanvasEditor = class {
5105
5891
  this.imageProvider = provider;
5106
5892
  }
5107
5893
  // ─── Canvas Operations ──────────────────────────────
5108
- setBackground(color) {
5109
- this.designBackground = color;
5894
+ setBackground(color2) {
5895
+ this.designBackground = color2;
5110
5896
  if (!this.mockup) {
5111
- this.canvas.backgroundColor = color;
5897
+ this.canvas.backgroundColor = color2;
5112
5898
  this.canvas.requestRenderAll();
5113
5899
  }
5114
5900
  this.history.save();
@@ -5415,9 +6201,11 @@ var CanvasEditor = class {
5415
6201
  }
5416
6202
  // ─── Cleanup ────────────────────────────────────────
5417
6203
  dispose() {
6204
+ this.imageEffects.dispose();
5418
6205
  this.masks.dispose();
5419
6206
  this.layerMasks.dispose();
5420
6207
  this.snapping.dispose();
6208
+ this.shapes.dispose();
5421
6209
  this.crop.dispose();
5422
6210
  this.history.dispose();
5423
6211
  this.patterns.dispose();
@@ -5451,12 +6239,26 @@ var CanvasEditor = class {
5451
6239
  const active = this.canvas.getActiveObject();
5452
6240
  if (active) applyObjectSelectionStyle(active, this.selectionStyle, this.zoomLevel);
5453
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
+ });
5454
6251
  this.canvas.on("object:modified", (e) => {
5455
6252
  if (!e.target) return;
5456
6253
  const layer = this.layers.findByObject(e.target);
5457
6254
  if (layer) {
5458
6255
  this.events.emit("layer:modified", { layerId: layer.id });
5459
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
+ }
5460
6262
  }
5461
6263
  });
5462
6264
  this.canvas.on("selection:created", (e) => {
@@ -5544,6 +6346,8 @@ var AnnotationOverlay = class {
5544
6346
  }
5545
6347
  };
5546
6348
  export {
6349
+ ADJUST_KEYS,
6350
+ ADJUST_RANGES,
5547
6351
  AnnotationOverlay,
5548
6352
  BLEND_GROUPS,
5549
6353
  BLEND_MODES,
@@ -5559,7 +6363,12 @@ export {
5559
6363
  EventEmitter,
5560
6364
  FontRegistry,
5561
6365
  HistoryManager,
6366
+ IMAGE_FILTER_PRESETS,
6367
+ IMAGE_PROXY_MAX_EDGE,
6368
+ ImageEffectsManager,
6369
+ LAYER_EFFECT_DEFAULTS,
5562
6370
  Layer,
6371
+ LayerEffectsManager,
5563
6372
  LayerManager,
5564
6373
  LayerMaskManager,
5565
6374
  LicenseManager,
@@ -5567,10 +6376,12 @@ export {
5567
6376
  MaskController,
5568
6377
  MaskPresetManager,
5569
6378
  MaskRefinementError,
6379
+ NEUTRAL_ADJUST,
5570
6380
  PatternManager,
5571
6381
  ProjectManager,
5572
6382
  SHAPE_MASK_BOX,
5573
6383
  SHAPE_MASK_IDS,
6384
+ ShapeManager,
5574
6385
  SnapManager,
5575
6386
  TEXTURE_MASK_IDS,
5576
6387
  TEXTURE_MASK_SIZE,
@@ -5578,6 +6389,8 @@ export {
5578
6389
  TextWrapManager,
5579
6390
  TiledPatternObject,
5580
6391
  UnitConverter,
6392
+ alignObjects,
6393
+ alphaClipMatrix,
5581
6394
  angleFromCoords,
5582
6395
  applyAspectLock,
5583
6396
  applyLayerShadow,
@@ -5590,6 +6403,8 @@ export {
5590
6403
  buildCurveLinePaths,
5591
6404
  buildCurvePathData,
5592
6405
  clamp,
6406
+ clampAdjust,
6407
+ clampAlphaClip,
5593
6408
  clearTextureMaskCache,
5594
6409
  composeMaskGroup,
5595
6410
  computeCoverPlacement,
@@ -5597,6 +6412,7 @@ export {
5597
6412
  computeTilePositions,
5598
6413
  deserializeEditor,
5599
6414
  displaceRgba,
6415
+ distributeObjects,
5600
6416
  drawTiles,
5601
6417
  escapeXml,
5602
6418
  explodeTemplateSvg,
@@ -5608,12 +6424,15 @@ export {
5608
6424
  fitTextWidth,
5609
6425
  fitToBox,
5610
6426
  generateId,
6427
+ imageEffectsFilterSpecs,
6428
+ imageFilterPreset,
5611
6429
  isCssColor,
5612
6430
  isMaskPresetId,
5613
6431
  isShapeMaskId,
5614
6432
  isTextureMaskId,
5615
6433
  linearCoords,
5616
6434
  needsAbsoluteSpace,
6435
+ normalizeImageEffects,
5617
6436
  normalizeTextCurve,
5618
6437
  preWrapWordSplit,
5619
6438
  readGradientConfig,
@@ -5621,6 +6440,7 @@ export {
5621
6440
  readTextWrap,
5622
6441
  renderTextureMask,
5623
6442
  resetTransform,
6443
+ resolveShapeParams,
5624
6444
  restoreLocks,
5625
6445
  round2,
5626
6446
  sanitizeSvg,
@@ -5631,6 +6451,7 @@ export {
5631
6451
  toCanvasSpace,
5632
6452
  toFabricGradient,
5633
6453
  toHostSpace,
6454
+ unionBox,
5634
6455
  unwrapGroup
5635
6456
  };
5636
6457
  //# sourceMappingURL=index.mjs.map