Files
paint-ui/lib/selection-operations.ts

288 lines
8.0 KiB
TypeScript
Raw Normal View History

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>
2025-11-21 02:24:12 +01:00
import type { Layer, Selection } from '@/types';
import { useLayerStore } from '@/store/layer-store';
import { useSelectionStore } from '@/store/selection-store';
import { useHistoryStore } from '@/store/history-store';
import { DrawCommand } from '@/core/commands/draw-command';
import { cloneCanvas } from './canvas-utils';
/**
* Copy selected pixels to clipboard (as canvas)
*/
export function copySelection(): HTMLCanvasElement | null {
const { activeSelection } = useSelectionStore.getState();
const { activeLayerId, layers } = useLayerStore.getState();
if (!activeSelection) return null;
const layer = layers.find((l) => l.id === activeLayerId);
if (!layer?.canvas) return null;
const { mask } = activeSelection;
const { bounds } = mask;
// Create a canvas for the copied pixels
const copyCanvas = document.createElement('canvas');
copyCanvas.width = bounds.width;
copyCanvas.height = bounds.height;
const ctx = copyCanvas.getContext('2d');
if (!ctx) return null;
const layerCtx = layer.canvas.getContext('2d');
if (!layerCtx) return null;
const imageData = layerCtx.getImageData(
bounds.x,
bounds.y,
bounds.width,
bounds.height
);
// Apply mask to image data
for (let y = 0; y < bounds.height; y++) {
for (let x = 0; x < bounds.width; x++) {
const maskIdx =
(bounds.y + y) * mask.width + (bounds.x + x);
const dataIdx = (y * bounds.width + x) * 4;
const alpha = mask.data[maskIdx];
if (alpha === 0) {
// Clear unselected pixels
imageData.data[dataIdx + 3] = 0;
} else if (alpha < 255) {
// Apply partial transparency for feathered edges
imageData.data[dataIdx + 3] =
(imageData.data[dataIdx + 3] * alpha) / 255;
}
}
}
ctx.putImageData(imageData, 0, 0);
return copyCanvas;
}
/**
* Cut selected pixels (copy and delete)
*/
export function cutSelection(): HTMLCanvasElement | null {
const copiedCanvas = copySelection();
if (copiedCanvas) {
deleteSelection();
}
return copiedCanvas;
}
/**
* Delete selected pixels
*/
export function deleteSelection(): void {
const { activeSelection } = useSelectionStore.getState();
const { activeLayerId, layers } = useLayerStore.getState();
const { executeCommand } = useHistoryStore.getState();
if (!activeSelection) return;
const layer = layers.find((l) => l.id === activeLayerId);
if (!layer?.canvas) return;
const ctx = layer.canvas.getContext('2d');
if (!ctx) return;
// Create a draw command for undo
const command = new DrawCommand(layer.id, 'Delete Selection');
const { mask } = activeSelection;
// Delete pixels within selection
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
const imageData = ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height);
for (let y = 0; y < mask.height; y++) {
for (let x = 0; x < mask.width; x++) {
const maskIdx = y * mask.width + x;
const alpha = mask.data[maskIdx];
if (alpha > 0) {
const dataIdx = (y * mask.width + x) * 4;
if (alpha === 255) {
imageData.data[dataIdx + 3] = 0;
} else {
// Reduce alpha for feathered edges
const currentAlpha = imageData.data[dataIdx + 3];
imageData.data[dataIdx + 3] = currentAlpha * (1 - alpha / 255);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
ctx.restore();
command.captureAfterState();
executeCommand(command);
}
/**
* Paste canvas content at position
*/
export function pasteCanvas(
canvas: HTMLCanvasElement,
x: number = 0,
y: number = 0
): void {
const { activeLayerId, layers } = useLayerStore.getState();
const { executeCommand } = useHistoryStore.getState();
const layer = layers.find((l) => l.id === activeLayerId);
if (!layer?.canvas) return;
const ctx = layer.canvas.getContext('2d');
if (!ctx) return;
// Create a draw command for undo
const command = new DrawCommand(layer.id, 'Paste');
ctx.drawImage(canvas, x, y);
command.captureAfterState();
executeCommand(command);
}
/**
* Fill selection with color
*/
export function fillSelection(color: string): void {
const { activeSelection } = useSelectionStore.getState();
const { activeLayerId, layers } = useLayerStore.getState();
const { executeCommand } = useHistoryStore.getState();
if (!activeSelection) return;
const layer = layers.find((l) => l.id === activeLayerId);
if (!layer?.canvas) return;
const ctx = layer.canvas.getContext('2d');
if (!ctx) return;
// Create a draw command for undo
const command = new DrawCommand(layer.id, 'Fill Selection');
const { mask } = activeSelection;
const imageData = ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height);
// Parse color
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d')!;
tempCtx.fillStyle = color;
tempCtx.fillRect(0, 0, 1, 1);
const colorData = tempCtx.getImageData(0, 0, 1, 1).data;
// Fill pixels within selection
for (let y = 0; y < mask.height; y++) {
for (let x = 0; x < mask.width; x++) {
const maskIdx = y * mask.width + x;
const alpha = mask.data[maskIdx];
if (alpha > 0) {
const dataIdx = (y * mask.width + x) * 4;
if (alpha === 255) {
imageData.data[dataIdx] = colorData[0];
imageData.data[dataIdx + 1] = colorData[1];
imageData.data[dataIdx + 2] = colorData[2];
imageData.data[dataIdx + 3] = colorData[3];
} else {
// Blend for feathered edges
const blendAlpha = alpha / 255;
imageData.data[dataIdx] =
imageData.data[dataIdx] * (1 - blendAlpha) + colorData[0] * blendAlpha;
imageData.data[dataIdx + 1] =
imageData.data[dataIdx + 1] * (1 - blendAlpha) + colorData[1] * blendAlpha;
imageData.data[dataIdx + 2] =
imageData.data[dataIdx + 2] * (1 - blendAlpha) + colorData[2] * blendAlpha;
}
}
}
}
ctx.putImageData(imageData, 0, 0);
command.captureAfterState();
executeCommand(command);
}
/**
* Stroke selection outline with color
*/
export function strokeSelection(color: string, width: number = 1): void {
const { activeSelection } = useSelectionStore.getState();
const { activeLayerId, layers } = useLayerStore.getState();
const { executeCommand } = useHistoryStore.getState();
if (!activeSelection) return;
const layer = layers.find((l) => l.id === activeLayerId);
if (!layer?.canvas) return;
const ctx = layer.canvas.getContext('2d');
if (!ctx) return;
// Create a draw command for undo
const command = new DrawCommand(layer.id, 'Stroke Selection');
const { mask } = activeSelection;
// Find edges
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = width;
for (let y = 1; y < mask.height - 1; y++) {
for (let x = 1; x < mask.width - 1; x++) {
const idx = y * mask.width + x;
if (mask.data[idx] === 0) continue;
// Check if this pixel is on the edge
const isEdge =
mask.data[idx - 1] === 0 || // left
mask.data[idx + 1] === 0 || // right
mask.data[idx - mask.width] === 0 || // top
mask.data[idx + mask.width] === 0; // bottom
if (isEdge) {
ctx.fillRect(x, y, 1, 1);
}
}
}
ctx.restore();
command.captureAfterState();
executeCommand(command);
}
/**
* Expand selection by pixels
*/
export function expandSelection(pixels: number): void {
const { activeSelection } = useSelectionStore.getState();
if (!activeSelection) return;
// TODO: Implement morphological dilation
// This is a placeholder - full implementation would use proper dilation algorithm
console.log('Expand selection by', pixels, 'pixels');
}
/**
* Contract selection by pixels
*/
export function contractSelection(pixels: number): void {
const { activeSelection } = useSelectionStore.getState();
if (!activeSelection) return;
// TODO: Implement morphological erosion
// This is a placeholder - full implementation would use proper erosion algorithm
console.log('Contract selection by', pixels, 'pixels');
}