@pixerate/editor 0.188.0 → 0.190.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 (41) hide show
  1. package/dist/{chunk-IGMNJM2H.js → chunk-7ZWLDVFH.js} +979 -753
  2. package/dist/{chunk-JYV4R62M.cjs → chunk-T4U4O6DS.cjs} +1013 -787
  3. package/dist/image-editor/controller.d.cts +68 -1
  4. package/dist/image-editor/controller.d.ts +68 -1
  5. package/dist/image-editor/free-transform.d.cts +3 -6
  6. package/dist/image-editor/free-transform.d.ts +3 -6
  7. package/dist/image-editor/index.cjs +1867 -1413
  8. package/dist/image-editor/index.d.cts +1 -0
  9. package/dist/image-editor/index.d.ts +1 -0
  10. package/dist/image-editor/index.js +869 -415
  11. package/dist/image-editor/layer-tools.d.cts +1 -1
  12. package/dist/image-editor/layer-tools.d.ts +1 -1
  13. package/dist/image-editor/layers/compositor.d.cts +3 -0
  14. package/dist/image-editor/layers/compositor.d.ts +3 -0
  15. package/dist/image-editor/layers/model.d.cts +23 -0
  16. package/dist/image-editor/layers/model.d.ts +23 -0
  17. package/dist/image-editor/layers/paint-ui.d.cts +2 -1
  18. package/dist/image-editor/layers/paint-ui.d.ts +2 -1
  19. package/dist/image-editor/layers/panel.d.cts +14 -3
  20. package/dist/image-editor/layers/panel.d.ts +14 -3
  21. package/dist/image-editor/layers/serialize.d.cts +11 -0
  22. package/dist/image-editor/layers/serialize.d.ts +11 -0
  23. package/dist/image-editor/layers/shape.d.cts +5 -0
  24. package/dist/image-editor/layers/shape.d.ts +5 -0
  25. package/dist/image-editor/layers/vector-mask.d.cts +51 -0
  26. package/dist/image-editor/layers/vector-mask.d.ts +51 -0
  27. package/dist/image-editor/psd/import.d.cts +0 -7
  28. package/dist/image-editor/psd/import.d.ts +0 -7
  29. package/dist/image-editor/renderer.d.cts +6 -0
  30. package/dist/image-editor/renderer.d.ts +6 -0
  31. package/dist/image-editor/selection/gesture.d.cts +10 -0
  32. package/dist/image-editor/selection/gesture.d.ts +10 -0
  33. package/dist/image-editor/selection/layer-mask.d.cts +6 -0
  34. package/dist/image-editor/selection/layer-mask.d.ts +6 -0
  35. package/dist/image-editor/types.d.cts +8 -0
  36. package/dist/image-editor/types.d.ts +8 -0
  37. package/dist/paint-editor/index.cjs +6 -6
  38. package/dist/paint-editor/index.js +1 -1
  39. package/package.json +1 -1
  40. package/skills/pixerate-image-editor/SKILL.md +7 -0
  41. package/skills/pixerate-image-selection-masking/SKILL.md +10 -3
