mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: magic wand tool selection, overlay, and mask consistency
- Replace naive 4-neighbor flood fill with scanline algorithm for performance - Include alpha channel in color distance calculation - Render actual mask outline with marching ants instead of bounding rectangle - Use Konva Shape with batched canvas path for efficient edge rendering - Add dedicated magicWandContiguous state (was incorrectly sharing fillContiguous) - Add Contiguous checkbox to magic wand options bar - Fix invertSelection to use consistent mask values (1 not 255) and expand inverted mask to full canvas dimensions instead of staying within old bounds
This commit is contained in:
@@ -597,7 +597,7 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
|
|||||||
const zoom = useEditorStore((s) => s.zoom);
|
const zoom = useEditorStore((s) => s.zoom);
|
||||||
const panOffset = useEditorStore((s) => s.panOffset);
|
const panOffset = useEditorStore((s) => s.panOffset);
|
||||||
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
|
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
|
||||||
const fillContiguous = useEditorStore((s) => s.fillContiguous);
|
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
|
||||||
|
|
||||||
const brushTool = useBrushTool();
|
const brushTool = useBrushTool();
|
||||||
const eraserTool = useEraserTool();
|
const eraserTool = useEraserTool();
|
||||||
@@ -655,12 +655,12 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
|
|||||||
const pointer = stage?.getPointerPosition();
|
const pointer = stage?.getPointerPosition();
|
||||||
if (!pointer || !stage) return;
|
if (!pointer || !stage) return;
|
||||||
const pos = { x: (pointer.x - panOffset.x) / zoom, y: (pointer.y - panOffset.y) / zoom };
|
const pos = { x: (pointer.x - panOffset.x) / zoom, y: (pointer.y - panOffset.y) / zoom };
|
||||||
selectionTool.magicWandSelect(stage, pos.x, pos.y, magicWandTolerance, fillContiguous);
|
selectionTool.magicWandSelect(stage, pos.x, pos.y, magicWandTolerance, magicWandContiguous);
|
||||||
},
|
},
|
||||||
handleMouseMove: () => {},
|
handleMouseMove: () => {},
|
||||||
handleMouseUp: () => {},
|
handleMouseUp: () => {},
|
||||||
}),
|
}),
|
||||||
[selectionTool, zoom, panOffset, magicWandTolerance, fillContiguous],
|
[selectionTool, zoom, panOffset, magicWandTolerance, magicWandContiguous],
|
||||||
);
|
);
|
||||||
|
|
||||||
const eyedropperHandlers = useMemo(
|
const eyedropperHandlers = useMemo(
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export function SelectionOptions() {
|
|||||||
const setSelectionMode = useEditorStore((s) => s.setSelectionMode);
|
const setSelectionMode = useEditorStore((s) => s.setSelectionMode);
|
||||||
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
|
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
|
||||||
const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
|
const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
|
||||||
|
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
|
||||||
|
const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous);
|
||||||
|
|
||||||
const selectionType: SelectionType =
|
const selectionType: SelectionType =
|
||||||
activeTool === "marquee-ellipse"
|
activeTool === "marquee-ellipse"
|
||||||
@@ -168,7 +170,7 @@ export function SelectionOptions() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Magic Wand tolerance */}
|
{/* Magic Wand tolerance + contiguous */}
|
||||||
{isMagicWand && (
|
{isMagicWand && (
|
||||||
<>
|
<>
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
@@ -189,6 +191,15 @@ export function SelectionOptions() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={magicWandContiguous}
|
||||||
|
onChange={(e) => setMagicWandContiguous(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
Contiguous
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type Konva from "konva";
|
import type Konva from "konva";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Ellipse, Group, Line, Rect } from "react-konva";
|
import { Ellipse, Group, Line, Rect, Shape } from "react-konva";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { SelectionMode, SelectionState } from "@/types/editor";
|
import type { SelectionMode, SelectionState } from "@/types/editor";
|
||||||
|
|
||||||
@@ -63,34 +63,74 @@ function floodFillMask(
|
|||||||
const targetR = data[idx];
|
const targetR = data[idx];
|
||||||
const targetG = data[idx + 1];
|
const targetG = data[idx + 1];
|
||||||
const targetB = data[idx + 2];
|
const targetB = data[idx + 2];
|
||||||
|
const targetA = data[idx + 3];
|
||||||
|
|
||||||
function colorDist(i: number): number {
|
const tolSq = tolerance * tolerance;
|
||||||
|
|
||||||
|
function matchesAt(i: number): boolean {
|
||||||
const dr = data[i] - targetR;
|
const dr = data[i] - targetR;
|
||||||
const dg = data[i + 1] - targetG;
|
const dg = data[i + 1] - targetG;
|
||||||
const db = data[i + 2] - targetB;
|
const db = data[i + 2] - targetB;
|
||||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
const da = data[i + 3] - targetA;
|
||||||
|
return dr * dr + dg * dg + db * db + da * da <= tolSq;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contiguous) {
|
if (contiguous) {
|
||||||
// Scanline flood fill
|
const visited = new Uint8Array(width * height);
|
||||||
const stack: [number, number][] = [[sx, sy]];
|
const stack: [number, number][] = [[sx, sy]];
|
||||||
|
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const item = stack.pop();
|
const entry = stack.pop();
|
||||||
if (!item) break;
|
if (!entry) break;
|
||||||
const [cx, cy] = item;
|
const [seedX, seedY] = entry;
|
||||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
let x = seedX;
|
||||||
if (mask[cy][cx]) continue;
|
|
||||||
const ci = (cy * width + cx) * 4;
|
while (
|
||||||
if (colorDist(ci) > tolerance) continue;
|
x > 0 &&
|
||||||
mask[cy][cx] = true;
|
!visited[seedY * width + (x - 1)] &&
|
||||||
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
matchesAt((seedY * width + (x - 1)) * 4)
|
||||||
|
) {
|
||||||
|
x--;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spanAbove = false;
|
||||||
|
let spanBelow = false;
|
||||||
|
|
||||||
|
while (x < width && !visited[seedY * width + x] && matchesAt((seedY * width + x) * 4)) {
|
||||||
|
visited[seedY * width + x] = 1;
|
||||||
|
mask[seedY][x] = true;
|
||||||
|
|
||||||
|
if (seedY > 0) {
|
||||||
|
const aboveIdx = (seedY - 1) * width + x;
|
||||||
|
if (!visited[aboveIdx] && matchesAt(aboveIdx * 4)) {
|
||||||
|
if (!spanAbove) {
|
||||||
|
stack.push([x, seedY - 1]);
|
||||||
|
spanAbove = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spanAbove = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seedY < height - 1) {
|
||||||
|
const belowIdx = (seedY + 1) * width + x;
|
||||||
|
if (!visited[belowIdx] && matchesAt(belowIdx * 4)) {
|
||||||
|
if (!spanBelow) {
|
||||||
|
stack.push([x, seedY + 1]);
|
||||||
|
spanBelow = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spanBelow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Select all matching pixels
|
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const ci = (y * width + x) * 4;
|
if (matchesAt((y * width + x) * 4)) {
|
||||||
if (colorDist(ci) <= tolerance) {
|
|
||||||
mask[y][x] = true;
|
mask[y][x] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,13 +628,34 @@ export function useSelectionTool(): SelectionToolApi {
|
|||||||
// SelectionOverlay -- renders selection outline with marching ants
|
// SelectionOverlay -- renders selection outline with marching ants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function maskToEdgePoints(
|
||||||
|
mask: Uint8Array,
|
||||||
|
bounds: { x: number; y: number; width: number; height: number },
|
||||||
|
): number[] {
|
||||||
|
const { width: w, height: h, x: ox, y: oy } = bounds;
|
||||||
|
const pts: number[] = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < h; row++) {
|
||||||
|
for (let col = 0; col < w; col++) {
|
||||||
|
if (!mask[row * w + col]) continue;
|
||||||
|
const ax = ox + col;
|
||||||
|
const ay = oy + row;
|
||||||
|
if (col === 0 || !mask[row * w + (col - 1)]) pts.push(ax, ay, ax, ay + 1);
|
||||||
|
if (col === w - 1 || !mask[row * w + (col + 1)]) pts.push(ax + 1, ay, ax + 1, ay + 1);
|
||||||
|
if (row === 0 || !mask[(row - 1) * w + col]) pts.push(ax, ay, ax + 1, ay);
|
||||||
|
if (row === h - 1 || !mask[(row + 1) * w + col]) pts.push(ax, ay + 1, ax + 1, ay + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva.Layer | null> }) {
|
export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva.Layer | null> }) {
|
||||||
const selection = useEditorStore((s) => s.selection);
|
const selection = useEditorStore((s) => s.selection);
|
||||||
const dashOffset = useMarchingAnts(layerRef);
|
const dashOffset = useMarchingAnts(layerRef);
|
||||||
|
|
||||||
if (!selection) return null;
|
if (!selection) return null;
|
||||||
|
|
||||||
const { type, bounds, points } = selection;
|
const { type, bounds, points, mask } = selection;
|
||||||
|
|
||||||
if (type === "lasso" && points.length >= 6) {
|
if (type === "lasso" && points.length >= 6) {
|
||||||
return (
|
return (
|
||||||
@@ -652,6 +713,44 @@ export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "wand" && mask) {
|
||||||
|
const pts = maskToEdgePoints(mask, bounds);
|
||||||
|
return (
|
||||||
|
<Group listening={false}>
|
||||||
|
<Shape
|
||||||
|
sceneFunc={(ctx, shape) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < pts.length; i += 4) {
|
||||||
|
ctx.moveTo(pts[i], pts[i + 1]);
|
||||||
|
ctx.lineTo(pts[i + 2], pts[i + 3]);
|
||||||
|
}
|
||||||
|
ctx.strokeShape(shape);
|
||||||
|
}}
|
||||||
|
stroke="#000000"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={DASH}
|
||||||
|
dashOffset={dashOffset.current}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
<Shape
|
||||||
|
sceneFunc={(ctx, shape) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < pts.length; i += 4) {
|
||||||
|
ctx.moveTo(pts[i], pts[i + 1]);
|
||||||
|
ctx.lineTo(pts[i + 2], pts[i + 3]);
|
||||||
|
}
|
||||||
|
ctx.strokeShape(shape);
|
||||||
|
}}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={DASH}
|
||||||
|
dashOffset={dashOffset.current + DASH[0]}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Rectangular selection
|
// Rectangular selection
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
selection: null,
|
selection: null,
|
||||||
selectionMode: "new" as SelectionMode,
|
selectionMode: "new" as SelectionMode,
|
||||||
magicWandTolerance: 32,
|
magicWandTolerance: 32,
|
||||||
|
magicWandContiguous: true,
|
||||||
|
|
||||||
// --- Crop ---
|
// --- Crop ---
|
||||||
cropState: null,
|
cropState: null,
|
||||||
@@ -927,32 +928,56 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
|||||||
setSelection: (selection) => set({ selection }),
|
setSelection: (selection) => set({ selection }),
|
||||||
setSelectionMode: (mode) => set({ selectionMode: mode }),
|
setSelectionMode: (mode) => set({ selectionMode: mode }),
|
||||||
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
||||||
|
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
|
||||||
|
|
||||||
invertSelection: () => {
|
invertSelection: () => {
|
||||||
const { selection, canvasSize } = get();
|
const { selection, canvasSize } = get();
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
let mask = selection.mask;
|
const oldBounds = selection.bounds;
|
||||||
// If no mask but bounds exist, create a mask from the bounds
|
const oldMask = selection.mask;
|
||||||
if (!mask && selection.bounds) {
|
|
||||||
const totalPixels = canvasSize.width * canvasSize.height;
|
const fullW = canvasSize.width;
|
||||||
mask = new Uint8Array(totalPixels);
|
const fullH = canvasSize.height;
|
||||||
const { x, y, width, height } = selection.bounds;
|
const inverted = new Uint8Array(fullW * fullH);
|
||||||
const x0 = Math.max(0, Math.floor(x));
|
|
||||||
const y0 = Math.max(0, Math.floor(y));
|
if (oldMask) {
|
||||||
const x1 = Math.min(canvasSize.width, Math.ceil(x + width));
|
// Start with everything selected
|
||||||
const y1 = Math.min(canvasSize.height, Math.ceil(y + height));
|
inverted.fill(1);
|
||||||
|
// Clear pixels that were selected in the old mask
|
||||||
|
for (let row = 0; row < oldBounds.height; row++) {
|
||||||
|
for (let col = 0; col < oldBounds.width; col++) {
|
||||||
|
if (oldMask[row * oldBounds.width + col]) {
|
||||||
|
const absY = oldBounds.y + row;
|
||||||
|
const absX = oldBounds.x + col;
|
||||||
|
if (absX >= 0 && absX < fullW && absY >= 0 && absY < fullH) {
|
||||||
|
inverted[absY * fullW + absX] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Geometric selection (rect/ellipse): invert by marking everything
|
||||||
|
// outside the bounds as selected
|
||||||
|
inverted.fill(1);
|
||||||
|
const x0 = Math.max(0, Math.floor(oldBounds.x));
|
||||||
|
const y0 = Math.max(0, Math.floor(oldBounds.y));
|
||||||
|
const x1 = Math.min(fullW, Math.ceil(oldBounds.x + oldBounds.width));
|
||||||
|
const y1 = Math.min(fullH, Math.ceil(oldBounds.y + oldBounds.height));
|
||||||
for (let row = y0; row < y1; row++) {
|
for (let row = y0; row < y1; row++) {
|
||||||
for (let col = x0; col < x1; col++) {
|
for (let col = x0; col < x1; col++) {
|
||||||
mask[row * canvasSize.width + col] = 255;
|
inverted[row * fullW + col] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mask) return;
|
|
||||||
const inverted = new Uint8Array(mask.length);
|
set({
|
||||||
for (let i = 0; i < mask.length; i++) {
|
selection: {
|
||||||
inverted[i] = 255 - mask[i];
|
...selection,
|
||||||
}
|
type: "wand",
|
||||||
set({ selection: { ...selection, mask: inverted } });
|
bounds: { x: 0, y: 0, width: fullW, height: fullH },
|
||||||
|
mask: inverted,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Crop
|
// Crop
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ export interface EditorState {
|
|||||||
selection: SelectionState | null;
|
selection: SelectionState | null;
|
||||||
selectionMode: SelectionMode;
|
selectionMode: SelectionMode;
|
||||||
magicWandTolerance: number;
|
magicWandTolerance: number;
|
||||||
|
magicWandContiguous: boolean;
|
||||||
|
|
||||||
// Crop
|
// Crop
|
||||||
cropState: CropState | null;
|
cropState: CropState | null;
|
||||||
@@ -412,6 +413,7 @@ export interface EditorState {
|
|||||||
setSelection: (selection: SelectionState | null) => void;
|
setSelection: (selection: SelectionState | null) => void;
|
||||||
setSelectionMode: (mode: SelectionMode) => void;
|
setSelectionMode: (mode: SelectionMode) => void;
|
||||||
setMagicWandTolerance: (v: number) => void;
|
setMagicWandTolerance: (v: number) => void;
|
||||||
|
setMagicWandContiguous: (v: boolean) => void;
|
||||||
invertSelection: () => void;
|
invertSelection: () => void;
|
||||||
|
|
||||||
// Crop
|
// Crop
|
||||||
|
|||||||
Reference in New Issue
Block a user