feat(phase-8): implement comprehensive selection system with marching ants

This commit completes Phase 8 of the paint-ui implementation, adding a full
selection system with multiple selection tools, operations, and visual feedback.

**New Files:**
- types/selection.ts: Selection types, masks, and state interfaces
- lib/selection-utils.ts: Selection mask generation and manipulation algorithms
- lib/selection-operations.ts: Copy/cut/paste/delete/fill/stroke operations
- store/selection-store.ts: Selection state management with Zustand
- core/commands/selection-command.ts: Undo/redo commands for selections
- tools/rectangular-selection-tool.ts: Rectangular marquee selection
- tools/elliptical-selection-tool.ts: Elliptical marquee selection
- tools/lasso-selection-tool.ts: Free-form polygon selection
- tools/magic-wand-tool.ts: Color-based flood-fill selection
- components/selection/selection-panel.tsx: Complete selection UI panel
- components/selection/index.ts: Selection components barrel export

**Updated Files:**
- components/canvas/canvas-with-tools.tsx: Added selection tools integration and marching ants animation
- components/editor/editor-layout.tsx: Integrated SelectionPanel into layout
- store/index.ts: Added selection-store export
- store/canvas-store.ts: Renamed Selection to CanvasSelection to avoid conflicts
- tools/index.ts: Added selection tool exports
- types/index.ts: Added selection types export
- types/canvas.ts: Renamed Selection interface to CanvasSelection

**Selection Tools:**

**Marquee Tools:**
-  Rectangular Selection: Click-drag rectangular regions
-  Elliptical Selection: Click-drag elliptical regions

**Free-form Tools:**
-  Lasso Selection: Draw free-form polygon selections
-  Magic Wand: Color-based flood-fill selection with tolerance

**Selection Modes:**
- 🔷 New: Replace existing selection
-  Add: Add to existing selection
-  Subtract: Remove from existing selection
-  Intersect: Keep only overlapping areas

**Selection Operations:**
- 📋 Copy/Cut/Paste: Standard clipboard operations with selection mask
- 🗑️ Delete: Remove selected pixels
- 🎨 Fill Selection: Fill with current color
- 🖌️ Stroke Selection: Outline selection with current color
- 🔄 Invert Selection: Invert selected/unselected pixels
-  Clear Selection: Deselect all

**Technical Features:**
- Marching ants animation (animated dashed outline at 50ms interval)
- Selection masks using Uint8Array (0-255 values for anti-aliasing)
- Feathering support (0-250px gaussian blur on selection edges)
- Tolerance control for magic wand (0-255 color difference threshold)
- Scanline polygon fill algorithm for lasso tool
- Flood-fill with Set-based visited tracking for magic wand
- Selection bounds calculation for optimized operations
- Keyboard shortcuts (Ctrl+C, Ctrl+X, Ctrl+V, Ctrl+D, Ctrl+Shift+I)
- Undo/redo integration via selection commands
- Non-destructive operations with proper history tracking

**Algorithm Implementations:**
- Rectangular mask: Simple bounds-based pixel marking
- Elliptical mask: Distance formula from ellipse center
- Lasso mask: Scanline polygon fill with edge intersection
- Magic wand: BFS flood-fill with color tolerance matching
- Mask combination: Per-pixel operations (max, subtract, AND)
- Feathering: Separable box blur (horizontal + vertical passes)
- Mask inversion: Per-pixel NOT operation with bounds recalculation

**UI/UX Features:**
- 264px wide selection panel with all tools and operations
- Tool selection with visual feedback (highlighted active tool)
- Selection mode toggles (new/add/subtract/intersect)
- Feather and tolerance sliders with live value display
- Disabled state when no selection exists
- Keyboard shortcut hints next to operations
- Visual marching ants animation (animated dashes)

Build verified: ✓ Compiled successfully in 1302ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 02:24:12 +01:00
parent 924c10a3e4
commit 7f1e69559f
18 changed files with 1808 additions and 13 deletions

View File

@@ -3,6 +3,8 @@
import { useEffect, useRef, useState } from 'react';
import { useCanvasStore, useLayerStore, useToolStore } from '@/store';
import { useHistoryStore } from '@/store/history-store';
import { useSelectionStore } from '@/store/selection-store';
import { drawMarchingAnts } from '@/lib/selection-utils';
import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
import { DrawCommand } from '@/core/commands';
import {
@@ -11,6 +13,10 @@ import {
EraserTool,
FillTool,
EyedropperTool,
RectangularSelectionTool,
EllipticalSelectionTool,
LassoSelectionTool,
MagicWandTool,
type BaseTool,
} from '@/tools';
import type { PointerState } from '@/types';
@@ -23,6 +29,11 @@ const tools: Record<string, BaseTool> = {
eraser: new EraserTool(),
fill: new FillTool(),
eyedropper: new EyedropperTool(),
select: new RectangularSelectionTool(),
'rectangular-select': new RectangularSelectionTool(),
'elliptical-select': new EllipticalSelectionTool(),
'lasso-select': new LassoSelectionTool(),
'magic-wand': new MagicWandTool(),
};
export function CanvasWithTools() {
@@ -46,6 +57,8 @@ export function CanvasWithTools() {
const { layers, getActiveLayer } = useLayerStore();
const { activeTool, settings } = useToolStore();
const { executeCommand } = useHistoryStore();
const { activeSelection, selectionType, isMarching } = useSelectionStore();
const [marchingOffset, setMarchingOffset] = useState(0);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
@@ -113,18 +126,25 @@ export function CanvasWithTools() {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)');
}
// Draw selection if active
if (selection.active) {
ctx.strokeStyle = '#0066ff';
ctx.lineWidth = 1 / zoom;
ctx.setLineDash([4 / zoom, 4 / zoom]);
ctx.strokeRect(selection.x, selection.y, selection.width, selection.height);
ctx.setLineDash([]);
// Draw selection if active (marching ants)
if (activeSelection && isMarching) {
drawMarchingAnts(ctx, activeSelection.mask, marchingOffset);
}
// Restore context state
ctx.restore();
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer]);
}, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset]);
// Marching ants animation
useEffect(() => {
if (!activeSelection || !isMarching) return;
const interval = setInterval(() => {
setMarchingOffset((prev) => (prev + 1) % 8);
}, 50);
return () => clearInterval(interval);
}, [activeSelection, isMarching]);
// Handle mouse wheel for zooming
const handleWheel = (e: React.WheelEvent) => {
@@ -156,6 +176,31 @@ export function CanvasWithTools() {
return;
}
// Selection tools
const selectionTools = ['select', 'rectangular-select', 'elliptical-select', 'lasso-select', 'magic-wand'];
if (e.button === 0 && !e.shiftKey && selectionTools.includes(activeTool)) {
const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return;
const tool = tools[`${selectionType}-select`] || tools['select'];
const newPointer: PointerState = {
isDown: true,
x: canvasPos.x,
y: canvasPos.y,
prevX: canvasPos.x,
prevY: canvasPos.y,
pressure: e.pressure || 1,
};
setPointer(newPointer);
const ctx = activeLayer.canvas.getContext('2d');
if (ctx) {
tool.onPointerDown(newPointer, ctx, settings);
}
return;
}
// Drawing tools
if (e.button === 0 && !e.shiftKey && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper'].includes(activeTool)) {
const activeLayer = getActiveLayer();