@pixerate/editor 0.219.2 → 0.221.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.
Files changed (30) hide show
  1. package/dist/{chunk-S55JFWDA.cjs → chunk-R35YOUP2.cjs} +407 -161
  2. package/dist/{chunk-F7Z2MKY2.js → chunk-S6KKLFZO.js} +261 -15
  3. package/dist/image-editor/controller.d.cts +52 -5
  4. package/dist/image-editor/controller.d.ts +52 -5
  5. package/dist/image-editor/index.cjs +3065 -2536
  6. package/dist/image-editor/index.d.cts +1 -0
  7. package/dist/image-editor/index.d.ts +1 -0
  8. package/dist/image-editor/index.js +1528 -999
  9. package/dist/image-editor/layers/hybrid-mask.d.cts +31 -0
  10. package/dist/image-editor/layers/hybrid-mask.d.ts +31 -0
  11. package/dist/image-editor/layers/model.d.cts +17 -0
  12. package/dist/image-editor/layers/model.d.ts +17 -0
  13. package/dist/image-editor/layers/serialize.d.cts +4 -0
  14. package/dist/image-editor/layers/serialize.d.ts +4 -0
  15. package/dist/image-editor/layers/vector-mask.d.cts +10 -5
  16. package/dist/image-editor/layers/vector-mask.d.ts +10 -5
  17. package/dist/image-editor/path-edit.d.cts +4 -0
  18. package/dist/image-editor/path-edit.d.ts +4 -0
  19. package/dist/image-editor/selection/index.d.cts +1 -0
  20. package/dist/image-editor/selection/index.d.ts +1 -0
  21. package/dist/image-editor/selection/recipe.d.cts +89 -0
  22. package/dist/image-editor/selection/recipe.d.ts +89 -0
  23. package/dist/image-editor/types.d.cts +9 -0
  24. package/dist/image-editor/types.d.ts +9 -0
  25. package/dist/paint-editor/index.cjs +6 -6
  26. package/dist/paint-editor/index.js +1 -1
  27. package/package.json +1 -1
  28. package/skills/pixerate-image-editor/SKILL.md +7 -11
  29. package/skills/pixerate-image-editor/references/painting.md +17 -1
  30. package/skills/pixerate-image-selection-masking/SKILL.md +3 -3