@@ -99,6 +99,7 @@ import {
99
99
  combineSelections,
100
100
  compileAdjustmentLayer,
101
101
  componentPath,
102
+ componentsCoverage,
102
103
  compositeDeep,
103
104
  computeSelectionBounds,
104
105
  contentsBox,
@@ -137,6 +138,7 @@ import {
137
138
  distanceTransform,
138
139
  documentContents,
139
140
  duplicateLayer,
141
+ effectiveLayerMask,
140
142
  encodeDeepSurface,
141
143
  encodeRle,
142
144
  encodeSelectionRle,
@@ -148,6 +150,7 @@ import {
148
150
  filterMaskCoverage,
149
151
  flattenPath,
150
152
  flipCorners,
153
+ followLayerMove,
151
154
  fullSelection,
152
155
  geometryHomography,
153
156
  getAnnotationBounds,
@@ -177,6 +180,7 @@ import {
177
180
  invertMatrix3,
178
181
  invertSelection,
179
182
  isActiveFilter,
183
+ isActiveVectorMask,
180
184
  isAdjustmentKind,
181
185
  isContentsDecoded,
182
186
  isDefaultDepthMask,
@@ -196,6 +200,7 @@ import {
196
200
  layerMaskCoverage,
197
201
  layerMaskFromSelection,
198
202
  layerMaskKey,
203
+ layerMaskSelection,
199
204
  layerName,
200
205
  layerTree,
201
206
  layerTypeLabel,
@@ -236,6 +241,7 @@ import {
236
241
  offsetLayer,
237
242
  offsetShape,
238
243
  offsetSmartObject,
244
+ offsetVectorMask,
239
245
  parseAdjustmentBlock,
240
246
  parseBlend,
241
247
  parseLfx2,
@@ -289,6 +295,7 @@ import {
289
295
  rerenderText,
290
296
  resizeSelection,
291
297
  resolveExternalAssets,
298
+ resolveVectorMasks,
292
299
  rgbToGray,
293
300
  rgbToHsl,
294
301
  runLayerFilters,
@@ -340,6 +347,7 @@ import {
340
347
  transformMap,
341
348
  transformShapeComponents,
342
349
  transformTypeLayer,
350
+ transformVectorMask,
343
351
  transformedBox,
344
352
  transformedPoint,
345
353
  translateLayer,
@@ -358,6 +366,9 @@ import {
358
366
  updateTypeLayer,
359
367
  userErrorMessage,
360
368
  uvToXy,
369
+ vectorMaskBounds,
370
+ vectorMaskCoverage,
371
+ vectorMaskThumbnail,
361
372
  versionedDescriptorBytes,
362
373
  vibranceLinear,
363
374
  vibrancePixel,
@@ -372,7 +383,7 @@ import {
372
383
  writePsd,
373
384
  xyToTempTint,
374
385
  xyToUv
375
- } from "../chunk-IGMNJM2H.js";
386
+ } from "../chunk-7ZWLDVFH.js";
376
387
  import {
377
388
  parseCssColor
378
389
  } from "../chunk-3CI7FRMK.js";
@@ -1365,6 +1376,24 @@ var ImageEditorRenderer = class {
1365
1376
  }
1366
1377
  ctx.restore();
1367
1378
  }
1379
+ const maskView = state.maskView;
1380
+ if (maskView) {
1381
+ const layer = flattenLayers(state.layers ?? []).find((l) => l.id === maskView.layerId);
1382
+ const mask = layer ? this.maskViewSelection(layer, Math.round(state.imageDimensions.width), Math.round(state.imageDimensions.height)) : null;
1383
+ if (mask) {
1384
+ ctx.save();
1385
+ try {
1386
+ applyImageProjectionToContext(ctx, projection);
1387
+ ctx.beginPath();
1388
+ ctx.rect(region.x, region.y, region.width, region.height);
1389
+ ctx.clip();
1390
+ if (maskView.mode === "gray") this.drawMaskGray(ctx, mask);
1391
+ else this.drawMaskOverlay(ctx, mask, { color: [255, 0, 0], opacity: 0.5, indicates: "masked" });
1392
+ } catch {
1393
+ }
1394
+ ctx.restore();
1395
+ }
1396
+ }
1368
1397
  const display = options.selectionDisplay ?? state.selectionDisplay ?? (state.quickMask ? "quickMask" : "ants");
1369
1398
  const shownChannels = (state.channels ?? []).filter((c) => c.visible);
1370
1399
  if (shownChannels.length) {
@@ -1804,6 +1833,32 @@ var ImageEditorRenderer = class {
1804
1833
  return this.emptyMaskCache;
1805
1834
  }
1806
1835
  overlayCache = /* @__PURE__ */ new WeakMap();
1836
+ maskViewCache = /* @__PURE__ */ new WeakMap();
1837
+ grayCache = /* @__PURE__ */ new WeakMap();
1838
+ /** A layer's mask as canvas-sized coverage for its mask view, kept while the mask is unchanged. */
1839
+ maskViewSelection(layer, width, height) {
1840
+ if (!layer.mask || layer.mask.source || width < 1 || height < 1) return null;
1841
+ const key = `${width}x${height}:${layer.type === "pixel" ? `${layer.offset.x},${layer.offset.y}` : ""}`;
1842
+ const cached = this.maskViewCache.get(layer.mask.surface);
1843
+ if (cached?.key === key) return cached.mask;
1844
+ const mask = layerMaskSelection(layer, width, height);
1845
+ if (mask) this.maskViewCache.set(layer.mask.surface, { key, mask });
1846
+ return mask;
1847
+ }
1848
+ /** A mask alone in grey (white = revealed), opaque, drawn in natural image space. */
1849
+ drawMaskGray(ctx, mask) {
1850
+ let canvas = this.grayCache.get(mask);
1851
+ if (!canvas) {
1852
+ canvas = this.createCanvas(mask.width, mask.height);
1853
+ const mctx = canvas.getContext("2d");
1854
+ if (!mctx || typeof mctx.createImageData !== "function") return;
1855
+ const img = mctx.createImageData(mask.width, mask.height);
1856
+ img.data.set(selectionToPixels(mask, "gray").data);
1857
+ mctx.putImageData(img, 0, 0);
1858
+ this.grayCache.set(mask, canvas);
1859
+ }
1860
+ ctx.drawImage(canvas, 0, 0, mask.width, mask.height);
1861
+ }
1807
1862
  /** A channel's tint (colour, opacity, masked or selected area), drawn in natural image space. */
1808
1863
  drawMaskOverlay(ctx, mask, style) {
1809
1864
  const key = `${style.color?.join(",")}|${style.opacity}|${style.indicates}`;
@@ -4119,6 +4174,24 @@ var FreeTransformSession = class {
4119
4174
  }
4120
4175
  /** Resamples every part for the current geometry and shows it (or returns it, for commit). */
4121
4176
  resample(final, preview = {}) {
4177
+ const out = this.resampleContent(final, preview);
4178
+ return { ...out, layers: this.moveVectorMask(out.layers) };
4179
+ }
4180
+ /** A linked vector mask follows the layer's content through the same document-space map. */
4181
+ moveVectorMask(layers) {
4182
+ const g = this.geometryValue;
4183
+ if (!this.layerId || this.copyId || !this.moves.includes("pixels") || isIdentityTransform(g)) return layers;
4184
+ const vm = findLayer(layers, this.layerId)?.layer.vectorMask;
4185
+ if (!vm || vm.linked === false) return layers;
4186
+ const warp = hasWarp(g);
4187
+ const f = warp ? transformMap(g) : null;
4188
+ const h = warp ? null : geometryHomography(g);
4189
+ const affine = !warp && h && Math.abs(h[6]) < 1e-12 && Math.abs(h[7]) < 1e-12 && Math.abs(h[8] - 1) < 1e-12 ? [h[0], h[3], h[1], h[4], h[2], h[5]] : void 0;
4190
+ const map = (p) => (f ? f(p) : h ? applyHomography(h, p) : p) ?? p;
4191
+ const moved = transformVectorMask(vm, map, affine);
4192
+ return mapLayer(layers, this.layerId, (l) => ({ ...l, vectorMask: moved }));
4193
+ }
4194
+ resampleContent(final, preview = {}) {
4122
4195
  if (this.smart) return { layers: this.resampleSmart(final, preview), selection: this.startSelection };
4123
4196
  if (this.typeTarget) return { layers: this.resampleType(), selection: this.startSelection };
4124
4197
  const g = this.geometryValue;
@@ -6122,6 +6195,7 @@ var ImageEditorController = class {
6122
6195
  let moved = { ...transformTypeLayer(layer, [1, 0, 0, 1, mx, my]), surface: translateSurface(layer.surface, mx, my) };
6123
6196
  const mask = layer.mask;
6124
6197
  if (mask && mask.linked !== false && !mask.source && (mx || my)) moved = { ...moved, mask: { ...mask, surface: translateSurface(mask.surface, mx, my) } };
6198
+ moved = followLayerMove(moved, mx, my);
6125
6199
  this.commitLayers(mapLayer(this.state.layers, id, () => moved), void 0, commitToHistory);
6126
6200
  const t = moved.transform ?? [1, 0, 0, 1, 0, 0];
6127
6201
  return { offset: { x: Math.round(t[4]), y: Math.round(t[5]) }, bounds: this.getLayerBounds(id) };
@@ -6173,6 +6247,139 @@ var ImageEditorController = class {
6173
6247
  if (this.state.activeLayerId === id) this.state.activeTarget = "pixels";
6174
6248
  });
6175
6249
  }
6250
+ // --- Vector masks ---
6251
+ /** The layer for a vector mask command: not a shape layer (its path is its vector mask), not a depth-masked one. */
6252
+ vectorMaskLayer(id) {
6253
+ const layer = requireLayer(this.state.layers, id).layer;
6254
+ const name = layer.name || id;
6255
+ if (layer.type === "shape") throw new LayerOperationError(`Layer "${name}" is a shape layer, whose path already is its vector mask; change it with set_shape_component, or rasterize the layer first.`);
6256
+ if (layer.mask?.source) throw new LayerOperationError(`Layer "${name}" has a depth mask, which cannot be combined with a vector mask; remove the depth mask first.`);
6257
+ return layer;
6258
+ }
6259
+ requireVectorMask(id) {
6260
+ const layer = this.vectorMaskLayer(id);
6261
+ if (!layer.vectorMask) throw new LayerOperationError(`Layer "${layer.name || id}" has no vector mask; add one with add_vector_mask.`);
6262
+ return { layer, vm: layer.vectorMask };
6263
+ }
6264
+ /** Components from a path spec: `components`, or one `shape` / `subpaths`. */
6265
+ vectorMaskComponents(path) {
6266
+ if (path.components?.length && (path.shape !== void 0 || path.subpaths !== void 0)) throw new LayerOperationError("Give components, or shape / subpaths, not both.");
6267
+ if (path.fillRule !== void 0 && path.fillRule !== "nonzero" && path.fillRule !== "evenodd") throw new LayerOperationError("fillRule must be nonzero or evenodd.");
6268
+ const specs = path.components?.length ? path.components : [path];
6269
+ return specs.map((c, i) => this.componentFrom(c, path.components?.length ? `components[${i}].` : ""));
6270
+ }
6271
+ /**
6272
+ * Layer › Vector Mask: `reveal-all` (an empty path showing everything), `hide-all` (an empty path
6273
+ * hiding everything) or `path` (reveals inside the path given). Replaces an existing vector mask;
6274
+ * the raster mask is kept and both apply. One undo step. Returns the vector mask.
6275
+ */
6276
+ addVectorMask(id, from = "reveal-all", path = {}) {
6277
+ this.vectorMaskLayer(id);
6278
+ let vm;
6279
+ if (from === "path") {
6280
+ vm = { components: this.vectorMaskComponents(path) };
6281
+ if (path.fillRule === "evenodd") vm.fillRule = "evenodd";
6282
+ } else if (from === "reveal-all" || from === "hide-all") {
6283
+ if (path.components?.length || path.shape !== void 0 || path.subpaths !== void 0) throw new LayerOperationError(`A ${from} vector mask starts empty; use from path to give a path.`);
6284
+ vm = { components: [], ...from === "reveal-all" ? { inverted: true } : {} };
6285
+ } else {
6286
+ throw new LayerOperationError(`Unknown vector mask source "${String(from)}". Use reveal-all, hide-all or path.`);
6287
+ }
6288
+ this.commitLayers(mapLayer(this.state.layers, id, (l) => ({ ...l, vectorMask: vm })));
6289
+ return vm;
6290
+ }
6291
+ /** Replaces a vector mask's path (its properties are kept). One undo step. Returns the vector mask. */
6292
+ setVectorMaskPath(id, path) {
6293
+ const { vm } = this.requireVectorMask(id);
6294
+ const next = { ...vm, components: this.vectorMaskComponents(path) };
6295
+ if (path.fillRule === "evenodd") next.fillRule = "evenodd";
6296
+ else if (path.fillRule === "nonzero") delete next.fillRule;
6297
+ this.commitLayers(mapLayer(this.state.layers, id, (l) => ({ ...l, vectorMask: next })));
6298
+ return next;
6299
+ }
6300
+ /** Changes a vector mask's enabled, linked, inverted, density or feather (the path is kept). One undo step. */
6301
+ setVectorMaskProps(id, props) {
6302
+ const { vm } = this.requireVectorMask(id);
6303
+ if (props.density !== void 0 && !(props.density >= 0 && props.density <= 1)) throw new LayerOperationError("density must be from 0 to 1.");
6304
+ if (props.feather !== void 0 && !(props.feather >= 0 && props.feather <= 1e3)) throw new LayerOperationError("feather must be from 0 to 1000 px.");
6305
+ const next = { ...vm };
6306
+ for (const k of ["enabled", "linked", "inverted"]) {
6307
+ if (props[k] === void 0) continue;
6308
+ if (typeof props[k] !== "boolean") throw new LayerOperationError(`${k} must be true or false.`);
6309
+ if (k === "inverted" ? props[k] : !props[k]) next[k] = k === "inverted";
6310
+ else delete next[k];
6311
+ }
6312
+ if (props.density !== void 0) {
6313
+ if (props.density === 1) delete next.density;
6314
+ else next.density = props.density;
6315
+ }
6316
+ if (props.feather !== void 0) {
6317
+ if (props.feather === 0) delete next.feather;
6318
+ else next.feather = props.feather;
6319
+ }
6320
+ this.commitLayers(mapLayer(this.state.layers, id, (l) => ({ ...l, vectorMask: next })));
6321
+ return next;
6322
+ }
6323
+ /** Deletes a layer's vector mask (one undo step). */
6324
+ removeVectorMask(id) {
6325
+ this.requireVectorMask(id);
6326
+ this.commitLayers(
6327
+ mapLayer(this.state.layers, id, (l) => {
6328
+ const next = { ...l };
6329
+ delete next.vectorMask;
6330
+ return next;
6331
+ })
6332
+ );
6333
+ }
6334
+ /**
6335
+ * Layer › Rasterize › Vector Mask: multiplies the path (with its density and feather) into the
6336
+ * layer's raster mask, adding one when there is none, and removes the vector mask. The raster
6337
+ * mask keeps its own properties. One undo step. Returns the raster mask.
6338
+ */
6339
+ rasterizeVectorMask(id) {
6340
+ const { layer } = this.requireVectorMask(id);
6341
+ const { width, height } = this.canvasSize;
6342
+ const raster = layer.mask;
6343
+ const plain3 = { ...layer, vectorMask: { ...layer.vectorMask, enabled: true }, mask: raster ? { surface: raster.surface, defaultValue: raster.defaultValue } : void 0 };
6344
+ const baked = effectiveLayerMask(plain3, width, height);
6345
+ const mask = raster ? { ...raster, surface: baked.surface, defaultValue: baked.defaultValue } : baked;
6346
+ this.commitLayers(
6347
+ mapLayer(this.state.layers, id, (l) => {
6348
+ const next = { ...l, mask };
6349
+ delete next.vectorMask;
6350
+ return next;
6351
+ })
6352
+ );
6353
+ return mask;
6354
+ }
6355
+ /** Load Selection from a vector mask's path (density and feather are not applied), inverted with `invert`. */
6356
+ selectionFromVectorMask(id, mode = "replace", invert2 = false) {
6357
+ const { vm } = this.requireVectorMask(id);
6358
+ const { width, height } = this.naturalSize;
6359
+ const cov = componentsCoverage(vm.components, { x: 0, y: 0, width, height }, vm.fillRule ?? "nonzero", vm.inverted === true !== invert2);
6360
+ const bytes = new Uint8Array(cov.length);
6361
+ for (let i = 0; i < cov.length; i++) bytes[i] = Math.round(Math.min(1, Math.max(0, cov[i])) * 255);
6362
+ return this.setSelection(createSelectionMask(width, height, bytes), mode);
6363
+ }
6364
+ /** A layer's vector mask with its paths (rounded), or `null` without one. */
6365
+ getVectorMask(id) {
6366
+ const layer = requireLayer(this.state.layers, id).layer;
6367
+ const vm = layer.vectorMask;
6368
+ if (!vm) return null;
6369
+ const b = vectorMaskBounds(vm);
6370
+ const r = (n) => Math.round(n * 1e3) / 1e3;
6371
+ return {
6372
+ layerId: id,
6373
+ enabled: vm.enabled !== false,
6374
+ linked: vm.linked !== false,
6375
+ inverted: vm.inverted === true,
6376
+ fillRule: vm.fillRule ?? "nonzero",
6377
+ density: vm.density ?? 1,
6378
+ feather: vm.feather ?? 0,
6379
+ bounds: b ? { x: r(b.x), y: r(b.y), width: r(b.width), height: r(b.height) } : null,
6380
+ components: vm.components.map((c, index) => ({ index, op: c.op, live: c.live ? JSON.parse(JSON.stringify(c.live)) : null, subpaths: roundSubpaths(c.subpaths) }))
6381
+ };
6382
+ }
6176
6383
  /** Changes any layer mask's density, feather, enabled or linked flag (pixels kept). Returns the mask. */
6177
6384
  setLayerMaskProps(id, props, commitToHistory = true) {
6178
6385
  const layer = requireLayer(this.state.layers, id).layer;
@@ -8351,6 +8558,41 @@ var ImageEditorController = class {
8351
8558
  this.state.quickMask = display === "quickMask";
8352
8559
  this.notify();
8353
8560
  }
8561
+ // --- Mask views ---
8562
+ /** The layer mask shown on the canvas, or `null` (see `setMaskView`). */
8563
+ getMaskView() {
8564
+ const view = this.state.maskView;
8565
+ if (!view) return null;
8566
+ const layer = this.getLayer(view.layerId);
8567
+ return layer?.mask && !layer.mask.source ? view : null;
8568
+ }
8569
+ /**
8570
+ * Shows a layer's mask on the canvas: `gray` alone in grey (Alt-click the mask thumbnail),
8571
+ * `overlay` as a red tint over what it hides (the \ key), `off` back to the image. The layer
8572
+ * defaults to the active one. View state: no undo step, not saved. Throws when the layer has
8573
+ * no painted mask.
8574
+ */
8575
+ setMaskView(mode, layerId) {
8576
+ if (mode !== "off" && mode !== "gray" && mode !== "overlay") throw new Error(`Unknown mask view "${String(mode)}". Use gray, overlay or off.`);
8577
+ if (mode === "off") {
8578
+ if (this.state.maskView) {
8579
+ this.state.maskView = null;
8580
+ this.notify();
8581
+ }
8582
+ return null;
8583
+ }
8584
+ const id = layerId ?? this.state.activeLayerId;
8585
+ if (!id) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
8586
+ const layer = requireLayer(this.state.layers, id).layer;
8587
+ if (!layer.mask) throw new LayerOperationError(`Layer "${layer.name || id}" has no mask to show; add one with add_layer_mask.`);
8588
+ if (layer.mask.source) throw new LayerOperationError(`Layer "${layer.name || id}" has a depth mask, which is computed, not painted, so it has no mask view.`);
8589
+ const cur = this.state.maskView;
8590
+ if (cur?.layerId !== id || cur.mode !== mode) {
8591
+ this.state.maskView = { layerId: id, mode };
8592
+ this.notify();
8593
+ }
8594
+ return this.state.maskView;
8595
+ }
8354
8596
  // --- Alpha channels ---
8355
8597
  /** The alpha channels (saved selections), in panel order. */
8356
8598
  getChannels() {
@@ -11578,11 +11820,12 @@ function createImageSelectionTools(controller) {
11578
11820
  const loadFromMask = {
11579
11821
  name: "load_selection_from_mask",
11580
11822
  title: "Load a selection from a layer mask",
11581
- description: "Load Selection from a layer's mask (Ctrl/\u2318-click its mask thumbnail): the mask's pixels become the selection, combined by mode (density and feather are not applied). Use it to reuse a mask on another layer or to edit it as a selection. Fails when the layer has no painted mask. One undoable step. Returns the selection.",
11823
+ description: "Load Selection from a layer's mask (Ctrl/\u2318-click its mask thumbnail): the raster mask's pixels, or with source vector the vector mask's path, become the selection, combined by mode (density and feather are not applied). Use it to reuse a mask on another layer or to edit it as a selection. Fails when the layer has no such mask. One undoable step. Returns the selection.",
11582
11824
  inputSchema: {
11583
11825
  type: "object",
11584
11826
  properties: {
11585
11827
  layerId: { type: "string", minLength: 1, description: "The layer whose mask to load (see list_layers)." },
11828
+ source: { type: "string", enum: ["pixels", "vector"], description: "pixels: the raster (layer) mask (default); vector: the vector mask's path." },
11586
11829
  invert: { type: "boolean", description: "Select what the mask hides instead (default false)." },
11587
11830
  mode: modeSchema
11588
11831
  },
@@ -11594,6 +11837,11 @@ function createImageSelectionTools(controller) {
11594
11837
  const id = String(args.layerId ?? "");
11595
11838
  const layer = controller.getLayer(id);
11596
11839
  if (!layer) throw new Error(`No layer "${id}". Call list_layers for ids.`);
11840
+ if (args.source === "vector") {
11841
+ if (!layer.vectorMask) throw new Error(`Layer "${id}" has no vector mask. Add one with add_vector_mask, or load its pixels mask (source pixels).`);
11842
+ controller.selectionFromVectorMask(id, parseSelectionMode(args.mode), args.invert === true);
11843
+ return summary(controller);
11844
+ }
11597
11845
  if (!layer.mask) throw new Error(`Layer "${id}" has no mask. Add one with add_layer_mask, or pick a layer with a mask.`);
11598
11846
  if (layer.mask.source) throw new Error(`Layer "${id}" has a computed (depth) mask, not a painted one, so it cannot be loaded as a selection.`);
11599
11847
  controller.selectionFromLayerMask(id, parseSelectionMode(args.mode), args.invert === true);
@@ -12087,6 +12335,22 @@ function selectionShade(selection, doc) {
12087
12335
  context.putImageData(pixels, 0, 0);
12088
12336
  return canvas;
12089
12337
  }
12338
+ function maskViewCanvas(controller, doc) {
12339
+ const view = controller.getMaskView();
12340
+ const layer = view ? controller.getLayer(view.layerId) : null;
12341
+ const { width, height } = controller.getState().imageDimensions;
12342
+ const mask = layer ? layerMaskSelection(layer, Math.round(width), Math.round(height)) : null;
12343
+ if (!view || !mask) return null;
12344
+ const canvas = doc.createElement("canvas");
12345
+ canvas.width = mask.width;
12346
+ canvas.height = mask.height;
12347
+ const context = canvas.getContext("2d");
12348
+ if (!context) return null;
12349
+ const pixels = context.createImageData(mask.width, mask.height);
12350
+ pixels.data.set(view.mode === "gray" ? selectionToPixels(mask, "gray").data : quickMaskPixels(mask));
12351
+ context.putImageData(pixels, 0, 0);
12352
+ return canvas;
12353
+ }
12090
12354
  async function renderImageRegion(controller, request) {
12091
12355
  const state = controller.getState();
12092
12356
  const k = Math.min(1, request.scale);
@@ -12109,6 +12373,8 @@ async function renderImageRegion(controller, request) {
12109
12373
  context.drawImage(view, rect.x * kx, rect.y * ky, rect.width * kx, rect.height * ky, 0, 0, out.width, out.height);
12110
12374
  if (request.includeOverlays) {
12111
12375
  const s = request.scale;
12376
+ const maskView = maskViewCanvas(controller, document);
12377
+ if (maskView) context.drawImage(maskView, rect.x, rect.y, rect.width, rect.height, 0, 0, out.width, out.height);
12112
12378
  const selection = state.selection;
12113
12379
  if (selection?.bounds && selection.width === state.imageDimensions.width && selection.height === state.imageDimensions.height) {
12114
12380
  const shade = selectionShade(selection, document);
@@ -12136,7 +12402,7 @@ function createImageCaptureSurface(controller) {
12136
12402
  units: "image pixels",
12137
12403
  content: whole,
12138
12404
  selection: () => ({ rect: controller.getState().selection?.bounds ?? null }),
12139
- overlays: "a shade over unselected pixels and the crop box outline",
12405
+ overlays: "a shade over unselected pixels, the crop box outline and a layer mask view (set_mask_view)",
12140
12406
  maxScale: 8,
12141
12407
  render: (request) => renderImageRegion(controller, request)
12142
12408
  };
@@ -12442,6 +12708,7 @@ function describeLayer(layer, activeId, boundsOf) {
12442
12708
  clipped: Boolean(layer.clipped),
12443
12709
  locks: layer.locks ? { ...layer.locks } : {},
12444
12710
  mask: maskSummary(layer.mask),
12711
+ vectorMask: layer.vectorMask ? { enabled: layer.vectorMask.enabled !== false, linked: layer.vectorMask.linked !== false, inverted: layer.vectorMask.inverted === true, components: layer.vectorMask.components.length, bounds: vectorMaskBounds(layer.vectorMask) } : null,
12445
12712
  effects: layer.effects ? { enabled: layer.effects.enabled, scale: layer.effects.scale ?? 1, items: layer.effects.items.map((e) => ({ id: e.id, kind: e.kind, enabled: e.enabled })) } : null,
12446
12713
  active: layer.id === activeId
12447
12714
  };
@@ -12824,6 +13091,27 @@ function createImageLayerTools(controller) {
12824
13091
  return { layer: describe(id) };
12825
13092
  }
12826
13093
  };
13094
+ const maskView = {
13095
+ name: "set_mask_view",
13096
+ title: "Show a layer mask",
13097
+ description: "Shows a layer's mask on the canvas to check it: gray (the mask alone, white = revealed, as Alt-clicking its thumbnail does), overlay (a red tint over what it hides, as the backslash key does) or off (the image again). It changes only the view: no undo step, nothing saved, and render_preview and exports still show the image. Look with capture_view includeOverlays true, then turn it off. Fails when the layer has no painted mask. Returns the view.",
13098
+ inputSchema: {
13099
+ type: "object",
13100
+ properties: {
13101
+ view: { type: "string", enum: ["gray", "overlay", "off"], description: "gray, overlay or off." },
13102
+ layerId: { ...LAYER_ID2, description: "The layer whose mask to show (default the active layer; see list_layers). Ignored for off." }
13103
+ },
13104
+ required: ["view"],
13105
+ additionalProperties: false
13106
+ },
13107
+ annotations: { title: "Show a layer mask", ...WRITE_IDEMPOTENT2 },
13108
+ handler: ({ view, layerId }) => {
13109
+ if (view !== "gray" && view !== "overlay" && view !== "off") throw new Error(`Unknown view "${String(view)}". Use gray, overlay or off.`);
13110
+ const id = view === "off" || layerId === void 0 ? void 0 : exists(layerId);
13111
+ const shown = controller.setMaskView(view, id);
13112
+ return { view: shown?.mode ?? "off", layerId: shown?.layerId ?? null };
13113
+ }
13114
+ };
12827
13115
  const maskProps = {
12828
13116
  name: "set_layer_mask_properties",
12829
13117
  title: "Set layer mask properties",
@@ -12964,6 +13252,7 @@ function createImageLayerTools(controller) {
12964
13252
  addMask,
12965
13253
  applyMask,
12966
13254
  removeMask,
13255
+ maskView,
12967
13256
  maskProps,
12968
13257
  viaCopy,
12969
13258
  rasterize,
@@ -13304,171 +13593,422 @@ function createImageEffectTools(controller) {
13304
13593
  return [list, add2, update, remove, setEnabled, light];
13305
13594
  }
13306
13595
 
13307
- // src/image-editor/layers/paint-ui.ts
13308
- var plain2 = { kind: "plain" };
13309
- var BRUSH_OPTION_CONTROLS = [
13310
- { id: "size", label: "Size", section: "layer", min: 1, max: 500, step: 1, default: 20, decimals: 0, track: plain2, unit: "px" },
13311
- { id: "hardness", label: "Hardness", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13312
- { id: "opacity", label: "Opacity", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13313
- { id: "flow", label: "Flow", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13314
- { id: "smoothing", label: "Smoothing", section: "layer", min: 0, max: 0.95, step: 0.01, default: 0, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13315
- ];
13316
- var MIXER_OPTION_CONTROLS = [
13317
- { id: "wet", label: "Wet", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13318
- { id: "load", label: "Load", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13319
- { id: "mix", label: "Mix", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13320
- ];
13321
- var MIXER_MIXING_OPTIONS = [
13322
- { id: "rgb", label: "RGB", title: "Mix colours as RGB averages" },
13323
- { id: "pigment", label: "Pigment", title: "Mix colours like paint: blue and yellow make green" }
13324
- ];
13325
- var PAINT_TOOLS = [
13326
- { id: "move", label: "Move", shortcut: "V" },
13327
- { id: "brush", label: "Brush", shortcut: "B" },
13328
- { id: "mixer", label: "Mixer", shortcut: "Shift+B" },
13329
- { id: "eraser", label: "Eraser", shortcut: "E" },
13330
- { id: "clone", label: "Clone", shortcut: "S" },
13331
- { id: "heal", label: "Heal", shortcut: "Shift+J" },
13332
- { id: "spot-heal", label: "Spot Heal", shortcut: "J" },
13333
- { id: "smudge", label: "Smudge", shortcut: "" },
13334
- { id: "dodge", label: "Dodge", shortcut: "O" },
13335
- { id: "burn", label: "Burn", shortcut: "Shift+O" },
13336
- { id: "sponge", label: "Sponge", shortcut: "Shift+O" },
13337
- { id: "blur", label: "Blur", shortcut: "" },
13338
- { id: "sharpen", label: "Sharpen", shortcut: "" },
13339
- { id: "history", label: "History", shortcut: "Y" }
13340
- ];
13341
- var isPaintTool = (tool) => PAINT_TOOLS.some((t) => t.id === tool);
13342
- var isRetouchPaintTool = (tool) => tool === "clone" || tool === "heal" || tool === "spot-heal" || tool === "smudge" || tool === "dodge" || tool === "burn" || tool === "sponge" || tool === "blur" || tool === "sharpen" || tool === "history";
13343
- var RETOUCH_OPTION_CONTROLS = [
13344
- { id: "exposure", label: "Exposure", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%", tools: ["dodge", "burn"] },
13345
- { id: "strength", label: "Strength", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%", tools: ["smudge", "blur", "sharpen"] },
13346
- { id: "historySteps", label: "Steps back", section: "layer", min: 0, max: 50, step: 1, default: 0, decimals: 0, track: plain2, unit: "", tools: ["history"] }
13347
- ];
13348
- var CLONE_TRANSFORM_CONTROLS = [
13349
- { id: "width", label: "Source W", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13350
- { id: "height", label: "Source H", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13351
- { id: "angle", label: "Source angle", section: "layer", min: -180, max: 180, step: 1, default: 0, decimals: 0, track: plain2, unit: "\xB0" }
13352
- ];
13353
- function cloneTransformView(controller) {
13354
- const t = controller.getState().paint.cloneTransform;
13355
- return {
13356
- width: Math.round(Math.abs(t.scaleX) * 100),
13357
- height: Math.round(Math.abs(t.scaleY) * 100),
13358
- angle: t.rotate,
13359
- flipH: t.scaleX < 0,
13360
- flipV: t.scaleY < 0,
13361
- identity: t.scaleX === 1 && t.scaleY === 1 && t.rotate === 0
13362
- };
13363
- }
13364
- function setCloneTransformValue(controller, id, value) {
13365
- const t = controller.getState().paint.cloneTransform;
13366
- if (id === "width") controller.setCloneTransform({ scaleX: Math.sign(t.scaleX) * (value ?? 100) / 100 });
13367
- else if (id === "height") controller.setCloneTransform({ scaleY: Math.sign(t.scaleY) * (value ?? 100) / 100 });
13368
- else if (id === "angle") controller.setCloneTransform({ rotate: value ?? 0 });
13369
- else if (id === "flipH") controller.setCloneTransform({ scaleX: -t.scaleX });
13370
- else controller.setCloneTransform({ scaleY: -t.scaleY });
13596
+ // src/image-editor/selection/gesture.ts
13597
+ function snapTo45(from, point) {
13598
+ const dx = point.x - from.x;
13599
+ const dy = point.y - from.y;
13600
+ const len = Math.hypot(dx, dy);
13601
+ if (len === 0) return point;
13602
+ const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
13603
+ return { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len };
13371
13604
  }
13372
- var RETOUCH_CHOICES = [
13373
- {
13374
- id: "sample",
13375
- label: "Sample",
13376
- tools: ["clone", "heal", "spot-heal"],
13377
- options: [
13378
- { id: "current", label: "Current", title: "Sample the layer being retouched" },
13379
- { id: "current-below", label: "Current & Below", title: "Sample this layer and the layers under it" },
13380
- { id: "all", label: "All", title: "Sample every layer (retouch onto an empty layer)" }
13381
- ]
13382
- },
13383
- {
13384
- id: "spotType",
13385
- label: "Type",
13386
- tools: ["spot-heal"],
13387
- options: [
13388
- { id: "proximity-match", label: "Proximity Match", title: "Copy the best matching patch nearby" },
13389
- { id: "content-aware", label: "Content-Aware", title: "Synthesise texture and edges from patches around the spot" },
13390
- { id: "smooth", label: "Smooth", title: "Fill smoothly from the edges" }
13391
- ]
13392
- },
13393
- {
13394
- id: "range",
13395
- label: "Range",
13396
- tools: ["dodge", "burn"],
13397
- options: [
13398
- { id: "shadows", label: "Shadows", title: "Change the dark tones most" },
13399
- { id: "midtones", label: "Midtones", title: "Change the middle tones most" },
13400
- { id: "highlights", label: "Highlights", title: "Change the light tones most" }
13401
- ]
13402
- },
13403
- {
13404
- id: "spongeMode",
13405
- label: "Mode",
13406
- tools: ["sponge"],
13407
- options: [
13408
- { id: "desaturate", label: "Desaturate", title: "Take colour out" },
13409
- { id: "saturate", label: "Saturate", title: "Add colour" }
13410
- ]
13605
+ var SelectionGesture = class {
13606
+ constructor(controller, getOptions) {
13607
+ this.controller = controller;
13608
+ this.getOptions = getOptions;
13411
13609
  }
13412
- ];
13413
- var RETOUCH_TOGGLES = [
13414
- { id: "aligned", label: "Aligned", tools: ["clone", "heal"] },
13415
- { id: "protectTones", label: "Protect tones", tools: ["dodge", "burn"] },
13416
- { id: "vibrance", label: "Vibrance", tools: ["sponge"] }
13417
- ];
13418
- function setRetouchChoice(controller, id, value) {
13419
- controller.setPaintOptions({ retouch: { [id]: value } });
13420
- }
13421
- function cloneSourceLabel(controller) {
13422
- const cs = controller.getState().paint.cloneSource;
13423
- return cs ? `Source ${cs.x}, ${cs.y}${cs.offset ? ` (offset ${cs.offset.x}, ${cs.offset.y})` : ""}` : "Alt-click to set the source";
13424
- }
13425
- function rgbUnitToHex(c) {
13426
- return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
13427
- }
13428
- function sizeStep(size) {
13429
- return size < 10 ? 1 : size < 50 ? 5 : size < 100 ? 10 : size < 200 ? 25 : size < 500 ? 50 : 100;
13430
- }
13431
- var isTyping = (t) => {
13432
- const el = t;
13433
- return Boolean(el && (el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"));
13434
- };
13435
- function handlePaintShortcut(controller, e) {
13436
- if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return false;
13437
- const brush = controller.getState().paint.brush;
13438
- const set = (b) => controller.setPaintOptions({ brush: b });
13439
- const key = e.key;
13440
- switch (key.toLowerCase()) {
13441
- case "q":
13442
- controller.setQuickMaskMode(!controller.quickMaskEditing);
13443
- return true;
13444
- case "b":
13445
- controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
13446
- return true;
13447
- case "e":
13448
- controller.setTool("eraser");
13449
- return true;
13450
- case "v":
13451
- controller.setTool("move");
13452
- return true;
13453
- case "s":
13454
- controller.setTool("clone");
13455
- return true;
13456
- case "j":
13457
- controller.setTool(e.shiftKey || key === "J" ? controller.getState().activeTool === "heal" ? "spot-heal" : "heal" : "spot-heal");
13458
- return true;
13459
- case "o": {
13460
- const cur = controller.getState().activeTool;
13461
- const next = e.shiftKey || key === "O" ? cur === "dodge" ? "burn" : cur === "burn" ? "sponge" : "dodge" : "dodge";
13462
- controller.setTool(next);
13463
- return true;
13464
- }
13465
- case "y":
13466
- controller.setTool("history");
13467
- return true;
13468
- case "d":
13469
- controller.resetPaintColors();
13470
- return true;
13471
- case "x":
13610
+ controller;
13611
+ getOptions;
13612
+ start = null;
13613
+ points = [];
13614
+ current = null;
13615
+ modifiers = {};
13616
+ /** Polygonal lasso corners while one is being drawn. */
13617
+ polygon = null;
13618
+ pressedAt = null;
13619
+ freehand = false;
13620
+ /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
13621
+ closeRadius = 8;
13622
+ /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
13623
+ error = null;
13624
+ get active() {
13625
+ return this.start !== null || this.polygon !== null;
13626
+ }
13627
+ /** The outline of the shape being drawn, or `null`. */
13628
+ get draftPath() {
13629
+ if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
13630
+ if (!this.start || !this.current) return null;
13631
+ const { tool } = this.getOptions();
13632
+ if (tool === "lasso") return this.points.length > 1 ? this.points.slice() : null;
13633
+ const rect = normalizeRect({
13634
+ x: this.start.x,
13635
+ y: this.start.y,
13636
+ width: this.current.x - this.start.x,
13637
+ height: this.current.y - this.start.y
13638
+ });
13639
+ if (rect.width < 1 && rect.height < 1) return null;
13640
+ if (tool === "ellipse") return ellipsePoints(rect);
13641
+ return [
13642
+ { x: rect.x, y: rect.y },
13643
+ { x: rect.x + rect.width, y: rect.y },
13644
+ { x: rect.x + rect.width, y: rect.y + rect.height },
13645
+ { x: rect.x, y: rect.y + rect.height }
13646
+ ];
13647
+ }
13648
+ /** Pointer down. Resolves once a wand click has been applied. */
13649
+ async begin(point, modifiers = {}) {
13650
+ const options = this.getOptions();
13651
+ this.error = null;
13652
+ if (this.polygon && options.tool !== "polygon") this.cancel();
13653
+ if (options.tool === "polygon") {
13654
+ this.pressedAt = point;
13655
+ this.freehand = false;
13656
+ if (!this.polygon) {
13657
+ this.modifiers = modifiers;
13658
+ this.polygon = [point];
13659
+ this.current = point;
13660
+ return;
13661
+ }
13662
+ const last = this.polygon[this.polygon.length - 1];
13663
+ const p = modifiers.shiftKey ? snapTo45(last, point) : point;
13664
+ const first = this.polygon[0];
13665
+ if (this.polygon.length >= 3 && Math.hypot(p.x - first.x, p.y - first.y) <= this.closeRadius) {
13666
+ this.close();
13667
+ return;
13668
+ }
13669
+ if (Math.hypot(p.x - last.x, p.y - last.y) >= 0.5) this.polygon.push(p);
13670
+ this.current = p;
13671
+ if ((modifiers.detail ?? 1) >= 2) this.close();
13672
+ return;
13673
+ }
13674
+ this.modifiers = modifiers;
13675
+ if (options.tool === "row" || options.tool === "column") {
13676
+ this.start = null;
13677
+ try {
13678
+ this.controller.selectLine(options.tool, options.tool === "row" ? point.y : point.x, {
13679
+ mode: selectionModeFromModifiers(modifiers, options.mode),
13680
+ feather: options.feather ?? 0
13681
+ });
13682
+ } catch (err) {
13683
+ this.error = err instanceof Error ? err.message : String(err);
13684
+ }
13685
+ return;
13686
+ }
13687
+ if (options.tool === "wand" || options.tool === "range") {
13688
+ this.start = null;
13689
+ const common = {
13690
+ sampleMerged: options.sampleMerged,
13691
+ sample: options.sample,
13692
+ feather: options.feather ?? 0,
13693
+ mode: selectionModeFromModifiers(modifiers, options.mode)
13694
+ };
13695
+ try {
13696
+ if (options.tool === "range") {
13697
+ const pixels = await this.controller.getSamplePixels(common);
13698
+ if (!pixels) throw new Error("The image pixels are not available to sample.");
13699
+ const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point.x)));
13700
+ const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point.y)));
13701
+ const p = (y * pixels.width + x) * 4;
13702
+ const color = [pixels.data[p], pixels.data[p + 1], pixels.data[p + 2]];
13703
+ await this.controller.selectColorRange([{ color, at: point }], {
13704
+ ...common,
13705
+ fuzziness: options.fuzziness ?? 40,
13706
+ localized: options.localized ? options.localized : void 0,
13707
+ invert: options.rangeInvert ?? false
13708
+ });
13709
+ return;
13710
+ }
13711
+ await this.controller.magicWand(point, {
13712
+ ...common,
13713
+ tolerance: options.tolerance,
13714
+ contiguous: options.contiguous,
13715
+ antiAlias: options.antiAlias,
13716
+ distance: options.distance
13717
+ });
13718
+ } catch (err) {
13719
+ this.error = err instanceof Error ? err.message : String(err);
13720
+ }
13721
+ return;
13722
+ }
13723
+ this.start = point;
13724
+ this.current = point;
13725
+ this.points = [point];
13726
+ }
13727
+ /** Pointer move; returns true when the draft changed (re-render). */
13728
+ move(point, modifiers = {}) {
13729
+ if (this.polygon) {
13730
+ const last = this.polygon[this.polygon.length - 1];
13731
+ if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
13732
+ this.freehand = true;
13733
+ if (Math.hypot(point.x - last.x, point.y - last.y) >= 0.5) this.polygon.push(point);
13734
+ this.current = null;
13735
+ return true;
13736
+ }
13737
+ this.current = modifiers.shiftKey ? snapTo45(last, point) : point;
13738
+ return true;
13739
+ }
13740
+ if (!this.start) return false;
13741
+ this.current = point;
13742
+ if (this.getOptions().tool === "lasso") {
13743
+ const last = this.points[this.points.length - 1];
13744
+ if (Math.hypot(point.x - last.x, point.y - last.y) < 0.5) return false;
13745
+ this.points.push(point);
13746
+ }
13747
+ return true;
13748
+ }
13749
+ /** Pointer up: commits the shape. Returns true when the selection changed. */
13750
+ end(point, modifiers) {
13751
+ if (this.polygon) {
13752
+ if (point && this.freehand) this.move(point);
13753
+ this.pressedAt = null;
13754
+ this.freehand = false;
13755
+ return false;
13756
+ }
13757
+ if (!this.start) return false;
13758
+ if (point) this.move(point);
13759
+ const options = this.getOptions();
13760
+ const mode = selectionModeFromModifiers(modifiers ?? this.modifiers, options.mode);
13761
+ const path = this.draftPath;
13762
+ const rect = normalizeRect({
13763
+ x: this.start.x,
13764
+ y: this.start.y,
13765
+ width: this.current.x - this.start.x,
13766
+ height: this.current.y - this.start.y
13767
+ });
13768
+ this.start = null;
13769
+ this.current = null;
13770
+ this.points = [];
13771
+ if (!path) return mode === "replace" ? this.controller.deselect() : false;
13772
+ const opts = { mode, antiAlias: options.antiAlias, feather: options.feather ?? 0 };
13773
+ if (options.tool === "lasso") return path.length >= 3 ? this.controller.selectPolygon(path, opts) : false;
13774
+ return options.tool === "ellipse" ? this.controller.selectEllipse(rect, opts) : this.controller.selectRect(rect, opts);
13775
+ }
13776
+ /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
13777
+ close() {
13778
+ const polygon = this.polygon;
13779
+ if (!polygon) return false;
13780
+ const options = this.getOptions();
13781
+ this.cancel();
13782
+ if (polygon.length < 3) return false;
13783
+ return this.controller.selectPolygon(polygon, {
13784
+ mode: selectionModeFromModifiers(this.modifiers, options.mode),
13785
+ antiAlias: options.antiAlias,
13786
+ feather: options.feather ?? 0
13787
+ });
13788
+ }
13789
+ /**
13790
+ * Keys for a polygonal lasso being drawn: Enter closes it, Escape cancels, Backspace / Delete
13791
+ * removes the last corner. Returns true when the key was used (the host prevents its default).
13792
+ */
13793
+ key(key) {
13794
+ if (!this.polygon) return false;
13795
+ if (key === "Enter") this.close();
13796
+ else if (key === "Escape") this.cancel();
13797
+ else if (key === "Backspace" || key === "Delete") {
13798
+ this.polygon.pop();
13799
+ if (!this.polygon.length) this.cancel();
13800
+ } else return false;
13801
+ return true;
13802
+ }
13803
+ /** Abandons a drag (or a polygonal lasso) without changing the selection. */
13804
+ cancel() {
13805
+ this.start = null;
13806
+ this.current = null;
13807
+ this.points = [];
13808
+ this.polygon = null;
13809
+ this.pressedAt = null;
13810
+ this.freehand = false;
13811
+ }
13812
+ };
13813
+ function handleSelectionShortcut(controller, e, onError) {
13814
+ if (!(e.ctrlKey || e.metaKey)) return false;
13815
+ const key = e.key.toLowerCase();
13816
+ const run = (fn) => {
13817
+ try {
13818
+ fn();
13819
+ } catch (err) {
13820
+ onError?.(err instanceof Error ? err.message : String(err));
13821
+ }
13822
+ return true;
13823
+ };
13824
+ if (key === "c") return run(() => controller.copyPixels({ merged: Boolean(e.shiftKey) }));
13825
+ if (key === "x" && !e.shiftKey) return run(() => controller.cutPixels());
13826
+ if (key === "v") return run(() => controller.pastePixels(e.shiftKey && e.altKey ? "into" : e.shiftKey ? "in-place" : "new-layer"));
13827
+ if (key === "d") return run(() => e.shiftKey ? controller.reselect() : controller.deselect());
13828
+ if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
13829
+ return false;
13830
+ }
13831
+ function handleMaskViewKey(controller, e) {
13832
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key !== "\\") return false;
13833
+ const id = controller.getState().activeLayerId;
13834
+ const layer = id ? controller.getLayer(id) : null;
13835
+ if (!layer?.mask || layer.mask.source) return false;
13836
+ const view = controller.getMaskView();
13837
+ controller.setMaskView(view?.layerId === layer.id && view.mode === "overlay" ? "off" : "overlay", layer.id);
13838
+ return true;
13839
+ }
13840
+ function handleQuickMaskKey(controller, e) {
13841
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
13842
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13843
+ return true;
13844
+ }
13845
+
13846
+ // src/image-editor/layers/paint-ui.ts
13847
+ var plain2 = { kind: "plain" };
13848
+ var BRUSH_OPTION_CONTROLS = [
13849
+ { id: "size", label: "Size", section: "layer", min: 1, max: 500, step: 1, default: 20, decimals: 0, track: plain2, unit: "px" },
13850
+ { id: "hardness", label: "Hardness", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13851
+ { id: "opacity", label: "Opacity", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13852
+ { id: "flow", label: "Flow", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13853
+ { id: "smoothing", label: "Smoothing", section: "layer", min: 0, max: 0.95, step: 0.01, default: 0, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13854
+ ];
13855
+ var MIXER_OPTION_CONTROLS = [
13856
+ { id: "wet", label: "Wet", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13857
+ { id: "load", label: "Load", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13858
+ { id: "mix", label: "Mix", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13859
+ ];
13860
+ var MIXER_MIXING_OPTIONS = [
13861
+ { id: "rgb", label: "RGB", title: "Mix colours as RGB averages" },
13862
+ { id: "pigment", label: "Pigment", title: "Mix colours like paint: blue and yellow make green" }
13863
+ ];
13864
+ var PAINT_TOOLS = [
13865
+ { id: "move", label: "Move", shortcut: "V" },
13866
+ { id: "brush", label: "Brush", shortcut: "B" },
13867
+ { id: "mixer", label: "Mixer", shortcut: "Shift+B" },
13868
+ { id: "eraser", label: "Eraser", shortcut: "E" },
13869
+ { id: "clone", label: "Clone", shortcut: "S" },
13870
+ { id: "heal", label: "Heal", shortcut: "Shift+J" },
13871
+ { id: "spot-heal", label: "Spot Heal", shortcut: "J" },
13872
+ { id: "smudge", label: "Smudge", shortcut: "" },
13873
+ { id: "dodge", label: "Dodge", shortcut: "O" },
13874
+ { id: "burn", label: "Burn", shortcut: "Shift+O" },
13875
+ { id: "sponge", label: "Sponge", shortcut: "Shift+O" },
13876
+ { id: "blur", label: "Blur", shortcut: "" },
13877
+ { id: "sharpen", label: "Sharpen", shortcut: "" },
13878
+ { id: "history", label: "History", shortcut: "Y" }
13879
+ ];
13880
+ var isPaintTool = (tool) => PAINT_TOOLS.some((t) => t.id === tool);
13881
+ var isRetouchPaintTool = (tool) => tool === "clone" || tool === "heal" || tool === "spot-heal" || tool === "smudge" || tool === "dodge" || tool === "burn" || tool === "sponge" || tool === "blur" || tool === "sharpen" || tool === "history";
13882
+ var RETOUCH_OPTION_CONTROLS = [
13883
+ { id: "exposure", label: "Exposure", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%", tools: ["dodge", "burn"] },
13884
+ { id: "strength", label: "Strength", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%", tools: ["smudge", "blur", "sharpen"] },
13885
+ { id: "historySteps", label: "Steps back", section: "layer", min: 0, max: 50, step: 1, default: 0, decimals: 0, track: plain2, unit: "", tools: ["history"] }
13886
+ ];
13887
+ var CLONE_TRANSFORM_CONTROLS = [
13888
+ { id: "width", label: "Source W", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13889
+ { id: "height", label: "Source H", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13890
+ { id: "angle", label: "Source angle", section: "layer", min: -180, max: 180, step: 1, default: 0, decimals: 0, track: plain2, unit: "\xB0" }
13891
+ ];
13892
+ function cloneTransformView(controller) {
13893
+ const t = controller.getState().paint.cloneTransform;
13894
+ return {
13895
+ width: Math.round(Math.abs(t.scaleX) * 100),
13896
+ height: Math.round(Math.abs(t.scaleY) * 100),
13897
+ angle: t.rotate,
13898
+ flipH: t.scaleX < 0,
13899
+ flipV: t.scaleY < 0,
13900
+ identity: t.scaleX === 1 && t.scaleY === 1 && t.rotate === 0
13901
+ };
13902
+ }
13903
+ function setCloneTransformValue(controller, id, value) {
13904
+ const t = controller.getState().paint.cloneTransform;
13905
+ if (id === "width") controller.setCloneTransform({ scaleX: Math.sign(t.scaleX) * (value ?? 100) / 100 });
13906
+ else if (id === "height") controller.setCloneTransform({ scaleY: Math.sign(t.scaleY) * (value ?? 100) / 100 });
13907
+ else if (id === "angle") controller.setCloneTransform({ rotate: value ?? 0 });
13908
+ else if (id === "flipH") controller.setCloneTransform({ scaleX: -t.scaleX });
13909
+ else controller.setCloneTransform({ scaleY: -t.scaleY });
13910
+ }
13911
+ var RETOUCH_CHOICES = [
13912
+ {
13913
+ id: "sample",
13914
+ label: "Sample",
13915
+ tools: ["clone", "heal", "spot-heal"],
13916
+ options: [
13917
+ { id: "current", label: "Current", title: "Sample the layer being retouched" },
13918
+ { id: "current-below", label: "Current & Below", title: "Sample this layer and the layers under it" },
13919
+ { id: "all", label: "All", title: "Sample every layer (retouch onto an empty layer)" }
13920
+ ]
13921
+ },
13922
+ {
13923
+ id: "spotType",
13924
+ label: "Type",
13925
+ tools: ["spot-heal"],
13926
+ options: [
13927
+ { id: "proximity-match", label: "Proximity Match", title: "Copy the best matching patch nearby" },
13928
+ { id: "content-aware", label: "Content-Aware", title: "Synthesise texture and edges from patches around the spot" },
13929
+ { id: "smooth", label: "Smooth", title: "Fill smoothly from the edges" }
13930
+ ]
13931
+ },
13932
+ {
13933
+ id: "range",
13934
+ label: "Range",
13935
+ tools: ["dodge", "burn"],
13936
+ options: [
13937
+ { id: "shadows", label: "Shadows", title: "Change the dark tones most" },
13938
+ { id: "midtones", label: "Midtones", title: "Change the middle tones most" },
13939
+ { id: "highlights", label: "Highlights", title: "Change the light tones most" }
13940
+ ]
13941
+ },
13942
+ {
13943
+ id: "spongeMode",
13944
+ label: "Mode",
13945
+ tools: ["sponge"],
13946
+ options: [
13947
+ { id: "desaturate", label: "Desaturate", title: "Take colour out" },
13948
+ { id: "saturate", label: "Saturate", title: "Add colour" }
13949
+ ]
13950
+ }
13951
+ ];
13952
+ var RETOUCH_TOGGLES = [
13953
+ { id: "aligned", label: "Aligned", tools: ["clone", "heal"] },
13954
+ { id: "protectTones", label: "Protect tones", tools: ["dodge", "burn"] },
13955
+ { id: "vibrance", label: "Vibrance", tools: ["sponge"] }
13956
+ ];
13957
+ function setRetouchChoice(controller, id, value) {
13958
+ controller.setPaintOptions({ retouch: { [id]: value } });
13959
+ }
13960
+ function cloneSourceLabel(controller) {
13961
+ const cs = controller.getState().paint.cloneSource;
13962
+ return cs ? `Source ${cs.x}, ${cs.y}${cs.offset ? ` (offset ${cs.offset.x}, ${cs.offset.y})` : ""}` : "Alt-click to set the source";
13963
+ }
13964
+ function rgbUnitToHex(c) {
13965
+ return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
13966
+ }
13967
+ function sizeStep(size) {
13968
+ return size < 10 ? 1 : size < 50 ? 5 : size < 100 ? 10 : size < 200 ? 25 : size < 500 ? 50 : 100;
13969
+ }
13970
+ var isTyping = (t) => {
13971
+ const el = t;
13972
+ return Boolean(el && (el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"));
13973
+ };
13974
+ function handlePaintShortcut(controller, e) {
13975
+ if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return false;
13976
+ const brush = controller.getState().paint.brush;
13977
+ const set = (b) => controller.setPaintOptions({ brush: b });
13978
+ const key = e.key;
13979
+ if (handleMaskViewKey(controller, e)) return true;
13980
+ switch (key.toLowerCase()) {
13981
+ case "q":
13982
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13983
+ return true;
13984
+ case "b":
13985
+ controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
13986
+ return true;
13987
+ case "e":
13988
+ controller.setTool("eraser");
13989
+ return true;
13990
+ case "v":
13991
+ controller.setTool("move");
13992
+ return true;
13993
+ case "s":
13994
+ controller.setTool("clone");
13995
+ return true;
13996
+ case "j":
13997
+ controller.setTool(e.shiftKey || key === "J" ? controller.getState().activeTool === "heal" ? "spot-heal" : "heal" : "spot-heal");
13998
+ return true;
13999
+ case "o": {
14000
+ const cur = controller.getState().activeTool;
14001
+ const next = e.shiftKey || key === "O" ? cur === "dodge" ? "burn" : cur === "burn" ? "sponge" : "dodge" : "dodge";
14002
+ controller.setTool(next);
14003
+ return true;
14004
+ }
14005
+ case "y":
14006
+ controller.setTool("history");
14007
+ return true;
14008
+ case "d":
14009
+ controller.resetPaintColors();
14010
+ return true;
14011
+ case "x":
13472
14012
  controller.swapPaintColors();
13473
14013
  return true;
13474
14014
  }
@@ -15347,15 +15887,130 @@ function createImageShapeTools(controller) {
15347
15887
  annotations: { title: "Delete a shape component", ...DESTRUCTIVE6 },
15348
15888
  handler: ({ layerId, componentIndex }) => controller.removeShapeComponent(shapeId(layerId), componentIndex)
15349
15889
  };
15350
- const merge = {
15351
- name: "merge_shape_components",
15352
- title: "Merge shape components",
15353
- description: "Merge Shape Components: the area a shape\u2019s components combine to (after their path operations) becomes one plain component; live shapes become paths. The look is unchanged. One undo step. Returns the shape.",
15354
- inputSchema: { type: "object", properties: { layerId: LAYER_ID5 }, required: ["layerId"], additionalProperties: false },
15355
- annotations: { title: "Merge shape components", ...DESTRUCTIVE6 },
15356
- handler: ({ layerId }) => controller.mergeShapeComponents(shapeId(layerId))
15890
+ const merge = {
15891
+ name: "merge_shape_components",
15892
+ title: "Merge shape components",
15893
+ description: "Merge Shape Components: the area a shape\u2019s components combine to (after their path operations) becomes one plain component; live shapes become paths. The look is unchanged. One undo step. Returns the shape.",
15894
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID5 }, required: ["layerId"], additionalProperties: false },
15895
+ annotations: { title: "Merge shape components", ...DESTRUCTIVE6 },
15896
+ handler: ({ layerId }) => controller.mergeShapeComponents(shapeId(layerId))
15897
+ };
15898
+ const layerArg = (v) => {
15899
+ const id = String(v ?? "");
15900
+ if (!controller.getLayer(id)) throw new Error(`No layer "${id}". Call list_layers for ids.`);
15901
+ return id;
15902
+ };
15903
+ const pathSpec = (args) => {
15904
+ const spec = { ...geometry(args) };
15905
+ if (args.components !== void 0) spec.components = args.components.map(geometry);
15906
+ if (args.fillRule !== void 0) spec.fillRule = args.fillRule;
15907
+ return spec;
15908
+ };
15909
+ const PATH_PROPS = {
15910
+ ...GEOMETRY2,
15911
+ components: {
15912
+ type: "array",
15913
+ minItems: 1,
15914
+ description: "Several components instead of shape / subpaths, bottom first, each with its path operation (combine, subtract, intersect, exclude).",
15915
+ items: { type: "object", description: "One component.", properties: { ...GEOMETRY2, op: OP }, additionalProperties: false }
15916
+ },
15917
+ fillRule: { type: "string", enum: ["nonzero", "evenodd"], description: "Winding rule within a component (default nonzero)." }
15918
+ };
15919
+ const LAYER = { type: "string", minLength: 1, description: "The layer (see list_layers). Not a shape layer: its path already is its vector mask." };
15920
+ const getVectorMask = {
15921
+ name: "get_vector_mask",
15922
+ title: "Read a vector mask",
15923
+ description: "Reads a layer's vector mask: enabled, linked, inverted, fill rule, density, feather, the bounds of its path and its components (op, live shape, subpaths in document pixels). Use it before set_vector_mask_path to edit the path. Returns null when the layer has none.",
15924
+ inputSchema: { type: "object", properties: { layerId: LAYER }, required: ["layerId"], additionalProperties: false },
15925
+ annotations: { title: "Read a vector mask", ...READ10 },
15926
+ handler: ({ layerId }) => ({ vectorMask: controller.getVectorMask(layerArg(layerId)) })
15927
+ };
15928
+ const addVectorMask = {
15929
+ name: "add_vector_mask",
15930
+ title: "Add a vector mask",
15931
+ description: "Layer \u203A Vector Mask: a path that reveals the layer inside it and stays sharp at any size, on top of any raster mask (both apply). from path takes a live shape, free subpaths or components like add_shape_layer; reveal-all and hide-all start with an empty path. Use it for hard-edged geometric or traced cutouts that must stay editable; use add_layer_mask for soft or painted ones. A linked vector mask moves and transforms with the layer. Replaces an existing vector mask. One undo step. Returns the vector mask.",
15932
+ inputSchema: {
15933
+ type: "object",
15934
+ $defs: DEFS2,
15935
+ properties: {
15936
+ layerId: LAYER,
15937
+ from: { type: "string", enum: ["path", "reveal-all", "hide-all"], description: "path (default when a shape, subpaths or components are given), reveal-all or hide-all (an empty path)." },
15938
+ ...PATH_PROPS
15939
+ },
15940
+ required: ["layerId"],
15941
+ additionalProperties: false
15942
+ },
15943
+ annotations: { title: "Add a vector mask", ...WRITE8 },
15944
+ handler: (args) => {
15945
+ const id = layerArg(args.layerId);
15946
+ const spec = pathSpec(args);
15947
+ const hasPath = spec.components?.length || spec.shape !== void 0 || spec.subpaths !== void 0;
15948
+ const from = args.from ?? (hasPath ? "path" : "reveal-all");
15949
+ controller.addVectorMask(id, from, spec);
15950
+ return { vectorMask: controller.getVectorMask(id) };
15951
+ }
15952
+ };
15953
+ const setVectorMaskPath = {
15954
+ name: "set_vector_mask_path",
15955
+ title: "Replace a vector mask path",
15956
+ description: "Replaces the path of a layer's vector mask (shape, subpaths or components, document pixels); enabled, linked, inverted, density and feather are kept. Read the current path with get_vector_mask first to change part of it. One undo step. Returns the vector mask.",
15957
+ inputSchema: { type: "object", $defs: DEFS2, properties: { layerId: LAYER, ...PATH_PROPS }, required: ["layerId"], additionalProperties: false },
15958
+ annotations: { title: "Replace a vector mask path", ...WRITE_IDEMPOTENT5 },
15959
+ handler: (args) => {
15960
+ const id = layerArg(args.layerId);
15961
+ controller.setVectorMaskPath(id, pathSpec(args));
15962
+ return { vectorMask: controller.getVectorMask(id) };
15963
+ }
15964
+ };
15965
+ const setVectorMaskProps = {
15966
+ name: "set_vector_mask_properties",
15967
+ title: "Set vector mask properties",
15968
+ description: "Changes a layer's vector mask without touching its path: enabled (false ignores it), linked (false: it stays put when the layer moves or transforms), inverted (reveal outside the path), density 0..1 and feather in px (soften the edge without losing the path). One undo step. Returns the vector mask.",
15969
+ inputSchema: {
15970
+ type: "object",
15971
+ properties: {
15972
+ layerId: LAYER,
15973
+ enabled: { type: "boolean", description: "false ignores the vector mask but keeps it." },
15974
+ linked: { type: "boolean", description: "false keeps the path in place when the layer moves or transforms." },
15975
+ inverted: { type: "boolean", description: "true reveals outside the path instead." },
15976
+ density: { type: "number", minimum: 0, maximum: 1, description: "Density 0..1 (default 1): at 0.5 the hidden area still shows half." },
15977
+ feather: { type: "number", minimum: 0, maximum: 1e3, description: "Feather \u03C3 in px (default 0)." }
15978
+ },
15979
+ required: ["layerId"],
15980
+ additionalProperties: false
15981
+ },
15982
+ annotations: { title: "Set vector mask properties", ...WRITE_IDEMPOTENT5 },
15983
+ handler: ({ layerId, ...props }) => {
15984
+ const id = layerArg(layerId);
15985
+ controller.setVectorMaskProps(id, props);
15986
+ return { vectorMask: controller.getVectorMask(id) };
15987
+ }
15988
+ };
15989
+ const rasterizeVectorMask = {
15990
+ name: "rasterize_vector_mask",
15991
+ title: "Rasterize a vector mask",
15992
+ description: "Layer \u203A Rasterize \u203A Vector Mask: multiplies the path (with its density and feather) into the layer's raster mask, adding one if needed, and removes the vector mask, so the mask can be painted. The path can no longer be edited (undo_image brings it back). One undo step. Returns the layer's raster mask summary.",
15993
+ inputSchema: { type: "object", properties: { layerId: LAYER }, required: ["layerId"], additionalProperties: false },
15994
+ annotations: { title: "Rasterize a vector mask", ...DESTRUCTIVE6 },
15995
+ handler: ({ layerId }) => {
15996
+ const id = layerArg(layerId);
15997
+ const mask = controller.rasterizeVectorMask(id);
15998
+ return { layerId: id, mask: { defaultValue: mask.defaultValue, density: mask.density ?? 1, feather: mask.feather ?? 0, enabled: mask.enabled !== false } };
15999
+ }
16000
+ };
16001
+ const removeVectorMask = {
16002
+ name: "remove_vector_mask",
16003
+ title: "Delete a vector mask",
16004
+ description: "Deletes a layer's vector mask without applying it (the raster mask, if any, stays). Disable it instead with set_vector_mask_properties enabled false to keep it. One undo step. Returns the layer id.",
16005
+ inputSchema: { type: "object", properties: { layerId: LAYER }, required: ["layerId"], additionalProperties: false },
16006
+ annotations: { title: "Delete a vector mask", ...DESTRUCTIVE6 },
16007
+ handler: ({ layerId }) => {
16008
+ const id = layerArg(layerId);
16009
+ controller.removeVectorMask(id);
16010
+ return { layerId: id, vectorMask: null };
16011
+ }
15357
16012
  };
15358
- return [get, add2, update, setComponent, addComponent, removeComponent, merge];
16013
+ return [get, add2, update, setComponent, addComponent, removeComponent, merge, getVectorMask, addVectorMask, setVectorMaskPath, setVectorMaskProps, rasterizeVectorMask, removeVectorMask];
15359
16014
  }
15360
16015
 
15361
16016
  // src/image-editor/type-tools.ts
@@ -15837,247 +16492,6 @@ function removeCurvePoint(points, index) {
15837
16492
  }
15838
16493
  var round22 = (v) => Math.round(v * 100) / 100;
15839
16494
 
15840
- // src/image-editor/selection/gesture.ts
15841
- function snapTo45(from, point) {
15842
- const dx = point.x - from.x;
15843
- const dy = point.y - from.y;
15844
- const len = Math.hypot(dx, dy);
15845
- if (len === 0) return point;
15846
- const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
15847
- return { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len };
15848
- }
15849
- var SelectionGesture = class {
15850
- constructor(controller, getOptions) {
15851
- this.controller = controller;
15852
- this.getOptions = getOptions;
15853
- }
15854
- controller;
15855
- getOptions;
15856
- start = null;
15857
- points = [];
15858
- current = null;
15859
- modifiers = {};
15860
- /** Polygonal lasso corners while one is being drawn. */
15861
- polygon = null;
15862
- pressedAt = null;
15863
- freehand = false;
15864
- /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
15865
- closeRadius = 8;
15866
- /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
15867
- error = null;
15868
- get active() {
15869
- return this.start !== null || this.polygon !== null;
15870
- }
15871
- /** The outline of the shape being drawn, or `null`. */
15872
- get draftPath() {
15873
- if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
15874
- if (!this.start || !this.current) return null;
15875
- const { tool } = this.getOptions();
15876
- if (tool === "lasso") return this.points.length > 1 ? this.points.slice() : null;
15877
- const rect = normalizeRect({
15878
- x: this.start.x,
15879
- y: this.start.y,
15880
- width: this.current.x - this.start.x,
15881
- height: this.current.y - this.start.y
15882
- });
15883
- if (rect.width < 1 && rect.height < 1) return null;
15884
- if (tool === "ellipse") return ellipsePoints(rect);
15885
- return [
15886
- { x: rect.x, y: rect.y },
15887
- { x: rect.x + rect.width, y: rect.y },
15888
- { x: rect.x + rect.width, y: rect.y + rect.height },
15889
- { x: rect.x, y: rect.y + rect.height }
15890
- ];
15891
- }
15892
- /** Pointer down. Resolves once a wand click has been applied. */
15893
- async begin(point, modifiers = {}) {
15894
- const options = this.getOptions();
15895
- this.error = null;
15896
- if (this.polygon && options.tool !== "polygon") this.cancel();
15897
- if (options.tool === "polygon") {
15898
- this.pressedAt = point;
15899
- this.freehand = false;
15900
- if (!this.polygon) {
15901
- this.modifiers = modifiers;
15902
- this.polygon = [point];
15903
- this.current = point;
15904
- return;
15905
- }
15906
- const last = this.polygon[this.polygon.length - 1];
15907
- const p = modifiers.shiftKey ? snapTo45(last, point) : point;
15908
- const first = this.polygon[0];
15909
- if (this.polygon.length >= 3 && Math.hypot(p.x - first.x, p.y - first.y) <= this.closeRadius) {
15910
- this.close();
15911
- return;
15912
- }
15913
- if (Math.hypot(p.x - last.x, p.y - last.y) >= 0.5) this.polygon.push(p);
15914
- this.current = p;
15915
- if ((modifiers.detail ?? 1) >= 2) this.close();
15916
- return;
15917
- }
15918
- this.modifiers = modifiers;
15919
- if (options.tool === "row" || options.tool === "column") {
15920
- this.start = null;
15921
- try {
15922
- this.controller.selectLine(options.tool, options.tool === "row" ? point.y : point.x, {
15923
- mode: selectionModeFromModifiers(modifiers, options.mode),
15924
- feather: options.feather ?? 0
15925
- });
15926
- } catch (err) {
15927
- this.error = err instanceof Error ? err.message : String(err);
15928
- }
15929
- return;
15930
- }
15931
- if (options.tool === "wand" || options.tool === "range") {
15932
- this.start = null;
15933
- const common = {
15934
- sampleMerged: options.sampleMerged,
15935
- sample: options.sample,
15936
- feather: options.feather ?? 0,
15937
- mode: selectionModeFromModifiers(modifiers, options.mode)
15938
- };
15939
- try {
15940
- if (options.tool === "range") {
15941
- const pixels = await this.controller.getSamplePixels(common);
15942
- if (!pixels) throw new Error("The image pixels are not available to sample.");
15943
- const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point.x)));
15944
- const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point.y)));
15945
- const p = (y * pixels.width + x) * 4;
15946
- const color = [pixels.data[p], pixels.data[p + 1], pixels.data[p + 2]];
15947
- await this.controller.selectColorRange([{ color, at: point }], {
15948
- ...common,
15949
- fuzziness: options.fuzziness ?? 40,
15950
- localized: options.localized ? options.localized : void 0,
15951
- invert: options.rangeInvert ?? false
15952
- });
15953
- return;
15954
- }
15955
- await this.controller.magicWand(point, {
15956
- ...common,
15957
- tolerance: options.tolerance,
15958
- contiguous: options.contiguous,
15959
- antiAlias: options.antiAlias,
15960
- distance: options.distance
15961
- });
15962
- } catch (err) {
15963
- this.error = err instanceof Error ? err.message : String(err);
15964
- }
15965
- return;
15966
- }
15967
- this.start = point;
15968
- this.current = point;
15969
- this.points = [point];
15970
- }
15971
- /** Pointer move; returns true when the draft changed (re-render). */
15972
- move(point, modifiers = {}) {
15973
- if (this.polygon) {
15974
- const last = this.polygon[this.polygon.length - 1];
15975
- if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
15976
- this.freehand = true;
15977
- if (Math.hypot(point.x - last.x, point.y - last.y) >= 0.5) this.polygon.push(point);
15978
- this.current = null;
15979
- return true;
15980
- }
15981
- this.current = modifiers.shiftKey ? snapTo45(last, point) : point;
15982
- return true;
15983
- }
15984
- if (!this.start) return false;
15985
- this.current = point;
15986
- if (this.getOptions().tool === "lasso") {
15987
- const last = this.points[this.points.length - 1];
15988
- if (Math.hypot(point.x - last.x, point.y - last.y) < 0.5) return false;
15989
- this.points.push(point);
15990
- }
15991
- return true;
15992
- }
15993
- /** Pointer up: commits the shape. Returns true when the selection changed. */
15994
- end(point, modifiers) {
15995
- if (this.polygon) {
15996
- if (point && this.freehand) this.move(point);
15997
- this.pressedAt = null;
15998
- this.freehand = false;
15999
- return false;
16000
- }
16001
- if (!this.start) return false;
16002
- if (point) this.move(point);
16003
- const options = this.getOptions();
16004
- const mode = selectionModeFromModifiers(modifiers ?? this.modifiers, options.mode);
16005
- const path = this.draftPath;
16006
- const rect = normalizeRect({
16007
- x: this.start.x,
16008
- y: this.start.y,
16009
- width: this.current.x - this.start.x,
16010
- height: this.current.y - this.start.y
16011
- });
16012
- this.start = null;
16013
- this.current = null;
16014
- this.points = [];
16015
- if (!path) return mode === "replace" ? this.controller.deselect() : false;
16016
- const opts = { mode, antiAlias: options.antiAlias, feather: options.feather ?? 0 };
16017
- if (options.tool === "lasso") return path.length >= 3 ? this.controller.selectPolygon(path, opts) : false;
16018
- return options.tool === "ellipse" ? this.controller.selectEllipse(rect, opts) : this.controller.selectRect(rect, opts);
16019
- }
16020
- /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
16021
- close() {
16022
- const polygon = this.polygon;
16023
- if (!polygon) return false;
16024
- const options = this.getOptions();
16025
- this.cancel();
16026
- if (polygon.length < 3) return false;
16027
- return this.controller.selectPolygon(polygon, {
16028
- mode: selectionModeFromModifiers(this.modifiers, options.mode),
16029
- antiAlias: options.antiAlias,
16030
- feather: options.feather ?? 0
16031
- });
16032
- }
16033
- /**
16034
- * Keys for a polygonal lasso being drawn: Enter closes it, Escape cancels, Backspace / Delete
16035
- * removes the last corner. Returns true when the key was used (the host prevents its default).
16036
- */
16037
- key(key) {
16038
- if (!this.polygon) return false;
16039
- if (key === "Enter") this.close();
16040
- else if (key === "Escape") this.cancel();
16041
- else if (key === "Backspace" || key === "Delete") {
16042
- this.polygon.pop();
16043
- if (!this.polygon.length) this.cancel();
16044
- } else return false;
16045
- return true;
16046
- }
16047
- /** Abandons a drag (or a polygonal lasso) without changing the selection. */
16048
- cancel() {
16049
- this.start = null;
16050
- this.current = null;
16051
- this.points = [];
16052
- this.polygon = null;
16053
- this.pressedAt = null;
16054
- this.freehand = false;
16055
- }
16056
- };
16057
- function handleSelectionShortcut(controller, e, onError) {
16058
- if (!(e.ctrlKey || e.metaKey)) return false;
16059
- const key = e.key.toLowerCase();
16060
- const run = (fn) => {
16061
- try {
16062
- fn();
16063
- } catch (err) {
16064
- onError?.(err instanceof Error ? err.message : String(err));
16065
- }
16066
- return true;
16067
- };
16068
- if (key === "c") return run(() => controller.copyPixels({ merged: Boolean(e.shiftKey) }));
16069
- if (key === "x" && !e.shiftKey) return run(() => controller.cutPixels());
16070
- if (key === "v") return run(() => controller.pastePixels(e.shiftKey && e.altKey ? "into" : e.shiftKey ? "in-place" : "new-layer"));
16071
- if (key === "d") return run(() => e.shiftKey ? controller.reselect() : controller.deselect());
16072
- if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
16073
- return false;
16074
- }
16075
- function handleQuickMaskKey(controller, e) {
16076
- if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
16077
- controller.setQuickMaskMode(!controller.quickMaskEditing);
16078
- return true;
16079
- }
16080
-
16081
16495
  // src/image-editor/selection/mask-files.ts
16082
16496
  var MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
16083
16497
  function blobDataUrl(file) {
@@ -16253,12 +16667,21 @@ function maskThumbnailClick(controller, layer, e) {
16253
16667
  if (!layer.mask) return;
16254
16668
  if (e.ctrlKey || e.metaKey) {
16255
16669
  controller.selectionFromLayerMask(layer.id, selectionModeFromModifiers(e, "replace"));
16670
+ } else if (e.altKey && !layer.mask.source) {
16671
+ const mode = e.shiftKey ? "overlay" : "gray";
16672
+ const view = controller.getMaskView();
16673
+ controller.setMaskView(view?.layerId === layer.id && view.mode === mode ? "off" : mode, layer.id);
16256
16674
  } else if (e.shiftKey) {
16257
16675
  controller.setLayerMaskProps(layer.id, { enabled: layer.mask.enabled === false });
16258
16676
  } else {
16259
16677
  controller.setActiveLayer(layer.id, "mask");
16260
16678
  }
16261
16679
  }
16680
+ function vectorMaskThumbnailClick(controller, layer, e) {
16681
+ if (!layer.vectorMask) return;
16682
+ if (e.ctrlKey || e.metaKey) controller.selectionFromVectorMask(layer.id, selectionModeFromModifiers(e, "replace"));
16683
+ else if (e.shiftKey) controller.setVectorMaskProps(layer.id, { enabled: layer.vectorMask.enabled === false });
16684
+ }
16262
16685
  function layerThumbnailClick(controller, layer, e) {
16263
16686
  if (!(e.ctrlKey || e.metaKey)) return false;
16264
16687
  controller.selectLayerTransparency(layer.id, { mode: selectionModeFromModifiers(e, "replace") });
@@ -16357,6 +16780,11 @@ function layerMenu(controller) {
16357
16780
  { action: "delete-mask", label: "Delete Layer Mask", enabled: Boolean(active?.mask) },
16358
16781
  { action: "toggle-mask", label: active?.mask?.enabled === false ? "Enable Layer Mask" : "Disable Layer Mask", enabled: Boolean(active?.mask) },
16359
16782
  { action: "toggle-mask-link", label: active?.mask?.linked === false ? "Link Layer Mask" : "Unlink Layer Mask", enabled: Boolean(active?.mask && !active.mask.source) },
16783
+ { action: "add-vector-mask", label: "Add Vector Mask (Reveal All)", enabled: Boolean(active && active.type !== "shape" && !active.mask?.source) },
16784
+ { action: "add-vector-mask-hide", label: "Add Vector Mask (Hide All)", enabled: Boolean(active && active.type !== "shape" && !active.mask?.source) },
16785
+ { action: "delete-vector-mask", label: "Delete Vector Mask", enabled: Boolean(active?.vectorMask) },
16786
+ { action: "toggle-vector-mask", label: active?.vectorMask?.enabled === false ? "Enable Vector Mask" : "Disable Vector Mask", enabled: Boolean(active?.vectorMask) },
16787
+ { action: "rasterize-vector-mask", label: "Rasterize Vector Mask", enabled: Boolean(active?.vectorMask) },
16360
16788
  { action: "rasterize", label: "Rasterize Layer", enabled: active?.type === "annotation" || active?.type === "smart" || active?.type === "shape" || active?.type === "type" || isPixel && Boolean(active?.type === "pixel" && active.filters?.length) },
16361
16789
  { action: "convert-smart", label: "Convert to Smart Object", enabled: Boolean(active && active.type !== "annotation") },
16362
16790
  { action: "edit-contents", label: "Edit Contents", enabled: active?.type === "smart" },
@@ -16428,6 +16856,19 @@ async function runLayerMenuAction(controller, action) {
16428
16856
  case "toggle-mask-link":
16429
16857
  if (id && active?.mask) controller.setLayerMaskProps(id, { linked: active.mask.linked === false });
16430
16858
  break;
16859
+ case "add-vector-mask":
16860
+ case "add-vector-mask-hide":
16861
+ if (id) controller.addVectorMask(id, action === "add-vector-mask" ? "reveal-all" : "hide-all");
16862
+ break;
16863
+ case "delete-vector-mask":
16864
+ if (id) controller.removeVectorMask(id);
16865
+ break;
16866
+ case "toggle-vector-mask":
16867
+ if (id && active?.vectorMask) controller.setVectorMaskProps(id, { enabled: active.vectorMask.enabled === false });
16868
+ break;
16869
+ case "rasterize-vector-mask":
16870
+ if (id) controller.rasterizeVectorMask(id);
16871
+ break;
16431
16872
  case "rasterize":
16432
16873
  if (id) await controller.rasterizeLayer(id);
16433
16874
  break;
@@ -18020,6 +18461,7 @@ export {
18020
18461
  combineSelections,
18021
18462
  compileAdjustmentLayer,
18022
18463
  componentPath,
18464
+ componentsCoverage,
18023
18465
  compositeDeep,
18024
18466
  compositeSurfaceOver,
18025
18467
  computeHistogram,
@@ -18139,6 +18581,7 @@ export {
18139
18581
  effectLayerTiming,
18140
18582
  effectPixelsToSurface,
18141
18583
  effectReach,
18584
+ effectiveLayerMask,
18142
18585
  effectiveProof,
18143
18586
  effectsFingerprint,
18144
18587
  effectsMargin,
@@ -18175,6 +18618,7 @@ export {
18175
18618
  flattenPath,
18176
18619
  flipCorners,
18177
18620
  flowField,
18621
+ followLayerMove,
18178
18622
  fontOf,
18179
18623
  formatControlValue,
18180
18624
  fullSelection,
@@ -18195,6 +18639,7 @@ export {
18195
18639
  growSelection,
18196
18640
  guidePlanes,
18197
18641
  guidedFilterColor,
18642
+ handleMaskViewKey,
18198
18643
  handlePaintShortcut,
18199
18644
  handleQuickMaskKey,
18200
18645
  handleSelectionShortcut,
@@ -18238,6 +18683,7 @@ export {
18238
18683
  invertMatrix3,
18239
18684
  invertSelection,
18240
18685
  isActiveFilter,
18686
+ isActiveVectorMask,
18241
18687
  isAdjustmentKind,
18242
18688
  isBitDepth,
18243
18689
  isBlendMode,
@@ -18285,6 +18731,7 @@ export {
18285
18731
  layerMaskCoverage,
18286
18732
  layerMaskFromSelection,
18287
18733
  layerMaskKey,
18734
+ layerMaskSelection,
18288
18735
  layerMenu,
18289
18736
  layerName,
18290
18737
  layerPanelRows,
@@ -18376,6 +18823,7 @@ export {
18376
18823
  offsetLayer,
18377
18824
  offsetShape,
18378
18825
  offsetSmartObject,
18826
+ offsetVectorMask,
18379
18827
  openMaskFile,
18380
18828
  openPsdFile,
18381
18829
  packRgba,
@@ -18496,6 +18944,7 @@ export {
18496
18944
  resolveMosaicRegionBounds,
18497
18945
  resolvePaintGradient,
18498
18946
  resolveProfile,
18947
+ resolveVectorMasks,
18499
18948
  restyleRange,
18500
18949
  retouchFilter,
18501
18950
  reversePaintGradient,
@@ -18625,6 +19074,7 @@ export {
18625
19074
  transformShapeComponents,
18626
19075
  transformSurface,
18627
19076
  transformTypeLayer,
19077
+ transformVectorMask,
18628
19078
  transformedBounds,
18629
19079
  transformedBox,
18630
19080
  transformedPoint,
@@ -18651,6 +19101,10 @@ export {
18651
19101
  validateEffectGradient,
18652
19102
  validateLayerEffect,
18653
19103
  validateLayerEffects,
19104
+ vectorMaskBounds,
19105
+ vectorMaskCoverage,
19106
+ vectorMaskThumbnail,
19107
+ vectorMaskThumbnailClick,
18654
19108
  versionedDescriptorBytes,
18655
19109
  vibranceLinear,
18656
19110
  vibrancePixel,