@pixerate/editor 0.187.0 → 0.189.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 (29) hide show
  1. package/dist/{chunk-X2KQQZBT.cjs → chunk-ED4L4UD6.cjs} +389 -180
  2. package/dist/{chunk-FW7N64XK.js → chunk-ZEHTUR7Z.js} +229 -20
  3. package/dist/image-editor/controller.d.cts +20 -3
  4. package/dist/image-editor/controller.d.ts +20 -3
  5. package/dist/image-editor/index.cjs +1618 -1467
  6. package/dist/image-editor/index.js +623 -472
  7. package/dist/image-editor/layers/paint-ui.d.cts +2 -1
  8. package/dist/image-editor/layers/paint-ui.d.ts +2 -1
  9. package/dist/image-editor/layers/panel.d.cts +3 -2
  10. package/dist/image-editor/layers/panel.d.ts +3 -2
  11. package/dist/image-editor/renderer.d.cts +6 -0
  12. package/dist/image-editor/renderer.d.ts +6 -0
  13. package/dist/image-editor/selection/gesture.d.cts +10 -0
  14. package/dist/image-editor/selection/gesture.d.ts +10 -0
  15. package/dist/image-editor/selection/layer-mask.d.cts +6 -0
  16. package/dist/image-editor/selection/layer-mask.d.ts +6 -0
  17. package/dist/image-editor/selection/mask.d.cts +20 -1
  18. package/dist/image-editor/selection/mask.d.ts +20 -1
  19. package/dist/image-editor/selection/shapes.d.cts +8 -0
  20. package/dist/image-editor/selection/shapes.d.ts +8 -0
  21. package/dist/image-editor/selection/tool-options.d.cts +1 -1
  22. package/dist/image-editor/selection/tool-options.d.ts +1 -1
  23. package/dist/image-editor/types.d.cts +8 -0
  24. package/dist/image-editor/types.d.ts +8 -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 +1 -0
  29. package/skills/pixerate-image-selection-masking/SKILL.md +4 -1
@@ -196,6 +196,7 @@ import {
196
196
  layerMaskCoverage,
197
197
  layerMaskFromSelection,
198
198
  layerMaskKey,
199
+ layerMaskSelection,
199
200
  layerName,
200
201
  layerTree,
201
202
  layerTypeLabel,
@@ -298,6 +299,8 @@ import {
298
299
  selectionArea,
299
300
  selectionFromCoverage,
300
301
  selectionFromPixels,
302
+ selectionFromRegion,
303
+ selectionFromTiles,
301
304
  selectionToCoverage,
302
305
  selectionToPixels,
303
306
  selectionValueAt,
@@ -370,7 +373,7 @@ import {
370
373
  writePsd,
371
374
  xyToTempTint,
372
375
  xyToUv
373
- } from "../chunk-FW7N64XK.js";
376
+ } from "../chunk-ZEHTUR7Z.js";
374
377
  import {
375
378
  parseCssColor
376
379
  } from "../chunk-3CI7FRMK.js";
@@ -1363,6 +1366,24 @@ var ImageEditorRenderer = class {
1363
1366
  }
1364
1367
  ctx.restore();
1365
1368
  }
1369
+ const maskView = state.maskView;
1370
+ if (maskView) {
1371
+ const layer = flattenLayers(state.layers ?? []).find((l) => l.id === maskView.layerId);
1372
+ const mask = layer ? this.maskViewSelection(layer, Math.round(state.imageDimensions.width), Math.round(state.imageDimensions.height)) : null;
1373
+ if (mask) {
1374
+ ctx.save();
1375
+ try {
1376
+ applyImageProjectionToContext(ctx, projection);
1377
+ ctx.beginPath();
1378
+ ctx.rect(region.x, region.y, region.width, region.height);
1379
+ ctx.clip();
1380
+ if (maskView.mode === "gray") this.drawMaskGray(ctx, mask);
1381
+ else this.drawMaskOverlay(ctx, mask, { color: [255, 0, 0], opacity: 0.5, indicates: "masked" });
1382
+ } catch {
1383
+ }
1384
+ ctx.restore();
1385
+ }
1386
+ }
1366
1387
  const display = options.selectionDisplay ?? state.selectionDisplay ?? (state.quickMask ? "quickMask" : "ants");
1367
1388
  const shownChannels = (state.channels ?? []).filter((c) => c.visible);