@@ -91,6 +91,7 @@ import {
91
91
  TINT_SCALE,
92
92
  TONE_LUT_SIZE,
93
93
  TRANSFORM_ANCHORS,
94
+ VECTOR_MASK_EDGE_DEFAULTS,
94
95
  VIBRANCE_GREY,
95
96
  XYZ_D50_TO_SRGB,
96
97
  activeAdjustmentLayers,
@@ -201,6 +202,7 @@ import {
201
202
  hueRangeTables,
202
203
  hueRangeWeight,
203
204
  hueSaturation,
205
+ hybridVectorMaskCoverage,
204
206
  identityCurve,
205
207
  identityLevels,
206
208
  identityTransform,
@@ -224,6 +226,7 @@ import {
224
226
  isValidWarp,
225
227
  kelvinToTemperature,
226
228
  layerEffectsOf,
229
+ layerGuide,
227
230
  layerMaskCoverage,
228
231
  layerMaskFromSelection,
229
232
  layerMaskKey,
@@ -383,8 +386,10 @@ import {
383
386
  updateTypeLayer,
384
387
  userErrorMessage,
385
388
  uvToXy,
389
+ validVectorMaskEdges,
386
390
  vectorMaskBounds,
387
391
  vectorMaskCoverage,
392
+ vectorMaskSegments,
388
393
  vectorMaskThumbnail,
389
394
  versionedDescriptorBytes,
390
395
  vibranceLinear,
@@ -400,7 +405,7 @@ import {
400
405
  writePsd,
401
406
  xyToTempTint,
402
407
  xyToUv
403
- } from "../chunk-F7Z2MKY2.js";
408
+ } from "../chunk-S6KKLFZO.js";
404
409
  import {
405
410
  parseCssColor
406
411
  } from "../chunk-3CI7FRMK.js";
@@ -1333,10 +1338,10 @@ function emptyHistogram() {
1333
1338
  max: 0
1334
1339
  };
1335
1340
  }
1336
- function addToHistogram(h, data, stride, rect2) {
1337
- for (let y = rect2.y; y < rect2.y + rect2.height; y++) {
1338
- let i = (y * stride + rect2.x) * 4;
1339
- for (let x = 0; x < rect2.width; x++, i += 4) {
1341
+ function addToHistogram(h, data, stride, rect3) {
1342
+ for (let y = rect3.y; y < rect3.y + rect3.height; y++) {
1343
+ let i = (y * stride + rect3.x) * 4;
1344
+ for (let x = 0; x < rect3.width; x++, i += 4) {
1340
1345
  if (!(data[i + 3] > 0)) {
1341
1346
  h.transparent++;
1342
1347
  continue;
@@ -1822,10 +1827,22 @@ var ImageEditorRenderer = class {
1822
1827
  else ctx.lineTo(b.x, b.y);
1823
1828
  };
1824
1829
  ctx.beginPath();
1825
- e.curves.forEach(trace2);
1830
+ e.curves.forEach((c, i) => (e.curveModes?.[i] ?? "hard") === "hard" && trace2(c));
1826
1831
  ctx.lineWidth = 1.5 * u;
1827
1832
  ctx.strokeStyle = OVERLAY_HANDLE_EDGE;
1828
1833
  ctx.stroke();
1834
+ for (const [mode, color, width, dash] of [["feather", "#f59e0b", 2, [5, 3]], ["matte", "#d946ef", 3, []]]) {
1835
+ if (!e.curveModes?.includes(mode)) continue;
1836
+ ctx.beginPath();
1837
+ e.curves.forEach((c, i) => e.curveModes[i] === mode && trace2(c));
1838
+ ctx.lineWidth = width * u;
1839
+ ctx.strokeStyle = color;
1840
+ ctx.setLineDash(dash.map((d) => d * u));
1841
+ ctx.stroke();
1842
+ ctx.setLineDash([]);
1843
+ }
1844
+ ctx.lineWidth = 1.5 * u;
1845
+ ctx.strokeStyle = OVERLAY_HANDLE_EDGE;
1829
1846
  if (e.preview) {
1830
1847
  ctx.beginPath();
1831
1848
  trace2(e.preview);
@@ -1970,7 +1987,7 @@ var ImageEditorRenderer = class {
1970
1987
  if (!source.enabled || !source.maskSource) return null;
1971
1988
  return this.depthMaps.get(`${source.maskSource}|${width}x${height}`) ?? null;
1972
1989
  },
1973
- rasterizeAnnotation: (annotation, rect2) => this.rasterizeAnnotation(annotation, rect2),
1990
+ rasterizeAnnotation: (annotation, rect3) => this.rasterizeAnnotation(annotation, rect3),
1974
1991
  version: `${host?.version() ?? ""}:${this.assetVersion}`,
1975
1992
  globalLight: host?.globalLight?.(),
1976
1993
  pattern: host?.pattern ? (id) => host.pattern(id) : void 0
@@ -2011,34 +2028,34 @@ var ImageEditorRenderer = class {
2011
2028
  return Boolean(ctx && typeof ctx.getImageData === "function");
2012
2029
  }
2013
2030
  /** One annotation drawn into a tile-sized RGBA buffer at `rect` (document space), or `null` without a canvas. */
2014
- rasterizeAnnotation(annotation, rect2) {
2015
- if (!this.scratch) this.scratch = this.createCanvas(rect2.width, rect2.height);
2031
+ rasterizeAnnotation(annotation, rect3) {
2032
+ if (!this.scratch) this.scratch = this.createCanvas(rect3.width, rect3.height);
2016
2033
  const canvas = this.scratch;
2017
- if (canvas.width !== rect2.width || canvas.height !== rect2.height) {
2018
- canvas.width = rect2.width;
2019
- canvas.height = rect2.height;
2034
+ if (canvas.width !== rect3.width || canvas.height !== rect3.height) {
2035
+ canvas.width = rect3.width;
2036
+ canvas.height = rect3.height;
2020
2037
  }
2021
2038
  const ctx = canvas.getContext("2d");
2022
2039
  if (!ctx || typeof ctx.getImageData !== "function") return null;
2023
2040
  try {
2024
2041
  ctx.setTransform(1, 0, 0, 1, 0, 0);
2025
- ctx.clearRect(0, 0, rect2.width, rect2.height);
2026
- ctx.setTransform(1, 0, 0, 1, -rect2.x, -rect2.y);
2042
+ ctx.clearRect(0, 0, rect3.width, rect3.height);
2043
+ ctx.setTransform(1, 0, 0, 1, -rect3.x, -rect3.y);
2027
2044
  const img = annotation.type === "image" ? this.imageCache.peek(annotation.src) ?? null : null;
2028
2045
  this.drawAnnotationShape(ctx, annotation, img);
2029
2046
  ctx.setTransform(1, 0, 0, 1, 0, 0);
2030
- return new Uint8ClampedArray(ctx.getImageData(0, 0, rect2.width, rect2.height).data);
2047
+ return new Uint8ClampedArray(ctx.getImageData(0, 0, rect3.width, rect3.height).data);
2031
2048
  } catch {
2032
2049
  return null;
2033
2050
  }
2034
2051
  }
2035
- putTile(ctx, data, tx, ty, rect2) {
2036
- const ox = tx * TILE - rect2.x;
2037
- const oy = ty * TILE - rect2.y;
2052
+ putTile(ctx, data, tx, ty, rect3) {
2053
+ const ox = tx * TILE - rect3.x;
2054
+ const oy = ty * TILE - rect3.y;
2038
2055
  const x0 = Math.max(0, -ox);
2039
2056
  const y0 = Math.max(0, -oy);
2040
- const x1 = Math.min(TILE, rect2.width - ox);
2041
- const y1 = Math.min(TILE, rect2.height - oy);
2057
+ const x1 = Math.min(TILE, rect3.width - ox);
2058
+ const y1 = Math.min(TILE, rect3.height - oy);
2042
2059
  if (x1 <= x0 || y1 <= y0) return;
2043
2060
  const img = ctx.createImageData(TILE, TILE);
2044
2061
  img.data.set(data);
@@ -2050,23 +2067,23 @@ var ImageEditorRenderer = class {
2050
2067
  * uploading only the tiles that changed since the last render.
2051
2068
  */
2052
2069
  updateComposite(layers, region, width, height, options = {}) {
2053
- const rect2 = coveringRect(region);
2070
+ const rect3 = coveringRect(region);
2054
2071
  let comp = this.comp;
2055
2072
  let fresh = false;
2056
- if (!comp || comp.rect.x !== rect2.x || comp.rect.y !== rect2.y || comp.rect.width !== rect2.width || comp.rect.height !== rect2.height) {
2057
- const canvas = this.createCanvas(rect2.width, rect2.height);
2058
- comp = this.comp = new CompositeCanvas(canvas, rect2);
2073
+ if (!comp || comp.rect.x !== rect3.x || comp.rect.y !== rect3.y || comp.rect.width !== rect3.width || comp.rect.height !== rect3.height) {
2074
+ const canvas = this.createCanvas(rect3.width, rect3.height);
2075
+ comp = this.comp = new CompositeCanvas(canvas, rect3);
2059
2076
  fresh = true;
2060
2077
  }
2061
2078
  const ctx = comp.canvas.getContext("2d");
2062
2079
  if (!ctx || typeof ctx.createImageData !== "function") return null;
2063
2080
  let changed = false;
2064
- this.compositor.compositeTiles(layers, rect2, this.compositorSources(width, height), (tx, ty, data) => {
2081
+ this.compositor.compositeTiles(layers, rect3, this.compositorSources(width, height), (tx, ty, data) => {
2065
2082
  const key2 = tileKey(tx, ty);
2066
2083
  if (comp.tiles.get(key2) === data) return;
2067
2084
  comp.tiles.set(key2, data);
2068
2085
  comp.histograms.delete(key2);
2069
- this.putTile(ctx, data, tx, ty, rect2);
2086
+ this.putTile(ctx, data, tx, ty, rect3);
2070
2087
  changed = true;
2071
2088
  }, { checkpointLayerId: options.checkpointLayerId, dirty: fresh ? void 0 : options.dirty });
2072
2089
  if (changed) comp.version++;
@@ -2346,15 +2363,15 @@ var ImageEditorRenderer = class {
2346
2363
  break;
2347
2364
  }
2348
2365
  case "rect": {
2349
- const rect2 = ann;
2350
- ctx.strokeStyle = rect2.strokeColor;
2351
- ctx.lineWidth = rect2.strokeWidth;
2352
- if (rect2.fillColor) {
2353
- ctx.fillStyle = rect2.fillColor;
2354
- ctx.fillRect(rect2.x, rect2.y, rect2.width, rect2.height);
2366
+ const rect3 = ann;
2367
+ ctx.strokeStyle = rect3.strokeColor;
2368
+ ctx.lineWidth = rect3.strokeWidth;
2369
+ if (rect3.fillColor) {
2370
+ ctx.fillStyle = rect3.fillColor;
2371
+ ctx.fillRect(rect3.x, rect3.y, rect3.width, rect3.height);
2355
2372
  }
2356
- if (rect2.strokeWidth > 0) {
2357
- ctx.strokeRect(rect2.x, rect2.y, rect2.width, rect2.height);
2373
+ if (rect3.strokeWidth > 0) {
2374
+ ctx.strokeRect(rect3.x, rect3.y, rect3.width, rect3.height);
2358
2375
  }
2359
2376
  break;
2360
2377
  }
@@ -2545,12 +2562,12 @@ var ImageEditorRenderer = class {
2545
2562
  const split = splitOverlayAnnotations(layers);
2546
2563
  overlay = split.overlay.map((l) => l.annotation);
2547
2564
  await this.prepareLayerAssets(split.composite, naturalWidth, naturalHeight);
2548
- const rect2 = coveringRect(region);
2549
- const comp = this.createCanvas(rect2.width, rect2.height);
2565
+ const rect3 = coveringRect(region);
2566
+ const comp = this.createCanvas(rect3.width, rect3.height);
2550
2567
  const cctx = comp.getContext("2d");
2551
2568
  if (cctx && typeof cctx.createImageData === "function") {
2552
- this.exportCompositor.compositeTiles(split.composite, rect2, this.compositorSources(naturalWidth, naturalHeight), (tx, ty, data) => {
2553
- this.putTile(cctx, data, tx, ty, rect2);
2569
+ this.exportCompositor.compositeTiles(split.composite, rect3, this.compositorSources(naturalWidth, naturalHeight), (tx, ty, data) => {
2570
+ this.putTile(cctx, data, tx, ty, rect3);
2554
2571
  });
2555
2572
  }
2556
2573
  baseCanvas = this.createCanvas(baseW, baseH);
@@ -2561,7 +2578,7 @@ var ImageEditorRenderer = class {
2561
2578
  bctx.beginPath();
2562
2579
  bctx.rect(region.x, region.y, region.width, region.height);
2563
2580
  bctx.clip();
2564
- bctx.drawImage(comp, rect2.x, rect2.y);
2581
+ bctx.drawImage(comp, rect3.x, rect3.y);
2565
2582
  bctx.restore();
2566
2583
  this.applyPost(bctx, baseW, baseH, state);
2567
2584
  }
@@ -2689,10 +2706,10 @@ function getBaseProjection(region, transform, baseW, baseH) {
2689
2706
  };
2690
2707
  }
2691
2708
  var CompositeCanvas = class {
2692
- constructor(canvas, rect2) {
2709
+ constructor(canvas, rect3) {
2693
2710
  this.canvas = canvas;
2694
- this.rect = rect2;
2695
- this.region = rect2;
2711
+ this.rect = rect3;
2712
+ this.region = rect3;
2696
2713
  }
2697
2714
  canvas;
2698
2715
  rect;
@@ -2985,7 +3002,7 @@ function traceLoops(a, W, H, rx0, ry0, rx1, ry1) {
2985
3002
  };
2986
3003
  const on = (i, j) => f(i, j) >= 128;
2987
3004
  const V = w * h;
2988
- const point = (id) => {
3005
+ const point2 = (id) => {
2989
3006
  const vertical = id >= V;
2990
3007
  const k = vertical ? id - V : id;
2991
3008
  const i = k % w;
@@ -3070,7 +3087,7 @@ function traceLoops(a, W, H, rx0, ry0, rx1, ry1) {
3070
3087
  let from = segs[s * 2];
3071
3088
  while (!used[s]) {
3072
3089
  used[s] = 1;
3073
- loop.push(point(from));
3090
+ loop.push(point2(from));
3074
3091
  const to = segs[s * 2] === from ? segs[s * 2 + 1] : segs[s * 2];
3075
3092
  const next = touch.get(to).find((t) => !used[t]);
3076
3093
  from = to;
@@ -3159,133 +3176,648 @@ function regions(a, W, H, b, minArea) {
3159
3176
  };
3160
3177
  }
3161
3178
 
3162
- // src/image-editor/selection/uncertainty.ts
3163
- var BACKED = 0.2;
3164
- function selectionUncertainty(mask, image, options = {}) {
3165
- const W = mask.width;
3166
- const H = mask.height;
3167
- const empty = () => ({ unknown: createSelectionMask(W, H), regions: [], count: 0, confidence: 1 });
3168
- const b = mask.bounds;
3169
- if (!b) return empty();
3170
- const band = Math.max(1, Math.round(options.band ?? Math.max(2, Math.min(W, H) / 200)));
3171
- const minArea = Math.max(1, options.minArea ?? 16);
3172
- const maxRegions = Math.max(1, options.maxRegions ?? 20);
3173
- const a = mask.data;
3174
- const gradient = createGradientSampler(image, W);
3175
- const x0 = Math.max(0, b.x - band - 1);
3176
- const y0 = Math.max(0, b.y - band - 1);
3177
- const x1 = Math.min(W, b.x + b.width + band + 1);
3178
- const y1 = Math.min(H, b.y + b.height + band + 1);
3179
- const w = x1 - x0;
3180
- const h = y1 - y0;
3181
- const evidence = new Float32Array(w * h).fill(NaN);
3182
- const seeds = new Uint8Array(w * h);
3183
- let sum = 0;
3184
- let n = 0;
3185
- const on = (x, y) => a[y * W + x] >= 128;
3179
+ // src/image-editor/selection/wand.ts
3180
+ function checkPixels(p) {
3181
+ const n = p.width * p.height;
3182
+ if (!Number.isInteger(p.width) || !Number.isInteger(p.height) || p.width < 1 || p.height < 1) {
3183
+ throw new Error("Invalid pixel buffer size");
3184
+ }
3185
+ if (p.data.length < n * 4) throw new Error("Pixel data is shorter than width \xD7 height \xD7 4");
3186
+ return n;
3187
+ }
3188
+ var SRGB_TO_LINEAR2 = (() => {
3189
+ const t = new Float32Array(256);
3190
+ for (let i = 0; i < 256; i++) {
3191
+ const v = i / 255;
3192
+ t[i] = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
3193
+ }
3194
+ return t;
3195
+ })();
3196
+ var labF = (t) => t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
3197
+ function srgb8ToLab(r, g, b) {
3198
+ const rl = SRGB_TO_LINEAR2[r & 255];
3199
+ const gl = SRGB_TO_LINEAR2[g & 255];
3200
+ const bl = SRGB_TO_LINEAR2[b & 255];
3201
+ const x = (0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl) / 0.95047;
3202
+ const y = 0.2126729 * rl + 0.7151522 * gl + 0.072175 * bl;
3203
+ const z = (0.0193339 * rl + 0.119192 * gl + 0.9503041 * bl) / 1.08883;
3204
+ const fx = labF(x);
3205
+ const fy = labF(y);
3206
+ const fz = labF(z);
3207
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
3208
+ }
3209
+ function similarity(pixels, seed, tolerance, distance) {
3210
+ const d = pixels.data;
3211
+ const tr = d[seed * 4];
3212
+ const tg = d[seed * 4 + 1];
3213
+ const tb = d[seed * 4 + 2];
3214
+ const ta = d[seed * 4 + 3];
3215
+ const tol = Math.max(0, Math.floor(tolerance + 1e-3));
3216
+ if (distance === "perceptual") {
3217
+ const [tl, taa, tbb] = srgb8ToLab(tr, tg, tb);
3218
+ const de = Math.max(0, tolerance) * 100 / 255;
3219
+ const de2 = de * de + 1e-6;
3220
+ return (i) => {
3221
+ const p = i * 4;
3222
+ const a = d[p + 3];
3223
+ if (ta === 0 && a === 0) return true;
3224
+ if (Math.abs(a - ta) > tol) return false;
3225
+ const [l, aa, bb] = srgb8ToLab(d[p], d[p + 1], d[p + 2]);
3226
+ return (l - tl) ** 2 + (aa - taa) ** 2 + (bb - tbb) ** 2 <= de2;
3227
+ };
3228
+ }
3229
+ return (i) => {
3230
+ const p = i * 4;
3231
+ const a = d[p + 3];
3232
+ if (ta === 0 && a === 0) return true;
3233
+ return Math.abs(d[p] - tr) <= tol && Math.abs(d[p + 1] - tg) <= tol && Math.abs(d[p + 2] - tb) <= tol && Math.abs(a - ta) <= tol;
3234
+ };
3235
+ }
3236
+ function antialiasMarks(marks, w, h, x0, y0, x1, y1) {
3237
+ const out = marks.slice();
3186
3238
  for (let y = y0; y < y1; y++) {
3187
3239
  for (let x = x0; x < x1; x++) {
3188
- const v = on(x, y);
3189
- const differs = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && on(nx, ny) !== v;
3190
- if (!(differs(x - 1, y) || differs(x + 1, y) || differs(x, y - 1) || differs(x, y + 1))) continue;
3191
- let g = 0;
3192
- for (let dy = -2; dy <= 2; dy++) {
3193
- for (let dx = -2; dx <= 2; dx++) {
3240
+ const i = y * w + x;
3241
+ const v = marks[i];
3242
+ let sum = 0;
3243
+ let edge = false;
3244
+ for (let dy = -1; dy <= 1; dy++) {
3245
+ const ny = y + dy;
3246
+ for (let dx = -1; dx <= 1; dx++) {
3194
3247
  const nx = x + dx;
3195
- const ny = y + dy;
3196
- if (nx >= 0 && ny >= 0 && nx < W && ny < H) g = Math.max(g, gradient(nx, ny));
3248
+ const n = nx < 0 || ny < 0 || nx >= w || ny >= h ? v : marks[ny * w + nx];
3249
+ if (n !== v) edge = true;
3250
+ sum += n;
3197
3251
  }
3198
3252
  }
3199
- const e = Math.min(1, g / BACKED);
3200
- const k = (y - y0) * w + (x - x0);
3201
- evidence[k] = e;
3202
- sum += e;
3203
- n++;
3204
- if (e < 0.5) seeds[k] = 1;
3253
+ if (edge) {
3254
+ const avg = sum / (9 * 255);
3255
+ out[i] = Math.round((v > 127 ? 0.5 + 0.5 * avg : 0.5 * avg) * 255);
3256
+ }
3205
3257
  }
3206
3258
  }
3207
- if (!n) return empty();
3208
- const rows = new Uint8Array(w * h);
3209
- for (let y = 0; y < h; y++) {
3210
- let last = -Infinity;
3211
- for (let x = 0; x < w; x++) {
3212
- if (seeds[y * w + x]) last = x;
3213
- if (x - last <= band) rows[y * w + x] = 1;
3214
- }
3215
- last = Infinity;
3216
- for (let x = w - 1; x >= 0; x--) {
3217
- if (seeds[y * w + x]) last = x;
3218
- if (last - x <= band) rows[y * w + x] = 1;
3259
+ return out;
3260
+ }
3261
+ function magicWand(pixels, seed, options = {}) {
3262
+ const n = checkPixels(pixels);
3263
+ const { width: w, height: h } = pixels;
3264
+ const sx = Math.floor(seed.x);
3265
+ const sy = Math.floor(seed.y);
3266
+ if (!(sx >= 0 && sy >= 0 && sx < w && sy < h)) return createSelectionMask(w, h);
3267
+ const similar = similarity(pixels, sy * w + sx, options.tolerance ?? 32, options.distance ?? "rgb");
3268
+ const marks = new Uint8Array(n);
3269
+ let bx0 = w;
3270
+ let by0 = h;
3271
+ let bx1 = -1;
3272
+ let by1 = -1;
3273
+ if (options.contiguous ?? true) {
3274
+ const stack = [sy * w + sx];
3275
+ while (stack.length) {
3276
+ const idx = stack.pop();
3277
+ if (marks[idx]) continue;
3278
+ const y = idx / w | 0;
3279
+ const row = y * w;
3280
+ let l = idx - row;
3281
+ let r = l;
3282
+ while (l > 0 && !marks[row + l - 1] && similar(row + l - 1)) l--;
3283
+ while (r + 1 < w && !marks[row + r + 1] && similar(row + r + 1)) r++;
3284
+ marks.fill(255, row + l, row + r + 1);
3285
+ if (l < bx0) bx0 = l;
3286
+ if (r > bx1) bx1 = r;
3287
+ if (y < by0) by0 = y;
3288
+ if (y > by1) by1 = y;
3289
+ for (const ny of [y - 1, y + 1]) {
3290
+ if (ny < 0 || ny >= h) continue;
3291
+ const nrow = ny * w;
3292
+ let inRun = false;
3293
+ for (let nx = l; nx <= r; nx++) {
3294
+ const ok = !marks[nrow + nx] && similar(nrow + nx);
3295
+ if (ok && !inRun) stack.push(nrow + nx);
3296
+ inRun = ok;
3297
+ }
3298
+ }
3219
3299
  }
3220
- }
3221
- const grown = new Uint8Array(w * h);
3222
- for (let x = 0; x < w; x++) {
3223
- let last = -Infinity;
3300
+ } else {
3224
3301
  for (let y = 0; y < h; y++) {
3225
- if (rows[y * w + x]) last = y;
3226
- if (y - last <= band) grown[y * w + x] = 1;
3227
- }
3228
- last = Infinity;
3229
- for (let y = h - 1; y >= 0; y--) {
3230
- if (rows[y * w + x]) last = y;
3231
- if (last - y <= band) grown[y * w + x] = 1;
3232
- }
3233
- }
3234
- const label = new Int32Array(w * h);
3235
- const stack = new Int32Array(w * h);
3236
- const found = [];
3237
- for (let start = 0; start < w * h; start++) {
3238
- if (!grown[start] || label[start]) continue;
3239
- const r = { area: 0, e: 0, en: 0, bx0: w, by0: h, bx1: -1, by1: -1 };
3240
- const id = found.length + 1;
3241
- let top = 0;
3242
- stack[top++] = start;
3243
- label[start] = id;
3244
- while (top) {
3245
- const k = stack[--top];
3246
- const i = k % w;
3247
- const j = (k - i) / w;
3248
- r.area++;
3249
- if (!Number.isNaN(evidence[k])) {
3250
- r.e += evidence[k];
3251
- r.en++;
3252
- }
3253
- if (i < r.bx0) r.bx0 = i;
3254
- if (i > r.bx1) r.bx1 = i;
3255
- if (j < r.by0) r.by0 = j;
3256
- if (j > r.by1) r.by1 = j;
3257
- for (let dy = -1; dy <= 1; dy++) {
3258
- for (let dx = -1; dx <= 1; dx++) {
3259
- const ni = i + dx;
3260
- const nj = j + dy;
3261
- if (ni < 0 || nj < 0 || ni >= w || nj >= h) continue;
3262
- const nk = nj * w + ni;
3263
- if (!grown[nk] || label[nk]) continue;
3264
- label[nk] = id;
3265
- stack[top++] = nk;
3302
+ const row = y * w;
3303
+ for (let x = 0; x < w; x++) {
3304
+ if (similar(row + x)) {
3305
+ marks[row + x] = 255;
3306
+ if (x < bx0) bx0 = x;
3307
+ if (x > bx1) bx1 = x;
3308
+ if (y < by0) by0 = y;
3309
+ if (y > by1) by1 = y;
3266
3310
  }
3267
3311
  }
3268
3312
  }
3269
- found.push(r);
3270
3313
  }
3271
- const unknownData = new Uint8Array(W * H);
3272
- for (let y = 0; y < h; y++) {
3273
- for (let x = 0; x < w; x++) {
3274
- const id = label[y * w + x];
3275
- if (id && found[id - 1].area >= minArea) unknownData[(y + y0) * W + x + x0] = 255;
3276
- }
3314
+ if (bx1 < 0) return createSelectionMask(w, h);
3315
+ if (!(options.antiAlias ?? true)) return createSelectionMask(w, h, marks);
3316
+ return createSelectionMask(
3317
+ w,
3318
+ h,
3319
+ antialiasMarks(marks, w, h, Math.max(0, bx0 - 1), Math.max(0, by0 - 1), Math.min(w, bx1 + 2), Math.min(h, by1 + 2))
3320
+ );
3321
+ }
3322
+ function sampleColor(c) {
3323
+ if (typeof c === "string") {
3324
+ const m = /^#?([0-9a-f]{6})$/i.exec(c.trim());
3325
+ if (!m) throw new Error(`Invalid colour "${c}"`);
3326
+ const v = parseInt(m[1], 16);
3327
+ return [v >> 16 & 255, v >> 8 & 255, v & 255];
3277
3328
  }
3278
- const kept = found.filter((r) => r.area >= minArea).map((r) => ({
3279
- bounds: { x: x0 + r.bx0, y: y0 + r.by0, width: r.bx1 - r.bx0 + 1, height: r.by1 - r.by0 + 1 },
3280
- area: r.area,
3281
- confidence: Math.round((r.en ? r.e / r.en : 0) * 1e3) / 1e3
3282
- })).sort((p, q) => p.confidence - q.confidence || q.area - p.area);
3283
- return {
3284
- unknown: createSelectionMask(W, H, unknownData),
3285
- regions: kept.slice(0, maxRegions),
3286
- count: kept.length,
3287
- confidence: Math.round(sum / n * 1e3) / 1e3
3288
- };
3329
+ return [c[0], c[1], c[2]];
3330
+ }
3331
+ function colorRange(pixels, samples, options = {}) {
3332
+ const n = checkPixels(pixels);
3333
+ const { width: w, height: h, data } = pixels;
3334
+ const f = Math.max(0, options.fuzziness ?? 40) + 1;
3335
+ const parsed = samples.map((s) => ({ c: sampleColor(s.color), at: s.at }));
3336
+ const radius = options.localized !== void 0 ? Math.max(1, options.localized) : void 0;
3337
+ const out = new Uint8Array(n);
3338
+ if (!parsed.length) return createSelectionMask(w, h, out);
3339
+ for (let i = 0; i < n; i++) {
3340
+ const p = i * 4;
3341
+ if (data[p + 3] <= 0) continue;
3342
+ const x = i % w;
3343
+ const y = (i - x) / w;
3344
+ let best = 0;
3345
+ for (const s of parsed) {
3346
+ const d = Math.max(Math.abs(data[p] - s.c[0]), Math.abs(data[p + 1] - s.c[1]), Math.abs(data[p + 2] - s.c[2]));
3347
+ let k = Math.min(1, Math.max(0, 1 - d / f));
3348
+ if (k > 0 && radius !== void 0 && s.at) {
3349
+ const dist2 = Math.hypot(x - s.at.x, y - s.at.y);
3350
+ k *= Math.min(1, Math.max(0, 1 - dist2 / radius));
3351
+ }
3352
+ if (k > best) best = k;
3353
+ }
3354
+ out[i] = coverageToByte(best);
3355
+ }
3356
+ return createSelectionMask(w, h, out);
3357
+ }
3358
+ var TONE_RANGE_PRESETS = {
3359
+ shadows: [0, 65],
3360
+ midtones: [105, 150],
3361
+ highlights: [190, 255]
3362
+ };
3363
+ function toneRange(pixels, range, falloff = 20) {
3364
+ const n = checkPixels(pixels);
3365
+ const [lo, hi] = typeof range === "string" ? TONE_RANGE_PRESETS[range] : range;
3366
+ const ramp2 = Math.max(1e-3, falloff);
3367
+ const out = new Uint8Array(n);
3368
+ const d = pixels.data;
3369
+ for (let i = 0; i < n; i++) {
3370
+ const p = i * 4;
3371
+ if (d[p + 3] <= 0) continue;
3372
+ const l = Math.min(255, Math.max(0, 0.299 * d[p] + 0.587 * d[p + 1] + 0.114 * d[p + 2]));
3373
+ const outside = l < lo ? lo - l : l > hi ? l - hi : 0;
3374
+ out[i] = coverageToByte(1 - outside / ramp2);
3375
+ }
3376
+ return createSelectionMask(pixels.width, pixels.height, out);
3377
+ }
3378
+ function hueRange(pixels, center) {
3379
+ const n = checkPixels(pixels);
3380
+ const out = new Uint8Array(n);
3381
+ const d = pixels.data;
3382
+ for (let i = 0; i < n; i++) {
3383
+ const p = i * 4;
3384
+ if (d[p + 3] <= 0) continue;
3385
+ const r = d[p] / 255;
3386
+ const g = d[p + 1] / 255;
3387
+ const b = d[p + 2] / 255;
3388
+ const mx = Math.max(r, g, b);
3389
+ const mn = Math.min(r, g, b);
3390
+ const chroma = mx - mn;
3391
+ if (chroma <= 0) continue;
3392
+ let hue;
3393
+ if (mx === r) hue = ((g - b) / chroma % 6 + 6) % 6;
3394
+ else if (mx === g) hue = (b - r) / chroma + 2;
3395
+ else hue = (r - g) / chroma + 4;
3396
+ hue *= 60;
3397
+ let dh = ((hue - center) % 360 + 360) % 360;
3398
+ dh = Math.min(dh, 360 - dh);
3399
+ out[i] = coverageToByte(Math.min(1, Math.max(0, 1 - dh / 60)) * chroma * (2 - mx));
3400
+ }
3401
+ return createSelectionMask(pixels.width, pixels.height, out);
3402
+ }
3403
+ var HUE_RANGE_PRESETS = { reds: 0, yellows: 60, greens: 120, cyans: 180, blues: 240, magentas: 300 };
3404
+ var COLOR_RANGE_PRESETS = [
3405
+ ...Object.keys(HUE_RANGE_PRESETS),
3406
+ "highlights",
3407
+ "midtones",
3408
+ "shadows",
3409
+ "skinTones"
3410
+ ];
3411
+ function isColorRangePreset(v) {
3412
+ return typeof v === "string" && COLOR_RANGE_PRESETS.includes(v);
3413
+ }
3414
+ var ramp = (v, lo, hi, soft) => v < lo ? Math.max(0, 1 - (lo - v) / soft) : v > hi ? Math.max(0, 1 - (v - hi) / soft) : 1;
3415
+ function skinToneRange(pixels) {
3416
+ const n = checkPixels(pixels);
3417
+ const out = new Uint8Array(n);
3418
+ const d = pixels.data;
3419
+ for (let i = 0; i < n; i++) {
3420
+ const p = i * 4;
3421
+ if (d[p + 3] <= 0) continue;
3422
+ const r = d[p] / 255;
3423
+ const g = d[p + 1] / 255;
3424
+ const b = d[p + 2] / 255;
3425
+ const mx = Math.max(r, g, b);
3426
+ const mn = Math.min(r, g, b);
3427
+ const chroma = mx - mn;
3428
+ if (chroma <= 0 || mx <= 0) continue;
3429
+ let hue;
3430
+ if (mx === r) hue = ((g - b) / chroma % 6 + 6) % 6;
3431
+ else if (mx === g) hue = (b - r) / chroma + 2;
3432
+ else hue = (r - g) / chroma + 4;
3433
+ hue *= 60;
3434
+ if (hue > 180) hue -= 360;
3435
+ const k = ramp(hue, 5, 40, 20) * ramp(chroma / mx, 0.12, 0.8, 0.1) * ramp(mx, 0.3, 1, 0.15);
3436
+ out[i] = coverageToByte(k);
3437
+ }
3438
+ return createSelectionMask(pixels.width, pixels.height, out);
3439
+ }
3440
+ function colorRangePreset(pixels, preset, options = {}) {
3441
+ if (preset === "skinTones") return skinToneRange(pixels);
3442
+ if (preset === "highlights" || preset === "midtones" || preset === "shadows") return toneRange(pixels, preset, options.falloff ?? 20);
3443
+ if (preset in HUE_RANGE_PRESETS) return hueRange(pixels, HUE_RANGE_PRESETS[preset]);
3444
+ throw new Error(`Unknown Color Range preset "${String(preset)}". Use one of ${COLOR_RANGE_PRESETS.join(", ")}.`);
3445
+ }
3446
+ function selectedColorBins(pixels, sel, tolerance) {
3447
+ const S = 64;
3448
+ let bins = new Uint8Array(S * S * S);
3449
+ const d = pixels.data;
3450
+ const n = pixels.width * pixels.height;
3451
+ for (let i = 0; i < n; i++) {
3452
+ if (sel.data[i] < 128 || d[i * 4 + 3] === 0) continue;
3453
+ bins[((d[i * 4] >> 2) * S + (d[i * 4 + 1] >> 2)) * S + (d[i * 4 + 2] >> 2)] = 1;
3454
+ }
3455
+ const r = Math.round(Math.max(0, tolerance) / 4);
3456
+ if (r === 0) return bins;
3457
+ for (const stride of [1, S, S * S]) {
3458
+ const out = new Uint8Array(bins.length);
3459
+ for (let i = 0; i < bins.length; i++) {
3460
+ if (!bins[i]) continue;
3461
+ const c = Math.floor(i / stride) % S;
3462
+ const lo = Math.max(0, c - r);
3463
+ const hi = Math.min(S - 1, c + r);
3464
+ const base = i - c * stride;
3465
+ for (let k = lo; k <= hi; k++) out[base + k * stride] = 1;
3466
+ }
3467
+ bins = out;
3468
+ }
3469
+ return bins;
3470
+ }
3471
+ function matcher(pixels, sel, options) {
3472
+ if (sel.width !== pixels.width || sel.height !== pixels.height) throw new Error("The selection and the pixels differ in size");
3473
+ const bins = selectedColorBins(pixels, sel, options.tolerance ?? 32);
3474
+ const d = pixels.data;
3475
+ return (i) => d[i * 4 + 3] > 0 && bins[((d[i * 4] >> 2 << 6) + (d[i * 4 + 1] >> 2)) * 64 + (d[i * 4 + 2] >> 2)] === 1;
3476
+ }
3477
+ function growSelection(pixels, sel, options = {}) {
3478
+ checkPixels(pixels);
3479
+ const match = matcher(pixels, sel, options);
3480
+ const { width: w, height: h } = pixels;
3481
+ const out = new Uint8Array(sel.data);
3482
+ const stack = [];
3483
+ for (let i = 0; i < out.length; i++) if (out[i] >= 128) stack.push(i);
3484
+ while (stack.length) {
3485
+ const i = stack.pop();
3486
+ const x = i % w;
3487
+ const y = (i - x) / w;
3488
+ const visit = (j) => {
3489
+ if (out[j] < 255 && match(j)) {
3490
+ out[j] = 255;
3491
+ stack.push(j);
3492
+ }
3493
+ };
3494
+ if (x > 0) visit(i - 1);
3495
+ if (x < w - 1) visit(i + 1);
3496
+ if (y > 0) visit(i - w);
3497
+ if (y < h - 1) visit(i + w);
3498
+ }
3499
+ return createSelectionMask(w, h, out);
3500
+ }
3501
+ function selectSimilar(pixels, sel, options = {}) {
3502
+ const n = checkPixels(pixels);
3503
+ const match = matcher(pixels, sel, options);
3504
+ const out = new Uint8Array(sel.data);
3505
+ for (let i = 0; i < n; i++) if (match(i)) out[i] = 255;
3506
+ return createSelectionMask(pixels.width, pixels.height, out);
3507
+ }
3508
+
3509
+ // src/image-editor/selection/recipe.ts
3510
+ var SELECTION_RECIPE_OPS = ["rect", "ellipse", "polygon", "magic-wand", "color-range", "color-range-preset", "all", "invert", "modify", "refine"];
3511
+ function isShapeStep(step) {
3512
+ return step.op !== "all" && step.op !== "invert" && step.op !== "modify" && step.op !== "refine";
3513
+ }
3514
+ function stepNeedsPixels(step) {
3515
+ return step.op === "magic-wand" || step.op === "color-range" || step.op === "color-range-preset" || step.op === "refine" && (step.params.radius ?? 8) > 0;
3516
+ }
3517
+ function samplingKey(step) {
3518
+ const s = step;
3519
+ return `${s.sample ?? "composite"}:${s.sampleMerged === true}`;
3520
+ }
3521
+ var fail = (msg) => {
3522
+ throw new Error(msg);
3523
+ };
3524
+ var finite = (v) => typeof v === "number" && Number.isFinite(v);
3525
+ var point = (v, at) => {
3526
+ const p = v;
3527
+ if (!p || !finite(p.x) || !finite(p.y)) fail(`${at} must be { x, y } in natural image pixels.`);
3528
+ return { x: p.x, y: p.y };
3529
+ };
3530
+ var rect = (v, at) => {
3531
+ const r = v;
3532
+ if (!r || !finite(r.x) || !finite(r.y) || !finite(r.width) || !finite(r.height) || r.width <= 0 || r.height <= 0) fail(`${at} must be { x, y, width, height } with a positive size.`);
3533
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
3534
+ };
3535
+ function readRecipeStep(raw, at = "step") {
3536
+ const s = raw;
3537
+ if (!s || typeof s !== "object") return fail(`${at} must be an object with an op.`);
3538
+ const combine = () => {
3539
+ const out = {};
3540
+ if (s.mode !== void 0) {
3541
+ if (s.mode !== "replace" && s.mode !== "add" && s.mode !== "subtract" && s.mode !== "intersect") fail(`${at}.mode must be replace, add, subtract or intersect.`);
3542
+ out.mode = s.mode;
3543
+ }
3544
+ if (s.feather !== void 0) {
3545
+ if (!finite(s.feather) || s.feather < 0 || s.feather > 1e3) fail(`${at}.feather must be 0 to 1000 px.`);
3546
+ if (s.feather > 0) out.feather = s.feather;
3547
+ }
3548
+ if (s.invert === true) out.invert = true;
3549
+ return out;
3550
+ };
3551
+ const sampling = () => {
3552
+ const out = {};
3553
+ if (s.sample !== void 0) {
3554
+ if (s.sample !== "composite" && s.sample !== "active-layer") fail(`${at}.sample must be composite or active-layer.`);
3555
+ out.sample = s.sample;
3556
+ }
3557
+ if (s.sampleMerged === true) out.sampleMerged = true;
3558
+ return out;
3559
+ };
3560
+ const options = () => s.options && typeof s.options === "object" ? { options: JSON.parse(JSON.stringify(s.options)) } : {};
3561
+ switch (s.op) {
3562
+ case "rect":
3563
+ case "ellipse":
3564
+ return { op: s.op, rect: rect(s.rect, `${at}.rect`), ...s.antiAlias === false ? { antiAlias: false } : {}, ...combine() };
3565
+ case "polygon": {
3566
+ if (!Array.isArray(s.points) || s.points.length < 3) fail(`${at}.points must be at least 3 { x, y } points.`);
3567
+ return { op: "polygon", points: s.points.map((p, i) => point(p, `${at}.points[${i}]`)), ...s.antiAlias === false ? { antiAlias: false } : {}, ...combine() };
3568
+ }
3569
+ case "magic-wand":
3570
+ return { op: "magic-wand", point: point(s.point, `${at}.point`), ...options(), ...combine(), ...sampling() };
3571
+ case "color-range":
3572
+ if (!Array.isArray(s.samples) || !s.samples.length) fail(`${at}.samples must be a non-empty list of colour samples.`);
3573
+ return { op: "color-range", samples: JSON.parse(JSON.stringify(s.samples)), ...options(), ...combine(), ...sampling() };
3574
+ case "color-range-preset":
3575
+ if (typeof s.preset !== "string" || !isColorRangePreset(s.preset)) fail(`${at}.preset is not a Color Range preset.`);
3576
+ return { op: "color-range-preset", preset: s.preset, ...finite(s.falloff) ? { falloff: s.falloff } : {}, ...combine(), ...sampling() };
3577
+ case "all":
3578
+ return { op: "all" };
3579
+ case "invert":
3580
+ return { op: "invert" };
3581
+ case "modify":
3582
+ if (!SELECTION_MODIFY_OPS.includes(s.modify)) fail(`${at}.modify must be one of ${SELECTION_MODIFY_OPS.join(", ")}.`);
3583
+ if (!finite(s.radius) || s.radius < 0 || s.radius > 500) fail(`${at}.radius must be 0 to 500 px.`);
3584
+ return { op: "modify", modify: s.modify, radius: s.radius };
3585
+ case "refine":
3586
+ if (!s.params || typeof s.params !== "object") fail(`${at}.params must be the refine settings (radius, smooth, feather, contrast, shiftEdge).`);
3587
+ return { op: "refine", params: JSON.parse(JSON.stringify(s.params)), ...sampling() };
3588
+ default:
3589
+ return fail(`${at}.op "${String(s.op)}" is not a recipe step; use ${SELECTION_RECIPE_OPS.join(", ")}.`);
3590
+ }
3591
+ }
3592
+ function replaySelectionRecipe(steps, width, height, pixelsFor) {
3593
+ let sel = null;
3594
+ steps.forEach((step, i) => {
3595
+ const shape = (mask) => {
3596
+ const st = step;
3597
+ let m = st.feather && st.feather > 0 ? modifySelection(mask, "feather", st.feather) : mask;
3598
+ if (st.invert) m = invertSelection(m);
3599
+ return combineSelections(sel, m, st.mode ?? "replace");
3600
+ };
3601
+ let next;
3602
+ switch (step.op) {
3603
+ case "rect":
3604
+ next = shape(rectSelection(width, height, step.rect, { antiAlias: step.antiAlias }));
3605
+ break;
3606
+ case "ellipse":
3607
+ next = shape(ellipseSelection(width, height, step.rect, { antiAlias: step.antiAlias }));
3608
+ break;
3609
+ case "polygon":
3610
+ next = shape(polygonSelection(width, height, step.points, { antiAlias: step.antiAlias }));
3611
+ break;
3612
+ case "magic-wand":
3613
+ next = shape(magicWand(pixelsFor(step), step.point, step.options ?? {}));
3614
+ break;
3615
+ case "color-range":
3616
+ next = shape(colorRange(pixelsFor(step), step.samples, step.options ?? {}));
3617
+ break;
3618
+ case "color-range-preset":
3619
+ next = shape(colorRangePreset(pixelsFor(step), step.preset, step.falloff !== void 0 ? { falloff: step.falloff } : {}));
3620
+ break;
3621
+ case "all":
3622
+ next = fullSelection(width, height);
3623
+ break;
3624
+ case "invert":
3625
+ next = sel ? invertSelection(sel) : fullSelection(width, height);
3626
+ break;
3627
+ case "modify":
3628
+ if (!sel?.bounds) throw new Error(`Step ${i} (modify) has nothing selected to ${step.modify}; an earlier step must select something.`);
3629
+ next = modifySelection(sel, step.modify, step.radius);
3630
+ break;
3631
+ case "refine":
3632
+ if (!sel?.bounds) throw new Error(`Step ${i} (refine) has nothing selected to refine; an earlier step must select something.`);
3633
+ next = refineSelection(stepNeedsPixels(step) ? pixelsFor(step) : null, sel, step.params);
3634
+ break;
3635
+ }
3636
+ sel = next?.bounds ? next : null;
3637
+ });
3638
+ return sel;
3639
+ }
3640
+ function describeRecipe(steps) {
3641
+ return steps.map((s, index) => ({ index, ...JSON.parse(JSON.stringify(s)) }));
3642
+ }
3643
+ var SHAPE_LABELS = { rect: "Rectangle", ellipse: "Ellipse" };
3644
+ var MODIFY_LABELS = { expand: "Expand", grow: "Expand", contract: "Contract", shrink: "Contract", feather: "Feather", border: "Border", smooth: "Smooth" };
3645
+ function describeRecipeStep(step) {
3646
+ const r = (n) => Math.round(n);
3647
+ switch (step.op) {
3648
+ case "rect":
3649
+ case "ellipse":
3650
+ return `${SHAPE_LABELS[step.op]} ${r(step.rect.width)} \xD7 ${r(step.rect.height)} at ${r(step.rect.x)}, ${r(step.rect.y)}`;
3651
+ case "polygon":
3652
+ return `Polygon, ${step.points.length} points`;
3653
+ case "magic-wand":
3654
+ return `Magic Wand at ${r(step.point.x)}, ${r(step.point.y)}`;
3655
+ case "color-range":
3656
+ return `Color Range, ${step.samples.length} sample${step.samples.length === 1 ? "" : "s"}`;
3657
+ case "color-range-preset":
3658
+ return `Color Range: ${step.preset}`;
3659
+ case "all":
3660
+ return "Select All";
3661
+ case "invert":
3662
+ return "Inverse";
3663
+ case "modify":
3664
+ return `${MODIFY_LABELS[step.modify] ?? step.modify} ${step.radius} px`;
3665
+ case "refine":
3666
+ return "Refine Edge";
3667
+ }
3668
+ }
3669
+ function recipeStepSetting(step) {
3670
+ switch (step.op) {
3671
+ case "rect":
3672
+ case "ellipse":
3673
+ case "polygon":
3674
+ return { label: "feather", value: step.feather ?? 0, min: 0, max: 1e3, change: (v) => ({ feather: v }) };
3675
+ case "magic-wand": {
3676
+ const options = step.options ?? {};
3677
+ return { label: "tolerance", value: Number(options.tolerance ?? 32), min: 0, max: 255, change: (v) => ({ options: { ...options, tolerance: v } }) };
3678
+ }
3679
+ case "color-range": {
3680
+ const options = step.options ?? {};
3681
+ return { label: "fuzziness", value: Number(options.fuzziness ?? 40), min: 0, max: 200, change: (v) => ({ options: { ...options, fuzziness: v } }) };
3682
+ }
3683
+ case "color-range-preset":
3684
+ return step.preset === "highlights" || step.preset === "midtones" || step.preset === "shadows" ? { label: "falloff", value: step.falloff ?? 20, min: 0, max: 255, change: (v) => ({ falloff: v }) } : null;
3685
+ case "modify":
3686
+ return { label: "radius", value: step.radius, min: 0, max: 500, change: (v) => ({ radius: v }) };
3687
+ case "refine":
3688
+ return { label: "radius", value: step.params.radius ?? 8, min: 0, max: 64, change: (v) => ({ params: { ...step.params, radius: v } }) };
3689
+ default:
3690
+ return null;
3691
+ }
3692
+ }
3693
+
3694
+ // src/image-editor/selection/uncertainty.ts
3695
+ var BACKED = 0.2;
3696
+ function selectionUncertainty(mask, image, options = {}) {
3697
+ const W = mask.width;
3698
+ const H = mask.height;
3699
+ const empty = () => ({ unknown: createSelectionMask(W, H), regions: [], count: 0, confidence: 1 });
3700
+ const b = mask.bounds;
3701
+ if (!b) return empty();
3702
+ const band = Math.max(1, Math.round(options.band ?? Math.max(2, Math.min(W, H) / 200)));
3703
+ const minArea = Math.max(1, options.minArea ?? 16);
3704
+ const maxRegions = Math.max(1, options.maxRegions ?? 20);
3705
+ const a = mask.data;
3706
+ const gradient = createGradientSampler(image, W);
3707
+ const x0 = Math.max(0, b.x - band - 1);
3708
+ const y0 = Math.max(0, b.y - band - 1);
3709
+ const x1 = Math.min(W, b.x + b.width + band + 1);
3710
+ const y1 = Math.min(H, b.y + b.height + band + 1);
3711
+ const w = x1 - x0;
3712
+ const h = y1 - y0;
3713
+ const evidence = new Float32Array(w * h).fill(NaN);
3714
+ const seeds = new Uint8Array(w * h);
3715
+ let sum = 0;
3716
+ let n = 0;
3717
+ const on = (x, y) => a[y * W + x] >= 128;
3718
+ for (let y = y0; y < y1; y++) {
3719
+ for (let x = x0; x < x1; x++) {
3720
+ const v = on(x, y);
3721
+ const differs = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && on(nx, ny) !== v;
3722
+ if (!(differs(x - 1, y) || differs(x + 1, y) || differs(x, y - 1) || differs(x, y + 1))) continue;
3723
+ let g = 0;
3724
+ for (let dy = -2; dy <= 2; dy++) {
3725
+ for (let dx = -2; dx <= 2; dx++) {
3726
+ const nx = x + dx;
3727
+ const ny = y + dy;
3728
+ if (nx >= 0 && ny >= 0 && nx < W && ny < H) g = Math.max(g, gradient(nx, ny));
3729
+ }
3730
+ }
3731
+ const e = Math.min(1, g / BACKED);
3732
+ const k = (y - y0) * w + (x - x0);
3733
+ evidence[k] = e;
3734
+ sum += e;
3735
+ n++;
3736
+ if (e < 0.5) seeds[k] = 1;
3737
+ }
3738
+ }
3739
+ if (!n) return empty();
3740
+ const rows = new Uint8Array(w * h);
3741
+ for (let y = 0; y < h; y++) {
3742
+ let last = -Infinity;
3743
+ for (let x = 0; x < w; x++) {
3744
+ if (seeds[y * w + x]) last = x;
3745
+ if (x - last <= band) rows[y * w + x] = 1;
3746
+ }
3747
+ last = Infinity;
3748
+ for (let x = w - 1; x >= 0; x--) {
3749
+ if (seeds[y * w + x]) last = x;
3750
+ if (last - x <= band) rows[y * w + x] = 1;
3751
+ }
3752
+ }
3753
+ const grown = new Uint8Array(w * h);
3754
+ for (let x = 0; x < w; x++) {
3755
+ let last = -Infinity;
3756
+ for (let y = 0; y < h; y++) {
3757
+ if (rows[y * w + x]) last = y;
3758
+ if (y - last <= band) grown[y * w + x] = 1;
3759
+ }
3760
+ last = Infinity;
3761
+ for (let y = h - 1; y >= 0; y--) {
3762
+ if (rows[y * w + x]) last = y;
3763
+ if (last - y <= band) grown[y * w + x] = 1;
3764
+ }
3765
+ }
3766
+ const label = new Int32Array(w * h);
3767
+ const stack = new Int32Array(w * h);
3768
+ const found = [];
3769
+ for (let start = 0; start < w * h; start++) {
3770
+ if (!grown[start] || label[start]) continue;
3771
+ const r = { area: 0, e: 0, en: 0, bx0: w, by0: h, bx1: -1, by1: -1 };
3772
+ const id = found.length + 1;
3773
+ let top = 0;
3774
+ stack[top++] = start;
3775
+ label[start] = id;
3776
+ while (top) {
3777
+ const k = stack[--top];
3778
+ const i = k % w;
3779
+ const j = (k - i) / w;
3780
+ r.area++;
3781
+ if (!Number.isNaN(evidence[k])) {
3782
+ r.e += evidence[k];
3783
+ r.en++;
3784
+ }
3785
+ if (i < r.bx0) r.bx0 = i;
3786
+ if (i > r.bx1) r.bx1 = i;
3787
+ if (j < r.by0) r.by0 = j;
3788
+ if (j > r.by1) r.by1 = j;
3789
+ for (let dy = -1; dy <= 1; dy++) {
3790
+ for (let dx = -1; dx <= 1; dx++) {
3791
+ const ni = i + dx;
3792
+ const nj = j + dy;
3793
+ if (ni < 0 || nj < 0 || ni >= w || nj >= h) continue;
3794
+ const nk = nj * w + ni;
3795
+ if (!grown[nk] || label[nk]) continue;
3796
+ label[nk] = id;
3797
+ stack[top++] = nk;
3798
+ }
3799
+ }
3800
+ }
3801
+ found.push(r);
3802
+ }
3803
+ const unknownData = new Uint8Array(W * H);
3804
+ for (let y = 0; y < h; y++) {
3805
+ for (let x = 0; x < w; x++) {
3806
+ const id = label[y * w + x];
3807
+ if (id && found[id - 1].area >= minArea) unknownData[(y + y0) * W + x + x0] = 255;
3808
+ }
3809
+ }
3810
+ const kept = found.filter((r) => r.area >= minArea).map((r) => ({
3811
+ bounds: { x: x0 + r.bx0, y: y0 + r.by0, width: r.bx1 - r.bx0 + 1, height: r.by1 - r.by0 + 1 },
3812
+ area: r.area,
3813
+ confidence: Math.round((r.en ? r.e / r.en : 0) * 1e3) / 1e3
3814
+ })).sort((p, q) => p.confidence - q.confidence || q.area - p.area);
3815
+ return {
3816
+ unknown: createSelectionMask(W, H, unknownData),
3817
+ regions: kept.slice(0, maxRegions),
3818
+ count: kept.length,
3819
+ confidence: Math.round(sum / n * 1e3) / 1e3
3820
+ };
3289
3821
  }
3290
3822
 
3291
3823
  // src/image-editor/selection/path.ts
@@ -3339,12 +3871,12 @@ function pathToSelection(components, width, height, options = {}) {
3339
3871
  const rx1 = Math.min(width, Math.ceil(x1));
3340
3872
  const ry1 = Math.min(height, Math.ceil(y1));
3341
3873
  if (!(rx1 > rx0 && ry1 > ry0)) return createSelectionMask(width, height);
3342
- const rect2 = { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 };
3343
- const cov = componentsCoverage(components, rect2, options.fillRule ?? "nonzero");
3874
+ const rect3 = { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 };
3875
+ const cov = componentsCoverage(components, rect3, options.fillRule ?? "nonzero");
3344
3876
  const hard = options.antiAlias === false;
3345
3877
  const bytes = new Uint8Array(cov.length);
3346
3878
  for (let i = 0; i < cov.length; i++) bytes[i] = hard ? cov[i] >= 0.5 ? 255 : 0 : coverageToByte(cov[i]);
3347
- const mask = selectionFromRegion(width, height, rect2, bytes);
3879
+ const mask = selectionFromRegion(width, height, rect3, bytes);
3348
3880
  const feather = options.feather ?? 0;
3349
3881
  return feather > 0 && mask.bounds ? modifySelection(mask, "feather", feather) : mask;
3350
3882
  }
@@ -3454,9 +3986,9 @@ var EdgeMap = class {
3454
3986
  }
3455
3987
  };
3456
3988
  var clampPixel = (v, max) => Math.min(max - 1, Math.max(0, Math.floor(v)));
3457
- function snapToEdge(map, point, width = 10) {
3458
- const cx = clampPixel(point.x, map.width);
3459
- const cy = clampPixel(point.y, map.height);
3989
+ function snapToEdge(map, point2, width = 10) {
3990
+ const cx = clampPixel(point2.x, map.width);
3991
+ const cy = clampPixel(point2.y, map.height);
3460
3992
  const r = Math.max(0, Math.round(width));
3461
3993
  let best = 0;
3462
3994
  let bestD = Infinity;
@@ -3475,7 +4007,7 @@ function snapToEdge(map, point, width = 10) {
3475
4007
  }
3476
4008
  }
3477
4009
  }
3478
- return best > 0 ? { x: bx + 0.5, y: by + 0.5 } : point;
4010
+ return best > 0 ? { x: bx + 0.5, y: by + 0.5 } : point2;
3479
4011
  }
3480
4012
  var Heap = class {
3481
4013
  ids = new Int32Array(256);
@@ -3810,681 +4342,351 @@ var ShapeDrawGesture = class {
3810
4342
  this.start = null;
3811
4343
  return this.layerId;
3812
4344
  }
3813
- cancel() {
3814
- this.start = null;
3815
- }
3816
- };
3817
- function applyShapeOptions(controller, layerId, change) {
3818
- const info = controller.getShapeLayer(layerId);
3819
- const style = {};
3820
- if ("fill" in change) style.fill = change.fill ?? null;
3821
- if ("stroke" in change || "strokeWidth" in change) {
3822
- const color = "stroke" in change ? change.stroke : info.stroke?.paint.type === "color" ? info.stroke.paint.color : null;
3823
- style.stroke = color ? { width: change.strokeWidth ?? info.stroke?.width ?? 2, paint: color } : null;
3824
- }
3825
- if (Object.keys(style).length) controller.setShapeStyle(layerId, style);
3826
- const live = info.components[0]?.live;
3827
- if (!live) return;
3828
- const shape = {};
3829
- if (live.kind === "rect" && change.radius !== void 0) shape.radius = change.radius;
3830
- if (live.kind === "polygon" && change.sides !== void 0) shape.sides = change.sides;
3831
- if (live.kind === "star" && change.points !== void 0) shape.points = change.points;
3832
- if (live.kind === "star" && change.innerRatio !== void 0) shape.innerRatio = change.innerRatio;
3833
- if (live.kind === "line" && change.weight !== void 0) shape.weight = change.weight;
3834
- if (live.kind === "line" && change.arrow !== void 0) shape.endArrow = change.arrow ? { width: (live.weight || 1) * 4, length: (live.weight || 1) * 4 } : null;
3835
- if (Object.keys(shape).length) controller.setShapeComponent(layerId, 0, { shape });
3836
- }
3837
- var TextEditSession = class {
3838
- constructor(controller, layerId, index) {
3839
- this.controller = controller;
3840
- this.layerId = layerId;
3841
- const n = this.text.length;
3842
- this.anchor = this.focus = Math.max(0, Math.min(n, index ?? n));
3843
- }
3844
- controller;
3845
- layerId;
3846
- anchor;
3847
- focus;
3848
- changed = false;
3849
- listeners = /* @__PURE__ */ new Set();
3850
- error = null;
3851
- get layer() {
3852
- const l = findLayer(this.controller.getLayers(), this.layerId)?.layer;
3853
- if (!l || l.type !== "type") throw new Error("The type layer being edited is gone.");
3854
- return l;
3855
- }
3856
- get text() {
3857
- return this.layer.text;
3858
- }
3859
- get start() {
3860
- return Math.min(this.anchor, this.focus);
3861
- }
3862
- get end() {
3863
- return Math.max(this.anchor, this.focus);
3864
- }
3865
- /** Whether the layer still exists (it can be deleted or undone away while editing). */
3866
- get alive() {
3867
- return findLayer(this.controller.getLayers(), this.layerId)?.layer.type === "type";
3868
- }
3869
- subscribe(fn) {
3870
- this.listeners.add(fn);
3871
- return () => this.listeners.delete(fn);
3872
- }
3873
- emit() {
3874
- for (const l of this.listeners) l();
3875
- }
3876
- layout() {
3877
- return this.controller.getTextLayout(this.layerId);
3878
- }
3879
- run(fn) {
3880
- this.error = null;
3881
- try {
3882
- const out = fn(!this.changed);
3883
- this.changed = true;
3884
- return out;
3885
- } catch (e) {
3886
- this.error = e instanceof Error ? e.message : String(e);
3887
- return void 0;
3888
- } finally {
3889
- this.emit();
3890
- }
3891
- }
3892
- /** Replaces the selection with `text` (typing and pasting). */
3893
- insert(text) {
3894
- if (!text) return;
3895
- const s = this.start;
3896
- const e = this.end;
3897
- const done = this.run((commit) => this.controller.setText(this.layerId, text, { start: s, end: e }, commit));
3898
- if (done) this.anchor = this.focus = s + text.replace(/\r\n?/g, "\n").length;
3899
- }
3900
- /** Backspace: the selection, else the character before the caret. */
3901
- deleteBackward() {
3902
- if (this.start === this.end && this.start === 0) return;
3903
- const s = this.start === this.end ? Math.max(0, this.start - this.surrogate(this.start - 1, -1)) : this.start;
3904
- const e = this.end;
3905
- if (this.run((commit) => this.controller.setText(this.layerId, "", { start: s, end: e }, commit))) this.anchor = this.focus = s;
3906
- }
3907
- /** Delete: the selection, else the character after the caret. */
3908
- deleteForward() {
3909
- const n = this.text.length;
3910
- if (this.start === this.end && this.end >= n) return;
3911
- const s = this.start;
3912
- const e = this.start === this.end ? Math.min(n, this.end + this.surrogate(this.end, 1)) : this.end;
3913
- if (this.run((commit) => this.controller.setText(this.layerId, "", { start: s, end: e }, commit))) this.anchor = this.focus = s;
3914
- }
3915
- /** 2 when the code unit at `i` is half of a surrogate pair (moving `dir`), else 1. */
3916
- surrogate(i, dir) {
3917
- const t = this.text;
3918
- const c = t.charCodeAt(i);
3919
- if (dir < 0 && c >= 56320 && c <= 57343 && i > 0) return 2;
3920
- if (dir > 0 && c >= 55296 && c <= 56319 && i + 1 < t.length) return 2;
3921
- return 1;
3922
- }
3923
- /**
3924
- * Moves the caret (Shift extends the selection): `left` / `right` along the line, `up` / `down`
3925
- * between lines, `home` / `end` of the line, `start` / `finish` of the text. Arrow keys of vertical
3926
- * text map onto these through `textArrowStep` in `handleKey`.
3927
- */
3928
- move(to, extend = false) {
3929
- const n = this.text.length;
3930
- let f = this.focus;
3931
- const collapsedJump = !extend && this.start !== this.end;
3932
- if (to === "left") f = collapsedJump ? this.start : Math.max(0, f - this.surrogate(f - 1, -1));
3933
- else if (to === "right") f = collapsedJump ? this.end : Math.min(n, f + this.surrogate(f, 1));
3934
- else if (to === "start") f = 0;
3935
- else if (to === "finish") f = n;
3936
- else {
3937
- const layout = this.layout();
3938
- if (layout && layout.lines.length) {
3939
- const c = caretAt(layout, f);
3940
- const line = layout.lines[c.line];
3941
- if (to === "home") f = line.start;
3942
- else if (to === "end") {
3943
- const last = line.chars[line.chars.length - 1];
3944
- f = last ? Math.min(n, last.index + 1) : line.start;
3945
- if (last && this.text[last.index] === " " && c.line < layout.lines.length - 1) f = last.index;
3946
- } else f = textLineMove(layout, f, to === "up" ? -1 : 1, n);
3947
- }
3948
- }
3949
- this.focus = f;
3950
- if (!extend) this.anchor = f;
3951
- this.emit();
3952
- }
3953
- selectAll() {
3954
- this.anchor = 0;
3955
- this.focus = this.text.length;
3956
- this.emit();
3957
- }
3958
- /** Places the caret at a document point (Shift-click extends). */
3959
- pointAt(p, extend = false) {
3960
- this.focus = this.controller.textIndexAt(this.layerId, p);
3961
- if (!extend) this.anchor = this.focus;
3962
- this.emit();
3963
- }
3964
- /** Handles a key; returns `true` when it was the session's (the caller then prevents the default). */
3965
- handleKey(e) {
3966
- const mod2 = e.metaKey || e.ctrlKey;
3967
- if (mod2 && e.key.toLowerCase() === "a") {
3968
- this.selectAll();
3969
- return true;
3970
- }
3971
- switch (e.key) {
3972
- case "Backspace":
3973
- this.deleteBackward();
3974
- return true;
3975
- case "Delete":
3976
- this.deleteForward();
3977
- return true;
3978
- case "Enter":
3979
- this.insert("\n");
3980
- return true;
3981
- case "ArrowLeft":
3982
- case "ArrowRight":
3983
- case "ArrowUp":
3984
- case "ArrowDown": {
3985
- const step = textArrowStep(this.layer.orientation, e.key);
3986
- if (step === "previous") this.move(mod2 ? "home" : "left", e.shiftKey);
3987
- else if (step === "next") this.move(mod2 ? "end" : "right", e.shiftKey);
3988
- else if (step === "line-before") this.move(mod2 ? "start" : "up", e.shiftKey);
3989
- else this.move(mod2 ? "finish" : "down", e.shiftKey);
3990
- return true;
3991
- }
3992
- case "Home":
3993
- this.move("home", e.shiftKey);
3994
- return true;
3995
- case "End":
3996
- this.move("end", e.shiftKey);
3997
- return true;
3998
- case "Tab":
3999
- this.insert(" ");
4000
- return true;
4001
- }
4002
- if (!mod2 && !e.altKey && e.key.length >= 1 && [...e.key].length === 1) {
4003
- this.insert(e.key);
4004
- return true;
4005
- }
4006
- return false;
4007
- }
4008
- /** Sets character style fields on the selection, or on the whole text when nothing is selected. */
4009
- setStyle(style) {
4010
- const s = this.start;
4011
- const e = this.end;
4012
- const range = s === e ? {} : { start: s, end: e };
4013
- this.run((commit) => this.controller.setTextStyle(this.layerId, style, range, commit));
4014
- }
4015
- /** Sets paragraph fields on the paragraphs the selection touches. */
4016
- setParagraph(style) {
4017
- const s = this.start;
4018
- const e = this.end;
4019
- this.run((commit) => this.controller.setParagraphStyle(this.layerId, style, { start: s, end: e }, commit));
4020
- }
4021
- /** The style at the caret (or the selection's start), for the options bar. */
4022
- styleHere() {
4023
- const layer = this.layer;
4024
- const at = this.start > 0 && this.start === this.end ? this.start - 1 : this.start;
4025
- return styleAt(layer.runs, at, DEFAULT_TEXT_STYLE);
4026
- }
4027
- info() {
4028
- return this.controller.getTextLayer(this.layerId);
4029
- }
4030
- /** The caret, the selection and the frame in natural pixels, for `RenderOptions.textEdit`. */
4031
- overlay() {
4032
- const layer = this.layer;
4033
- const layout = this.layout();
4034
- const m = textMatrix(layer);
4035
- const at = (x, y) => applyMatrix(m, { x, y });
4036
- let frame = null;
4037
- if (layer.frame.kind === "box") frame = [at(0, 0), at(layer.frame.width, 0), at(layer.frame.width, layer.frame.height), at(0, layer.frame.height)];
4038
- else if (layout) {
4039
- const b = layout.bounds;
4040
- frame = [at(b.x, b.y), at(b.x + b.width, b.y), at(b.x + b.width, b.y + b.height), at(b.x, b.y + b.height)];
4041
- }
4042
- if (!layout) return { caret: null, selection: [], frame };
4043
- const selection = textSelectionQuads(layout, this.start, this.end).map((q) => q.map((p) => at(p.x, p.y)));
4044
- const c = caretAt(layout, this.focus);
4045
- return { caret: [at(c.from.x, c.from.y), at(c.to.x, c.to.y)], selection, frame };
4046
- }
4047
- /** Ends editing: an empty layer made for this session is deleted (as Photoshop does). Returns whether the layer remains. */
4048
- close() {
4049
- if (!this.alive) return false;
4050
- if (!this.text.trim().length) {
4051
- try {
4052
- this.controller.removeLayer(this.layerId);
4053
- } catch {
4054
- }
4055
- return false;
4056
- }
4057
- if (this.layer.name === "Text" && this.changed) {
4058
- try {
4059
- this.controller.setLayerProperties(this.layerId, { name: typeLayerName(this.text) }, false);
4060
- } catch {
4061
- }
4062
- }
4063
- return true;
4345
+ cancel() {
4346
+ this.start = null;
4064
4347
  }
4065
4348
  };
4066
- function typeLayerAt(controller, p, slop = 4) {
4067
- const layers = [...flattenTypeLayers(controller.getLayers())].reverse();
4068
- for (const l of layers) {
4069
- if (!l.visible) continue;
4070
- const inv = invert(textMatrix(l));
4071
- const q = applyMatrix(inv, p);
4072
- let b = null;
4073
- if (l.frame.kind === "box") b = { x: 0, y: 0, width: l.frame.width, height: l.frame.height };
4074
- else {
4075
- const layout = controller.getTextLayout(l.id);
4076
- if (layout) b = layout.bounds;
4077
- }
4078
- if (b && q.x >= b.x - slop && q.x <= b.x + b.width + slop && q.y >= b.y - slop && q.y <= b.y + b.height + slop) return l.id;
4349
+ function applyShapeOptions(controller, layerId, change) {
4350
+ const info = controller.getShapeLayer(layerId);
4351
+ const style = {};
4352
+ if ("fill" in change) style.fill = change.fill ?? null;
4353
+ if ("stroke" in change || "strokeWidth" in change) {
4354
+ const color = "stroke" in change ? change.stroke : info.stroke?.paint.type === "color" ? info.stroke.paint.color : null;
4355
+ style.stroke = color ? { width: change.strokeWidth ?? info.stroke?.width ?? 2, paint: color } : null;
4079
4356
  }
4080
- return null;
4081
- }
4082
- function flattenTypeLayers(layers) {
4083
- const out = [];
4084
- const walk = (list) => {
4085
- for (const l of list) {
4086
- if (l.type === "type") out.push(l);
4087
- else if (l.type === "group") walk(l.children);
4088
- }
4089
- };
4090
- walk(layers);
4091
- return out;
4092
- }
4093
- function invert(m) {
4094
- const [a, b, c, d, e, f] = m;
4095
- const det = a * d - b * c || 1;
4096
- return [d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det];
4357
+ if (Object.keys(style).length) controller.setShapeStyle(layerId, style);
4358
+ const live = info.components[0]?.live;
4359
+ if (!live) return;
4360
+ const shape = {};
4361
+ if (live.kind === "rect" && change.radius !== void 0) shape.radius = change.radius;
4362
+ if (live.kind === "polygon" && change.sides !== void 0) shape.sides = change.sides;
4363
+ if (live.kind === "star" && change.points !== void 0) shape.points = change.points;
4364
+ if (live.kind === "star" && change.innerRatio !== void 0) shape.innerRatio = change.innerRatio;
4365
+ if (live.kind === "line" && change.weight !== void 0) shape.weight = change.weight;
4366
+ if (live.kind === "line" && change.arrow !== void 0) shape.endArrow = change.arrow ? { width: (live.weight || 1) * 4, length: (live.weight || 1) * 4 } : null;
4367
+ if (Object.keys(shape).length) controller.setShapeComponent(layerId, 0, { shape });
4097
4368
  }
4098
- var TypeToolGesture = class {
4099
- constructor(controller, options) {
4369
+ var TextEditSession = class {
4370
+ constructor(controller, layerId, index) {
4100
4371
  this.controller = controller;
4101
- this.options = options;
4372
+ this.layerId = layerId;
4373
+ const n = this.text.length;
4374
+ this.anchor = this.focus = Math.max(0, Math.min(n, index ?? n));
4102
4375
  }
4103
4376
  controller;
4104
- options;
4105
- session = null;
4106
- dragStart = null;
4377
+ layerId;
4378
+ anchor;
4379
+ focus;
4380
+ changed = false;
4381
+ listeners = /* @__PURE__ */ new Set();
4107
4382
  error = null;
4108
- down(p, mods = {}) {
4109
- this.error = null;
4110
- const hit = typeLayerAt(this.controller, p);
4111
- if (this.session && this.session.alive && hit === this.session.layerId) {
4112
- this.session.pointAt(p, mods.shift);
4113
- this.dragStart = null;
4114
- return;
4115
- }
4116
- this.finish();
4117
- if (hit) {
4118
- this.controller.setActiveLayer(hit);
4119
- this.session = new TextEditSession(this.controller, hit, this.controller.textIndexAt(hit, p));
4120
- this.dragStart = null;
4121
- return;
4122
- }
4123
- this.dragStart = { x: p.x, y: p.y };
4383
+ get layer() {
4384
+ const l = findLayer(this.controller.getLayers(), this.layerId)?.layer;
4385
+ if (!l || l.type !== "type") throw new Error("The type layer being edited is gone.");
4386
+ return l;
4124
4387
  }
4125
- /** Releases a click or drag that did not land on type: adds a new text layer (point text, or a box for a drag). */
4126
- up(p) {
4127
- const start = this.dragStart;
4128
- this.dragStart = null;
4129
- if (!start) return this.session;
4130
- const o = this.options();
4131
- const w = Math.abs(p.x - start.x);
4132
- const h = Math.abs(p.y - start.y);
4388
+ get text() {
4389
+ return this.layer.text;
4390
+ }
4391
+ get start() {
4392
+ return Math.min(this.anchor, this.focus);
4393
+ }
4394
+ get end() {
4395
+ return Math.max(this.anchor, this.focus);
4396
+ }
4397
+ /** Whether the layer still exists (it can be deleted or undone away while editing). */
4398
+ get alive() {
4399
+ return findLayer(this.controller.getLayers(), this.layerId)?.layer.type === "type";
4400
+ }
4401
+ subscribe(fn) {
4402
+ this.listeners.add(fn);
4403
+ return () => this.listeners.delete(fn);
4404
+ }
4405
+ emit() {
4406
+ for (const l of this.listeners) l();
4407
+ }
4408
+ layout() {
4409
+ return this.controller.getTextLayout(this.layerId);
4410
+ }
4411
+ run(fn) {
4412
+ this.error = null;
4133
4413
  try {
4134
- const box = w >= 8 && h >= 8 ? { width: w, height: h } : void 0;
4135
- const id = this.controller.addTextLayer({
4136
- text: "",
4137
- x: box ? Math.min(start.x, p.x) : start.x,
4138
- y: box ? Math.min(start.y, p.y) : start.y,
4139
- ...box ? { box } : {},
4140
- style: { fontFamily: o.fontFamily, fontSize: o.fontSize, fontWeight: o.fontWeight, italic: o.italic, color: o.color },
4141
- paragraph: o.align !== "left" ? { align: o.align } : {},
4142
- ...o.orientation === "vertical" ? { orientation: "vertical" } : {},
4143
- name: "Text"
4144
- });
4145
- this.session = new TextEditSession(this.controller, id, 0);
4414
+ const out = fn(!this.changed);
4415
+ this.changed = true;
4416
+ return out;
4146
4417
  } catch (e) {
4147
4418
  this.error = e instanceof Error ? e.message : String(e);
4419
+ return void 0;
4420
+ } finally {
4421
+ this.emit();
4148
4422
  }
4149
- return this.session;
4150
4423
  }
4151
- /** Ends the open session (an empty new layer is removed). */
4152
- finish() {
4153
- const s = this.session;
4154
- this.session = null;
4155
- s?.close();
4424
+ /** Replaces the selection with `text` (typing and pasting). */
4425
+ insert(text) {
4426
+ if (!text) return;
4427
+ const s = this.start;
4428
+ const e = this.end;
4429
+ const done = this.run((commit) => this.controller.setText(this.layerId, text, { start: s, end: e }, commit));
4430
+ if (done) this.anchor = this.focus = s + text.replace(/\r\n?/g, "\n").length;
4156
4431
  }
4157
- };
4158
-
4159
- // src/image-editor/selection/wand.ts
4160
- function checkPixels(p) {
4161
- const n = p.width * p.height;
4162
- if (!Number.isInteger(p.width) || !Number.isInteger(p.height) || p.width < 1 || p.height < 1) {
4163
- throw new Error("Invalid pixel buffer size");
4432
+ /** Backspace: the selection, else the character before the caret. */
4433
+ deleteBackward() {
4434
+ if (this.start === this.end && this.start === 0) return;
4435
+ const s = this.start === this.end ? Math.max(0, this.start - this.surrogate(this.start - 1, -1)) : this.start;
4436
+ const e = this.end;
4437
+ if (this.run((commit) => this.controller.setText(this.layerId, "", { start: s, end: e }, commit))) this.anchor = this.focus = s;
4164
4438
  }
4165
- if (p.data.length < n * 4) throw new Error("Pixel data is shorter than width \xD7 height \xD7 4");
4166
- return n;
4167
- }
4168
- var SRGB_TO_LINEAR2 = (() => {
4169
- const t = new Float32Array(256);
4170
- for (let i = 0; i < 256; i++) {
4171
- const v = i / 255;
4172
- t[i] = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
4439
+ /** Delete: the selection, else the character after the caret. */
4440
+ deleteForward() {
4441
+ const n = this.text.length;
4442
+ if (this.start === this.end && this.end >= n) return;
4443
+ const s = this.start;
4444
+ const e = this.start === this.end ? Math.min(n, this.end + this.surrogate(this.end, 1)) : this.end;
4445
+ if (this.run((commit) => this.controller.setText(this.layerId, "", { start: s, end: e }, commit))) this.anchor = this.focus = s;
4173
4446
  }
4174
- return t;
4175
- })();
4176
- var labF = (t) => t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
4177
- function srgb8ToLab(r, g, b) {
4178
- const rl = SRGB_TO_LINEAR2[r & 255];
4179
- const gl = SRGB_TO_LINEAR2[g & 255];
4180
- const bl = SRGB_TO_LINEAR2[b & 255];
4181
- const x = (0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl) / 0.95047;
4182
- const y = 0.2126729 * rl + 0.7151522 * gl + 0.072175 * bl;
4183
- const z = (0.0193339 * rl + 0.119192 * gl + 0.9503041 * bl) / 1.08883;
4184
- const fx = labF(x);
4185
- const fy = labF(y);
4186
- const fz = labF(z);
4187
- return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
4188
- }
4189
- function similarity(pixels, seed, tolerance, distance) {
4190
- const d = pixels.data;
4191
- const tr = d[seed * 4];
4192
- const tg = d[seed * 4 + 1];
4193
- const tb = d[seed * 4 + 2];
4194
- const ta = d[seed * 4 + 3];
4195
- const tol = Math.max(0, Math.floor(tolerance + 1e-3));
4196
- if (distance === "perceptual") {
4197
- const [tl, taa, tbb] = srgb8ToLab(tr, tg, tb);
4198
- const de = Math.max(0, tolerance) * 100 / 255;
4199
- const de2 = de * de + 1e-6;
4200
- return (i) => {
4201
- const p = i * 4;
4202
- const a = d[p + 3];
4203
- if (ta === 0 && a === 0) return true;
4204
- if (Math.abs(a - ta) > tol) return false;
4205
- const [l, aa, bb] = srgb8ToLab(d[p], d[p + 1], d[p + 2]);
4206
- return (l - tl) ** 2 + (aa - taa) ** 2 + (bb - tbb) ** 2 <= de2;
4207
- };
4447
+ /** 2 when the code unit at `i` is half of a surrogate pair (moving `dir`), else 1. */
4448
+ surrogate(i, dir) {
4449
+ const t = this.text;
4450
+ const c = t.charCodeAt(i);
4451
+ if (dir < 0 && c >= 56320 && c <= 57343 && i > 0) return 2;
4452
+ if (dir > 0 && c >= 55296 && c <= 56319 && i + 1 < t.length) return 2;
4453
+ return 1;
4208
4454
  }
4209
- return (i) => {
4210
- const p = i * 4;
4211
- const a = d[p + 3];
4212
- if (ta === 0 && a === 0) return true;
4213
- return Math.abs(d[p] - tr) <= tol && Math.abs(d[p + 1] - tg) <= tol && Math.abs(d[p + 2] - tb) <= tol && Math.abs(a - ta) <= tol;
4214
- };
4215
- }
4216
- function antialiasMarks(marks, w, h, x0, y0, x1, y1) {
4217
- const out = marks.slice();
4218
- for (let y = y0; y < y1; y++) {
4219
- for (let x = x0; x < x1; x++) {
4220
- const i = y * w + x;
4221
- const v = marks[i];
4222
- let sum = 0;
4223
- let edge = false;
4224
- for (let dy = -1; dy <= 1; dy++) {
4225
- const ny = y + dy;
4226
- for (let dx = -1; dx <= 1; dx++) {
4227
- const nx = x + dx;
4228
- const n = nx < 0 || ny < 0 || nx >= w || ny >= h ? v : marks[ny * w + nx];
4229
- if (n !== v) edge = true;
4230
- sum += n;
4231
- }
4232
- }
4233
- if (edge) {
4234
- const avg = sum / (9 * 255);
4235
- out[i] = Math.round((v > 127 ? 0.5 + 0.5 * avg : 0.5 * avg) * 255);
4455
+ /**
4456
+ * Moves the caret (Shift extends the selection): `left` / `right` along the line, `up` / `down`
4457
+ * between lines, `home` / `end` of the line, `start` / `finish` of the text. Arrow keys of vertical
4458
+ * text map onto these through `textArrowStep` in `handleKey`.
4459
+ */
4460
+ move(to, extend = false) {
4461
+ const n = this.text.length;
4462
+ let f = this.focus;
4463
+ const collapsedJump = !extend && this.start !== this.end;
4464
+ if (to === "left") f = collapsedJump ? this.start : Math.max(0, f - this.surrogate(f - 1, -1));
4465
+ else if (to === "right") f = collapsedJump ? this.end : Math.min(n, f + this.surrogate(f, 1));
4466
+ else if (to === "start") f = 0;
4467
+ else if (to === "finish") f = n;
4468
+ else {
4469
+ const layout = this.layout();
4470
+ if (layout && layout.lines.length) {
4471
+ const c = caretAt(layout, f);
4472
+ const line = layout.lines[c.line];
4473
+ if (to === "home") f = line.start;
4474
+ else if (to === "end") {
4475
+ const last = line.chars[line.chars.length - 1];
4476
+ f = last ? Math.min(n, last.index + 1) : line.start;
4477
+ if (last && this.text[last.index] === " " && c.line < layout.lines.length - 1) f = last.index;
4478
+ } else f = textLineMove(layout, f, to === "up" ? -1 : 1, n);
4236
4479
  }
4237
4480
  }
4481
+ this.focus = f;
4482
+ if (!extend) this.anchor = f;
4483
+ this.emit();
4238
4484
  }
4239
- return out;
4240
- }
4241
- function magicWand(pixels, seed, options = {}) {
4242
- const n = checkPixels(pixels);
4243
- const { width: w, height: h } = pixels;
4244
- const sx = Math.floor(seed.x);
4245
- const sy = Math.floor(seed.y);
4246
- if (!(sx >= 0 && sy >= 0 && sx < w && sy < h)) return createSelectionMask(w, h);
4247
- const similar = similarity(pixels, sy * w + sx, options.tolerance ?? 32, options.distance ?? "rgb");
4248
- const marks = new Uint8Array(n);
4249
- let bx0 = w;
4250
- let by0 = h;
4251
- let bx1 = -1;
4252
- let by1 = -1;
4253
- if (options.contiguous ?? true) {
4254
- const stack = [sy * w + sx];
4255
- while (stack.length) {
4256
- const idx = stack.pop();
4257
- if (marks[idx]) continue;
4258
- const y = idx / w | 0;
4259
- const row = y * w;
4260
- let l = idx - row;
4261
- let r = l;
4262
- while (l > 0 && !marks[row + l - 1] && similar(row + l - 1)) l--;
4263
- while (r + 1 < w && !marks[row + r + 1] && similar(row + r + 1)) r++;
4264
- marks.fill(255, row + l, row + r + 1);
4265
- if (l < bx0) bx0 = l;
4266
- if (r > bx1) bx1 = r;
4267
- if (y < by0) by0 = y;
4268
- if (y > by1) by1 = y;
4269
- for (const ny of [y - 1, y + 1]) {
4270
- if (ny < 0 || ny >= h) continue;
4271
- const nrow = ny * w;
4272
- let inRun = false;
4273
- for (let nx = l; nx <= r; nx++) {
4274
- const ok = !marks[nrow + nx] && similar(nrow + nx);
4275
- if (ok && !inRun) stack.push(nrow + nx);
4276
- inRun = ok;
4277
- }
4278
- }
4485
+ selectAll() {
4486
+ this.anchor = 0;
4487
+ this.focus = this.text.length;
4488
+ this.emit();
4489
+ }
4490
+ /** Places the caret at a document point (Shift-click extends). */
4491
+ pointAt(p, extend = false) {
4492
+ this.focus = this.controller.textIndexAt(this.layerId, p);
4493
+ if (!extend) this.anchor = this.focus;
4494
+ this.emit();
4495
+ }
4496
+ /** Handles a key; returns `true` when it was the session's (the caller then prevents the default). */
4497
+ handleKey(e) {
4498
+ const mod2 = e.metaKey || e.ctrlKey;
4499
+ if (mod2 && e.key.toLowerCase() === "a") {
4500
+ this.selectAll();
4501
+ return true;
4279
4502
  }
4280
- } else {
4281
- for (let y = 0; y < h; y++) {
4282
- const row = y * w;
4283
- for (let x = 0; x < w; x++) {
4284
- if (similar(row + x)) {
4285
- marks[row + x] = 255;
4286
- if (x < bx0) bx0 = x;
4287
- if (x > bx1) bx1 = x;
4288
- if (y < by0) by0 = y;
4289
- if (y > by1) by1 = y;
4290
- }
4503
+ switch (e.key) {
4504
+ case "Backspace":
4505
+ this.deleteBackward();
4506
+ return true;
4507
+ case "Delete":
4508
+ this.deleteForward();
4509
+ return true;
4510
+ case "Enter":
4511
+ this.insert("\n");
4512
+ return true;
4513
+ case "ArrowLeft":
4514
+ case "ArrowRight":
4515
+ case "ArrowUp":
4516
+ case "ArrowDown": {
4517
+ const step = textArrowStep(this.layer.orientation, e.key);
4518
+ if (step === "previous") this.move(mod2 ? "home" : "left", e.shiftKey);
4519
+ else if (step === "next") this.move(mod2 ? "end" : "right", e.shiftKey);
4520
+ else if (step === "line-before") this.move(mod2 ? "start" : "up", e.shiftKey);
4521
+ else this.move(mod2 ? "finish" : "down", e.shiftKey);
4522
+ return true;
4291
4523
  }
4524
+ case "Home":
4525
+ this.move("home", e.shiftKey);
4526
+ return true;
4527
+ case "End":
4528
+ this.move("end", e.shiftKey);
4529
+ return true;
4530
+ case "Tab":
4531
+ this.insert(" ");
4532
+ return true;
4533
+ }
4534
+ if (!mod2 && !e.altKey && e.key.length >= 1 && [...e.key].length === 1) {
4535
+ this.insert(e.key);
4536
+ return true;
4292
4537
  }
4538
+ return false;
4293
4539
  }
4294
- if (bx1 < 0) return createSelectionMask(w, h);
4295
- if (!(options.antiAlias ?? true)) return createSelectionMask(w, h, marks);
4296
- return createSelectionMask(
4297
- w,
4298
- h,
4299
- antialiasMarks(marks, w, h, Math.max(0, bx0 - 1), Math.max(0, by0 - 1), Math.min(w, bx1 + 2), Math.min(h, by1 + 2))
4300
- );
4301
- }
4302
- function sampleColor(c) {
4303
- if (typeof c === "string") {
4304
- const m = /^#?([0-9a-f]{6})$/i.exec(c.trim());
4305
- if (!m) throw new Error(`Invalid colour "${c}"`);
4306
- const v = parseInt(m[1], 16);
4307
- return [v >> 16 & 255, v >> 8 & 255, v & 255];
4540
+ /** Sets character style fields on the selection, or on the whole text when nothing is selected. */
4541
+ setStyle(style) {
4542
+ const s = this.start;
4543
+ const e = this.end;
4544
+ const range = s === e ? {} : { start: s, end: e };
4545
+ this.run((commit) => this.controller.setTextStyle(this.layerId, style, range, commit));
4308
4546
  }
4309
- return [c[0], c[1], c[2]];
4310
- }
4311
- function colorRange(pixels, samples, options = {}) {
4312
- const n = checkPixels(pixels);
4313
- const { width: w, height: h, data } = pixels;
4314
- const f = Math.max(0, options.fuzziness ?? 40) + 1;
4315
- const parsed = samples.map((s) => ({ c: sampleColor(s.color), at: s.at }));
4316
- const radius = options.localized !== void 0 ? Math.max(1, options.localized) : void 0;
4317
- const out = new Uint8Array(n);
4318
- if (!parsed.length) return createSelectionMask(w, h, out);
4319
- for (let i = 0; i < n; i++) {
4320
- const p = i * 4;
4321
- if (data[p + 3] <= 0) continue;
4322
- const x = i % w;
4323
- const y = (i - x) / w;
4324
- let best = 0;
4325
- for (const s of parsed) {
4326
- const d = Math.max(Math.abs(data[p] - s.c[0]), Math.abs(data[p + 1] - s.c[1]), Math.abs(data[p + 2] - s.c[2]));
4327
- let k = Math.min(1, Math.max(0, 1 - d / f));
4328
- if (k > 0 && radius !== void 0 && s.at) {
4329
- const dist2 = Math.hypot(x - s.at.x, y - s.at.y);
4330
- k *= Math.min(1, Math.max(0, 1 - dist2 / radius));
4547
+ /** Sets paragraph fields on the paragraphs the selection touches. */
4548
+ setParagraph(style) {
4549
+ const s = this.start;
4550
+ const e = this.end;
4551
+ this.run((commit) => this.controller.setParagraphStyle(this.layerId, style, { start: s, end: e }, commit));
4552
+ }
4553
+ /** The style at the caret (or the selection's start), for the options bar. */
4554
+ styleHere() {
4555
+ const layer = this.layer;
4556
+ const at = this.start > 0 && this.start === this.end ? this.start - 1 : this.start;
4557
+ return styleAt(layer.runs, at, DEFAULT_TEXT_STYLE);
4558
+ }
4559
+ info() {
4560
+ return this.controller.getTextLayer(this.layerId);
4561
+ }
4562
+ /** The caret, the selection and the frame in natural pixels, for `RenderOptions.textEdit`. */
4563
+ overlay() {
4564
+ const layer = this.layer;
4565
+ const layout = this.layout();
4566
+ const m = textMatrix(layer);
4567
+ const at = (x, y) => applyMatrix(m, { x, y });
4568
+ let frame = null;
4569
+ if (layer.frame.kind === "box") frame = [at(0, 0), at(layer.frame.width, 0), at(layer.frame.width, layer.frame.height), at(0, layer.frame.height)];
4570
+ else if (layout) {
4571
+ const b = layout.bounds;
4572
+ frame = [at(b.x, b.y), at(b.x + b.width, b.y), at(b.x + b.width, b.y + b.height), at(b.x, b.y + b.height)];
4573
+ }
4574
+ if (!layout) return { caret: null, selection: [], frame };
4575
+ const selection = textSelectionQuads(layout, this.start, this.end).map((q) => q.map((p) => at(p.x, p.y)));
4576
+ const c = caretAt(layout, this.focus);
4577
+ return { caret: [at(c.from.x, c.from.y), at(c.to.x, c.to.y)], selection, frame };
4578
+ }
4579
+ /** Ends editing: an empty layer made for this session is deleted (as Photoshop does). Returns whether the layer remains. */
4580
+ close() {
4581
+ if (!this.alive) return false;
4582
+ if (!this.text.trim().length) {
4583
+ try {
4584
+ this.controller.removeLayer(this.layerId);
4585
+ } catch {
4331
4586
  }
4332
- if (k > best) best = k;
4587
+ return false;
4333
4588
  }
4334
- out[i] = coverageToByte(best);
4589
+ if (this.layer.name === "Text" && this.changed) {
4590
+ try {
4591
+ this.controller.setLayerProperties(this.layerId, { name: typeLayerName(this.text) }, false);
4592
+ } catch {
4593
+ }
4594
+ }
4595
+ return true;
4335
4596
  }
4336
- return createSelectionMask(w, h, out);
4337
- }
4338
- var TONE_RANGE_PRESETS = {
4339
- shadows: [0, 65],
4340
- midtones: [105, 150],
4341
- highlights: [190, 255]
4342
4597
  };
4343
- function toneRange(pixels, range, falloff = 20) {
4344
- const n = checkPixels(pixels);
4345
- const [lo, hi] = typeof range === "string" ? TONE_RANGE_PRESETS[range] : range;
4346
- const ramp2 = Math.max(1e-3, falloff);
4347
- const out = new Uint8Array(n);
4348
- const d = pixels.data;
4349
- for (let i = 0; i < n; i++) {
4350
- const p = i * 4;
4351
- if (d[p + 3] <= 0) continue;
4352
- const l = Math.min(255, Math.max(0, 0.299 * d[p] + 0.587 * d[p + 1] + 0.114 * d[p + 2]));
4353
- const outside = l < lo ? lo - l : l > hi ? l - hi : 0;
4354
- out[i] = coverageToByte(1 - outside / ramp2);
4355
- }
4356
- return createSelectionMask(pixels.width, pixels.height, out);
4357
- }
4358
- function hueRange(pixels, center) {
4359
- const n = checkPixels(pixels);
4360
- const out = new Uint8Array(n);
4361
- const d = pixels.data;
4362
- for (let i = 0; i < n; i++) {
4363
- const p = i * 4;
4364
- if (d[p + 3] <= 0) continue;
4365
- const r = d[p] / 255;
4366
- const g = d[p + 1] / 255;
4367
- const b = d[p + 2] / 255;
4368
- const mx = Math.max(r, g, b);
4369
- const mn = Math.min(r, g, b);
4370
- const chroma = mx - mn;
4371
- if (chroma <= 0) continue;
4372
- let hue;
4373
- if (mx === r) hue = ((g - b) / chroma % 6 + 6) % 6;
4374
- else if (mx === g) hue = (b - r) / chroma + 2;
4375
- else hue = (r - g) / chroma + 4;
4376
- hue *= 60;
4377
- let dh = ((hue - center) % 360 + 360) % 360;
4378
- dh = Math.min(dh, 360 - dh);
4379
- out[i] = coverageToByte(Math.min(1, Math.max(0, 1 - dh / 60)) * chroma * (2 - mx));
4598
+ function typeLayerAt(controller, p, slop = 4) {
4599
+ const layers = [...flattenTypeLayers(controller.getLayers())].reverse();
4600
+ for (const l of layers) {
4601
+ if (!l.visible) continue;
4602
+ const inv = invert(textMatrix(l));
4603
+ const q = applyMatrix(inv, p);
4604
+ let b = null;
4605
+ if (l.frame.kind === "box") b = { x: 0, y: 0, width: l.frame.width, height: l.frame.height };
4606
+ else {
4607
+ const layout = controller.getTextLayout(l.id);
4608
+ if (layout) b = layout.bounds;
4609
+ }
4610
+ if (b && q.x >= b.x - slop && q.x <= b.x + b.width + slop && q.y >= b.y - slop && q.y <= b.y + b.height + slop) return l.id;
4380
4611
  }
4381
- return createSelectionMask(pixels.width, pixels.height, out);
4382
- }
4383
- var HUE_RANGE_PRESETS = { reds: 0, yellows: 60, greens: 120, cyans: 180, blues: 240, magentas: 300 };
4384
- var COLOR_RANGE_PRESETS = [
4385
- ...Object.keys(HUE_RANGE_PRESETS),
4386
- "highlights",
4387
- "midtones",
4388
- "shadows",
4389
- "skinTones"
4390
- ];
4391
- function isColorRangePreset(v) {
4392
- return typeof v === "string" && COLOR_RANGE_PRESETS.includes(v);
4612
+ return null;
4393
4613
  }
4394
- var ramp = (v, lo, hi, soft) => v < lo ? Math.max(0, 1 - (lo - v) / soft) : v > hi ? Math.max(0, 1 - (v - hi) / soft) : 1;
4395
- function skinToneRange(pixels) {
4396
- const n = checkPixels(pixels);
4397
- const out = new Uint8Array(n);
4398
- const d = pixels.data;
4399
- for (let i = 0; i < n; i++) {
4400
- const p = i * 4;
4401
- if (d[p + 3] <= 0) continue;
4402
- const r = d[p] / 255;
4403
- const g = d[p + 1] / 255;
4404
- const b = d[p + 2] / 255;
4405
- const mx = Math.max(r, g, b);
4406
- const mn = Math.min(r, g, b);
4407
- const chroma = mx - mn;
4408
- if (chroma <= 0 || mx <= 0) continue;
4409
- let hue;
4410
- if (mx === r) hue = ((g - b) / chroma % 6 + 6) % 6;
4411
- else if (mx === g) hue = (b - r) / chroma + 2;
4412
- else hue = (r - g) / chroma + 4;
4413
- hue *= 60;
4414
- if (hue > 180) hue -= 360;
4415
- const k = ramp(hue, 5, 40, 20) * ramp(chroma / mx, 0.12, 0.8, 0.1) * ramp(mx, 0.3, 1, 0.15);
4416
- out[i] = coverageToByte(k);
4417
- }
4418
- return createSelectionMask(pixels.width, pixels.height, out);
4614
+ function flattenTypeLayers(layers) {
4615
+ const out = [];
4616
+ const walk = (list) => {
4617
+ for (const l of list) {
4618
+ if (l.type === "type") out.push(l);
4619
+ else if (l.type === "group") walk(l.children);
4620
+ }
4621
+ };
4622
+ walk(layers);
4623
+ return out;
4419
4624
  }
4420
- function colorRangePreset(pixels, preset, options = {}) {
4421
- if (preset === "skinTones") return skinToneRange(pixels);
4422
- if (preset === "highlights" || preset === "midtones" || preset === "shadows") return toneRange(pixels, preset, options.falloff ?? 20);
4423
- if (preset in HUE_RANGE_PRESETS) return hueRange(pixels, HUE_RANGE_PRESETS[preset]);
4424
- throw new Error(`Unknown Color Range preset "${String(preset)}". Use one of ${COLOR_RANGE_PRESETS.join(", ")}.`);
4625
+ function invert(m) {
4626
+ const [a, b, c, d, e, f] = m;
4627
+ const det = a * d - b * c || 1;
4628
+ return [d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det];
4425
4629
  }
4426
- function selectedColorBins(pixels, sel, tolerance) {
4427
- const S = 64;
4428
- let bins = new Uint8Array(S * S * S);
4429
- const d = pixels.data;
4430
- const n = pixels.width * pixels.height;
4431
- for (let i = 0; i < n; i++) {
4432
- if (sel.data[i] < 128 || d[i * 4 + 3] === 0) continue;
4433
- bins[((d[i * 4] >> 2) * S + (d[i * 4 + 1] >> 2)) * S + (d[i * 4 + 2] >> 2)] = 1;
4630
+ var TypeToolGesture = class {
4631
+ constructor(controller, options) {
4632
+ this.controller = controller;
4633
+ this.options = options;
4434
4634
  }
4435
- const r = Math.round(Math.max(0, tolerance) / 4);
4436
- if (r === 0) return bins;
4437
- for (const stride of [1, S, S * S]) {
4438
- const out = new Uint8Array(bins.length);
4439
- for (let i = 0; i < bins.length; i++) {
4440
- if (!bins[i]) continue;
4441
- const c = Math.floor(i / stride) % S;
4442
- const lo = Math.max(0, c - r);
4443
- const hi = Math.min(S - 1, c + r);
4444
- const base = i - c * stride;
4445
- for (let k = lo; k <= hi; k++) out[base + k * stride] = 1;
4635
+ controller;
4636
+ options;
4637
+ session = null;
4638
+ dragStart = null;
4639
+ error = null;
4640
+ down(p, mods = {}) {
4641
+ this.error = null;
4642
+ const hit = typeLayerAt(this.controller, p);
4643
+ if (this.session && this.session.alive && hit === this.session.layerId) {
4644
+ this.session.pointAt(p, mods.shift);
4645
+ this.dragStart = null;
4646
+ return;
4446
4647
  }
4447
- bins = out;
4648
+ this.finish();
4649
+ if (hit) {
4650
+ this.controller.setActiveLayer(hit);
4651
+ this.session = new TextEditSession(this.controller, hit, this.controller.textIndexAt(hit, p));
4652
+ this.dragStart = null;
4653
+ return;
4654
+ }
4655
+ this.dragStart = { x: p.x, y: p.y };
4448
4656
  }
4449
- return bins;
4450
- }
4451
- function matcher(pixels, sel, options) {
4452
- if (sel.width !== pixels.width || sel.height !== pixels.height) throw new Error("The selection and the pixels differ in size");
4453
- const bins = selectedColorBins(pixels, sel, options.tolerance ?? 32);
4454
- const d = pixels.data;
4455
- return (i) => d[i * 4 + 3] > 0 && bins[((d[i * 4] >> 2 << 6) + (d[i * 4 + 1] >> 2)) * 64 + (d[i * 4 + 2] >> 2)] === 1;
4456
- }
4457
- function growSelection(pixels, sel, options = {}) {
4458
- checkPixels(pixels);
4459
- const match = matcher(pixels, sel, options);
4460
- const { width: w, height: h } = pixels;
4461
- const out = new Uint8Array(sel.data);
4462
- const stack = [];
4463
- for (let i = 0; i < out.length; i++) if (out[i] >= 128) stack.push(i);
4464
- while (stack.length) {
4465
- const i = stack.pop();
4466
- const x = i % w;
4467
- const y = (i - x) / w;
4468
- const visit = (j) => {
4469
- if (out[j] < 255 && match(j)) {
4470
- out[j] = 255;
4471
- stack.push(j);
4472
- }
4473
- };
4474
- if (x > 0) visit(i - 1);
4475
- if (x < w - 1) visit(i + 1);
4476
- if (y > 0) visit(i - w);
4477
- if (y < h - 1) visit(i + w);
4657
+ /** Releases a click or drag that did not land on type: adds a new text layer (point text, or a box for a drag). */
4658
+ up(p) {
4659
+ const start = this.dragStart;
4660
+ this.dragStart = null;
4661
+ if (!start) return this.session;
4662
+ const o = this.options();
4663
+ const w = Math.abs(p.x - start.x);
4664
+ const h = Math.abs(p.y - start.y);
4665
+ try {
4666
+ const box = w >= 8 && h >= 8 ? { width: w, height: h } : void 0;
4667
+ const id = this.controller.addTextLayer({
4668
+ text: "",
4669
+ x: box ? Math.min(start.x, p.x) : start.x,
4670
+ y: box ? Math.min(start.y, p.y) : start.y,
4671
+ ...box ? { box } : {},
4672
+ style: { fontFamily: o.fontFamily, fontSize: o.fontSize, fontWeight: o.fontWeight, italic: o.italic, color: o.color },
4673
+ paragraph: o.align !== "left" ? { align: o.align } : {},
4674
+ ...o.orientation === "vertical" ? { orientation: "vertical" } : {},
4675
+ name: "Text"
4676
+ });
4677
+ this.session = new TextEditSession(this.controller, id, 0);
4678
+ } catch (e) {
4679
+ this.error = e instanceof Error ? e.message : String(e);
4680
+ }
4681
+ return this.session;
4478
4682
  }
4479
- return createSelectionMask(w, h, out);
4480
- }
4481
- function selectSimilar(pixels, sel, options = {}) {
4482
- const n = checkPixels(pixels);
4483
- const match = matcher(pixels, sel, options);
4484
- const out = new Uint8Array(sel.data);
4485
- for (let i = 0; i < n; i++) if (match(i)) out[i] = 255;
4486
- return createSelectionMask(pixels.width, pixels.height, out);
4487
- }
4683
+ /** Ends the open session (an empty new layer is removed). */
4684
+ finish() {
4685
+ const s = this.session;
4686
+ this.session = null;
4687
+ s?.close();
4688
+ }
4689
+ };
4488
4690
 
4489
4691
  // src/image-editor/selection/segmentation.ts
4490
4692
  var isPoint = (p) => !!p && typeof p === "object" && Number.isFinite(p.x) && Number.isFinite(p.y);
@@ -4769,7 +4971,7 @@ var ImageEffectsEngine = class {
4769
4971
 
4770
4972
  // src/image-editor/free-transform.ts
4771
4973
  var FREE_TRANSFORM_CONTENTS = ["auto", "layer", "selection", "selection-copy", "selection-outline"];
4772
- var fail = (message) => {
4974
+ var fail2 = (message) => {
4773
4975
  throw new LayerOperationError(message);
4774
4976
  };
4775
4977
  var T = (x, y) => [1, 0, x, 0, 1, y, 0, 0, 1];
@@ -4850,20 +5052,20 @@ function liftMask(mask, sel, o) {
4850
5052
  var FreeTransformSession = class {
4851
5053
  constructor(host, init = {}) {
4852
5054
  this.host = host;
4853
- if (init.interpolation !== void 0 && !isInterpolation(init.interpolation)) fail(`Unknown interpolation "${String(init.interpolation)}"; use nearest, bilinear or bicubic.`);
5055
+ if (init.interpolation !== void 0 && !isInterpolation(init.interpolation)) fail2(`Unknown interpolation "${String(init.interpolation)}"; use nearest, bilinear or bicubic.`);
4854
5056
  this.interp = init.interpolation ?? "bicubic";
4855
5057
  const sel = host.selection();
4856
5058
  const selected = Boolean(sel?.bounds);
4857
5059
  let content = init.content ?? "auto";
4858
5060
  if (!["auto", "layer", "selection", "selection-copy", "selection-outline"].includes(content)) {
4859
- fail(`Unknown content "${String(content)}"; use auto, layer, selection, selection-copy or selection-outline.`);
5061
+ fail2(`Unknown content "${String(content)}"; use auto, layer, selection, selection-copy or selection-outline.`);
4860
5062
  }
4861
5063
  const targetId = init.layerId ?? host.activeLayerId();
4862
5064
  const target = targetId && content !== "selection-outline" ? findLayer(host.layers(), targetId)?.layer : void 0;
4863
5065
  const smartTarget = (target?.type === "smart" || target?.type === "shape" || target?.type === "type") && (init.channel ?? (target.id === host.activeLayerId() ? host.activeTarget() : "pixels")) !== "mask";
4864
5066
  if (content === "auto") content = selected && !smartTarget ? "selection" : "layer";
4865
5067
  this.content = content;
4866
- if (content !== "layer" && !selected) fail(`Nothing is selected; ${content} needs a selection. Select first, or use content layer.`);
5068
+ if (content !== "layer" && !selected) fail2(`Nothing is selected; ${content} needs a selection. Select first, or use content layer.`);
4867
5069
  const layers = host.layers();
4868
5070
  this.startLayers = layers;
4869
5071
  this.startSelection = sel;
@@ -4878,17 +5080,17 @@ var FreeTransformSession = class {
4878
5080
  box = { ...sel.bounds };
4879
5081
  } else {
4880
5082
  const id = init.layerId ?? host.activeLayerId();
4881
- if (!id) fail("No layer given and none is active; pass layerId (see list_layers).");
5083
+ if (!id) fail2("No layer given and none is active; pass layerId (see list_layers).");
4882
5084
  const layer = requireLayer(layers, id).layer;
4883
5085
  this.layerId = layer.id;
4884
5086
  const mask = layer.mask && !layer.mask.source ? layer.mask : void 0;
4885
5087
  const channel = init.channel ?? (layer.id === host.activeLayerId() ? host.activeTarget() : "pixels");
4886
- if (channel !== "pixels" && channel !== "mask") fail(`Unknown channel "${String(channel)}"; use pixels or mask.`);
5088
+ if (channel !== "pixels" && channel !== "mask") fail2(`Unknown channel "${String(channel)}"; use pixels or mask.`);
4887
5089
  const linked = mask?.linked !== false;
4888
5090
  const maskAlone = channel === "mask" && (!linked || layer.type !== "pixel");
4889
- if (channel === "mask" && !mask) fail(layer.mask?.source ? `Layer "${layer.name || id}" has a depth mask, which is generated, not transformed.` : `Layer "${layer.name || id}" has no mask to transform; add one with add_layer_mask, or transform its pixels.`);
4890
- if (!maskAlone && layer.type !== "pixel" && layer.type !== "smart" && layer.type !== "shape" && layer.type !== "type") fail(`Layer "${layer.name || id}" is a ${layer.type} layer; Free Transform works on pixel, shape, type and smart object layers, layer masks (channel mask) and selections.`);
4891
- if (!layer.visible && content !== "selection-copy") fail(`Layer "${layer.name || id}" is hidden; show it before you transform it.`);
5091
+ if (channel === "mask" && !mask) fail2(layer.mask?.source ? `Layer "${layer.name || id}" has a depth mask, which is generated, not transformed.` : `Layer "${layer.name || id}" has no mask to transform; add one with add_layer_mask, or transform its pixels.`);
5092
+ if (!maskAlone && layer.type !== "pixel" && layer.type !== "smart" && layer.type !== "shape" && layer.type !== "type") fail2(`Layer "${layer.name || id}" is a ${layer.type} layer; Free Transform works on pixel, shape, type and smart object layers, layer masks (channel mask) and selections.`);
5093
+ if (!layer.visible && content !== "selection-copy") fail2(`Layer "${layer.name || id}" is hidden; show it before you transform it.`);
4892
5094
  if (content !== "selection-copy") {
4893
5095
  requireUnlocked(layer, "position", "transform it");
4894
5096
  if (!maskAlone) requireUnlocked(layer, "pixels", "transform it");
@@ -4896,8 +5098,8 @@ var FreeTransformSession = class {
4896
5098
  const o = layer.type === "pixel" ? layer.offset : { x: 0, y: 0 };
4897
5099
  const pixels = layer.type === "pixel" ? host.pixelsOf(layer) : null;
4898
5100
  if (layer.type === "smart" && !maskAlone) {
4899
- if (content !== "layer") fail(`Layer "${layer.name || id}" is a smart object, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
4900
- if (!isContentsDecoded(layer.contents)) fail(`The contents of "${layer.name || id}" are not decoded here, so it cannot be transformed; run prepareSmartObjects() first.`);
5101
+ if (content !== "layer") fail2(`Layer "${layer.name || id}" is a smart object, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
5102
+ if (!isContentsDecoded(layer.contents)) fail2(`The contents of "${layer.name || id}" are not decoded here, so it cannot be transformed; run prepareSmartObjects() first.`);
4901
5103
  this.smart = layer;
4902
5104
  this.smartObject = true;
4903
5105
  this.startGeometry = placementGeometry(layer);
@@ -4910,7 +5112,7 @@ var FreeTransformSession = class {
4910
5112
  return;
4911
5113
  }
4912
5114
  if (layer.type === "type" && !maskAlone) {
4913
- if (content !== "layer") fail(`Layer "${layer.name || id}" is a type layer, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
5115
+ if (content !== "layer") fail2(`Layer "${layer.name || id}" is a type layer, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
4914
5116
  const frame = typeFrame(layer, host.textEnv().shaper);
4915
5117
  const m = textMatrix(layer);
4916
5118
  const at = (x, y) => ({ x: m[0] * x + m[2] * y + m[4], y: m[1] * x + m[3] * y + m[5] });
@@ -4930,9 +5132,9 @@ var FreeTransformSession = class {
4930
5132
  return;
4931
5133
  }
4932
5134
  if (layer.type === "shape" && !maskAlone) {
4933
- if (content !== "layer") fail(`Layer "${layer.name || id}" is a shape layer, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
5135
+ if (content !== "layer") fail2(`Layer "${layer.name || id}" is a shape layer, which transforms as a whole; use content layer (or rasterize it to transform part of it).`);
4934
5136
  const g = shapeGeometryBounds(layer);
4935
- if (!g) fail(`Shape layer "${layer.name || id}" has no path to transform.`);
5137
+ if (!g) fail2(`Shape layer "${layer.name || id}" has no path to transform.`);
4936
5138
  const w = Math.max(g.width, 1);
4937
5139
  const h = Math.max(g.height, 1);
4938
5140
  box = { x: g.x + (g.width - w) / 2, y: g.y + (g.height - h) / 2, width: w, height: h };
@@ -4944,7 +5146,7 @@ var FreeTransformSession = class {
4944
5146
  moves.push("mask");
4945
5147
  }
4946
5148
  } else if (maskAlone) {
4947
- if (content === "selection-copy") fail("Layer via Copy copies pixels; transform a mask with content layer or selection.");
5149
+ if (content === "selection-copy") fail2("Layer via Copy copies pixels; transform a mask with content layer or selection.");
4948
5150
  if (content === "selection") {
4949
5151
  const { lifted, rest } = liftMask(mask.surface, sel, o);
4950
5152
  this.parts.push({ what: "mask", offset: o, moving: lifted, base: rest, reduced: /* @__PURE__ */ new Map() });
@@ -4999,8 +5201,8 @@ var FreeTransformSession = class {
4999
5201
  moves.push("selection");
5000
5202
  }
5001
5203
  }
5002
- if (!box && moves[0] === "mask") fail("The mask holds nothing but its default value, so there is nothing to transform; paint it first, or transform the pixels (channel pixels).");
5003
- if (!box) fail(content === "layer" ? "The layer has no pixels to transform." : "The selection holds no pixels of this layer; select some, or transform the outline (content selection-outline).");
5204
+ if (!box && moves[0] === "mask") fail2("The mask holds nothing but its default value, so there is nothing to transform; paint it first, or transform the pixels (channel pixels).");
5205
+ if (!box) fail2(content === "layer" ? "The layer has no pixels to transform." : "The selection holds no pixels of this layer; select some, or transform the outline (content selection-outline).");
5004
5206
  this.box = box;
5005
5207
  this.moves = moves;
5006
5208
  this.startGeometry = identityTransform(box);
@@ -5059,10 +5261,10 @@ var FreeTransformSession = class {
5059
5261
  return !this.closed;
5060
5262
  }
5061
5263
  check() {
5062
- if (this.closed) fail("This transform has already been committed or cancelled; begin a new one.");
5264
+ if (this.closed) fail2("This transform has already been committed or cancelled; begin a new one.");
5063
5265
  if (this.host.layers() !== this.shown) {
5064
5266
  this.closed = true;
5065
- fail("The document changed while the transform was open, so it was abandoned; begin a new one.");
5267
+ fail2("The document changed while the transform was open, so it was abandoned; begin a new one.");
5066
5268
  }
5067
5269
  }
5068
5270
  /**
@@ -5072,7 +5274,7 @@ var FreeTransformSession = class {
5072
5274
  update(changes, preview = {}) {
5073
5275
  this.check();
5074
5276
  if (changes.interpolation !== void 0) {
5075
- if (!isInterpolation(changes.interpolation)) fail(`Unknown interpolation "${String(changes.interpolation)}"; use nearest, bilinear or bicubic.`);
5277
+ if (!isInterpolation(changes.interpolation)) fail2(`Unknown interpolation "${String(changes.interpolation)}"; use nearest, bilinear or bicubic.`);
5076
5278
  this.interp = changes.interpolation;
5077
5279
  }
5078
5280
  const next = {
@@ -5080,10 +5282,10 @@ var FreeTransformSession = class {
5080
5282
  corners: (changes.corners ?? this.geometryValue.corners).map((p) => ({ x: p.x, y: p.y })),
5081
5283
  warp: changes.warp === void 0 ? this.geometryValue.warp ?? null : changes.warp
5082
5284
  };
5083
- if (next.corners.length !== 4 || !next.corners.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) fail("corners must be four finite points: top-left, top-right, bottom-right, bottom-left.");
5084
- if (!transformMap(next)) fail("These corners fold the frame over itself or through the horizon; keep the quad convex.");
5285
+ if (next.corners.length !== 4 || !next.corners.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) fail2("corners must be four finite points: top-left, top-right, bottom-right, bottom-left.");
5286
+ if (!transformMap(next)) fail2("These corners fold the frame over itself or through the horizon; keep the quad convex.");
5085
5287
  if (this.typeTarget && (hasWarp(next) || !matrixFromCorners(next.box, next.corners))) {
5086
- fail("Type layers take scale, rotate, skew and flips only (the corners must stay a parallelogram, no warp); rasterize the layer (rasterize_layer) for distort, perspective or warp.");
5288
+ fail2("Type layers take scale, rotate, skew and flips only (the corners must stay a parallelogram, no warp); rasterize the layer (rasterize_layer) for distort, perspective or warp.");
5087
5289
  }
5088
5290
  this.geometryValue = next;
5089
5291
  if (preview !== false) this.render(preview);
@@ -5151,7 +5353,7 @@ var FreeTransformSession = class {
5151
5353
  moved = transformSurface(src, hl, opts);
5152
5354
  }
5153
5355
  } catch (e) {
5154
- if (e instanceof TransformTooLargeError) fail(e.message);
5356
+ if (e instanceof TransformTooLargeError) fail2(e.message);
5155
5357
  throw e;
5156
5358
  }
5157
5359
  }
@@ -5197,9 +5399,9 @@ var FreeTransformSession = class {
5197
5399
  resampleType() {
5198
5400
  const layer = this.typeTarget;
5199
5401
  const g = this.geometryValue;
5200
- if (hasWarp(g)) fail("Type layers cannot be warped here; rasterize the layer first (rasterize_layer), or transform it with scale, rotate and skew.");
5402
+ if (hasWarp(g)) fail2("Type layers cannot be warped here; rasterize the layer first (rasterize_layer), or transform it with scale, rotate and skew.");
5201
5403
  const m = matrixFromCorners(g.box, g.corners);
5202
- if (!m) fail("Type layers take scale, rotate, skew and flips only (the corners must stay a parallelogram); rasterize the layer for distort or perspective.");
5404
+ if (!m) fail2("Type layers take scale, rotate, skew and flips only (the corners must stay a parallelogram); rasterize the layer for distort or perspective.");
5203
5405
  const next = { ...layer, transform: m };
5204
5406
  delete next.annotation;
5205
5407
  let layers = mapLayer(this.startLayers, layer.id, () => rerenderText(next, this.host.textEnv()));
@@ -5227,7 +5429,7 @@ var FreeTransformSession = class {
5227
5429
  const surface = renderSmartObject({ ...layer, placement }, canvas, { step, clip: preview.clip, skipFilters: true, reduced: this.smartReduced });
5228
5430
  return mapLayer(this.startLayers, layer.id, (l) => ({ ...l, placement, surface }));
5229
5431
  } catch (e) {
5230
- if (e instanceof TransformTooLargeError) fail(e.message);
5432
+ if (e instanceof TransformTooLargeError) fail2(e.message);
5231
5433
  throw e;
5232
5434
  }
5233
5435
  }
@@ -5774,9 +5976,9 @@ var PaintStrokeSession = class {
5774
5976
  this.controller._strokeCommit(this.before, this.target.id, this.target.channel, null);
5775
5977
  }
5776
5978
  };
5777
- function workRegionOf(surface, rect2, depth) {
5778
- if (!depth) return Float32Array.from(readRegion(surface, rect2));
5779
- const d = readDeepRegion(surface, rect2, depth);
5979
+ function workRegionOf(surface, rect3, depth) {
5980
+ if (!depth) return Float32Array.from(readRegion(surface, rect3));
5981
+ const d = readDeepRegion(surface, rect3, depth);
5780
5982
  const out = new Float32Array(d.length);
5781
5983
  if (d instanceof Uint16Array) {
5782
5984
  for (let i = 0; i < d.length; i++) out[i] = d[i] / 257;
@@ -5926,6 +6128,32 @@ function migrateToLayeredState(data, size) {
5926
6128
  var DEFAULT_TRANSFORM = DEFAULT_TRANSFORM_VALUES;
5927
6129
  var DEFAULT_DEPTH_MASK = DEFAULT_DEPTH_MASK_VALUES;
5928
6130
  var DEVELOP_KEYS = ["brightness", "contrast", "saturation", "exposure", "temperature", "tint"];
6131
+ function combineFields(o) {
6132
+ return { ...o.mode && o.mode !== "replace" ? { mode: o.mode } : {}, ...o.feather && o.feather > 0 ? { feather: o.feather } : {}, ...o.invert ? { invert: true } : {} };
6133
+ }
6134
+ function shapeStepFields(o) {
6135
+ return { ...o.antiAlias === false ? { antiAlias: false } : {}, ...combineFields(o) };
6136
+ }
6137
+ function samplingFields(o) {
6138
+ return { ...o.sample && o.sample !== "composite" ? { sample: o.sample } : {}, ...o.sampleMerged ? { sampleMerged: true } : {} };
6139
+ }
6140
+ function stepOptions(o) {
6141
+ const { mode: _m, feather: _f, invert: _i, sample: _s, sampleMerged: _sm, pixels: _p, ...rest } = o;
6142
+ return JSON.parse(JSON.stringify(rest));
6143
+ }
6144
+ function recipeAfter(prev, prevSel, step) {
6145
+ const replaces = step.op === "all" || isShapeStep(step) && (step.mode ?? "replace") === "replace";
6146
+ if (replaces || !prevSel?.bounds) return [step];
6147
+ return prev ? [...prev, step] : null;
6148
+ }
6149
+ function readStoredRecipe(raw) {
6150
+ if (!Array.isArray(raw) || !raw.length) return null;
6151
+ try {
6152
+ return raw.map((s, i) => readRecipeStep(s, `selectionRecipe[${i}]`));
6153
+ } catch {
6154
+ return null;
6155
+ }
6156
+ }
5929
6157
  var ImageEditorController = class {
5930
6158
  state;
5931
6159
  renderer;
@@ -6013,6 +6241,7 @@ var ImageEditorController = class {
6013
6241
  if (options.initialState?.selection) {
6014
6242
  try {
6015
6243
  this.state.selection = normalizeSelection(deserializeSelection(options.initialState.selection));
6244
+ if (this.state.selection) this.state.selectionRecipe = readStoredRecipe(options.initialState.selectionRecipe);
6016
6245
  } catch {
6017
6246
  }
6018
6247
  }
@@ -6492,6 +6721,7 @@ var ImageEditorController = class {
6492
6721
  transform: { ...s.transform },
6493
6722
  outputOpacity: s.outputOpacity,
6494
6723
  selection: s.selection ?? null,
6724
+ ...s.selectionRecipe ? { selectionRecipe: s.selectionRecipe } : {},
6495
6725
  ...s.channels?.length ? { channels: s.channels } : {},
6496
6726
  ...s.paths?.length ? { paths: s.paths } : {},
6497
6727
  source: {
@@ -6529,6 +6759,7 @@ var ImageEditorController = class {
6529
6759
  if (entry.colorMode) s.colorMode = entry.colorMode;
6530
6760
  else delete s.colorMode;
6531
6761
  s.selection = entry.selection;
6762
+ s.selectionRecipe = entry.selectionRecipe ?? null;
6532
6763
  if (entry.channels?.length) s.channels = entry.channels;
6533
6764
  else delete s.channels;
6534
6765
  if (entry.paths?.length) s.paths = entry.paths;
@@ -6796,7 +7027,7 @@ var ImageEditorController = class {
6796
7027
  getLayerFrame(id) {
6797
7028
  const layer = this.getLayer(id);
6798
7029
  if (!layer) return null;
6799
- const rect2 = (b2) => [
7030
+ const rect3 = (b2) => [
6800
7031
  { x: b2.x, y: b2.y },
6801
7032
  { x: b2.x + b2.width, y: b2.y },
6802
7033
  { x: b2.x + b2.width, y: b2.y + b2.height },
@@ -6811,16 +7042,16 @@ var ImageEditorController = class {
6811
7042
  }
6812
7043
  if (layer.type === "shape") {
6813
7044
  const g = shapeGeometryBounds(layer);
6814
- return g ? rect2(g) : null;
7045
+ return g ? rect3(g) : null;
6815
7046
  }
6816
7047
  const b = this.getLayerBounds(id);
6817
- return b ? rect2(b) : null;
7048
+ return b ? rect3(b) : null;
6818
7049
  }
6819
7050
  /**
6820
7051
  * Hit-tests the layer stack at `point` (in document space) and returns the id of the topmost
6821
7052
  * matching visible layer, or `null`. Skips adjustment layers.
6822
7053
  */
6823
- layerAt(point, opts = {}) {
7054
+ layerAt(point2, opts = {}) {
6824
7055
  const { includeHidden = false, includeLocked = false, alphaThreshold = 1 } = opts;
6825
7056
  const layers = this.state.layers;
6826
7057
  const testLayer = (layer) => {
@@ -6837,20 +7068,20 @@ var ImageEditorController = class {
6837
7068
  return null;
6838
7069
  }
6839
7070
  if (layer.type === "type") {
6840
- const hitId = typeLayerAt(this, point);
7071
+ const hitId = typeLayerAt(this, point2);
6841
7072
  if (hitId === layer.id) return layer.id;
6842
7073
  return null;
6843
7074
  }
6844
7075
  if (layer.type === "annotation") {
6845
- if (hitTestAnnotation(point, layer.annotation)) return layer.id;
7076
+ if (hitTestAnnotation(point2, layer.annotation)) return layer.id;
6846
7077
  return null;
6847
7078
  }
6848
7079
  if (layer.type === "shape") {
6849
7080
  const bounds = this.getLayerBounds(layer.id);
6850
- if (bounds && point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height) {
7081
+ if (bounds && point2.x >= bounds.x && point2.x <= bounds.x + bounds.width && point2.y >= bounds.y && point2.y <= bounds.y + bounds.height) {
6851
7082
  const surface = this.surfaceOf(layer);
6852
- const px2 = Math.floor(point.x - layer.offset.x);
6853
- const py = Math.floor(point.y - layer.offset.y);
7083
+ const px2 = Math.floor(point2.x - layer.offset.x);
7084
+ const py = Math.floor(point2.y - layer.offset.y);
6854
7085
  const pixel = readPixel(surface, px2, py);
6855
7086
  if (pixel.length >= 4 && pixel[3] >= alphaThreshold) return layer.id;
6856
7087
  }
@@ -6859,12 +7090,12 @@ var ImageEditorController = class {
6859
7090
  if (layer.type === "pixel" || layer.type === "smart") {
6860
7091
  const bounds = this.getLayerBounds(layer.id);
6861
7092
  if (!bounds) return null;
6862
- if (point.x < bounds.x || point.x > bounds.x + bounds.width || point.y < bounds.y || point.y > bounds.y + bounds.height) {
7093
+ if (point2.x < bounds.x || point2.x > bounds.x + bounds.width || point2.y < bounds.y || point2.y > bounds.y + bounds.height) {
6863
7094
  return null;
6864
7095
  }
6865
7096
  const surface = this.surfaceOf(layer);
6866
- const px2 = Math.floor(point.x - layer.offset.x);
6867
- const py = Math.floor(point.y - layer.offset.y);
7097
+ const px2 = Math.floor(point2.x - layer.offset.x);
7098
+ const py = Math.floor(point2.y - layer.offset.y);
6868
7099
  const pixel = readPixel(surface, px2, py);
6869
7100
  if (pixel.length >= 4 && pixel[3] >= alphaThreshold) {
6870
7101
  return layer.id;
@@ -6895,9 +7126,9 @@ var ImageEditorController = class {
6895
7126
  * and are left out): `rect` (default: the canvas) as RGBA, straight alpha. Crop, rotation and
6896
7127
  * output opacity are not applied. With `layerIds`, only those layers are composited.
6897
7128
  */
6898
- compositePixels(rect2, options = {}) {
7129
+ compositePixels(rect3, options = {}) {
6899
7130
  const { width, height } = this.naturalSize;
6900
- const r = rect2 ?? { x: 0, y: 0, width, height };
7131
+ const r = rect3 ?? { x: 0, y: 0, width, height };
6901
7132
  let layers = this.state.layers;
6902
7133
  if (options.layerIds) {
6903
7134
  const keep = new Set(options.layerIds);
@@ -6931,6 +7162,8 @@ var ImageEditorController = class {
6931
7162
  return String(id);
6932
7163
  }
6933
7164
  /** The pixels a pixel layer, smart object or shape composites (the decoded source for the Background). */
7165
+ /** A layer's real pixels for hybrid vector masks' matte guide (the Background's from the source). */
7166
+ guideSurfaceOf = (layer) => layer.type === "pixel" || layer.type === "smart" || layer.type === "shape" || layer.type === "type" ? this.surfaceOf(layer) : void 0;
6934
7167
  surfaceOf(layer) {
6935
7168
  if (layer.type !== "pixel" || layer.source !== "document") return layer.surface;
6936
7169
  return this.sourceSurface() ?? layer.surface;
@@ -7149,7 +7382,7 @@ var ImageEditorController = class {
7149
7382
  /** Whether the layer's mask (painted and vector, with feather) has a soft band. */
7150
7383
  maskHasSoftBand(layer) {
7151
7384
  const { width, height } = this.naturalSize;
7152
- const mask = effectiveLayerMask(layer, width, height);
7385
+ const mask = effectiveLayerMask(layer, width, height, this.guideSurfaceOf);
7153
7386
  if (!mask || mask.enabled === false || mask.source) return false;
7154
7387
  const ox = layer.type === "pixel" ? layer.offset.x : 0;
7155
7388
  const oy = layer.type === "pixel" ? layer.offset.y : 0;
@@ -7523,7 +7756,11 @@ var ImageEditorController = class {
7523
7756
  this.commitLayers(layers, void 0, commitToHistory);
7524
7757
  } else {
7525
7758
  const { vm } = this.requireVectorMask(target.layerId);
7526
- this.commitLayers(mapLayer(this.state.layers, target.layerId, (l) => ({ ...l, vectorMask: { ...vm, components: next } })), void 0, commitToHistory);
7759
+ const edges = validVectorMaskEdges(vm.edges, next);
7760
+ const nextVm = { ...vm, components: next };
7761
+ if (edges.length) nextVm.edges = edges;
7762
+ else delete nextVm.edges;
7763
+ this.commitLayers(mapLayer(this.state.layers, target.layerId, (l) => ({ ...l, vectorMask: nextVm })), void 0, commitToHistory);
7527
7764
  }
7528
7765
  }
7529
7766
  /**
@@ -7618,6 +7855,9 @@ var ImageEditorController = class {
7618
7855
  setVectorMaskPath(id, path) {
7619
7856
  const { vm } = this.requireVectorMask(id);
7620
7857
  const next = { ...vm, components: this.vectorMaskComponents(path) };
7858
+ const kept = validVectorMaskEdges(vm.edges, next.components);
7859
+ if (kept.length) next.edges = kept;
7860
+ else delete next.edges;
7621
7861
  if (path.fillRule === "evenodd") next.fillRule = "evenodd";
7622
7862
  else if (path.fillRule === "nonzero") delete next.fillRule;
7623
7863
  this.commitLayers(mapLayer(this.state.layers, id, (l) => ({ ...l, vectorMask: next })));
@@ -7667,7 +7907,7 @@ var ImageEditorController = class {
7667
7907
  const { width, height } = this.canvasSize;
7668
7908
  const raster = layer.mask;
7669
7909
  const plain3 = { ...layer, vectorMask: { ...layer.vectorMask, enabled: true }, mask: raster ? { surface: raster.surface, defaultValue: raster.defaultValue } : void 0 };
7670
- const baked = effectiveLayerMask(plain3, width, height);
7910
+ const baked = effectiveLayerMask(plain3, width, height, this.guideSurfaceOf);
7671
7911
  const mask = raster ? { ...raster, surface: baked.surface, defaultValue: baked.defaultValue } : baked;
7672
7912
  this.commitLayers(
7673
7913
  mapLayer(this.state.layers, id, (l) => {
@@ -7703,8 +7943,55 @@ var ImageEditorController = class {
7703
7943
  density: vm.density ?? 1,
7704
7944
  feather: vm.feather ?? 0,
7705
7945
  bounds: b ? { x: r(b.x), y: r(b.y), width: r(b.width), height: r(b.height) } : null,
7706
- components: vm.components.map((c, index) => ({ index, op: c.op, live: c.live ? JSON.parse(JSON.stringify(c.live)) : null, subpaths: roundSubpaths(c.subpaths) }))
7946
+ components: vm.components.map((c, index) => ({ index, op: c.op, live: c.live ? JSON.parse(JSON.stringify(c.live)) : null, subpaths: roundSubpaths(c.subpaths) })),
7947
+ edges: validVectorMaskEdges(vm.edges, vm.components).map((e) => ({ ...e })),
7948
+ segmentCount: vectorMaskSegments(vm.components).length
7949
+ };
7950
+ }
7951
+ /**
7952
+ * Hybrid mask: a vector mask segment's edge mode. `hard` (the default) is the path's own sharp
7953
+ * edge; `feather` blurs across it by `radius` px (default 4); `matte` solves a corridor from
7954
+ * `inner` px inside to `outer` px outside (defaults 4 and 8) from the layer's colours, for hair
7955
+ * and fur. Modes blend where they meet. One undo step. Returns the vector mask's info.
7956
+ */
7957
+ setVectorMaskEdge(id, at, mode, options = {}, commitToHistory = true) {
7958
+ const { layer, vm } = this.requireVectorMask(id);
7959
+ const name = layer.name || id;
7960
+ const sp = vm.components[at.component]?.subpaths[at.subpath];
7961
+ const count = sp ? subpathSegmentCount(sp) : 0;
7962
+ if (!sp || !Number.isInteger(at.segment) || at.segment < 0 || at.segment >= count) {
7963
+ const where = vm.components.map((c, ci) => c.subpaths.map((p, si) => `component ${ci} subpath ${si}: segments 0..${subpathSegmentCount(p) - 1}`).join("; ")).join("; ");
7964
+ throw new LayerOperationError(`The vector mask of "${name}" has no segment ${at.component}/${at.subpath}/${at.segment}; it has ${where || "no segments"} (see get_vector_mask).`);
7965
+ }
7966
+ if (mode !== "hard" && mode !== "feather" && mode !== "matte") throw new LayerOperationError(`mode must be hard, feather or matte, got "${String(mode)}".`);
7967
+ const num3 = (v, what, lo, hi) => {
7968
+ if (v === void 0) return void 0;
7969
+ if (!(typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi)) throw new LayerOperationError(`${what} must be ${lo} to ${hi} px, got ${String(v)}.`);
7970
+ return v;
7707
7971
  };
7972
+ const radius = num3(options.radius, "radius", 0.5, 250);
7973
+ const inner = num3(options.inner, "inner", 0.5, 200);
7974
+ const outer = num3(options.outer, "outer", 0.5, 200);
7975
+ if (mode !== "feather" && radius !== void 0) throw new LayerOperationError("radius is for feather edges; matte edges take inner and outer.");
7976
+ if (mode !== "matte" && (inner !== void 0 || outer !== void 0)) throw new LayerOperationError("inner and outer are for matte edges; feather edges take radius.");
7977
+ const same = (e) => e.component === at.component && e.subpath === at.subpath && e.segment === at.segment;
7978
+ const edges = validVectorMaskEdges(vm.edges, vm.components).filter((e) => !same(e));
7979
+ if (mode === "feather") edges.push({ ...at, mode, radius: radius ?? VECTOR_MASK_EDGE_DEFAULTS.radius });
7980
+ else if (mode === "matte") edges.push({ ...at, mode, inner: inner ?? VECTOR_MASK_EDGE_DEFAULTS.inner, outer: outer ?? VECTOR_MASK_EDGE_DEFAULTS.outer });
7981
+ const next = { ...vm };
7982
+ if (edges.length) next.edges = edges;
7983
+ else delete next.edges;
7984
+ this.commitLayers(mapLayer(this.state.layers, layer.id, (l) => ({ ...l, vectorMask: next })), void 0, commitToHistory);
7985
+ return this.getVectorMask(layer.id);
7986
+ }
7987
+ /** A layer's vector mask over the canvas as a selection mask, hybrid edges, feather and density included. */
7988
+ vectorMaskSelection(layer) {
7989
+ const { width, height } = this.naturalSize;
7990
+ const vm = layer.vectorMask;
7991
+ const v = vectorMaskCoverage(vm, width, height, vm.edges?.length ? layerGuide(layer, width, height, this.guideSurfaceOf) : null);
7992
+ const bytes = new Uint8Array(v.length);
7993
+ for (let i = 0; i < v.length; i++) bytes[i] = Math.round(v[i] * 255);
7994
+ return createSelectionMask(width, height, bytes);
7708
7995
  }
7709
7996
  /** Changes any layer mask's density, feather, enabled or linked flag (pixels kept). Returns the mask. */
7710
7997
  setLayerMaskProps(id, props, commitToHistory = true) {
@@ -8418,7 +8705,7 @@ var ImageEditorController = class {
8418
8705
  return this.getGlobalLight();
8419
8706
  }
8420
8707
  /** Composites `layers` (in isolation) over `rect` into a surface, loading depth maps first. */
8421
- async compositeToSurface(layers, rect2) {
8708
+ async compositeToSurface(layers, rect3) {
8422
8709
  const { width, height } = this.naturalSize;
8423
8710
  if (flattenLayers(layers).some((l) => l.type === "annotation" && l.visible) && !this.renderer.canRasterize()) {
8424
8711
  throw new LayerOperationError("Merging annotation layers needs a canvas (a browser); hide or delete them first, or merge in the browser.");
@@ -8427,20 +8714,20 @@ var ImageEditorController = class {
8427
8714
  const sources = this.renderer.compositorSources(width, height);
8428
8715
  const depth = this.bitDepth;
8429
8716
  if (depth !== 8) {
8430
- const samples = workingToSamples(compositeDeep(layers, rect2, sources, depth), depth);
8431
- return surfaceFromDeepPixels({ width: rect2.width, height: rect2.height, data: samples }, 4, rect2.x, rect2.y);
8717
+ const samples = workingToSamples(compositeDeep(layers, rect3, sources, depth), depth);
8718
+ return surfaceFromDeepPixels({ width: rect3.width, height: rect3.height, data: samples }, 4, rect3.x, rect3.y);
8432
8719
  }
8433
- const data = new LayerCompositor().composite(layers, rect2, sources);
8434
- return surfaceFromPixels({ width: rect2.width, height: rect2.height, data }, 4, rect2.x, rect2.y);
8720
+ const data = new LayerCompositor().composite(layers, rect3, sources);
8721
+ return surfaceFromPixels({ width: rect3.width, height: rect3.height, data }, 4, rect3.x, rect3.y);
8435
8722
  }
8436
8723
  /**
8437
8724
  * The composite of the layer stack at the document's bit depth (as `compositePixels`, no
8438
8725
  * canvas): 16-bit sRGB samples, or 32-bit linear float, over `rect` (default the canvas). At 8
8439
8726
  * bits, the 8-bit composite widened to 16 bits.
8440
8727
  */
8441
- compositeDeepPixels(rect2) {
8728
+ compositeDeepPixels(rect3) {
8442
8729
  const { width, height } = this.naturalSize;
8443
- const r = rect2 ?? { x: 0, y: 0, width, height };
8730
+ const r = rect3 ?? { x: 0, y: 0, width, height };
8444
8731
  const depth = this.bitDepth;
8445
8732
  if (depth === 8) return { width: r.width, height: r.height, depth: 16, data: to16From8(this.compositePixels(r).data) };
8446
8733
  const data = workingToSamples(compositeDeep(this.state.layers, r, this.compositorSources(), depth), depth);
@@ -8478,12 +8765,12 @@ var ImageEditorController = class {
8478
8765
  if (!upper.visible) throw new LayerOperationError(`Layer "${upper.name || id}" is hidden; show it or delete it instead of merging.`);
8479
8766
  requireUnlocked(upper, "all", "merge it");
8480
8767
  requireUnlocked(lower, "pixels", "merge into it");
8481
- let rect2 = unionRect(this.paintBounds(lower), upper.type === "adjustment" ? null : this.paintBounds(upper)) ?? { x: 0, y: 0, width: 1, height: 1 };
8768
+ let rect3 = unionRect(this.paintBounds(lower), upper.type === "adjustment" ? null : this.paintBounds(upper)) ?? { x: 0, y: 0, width: 1, height: 1 };
8482
8769
  const reach = Math.max(effectsMargin(lower.effects), upper.type === "adjustment" ? 0 : effectsMargin(upper.effects));
8483
- if (reach) rect2 = { x: rect2.x - reach, y: rect2.y - reach, width: rect2.width + 2 * reach, height: rect2.height + 2 * reach };
8770
+ if (reach) rect3 = { x: rect3.x - reach, y: rect3.y - reach, width: rect3.width + 2 * reach, height: rect3.height + 2 * reach };
8484
8771
  const base = { ...lower, blend: "normal", opacity: 1 };
8485
8772
  delete base.clipped;
8486
- const surface = await this.compositeToSurface([base, upper], rect2);
8773
+ const surface = await this.compositeToSurface([base, upper], rect3);
8487
8774
  const merged = { ...lower, surface, offset: { x: 0, y: 0 } };
8488
8775
  delete merged.mask;
8489
8776
  delete merged.fillOpacity;
@@ -8656,9 +8943,9 @@ var ImageEditorController = class {
8656
8943
  * Sets (Alt-click) or clears the Clone Stamp and Healing Brush source point, in document
8657
8944
  * pixels. The next stroke fixes the offset from its first point. View state, no undo.
8658
8945
  */
8659
- setCloneSource(point) {
8660
- if (point && (!Number.isFinite(point.x) || !Number.isFinite(point.y))) throw new LayerOperationError("The clone source needs finite x and y (document pixels).");
8661
- const source = point ? { x: Math.round(point.x), y: Math.round(point.y), offset: null } : null;
8946
+ setCloneSource(point2) {
8947
+ if (point2 && (!Number.isFinite(point2.x) || !Number.isFinite(point2.y))) throw new LayerOperationError("The clone source needs finite x and y (document pixels).");
8948
+ const source = point2 ? { x: Math.round(point2.x), y: Math.round(point2.y), offset: null } : null;
8662
8949
  this.state.paint = { ...this.state.paint, cloneSource: source };
8663
8950
  this.notify();
8664
8951
  return source;
@@ -9226,27 +9513,27 @@ var ImageEditorController = class {
9226
9513
  copyPixels(options = {}) {
9227
9514
  const { width, height } = this.naturalSize;
9228
9515
  const sel = this.state.selection?.bounds ? this.state.selection : null;
9229
- const rect2 = sel?.bounds ?? { x: 0, y: 0, width, height };
9516
+ const rect3 = sel?.bounds ?? { x: 0, y: 0, width, height };
9230
9517
  let data;
9231
9518
  if (options.merged) {
9232
- data = new Uint8ClampedArray(this.compositePixels(rect2).data);
9519
+ data = new Uint8ClampedArray(this.compositePixels(rect3).data);
9233
9520
  } else {
9234
9521
  const id = options.layerId ?? this.state.activeLayerId;
9235
9522
  if (!id) throw new LayerOperationError("No layer given and none is active; pass layerId, or merged: true to copy the visible image.");
9236
9523
  const layer = requireLayer(this.state.layers, id).layer;
9237
9524
  if (layer.type !== "pixel") throw new LayerOperationError(`Layer "${layer.name || id}" is not a pixel layer; copy merged, or rasterize it first.`);
9238
- data = readRegion(this.surfaceOf(layer), { x: rect2.x - layer.offset.x, y: rect2.y - layer.offset.y, width: rect2.width, height: rect2.height });
9525
+ data = readRegion(this.surfaceOf(layer), { x: rect3.x - layer.offset.x, y: rect3.y - layer.offset.y, width: rect3.width, height: rect3.height });
9239
9526
  }
9240
9527
  if (sel) {
9241
- for (let y = 0; y < rect2.height; y++) {
9242
- for (let x = 0; x < rect2.width; x++) {
9243
- const k = sel.data[(rect2.y + y) * sel.width + rect2.x + x];
9244
- const p = (y * rect2.width + x) * 4 + 3;
9528
+ for (let y = 0; y < rect3.height; y++) {
9529
+ for (let x = 0; x < rect3.width; x++) {
9530
+ const k = sel.data[(rect3.y + y) * sel.width + rect3.x + x];
9531
+ const p = (y * rect3.width + x) * 4 + 3;
9245
9532
  data[p] = Math.round(data[p] * k / 255);
9246
9533
  }
9247
9534
  }
9248
9535
  }
9249
- this.clipboard = { width: rect2.width, height: rect2.height, x: rect2.x, y: rect2.y, data };
9536
+ this.clipboard = { width: rect3.width, height: rect3.height, x: rect3.x, y: rect3.y, data };
9250
9537
  return this.clipboard;
9251
9538
  }
9252
9539
  /** Edit › Cut: Copy, then clear the selected pixels, as one undo step. */
@@ -9671,6 +9958,8 @@ var ImageEditorController = class {
9671
9958
  * Returns false when nothing changed.
9672
9959
  */
9673
9960
  setSelection(mask, mode = "replace") {
9961
+ const step = this.pendingStep;
9962
+ this.pendingStep = null;
9674
9963
  const { width, height } = this.naturalSize;
9675
9964
  let next = null;
9676
9965
  if (mask) {
@@ -9684,9 +9973,91 @@ var ImageEditorController = class {
9684
9973
  this.recordHistory();
9685
9974
  if (!next && prev?.bounds) this.lastSelection = prev;
9686
9975
  this.state.selection = next;
9976
+ this.state.selectionRecipe = step && next ? recipeAfter(this.state.selectionRecipe ?? null, prev, step) : null;
9977
+ this.notify();
9978
+ return true;
9979
+ }
9980
+ /** The recipe step of the selection command now running, taken by `setSelection`. */
9981
+ pendingStep = null;
9982
+ /** Runs a selection command whose change `step` describes, so the selection's recipe records it. */
9983
+ withRecipeStep(step, fn) {
9984
+ this.pendingStep = step;
9985
+ try {
9986
+ return fn();
9987
+ } finally {
9988
+ this.pendingStep = null;
9989
+ }
9990
+ }
9991
+ /** The selection's recipe as agents and panels read it (empty and not live when frozen or empty). */
9992
+ getSelectionRecipe() {
9993
+ const steps = this.state.selection?.bounds ? this.state.selectionRecipe ?? null : null;
9994
+ return { live: Boolean(steps), steps: describeRecipe(steps ?? []) };
9995
+ }
9996
+ requireRecipe(action) {
9997
+ const steps = this.state.selection?.bounds ? this.state.selectionRecipe : null;
9998
+ if (!steps) {
9999
+ throw new LayerOperationError(
10000
+ `The selection has no recipe to ${action}: it is frozen, or it was made or changed by a command recipes do not describe (quick, object, subject, sky and focus selections, Quick Mask painting, loaded masks and channels). Select again with marquees, polygons, the magic wand, Color Range, modify, invert or refine to start one.`
10001
+ );
10002
+ }
10003
+ return steps;
10004
+ }
10005
+ /**
10006
+ * Replaces the selection's recipe with `steps` (checked first) and the selection with what they
10007
+ * make, from nothing. One undo step; nothing changes on an error. Returns the recipe.
10008
+ */
10009
+ async setSelectionRecipe(steps) {
10010
+ if (!Array.isArray(steps) || !steps.length) throw new LayerOperationError("A recipe needs at least one step; use deselect to select nothing.");
10011
+ const parsed = steps.map((s, i) => readRecipeStep(s, `steps[${i}]`));
10012
+ const { width, height } = this.naturalSize;
10013
+ const images = /* @__PURE__ */ new Map();
10014
+ for (const step of parsed) {
10015
+ if (!stepNeedsPixels(step)) continue;
10016
+ const key2 = samplingKey(step);
10017
+ if (!images.has(key2)) images.set(key2, await this.requirePixels(step));
10018
+ }
10019
+ const result = replaySelectionRecipe(parsed, width, height, (step) => images.get(samplingKey(step)));
10020
+ this.recordHistory();
10021
+ if (!result && this.state.selection?.bounds) this.lastSelection = this.state.selection;
10022
+ this.state.selection = result;
10023
+ this.state.selectionRecipe = result ? parsed : null;
10024
+ this.notify();
10025
+ return this.getSelectionRecipe();
10026
+ }
10027
+ /** Changes step `index` of the selection's recipe (fields given replace its own; `op` replaces it whole) and replays. One undo step. */
10028
+ editSelectionStep(index, changes) {
10029
+ const steps = this.requireRecipe("edit");
10030
+ const i = this.recipeIndex(steps, index, false);
10031
+ const next = steps.map((s) => s);
10032
+ next[i] = changes.op !== void 0 && changes.op !== steps[i].op ? changes : { ...steps[i], ...changes };
10033
+ return this.setSelectionRecipe(next);
10034
+ }
10035
+ /** Inserts a step at `index` (0..length; length appends) and replays. One undo step. */
10036
+ insertSelectionStep(index, step) {
10037
+ const steps = this.requireRecipe("insert into");
10038
+ const i = this.recipeIndex(steps, index, true);
10039
+ return this.setSelectionRecipe([...steps.slice(0, i), step, ...steps.slice(i)]);
10040
+ }
10041
+ /** Removes step `index` and replays (the last step cannot go: deselect instead). One undo step. */
10042
+ removeSelectionStep(index) {
10043
+ const steps = this.requireRecipe("remove from");
10044
+ const i = this.recipeIndex(steps, index, false);
10045
+ if (steps.length === 1) throw new LayerOperationError("That is the recipe's only step; deselect to select nothing.");
10046
+ return this.setSelectionRecipe(steps.filter((_, k) => k !== i));
10047
+ }
10048
+ /** Drops the selection's recipe, keeping the selection as it is (a plain bitmap). One undo step. */
10049
+ freezeSelection() {
10050
+ if (!this.state.selectionRecipe) return false;
10051
+ this.recordHistory();
10052
+ this.state.selectionRecipe = null;
9687
10053
  this.notify();
9688
10054
  return true;
9689
10055
  }
10056
+ recipeIndex(steps, index, insert) {
10057
+ const max = insert ? steps.length : steps.length - 1;
10058
+ if (!Number.isInteger(index) || index < 0 || index > max) throw new LayerOperationError(`index must be 0..${max} (the recipe has ${steps.length} step${steps.length === 1 ? "" : "s"}; see get_selection_recipe), got ${String(index)}.`);
10059
+ return index;
10060
+ }
9690
10061
  /**
9691
10062
  * Shows `mask` in place of the selection while a gesture runs (Quick Selection as the brush
9692
10063
  * moves; `null` shows nothing selected), or with `undefined` stops. View state only: no undo
@@ -9701,7 +10072,7 @@ var ImageEditorController = class {
9701
10072
  /** Select › All. */
9702
10073
  selectAll() {
9703
10074
  const { width, height } = this.naturalSize;
9704
- return this.setSelection(fullSelection(width, height));
10075
+ return this.withRecipeStep({ op: "all" }, () => this.setSelection(fullSelection(width, height)));
9705
10076
  }
9706
10077
  /** Select › Deselect. */
9707
10078
  deselect() {
@@ -9730,8 +10101,8 @@ var ImageEditorController = class {
9730
10101
  if (!Number.isFinite(position) || at < 0 || at >= max) {
9731
10102
  throw new RangeError(`${orientation === "row" ? "y" : "x"} ${position} is outside the image (0..${max - 1}).`);
9732
10103
  }
9733
- const rect2 = orientation === "row" ? { x: 0, y: at, width, height: 1 } : { x: at, y: 0, width: 1, height };
9734
- return this.shapeCommand(rectSelection(width, height, rect2, { antiAlias: false }), options);
10104
+ const rect3 = orientation === "row" ? { x: 0, y: at, width, height: 1 } : { x: at, y: 0, width: 1, height };
10105
+ return this.shapeCommand(rectSelection(width, height, rect3, { antiAlias: false }), options);
9735
10106
  }
9736
10107
  /** Select › Grow: adds connected pixels within `tolerance` of a selected colour (one undo step). */
9737
10108
  async growSelection(options = {}) {
@@ -9779,7 +10150,7 @@ var ImageEditorController = class {
9779
10150
  invertSelection() {
9780
10151
  const { width, height } = this.naturalSize;
9781
10152
  const current = this.state.selection ?? createSelectionMask(width, height);
9782
- return this.setSelection(invertSelection(current));
10153
+ return this.withRecipeStep({ op: "invert" }, () => this.setSelection(invertSelection(current)));
9783
10154
  }
9784
10155
  shapeCommand(mask, options) {
9785
10156
  const feathered = options.feather && options.feather > 0 ? modifySelection(mask, "feather", options.feather) : mask;
@@ -9787,19 +10158,20 @@ var ImageEditorController = class {
9787
10158
  return this.setSelection(shaped, options.mode ?? "replace");
9788
10159
  }
9789
10160
  /** Rectangular marquee in natural image pixels. */
9790
- selectRect(rect2, options = {}) {
10161
+ selectRect(rect3, options = {}) {
9791
10162
  const { width, height } = this.naturalSize;
9792
- return this.shapeCommand(rectSelection(width, height, rect2, options), options);
10163
+ return this.withRecipeStep({ op: "rect", rect: { ...rect3 }, ...shapeStepFields(options) }, () => this.shapeCommand(rectSelection(width, height, rect3, options), options));
9793
10164
  }
9794
10165
  /** Elliptical marquee inscribed in `rect`. */
9795
- selectEllipse(rect2, options = {}) {
10166
+ selectEllipse(rect3, options = {}) {
9796
10167
  const { width, height } = this.naturalSize;
9797
- return this.shapeCommand(ellipseSelection(width, height, rect2, options), options);
10168
+ return this.withRecipeStep({ op: "ellipse", rect: { ...rect3 }, ...shapeStepFields(options) }, () => this.shapeCommand(ellipseSelection(width, height, rect3, options), options));
9798
10169
  }
9799
10170
  /** Lasso / polygonal lasso. */
9800
10171
  selectPolygon(points, options = {}) {
9801
10172
  const { width, height } = this.naturalSize;
9802
- return this.shapeCommand(polygonSelection(width, height, points, options), options);
10173
+ const step = { op: "polygon", points: points.map((p) => ({ x: p.x, y: p.y })), ...shapeStepFields(options) };
10174
+ return this.withRecipeStep(step, () => this.shapeCommand(polygonSelection(width, height, points, options), options));
9803
10175
  }
9804
10176
  /**
9805
10177
  * Magnetic Lasso for a given outline: each point snaps to the strongest edge within `width` px
@@ -10023,15 +10395,17 @@ var ImageEditorController = class {
10023
10395
  * Magic wand at `point` (natural image pixels). Resolves to the resulting selection (null when
10024
10396
  * nothing is selected).
10025
10397
  */
10026
- async magicWand(point, options = {}) {
10398
+ async magicWand(point2, options = {}) {
10027
10399
  const pixels = await this.requirePixels(options);
10028
- this.shapeCommand(magicWand(pixels, point, options), options);
10400
+ const step = { op: "magic-wand", point: { x: point2.x, y: point2.y }, options: stepOptions(options), ...combineFields(options), ...samplingFields(options) };
10401
+ this.withRecipeStep(step, () => this.shapeCommand(magicWand(pixels, point2, options), options));
10029
10402
  return this.getSelection();
10030
10403
  }
10031
10404
  /** Select › Color Range with colour samples (`#rrggbb` or `[r, g, b]`, optionally where they were picked). */
10032
10405
  async selectColorRange(samples, options = {}) {
10033
10406
  const pixels = await this.requirePixels(options);
10034
- this.shapeCommand(colorRange(pixels, samples, options), options);
10407
+ const step = { op: "color-range", samples: JSON.parse(JSON.stringify(samples)), options: stepOptions(options), ...combineFields(options), ...samplingFields(options) };
10408
+ this.withRecipeStep(step, () => this.shapeCommand(colorRange(pixels, samples, options), options));
10035
10409
  return this.getSelection();
10036
10410
  }
10037
10411
  /**
@@ -10040,14 +10414,15 @@ var ImageEditorController = class {
10040
10414
  */
10041
10415
  async selectColorRangePreset(preset, options = {}) {
10042
10416
  const pixels = await this.requirePixels(options);
10043
- this.shapeCommand(colorRangePreset(pixels, preset, options), options);
10417
+ const step = { op: "color-range-preset", preset, ...options.falloff !== void 0 ? { falloff: options.falloff } : {}, ...combineFields(options), ...samplingFields(options) };
10418
+ this.withRecipeStep(step, () => this.shapeCommand(colorRangePreset(pixels, preset, options), options));
10044
10419
  return this.getSelection();
10045
10420
  }
10046
10421
  /** Select › Modify: grow, shrink, feather, border or smooth by `radius` pixels (one undo step). */
10047
10422
  modifySelection(op, radius) {
10048
10423
  const sel = this.state.selection;
10049
10424
  if (!sel?.bounds) return false;
10050
- return this.setSelection(modifySelection(sel, op, radius));
10425
+ return this.withRecipeStep({ op: "modify", modify: op, radius }, () => this.setSelection(modifySelection(sel, op, radius)));
10051
10426
  }
10052
10427
  /**
10053
10428
  * Refine Edge: snaps the selection (e.g. a coarse AI mask loaded with `loadSelectionFromImage`)
@@ -10057,7 +10432,7 @@ var ImageEditorController = class {
10057
10432
  const sel = this.state.selection;
10058
10433
  if (!sel?.bounds) return false;
10059
10434
  const pixels = (params.radius ?? 0) > 0 ? await this.requirePixels(options) : null;
10060
- return this.setSelection(refineSelection(pixels, sel, params));
10435
+ return this.withRecipeStep({ op: "refine", params: { ...params }, ...samplingFields(options) }, () => this.setSelection(refineSelection(pixels, sel, params)));
10061
10436
  }
10062
10437
  /**
10063
10438
  * Select and Mask's output: refines the selection with `params`, then puts the result where
@@ -10127,7 +10502,9 @@ var ImageEditorController = class {
10127
10502
  return { output, layerId, refineMode, ...documentId ? { documentId } : {} };
10128
10503
  }
10129
10504
  return this.asOneStep(() => {
10130
- this.setSelection(refined);
10505
+ const plain3 = output === "selection" && !edits.length && !regions2 && refineMode === "color";
10506
+ if (plain3) this.withRecipeStep({ op: "refine", params: { ...params }, ...samplingFields(options) }, () => this.setSelection(refined));
10507
+ else this.setSelection(refined);
10131
10508
  if (output === "selection") return { output, refineMode };
10132
10509
  if (output === "layer-mask") {
10133
10510
  this.addLayerMask(layerId, "selection");
@@ -10546,9 +10923,8 @@ var ImageEditorController = class {
10546
10923
  mask = this.layerMaskCoverage(layer.id);
10547
10924
  if (!mask) throw new LayerOperationError(`Layer "${layer.name || layer.id}" has no painted mask; add one with add_layer_mask, or inspect its vector-mask.`);
10548
10925
  } else {
10549
- const { vm } = this.requireVectorMask(layer.id);
10550
- mask = pathToSelection(vm.components, width, height, { fillRule: vm.fillRule });
10551
- if (vm.inverted) mask = invertSelection(mask);
10926
+ this.requireVectorMask(layer.id);
10927
+ mask = this.vectorMaskSelection(layer);
10552
10928
  }
10553
10929
  if (!mask.bounds) throw new LayerOperationError(`The ${target} of "${layer.name || layer.id}" hides everything: there is no edge to inspect.`);
10554
10930
  } else {
@@ -10622,9 +10998,8 @@ var ImageEditorController = class {
10622
10998
  case "vector-mask": {
10623
10999
  const layer = this.getLayer(source.layerId);
10624
11000
  if (!layer) throw new LayerOperationError(`There is no layer "${source.layerId}" (see list_layers).`);
10625
- const { vm } = this.requireVectorMask(layer.id);
10626
- const mask = pathToSelection(vm.components, width, height, { fillRule: vm.fillRule });
10627
- return vm.inverted ? invertSelection(mask) : mask;
11001
+ this.requireVectorMask(layer.id);
11002
+ return this.vectorMaskSelection(layer);
10628
11003
  }
10629
11004
  default:
10630
11005
  throw new LayerOperationError(`Unknown selection source ${JSON.stringify(source)}. Use selection, history, channel, layer-mask or vector-mask.`);
@@ -11149,15 +11524,15 @@ var ImageEditorController = class {
11149
11524
  let mask;
11150
11525
  if (!b || x1 <= x0 || y1 <= y0) mask = createSelectionMask(width, height);
11151
11526
  else {
11152
- const rect2 = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
11153
- const rgba = readRegion(layer.surface, rect2);
11154
- const bytes = new Uint8Array(rect2.width * rect2.height);
11527
+ const rect3 = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
11528
+ const rgba = readRegion(layer.surface, rect3);
11529
+ const bytes = new Uint8Array(rect3.width * rect3.height);
11155
11530
  const hard = init.antiAlias === false;
11156
11531
  for (let i = 0; i < bytes.length; i++) {
11157
11532
  const a = rgba[i * 4 + 3];
11158
11533
  bytes[i] = hard ? a >= 128 ? 255 : 0 : a;
11159
11534
  }
11160
- mask = selectionFromRegion(width, height, rect2, bytes);
11535
+ mask = selectionFromRegion(width, height, rect3, bytes);
11161
11536
  }
11162
11537
  if ((init.feather ?? 0) > 0 && mask.bounds) mask = modifySelection(mask, "feather", init.feather);
11163
11538
  return this.setSelection(mask.bounds ? mask : null, init.mode ?? "replace");
@@ -11335,10 +11710,10 @@ var ImageEditorController = class {
11335
11710
  return shaper ? layoutText(layer, shaper) : null;
11336
11711
  }
11337
11712
  /** The text index nearest a document point (for clicks on a type layer). */
11338
- textIndexAt(id, point) {
11713
+ textIndexAt(id, point2) {
11339
11714
  const layer = requireTypeLayer(this.state.layers, id);
11340
11715
  const layout = this.getTextLayout(id);
11341
- return layout ? textIndexAt(layout, toTextSpace(layer, point)) : layer.text.length;
11716
+ return layout ? textIndexAt(layout, toTextSpace(layer, point2)) : layer.text.length;
11342
11717
  }
11343
11718
  /** The caret at text index `index` as a document-space segment (top to bottom), for drawing it. */
11344
11719
  textCaret(id, index) {
@@ -11516,8 +11891,8 @@ var ImageEditorController = class {
11516
11891
  delete content.mask;
11517
11892
  delete content.clipped;
11518
11893
  delete content.fillOpacity;
11519
- const rect2 = spatial ? canvas : this.getLayerBounds(id) ?? canvas;
11520
- const surface = await this.compositeToSurface([content], rect2);
11894
+ const rect3 = spatial ? canvas : this.getLayerBounds(id) ?? canvas;
11895
+ const surface = await this.compositeToSurface([content], rect3);
11521
11896
  next = { ...layer, surface, offset: { x: 0, y: 0 } };
11522
11897
  delete next.filters;
11523
11898
  delete next.source;
@@ -11905,6 +12280,12 @@ var ImageEditorController = class {
11905
12280
  if (layer.edge?.ambient) add2("The live edge cast (set_edge_ambient) has no PSD equivalent and was left out", layer.name);
11906
12281
  const p = await prepareLayer(layer);
11907
12282
  if (p?.edge) delete p.edge;
12283
+ if (p && isActiveVectorMask(layer.vectorMask) && layer.vectorMask.edges?.length) {
12284
+ add2("Hybrid vector masks (soft edge segments) were baked into pixel masks", layer.name);
12285
+ const baked = effectiveLayerMask(layer, width, height, this.guideSurfaceOf);
12286
+ if (baked) p.mask = baked;
12287
+ delete p.vectorMask;
12288
+ }
11908
12289
  if (p?.blend === "pigment") add2("The Pigment blend mode has no PSD equivalent and was written as Normal", layer.name);
11909
12290
  return p;
11910
12291
  };
@@ -11980,7 +12361,7 @@ var ImageEditorController = class {
11980
12361
  }
11981
12362
  if (layer.filters?.some((f) => !f.enabled)) add2("Disabled layer filters were left out", layer.name);
11982
12363
  delete out.filters;
11983
- const unmixMask = hasEdgeUnmix(layer.edge) ? effectiveLayerMask(layer, width, height) : void 0;
12364
+ const unmixMask = hasEdgeUnmix(layer.edge) ? effectiveLayerMask(layer, width, height, this.guideSurfaceOf) : void 0;
11984
12365
  if (unmixMask && !unmixMask.source && unmixMask.enabled !== false) {
11985
12366
  add2("Edge decontamination and despill were baked into the pixels", layer.name);
11986
12367
  const { x: ox, y: oy } = layer.offset;
@@ -12089,6 +12470,7 @@ var ImageEditorController = class {
12089
12470
  try {
12090
12471
  out = this.serializeState();
12091
12472
  if (this.state.selection?.bounds) out.selection = serializeSelection(this.state.selection);
12473
+ if (this.state.selection?.bounds && this.state.selectionRecipe) out.selectionRecipe = JSON.parse(JSON.stringify(this.state.selectionRecipe));
12092
12474
  if (this.state.channels?.length) out.channels = serializeChannels(this.state.channels);
12093
12475
  if (this.state.paths?.length) out.paths = serializePaths(this.state.paths);
12094
12476
  } finally {
@@ -12130,6 +12512,7 @@ var ImageEditorController = class {
12130
12512
  }
12131
12513
  }
12132
12514
  this.state.selection = selection;
12515
+ this.state.selectionRecipe = selection ? readStoredRecipe(data.selectionRecipe) : null;
12133
12516
  this.dropMismatchedSelection();
12134
12517
  const problems = [];
12135
12518
  const channels = deserializeChannels(data.channels, this.naturalSize, problems);
@@ -13360,10 +13743,10 @@ function sampleOptions(args) {
13360
13743
  return { sampleMerged: args.sampleMerged === true, sample: args.sample ?? "composite" };
13361
13744
  }
13362
13745
  function rectOf(args) {
13363
- const rect2 = { x: num2(args.x, NaN), y: num2(args.y, NaN), width: num2(args.width, NaN), height: num2(args.height, NaN) };
13364
- if (![rect2.x, rect2.y, rect2.width, rect2.height].every(Number.isFinite)) throw new Error("Pass numeric x, y, width and height in natural image pixels.");
13365
- if (rect2.width <= 0 || rect2.height <= 0) throw new Error("width and height must be greater than 0.");
13366
- return rect2;
13746
+ const rect3 = { x: num2(args.x, NaN), y: num2(args.y, NaN), width: num2(args.width, NaN), height: num2(args.height, NaN) };
13747
+ if (![rect3.x, rect3.y, rect3.width, rect3.height].every(Number.isFinite)) throw new Error("Pass numeric x, y, width and height in natural image pixels.");
13748
+ if (rect3.width <= 0 || rect3.height <= 0) throw new Error("width and height must be greater than 0.");
13749
+ return rect3;
13367
13750
  }
13368
13751
  function uncertainty(controller) {
13369
13752
  const u = controller.selectionUncertainty();
@@ -13477,6 +13860,83 @@ function createImageSelectionTools(controller) {
13477
13860
  show: show !== false
13478
13861
  })
13479
13862
  };
13863
+ const stepProperties = {
13864
+ op: { type: "string", enum: ["rect", "ellipse", "polygon", "magic-wand", "color-range", "color-range-preset", "all", "invert", "modify", "refine"], description: "The step: a marquee (rect / ellipse with rect), polygon (points), magic-wand (point, options), color-range (samples, options), color-range-preset (preset, falloff), all, invert, modify (modify, radius) or refine (params)." },
13865
+ rect: { ...rectSchemaStrict, description: "rect and ellipse: the box in natural image pixels." },
13866
+ points: { type: "array", minItems: 3, items: pointSchema, description: "polygon: the outline, natural image pixels." },
13867
+ point: { ...pointSchema, description: "magic-wand: where to click." },
13868
+ samples: { type: "array", minItems: 1, items: {}, description: "color-range: colour samples, as select_color_range takes them." },
13869
+ options: { type: "object", description: "magic-wand and color-range: their own options (tolerance, contiguous, fuzziness, \u2026) as those tools take them." },
13870
+ preset: { type: "string", description: "color-range-preset: the preset (reds \u2026 magentas, highlights, midtones, shadows, skinTones)." },
13871
+ falloff: { type: "number", minimum: 0, maximum: 255, description: "color-range-preset (tones): falloff in levels (default 20)." },
13872
+ modify: { type: "string", enum: [...SELECTION_MODIFY_OPS], description: "modify: expand, contract, feather, border or smooth (grow and shrink are older names)." },
13873
+ radius: { type: "number", minimum: 0, maximum: 500, description: "modify: radius in pixels." },
13874
+ params: { type: "object", description: "refine: radius, smartRadius, smooth, feather, contrast, shiftEdge as refine_selection takes them." },
13875
+ mode: modeSchema,
13876
+ feather: featherSchema,
13877
+ invert: { type: "boolean", description: "Shape steps: invert the new shape before combining (default false)." },
13878
+ antiAlias: antiAliasSchema,
13879
+ sample: sampleSchema,
13880
+ sampleMerged: sampleMergedSchema
13881
+ };
13882
+ const RECIPE_RETURNS = "Returns the selection summary and the recipe { live, steps (each with its index) }.";
13883
+ const withRecipe = async (p) => {
13884
+ await p;
13885
+ return { ...summary(controller), recipe: controller.getSelectionRecipe() };
13886
+ };
13887
+ const getRecipeTool = {
13888
+ name: "get_selection_recipe",
13889
+ title: "Read the selection recipe",
13890
+ description: `Live selections: the steps that made the current selection (marquees, polygons, magic wand, Color Range, modify, invert, refine), so one can be changed afterwards with edit_selection_step, insert_selection_step or remove_selection_step instead of selecting again. live false means it is frozen (made or changed by a command a recipe cannot describe, e.g. quick_select, select_subject, Quick Mask painting). ${RECIPE_RETURNS}`,
13891
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13892
+ annotations: { title: "Read the selection recipe", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
13893
+ handler: () => ({ ...summary(controller), recipe: controller.getSelectionRecipe() })
13894
+ };
13895
+ const editStepTool = {
13896
+ name: "edit_selection_step",
13897
+ title: "Edit a selection recipe step",
13898
+ description: `Changes one step of the selection's recipe (index from get_selection_recipe) and replays the recipe: the fields given replace the step's own (e.g. tolerance in options, the rect, the modify radius, mode); giving a different op replaces the step whole. One undo step; nothing changes on an error. ${RECIPE_RETURNS}`,
13899
+ inputSchema: {
13900
+ type: "object",
13901
+ properties: { index: { type: "integer", minimum: 0, description: "The step to change (see get_selection_recipe)." }, changes: { type: "object", properties: stepProperties, additionalProperties: false, description: "The step fields to set." } },
13902
+ required: ["index", "changes"],
13903
+ additionalProperties: false
13904
+ },
13905
+ annotations: writeAnnotations("Edit a selection recipe step"),
13906
+ handler: ({ index, changes }) => withRecipe(controller.editSelectionStep(index, changes))
13907
+ };
13908
+ const insertStepTool = {
13909
+ name: "insert_selection_step",
13910
+ title: "Insert a selection recipe step",
13911
+ description: `Inserts a step into the selection's recipe at index (its length appends) and replays it, e.g. a subtract rectangle before a refine, or a modify smooth after a magic wand. One undo step; nothing changes on an error. ${RECIPE_RETURNS}`,
13912
+ inputSchema: {
13913
+ type: "object",
13914
+ properties: { index: { type: "integer", minimum: 0, description: "Where to insert (0..number of steps)." }, step: { type: "object", properties: stepProperties, required: ["op"], additionalProperties: false, description: "The step to insert." } },
13915
+ required: ["index", "step"],
13916
+ additionalProperties: false
13917
+ },
13918
+ annotations: writeAnnotations("Insert a selection recipe step"),
13919
+ handler: ({ index, step }) => withRecipe(controller.insertSelectionStep(index, step))
13920
+ };
13921
+ const removeStepTool = {
13922
+ name: "remove_selection_step",
13923
+ title: "Remove a selection recipe step",
13924
+ description: `Removes one step of the selection's recipe and replays the rest (the only step cannot go: deselect instead). One undo step. ${RECIPE_RETURNS}`,
13925
+ inputSchema: { type: "object", properties: { index: { type: "integer", minimum: 0, description: "The step to remove (see get_selection_recipe)." } }, required: ["index"], additionalProperties: false },
13926
+ annotations: writeAnnotations("Remove a selection recipe step"),
13927
+ handler: ({ index }) => withRecipe(controller.removeSelectionStep(index))
13928
+ };
13929
+ const freezeTool = {
13930
+ name: "freeze_selection",
13931
+ title: "Freeze the selection",
13932
+ description: `Drops the selection's recipe and keeps the selection exactly as it is (a plain bitmap), so later edits start from it. One undo step; a frozen or empty selection is unchanged. ${RECIPE_RETURNS}`,
13933
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13934
+ annotations: { title: "Freeze the selection", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
13935
+ handler: () => {
13936
+ controller.freezeSelection();
13937
+ return { ...summary(controller), recipe: controller.getSelectionRecipe() };
13938
+ }
13939
+ };
13480
13940
  const selectRegion = {
13481
13941
  name: "select_region",
13482
13942
  title: "Select a region",
@@ -13521,12 +13981,12 @@ function createImageSelectionTools(controller) {
13521
13981
  break;
13522
13982
  case "rect":
13523
13983
  case "ellipse": {
13524
- const rect2 = { x: num2(x, NaN), y: num2(y, NaN), width: num2(width, NaN), height: num2(height, NaN) };
13525
- if (![rect2.x, rect2.y, rect2.width, rect2.height].every(Number.isFinite)) {
13984
+ const rect3 = { x: num2(x, NaN), y: num2(y, NaN), width: num2(width, NaN), height: num2(height, NaN) };
13985
+ if (![rect3.x, rect3.y, rect3.width, rect3.height].every(Number.isFinite)) {
13526
13986
  throw new Error("rect and ellipse need numeric x, y, width and height.");
13527
13987
  }
13528
- if (shape === "rect") controller.selectRect(rect2, opts);
13529
- else controller.selectEllipse(rect2, opts);
13988
+ if (shape === "rect") controller.selectRect(rect3, opts);
13989
+ else controller.selectEllipse(rect3, opts);
13530
13990
  break;
13531
13991
  }
13532
13992
  case "polygon": {
@@ -13905,10 +14365,10 @@ function createImageSelectionTools(controller) {
13905
14365
  },
13906
14366
  annotations: { ...writeAnnotations("Object Selection"), openWorldHint: true },
13907
14367
  handler: async (args, context) => {
13908
- const rect2 = args.rect;
13909
- const point = args.point;
13910
- if (!rect2 && !point) throw new Error("Pass rect (a box around the object) or point (a point on it).");
13911
- if (rect2 && point) throw new Error("Pass rect or point, not both.");
14368
+ const rect3 = args.rect;
14369
+ const point2 = args.point;
14370
+ if (!rect3 && !point2) throw new Error("Pass rect (a box around the object) or point (a point on it).");
14371
+ if (rect3 && point2) throw new Error("Pass rect or point, not both.");
13912
14372
  const options = {
13913
14373
  mode: parseSelectionMode(args.mode),
13914
14374
  sampleAllLayers: args.sampleAllLayers,
@@ -13916,7 +14376,7 @@ function createImageSelectionTools(controller) {
13916
14376
  useModel: args.useModel,
13917
14377
  signal: context?.signal
13918
14378
  };
13919
- const { method } = rect2 ? await controller.selectObject({ rect: { x: num2(rect2.x, NaN), y: num2(rect2.y, NaN), width: num2(rect2.width, NaN), height: num2(rect2.height, NaN) } }, options) : await controller.selectObject({ point: { x: num2(point.x, NaN), y: num2(point.y, NaN) } }, options);
14379
+ const { method } = rect3 ? await controller.selectObject({ rect: { x: num2(rect3.x, NaN), y: num2(rect3.y, NaN), width: num2(rect3.width, NaN), height: num2(rect3.height, NaN) } }, options) : await controller.selectObject({ point: { x: num2(point2.x, NaN), y: num2(point2.y, NaN) } }, options);
13920
14380
  return { ...summary(controller), method, ...uncertainty(controller) };
13921
14381
  }
13922
14382
  };
@@ -14555,6 +15015,11 @@ function createImageSelectionTools(controller) {
14555
15015
  getSelection,
14556
15016
  inspectEdge,
14557
15017
  compareTool,
15018
+ getRecipeTool,
15019
+ editStepTool,
15020
+ insertStepTool,
15021
+ removeStepTool,
15022
+ freezeTool,
14558
15023
  selectAll,
14559
15024
  deselect,
14560
15025
  invert2,
@@ -14694,7 +15159,7 @@ async function renderImageRegion(controller, request) {
14694
15159
  out.height = request.height;
14695
15160
  const context = out.getContext("2d");
14696
15161
  if (!context) throw new Error(NO_CANVAS);
14697
- const { rect: rect2 } = request;
15162
+ const { rect: rect3 } = request;
14698
15163
  context.imageSmoothingEnabled = request.scale < 1;
14699
15164
  if (request.format === "jpeg") {
14700
15165
  context.fillStyle = "#ffffff";
@@ -14702,22 +15167,22 @@ async function renderImageRegion(controller, request) {
14702
15167
  }
14703
15168
  const kx = view.width / state.imageDimensions.width;
14704
15169
  const ky = view.height / state.imageDimensions.height;
14705
- context.drawImage(view, rect2.x * kx, rect2.y * ky, rect2.width * kx, rect2.height * ky, 0, 0, out.width, out.height);
15170
+ context.drawImage(view, rect3.x * kx, rect3.y * ky, rect3.width * kx, rect3.height * ky, 0, 0, out.width, out.height);
14706
15171
  if (request.includeOverlays) {
14707
15172
  const s = request.scale;
14708
15173
  const maskView = maskViewCanvas(controller, document);
14709
- if (maskView) context.drawImage(maskView, rect2.x, rect2.y, rect2.width, rect2.height, 0, 0, out.width, out.height);
15174
+ if (maskView) context.drawImage(maskView, rect3.x, rect3.y, rect3.width, rect3.height, 0, 0, out.width, out.height);
14710
15175
  const selection = state.selection;
14711
15176
  if (selection?.bounds && selection.width === state.imageDimensions.width && selection.height === state.imageDimensions.height) {
14712
15177
  const shade = selectionShade(selection, document);
14713
- if (shade) context.drawImage(shade, rect2.x, rect2.y, rect2.width, rect2.height, 0, 0, out.width, out.height);
15178
+ if (shade) context.drawImage(shade, rect3.x, rect3.y, rect3.width, rect3.height, 0, 0, out.width, out.height);
14714
15179
  }
14715
15180
  if (state.crop) {
14716
15181
  context.save();
14717
15182
  context.strokeStyle = "#ffd400";
14718
15183
  context.lineWidth = 2;
14719
15184
  context.setLineDash([6, 4]);
14720
- context.strokeRect((state.crop.x - rect2.x) * s, (state.crop.y - rect2.y) * s, state.crop.width * s, state.crop.height * s);
15185
+ context.strokeRect((state.crop.x - rect3.x) * s, (state.crop.y - rect3.y) * s, state.crop.width * s, state.crop.height * s);
14721
15186
  context.restore();
14722
15187
  }
14723
15188
  }
@@ -15974,11 +16439,11 @@ function createImageEffectTools(controller) {
15974
16439
  }
15975
16440
 
15976
16441
  // src/image-editor/selection/gesture.ts
15977
- function snapTo45(from, point) {
15978
- const dx = point.x - from.x;
15979
- const dy = point.y - from.y;
16442
+ function snapTo45(from, point2) {
16443
+ const dx = point2.x - from.x;
16444
+ const dy = point2.y - from.y;
15980
16445
  const len = Math.hypot(dx, dy);
15981
- if (len === 0) return point;
16446
+ if (len === 0) return point2;
15982
16447
  const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
15983
16448
  return { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len };
15984
16449
  }
@@ -16066,38 +16531,38 @@ var SelectionGesture = class {
16066
16531
  const { tool } = this.getOptions();
16067
16532
  if (tool === "quick") return null;
16068
16533
  if (tool === "lasso") return this.points.length > 1 ? this.points.slice() : null;
16069
- const rect2 = normalizeRect({
16534
+ const rect3 = normalizeRect({
16070
16535
  x: this.start.x,
16071
16536
  y: this.start.y,
16072
16537
  width: this.current.x - this.start.x,
16073
16538
  height: this.current.y - this.start.y
16074
16539
  });
16075
- if (rect2.width < 1 && rect2.height < 1) return null;
16076
- if (tool === "ellipse") return ellipsePoints(rect2);
16540
+ if (rect3.width < 1 && rect3.height < 1) return null;
16541
+ if (tool === "ellipse") return ellipsePoints(rect3);
16077
16542
  return [
16078
- { x: rect2.x, y: rect2.y },
16079
- { x: rect2.x + rect2.width, y: rect2.y },
16080
- { x: rect2.x + rect2.width, y: rect2.y + rect2.height },
16081
- { x: rect2.x, y: rect2.y + rect2.height }
16543
+ { x: rect3.x, y: rect3.y },
16544
+ { x: rect3.x + rect3.width, y: rect3.y },
16545
+ { x: rect3.x + rect3.width, y: rect3.y + rect3.height },
16546
+ { x: rect3.x, y: rect3.y + rect3.height }
16082
16547
  ];
16083
16548
  }
16084
16549
  /** Pointer down. Resolves once a wand click has been applied. */
16085
- async begin(point, modifiers = {}) {
16550
+ async begin(point2, modifiers = {}) {
16086
16551
  const options = this.getOptions();
16087
16552
  this.error = null;
16088
16553
  if (this.polygon && options.tool !== "polygon" || this.magnetic && options.tool !== "magnetic") this.cancel();
16089
- if (options.tool === "magnetic") return this.magneticClick(point, modifiers, options);
16554
+ if (options.tool === "magnetic") return this.magneticClick(point2, modifiers, options);
16090
16555
  if (options.tool === "polygon") {
16091
- this.pressedAt = point;
16556
+ this.pressedAt = point2;
16092
16557
  this.freehand = false;
16093
16558
  if (!this.polygon) {
16094
16559
  this.modifiers = modifiers;
16095
- this.polygon = [point];
16096
- this.current = point;
16560
+ this.polygon = [point2];
16561
+ this.current = point2;
16097
16562
  return;
16098
16563
  }
16099
16564
  const last = this.polygon[this.polygon.length - 1];
16100
- const p = modifiers.shiftKey ? snapTo45(last, point) : point;
16565
+ const p = modifiers.shiftKey ? snapTo45(last, point2) : point2;
16101
16566
  const first = this.polygon[0];
16102
16567
  if (this.polygon.length >= 3 && Math.hypot(p.x - first.x, p.y - first.y) <= this.closeRadius) {
16103
16568
  this.close();
@@ -16112,7 +16577,7 @@ var SelectionGesture = class {
16112
16577
  if (options.tool === "row" || options.tool === "column") {
16113
16578
  this.start = null;
16114
16579
  try {
16115
- this.controller.selectLine(options.tool, options.tool === "row" ? point.y : point.x, {
16580
+ this.controller.selectLine(options.tool, options.tool === "row" ? point2.y : point2.x, {
16116
16581
  mode: selectionModeFromModifiers(modifiers, options.mode),
16117
16582
  feather: options.feather ?? 0
16118
16583
  });
@@ -16126,8 +16591,8 @@ var SelectionGesture = class {
16126
16591
  try {
16127
16592
  this.controller.selectTextShape({
16128
16593
  text: options.typeText || "Type",
16129
- x: point.x,
16130
- y: point.y,
16594
+ x: point2.x,
16595
+ y: point2.y,
16131
16596
  orientation: options.tool === "type-vertical" ? "vertical" : "horizontal",
16132
16597
  style: { fontSize: options.typeSize ?? 72, fontFamily: options.typeFamily || "sans-serif" },
16133
16598
  mode: selectionModeFromModifiers(modifiers, options.mode),
@@ -16151,11 +16616,11 @@ var SelectionGesture = class {
16151
16616
  if (options.tool === "range") {
16152
16617
  const pixels = await this.controller.getSamplePixels(common);
16153
16618
  if (!pixels) throw new Error("The image pixels are not available to sample.");
16154
- const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point.x)));
16155
- const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point.y)));
16619
+ const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point2.x)));
16620
+ const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point2.y)));
16156
16621
  const p = (y * pixels.width + x) * 4;
16157
16622
  const color = [pixels.data[p], pixels.data[p + 1], pixels.data[p + 2]];
16158
- await this.controller.selectColorRange([{ color, at: point }], {
16623
+ await this.controller.selectColorRange([{ color, at: point2 }], {
16159
16624
  ...common,
16160
16625
  fuzziness: options.fuzziness ?? 40,
16161
16626
  localized: options.localized ? options.localized : void 0,
@@ -16163,7 +16628,7 @@ var SelectionGesture = class {
16163
16628
  });
16164
16629
  return;
16165
16630
  }
16166
- await this.controller.magicWand(point, {
16631
+ await this.controller.magicWand(point2, {
16167
16632
  ...common,
16168
16633
  tolerance: options.tolerance,
16169
16634
  contiguous: options.contiguous,
@@ -16175,9 +16640,9 @@ var SelectionGesture = class {
16175
16640
  }
16176
16641
  return;
16177
16642
  }
16178
- this.start = point;
16179
- this.current = point;
16180
- this.points = [point];
16643
+ this.start = point2;
16644
+ this.current = point2;
16645
+ this.points = [point2];
16181
16646
  if (options.tool === "quick") {
16182
16647
  const picked = selectionModeFromModifiers(modifiers, options.mode);
16183
16648
  const base = this.controller.getSelection();
@@ -16192,53 +16657,53 @@ var SelectionGesture = class {
16192
16657
  }
16193
16658
  }
16194
16659
  /** Pointer move; returns true when the draft changed (re-render). */
16195
- move(point, modifiers = {}) {
16660
+ move(point2, modifiers = {}) {
16196
16661
  if (this.magnetic) {
16197
- this.current = point;
16198
- this.followEdges(point);
16662
+ this.current = point2;
16663
+ this.followEdges(point2);
16199
16664
  return true;
16200
16665
  }
16201
16666
  if (this.polygon) {
16202
16667
  const last = this.polygon[this.polygon.length - 1];
16203
- if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
16668
+ if (this.pressedAt && (this.freehand || Math.hypot(point2.x - this.pressedAt.x, point2.y - this.pressedAt.y) >= 3)) {
16204
16669
  this.freehand = true;
16205
- if (Math.hypot(point.x - last.x, point.y - last.y) >= 0.5) this.polygon.push(point);
16670
+ if (Math.hypot(point2.x - last.x, point2.y - last.y) >= 0.5) this.polygon.push(point2);
16206
16671
  this.current = null;
16207
16672
  return true;
16208
16673
  }
16209
- this.current = modifiers.shiftKey ? snapTo45(last, point) : point;
16674
+ this.current = modifiers.shiftKey ? snapTo45(last, point2) : point2;
16210
16675
  return true;
16211
16676
  }
16212
16677
  if (!this.start) return false;
16213
- this.current = point;
16678
+ this.current = point2;
16214
16679
  const drawn = this.getOptions().tool;
16215
16680
  if (drawn === "lasso" || drawn === "quick") {
16216
16681
  const last = this.points[this.points.length - 1];
16217
- if (Math.hypot(point.x - last.x, point.y - last.y) < 0.5) return false;
16218
- this.points.push(point);
16682
+ if (Math.hypot(point2.x - last.x, point2.y - last.y) < 0.5) return false;
16683
+ this.points.push(point2);
16219
16684
  if (drawn === "quick") this.growPreview();
16220
16685
  }
16221
16686
  return true;
16222
16687
  }
16223
16688
  /** Pointer up: commits the shape. Returns true when the selection changed. */
16224
- end(point, modifiers) {
16689
+ end(point2, modifiers) {
16225
16690
  if (this.magnetic) return false;
16226
16691
  if (this.polygon) {
16227
- if (point && this.freehand) this.move(point);
16692
+ if (point2 && this.freehand) this.move(point2);
16228
16693
  this.pressedAt = null;
16229
16694
  this.freehand = false;
16230
16695
  return false;
16231
16696
  }
16232
16697
  if (!this.start) return false;
16233
- if (point) this.move(point);
16698
+ if (point2) this.move(point2);
16234
16699
  const options = this.getOptions();
16235
16700
  const mode = selectionModeFromModifiers(modifiers ?? this.modifiers, options.mode);
16236
16701
  if (options.tool === "object") {
16237
- const rect3 = normalizeRect({ x: this.start.x, y: this.start.y, width: this.current.x - this.start.x, height: this.current.y - this.start.y });
16702
+ const rect4 = normalizeRect({ x: this.start.x, y: this.start.y, width: this.current.x - this.start.x, height: this.current.y - this.start.y });
16238
16703
  this.start = null;
16239
16704
  this.current = null;
16240
16705
  this.points = [];
16241
- const target = rect3.width >= 4 && rect3.height >= 4 ? { rect: rect3 } : { point: { x: rect3.x + rect3.width / 2, y: rect3.y + rect3.height / 2 } };
16706
+ const target = rect4.width >= 4 && rect4.height >= 4 ? { rect: rect4 } : { point: { x: rect4.x + rect4.width / 2, y: rect4.y + rect4.height / 2 } };
16242
16707
  this.pending = this.controller.selectObject(target, { mode, sampleAllLayers: options.objectSampleAll ?? true, enhanceEdge: options.objectEnhance ?? false }).then(
16243
16708
  () => void 0,
16244
16709
  (err) => {
@@ -16268,7 +16733,7 @@ var SelectionGesture = class {
16268
16733
  return false;
16269
16734
  }
16270
16735
  const path = this.draftPath;
16271
- const rect2 = normalizeRect({
16736
+ const rect3 = normalizeRect({
16272
16737
  x: this.start.x,
16273
16738
  y: this.start.y,
16274
16739
  width: this.current.x - this.start.x,
@@ -16280,7 +16745,7 @@ var SelectionGesture = class {
16280
16745
  if (!path) return mode === "replace" ? this.controller.deselect() : false;
16281
16746
  const opts = { mode, antiAlias: options.antiAlias, feather: options.feather ?? 0 };
16282
16747
  if (options.tool === "lasso") return path.length >= 3 ? this.controller.selectPolygon(path, opts) : false;
16283
- return options.tool === "ellipse" ? this.controller.selectEllipse(rect2, opts) : this.controller.selectRect(rect2, opts);
16748
+ return options.tool === "ellipse" ? this.controller.selectEllipse(rect3, opts) : this.controller.selectRect(rect3, opts);
16284
16749
  }
16285
16750
  /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
16286
16751
  close() {
@@ -16329,19 +16794,19 @@ var SelectionGesture = class {
16329
16794
  corridor(a, b) {
16330
16795
  return Math.max(2 * (this.getOptions().magneticWidth ?? 10), 16, Math.hypot(b.x - a.x, b.y - a.y) / 4);
16331
16796
  }
16332
- async magneticClick(point, modifiers, options) {
16797
+ async magneticClick(point2, modifiers, options) {
16333
16798
  const m = this.magnetic;
16334
16799
  if (!m) {
16335
16800
  this.modifiers = modifiers;
16336
- const draft = { fixed: [point], anchors: [0], live: [], edges: null };
16801
+ const draft = { fixed: [point2], anchors: [0], live: [], edges: null };
16337
16802
  this.magnetic = draft;
16338
- this.current = point;
16803
+ this.current = point2;
16339
16804
  try {
16340
16805
  const pixels = await this.controller.getSamplePixels({ sampleMerged: options.sampleMerged, sample: options.sample });
16341
16806
  if (!pixels) throw new Error("The image pixels are not available to find edges in.");
16342
16807
  if (this.magnetic !== draft) return;
16343
16808
  draft.edges = new EdgeMap(pixels, { contrast: (options.magneticContrast ?? 10) / 100 });
16344
- draft.fixed = [snapToEdge(draft.edges, point, options.magneticWidth ?? 10)];
16809
+ draft.fixed = [snapToEdge(draft.edges, point2, options.magneticWidth ?? 10)];
16345
16810
  if (this.current) this.followEdges(this.current);
16346
16811
  } catch (err) {
16347
16812
  this.error = err instanceof Error ? err.message : String(err);
@@ -16350,12 +16815,12 @@ var SelectionGesture = class {
16350
16815
  return;
16351
16816
  }
16352
16817
  const first = m.fixed[0];
16353
- if (m.anchors.length >= 2 && Math.hypot(point.x - first.x, point.y - first.y) <= this.closeRadius) {
16818
+ if (m.anchors.length >= 2 && Math.hypot(point2.x - first.x, point2.y - first.y) <= this.closeRadius) {
16354
16819
  this.close();
16355
16820
  return;
16356
16821
  }
16357
- this.current = point;
16358
- this.followEdges(point);
16822
+ this.current = point2;
16823
+ this.followEdges(point2);
16359
16824
  if (m.live.length > 1) {
16360
16825
  m.fixed.push(...m.live.slice(1));
16361
16826
  m.anchors.push(m.fixed.length - 1);
@@ -16364,15 +16829,15 @@ var SelectionGesture = class {
16364
16829
  if ((modifiers.detail ?? 1) >= 2) this.close();
16365
16830
  }
16366
16831
  /** Updates the live path to `point`, fixing anchors along it every `frequency` px (with `place`). */
16367
- followEdges(point, place = true) {
16832
+ followEdges(point2, place = true) {
16368
16833
  const m = this.magnetic;
16369
16834
  const last = () => m.fixed[m.fixed.length - 1];
16370
16835
  if (!m.edges) {
16371
- m.live = [last(), point];
16836
+ m.live = [last(), point2];
16372
16837
  return;
16373
16838
  }
16374
16839
  const options = this.getOptions();
16375
- const target = snapToEdge(m.edges, point, options.magneticWidth ?? 10);
16840
+ const target = snapToEdge(m.edges, point2, options.magneticWidth ?? 10);
16376
16841
  const spacing = magneticAnchorSpacing(options.magneticFrequency ?? 57);
16377
16842
  for (let guard = 0; guard < 64; guard++) {
16378
16843
  const path = liveWire(m.edges, last(), target, this.corridor(last(), target));
@@ -18645,6 +19110,37 @@ function createImageShapeTools(controller) {
18645
19110
  return { vectorMask: controller.getVectorMask(id) };
18646
19111
  }
18647
19112
  };
19113
+ const setVectorMaskEdge = {
19114
+ name: "set_vector_mask_edge",
19115
+ title: "Set a vector mask segment's edge",
19116
+ description: "Hybrid mask: gives one segment of a layer's vector mask its own edge, so one mask holds sharp edges and hair or fur. hard (default) is the path's sharp edge; feather blurs across it by radius px; matte solves a corridor from inner px inside the path to outer px outside it from the layer's colours (the colour-guided filter), so strands keep their detail. Modes blend where they meet. Address the segment with component, subpath and segment (the segment from anchor segment to the next; see get_vector_mask segmentCount and components). Check the result with inspect_mask_edge target vector-mask. One undo step. Returns the vector mask with its edges.",
19117
+ inputSchema: {
19118
+ type: "object",
19119
+ properties: {
19120
+ layerId: LAYER,
19121
+ component: { type: "integer", minimum: 0, description: "Component index of the path (0 for a single shape)." },
19122
+ subpath: { type: "integer", minimum: 0, description: "Subpath index in the component (0 for a single outline)." },
19123
+ segment: { type: "integer", minimum: 0, description: "Segment index in the subpath: from anchor segment to the next (the last closes back to anchor 0)." },
19124
+ mode: { type: "string", enum: ["hard", "feather", "matte"], description: "hard (sharp, the default), feather (a soft falloff) or matte (a solved corridor for hair and fur)." },
19125
+ radius: { type: "number", minimum: 0.5, maximum: 250, description: "feather only: Gaussian falloff in px across the curve (default 4)." },
19126
+ inner: { type: "number", minimum: 0.5, maximum: 200, description: "matte only: how far inside the path the unsure corridor reaches, in px (default 4)." },
19127
+ outer: { type: "number", minimum: 0.5, maximum: 200, description: "matte only: how far outside the path the unsure corridor reaches, in px (default 8; cover the longest strands)." }
19128
+ },
19129
+ required: ["layerId", "component", "subpath", "segment", "mode"],
19130
+ additionalProperties: false
19131
+ },
19132
+ annotations: { title: "Set a vector mask segment's edge", ...WRITE_IDEMPOTENT5 },
19133
+ handler: ({ layerId, component, subpath, segment, mode, radius, inner, outer }) => {
19134
+ const id = layerArg(layerId);
19135
+ const vectorMask = controller.setVectorMaskEdge(
19136
+ id,
19137
+ { component, subpath, segment },
19138
+ mode,
19139
+ { radius, inner, outer }
19140
+ );
19141
+ return { vectorMask };
19142
+ }
19143
+ };
18648
19144
  const rasterizeVectorMask = {
18649
19145
  name: "rasterize_vector_mask",
18650
19146
  title: "Rasterize a vector mask",
@@ -18787,6 +19283,7 @@ function createImageShapeTools(controller) {
18787
19283
  addVectorMask,
18788
19284
  setVectorMaskPath,
18789
19285
  setVectorMaskProps,
19286
+ setVectorMaskEdge,
18790
19287
  rasterizeVectorMask,
18791
19288
  removeVectorMask,
18792
19289
  selectionToPath,
@@ -19313,11 +19810,11 @@ function hitTestCurvePoint(points, x, y, width, height, radius = 8) {
19313
19810
  return best;
19314
19811
  }
19315
19812
  function insertCurvePoint(points, p) {
19316
- const point = { input: clampLevel(p.input), output: clampLevel(p.output) };
19317
- const out = points.filter((q) => q.input !== point.input).map((q) => ({ ...q }));
19318
- let index = out.findIndex((q) => q.input > point.input);
19813
+ const point2 = { input: clampLevel(p.input), output: clampLevel(p.output) };
19814
+ const out = points.filter((q) => q.input !== point2.input).map((q) => ({ ...q }));
19815
+ let index = out.findIndex((q) => q.input > point2.input);
19319
19816
  if (index === -1) index = out.length;
19320
- out.splice(index, 0, point);
19817
+ out.splice(index, 0, point2);
19321
19818
  return { points: out, index };
19322
19819
  }
19323
19820
  function moveCurvePoint(points, index, p) {
@@ -19686,25 +20183,25 @@ var SelectAndMaskSession = class {
19686
20183
  * Selection, brushes, lasso) or a box (Object Selection). Alt (or the subtract mode) removes.
19687
20184
  * Hand and Zoom are the UI's own; they return false here.
19688
20185
  */
19689
- begin(point, modifiers = {}) {
20186
+ begin(point2, modifiers = {}) {
19690
20187
  const s = this.settingsValue;
19691
20188
  const mode = modifiers.altKey ? s.brushMode === "add" ? "subtract" : "add" : s.brushMode;
19692
- this.dragStart = point;
20189
+ this.dragStart = point2;
19693
20190
  switch (s.tool) {
19694
20191
  case "quick-select":
19695
- this.live = { kind: "quick-select", points: [point], size: s.brushSize, mode };
20192
+ this.live = { kind: "quick-select", points: [point2], size: s.brushSize, mode };
19696
20193
  break;
19697
20194
  case "brush":
19698
- this.live = { kind: "brush", points: [point], size: s.brushSize, mode };
20195
+ this.live = { kind: "brush", points: [point2], size: s.brushSize, mode };
19699
20196
  break;
19700
20197
  case "refine-brush":
19701
- this.live = { kind: "refine-brush", points: [point], size: s.brushSize };
20198
+ this.live = { kind: "refine-brush", points: [point2], size: s.brushSize };
19702
20199
  break;
19703
20200
  case "lasso":
19704
- this.live = { kind: "lasso", points: [point], mode };
20201
+ this.live = { kind: "lasso", points: [point2], mode };
19705
20202
  break;
19706
20203
  case "object":
19707
- this.live = { kind: "object", point, mode };
20204
+ this.live = { kind: "object", point: point2, mode };
19708
20205
  break;
19709
20206
  default:
19710
20207
  this.dragStart = null;
@@ -19714,18 +20211,18 @@ var SelectAndMaskSession = class {
19714
20211
  return true;
19715
20212
  }
19716
20213
  /** Pointer move while a stroke is down. */
19717
- move(point) {
20214
+ move(point2) {
19718
20215
  const live = this.live;
19719
20216
  if (!live || !this.dragStart) return;
19720
20217
  if (live.kind === "object") {
19721
20218
  const a = this.dragStart;
19722
- const rect2 = { x: Math.min(a.x, point.x), y: Math.min(a.y, point.y), width: Math.abs(point.x - a.x), height: Math.abs(point.y - a.y) };
19723
- this.live = rect2.width >= 4 && rect2.height >= 4 ? { kind: "object", rect: rect2, mode: live.mode } : { kind: "object", point: a, mode: live.mode };
20219
+ const rect3 = { x: Math.min(a.x, point2.x), y: Math.min(a.y, point2.y), width: Math.abs(point2.x - a.x), height: Math.abs(point2.y - a.y) };
20220
+ this.live = rect3.width >= 4 && rect3.height >= 4 ? { kind: "object", rect: rect3, mode: live.mode } : { kind: "object", point: a, mode: live.mode };
19724
20221
  return;
19725
20222
  }
19726
20223
  const last = live.points[live.points.length - 1];
19727
- if (Math.hypot(point.x - last.x, point.y - last.y) < 1) return;
19728
- live.points.push(point);
20224
+ if (Math.hypot(point2.x - last.x, point2.y - last.y) < 1) return;
20225
+ live.points.push(point2);
19729
20226
  if (this.settingsValue.realTime && live.kind !== "lasso") this.recompute();
19730
20227
  }
19731
20228
  /** Pointer up: the stroke becomes an edit (one step of the workspace's own undo). */
@@ -21649,6 +22146,7 @@ var PathEditGesture = class {
21649
22146
  return;
21650
22147
  }
21651
22148
  const seg = this.segmentAt(cs, p);
22149
+ if (seg && mods.alt && mods.shift && this.target?.kind === "vector-mask") return this.cycleEdge(this.target.layerId, seg.ref);
21652
22150
  if (seg) {
21653
22151
  if (!mods.shift) this.selected.clear();
21654
22152
  this.drag = { kind: "segment", ...seg, base: cloneComponents(cs), moved: false };
@@ -21657,6 +22155,17 @@ var PathEditGesture = class {
21657
22155
  this.drag = { kind: "marquee", start: pt(p), end: pt(p), keep: mods.shift ? new Set(this.selected) : /* @__PURE__ */ new Set() };
21658
22156
  if (!mods.shift) this.selected.clear();
21659
22157
  }
22158
+ /** Alt+Shift-click on a vector mask's segment: hard → feather → matte → hard (one undo step). */
22159
+ cycleEdge(layerId, r) {
22160
+ const at = { component: r.component, subpath: r.subpath, segment: r.anchor };
22161
+ const now = this.controller.getVectorMask(layerId)?.edges.find((e) => e.component === at.component && e.subpath === at.subpath && e.segment === at.segment)?.mode ?? "hard";
22162
+ const next = now === "hard" ? "feather" : now === "feather" ? "matte" : "hard";
22163
+ try {
22164
+ this.controller.setVectorMaskEdge(layerId, at, next);
22165
+ } catch (e) {
22166
+ this.error = e instanceof Error ? e.message : String(e);
22167
+ }
22168
+ }
21660
22169
  /** Alt-click: a corner anchor becomes smooth, a smooth one a corner (one undo step). */
21661
22170
  convert(cs, r) {
21662
22171
  const next = cloneComponents(cs);
@@ -21745,7 +22254,7 @@ var PathEditGesture = class {
21745
22254
  if (!g) return;
21746
22255
  if (g.kind === "marquee") {
21747
22256
  g.end = pt(p);
21748
- const box = rect(g.start, g.end);
22257
+ const box = rect2(g.start, g.end);
21749
22258
  const cs = this.target ? this.controller.getPathEditComponents(this.target) : [];
21750
22259
  this.selected = new Set(g.keep);
21751
22260
  cs.forEach(
@@ -21852,12 +22361,18 @@ var PathEditGesture = class {
21852
22361
  const cs = this.components();
21853
22362
  if (!cs) return null;
21854
22363
  const curves = [];
22364
+ const curveModes = [];
22365
+ const edges = this.target?.kind === "vector-mask" ? this.controller.getVectorMask(this.target.layerId)?.edges ?? [] : [];
22366
+ const modeOf = (ci, si, k) => edges.find((e) => e.component === ci && e.subpath === si && e.segment === k)?.mode ?? "hard";
21855
22367
  const anchors = [];
21856
22368
  const handles = [];
21857
22369
  const selectedComponents = new Set(this.selection.map((r) => r.component));
21858
22370
  cs.forEach(
21859
22371
  (c, ci) => c.subpaths.forEach((sp, si) => {
21860
- for (let i = 0; i < subpathSegmentCount(sp); i++) curves.push(subpathSegment(sp, i).map(([x, y]) => ({ x, y })));
22372
+ for (let i = 0; i < subpathSegmentCount(sp); i++) {
22373
+ curves.push(subpathSegment(sp, i).map(([x, y]) => ({ x, y })));
22374
+ curveModes.push(modeOf(ci, si, i));
22375
+ }
21861
22376
  sp.anchors.forEach((a, ai) => {
21862
22377
  const selected = this.selected.has(key({ component: ci, subpath: si, anchor: ai }));
21863
22378
  if (tool === "path-select" && !selectedComponents.has(ci)) return;
@@ -21874,11 +22389,11 @@ var PathEditGesture = class {
21874
22389
  const last = sp?.anchors[sp.anchors.length - 1];
21875
22390
  if (last) preview = last.out ? [pt(last), pt(last.out), this.pointer, this.pointer] : [pt(last), this.pointer];
21876
22391
  }
21877
- const marquee = this.drag?.kind === "marquee" ? rect(this.drag.start, this.drag.end) : null;
21878
- return { curves, anchors, handles, marquee, preview };
22392
+ const marquee = this.drag?.kind === "marquee" ? rect2(this.drag.start, this.drag.end) : null;
22393
+ return { curves, ...edges.length ? { curveModes } : {}, anchors, handles, marquee, preview };
21879
22394
  }
21880
22395
  };
21881
- function rect(a, b) {
22396
+ function rect2(a, b) {
21882
22397
  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
21883
22398
  }
21884
22399
  function prune(cs) {
@@ -22079,21 +22594,21 @@ var ContentAwareFillSession = class _ContentAwareFillSession {
22079
22594
  }
22080
22595
  }
22081
22596
  /** Pointer down with the Sampling Brush (Alt flips add and subtract). Returns false for other tools. */
22082
- begin(point, modifiers = {}) {
22597
+ begin(point2, modifiers = {}) {
22083
22598
  if (this.settingsValue.tool !== "sampling-brush" || !this.win) return false;
22084
22599
  const add2 = this.settingsValue.brushMode === "add" !== Boolean(modifiers.altKey);
22085
22600
  this.settingsValue = { ...this.settingsValue, sampling: "custom" };
22086
- this.lastPoint = point;
22601
+ this.lastPoint = point2;
22087
22602
  this.strokeAdds = add2;
22088
- this.paint(point, point, add2);
22603
+ this.paint(point2, point2, add2);
22089
22604
  this.emit();
22090
22605
  return true;
22091
22606
  }
22092
22607
  strokeAdds = true;
22093
- move(point) {
22608
+ move(point2) {
22094
22609
  if (!this.lastPoint) return;
22095
- this.paint(this.lastPoint, point, this.strokeAdds);
22096
- this.lastPoint = point;
22610
+ this.paint(this.lastPoint, point2, this.strokeAdds);
22611
+ this.lastPoint = point2;
22097
22612
  this.emit();
22098
22613
  }
22099
22614
  /** Pointer up: the preview follows the new area when live. */
@@ -22407,6 +22922,7 @@ export {
22407
22922
  SELECTION_MODES,
22408
22923
  SELECTION_MODE_LABELS,
22409
22924
  SELECTION_MODIFY_OPS,
22925
+ SELECTION_RECIPE_OPS,
22410
22926
  SELECTION_TOOLS,
22411
22927
  SELECT_AND_MASK_TOOLS,
22412
22928
  SELECT_AND_MASK_VIEWS,
@@ -22453,6 +22969,7 @@ export {
22453
22969
  TypeToolGesture,
22454
22970
  UNITS,
22455
22971
  U_MAX,
22972
+ VECTOR_MASK_EDGE_DEFAULTS,
22456
22973
  VIBRANCE_GREY,
22457
22974
  W9,
22458
22975
  WARP_GRID_OPTIONS,
@@ -22671,6 +23188,8 @@ export {
22671
23188
  describeChannel,
22672
23189
  describeLayer,
22673
23190
  describeLayerEffects,
23191
+ describeRecipe,
23192
+ describeRecipeStep,
22674
23193
  describeSelection,
22675
23194
  describeTransform,
22676
23195
  deserializeChannels,
@@ -22801,6 +23320,7 @@ export {
22801
23320
  hueRangeTables,
22802
23321
  hueRangeWeight,
22803
23322
  hueSaturation,
23323
+ hybridVectorMaskCoverage,
22804
23324
  identityCurve,
22805
23325
  identityLevels,
22806
23326
  identityTransform,
@@ -22854,6 +23374,7 @@ export {
22854
23374
  isSelectionEmpty,
22855
23375
  isSelectionMode,
22856
23376
  isSeparableBlendMode,
23377
+ isShapeStep,
22857
23378
  isSmartSurfaceIncomplete,
22858
23379
  isSpatialFilter,
22859
23380
  isSrgb,
@@ -22871,6 +23392,7 @@ export {
22871
23392
  layerEffectsBadge,
22872
23393
  layerEffectsOf,
22873
23394
  layerEffectsPanel,
23395
+ layerGuide,
22874
23396
  layerHasThumbnail,
22875
23397
  layerLabel,
22876
23398
  layerMaskCoverage,
@@ -23060,9 +23582,11 @@ export {
23060
23582
  readPaths,
23061
23583
  readPixel,
23062
23584
  readPsd,
23585
+ readRecipeStep,
23063
23586
  readRegion,
23064
23587
  readShapeComponents,
23065
23588
  readSourceRegion,
23589
+ recipeStepSetting,
23066
23590
  rectSelection,
23067
23591
  rectToQuad,
23068
23592
  reduceSurface,
@@ -23087,6 +23611,7 @@ export {
23087
23611
  renderText,
23088
23612
  replaceContents,
23089
23613
  replaceTextRange,
23614
+ replaySelectionRecipe,
23090
23615
  requireLayer,
23091
23616
  requireOperation,
23092
23617
  requireShapeLayer,
@@ -23119,6 +23644,7 @@ export {
23119
23644
  sameCanvasViewport,
23120
23645
  sampleLut,
23121
23646
  sampleToneLut,
23647
+ samplingKey,
23122
23648
  saturationOf,
23123
23649
  scaleLayerEffect,
23124
23650
  scalePaintGradient,
@@ -23206,6 +23732,7 @@ export {
23206
23732
  standardBlendMode,
23207
23733
  startMarchingAnts,
23208
23734
  startSelectionWorker,
23735
+ stepNeedsPixels,
23209
23736
  stepSlotTs,
23210
23737
  strokeGrainCatch,
23211
23738
  styleAt,
@@ -23276,11 +23803,13 @@ export {
23276
23803
  upscaleRegion,
23277
23804
  userErrorMessage,
23278
23805
  uvToXy,
23806
+ validVectorMaskEdges,
23279
23807
  validateEffectGradient,
23280
23808
  validateLayerEffect,
23281
23809
  validateLayerEffects,
23282
23810
  vectorMaskBounds,
23283
23811
  vectorMaskCoverage,
23812
+ vectorMaskSegments,
23284
23813
  vectorMaskThumbnail,
23285
23814
  vectorMaskThumbnailClick,
23286
23815
  versionedDescriptorBytes,