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:
109
tools/elliptical-selection-tool.ts
Normal file
109
tools/elliptical-selection-tool.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState } from '@/types';
|
||||
import { useSelectionStore } from '@/store/selection-store';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import {
|
||||
createEllipticalMask,
|
||||
createSelection,
|
||||
combineMasks,
|
||||
} from '@/lib/selection-utils';
|
||||
|
||||
export class EllipticalSelectionTool extends BaseTool {
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
|
||||
constructor() {
|
||||
super('Elliptical Selection');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.startX = pointer.x;
|
||||
this.startY = pointer.y;
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
|
||||
// Draw preview ellipse
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
const cx = (this.startX + this.currentX) / 2;
|
||||
const cy = (this.startY + this.currentY) / 2;
|
||||
const rx = Math.abs(this.currentX - this.startX) / 2;
|
||||
const ry = Math.abs(this.currentY - this.startY) / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineDashOffset = 4;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const cx = (this.startX + this.currentX) / 2;
|
||||
const cy = (this.startY + this.currentY) / 2;
|
||||
const rx = Math.abs(this.currentX - this.startX) / 2;
|
||||
const ry = Math.abs(this.currentY - this.startY) / 2;
|
||||
|
||||
if (rx > 0 && ry > 0) {
|
||||
const { selectionMode, feather, activeSelection } =
|
||||
useSelectionStore.getState();
|
||||
|
||||
const newMask = createEllipticalMask(
|
||||
cx,
|
||||
cy,
|
||||
rx,
|
||||
ry,
|
||||
layer.canvas.width,
|
||||
layer.canvas.height
|
||||
);
|
||||
|
||||
let finalMask = newMask;
|
||||
|
||||
// Combine with existing selection if needed
|
||||
if (activeSelection && selectionMode !== 'new') {
|
||||
finalMask = combineMasks(activeSelection.mask, newMask, selectionMode);
|
||||
}
|
||||
|
||||
const selection = createSelection(layer.id, finalMask, feather);
|
||||
useSelectionStore.getState().setActiveSelection(selection);
|
||||
}
|
||||
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'crosshair';
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,7 @@ export * from './brush-tool';
|
||||
export * from './eraser-tool';
|
||||
export * from './fill-tool';
|
||||
export * from './eyedropper-tool';
|
||||
export * from './rectangular-selection-tool';
|
||||
export * from './elliptical-selection-tool';
|
||||
export * from './lasso-selection-tool';
|
||||
export * from './magic-wand-tool';
|
||||
|
||||
116
tools/lasso-selection-tool.ts
Normal file
116
tools/lasso-selection-tool.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState, LassoPoint } from '@/types';
|
||||
import { useSelectionStore } from '@/store/selection-store';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import {
|
||||
createLassoMask,
|
||||
createSelection,
|
||||
combineMasks,
|
||||
} from '@/lib/selection-utils';
|
||||
|
||||
export class LassoSelectionTool extends BaseTool {
|
||||
private points: LassoPoint[] = [];
|
||||
private minDistance = 2; // Minimum distance between points
|
||||
|
||||
constructor() {
|
||||
super('Lasso Selection');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.points = [];
|
||||
this.points.push({ x: pointer.x, y: pointer.y });
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const lastPoint = this.points[this.points.length - 1];
|
||||
const dx = pointer.x - lastPoint.x;
|
||||
const dy = pointer.y - lastPoint.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Only add point if far enough from last point
|
||||
if (distance >= this.minDistance) {
|
||||
this.points.push({ x: pointer.x, y: pointer.y });
|
||||
}
|
||||
|
||||
// Draw preview
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.points[0].x, this.points[0].y);
|
||||
for (let i = 1; i < this.points.length; i++) {
|
||||
ctx.lineTo(this.points[i].x, this.points[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineDashOffset = 4;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas || this.points.length < 3) {
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the path if not already closed
|
||||
const firstPoint = this.points[0];
|
||||
const lastPoint = this.points[this.points.length - 1];
|
||||
const dx = lastPoint.x - firstPoint.x;
|
||||
const dy = lastPoint.y - firstPoint.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > this.minDistance) {
|
||||
this.points.push(firstPoint);
|
||||
}
|
||||
|
||||
const { selectionMode, feather, activeSelection } =
|
||||
useSelectionStore.getState();
|
||||
|
||||
const newMask = createLassoMask(
|
||||
this.points,
|
||||
layer.canvas.width,
|
||||
layer.canvas.height
|
||||
);
|
||||
|
||||
let finalMask = newMask;
|
||||
|
||||
// Combine with existing selection if needed
|
||||
if (activeSelection && selectionMode !== 'new') {
|
||||
finalMask = combineMasks(activeSelection.mask, newMask, selectionMode);
|
||||
}
|
||||
|
||||
const selection = createSelection(layer.id, finalMask, feather);
|
||||
useSelectionStore.getState().setActiveSelection(selection);
|
||||
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
this.points = [];
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'crosshair';
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
78
tools/magic-wand-tool.ts
Normal file
78
tools/magic-wand-tool.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState } from '@/types';
|
||||
import { useSelectionStore } from '@/store/selection-store';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import {
|
||||
createMagicWandMask,
|
||||
createSelection,
|
||||
combineMasks,
|
||||
} from '@/lib/selection-utils';
|
||||
|
||||
export class MagicWandTool extends BaseTool {
|
||||
constructor() {
|
||||
super('Magic Wand');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const ctx = layer.canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const x = Math.floor(pointer.x);
|
||||
const y = Math.floor(pointer.y);
|
||||
|
||||
if (
|
||||
x < 0 ||
|
||||
x >= layer.canvas.width ||
|
||||
y < 0 ||
|
||||
y >= layer.canvas.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
layer.canvas.width,
|
||||
layer.canvas.height
|
||||
);
|
||||
|
||||
const { selectionMode, feather, tolerance, activeSelection } =
|
||||
useSelectionStore.getState();
|
||||
|
||||
const newMask = createMagicWandMask(x, y, imageData, tolerance);
|
||||
|
||||
let finalMask = newMask;
|
||||
|
||||
// Combine with existing selection if needed
|
||||
if (activeSelection && selectionMode !== 'new') {
|
||||
finalMask = combineMasks(activeSelection.mask, newMask, selectionMode);
|
||||
}
|
||||
|
||||
const selection = createSelection(layer.id, finalMask, feather);
|
||||
useSelectionStore.getState().setActiveSelection(selection);
|
||||
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
onPointerMove(): void {
|
||||
// Magic wand doesn't need pointer move
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'crosshair';
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
108
tools/rectangular-selection-tool.ts
Normal file
108
tools/rectangular-selection-tool.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState } from '@/types';
|
||||
import { useSelectionStore } from '@/store/selection-store';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import {
|
||||
createRectangularMask,
|
||||
createSelection,
|
||||
combineMasks,
|
||||
} from '@/lib/selection-utils';
|
||||
|
||||
export class RectangularSelectionTool extends BaseTool {
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
private previewCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
constructor() {
|
||||
super('Rectangular Selection');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.startX = pointer.x;
|
||||
this.startY = pointer.y;
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
|
||||
// Draw preview rectangle
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
|
||||
const x = Math.min(this.startX, this.currentX);
|
||||
const y = Math.min(this.startY, this.currentY);
|
||||
const w = Math.abs(this.currentX - this.startX);
|
||||
const h = Math.abs(this.currentY - this.startY);
|
||||
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineDashOffset = 4;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const x = Math.min(this.startX, this.currentX);
|
||||
const y = Math.min(this.startY, this.currentY);
|
||||
const width = Math.abs(this.currentX - this.startX);
|
||||
const height = Math.abs(this.currentY - this.startY);
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
const { selectionMode, feather, activeSelection } =
|
||||
useSelectionStore.getState();
|
||||
|
||||
const newMask = createRectangularMask(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
layer.canvas.width,
|
||||
layer.canvas.height
|
||||
);
|
||||
|
||||
let finalMask = newMask;
|
||||
|
||||
// Combine with existing selection if needed
|
||||
if (activeSelection && selectionMode !== 'new') {
|
||||
finalMask = combineMasks(activeSelection.mask, newMask, selectionMode);
|
||||
}
|
||||
|
||||
const selection = createSelection(layer.id, finalMask, feather);
|
||||
useSelectionStore.getState().setActiveSelection(selection);
|
||||
}
|
||||
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'crosshair';
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user