1368
1389
  if (shownChannels.length) {
@@ -1802,6 +1823,32 @@ var ImageEditorRenderer = class {
1802
1823
  return this.emptyMaskCache;
1803
1824
  }
1804
1825
  overlayCache = /* @__PURE__ */ new WeakMap();
1826
+ maskViewCache = /* @__PURE__ */ new WeakMap();
1827
+ grayCache = /* @__PURE__ */ new WeakMap();
1828
+ /** A layer's mask as canvas-sized coverage for its mask view, kept while the mask is unchanged. */
1829
+ maskViewSelection(layer, width, height) {
1830
+ if (!layer.mask || layer.mask.source || width < 1 || height < 1) return null;
1831
+ const key = `${width}x${height}:${layer.type === "pixel" ? `${layer.offset.x},${layer.offset.y}` : ""}`;
1832
+ const cached = this.maskViewCache.get(layer.mask.surface);
1833
+ if (cached?.key === key) return cached.mask;
1834
+ const mask = layerMaskSelection(layer, width, height);
1835
+ if (mask) this.maskViewCache.set(layer.mask.surface, { key, mask });
1836
+ return mask;
1837
+ }
1838
+ /** A mask alone in grey (white = revealed), opaque, drawn in natural image space. */
1839
+ drawMaskGray(ctx, mask) {
1840
+ let canvas = this.grayCache.get(mask);
1841
+ if (!canvas) {
1842
+ canvas = this.createCanvas(mask.width, mask.height);
1843
+ const mctx = canvas.getContext("2d");
1844
+ if (!mctx || typeof mctx.createImageData !== "function") return;
1845
+ const img = mctx.createImageData(mask.width, mask.height);
1846
+ img.data.set(selectionToPixels(mask, "gray").data);
1847
+ mctx.putImageData(img, 0, 0);
1848
+ this.grayCache.set(mask, canvas);
1849
+ }
1850
+ ctx.drawImage(canvas, 0, 0, mask.width, mask.height);
1851
+ }
1805
1852
  /** A channel's tint (colour, opacity, masked or selected area), drawn in natural image space. */
1806
1853
  drawMaskOverlay(ctx, mask, style) {
1807
1854
  const key = `${style.color?.join(",")}|${style.opacity}|${style.indicates}`;
@@ -2835,8 +2882,33 @@ var TypeToolGesture = class {
2835
2882
 
2836
2883
  // src/image-editor/selection/shapes.ts
2837
2884
  function polygonCoverage(points, width, height, antiAlias = true) {
2885
+ const { box, coverage } = polygonCoverageBox(points, width, height, antiAlias);
2886
+ if (!box || box.width === width && box.height === height) return box ? coverage : new Float32Array(width * height);
2838
2887
  const m = new Float32Array(width * height);
2839
- if (points.length < 3 || width <= 0 || height <= 0) return m;
2888
+ for (let y = 0; y < box.height; y++) m.set(coverage.subarray(y * box.width, (y + 1) * box.width), (box.y + y) * width + box.x);
2889
+ return m;
2890
+ }
2891
+ function polygonCoverageBox(points, width, height, antiAlias = true) {
2892
+ const none = { box: null, coverage: new Float32Array(0) };
2893
+ if (points.length < 3 || width <= 0 || height <= 0) return none;
2894
+ let minX = Infinity;
2895
+ let minY = Infinity;
2896
+ let maxX = -Infinity;
2897
+ let maxY = -Infinity;
2898
+ for (const p of points) {
2899
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
2900
+ minX = Math.min(minX, p.x);
2901
+ minY = Math.min(minY, p.y);
2902
+ maxX = Math.max(maxX, p.x);
2903
+ maxY = Math.max(maxY, p.y);
2904
+ }
2905
+ const bx0 = Math.max(0, Math.floor(minX));
2906
+ const by0 = Math.max(0, Math.floor(minY));
2907
+ const bx1 = Math.min(width, Math.ceil(maxX));
2908
+ const by1 = Math.min(height, Math.ceil(maxY));
2909
+ if (!(bx1 > bx0 && by1 > by0)) return none;
2910
+ const bw = bx1 - bx0;
2911
+ const m = new Float32Array(bw * (by1 - by0));
2840
2912
  const sub2 = antiAlias ? 4 : 1;
2841
2913
  const edges = [];
2842
2914
  for (let i = 0; i < points.length; i++) {
@@ -2850,8 +2922,8 @@ function polygonCoverage(points, width, height, antiAlias = true) {
2850
2922
  let next = 0;
2851
2923
  let active = [];
2852
2924
  const xs = [];
2853
- for (let py = 0; py < height; py++) {
2854
- const row = py * width;
2925
+ for (let py = by0; py < by1; py++) {
2926
+ const row = (py - by0) * bw - bx0;
2855
2927
  for (let s = 0; s < sub2; s++) {
2856
2928
  const y = py + (s + 0.5) / sub2;
2857
2929
  while (next < edges.length && edges[next].top <= y) active.push(edges[next++]);
@@ -2881,15 +2953,14 @@ function polygonCoverage(points, width, height, antiAlias = true) {
2881
2953
  }
2882
2954
  }
2883
2955
  for (let i = 0; i < m.length; i++) if (m[i] > 1) m[i] = 1;
2884
- return m;
2956
+ return { box: { x: bx0, y: by0, width: bw, height: by1 - by0 }, coverage: m };
2885
2957
  }
2886
- function toMask(width, height, coverage) {
2958
+ function polygonSelection(width, height, points, options = {}) {
2959
+ const { box, coverage } = polygonCoverageBox(points, width, height, options.antiAlias ?? true);
2960
+ if (!box) return createSelectionMask(width, height);
2887
2961
  const out = new Uint8Array(coverage.length);
2888
2962
  for (let i = 0; i < out.length; i++) out[i] = coverageToByte(coverage[i]);
2889
- return createSelectionMask(width, height, out);
2890
- }
2891
- function polygonSelection(width, height, points, options = {}) {
2892
- return toMask(width, height, polygonCoverage(points, width, height, options.antiAlias ?? true));
2963
+ return selectionFromRegion(width, height, box, out);
2893
2964
  }
2894
2965
  function normalizeRect(rect) {
2895
2966
  const x = rect.width < 0 ? rect.x + rect.width : rect.x;
@@ -3531,9 +3602,7 @@ function selectionRing(sel, width, location) {
3531
3602
  var DEFAULT_CHANNEL_COLOR = "#ff0000";
3532
3603
  function describeChannel(channel) {
3533
3604
  const { mask, ...rest } = channel;
3534
- let sum = 0;
3535
- for (let i = 0; i < mask.data.length; i++) sum += mask.data[i];
3536
- const percent = Math.round(sum / 255 / Math.max(1, mask.data.length) * 1e4) / 100;
3605
+ const percent = Math.round(selectionArea(mask) / Math.max(1, mask.width * mask.height) * 1e4) / 100;
3537
3606
  return { ...rest, bounds: mask.bounds, percent };
3538
3607
  }
3539
3608
  function serializeChannels(channels) {
@@ -4144,8 +4213,7 @@ var FreeTransformSession = class {
4144
4213
  const result = part.base ? compositeSurfaceOver(part.base, moved) : moved;
4145
4214
  if (part.what === "selection") {
4146
4215
  const { width, height } = this.host.canvas();
4147
- const data = new Uint8Array(readRegion(result, { x: 0, y: 0, width, height }).buffer);
4148
- const next = createSelectionMask(width, height, data);
4216
+ const next = selectionFromTiles(width, height, result);
4149
4217
  selection = next.bounds ? next : null;
4150
4218
  } else if (part.what === "pixels") {
4151
4219
  layers = mapLayer(layers, targetId, (l) => {
@@ -5471,14 +5539,18 @@ var ImageEditorController = class {
5471
5539
  this.trimHistoryBytes();
5472
5540
  }
5473
5541
  /**
5474
- * Bytes of pixel tiles held only by undo / redo steps (not by the current state). The oldest
5475
- * undo steps are dropped while it exceeds `maxHistoryBytes`.
5542
+ * Bytes of pixel, selection and channel tiles held only by undo / redo steps (not by the
5543
+ * current state). The oldest undo steps are dropped while it exceeds `maxHistoryBytes`.
5476
5544
  */
5477
5545
  get historyBytes() {
5478
5546
  const live = /* @__PURE__ */ new Set();
5479
5547
  collectStateTiles(this.state.layers, live);
5548
+ collectSelectionTiles(this.state.selection, this.state.channels, live);
5480
5549
  const held = /* @__PURE__ */ new Set();
5481
- for (const e of [...this.undoStack, ...this.redoStack]) collectStateTiles(e.layers, held);
5550
+ for (const e of [...this.undoStack, ...this.redoStack]) {
5551
+ collectStateTiles(e.layers, held);
5552
+ collectSelectionTiles(e.selection, e.channels, held);
5553
+ }
5482
5554
  let n = 0;
5483
5555
  for (const t of held) if (!live.has(t)) n += t.data.byteLength;
5484
5556
  return n;
@@ -7376,7 +7448,7 @@ var ImageEditorController = class {
7376
7448
  /** @internal Shows a stroke's live surface (no history). */
7377
7449
  _strokeUpdate(layerId, channel, surface, rects) {
7378
7450
  if (channel === "quick-mask") {
7379
- this.state.selection = this.selectionFromSurface(surface, this.state.selection ?? null, rects);
7451
+ this.state.selection = this.selectionFromSurface(surface);
7380
7452
  this.notify();
7381
7453
  return;
7382
7454
  }
@@ -8324,6 +8396,41 @@ var ImageEditorController = class {
8324
8396
  this.state.quickMask = display === "quickMask";
8325
8397
  this.notify();
8326
8398
  }
8399
+ // --- Mask views ---
8400
+ /** The layer mask shown on the canvas, or `null` (see `setMaskView`). */
8401
+ getMaskView() {
8402
+ const view = this.state.maskView;
8403
+ if (!view) return null;
8404
+ const layer = this.getLayer(view.layerId);
8405
+ return layer?.mask && !layer.mask.source ? view : null;
8406
+ }
8407
+ /**
8408
+ * Shows a layer's mask on the canvas: `gray` alone in grey (Alt-click the mask thumbnail),
8409
+ * `overlay` as a red tint over what it hides (the \ key), `off` back to the image. The layer
8410
+ * defaults to the active one. View state: no undo step, not saved. Throws when the layer has
8411
+ * no painted mask.
8412
+ */
8413
+ setMaskView(mode, layerId) {
8414
+ if (mode !== "off" && mode !== "gray" && mode !== "overlay") throw new Error(`Unknown mask view "${String(mode)}". Use gray, overlay or off.`);
8415
+ if (mode === "off") {
8416
+ if (this.state.maskView) {
8417
+ this.state.maskView = null;
8418
+ this.notify();
8419
+ }
8420
+ return null;
8421
+ }
8422
+ const id = layerId ?? this.state.activeLayerId;
8423
+ if (!id) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
8424
+ const layer = requireLayer(this.state.layers, id).layer;
8425
+ if (!layer.mask) throw new LayerOperationError(`Layer "${layer.name || id}" has no mask to show; add one with add_layer_mask.`);
8426
+ 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.`);
8427
+ const cur = this.state.maskView;
8428
+ if (cur?.layerId !== id || cur.mode !== mode) {
8429
+ this.state.maskView = { layerId: id, mode };
8430
+ this.notify();
8431
+ }
8432
+ return this.state.maskView;
8433
+ }
8327
8434
  // --- Alpha channels ---
8328
8435
  /** The alpha channels (saved selections), in panel order. */
8329
8436
  getChannels() {
@@ -8454,35 +8561,18 @@ var ImageEditorController = class {
8454
8561
  const { width, height } = this.naturalSize;
8455
8562
  const sel = this.state.selection;
8456
8563
  if (!sel?.bounds) return emptySurface(1, 0);
8457
- const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8458
- return surfaceFromPixels({ width, height, data: sized.data }, 1, 0, 0, 0);
8564
+ return (sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height)).surface;
8459
8565
  }
8460
- /** The selection a painted Quick Mask surface stands for (`rects` changed since `base`). */
8461
- selectionFromSurface(surface, base = null, rects) {
8566
+ /** The selection a painted Quick Mask surface stands for (it shares the surface's tiles). */
8567
+ selectionFromSurface(surface) {
8462
8568
  const { width, height } = this.naturalSize;
8463
- let data;
8464
- if (base && rects && base.width === width && base.height === height) {
8465
- data = new Uint8Array(base.data);
8466
- for (const r of rects) {
8467
- const x0 = Math.max(0, Math.floor(r.x));
8468
- const y0 = Math.max(0, Math.floor(r.y));
8469
- const x1 = Math.min(width, Math.ceil(r.x + r.width));
8470
- const y1 = Math.min(height, Math.ceil(r.y + r.height));
8471
- if (x1 <= x0 || y1 <= y0) continue;
8472
- const part = readRegion(surface, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
8473
- for (let y = y0; y < y1; y++) data.set(part.subarray((y - y0) * (x1 - x0), (y - y0 + 1) * (x1 - x0)), y * width + x0);
8474
- }
8475
- } else {
8476
- const all = readRegion(surface, { x: 0, y: 0, width, height });
8477
- data = new Uint8Array(all.buffer, all.byteOffset, all.length);
8478
- }
8479
- return normalizeSelection(createSelectionMask(width, height, data));
8569
+ return normalizeSelection(selectionFromTiles(width, height, surface));
8480
8570
  }
8481
8571
  // --- Layer masks ---
8482
8572
  maskFromSelection(sel, props = {}) {
8483
8573
  const { width, height } = this.naturalSize;
8484
8574
  const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8485
- const mask = { surface: surfaceFromPixels({ width, height, data: sized.data }, 1, 0, 0, 0), defaultValue: 0 };
8575
+ const mask = { surface: sized.surface, defaultValue: 0 };
8486
8576
  return withMaskProps(mask, props);
8487
8577
  }
8488
8578
  /**
@@ -10154,6 +10244,10 @@ function isOpaqueInside(surface, width, height) {
10154
10244
  }
10155
10245
  return true;
10156
10246
  }
10247
+ function collectSelectionTiles(selection, channels, into) {
10248
+ if (selection) collectTiles(selection.surface, into);
10249
+ for (const c of channels ?? []) collectTiles(c.mask.surface, into);
10250
+ }
10157
10251
  function collectStateTiles(layers, into) {
10158
10252
  for (const l of flattenLayers(layers)) {
10159
10253
  if (l.type === "pixel" && l.source !== "document") collectTiles(l.surface, into);
@@ -11063,9 +11157,7 @@ var REFINE_EDGE_CONTROLS = [
11063
11157
  ];
11064
11158
  function describeSelection(mask) {
11065
11159
  if (!mask || !mask.bounds) return "No selection";
11066
- let sum = 0;
11067
- for (let i = 0; i < mask.data.length; i++) sum += mask.data[i];
11068
- const pct2 = sum / 255 / mask.data.length * 100;
11160
+ const pct2 = selectionArea(mask) / (mask.width * mask.height) * 100;
11069
11161
  const shown = pct2 >= 10 ? Math.round(pct2) : Math.round(pct2 * 10) / 10;
11070
11162
  return `${mask.bounds.width} \xD7 ${mask.bounds.height} px selected (${shown}% of the image)`;
11071
11163
  }
@@ -12075,6 +12167,22 @@ function selectionShade(selection, doc) {
12075
12167
  context.putImageData(pixels, 0, 0);
12076
12168
  return canvas;
12077
12169
  }
12170
+ function maskViewCanvas(controller, doc) {
12171
+ const view = controller.getMaskView();
12172
+ const layer = view ? controller.getLayer(view.layerId) : null;
12173
+ const { width, height } = controller.getState().imageDimensions;
12174
+ const mask = layer ? layerMaskSelection(layer, Math.round(width), Math.round(height)) : null;
12175
+ if (!view || !mask) return null;
12176
+ const canvas = doc.createElement("canvas");
12177
+ canvas.width = mask.width;
12178
+ canvas.height = mask.height;
12179
+ const context = canvas.getContext("2d");
12180
+ if (!context) return null;
12181
+ const pixels = context.createImageData(mask.width, mask.height);
12182
+ pixels.data.set(view.mode === "gray" ? selectionToPixels(mask, "gray").data : quickMaskPixels(mask));
12183
+ context.putImageData(pixels, 0, 0);
12184
+ return canvas;
12185
+ }
12078
12186
  async function renderImageRegion(controller, request) {
12079
12187
  const state = controller.getState();
12080
12188
  const k = Math.min(1, request.scale);
@@ -12097,6 +12205,8 @@ async function renderImageRegion(controller, request) {
12097
12205
  context.drawImage(view, rect.x * kx, rect.y * ky, rect.width * kx, rect.height * ky, 0, 0, out.width, out.height);
12098
12206
  if (request.includeOverlays) {
12099
12207
  const s = request.scale;
12208
+ const maskView = maskViewCanvas(controller, document);
12209
+ if (maskView) context.drawImage(maskView, rect.x, rect.y, rect.width, rect.height, 0, 0, out.width, out.height);
12100
12210
  const selection = state.selection;
12101
12211
  if (selection?.bounds && selection.width === state.imageDimensions.width && selection.height === state.imageDimensions.height) {
12102
12212
  const shade = selectionShade(selection, document);
@@ -12124,7 +12234,7 @@ function createImageCaptureSurface(controller) {
12124
12234
  units: "image pixels",
12125
12235
  content: whole,
12126
12236
  selection: () => ({ rect: controller.getState().selection?.bounds ?? null }),
12127
- overlays: "a shade over unselected pixels and the crop box outline",
12237
+ overlays: "a shade over unselected pixels, the crop box outline and a layer mask view (set_mask_view)",
12128
12238
  maxScale: 8,
12129
12239
  render: (request) => renderImageRegion(controller, request)
12130
12240
  };
@@ -12812,6 +12922,27 @@ function createImageLayerTools(controller) {
12812
12922
  return { layer: describe(id) };
12813
12923
  }
12814
12924
  };
12925
+ const maskView = {
12926
+ name: "set_mask_view",
12927
+ title: "Show a layer mask",
12928
+ 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.",
12929
+ inputSchema: {
12930
+ type: "object",
12931
+ properties: {
12932
+ view: { type: "string", enum: ["gray", "overlay", "off"], description: "gray, overlay or off." },
12933
+ layerId: { ...LAYER_ID2, description: "The layer whose mask to show (default the active layer; see list_layers). Ignored for off." }
12934
+ },
12935
+ required: ["view"],
12936
+ additionalProperties: false
12937
+ },
12938
+ annotations: { title: "Show a layer mask", ...WRITE_IDEMPOTENT2 },
12939
+ handler: ({ view, layerId }) => {
12940
+ if (view !== "gray" && view !== "overlay" && view !== "off") throw new Error(`Unknown view "${String(view)}". Use gray, overlay or off.`);
12941
+ const id = view === "off" || layerId === void 0 ? void 0 : exists(layerId);
12942
+ const shown = controller.setMaskView(view, id);
12943
+ return { view: shown?.mode ?? "off", layerId: shown?.layerId ?? null };
12944
+ }
12945
+ };
12815
12946
  const maskProps = {
12816
12947
  name: "set_layer_mask_properties",
12817
12948
  title: "Set layer mask properties",
@@ -12952,6 +13083,7 @@ function createImageLayerTools(controller) {
12952
13083
  addMask,
12953
13084
  applyMask,
12954
13085
  removeMask,
13086
+ maskView,
12955
13087
  maskProps,
12956
13088
  viaCopy,
12957
13089
  rasterize,
@@ -13292,196 +13424,447 @@ function createImageEffectTools(controller) {
13292
13424
  return [list, add2, update, remove, setEnabled, light];
13293
13425
  }
13294
13426
 
13295
- // src/image-editor/layers/paint-ui.ts
13296
- var plain2 = { kind: "plain" };
13297
- var BRUSH_OPTION_CONTROLS = [
13298
- { id: "size", label: "Size", section: "layer", min: 1, max: 500, step: 1, default: 20, decimals: 0, track: plain2, unit: "px" },
13299
- { id: "hardness", label: "Hardness", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13300
- { id: "opacity", label: "Opacity", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13301
- { id: "flow", label: "Flow", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13302
- { id: "smoothing", label: "Smoothing", section: "layer", min: 0, max: 0.95, step: 0.01, default: 0, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13303
- ];
13304
- var MIXER_OPTION_CONTROLS = [
13305
- { id: "wet", label: "Wet", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13306
- { id: "load", label: "Load", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13307
- { id: "mix", label: "Mix", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13308
- ];
13309
- var MIXER_MIXING_OPTIONS = [
13310
- { id: "rgb", label: "RGB", title: "Mix colours as RGB averages" },
13311
- { id: "pigment", label: "Pigment", title: "Mix colours like paint: blue and yellow make green" }
13312
- ];
13313
- var PAINT_TOOLS = [
13314
- { id: "move", label: "Move", shortcut: "V" },
13315
- { id: "brush", label: "Brush", shortcut: "B" },
13316
- { id: "mixer", label: "Mixer", shortcut: "Shift+B" },
13317
- { id: "eraser", label: "Eraser", shortcut: "E" },
13318
- { id: "clone", label: "Clone", shortcut: "S" },
13319
- { id: "heal", label: "Heal", shortcut: "Shift+J" },
13320
- { id: "spot-heal", label: "Spot Heal", shortcut: "J" },
13321
- { id: "smudge", label: "Smudge", shortcut: "" },
13322
- { id: "dodge", label: "Dodge", shortcut: "O" },
13323
- { id: "burn", label: "Burn", shortcut: "Shift+O" },
13324
- { id: "sponge", label: "Sponge", shortcut: "Shift+O" },
13325
- { id: "blur", label: "Blur", shortcut: "" },
13326
- { id: "sharpen", label: "Sharpen", shortcut: "" },
13327
- { id: "history", label: "History", shortcut: "Y" }
13328
- ];
13329
- var isPaintTool = (tool) => PAINT_TOOLS.some((t) => t.id === tool);
13330
- var isRetouchPaintTool = (tool) => tool === "clone" || tool === "heal" || tool === "spot-heal" || tool === "smudge" || tool === "dodge" || tool === "burn" || tool === "sponge" || tool === "blur" || tool === "sharpen" || tool === "history";
13331
- var RETOUCH_OPTION_CONTROLS = [
13332
- { 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"] },
13333
- { 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"] },
13334
- { id: "historySteps", label: "Steps back", section: "layer", min: 0, max: 50, step: 1, default: 0, decimals: 0, track: plain2, unit: "", tools: ["history"] }
13335
- ];
13336
- var CLONE_TRANSFORM_CONTROLS = [
13337
- { id: "width", label: "Source W", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13338
- { id: "height", label: "Source H", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13339
- { id: "angle", label: "Source angle", section: "layer", min: -180, max: 180, step: 1, default: 0, decimals: 0, track: plain2, unit: "\xB0" }
13340
- ];
13341
- function cloneTransformView(controller) {
13342
- const t = controller.getState().paint.cloneTransform;
13343
- return {
13344
- width: Math.round(Math.abs(t.scaleX) * 100),
13345
- height: Math.round(Math.abs(t.scaleY) * 100),
13346
- angle: t.rotate,
13347
- flipH: t.scaleX < 0,
13348
- flipV: t.scaleY < 0,
13349
- identity: t.scaleX === 1 && t.scaleY === 1 && t.rotate === 0
13350
- };
13351
- }
13352
- function setCloneTransformValue(controller, id, value) {
13353
- const t = controller.getState().paint.cloneTransform;
13354
- if (id === "width") controller.setCloneTransform({ scaleX: Math.sign(t.scaleX) * (value ?? 100) / 100 });
13355
- else if (id === "height") controller.setCloneTransform({ scaleY: Math.sign(t.scaleY) * (value ?? 100) / 100 });
13356
- else if (id === "angle") controller.setCloneTransform({ rotate: value ?? 0 });
13357
- else if (id === "flipH") controller.setCloneTransform({ scaleX: -t.scaleX });
13358
- else controller.setCloneTransform({ scaleY: -t.scaleY });
13359
- }
13360
- var RETOUCH_CHOICES = [
13361
- {
13362
- id: "sample",
13363
- label: "Sample",
13364
- tools: ["clone", "heal", "spot-heal"],
13365
- options: [
13366
- { id: "current", label: "Current", title: "Sample the layer being retouched" },
13367
- { id: "current-below", label: "Current & Below", title: "Sample this layer and the layers under it" },
13368
- { id: "all", label: "All", title: "Sample every layer (retouch onto an empty layer)" }
13369
- ]
13370
- },
13371
- {
13372
- id: "spotType",
13373
- label: "Type",
13374
- tools: ["spot-heal"],
13375
- options: [
13376
- { id: "proximity-match", label: "Proximity Match", title: "Copy the best matching patch nearby" },
13377
- { id: "content-aware", label: "Content-Aware", title: "Synthesise texture and edges from patches around the spot" },
13378
- { id: "smooth", label: "Smooth", title: "Fill smoothly from the edges" }
13379
- ]
13380
- },
13381
- {
13382
- id: "range",
13383
- label: "Range",
13384
- tools: ["dodge", "burn"],
13385
- options: [
13386
- { id: "shadows", label: "Shadows", title: "Change the dark tones most" },
13387
- { id: "midtones", label: "Midtones", title: "Change the middle tones most" },
13388
- { id: "highlights", label: "Highlights", title: "Change the light tones most" }
13389
- ]
13390
- },
13391
- {
13392
- id: "spongeMode",
13393
- label: "Mode",
13394
- tools: ["sponge"],
13395
- options: [
13396
- { id: "desaturate", label: "Desaturate", title: "Take colour out" },
13397
- { id: "saturate", label: "Saturate", title: "Add colour" }
13398
- ]
13399
- }
13400
- ];
13401
- var RETOUCH_TOGGLES = [
13402
- { id: "aligned", label: "Aligned", tools: ["clone", "heal"] },
13403
- { id: "protectTones", label: "Protect tones", tools: ["dodge", "burn"] },
13404
- { id: "vibrance", label: "Vibrance", tools: ["sponge"] }
13405
- ];
13406
- function setRetouchChoice(controller, id, value) {
13407
- controller.setPaintOptions({ retouch: { [id]: value } });
13408
- }
13409
- function cloneSourceLabel(controller) {
13410
- const cs = controller.getState().paint.cloneSource;
13411
- return cs ? `Source ${cs.x}, ${cs.y}${cs.offset ? ` (offset ${cs.offset.x}, ${cs.offset.y})` : ""}` : "Alt-click to set the source";
13412
- }
13413
- function rgbUnitToHex(c) {
13414
- return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
13415
- }
13416
- function sizeStep(size) {
13417
- return size < 10 ? 1 : size < 50 ? 5 : size < 100 ? 10 : size < 200 ? 25 : size < 500 ? 50 : 100;
13418
- }
13419
- var isTyping = (t) => {
13420
- const el = t;
13421
- return Boolean(el && (el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"));
13422
- };
13423
- function handlePaintShortcut(controller, e) {
13424
- if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return false;
13425
- const brush = controller.getState().paint.brush;
13426
- const set = (b) => controller.setPaintOptions({ brush: b });
13427
- const key = e.key;
13428
- switch (key.toLowerCase()) {
13429
- case "q":
13430
- controller.setQuickMaskMode(!controller.quickMaskEditing);
13431
- return true;
13432
- case "b":
13433
- controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
13434
- return true;
13435
- case "e":
13436
- controller.setTool("eraser");
13437
- return true;
13438
- case "v":
13439
- controller.setTool("move");
13440
- return true;
13441
- case "s":
13442
- controller.setTool("clone");
13443
- return true;
13444
- case "j":
13445
- controller.setTool(e.shiftKey || key === "J" ? controller.getState().activeTool === "heal" ? "spot-heal" : "heal" : "spot-heal");
13446
- return true;
13447
- case "o": {
13448
- const cur = controller.getState().activeTool;
13449
- const next = e.shiftKey || key === "O" ? cur === "dodge" ? "burn" : cur === "burn" ? "sponge" : "dodge" : "dodge";
13450
- controller.setTool(next);
13451
- return true;
13452
- }
13453
- case "y":
13454
- controller.setTool("history");
13455
- return true;
13456
- case "d":
13457
- controller.resetPaintColors();
13458
- return true;
13459
- case "x":
13460
- controller.swapPaintColors();
13461
- return true;
13462
- }
13463
- if (key === "[" || key === "{") {
13464
- if (e.shiftKey || key === "{") set({ hardness: Math.max(0, Math.round((brush.hardness - 0.25) * 4) / 4) });
13465
- else set({ size: Math.max(1, brush.size - sizeStep(brush.size - 1)) });
13466
- return true;
13467
- }
13468
- if (key === "]" || key === "}") {
13469
- if (e.shiftKey || key === "}") set({ hardness: Math.min(1, Math.round((brush.hardness + 0.25) * 4) / 4) });
13470
- else set({ size: Math.min(MAX_BRUSH_SIZE, brush.size + sizeStep(brush.size)) });
13471
- return true;
13472
- }
13473
- if (/^[0-9]$/.test(key) && isPaintTool(controller.getState().activeTool)) {
13474
- set({ opacity: key === "0" ? 1 : Number(key) / 10 });
13475
- return true;
13476
- }
13477
- return false;
13427
+ // src/image-editor/selection/gesture.ts
13428
+ function snapTo45(from, point) {
13429
+ const dx = point.x - from.x;
13430
+ const dy = point.y - from.y;
13431
+ const len = Math.hypot(dx, dy);
13432
+ if (len === 0) return point;
13433
+ const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
13434
+ return { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len };
13478
13435
  }
13479
- var PaintGesture = class {
13480
- constructor(controller) {
13436
+ var SelectionGesture = class {
13437
+ constructor(controller, getOptions) {
13481
13438
  this.controller = controller;
13439
+ this.getOptions = getOptions;
13482
13440
  }
13483
13441
  controller;
13484
- stroke = null;
13442
+ getOptions;
13443
+ start = null;
13444
+ points = [];
13445
+ current = null;
13446
+ modifiers = {};
13447
+ /** Polygonal lasso corners while one is being drawn. */
13448
+ polygon = null;
13449
+ pressedAt = null;
13450
+ freehand = false;
13451
+ /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
13452
+ closeRadius = 8;
13453
+ /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
13454
+ error = null;
13455
+ get active() {
13456
+ return this.start !== null || this.polygon !== null;
13457
+ }
13458
+ /** The outline of the shape being drawn, or `null`. */
13459
+ get draftPath() {
13460
+ if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
13461
+ if (!this.start || !this.current) return null;
13462
+ const { tool } = this.getOptions();
13463
+ if (tool === "lasso") return this.points.length > 1 ? this.points.slice() : null;
13464
+ const rect = normalizeRect({
13465
+ x: this.start.x,
13466
+ y: this.start.y,
13467
+ width: this.current.x - this.start.x,
13468
+ height: this.current.y - this.start.y
13469
+ });
13470
+ if (rect.width < 1 && rect.height < 1) return null;
13471
+ if (tool === "ellipse") return ellipsePoints(rect);
13472
+ return [
13473
+ { x: rect.x, y: rect.y },
13474
+ { x: rect.x + rect.width, y: rect.y },
13475
+ { x: rect.x + rect.width, y: rect.y + rect.height },
13476
+ { x: rect.x, y: rect.y + rect.height }
13477
+ ];
13478
+ }
13479
+ /** Pointer down. Resolves once a wand click has been applied. */
13480
+ async begin(point, modifiers = {}) {
13481
+ const options = this.getOptions();
13482
+ this.error = null;
13483
+ if (this.polygon && options.tool !== "polygon") this.cancel();
13484
+ if (options.tool === "polygon") {
13485
+ this.pressedAt = point;
13486
+ this.freehand = false;
13487
+ if (!this.polygon) {
13488
+ this.modifiers = modifiers;
13489
+ this.polygon = [point];
13490
+ this.current = point;
13491
+ return;
13492
+ }
13493
+ const last = this.polygon[this.polygon.length - 1];
13494
+ const p = modifiers.shiftKey ? snapTo45(last, point) : point;
13495
+ const first = this.polygon[0];
13496
+ if (this.polygon.length >= 3 && Math.hypot(p.x - first.x, p.y - first.y) <= this.closeRadius) {
13497
+ this.close();
13498
+ return;
13499
+ }
13500
+ if (Math.hypot(p.x - last.x, p.y - last.y) >= 0.5) this.polygon.push(p);
13501
+ this.current = p;
13502
+ if ((modifiers.detail ?? 1) >= 2) this.close();
13503
+ return;
13504
+ }
13505
+ this.modifiers = modifiers;
13506
+ if (options.tool === "row" || options.tool === "column") {
13507
+ this.start = null;
13508
+ try {
13509
+ this.controller.selectLine(options.tool, options.tool === "row" ? point.y : point.x, {
13510
+ mode: selectionModeFromModifiers(modifiers, options.mode),
13511
+ feather: options.feather ?? 0
13512
+ });
13513
+ } catch (err) {
13514
+ this.error = err instanceof Error ? err.message : String(err);
13515
+ }
13516
+ return;
13517
+ }
13518
+ if (options.tool === "wand" || options.tool === "range") {
13519
+ this.start = null;
13520
+ const common = {
13521
+ sampleMerged: options.sampleMerged,
13522
+ sample: options.sample,
13523
+ feather: options.feather ?? 0,
13524
+ mode: selectionModeFromModifiers(modifiers, options.mode)
13525
+ };
13526
+ try {
13527
+ if (options.tool === "range") {
13528
+ const pixels = await this.controller.getSamplePixels(common);
13529
+ if (!pixels) throw new Error("The image pixels are not available to sample.");
13530
+ const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point.x)));
13531
+ const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point.y)));
13532
+ const p = (y * pixels.width + x) * 4;
13533
+ const color = [pixels.data[p], pixels.data[p + 1], pixels.data[p + 2]];
13534
+ await this.controller.selectColorRange([{ color, at: point }], {
13535
+ ...common,
13536
+ fuzziness: options.fuzziness ?? 40,
13537
+ localized: options.localized ? options.localized : void 0,
13538
+ invert: options.rangeInvert ?? false
13539
+ });
13540
+ return;
13541
+ }
13542
+ await this.controller.magicWand(point, {
13543
+ ...common,
13544
+ tolerance: options.tolerance,
13545
+ contiguous: options.contiguous,
13546
+ antiAlias: options.antiAlias,
13547
+ distance: options.distance
13548
+ });
13549
+ } catch (err) {
13550
+ this.error = err instanceof Error ? err.message : String(err);
13551
+ }
13552
+ return;
13553
+ }
13554
+ this.start = point;
13555
+ this.current = point;
13556
+ this.points = [point];
13557
+ }
13558
+ /** Pointer move; returns true when the draft changed (re-render). */
13559
+ move(point, modifiers = {}) {
13560
+ if (this.polygon) {
13561
+ const last = this.polygon[this.polygon.length - 1];
13562
+ if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
13563
+ this.freehand = true;
13564
+ if (Math.hypot(point.x - last.x, point.y - last.y) >= 0.5) this.polygon.push(point);
13565
+ this.current = null;
13566
+ return true;
13567
+ }
13568
+ this.current = modifiers.shiftKey ? snapTo45(last, point) : point;
13569
+ return true;
13570
+ }
13571
+ if (!this.start) return false;
13572
+ this.current = point;
13573
+ if (this.getOptions().tool === "lasso") {
13574
+ const last = this.points[this.points.length - 1];
13575
+ if (Math.hypot(point.x - last.x, point.y - last.y) < 0.5) return false;
13576
+ this.points.push(point);
13577
+ }
13578
+ return true;
13579
+ }
13580
+ /** Pointer up: commits the shape. Returns true when the selection changed. */
13581
+ end(point, modifiers) {
13582
+ if (this.polygon) {
13583
+ if (point && this.freehand) this.move(point);
13584
+ this.pressedAt = null;
13585
+ this.freehand = false;
13586
+ return false;
13587
+ }
13588
+ if (!this.start) return false;
13589
+ if (point) this.move(point);
13590
+ const options = this.getOptions();
13591
+ const mode = selectionModeFromModifiers(modifiers ?? this.modifiers, options.mode);
13592
+ const path = this.draftPath;
13593
+ const rect = normalizeRect({
13594
+ x: this.start.x,
13595
+ y: this.start.y,
13596
+ width: this.current.x - this.start.x,
13597
+ height: this.current.y - this.start.y
13598
+ });
13599
+ this.start = null;
13600
+ this.current = null;
13601
+ this.points = [];
13602
+ if (!path) return mode === "replace" ? this.controller.deselect() : false;
13603
+ const opts = { mode, antiAlias: options.antiAlias, feather: options.feather ?? 0 };
13604
+ if (options.tool === "lasso") return path.length >= 3 ? this.controller.selectPolygon(path, opts) : false;
13605
+ return options.tool === "ellipse" ? this.controller.selectEllipse(rect, opts) : this.controller.selectRect(rect, opts);
13606
+ }
13607
+ /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
13608
+ close() {
13609
+ const polygon = this.polygon;
13610
+ if (!polygon) return false;
13611
+ const options = this.getOptions();
13612
+ this.cancel();
13613
+ if (polygon.length < 3) return false;
13614
+ return this.controller.selectPolygon(polygon, {
13615
+ mode: selectionModeFromModifiers(this.modifiers, options.mode),
13616
+ antiAlias: options.antiAlias,
13617
+ feather: options.feather ?? 0
13618
+ });
13619
+ }
13620
+ /**
13621
+ * Keys for a polygonal lasso being drawn: Enter closes it, Escape cancels, Backspace / Delete
13622
+ * removes the last corner. Returns true when the key was used (the host prevents its default).
13623
+ */
13624
+ key(key) {
13625
+ if (!this.polygon) return false;
13626
+ if (key === "Enter") this.close();
13627
+ else if (key === "Escape") this.cancel();
13628
+ else if (key === "Backspace" || key === "Delete") {
13629
+ this.polygon.pop();
13630
+ if (!this.polygon.length) this.cancel();
13631
+ } else return false;
13632
+ return true;
13633
+ }
13634
+ /** Abandons a drag (or a polygonal lasso) without changing the selection. */
13635
+ cancel() {
13636
+ this.start = null;
13637
+ this.current = null;
13638
+ this.points = [];
13639
+ this.polygon = null;
13640
+ this.pressedAt = null;
13641
+ this.freehand = false;
13642
+ }
13643
+ };
13644
+ function handleSelectionShortcut(controller, e, onError) {
13645
+ if (!(e.ctrlKey || e.metaKey)) return false;
13646
+ const key = e.key.toLowerCase();
13647
+ const run = (fn) => {
13648
+ try {
13649
+ fn();
13650
+ } catch (err) {
13651
+ onError?.(err instanceof Error ? err.message : String(err));
13652
+ }
13653
+ return true;
13654
+ };
13655
+ if (key === "c") return run(() => controller.copyPixels({ merged: Boolean(e.shiftKey) }));
13656
+ if (key === "x" && !e.shiftKey) return run(() => controller.cutPixels());
13657
+ if (key === "v") return run(() => controller.pastePixels(e.shiftKey && e.altKey ? "into" : e.shiftKey ? "in-place" : "new-layer"));
13658
+ if (key === "d") return run(() => e.shiftKey ? controller.reselect() : controller.deselect());
13659
+ if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
13660
+ return false;
13661
+ }
13662
+ function handleMaskViewKey(controller, e) {
13663
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key !== "\\") return false;
13664
+ const id = controller.getState().activeLayerId;
13665
+ const layer = id ? controller.getLayer(id) : null;
13666
+ if (!layer?.mask || layer.mask.source) return false;
13667
+ const view = controller.getMaskView();
13668
+ controller.setMaskView(view?.layerId === layer.id && view.mode === "overlay" ? "off" : "overlay", layer.id);
13669
+ return true;
13670
+ }
13671
+ function handleQuickMaskKey(controller, e) {
13672
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
13673
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13674
+ return true;
13675
+ }
13676
+
13677
+ // src/image-editor/layers/paint-ui.ts
13678
+ var plain2 = { kind: "plain" };
13679
+ var BRUSH_OPTION_CONTROLS = [
13680
+ { id: "size", label: "Size", section: "layer", min: 1, max: 500, step: 1, default: 20, decimals: 0, track: plain2, unit: "px" },
13681
+ { id: "hardness", label: "Hardness", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13682
+ { id: "opacity", label: "Opacity", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13683
+ { id: "flow", label: "Flow", section: "layer", min: 0, max: 1, step: 0.01, default: 1, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13684
+ { id: "smoothing", label: "Smoothing", section: "layer", min: 0, max: 0.95, step: 0.01, default: 0, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13685
+ ];
13686
+ var MIXER_OPTION_CONTROLS = [
13687
+ { id: "wet", label: "Wet", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13688
+ { id: "load", label: "Load", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" },
13689
+ { id: "mix", label: "Mix", section: "layer", min: 0, max: 1, step: 0.01, default: 0.5, decimals: 0, track: plain2, displayScale: 100, unit: "%" }
13690
+ ];
13691
+ var MIXER_MIXING_OPTIONS = [
13692
+ { id: "rgb", label: "RGB", title: "Mix colours as RGB averages" },
13693
+ { id: "pigment", label: "Pigment", title: "Mix colours like paint: blue and yellow make green" }
13694
+ ];
13695
+ var PAINT_TOOLS = [
13696
+ { id: "move", label: "Move", shortcut: "V" },
13697
+ { id: "brush", label: "Brush", shortcut: "B" },
13698
+ { id: "mixer", label: "Mixer", shortcut: "Shift+B" },
13699
+ { id: "eraser", label: "Eraser", shortcut: "E" },
13700
+ { id: "clone", label: "Clone", shortcut: "S" },
13701
+ { id: "heal", label: "Heal", shortcut: "Shift+J" },
13702
+ { id: "spot-heal", label: "Spot Heal", shortcut: "J" },
13703
+ { id: "smudge", label: "Smudge", shortcut: "" },
13704
+ { id: "dodge", label: "Dodge", shortcut: "O" },
13705
+ { id: "burn", label: "Burn", shortcut: "Shift+O" },
13706
+ { id: "sponge", label: "Sponge", shortcut: "Shift+O" },
13707
+ { id: "blur", label: "Blur", shortcut: "" },
13708
+ { id: "sharpen", label: "Sharpen", shortcut: "" },
13709
+ { id: "history", label: "History", shortcut: "Y" }
13710
+ ];
13711
+ var isPaintTool = (tool) => PAINT_TOOLS.some((t) => t.id === tool);
13712
+ var isRetouchPaintTool = (tool) => tool === "clone" || tool === "heal" || tool === "spot-heal" || tool === "smudge" || tool === "dodge" || tool === "burn" || tool === "sponge" || tool === "blur" || tool === "sharpen" || tool === "history";
13713
+ var RETOUCH_OPTION_CONTROLS = [
13714
+ { 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"] },
13715
+ { 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"] },
13716
+ { id: "historySteps", label: "Steps back", section: "layer", min: 0, max: 50, step: 1, default: 0, decimals: 0, track: plain2, unit: "", tools: ["history"] }
13717
+ ];
13718
+ var CLONE_TRANSFORM_CONTROLS = [
13719
+ { id: "width", label: "Source W", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13720
+ { id: "height", label: "Source H", section: "layer", min: 1, max: 400, step: 1, default: 100, decimals: 0, track: plain2, unit: "%" },
13721
+ { id: "angle", label: "Source angle", section: "layer", min: -180, max: 180, step: 1, default: 0, decimals: 0, track: plain2, unit: "\xB0" }
13722
+ ];
13723
+ function cloneTransformView(controller) {
13724
+ const t = controller.getState().paint.cloneTransform;
13725
+ return {
13726
+ width: Math.round(Math.abs(t.scaleX) * 100),
13727
+ height: Math.round(Math.abs(t.scaleY) * 100),
13728
+ angle: t.rotate,
13729
+ flipH: t.scaleX < 0,
13730
+ flipV: t.scaleY < 0,
13731
+ identity: t.scaleX === 1 && t.scaleY === 1 && t.rotate === 0
13732
+ };
13733
+ }
13734
+ function setCloneTransformValue(controller, id, value) {
13735
+ const t = controller.getState().paint.cloneTransform;
13736
+ if (id === "width") controller.setCloneTransform({ scaleX: Math.sign(t.scaleX) * (value ?? 100) / 100 });
13737
+ else if (id === "height") controller.setCloneTransform({ scaleY: Math.sign(t.scaleY) * (value ?? 100) / 100 });
13738
+ else if (id === "angle") controller.setCloneTransform({ rotate: value ?? 0 });
13739
+ else if (id === "flipH") controller.setCloneTransform({ scaleX: -t.scaleX });
13740
+ else controller.setCloneTransform({ scaleY: -t.scaleY });
13741
+ }
13742
+ var RETOUCH_CHOICES = [
13743
+ {
13744
+ id: "sample",
13745
+ label: "Sample",
13746
+ tools: ["clone", "heal", "spot-heal"],
13747
+ options: [
13748
+ { id: "current", label: "Current", title: "Sample the layer being retouched" },
13749
+ { id: "current-below", label: "Current & Below", title: "Sample this layer and the layers under it" },
13750
+ { id: "all", label: "All", title: "Sample every layer (retouch onto an empty layer)" }
13751
+ ]
13752
+ },
13753
+ {
13754
+ id: "spotType",
13755
+ label: "Type",
13756
+ tools: ["spot-heal"],
13757
+ options: [
13758
+ { id: "proximity-match", label: "Proximity Match", title: "Copy the best matching patch nearby" },
13759
+ { id: "content-aware", label: "Content-Aware", title: "Synthesise texture and edges from patches around the spot" },
13760
+ { id: "smooth", label: "Smooth", title: "Fill smoothly from the edges" }
13761
+ ]
13762
+ },
13763
+ {
13764
+ id: "range",
13765
+ label: "Range",
13766
+ tools: ["dodge", "burn"],
13767
+ options: [
13768
+ { id: "shadows", label: "Shadows", title: "Change the dark tones most" },
13769
+ { id: "midtones", label: "Midtones", title: "Change the middle tones most" },
13770
+ { id: "highlights", label: "Highlights", title: "Change the light tones most" }
13771
+ ]
13772
+ },
13773
+ {
13774
+ id: "spongeMode",
13775
+ label: "Mode",
13776
+ tools: ["sponge"],
13777
+ options: [
13778
+ { id: "desaturate", label: "Desaturate", title: "Take colour out" },
13779
+ { id: "saturate", label: "Saturate", title: "Add colour" }
13780
+ ]
13781
+ }
13782
+ ];
13783
+ var RETOUCH_TOGGLES = [
13784
+ { id: "aligned", label: "Aligned", tools: ["clone", "heal"] },
13785
+ { id: "protectTones", label: "Protect tones", tools: ["dodge", "burn"] },
13786
+ { id: "vibrance", label: "Vibrance", tools: ["sponge"] }
13787
+ ];
13788
+ function setRetouchChoice(controller, id, value) {
13789
+ controller.setPaintOptions({ retouch: { [id]: value } });
13790
+ }
13791
+ function cloneSourceLabel(controller) {
13792
+ const cs = controller.getState().paint.cloneSource;
13793
+ return cs ? `Source ${cs.x}, ${cs.y}${cs.offset ? ` (offset ${cs.offset.x}, ${cs.offset.y})` : ""}` : "Alt-click to set the source";
13794
+ }
13795
+ function rgbUnitToHex(c) {
13796
+ return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
13797
+ }
13798
+ function sizeStep(size) {
13799
+ return size < 10 ? 1 : size < 50 ? 5 : size < 100 ? 10 : size < 200 ? 25 : size < 500 ? 50 : 100;
13800
+ }
13801
+ var isTyping = (t) => {
13802
+ const el = t;
13803
+ return Boolean(el && (el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT"));
13804
+ };
13805
+ function handlePaintShortcut(controller, e) {
13806
+ if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return false;
13807
+ const brush = controller.getState().paint.brush;
13808
+ const set = (b) => controller.setPaintOptions({ brush: b });
13809
+ const key = e.key;
13810
+ if (handleMaskViewKey(controller, e)) return true;
13811
+ switch (key.toLowerCase()) {
13812
+ case "q":
13813
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13814
+ return true;
13815
+ case "b":
13816
+ controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
13817
+ return true;
13818
+ case "e":
13819
+ controller.setTool("eraser");
13820
+ return true;
13821
+ case "v":
13822
+ controller.setTool("move");
13823
+ return true;
13824
+ case "s":
13825
+ controller.setTool("clone");
13826
+ return true;
13827
+ case "j":
13828
+ controller.setTool(e.shiftKey || key === "J" ? controller.getState().activeTool === "heal" ? "spot-heal" : "heal" : "spot-heal");
13829
+ return true;
13830
+ case "o": {
13831
+ const cur = controller.getState().activeTool;
13832
+ const next = e.shiftKey || key === "O" ? cur === "dodge" ? "burn" : cur === "burn" ? "sponge" : "dodge" : "dodge";
13833
+ controller.setTool(next);
13834
+ return true;
13835
+ }
13836
+ case "y":
13837
+ controller.setTool("history");
13838
+ return true;
13839
+ case "d":
13840
+ controller.resetPaintColors();
13841
+ return true;
13842
+ case "x":
13843
+ controller.swapPaintColors();
13844
+ return true;
13845
+ }
13846
+ if (key === "[" || key === "{") {
13847
+ if (e.shiftKey || key === "{") set({ hardness: Math.max(0, Math.round((brush.hardness - 0.25) * 4) / 4) });
13848
+ else set({ size: Math.max(1, brush.size - sizeStep(brush.size - 1)) });
13849
+ return true;
13850
+ }
13851
+ if (key === "]" || key === "}") {
13852
+ if (e.shiftKey || key === "}") set({ hardness: Math.min(1, Math.round((brush.hardness + 0.25) * 4) / 4) });
13853
+ else set({ size: Math.min(MAX_BRUSH_SIZE, brush.size + sizeStep(brush.size)) });
13854
+ return true;
13855
+ }
13856
+ if (/^[0-9]$/.test(key) && isPaintTool(controller.getState().activeTool)) {
13857
+ set({ opacity: key === "0" ? 1 : Number(key) / 10 });
13858
+ return true;
13859
+ }
13860
+ return false;
13861
+ }
13862
+ var PaintGesture = class {
13863
+ constructor(controller) {
13864
+ this.controller = controller;
13865
+ }
13866
+ controller;
13867
+ stroke = null;
13485
13868
  move = null;
13486
13869
  /** The last refusal (e.g. "layer is locked"), for the editor to show. */
13487
13870
  error = null;
@@ -15825,247 +16208,6 @@ function removeCurvePoint(points, index) {
15825
16208
  }
15826
16209
  var round22 = (v) => Math.round(v * 100) / 100;
15827
16210
 
15828
- // src/image-editor/selection/gesture.ts
15829
- function snapTo45(from, point) {
15830
- const dx = point.x - from.x;
15831
- const dy = point.y - from.y;
15832
- const len = Math.hypot(dx, dy);
15833
- if (len === 0) return point;
15834
- const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
15835
- return { x: from.x + Math.cos(a) * len, y: from.y + Math.sin(a) * len };
15836
- }
15837
- var SelectionGesture = class {
15838
- constructor(controller, getOptions) {
15839
- this.controller = controller;
15840
- this.getOptions = getOptions;
15841
- }
15842
- controller;
15843
- getOptions;
15844
- start = null;
15845
- points = [];
15846
- current = null;
15847
- modifiers = {};
15848
- /** Polygonal lasso corners while one is being drawn. */
15849
- polygon = null;
15850
- pressedAt = null;
15851
- freehand = false;
15852
- /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
15853
- closeRadius = 8;
15854
- /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
15855
- error = null;
15856
- get active() {
15857
- return this.start !== null || this.polygon !== null;
15858
- }
15859
- /** The outline of the shape being drawn, or `null`. */
15860
- get draftPath() {
15861
- if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
15862
- if (!this.start || !this.current) return null;
15863
- const { tool } = this.getOptions();
15864
- if (tool === "lasso") return this.points.length > 1 ? this.points.slice() : null;
15865
- const rect = normalizeRect({
15866
- x: this.start.x,
15867
- y: this.start.y,
15868
- width: this.current.x - this.start.x,
15869
- height: this.current.y - this.start.y
15870
- });
15871
- if (rect.width < 1 && rect.height < 1) return null;
15872
- if (tool === "ellipse") return ellipsePoints(rect);
15873
- return [
15874
- { x: rect.x, y: rect.y },
15875
- { x: rect.x + rect.width, y: rect.y },
15876
- { x: rect.x + rect.width, y: rect.y + rect.height },
15877
- { x: rect.x, y: rect.y + rect.height }
15878
- ];
15879
- }
15880
- /** Pointer down. Resolves once a wand click has been applied. */
15881
- async begin(point, modifiers = {}) {
15882
- const options = this.getOptions();
15883
- this.error = null;
15884
- if (this.polygon && options.tool !== "polygon") this.cancel();
15885
- if (options.tool === "polygon") {
15886
- this.pressedAt = point;
15887
- this.freehand = false;
15888
- if (!this.polygon) {
15889
- this.modifiers = modifiers;
15890
- this.polygon = [point];
15891
- this.current = point;
15892
- return;
15893
- }
15894
- const last = this.polygon[this.polygon.length - 1];
15895
- const p = modifiers.shiftKey ? snapTo45(last, point) : point;
15896
- const first = this.polygon[0];
15897
- if (this.polygon.length >= 3 && Math.hypot(p.x - first.x, p.y - first.y) <= this.closeRadius) {
15898
- this.close();
15899
- return;
15900
- }
15901
- if (Math.hypot(p.x - last.x, p.y - last.y) >= 0.5) this.polygon.push(p);
15902
- this.current = p;
15903
- if ((modifiers.detail ?? 1) >= 2) this.close();
15904
- return;
15905
- }
15906
- this.modifiers = modifiers;
15907
- if (options.tool === "row" || options.tool === "column") {
15908
- this.start = null;
15909
- try {
15910
- this.controller.selectLine(options.tool, options.tool === "row" ? point.y : point.x, {
15911
- mode: selectionModeFromModifiers(modifiers, options.mode),
15912
- feather: options.feather ?? 0
15913
- });
15914
- } catch (err) {
15915
- this.error = err instanceof Error ? err.message : String(err);
15916
- }
15917
- return;
15918
- }
15919
- if (options.tool === "wand" || options.tool === "range") {
15920
- this.start = null;
15921
- const common = {
15922
- sampleMerged: options.sampleMerged,
15923
- sample: options.sample,
15924
- feather: options.feather ?? 0,
15925
- mode: selectionModeFromModifiers(modifiers, options.mode)
15926
- };
15927
- try {
15928
- if (options.tool === "range") {
15929
- const pixels = await this.controller.getSamplePixels(common);
15930
- if (!pixels) throw new Error("The image pixels are not available to sample.");
15931
- const x = Math.min(pixels.width - 1, Math.max(0, Math.floor(point.x)));
15932
- const y = Math.min(pixels.height - 1, Math.max(0, Math.floor(point.y)));
15933
- const p = (y * pixels.width + x) * 4;
15934
- const color = [pixels.data[p], pixels.data[p + 1], pixels.data[p + 2]];
15935
- await this.controller.selectColorRange([{ color, at: point }], {
15936
- ...common,
15937
- fuzziness: options.fuzziness ?? 40,
15938
- localized: options.localized ? options.localized : void 0,
15939
- invert: options.rangeInvert ?? false
15940
- });
15941
- return;
15942
- }
15943
- await this.controller.magicWand(point, {
15944
- ...common,
15945
- tolerance: options.tolerance,
15946
- contiguous: options.contiguous,
15947
- antiAlias: options.antiAlias,
15948
- distance: options.distance
15949
- });
15950
- } catch (err) {
15951
- this.error = err instanceof Error ? err.message : String(err);
15952
- }
15953
- return;
15954
- }
15955
- this.start = point;
15956
- this.current = point;
15957
- this.points = [point];
15958
- }
15959
- /** Pointer move; returns true when the draft changed (re-render). */
15960
- move(point, modifiers = {}) {
15961
- if (this.polygon) {
15962
- const last = this.polygon[this.polygon.length - 1];
15963
- if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
15964
- this.freehand = true;
15965
- if (Math.hypot(point.x - last.x, point.y - last.y) >= 0.5) this.polygon.push(point);
15966
- this.current = null;
15967
- return true;
15968
- }
15969
- this.current = modifiers.shiftKey ? snapTo45(last, point) : point;
15970
- return true;
15971
- }
15972
- if (!this.start) return false;
15973
- this.current = point;
15974
- if (this.getOptions().tool === "lasso") {
15975
- const last = this.points[this.points.length - 1];
15976
- if (Math.hypot(point.x - last.x, point.y - last.y) < 0.5) return false;
15977
- this.points.push(point);
15978
- }
15979
- return true;
15980
- }
15981
- /** Pointer up: commits the shape. Returns true when the selection changed. */
15982
- end(point, modifiers) {
15983
- if (this.polygon) {
15984
- if (point && this.freehand) this.move(point);
15985
- this.pressedAt = null;
15986
- this.freehand = false;
15987
- return false;
15988
- }
15989
- if (!this.start) return false;
15990
- if (point) this.move(point);
15991
- const options = this.getOptions();
15992
- const mode = selectionModeFromModifiers(modifiers ?? this.modifiers, options.mode);
15993
- const path = this.draftPath;
15994
- const rect = normalizeRect({
15995
- x: this.start.x,
15996
- y: this.start.y,
15997
- width: this.current.x - this.start.x,
15998
- height: this.current.y - this.start.y
15999
- });
16000
- this.start = null;
16001
- this.current = null;
16002
- this.points = [];
16003
- if (!path) return mode === "replace" ? this.controller.deselect() : false;
16004
- const opts = { mode, antiAlias: options.antiAlias, feather: options.feather ?? 0 };
16005
- if (options.tool === "lasso") return path.length >= 3 ? this.controller.selectPolygon(path, opts) : false;
16006
- return options.tool === "ellipse" ? this.controller.selectEllipse(rect, opts) : this.controller.selectRect(rect, opts);
16007
- }
16008
- /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
16009
- close() {
16010
- const polygon = this.polygon;
16011
- if (!polygon) return false;
16012
- const options = this.getOptions();
16013
- this.cancel();
16014
- if (polygon.length < 3) return false;
16015
- return this.controller.selectPolygon(polygon, {
16016
- mode: selectionModeFromModifiers(this.modifiers, options.mode),
16017
- antiAlias: options.antiAlias,
16018
- feather: options.feather ?? 0
16019
- });
16020
- }
16021
- /**
16022
- * Keys for a polygonal lasso being drawn: Enter closes it, Escape cancels, Backspace / Delete
16023
- * removes the last corner. Returns true when the key was used (the host prevents its default).
16024
- */
16025
- key(key) {
16026
- if (!this.polygon) return false;
16027
- if (key === "Enter") this.close();
16028
- else if (key === "Escape") this.cancel();
16029
- else if (key === "Backspace" || key === "Delete") {
16030
- this.polygon.pop();
16031
- if (!this.polygon.length) this.cancel();
16032
- } else return false;
16033
- return true;
16034
- }
16035
- /** Abandons a drag (or a polygonal lasso) without changing the selection. */
16036
- cancel() {
16037
- this.start = null;
16038
- this.current = null;
16039
- this.points = [];
16040
- this.polygon = null;
16041
- this.pressedAt = null;
16042
- this.freehand = false;
16043
- }
16044
- };
16045
- function handleSelectionShortcut(controller, e, onError) {
16046
- if (!(e.ctrlKey || e.metaKey)) return false;
16047
- const key = e.key.toLowerCase();
16048
- const run = (fn) => {
16049
- try {
16050
- fn();
16051
- } catch (err) {
16052
- onError?.(err instanceof Error ? err.message : String(err));
16053
- }
16054
- return true;
16055
- };
16056
- if (key === "c") return run(() => controller.copyPixels({ merged: Boolean(e.shiftKey) }));
16057
- if (key === "x" && !e.shiftKey) return run(() => controller.cutPixels());
16058
- if (key === "v") return run(() => controller.pastePixels(e.shiftKey && e.altKey ? "into" : e.shiftKey ? "in-place" : "new-layer"));
16059
- if (key === "d") return run(() => e.shiftKey ? controller.reselect() : controller.deselect());
16060
- if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
16061
- return false;
16062
- }
16063
- function handleQuickMaskKey(controller, e) {
16064
- if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
16065
- controller.setQuickMaskMode(!controller.quickMaskEditing);
16066
- return true;
16067
- }
16068
-
16069
16211
  // src/image-editor/selection/mask-files.ts
16070
16212
  var MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
16071
16213
  function blobDataUrl(file) {
@@ -16241,6 +16383,10 @@ function maskThumbnailClick(controller, layer, e) {
16241
16383
  if (!layer.mask) return;
16242
16384
  if (e.ctrlKey || e.metaKey) {
16243
16385
  controller.selectionFromLayerMask(layer.id, selectionModeFromModifiers(e, "replace"));
16386
+ } else if (e.altKey && !layer.mask.source) {
16387
+ const mode = e.shiftKey ? "overlay" : "gray";
16388
+ const view = controller.getMaskView();
16389
+ controller.setMaskView(view?.layerId === layer.id && view.mode === mode ? "off" : mode, layer.id);
16244
16390
  } else if (e.shiftKey) {
16245
16391
  controller.setLayerMaskProps(layer.id, { enabled: layer.mask.enabled === false });
16246
16392
  } else {
@@ -18183,6 +18329,7 @@ export {
18183
18329
  growSelection,
18184
18330
  guidePlanes,
18185
18331
  guidedFilterColor,
18332
+ handleMaskViewKey,
18186
18333
  handlePaintShortcut,
18187
18334
  handleQuickMaskKey,
18188
18335
  handleSelectionShortcut,
@@ -18273,6 +18420,7 @@ export {
18273
18420
  layerMaskCoverage,
18274
18421
  layerMaskFromSelection,
18275
18422
  layerMaskKey,
18423
+ layerMaskSelection,
18276
18424
  layerMenu,
18277
18425
  layerName,
18278
18426
  layerPanelRows,
@@ -18410,6 +18558,7 @@ export {
18410
18558
  pointerStrokeSample,
18411
18559
  pointerStrokeSamples,
18412
18560
  polygonCoverage,
18561
+ polygonCoverageBox,
18413
18562
  polygonSelection,
18414
18563
  posterizeValue,
18415
18564
  presetWarp,
@@ -18509,6 +18658,8 @@ export {
18509
18658
  selectionArea,
18510
18659
  selectionFromCoverage,
18511
18660
  selectionFromPixels,
18661
+ selectionFromRegion,
18662
+ selectionFromTiles,
18512
18663
  selectionModeFromModifiers,
18513
18664
  selectionOutline,
18514
18665
  selectionRing,