From 96b055093bdf2cacc093ba19ec1c4f7120ccd2ba Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 9 May 2026 00:14:57 +0800 Subject: [PATCH] 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 --- .../src/components/editor/editor-canvas.tsx | 6 +- .../editor/options/selection-options.tsx | 13 +- .../editor/tools/selection-tool.tsx | 133 +++++++++++++++--- apps/web/src/stores/editor-store.ts | 59 +++++--- apps/web/src/types/editor.ts | 2 + 5 files changed, 175 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/editor/editor-canvas.tsx b/apps/web/src/components/editor/editor-canvas.tsx index 7bf48aeb..b55ac77c 100644 --- a/apps/web/src/components/editor/editor-canvas.tsx +++ b/apps/web/src/components/editor/editor-canvas.tsx @@ -597,7 +597,7 @@ function useActiveToolHandlers(stageRef: React.RefObject) { const zoom = useEditorStore((s) => s.zoom); const panOffset = useEditorStore((s) => s.panOffset); const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance); - const fillContiguous = useEditorStore((s) => s.fillContiguous); + const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous); const brushTool = useBrushTool(); const eraserTool = useEraserTool(); @@ -655,12 +655,12 @@ function useActiveToolHandlers(stageRef: React.RefObject) { const pointer = stage?.getPointerPosition(); if (!pointer || !stage) return; 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: () => {}, handleMouseUp: () => {}, }), - [selectionTool, zoom, panOffset, magicWandTolerance, fillContiguous], + [selectionTool, zoom, panOffset, magicWandTolerance, magicWandContiguous], ); const eyedropperHandlers = useMemo( diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx index 5c34a40a..898b8b2a 100644 --- a/apps/web/src/components/editor/options/selection-options.tsx +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -48,6 +48,8 @@ export function SelectionOptions() { const setSelectionMode = useEditorStore((s) => s.setSelectionMode); const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance); const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance); + const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous); + const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous); const selectionType: SelectionType = activeTool === "marquee-ellipse" @@ -168,7 +170,7 @@ export function SelectionOptions() { )} - {/* Magic Wand tolerance */} + {/* Magic Wand tolerance + contiguous */} {isMagicWand && ( <>
@@ -189,6 +191,15 @@ export function SelectionOptions() { )} />
+ )} diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx index 0c8b7abf..68ca2872 100644 --- a/apps/web/src/components/editor/tools/selection-tool.tsx +++ b/apps/web/src/components/editor/tools/selection-tool.tsx @@ -1,6 +1,6 @@ import type Konva from "konva"; 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 type { SelectionMode, SelectionState } from "@/types/editor"; @@ -63,34 +63,74 @@ function floodFillMask( const targetR = data[idx]; const targetG = data[idx + 1]; 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 dg = data[i + 1] - targetG; 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) { - // Scanline flood fill + const visited = new Uint8Array(width * height); const stack: [number, number][] = [[sx, sy]]; + while (stack.length > 0) { - const item = stack.pop(); - if (!item) break; - const [cx, cy] = item; - if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue; - if (mask[cy][cx]) continue; - const ci = (cy * width + cx) * 4; - if (colorDist(ci) > tolerance) continue; - mask[cy][cx] = true; - stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + const entry = stack.pop(); + if (!entry) break; + const [seedX, seedY] = entry; + let x = seedX; + + while ( + x > 0 && + !visited[seedY * width + (x - 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 { - // Select all matching pixels for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const ci = (y * width + x) * 4; - if (colorDist(ci) <= tolerance) { + if (matchesAt((y * width + x) * 4)) { mask[y][x] = true; } } @@ -588,13 +628,34 @@ export function useSelectionTool(): SelectionToolApi { // 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 }) { const selection = useEditorStore((s) => s.selection); const dashOffset = useMarchingAnts(layerRef); if (!selection) return null; - const { type, bounds, points } = selection; + const { type, bounds, points, mask } = selection; if (type === "lasso" && points.length >= 6) { return ( @@ -652,6 +713,44 @@ export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject + { + 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} + /> + { + 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} + /> + + ); + } + // Rectangular selection return ( diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 22fef75b..9828a90b 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -148,6 +148,7 @@ export const useEditorStore = create()( selection: null, selectionMode: "new" as SelectionMode, magicWandTolerance: 32, + magicWandContiguous: true, // --- Crop --- cropState: null, @@ -927,32 +928,56 @@ export const useEditorStore = create()( setSelection: (selection) => set({ selection }), setSelectionMode: (mode) => set({ selectionMode: mode }), setMagicWandTolerance: (v) => set({ magicWandTolerance: v }), + setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }), invertSelection: () => { const { selection, canvasSize } = get(); if (!selection) return; - let mask = selection.mask; - // If no mask but bounds exist, create a mask from the bounds - if (!mask && selection.bounds) { - const totalPixels = canvasSize.width * canvasSize.height; - mask = new Uint8Array(totalPixels); - const { x, y, width, height } = selection.bounds; - const x0 = Math.max(0, Math.floor(x)); - const y0 = Math.max(0, Math.floor(y)); - const x1 = Math.min(canvasSize.width, Math.ceil(x + width)); - const y1 = Math.min(canvasSize.height, Math.ceil(y + height)); + const oldBounds = selection.bounds; + const oldMask = selection.mask; + + const fullW = canvasSize.width; + const fullH = canvasSize.height; + const inverted = new Uint8Array(fullW * fullH); + + if (oldMask) { + // Start with everything selected + 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 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); - for (let i = 0; i < mask.length; i++) { - inverted[i] = 255 - mask[i]; - } - set({ selection: { ...selection, mask: inverted } }); + + set({ + selection: { + ...selection, + type: "wand", + bounds: { x: 0, y: 0, width: fullW, height: fullH }, + mask: inverted, + }, + }); }, // Crop diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index feb83dab..53da4450 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -291,6 +291,7 @@ export interface EditorState { selection: SelectionState | null; selectionMode: SelectionMode; magicWandTolerance: number; + magicWandContiguous: boolean; // Crop cropState: CropState | null; @@ -412,6 +413,7 @@ export interface EditorState { setSelection: (selection: SelectionState | null) => void; setSelectionMode: (mode: SelectionMode) => void; setMagicWandTolerance: (v: number) => void; + setMagicWandContiguous: (v: boolean) => void; invertSelection: () => void; // Crop