@pixerate/editor 0.188.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.
@@ -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,
@@ -372,7 +373,7 @@ import {
372
373
  writePsd,
373
374
  xyToTempTint,
374
375
  xyToUv
375
- } from "../chunk-IGMNJM2H.js";
376
+ } from "../chunk-ZEHTUR7Z.js";
376
377
  import {
377
378
  parseCssColor
378
379
  } from "../chunk-3CI7FRMK.js";
@@ -1365,6 +1366,24 @@ var ImageEditorRenderer = class {
1365
1366
  }
1366
1367
  ctx.restore();
1367
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
+ }
1368
1387
  const display = options.selectionDisplay ?? state.selectionDisplay ?? (state.quickMask ? "quickMask" : "ants");
1369
1388
  const shownChannels = (state.channels ?? []).filter((c) => c.visible);
1370
1389
  if (shownChannels.length) {
@@ -1804,6 +1823,32 @@ var ImageEditorRenderer = class {
1804
1823
  return this.emptyMaskCache;
1805
1824
  }
1806
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
+ }
1807
1852
  /** A channel's tint (colour, opacity, masked or selected area), drawn in natural image space. */
1808
1853
  drawMaskOverlay(ctx, mask, style) {
1809
1854
  const key = `${style.color?.join(",")}|${style.opacity}|${style.indicates}`;
@@ -8351,6 +8396,41 @@ var ImageEditorController = class {
8351
8396
  this.state.quickMask = display === "quickMask";
8352
8397
  this.notify();
8353
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
+ }
8354
8434
  // --- Alpha channels ---
8355
8435
  /** The alpha channels (saved selections), in panel order. */
8356
8436
  getChannels() {
@@ -12087,6 +12167,22 @@ function selectionShade(selection, doc) {
12087
12167
  context.putImageData(pixels, 0, 0);
12088
12168
  return canvas;
12089
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
+ }
12090
12186
  async function renderImageRegion(controller, request) {
12091
12187
  const state = controller.getState();
12092
12188
  const k = Math.min(1, request.scale);
@@ -12109,6 +12205,8 @@ async function renderImageRegion(controller, request) {
12109
12205
  context.drawImage(view, rect.x * kx, rect.y * ky, rect.width * kx, rect.height * ky, 0, 0, out.width, out.height);
12110
12206
  if (request.includeOverlays) {
12111
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);
12112
12210
  const selection = state.selection;
12113
12211
  if (selection?.bounds && selection.width === state.imageDimensions.width && selection.height === state.imageDimensions.height) {
12114
12212
  const shade = selectionShade(selection, document);
@@ -12136,7 +12234,7 @@ function createImageCaptureSurface(controller) {
12136
12234
  units: "image pixels",
12137
12235
  content: whole,
12138
12236
  selection: () => ({ rect: controller.getState().selection?.bounds ?? null }),
12139
- 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)",
12140
12238
  maxScale: 8,
12141
12239
  render: (request) => renderImageRegion(controller, request)
12142
12240
  };
@@ -12824,6 +12922,27 @@ function createImageLayerTools(controller) {
12824
12922
  return { layer: describe(id) };
12825
12923
  }
12826
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
+ };
12827
12946
  const maskProps = {
12828
12947
  name: "set_layer_mask_properties",
12829
12948
  title: "Set layer mask properties",
@@ -12964,6 +13083,7 @@ function createImageLayerTools(controller) {
12964
13083
  addMask,
12965
13084
  applyMask,
12966
13085
  removeMask,
13086
+ maskView,
12967
13087
  maskProps,
12968
13088
  viaCopy,
12969
13089
  rasterize,
@@ -13304,201 +13424,452 @@ function createImageEffectTools(controller) {
13304
13424
  return [list, add2, update, remove, setEnabled, light];
13305
13425
  }
13306
13426
 
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 });
13371
- }
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
- ]
13411
- }
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":
13472
- controller.swapPaintColors();
13473
- return true;
13474
- }
13475
- if (key === "[" || key === "{") {
13476
- if (e.shiftKey || key === "{") set({ hardness: Math.max(0, Math.round((brush.hardness - 0.25) * 4) / 4) });
13477
- else set({ size: Math.max(1, brush.size - sizeStep(brush.size - 1)) });
13478
- return true;
13479
- }
13480
- if (key === "]" || key === "}") {
13481
- if (e.shiftKey || key === "}") set({ hardness: Math.min(1, Math.round((brush.hardness + 0.25) * 4) / 4) });
13482
- else set({ size: Math.min(MAX_BRUSH_SIZE, brush.size + sizeStep(brush.size)) });
13483
- return true;
13484
- }
13485
- if (/^[0-9]$/.test(key) && isPaintTool(controller.getState().activeTool)) {
13486
- set({ opacity: key === "0" ? 1 : Number(key) / 10 });
13487
- return true;
13488
- }
13489
- 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 };
13490
13435
  }
13491
- var PaintGesture = class {
13492
- constructor(controller) {
13436
+ var SelectionGesture = class {
13437
+ constructor(controller, getOptions) {
13493
13438
  this.controller = controller;
13439
+ this.getOptions = getOptions;
13494
13440
  }
13495
13441
  controller;
13496
- stroke = null;
13497
- move = null;
13498
- /** The last refusal (e.g. "layer is locked"), for the editor to show. */
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. */
13499
13454
  error = null;
13500
13455
  get active() {
13501
- return Boolean(this.stroke || this.move);
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;
13868
+ move = null;
13869
+ /** The last refusal (e.g. "layer is locked"), for the editor to show. */
13870
+ error = null;
13871
+ get active() {
13872
+ return Boolean(this.stroke || this.move);
13502
13873
  }
13503
13874
  down(sample) {
13504
13875
  this.error = null;
@@ -15837,247 +16208,6 @@ function removeCurvePoint(points, index) {
15837
16208
  }
15838
16209
  var round22 = (v) => Math.round(v * 100) / 100;
15839
16210
 
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
16211
  // src/image-editor/selection/mask-files.ts
16082
16212
  var MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
16083
16213
  function blobDataUrl(file) {
@@ -16253,6 +16383,10 @@ function maskThumbnailClick(controller, layer, e) {
16253
16383
  if (!layer.mask) return;
16254
16384
  if (e.ctrlKey || e.metaKey) {
16255
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);
16256
16390
  } else if (e.shiftKey) {
16257
16391
  controller.setLayerMaskProps(layer.id, { enabled: layer.mask.enabled === false });
16258
16392
  } else {
@@ -18195,6 +18329,7 @@ export {
18195
18329
  growSelection,
18196
18330
  guidePlanes,
18197
18331
  guidedFilterColor,
18332
+ handleMaskViewKey,
18198
18333
  handlePaintShortcut,
18199
18334
  handleQuickMaskKey,
18200
18335
  handleSelectionShortcut,
@@ -18285,6 +18420,7 @@ export {
18285
18420
  layerMaskCoverage,
18286
18421
  layerMaskFromSelection,
18287
18422
  layerMaskKey,
18423
+ layerMaskSelection,
18288
18424
  layerMenu,
18289
18425
  layerName,
18290
18426
  layerPanelRows,