@pixerate/editor 0.193.0 → 0.195.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.
@@ -2497,6 +2497,312 @@ function pathToSelection(components, width, height, options = {}) {
2497
2497
  return feather > 0 && mask.bounds ? modifySelection(mask, "feather", feather) : mask;
2498
2498
  }
2499
2499
 
2500
+ // src/image-editor/selection/magnetic.ts
2501
+ var BLOCK = 64;
2502
+ var W_ZERO = 0.3;
2503
+ var W_GRADIENT = 0.4;
2504
+ var W_DIRECTION = 0.3;
2505
+ var EdgeMap = class {
2506
+ width;
2507
+ height;
2508
+ contrast;
2509
+ luma;
2510
+ blocks = /* @__PURE__ */ new Map();
2511
+ constructor(pixels, options = {}) {
2512
+ const { width, height, data } = pixels;
2513
+ if (!(width >= 1 && height >= 1) || data.length < width * height * 4) throw new Error("Invalid pixel buffer for edge detection.");
2514
+ this.width = width;
2515
+ this.height = height;
2516
+ this.contrast = Math.min(1, Math.max(0, options.contrast ?? 0.1));
2517
+ const luma2 = new Uint8Array(width * height);
2518
+ for (let i = 0, p = 0; i < luma2.length; i++, p += 4) {
2519
+ const a = data[p + 3] / 255;
2520
+ const y = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
2521
+ luma2[i] = Math.round(y * a + 255 * (1 - a));
2522
+ }
2523
+ this.luma = luma2;
2524
+ }
2525
+ /** Edge strength at (x, y) in whole pixels, 0..1 (0 outside the image or below the contrast). */
2526
+ strength(x, y) {
2527
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return 0;
2528
+ const b = this.block(x, y);
2529
+ return b.strength[y % BLOCK * BLOCK + x % BLOCK];
2530
+ }
2531
+ block(x, y) {
2532
+ const bx = Math.floor(x / BLOCK);
2533
+ const by = Math.floor(y / BLOCK);
2534
+ const key = by * 65536 + bx;
2535
+ let b = this.blocks.get(key);
2536
+ if (!b) {
2537
+ b = this.computeBlock(bx * BLOCK, by * BLOCK);
2538
+ this.blocks.set(key, b);
2539
+ }
2540
+ return b;
2541
+ }
2542
+ computeBlock(x0, y0) {
2543
+ const { width: W, height: H, luma: luma2 } = this;
2544
+ const M = 2;
2545
+ const S = BLOCK + 2 * M;
2546
+ const smooth = new Float32Array(S * S);
2547
+ const at = (x, y) => luma2[Math.min(H - 1, Math.max(0, y)) * W + Math.min(W - 1, Math.max(0, x))];
2548
+ for (let j = 0; j < S; j++) {
2549
+ const y = y0 - M + j;
2550
+ for (let i = 0; i < S; i++) {
2551
+ const x = x0 - M + i;
2552
+ const v = at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) + 2 * at(x - 1, y) + 4 * at(x, y) + 2 * at(x + 1, y) + at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1);
2553
+ smooth[j * S + i] = v / (16 * 255);
2554
+ }
2555
+ }
2556
+ const lap = new Float32Array(S * S);
2557
+ for (let j = 1; j < S - 1; j++) {
2558
+ for (let i = 1; i < S - 1; i++) {
2559
+ const k = j * S + i;
2560
+ lap[k] = smooth[k - 1] + smooth[k + 1] + smooth[k - S] + smooth[k + S] - 4 * smooth[k];
2561
+ }
2562
+ }
2563
+ const n = BLOCK * BLOCK;
2564
+ const strength = new Float32Array(n);
2565
+ const dirX = new Float32Array(n);
2566
+ const dirY = new Float32Array(n);
2567
+ const zero = new Uint8Array(n);
2568
+ const c = this.contrast;
2569
+ for (let j = 0; j < BLOCK; j++) {
2570
+ if (y0 + j >= H) break;
2571
+ for (let i = 0; i < BLOCK; i++) {
2572
+ if (x0 + i >= W) break;
2573
+ const k = (j + M) * S + (i + M);
2574
+ const gx = (smooth[k + 1] - smooth[k - 1]) / 2;
2575
+ const gy = (smooth[k + S] - smooth[k - S]) / 2;
2576
+ const g = Math.hypot(gx, gy);
2577
+ const e = Math.min(1, 2 * g);
2578
+ if (e < c || g === 0) continue;
2579
+ const o = j * BLOCK + i;
2580
+ strength[o] = e;
2581
+ dirX[o] = gy / g;
2582
+ dirY[o] = -gx / g;
2583
+ const l = lap[k];
2584
+ for (const d of [1, -1, S, -S]) {
2585
+ const m = lap[k + d];
2586
+ if (l > 0 && m < 0 || l < 0 && m > 0 || l === 0) {
2587
+ if (Math.abs(l) <= Math.abs(m)) {
2588
+ zero[o] = 1;
2589
+ break;
2590
+ }
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ return { strength, dirX, dirY, zero };
2596
+ }
2597
+ /** Features of the pixel at (x, y), which must be inside the image. */
2598
+ features(x, y) {
2599
+ const b = this.block(x, y);
2600
+ const o = y % BLOCK * BLOCK + x % BLOCK;
2601
+ return { strength: b.strength[o], dirX: b.dirX[o], dirY: b.dirY[o], zero: b.zero[o] };
2602
+ }
2603
+ };
2604
+ var clampPixel = (v, max) => Math.min(max - 1, Math.max(0, Math.floor(v)));
2605
+ function snapToEdge(map, point, width = 10) {
2606
+ const cx = clampPixel(point.x, map.width);
2607
+ const cy = clampPixel(point.y, map.height);
2608
+ const r = Math.max(0, Math.round(width));
2609
+ let best = 0;
2610
+ let bestD = Infinity;
2611
+ let bx = cx;
2612
+ let by = cy;
2613
+ for (let y = Math.max(0, cy - r); y <= Math.min(map.height - 1, cy + r); y++) {
2614
+ for (let x = Math.max(0, cx - r); x <= Math.min(map.width - 1, cx + r); x++) {
2615
+ const d = (x - cx) ** 2 + (y - cy) ** 2;
2616
+ if (d > r * r) continue;
2617
+ const s = map.strength(x, y);
2618
+ if (s > best + 0.02 || s > 0 && Math.abs(s - best) <= 0.02 && d < bestD) {
2619
+ if (s > best) best = s;
2620
+ bestD = d;
2621
+ bx = x;
2622
+ by = y;
2623
+ }
2624
+ }
2625
+ }
2626
+ return best > 0 ? { x: bx + 0.5, y: by + 0.5 } : point;
2627
+ }
2628
+ var Heap = class {
2629
+ ids = new Int32Array(256);
2630
+ keys = new Float64Array(256);
2631
+ size = 0;
2632
+ push(id, key) {
2633
+ if (this.size === this.ids.length) {
2634
+ const ids = new Int32Array(this.size * 2);
2635
+ ids.set(this.ids);
2636
+ const keys = new Float64Array(this.size * 2);
2637
+ keys.set(this.keys);
2638
+ this.ids = ids;
2639
+ this.keys = keys;
2640
+ }
2641
+ let i = this.size++;
2642
+ while (i > 0) {
2643
+ const p = i - 1 >> 1;
2644
+ if (this.keys[p] <= key) break;
2645
+ this.ids[i] = this.ids[p];
2646
+ this.keys[i] = this.keys[p];
2647
+ i = p;
2648
+ }
2649
+ this.ids[i] = id;
2650
+ this.keys[i] = key;
2651
+ }
2652
+ /** Removes the cheapest entry; read it from `topId` / `topKey` first. */
2653
+ pop() {
2654
+ const n = --this.size;
2655
+ const id = this.ids[n];
2656
+ const key = this.keys[n];
2657
+ let i = 0;
2658
+ for (; ; ) {
2659
+ let c = 2 * i + 1;
2660
+ if (c >= n) break;
2661
+ if (c + 1 < n && this.keys[c + 1] < this.keys[c]) c++;
2662
+ if (this.keys[c] >= key) break;
2663
+ this.ids[i] = this.ids[c];
2664
+ this.keys[i] = this.keys[c];
2665
+ i = c;
2666
+ }
2667
+ this.ids[i] = id;
2668
+ this.keys[i] = key;
2669
+ }
2670
+ get topId() {
2671
+ return this.ids[0];
2672
+ }
2673
+ get topKey() {
2674
+ return this.keys[0];
2675
+ }
2676
+ };
2677
+ var NEIGHBOURS = [
2678
+ [1, 0],
2679
+ [-1, 0],
2680
+ [0, 1],
2681
+ [0, -1],
2682
+ [1, 1],
2683
+ [1, -1],
2684
+ [-1, 1],
2685
+ [-1, -1]
2686
+ ];
2687
+ function liveWire(map, from, to, band) {
2688
+ const ax = clampPixel(from.x, map.width);
2689
+ const ay = clampPixel(from.y, map.height);
2690
+ const bx = clampPixel(to.x, map.width);
2691
+ const by = clampPixel(to.y, map.height);
2692
+ if (ax === bx && ay === by) return [{ x: ax + 0.5, y: ay + 0.5 }];
2693
+ const len = Math.hypot(bx - ax, by - ay);
2694
+ const half = Math.max(2, band ?? Math.max(16, len / 4));
2695
+ const rx0 = Math.max(0, Math.floor(Math.min(ax, bx) - half));
2696
+ const ry0 = Math.max(0, Math.floor(Math.min(ay, by) - half));
2697
+ const rx1 = Math.min(map.width - 1, Math.ceil(Math.max(ax, bx) + half));
2698
+ const ry1 = Math.min(map.height - 1, Math.ceil(Math.max(ay, by) + half));
2699
+ const w = rx1 - rx0 + 1;
2700
+ const h = ry1 - ry0 + 1;
2701
+ const n = w * h;
2702
+ const strength = new Float32Array(n);
2703
+ const dirX = new Float32Array(n);
2704
+ const dirY = new Float32Array(n);
2705
+ const zero = new Uint8Array(n);
2706
+ const inside = new Uint8Array(n);
2707
+ let maxStrength = 0;
2708
+ const ux = (bx - ax) / len;
2709
+ const uy = (by - ay) / len;
2710
+ for (let y = 0; y < h; y++) {
2711
+ for (let x = 0; x < w; x++) {
2712
+ const px2 = rx0 + x - ax;
2713
+ const py = ry0 + y - ay;
2714
+ const t = Math.min(len, Math.max(0, px2 * ux + py * uy));
2715
+ if (Math.hypot(px2 - t * ux, py - t * uy) > half) continue;
2716
+ const i = y * w + x;
2717
+ inside[i] = 1;
2718
+ const f = map.features(rx0 + x, ry0 + y);
2719
+ strength[i] = f.strength;
2720
+ dirX[i] = f.dirX;
2721
+ dirY[i] = f.dirY;
2722
+ zero[i] = f.zero;
2723
+ if (f.strength > maxStrength) maxStrength = f.strength;
2724
+ }
2725
+ }
2726
+ const scale = maxStrength > 0 ? 1 / maxStrength : 0;
2727
+ const dist = new Float64Array(n).fill(Infinity);
2728
+ const prev = new Int32Array(n).fill(-1);
2729
+ const done = new Uint8Array(n);
2730
+ const start = (ay - ry0) * w + (ax - rx0);
2731
+ const goal = (by - ry0) * w + (bx - rx0);
2732
+ dist[start] = 0;
2733
+ const heap = new Heap();
2734
+ heap.push(start, 0);
2735
+ while (heap.size) {
2736
+ const p = heap.topId;
2737
+ const d = heap.topKey;
2738
+ heap.pop();
2739
+ if (done[p] || d > dist[p]) continue;
2740
+ done[p] = 1;
2741
+ if (p === goal) break;
2742
+ const x = p % w;
2743
+ const y = (p - x) / w;
2744
+ for (const [dx, dy] of NEIGHBOURS) {
2745
+ const qx = x + dx;
2746
+ const qy = y + dy;
2747
+ if (qx < 0 || qy < 0 || qx >= w || qy >= h) continue;
2748
+ const q = qy * w + qx;
2749
+ if (!inside[q] || done[q]) continue;
2750
+ const step = dx && dy ? Math.SQRT2 : 1;
2751
+ const lx = dx / step;
2752
+ const ly = dy / step;
2753
+ const dp = Math.min(1, Math.abs(dirX[p] * lx + dirY[p] * ly));
2754
+ const dq = Math.min(1, Math.abs(dirX[q] * lx + dirY[q] * ly));
2755
+ const fD = (Math.acos(dp) + Math.acos(dq)) / Math.PI;
2756
+ const fG = 1 - strength[q] * scale;
2757
+ const fZ = 1 - zero[q];
2758
+ const cost = (W_ZERO * fZ + W_GRADIENT * fG + W_DIRECTION * fD + 0.01) * step;
2759
+ const nd = d + cost;
2760
+ if (nd < dist[q]) {
2761
+ dist[q] = nd;
2762
+ prev[q] = p;
2763
+ heap.push(q, nd);
2764
+ }
2765
+ }
2766
+ }
2767
+ if (prev[goal] < 0) return [{ x: ax + 0.5, y: ay + 0.5 }, { x: bx + 0.5, y: by + 0.5 }];
2768
+ const path = [];
2769
+ for (let p = goal; p >= 0; p = prev[p]) {
2770
+ const x = p % w;
2771
+ path.push({ x: rx0 + x + 0.5, y: ry0 + (p - x) / w + 0.5 });
2772
+ }
2773
+ return path.reverse();
2774
+ }
2775
+ function simplifyPixelPath(points) {
2776
+ if (points.length < 3) return points.slice();
2777
+ const out = [points[0]];
2778
+ for (let i = 1; i < points.length - 1; i++) {
2779
+ const a = out[out.length - 1];
2780
+ const b = points[i];
2781
+ const c = points[i + 1];
2782
+ const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
2783
+ if (Math.abs(cross) > 1e-9) out.push(b);
2784
+ }
2785
+ out.push(points[points.length - 1]);
2786
+ return out;
2787
+ }
2788
+ function snapPolygonToEdges(pixels, points, options = {}) {
2789
+ const map = pixels instanceof EdgeMap ? pixels : new EdgeMap(pixels, options);
2790
+ const width = options.width ?? 10;
2791
+ const snapped = points.map((p) => snapToEdge(map, p, width));
2792
+ const out = [];
2793
+ for (let i = 0; i < snapped.length; i++) {
2794
+ const a = snapped[i];
2795
+ const b = snapped[(i + 1) % snapped.length];
2796
+ const seg = liveWire(map, a, b, Math.max(2 * width, 16, Math.hypot(b.x - a.x, b.y - a.y) / 4));
2797
+ out.push(...seg.slice(0, -1));
2798
+ }
2799
+ return simplifyPixelPath(out);
2800
+ }
2801
+ function magneticAnchorSpacing(frequency = 57) {
2802
+ const f = Math.min(100, Math.max(0, frequency));
2803
+ return 8 + (100 - f);
2804
+ }
2805
+
2500
2806
  // src/image-editor/layers/shape-type-ui.ts
2501
2807
  var DEFAULT_SHAPE_TOOL_OPTIONS = {
2502
2808
  kind: "rect",
@@ -8432,6 +8738,15 @@ var ImageEditorController = class {
8432
8738
  const { width, height } = this.naturalSize;
8433
8739
  return this.shapeCommand(polygonSelection(width, height, points, options), options);
8434
8740
  }
8741
+ /**
8742
+ * Magnetic Lasso for a given outline: each point snaps to the strongest edge within `width` px
8743
+ * and consecutive points (the last back to the first) are joined along the edges. Returns the
8744
+ * snapped outline in natural pixels, for {@link selectPolygon}.
8745
+ */
8746
+ async snapToEdges(points, options = {}) {
8747
+ const pixels = await this.requirePixels(options);
8748
+ return snapPolygonToEdges(pixels, points, options);
8749
+ }
8435
8750
  /**
8436
8751
  * Supplies the pixels the selection tools sample (natural size RGBA), e.g. decoded in a worker
8437
8752
  * or in a test; `null` goes back to reading the source image. Without a canvas (Node, workers)
@@ -11440,6 +11755,7 @@ var SELECTION_TOOLS = [
11440
11755
  { id: "column", label: "Single Column" },
11441
11756
  { id: "lasso", label: "Lasso" },
11442
11757
  { id: "polygon", label: "Polygonal Lasso" },
11758
+ { id: "magnetic", label: "Magnetic Lasso" },
11443
11759
  { id: "range", label: "Color Range" },
11444
11760
  { id: "type", label: "Type Mask" },
11445
11761
  { id: "type-vertical", label: "Vertical Type Mask" }
@@ -11474,6 +11790,9 @@ var DEFAULT_SELECTION_TOOL_OPTIONS = {
11474
11790
  fuzziness: 40,
11475
11791
  localized: 0,
11476
11792
  rangeInvert: false,
11793
+ magneticWidth: 10,
11794
+ magneticContrast: 10,
11795
+ magneticFrequency: 57,
11477
11796
  typeText: "Type",
11478
11797
  typeSize: 72,
11479
11798
  typeFamily: "sans-serif"
@@ -11836,11 +12155,20 @@ function createImageSelectionTools(controller) {
11836
12155
  const selectPolygonTool = {
11837
12156
  name: "select_polygon",
11838
12157
  title: "Select a polygon (lasso)",
11839
- description: "Lasso: selects the polygon through points (natural image pixels, in order along the outline, closed automatically), combined by mode. To cut out a subject, follow its contour closely (more points give a tighter edge), then refine_selection for hair or foliage. One undoable step. Returns the selection.",
12158
+ description: "Lasso: selects the polygon through points (natural image pixels, in order along the outline, closed automatically), combined by mode. To cut out a subject, follow its contour closely (more points give a tighter edge), then refine_selection for hair or foliage. With snapToEdges (the Magnetic Lasso), each point snaps to the strongest edge within width px and the outline between points follows the edges of the image, so a rough outline a few dozen px per point is enough on a subject with a clear edge. One undoable step. Returns the selection (with snapToEdges, also outlinePoints: how many vertices the snapped outline has).",
11840
12159
  inputSchema: {
11841
12160
  type: "object",
11842
12161
  properties: {
11843
12162
  points: { type: "array", minItems: 3, items: pointSchema, description: "At least 3 vertices along the outline, in order, in natural image pixels." },
12163
+ snapToEdges: {
12164
+ type: "object",
12165
+ description: "Magnetic Lasso: snap the outline to the edges of the image (the composite as painted) instead of joining the points with straight lines. Omit for a plain polygon.",
12166
+ properties: {
12167
+ width: { type: "number", minimum: 1, maximum: 256, description: "Detection width in px: each point snaps to the strongest edge this close (default 10). Keep points within it of the edge." },
12168
+ contrast: { type: "number", minimum: 0.01, maximum: 1, description: "Ignore edges with less contrast than this, 0.01..1 (default 0.1). Raise it on a noisy or textured background; lower it for a faint edge." }
12169
+ },
12170
+ additionalProperties: false
12171
+ },
11844
12172
  mode: modeSchema,
11845
12173
  feather: featherSchema,
11846
12174
  antiAlias: antiAliasSchema
@@ -11849,13 +12177,19 @@ function createImageSelectionTools(controller) {
11849
12177
  additionalProperties: false
11850
12178
  },
11851
12179
  annotations: writeAnnotations("Select a polygon (lasso)", true),
11852
- handler: (args) => {
12180
+ handler: async (args) => {
11853
12181
  const list = Array.isArray(args.points) ? args.points.map((p) => ({ x: num2(p?.x, NaN), y: num2(p?.y, NaN) })) : [];
11854
12182
  if (list.length < 3 || !list.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) {
11855
12183
  throw new Error("points needs at least 3 { x, y } vertices in natural image pixels.");
11856
12184
  }
11857
- controller.selectPolygon(list, shapeOptions(args));
11858
- return summary(controller);
12185
+ const snap = args.snapToEdges;
12186
+ if (!snap) {
12187
+ controller.selectPolygon(list, shapeOptions(args));
12188
+ return summary(controller);
12189
+ }
12190
+ const outline = await controller.snapToEdges(list, { width: snap.width, contrast: snap.contrast });
12191
+ controller.selectPolygon(outline, shapeOptions(args));
12192
+ return { ...summary(controller), outlinePoints: outline.length };
11859
12193
  }
11860
12194
  };
11861
12195
  const magicWandTool = {
@@ -13792,15 +14126,22 @@ var SelectionGesture = class {
13792
14126
  polygon = null;
13793
14127
  pressedAt = null;
13794
14128
  freehand = false;
14129
+ magnetic = null;
13795
14130
  /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
13796
14131
  closeRadius = 8;
13797
14132
  /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
13798
14133
  error = null;
13799
14134
  get active() {
13800
- return this.start !== null || this.polygon !== null;
14135
+ return this.start !== null || this.polygon !== null || this.magnetic !== null;
14136
+ }
14137
+ /** The anchors of a Magnetic Lasso being drawn (empty otherwise). */
14138
+ get magneticAnchors() {
14139
+ const m = this.magnetic;
14140
+ return m ? m.anchors.map((i) => m.fixed[i]) : [];
13801
14141
  }
13802
14142
  /** The outline of the shape being drawn, or `null`. */
13803
14143
  get draftPath() {
14144
+ if (this.magnetic) return [...this.magnetic.fixed, ...this.magnetic.live.slice(1)];
13804
14145
  if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
13805
14146
  if (!this.start || !this.current) return null;
13806
14147
  const { tool } = this.getOptions();
@@ -13824,7 +14165,8 @@ var SelectionGesture = class {
13824
14165
  async begin(point, modifiers = {}) {
13825
14166
  const options = this.getOptions();
13826
14167
  this.error = null;
13827
- if (this.polygon && options.tool !== "polygon") this.cancel();
14168
+ if (this.polygon && options.tool !== "polygon" || this.magnetic && options.tool !== "magnetic") this.cancel();
14169
+ if (options.tool === "magnetic") return this.magneticClick(point, modifiers, options);
13828
14170
  if (options.tool === "polygon") {
13829
14171
  this.pressedAt = point;
13830
14172
  this.freehand = false;
@@ -13919,6 +14261,11 @@ var SelectionGesture = class {
13919
14261
  }
13920
14262
  /** Pointer move; returns true when the draft changed (re-render). */
13921
14263
  move(point, modifiers = {}) {
14264
+ if (this.magnetic) {
14265
+ this.current = point;
14266
+ this.followEdges(point);
14267
+ return true;
14268
+ }
13922
14269
  if (this.polygon) {
13923
14270
  const last = this.polygon[this.polygon.length - 1];
13924
14271
  if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
@@ -13941,6 +14288,7 @@ var SelectionGesture = class {
13941
14288
  }
13942
14289
  /** Pointer up: commits the shape. Returns true when the selection changed. */
13943
14290
  end(point, modifiers) {
14291
+ if (this.magnetic) return false;
13944
14292
  if (this.polygon) {
13945
14293
  if (point && this.freehand) this.move(point);
13946
14294
  this.pressedAt = null;
@@ -13968,7 +14316,12 @@ var SelectionGesture = class {
13968
14316
  }
13969
14317
  /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
13970
14318
  close() {
13971
- const polygon = this.polygon;
14319
+ let polygon = this.polygon;
14320
+ const m = this.magnetic;
14321
+ if (m) {
14322
+ const tail = m.edges && m.fixed.length > 1 ? liveWire(m.edges, m.fixed[m.fixed.length - 1], m.fixed[0], this.corridor(m.fixed[m.fixed.length - 1], m.fixed[0])) : [];
14323
+ polygon = simplifyPixelPath([...m.fixed, ...tail.slice(1, -1)]);
14324
+ }
13972
14325
  if (!polygon) return false;
13973
14326
  const options = this.getOptions();
13974
14327
  this.cancel();
@@ -13984,21 +14337,99 @@ var SelectionGesture = class {
13984
14337
  * removes the last corner. Returns true when the key was used (the host prevents its default).
13985
14338
  */
13986
14339
  key(key) {
13987
- if (!this.polygon) return false;
14340
+ if (!this.polygon && !this.magnetic) return false;
13988
14341
  if (key === "Enter") this.close();
13989
14342
  else if (key === "Escape") this.cancel();
13990
14343
  else if (key === "Backspace" || key === "Delete") {
13991
- this.polygon.pop();
13992
- if (!this.polygon.length) this.cancel();
14344
+ const m = this.magnetic;
14345
+ if (m) {
14346
+ m.anchors.pop();
14347
+ if (!m.anchors.length) this.cancel();
14348
+ else {
14349
+ m.fixed.length = m.anchors[m.anchors.length - 1] + 1;
14350
+ m.live = [];
14351
+ if (this.current) this.followEdges(this.current, false);
14352
+ }
14353
+ } else {
14354
+ this.polygon.pop();
14355
+ if (!this.polygon.length) this.cancel();
14356
+ }
13993
14357
  } else return false;
13994
14358
  return true;
13995
14359
  }
14360
+ /** Corridor half-width for a live-wire between two points. */
14361
+ corridor(a, b) {
14362
+ return Math.max(2 * (this.getOptions().magneticWidth ?? 10), 16, Math.hypot(b.x - a.x, b.y - a.y) / 4);
14363
+ }
14364
+ async magneticClick(point, modifiers, options) {
14365
+ const m = this.magnetic;
14366
+ if (!m) {
14367
+ this.modifiers = modifiers;
14368
+ const draft = { fixed: [point], anchors: [0], live: [], edges: null };
14369
+ this.magnetic = draft;
14370
+ this.current = point;
14371
+ try {
14372
+ const pixels = await this.controller.getSamplePixels({ sampleMerged: options.sampleMerged, sample: options.sample });
14373
+ if (!pixels) throw new Error("The image pixels are not available to find edges in.");
14374
+ if (this.magnetic !== draft) return;
14375
+ draft.edges = new EdgeMap(pixels, { contrast: (options.magneticContrast ?? 10) / 100 });
14376
+ draft.fixed = [snapToEdge(draft.edges, point, options.magneticWidth ?? 10)];
14377
+ if (this.current) this.followEdges(this.current);
14378
+ } catch (err) {
14379
+ this.error = err instanceof Error ? err.message : String(err);
14380
+ if (this.magnetic === draft) this.cancel();
14381
+ }
14382
+ return;
14383
+ }
14384
+ const first = m.fixed[0];
14385
+ if (m.anchors.length >= 2 && Math.hypot(point.x - first.x, point.y - first.y) <= this.closeRadius) {
14386
+ this.close();
14387
+ return;
14388
+ }
14389
+ this.current = point;
14390
+ this.followEdges(point);
14391
+ if (m.live.length > 1) {
14392
+ m.fixed.push(...m.live.slice(1));
14393
+ m.anchors.push(m.fixed.length - 1);
14394
+ m.live = [];
14395
+ }
14396
+ if ((modifiers.detail ?? 1) >= 2) this.close();
14397
+ }
14398
+ /** Updates the live path to `point`, fixing anchors along it every `frequency` px (with `place`). */
14399
+ followEdges(point, place = true) {
14400
+ const m = this.magnetic;
14401
+ const last = () => m.fixed[m.fixed.length - 1];
14402
+ if (!m.edges) {
14403
+ m.live = [last(), point];
14404
+ return;
14405
+ }
14406
+ const options = this.getOptions();
14407
+ const target = snapToEdge(m.edges, point, options.magneticWidth ?? 10);
14408
+ const spacing = magneticAnchorSpacing(options.magneticFrequency ?? 57);
14409
+ for (let guard = 0; guard < 64; guard++) {
14410
+ const path = liveWire(m.edges, last(), target, this.corridor(last(), target));
14411
+ let length = 0;
14412
+ let k = 0;
14413
+ for (k = 1; k < path.length; k++) {
14414
+ length += Math.hypot(path[k].x - path[k - 1].x, path[k].y - path[k - 1].y);
14415
+ if (length >= spacing) break;
14416
+ }
14417
+ if (!place || k >= path.length - 1) {
14418
+ m.live = path;
14419
+ return;
14420
+ }
14421
+ m.fixed.push(...path.slice(1, k + 1));
14422
+ m.anchors.push(m.fixed.length - 1);
14423
+ }
14424
+ m.live = [last(), target];
14425
+ }
13996
14426
  /** Abandons a drag (or a polygonal lasso) without changing the selection. */
13997
14427
  cancel() {
13998
14428
  this.start = null;
13999
14429
  this.current = null;
14000
14430
  this.points = [];
14001
14431
  this.polygon = null;
14432
+ this.magnetic = null;
14002
14433
  this.pressedAt = null;
14003
14434
  this.freehand = false;
14004
14435
  }
@@ -18372,6 +18803,76 @@ function createImageEffectsToolProvider(controller, options = {}) {
18372
18803
  }), options.playbackSurface);
18373
18804
  }
18374
18805
 
18806
+ // src/image-editor/tool-groups.ts
18807
+ var IMAGE_TOOL_GROUPS = [
18808
+ { id: "move", label: "Move", tools: ["move"] },
18809
+ { id: "select", label: "Select", tools: ["select"] },
18810
+ { id: "crop", label: "Crop", tools: ["crop"] },
18811
+ { id: "brush", label: "Brush", tools: ["brush", "mixer"] },
18812
+ { id: "eraser", label: "Eraser", tools: ["eraser"] },
18813
+ { id: "repair", label: "Repair", tools: ["spot-heal", "heal", "clone"] },
18814
+ { id: "history", label: "History Brush", tools: ["history"] },
18815
+ { id: "focus", label: "Blur & Sharpen", tools: ["blur", "sharpen", "smudge"] },
18816
+ { id: "tone", label: "Dodge & Burn", tools: ["dodge", "burn", "sponge"] },
18817
+ { id: "shape", label: "Shape", tools: ["shape"] },
18818
+ { id: "type", label: "Type", tools: ["type"] },
18819
+ { id: "annotate", label: "Annotate", tools: ["pen", "line", "arrow", "rect", "circle"] }
18820
+ ];
18821
+ function imageToolGroup(tool) {
18822
+ return IMAGE_TOOL_GROUPS.find((group) => group.tools.includes(tool)) ?? IMAGE_TOOL_GROUPS[0];
18823
+ }
18824
+ var OTHER_TOOL_LABELS = {
18825
+ select: "Select",
18826
+ crop: "Crop",
18827
+ shape: "Shape",
18828
+ type: "Type",
18829
+ pen: "Pen",
18830
+ line: "Line",
18831
+ arrow: "Arrow",
18832
+ rect: "Rectangle",
18833
+ circle: "Ellipse",
18834
+ text: "Text",
18835
+ watermark: "Watermark"
18836
+ };
18837
+ function imageToolLabel(tool) {
18838
+ return PAINT_TOOLS.find((t) => t.id === tool)?.label ?? OTHER_TOOL_LABELS[tool] ?? tool;
18839
+ }
18840
+ var IMAGE_TOOL_SHORTCUTS = {
18841
+ ...Object.fromEntries(PAINT_TOOLS.filter((t) => t.shortcut).map((t) => [t.id, t.shortcut])),
18842
+ select: "M",
18843
+ crop: "C",
18844
+ shape: "U",
18845
+ type: "T"
18846
+ };
18847
+ function imageToolForShortcut(key, shift = false) {
18848
+ const shortcut = `${shift ? "Shift+" : ""}${key.toUpperCase()}`;
18849
+ for (const group of IMAGE_TOOL_GROUPS) {
18850
+ for (const tool of group.tools) if (IMAGE_TOOL_SHORTCUTS[tool] === shortcut) return tool;
18851
+ }
18852
+ return null;
18853
+ }
18854
+ function imageToolCanvasMode(tool) {
18855
+ if (tool === "move") return "transform";
18856
+ if (isPaintTool(tool)) return "paint";
18857
+ if (tool === "select") return "select";
18858
+ if (tool === "crop") return "crop";
18859
+ return "annotate";
18860
+ }
18861
+ function imageToolPanel(tool) {
18862
+ if (tool === "move") return null;
18863
+ if (isPaintTool(tool)) return "paint";
18864
+ if (tool === "select") return "selection";
18865
+ if (tool === "crop") return "canvas-size";
18866
+ if (tool === "shape" || tool === "type") return tool;
18867
+ return null;
18868
+ }
18869
+ function imagePanelTool(panel) {
18870
+ for (const group of IMAGE_TOOL_GROUPS) {
18871
+ for (const tool of group.tools) if (imageToolPanel(tool) === panel) return tool;
18872
+ }
18873
+ return null;
18874
+ }
18875
+
18375
18876
  // src/image-editor/editorDocument.ts
18376
18877
  function createImageEditorDocument(controller, options = {}) {
18377
18878
  return defineEditorDocument({
@@ -18462,6 +18963,7 @@ export {
18462
18963
  EMPTY_MIXER_LOAD,
18463
18964
  EX,
18464
18965
  EY,
18966
+ EdgeMap,
18465
18967
  FLAG_ALIVE,
18466
18968
  FLAG_BOTTOM,
18467
18969
  FLAG_LEFT,
@@ -18492,6 +18994,8 @@ export {
18492
18994
  IMAGE_EFFECT_PLACES,
18493
18995
  IMAGE_FILTER_PRESETS,
18494
18996
  IMAGE_PLAYBACK_KIND,
18997
+ IMAGE_TOOL_GROUPS,
18998
+ IMAGE_TOOL_SHORTCUTS,
18495
18999
  INTENTS,
18496
19000
  INTENT_OPTIONS,
18497
19001
  INTERPOLATIONS,
@@ -18968,8 +19472,14 @@ export {
18968
19472
  identityWarpMesh,
18969
19473
  imageEditorStateMigrations,
18970
19474
  imageLayerAppliedEffects,
19475
+ imagePanelTool,
18971
19476
  imagePlaybackPanel,
18972
19477
  imageToCanvasPoint,
19478
+ imageToolCanvasMode,
19479
+ imageToolForShortcut,
19480
+ imageToolGroup,
19481
+ imageToolLabel,
19482
+ imageToolPanel,
18973
19483
  importPsd,
18974
19484
  inflate,
18975
19485
  inflateRaw,
@@ -19051,12 +19561,14 @@ export {
19051
19561
  liveComponent,
19052
19562
  liveShapeForDrag,
19053
19563
  liveShapeSubpaths,
19564
+ liveWire,
19054
19565
  loadImageEditorDocument,
19055
19566
  loadMixer,
19056
19567
  loadOpenTypeFont,
19057
19568
  loadWetKernel,
19058
19569
  luma,
19059
19570
  magicWand,
19571
+ magneticAnchorSpacing,
19060
19572
  mapCropHandle,
19061
19573
  mapLayer,
19062
19574
  mapSubpaths,
@@ -19308,13 +19820,16 @@ export {
19308
19820
  shapeOutline,
19309
19821
  shapeRenderClip,
19310
19822
  shiftCoverage,
19823
+ simplifyPixelPath,
19311
19824
  skinToneRange,
19312
19825
  smartFilterRows,
19313
19826
  smartObjectLayers,
19314
19827
  smoothCoverage,
19315
19828
  smoothPoints,
19316
19829
  smudgeEffect,
19830
+ snapPolygonToEdges,
19317
19831
  snapTo45,
19832
+ snapToEdge,
19318
19833
  solidPaint,
19319
19834
  solidSurface,
19320
19835
  solveMembrane,