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

497 lines
11 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 {
Selection,
SelectionMask,
SelectionBounds,
LassoPoint,
SelectionMode,
} from '@/types/selection';
import { v4 as uuidv4 } from 'uuid';
/**
* Create an empty selection mask
*/
export function createEmptyMask(width: number, height: number): SelectionMask {
return {
width,
height,
data: new Uint8Array(width * height),
bounds: { x: 0, y: 0, width: 0, height: 0 },
};
}
/**
* Create a rectangular selection mask
*/
export function createRectangularMask(
x: number,
y: number,
width: number,
height: number,
canvasWidth: number,
canvasHeight: number
): SelectionMask {
const mask = createEmptyMask(canvasWidth, canvasHeight);
const startX = Math.max(0, Math.floor(Math.min(x, x + width)));
const startY = Math.max(0, Math.floor(Math.min(y, y + height)));
const endX = Math.min(canvasWidth, Math.ceil(Math.max(x, x + width)));
const endY = Math.min(canvasHeight, Math.ceil(Math.max(y, y + height)));
for (let py = startY; py < endY; py++) {
for (let px = startX; px < endX; px++) {
mask.data[py * canvasWidth + px] = 255;
}
}
mask.bounds = {
x: startX,
y: startY,
width: endX - startX,
height: endY - startY,
};
return mask;
}
/**
* Create an elliptical selection mask
*/
export function createEllipticalMask(
cx: number,
cy: number,
rx: number,
ry: number,
canvasWidth: number,
canvasHeight: number
): SelectionMask {
const mask = createEmptyMask(canvasWidth, canvasHeight);
const startX = Math.max(0, Math.floor(cx - rx));
const startY = Math.max(0, Math.floor(cy - ry));
const endX = Math.min(canvasWidth, Math.ceil(cx + rx));
const endY = Math.min(canvasHeight, Math.ceil(cy + ry));
for (let y = startY; y < endY; y++) {
for (let x = startX; x < endX; x++) {
const dx = (x - cx) / rx;
const dy = (y - cy) / ry;
if (dx * dx + dy * dy <= 1) {
mask.data[y * canvasWidth + x] = 255;
}
}
}
mask.bounds = {
x: startX,
y: startY,
width: endX - startX,
height: endY - startY,
};
return mask;
}
/**
* Create a lasso (polygon) selection mask using scanline fill
*/
export function createLassoMask(
points: LassoPoint[],
canvasWidth: number,
canvasHeight: number
): SelectionMask {
const mask = createEmptyMask(canvasWidth, canvasHeight);
if (points.length < 3) return mask;
// Find bounds
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const point of points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
minX = Math.max(0, Math.floor(minX));
minY = Math.max(0, Math.floor(minY));
maxX = Math.min(canvasWidth, Math.ceil(maxX));
maxY = Math.min(canvasHeight, Math.ceil(maxY));
// Scanline fill algorithm
for (let y = minY; y < maxY; y++) {
const intersections: number[] = [];
// Find intersections with polygon edges
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
if (
(p1.y <= y && p2.y > y) ||
(p2.y <= y && p1.y > y)
) {
const x = p1.x + ((y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x);
intersections.push(x);
}
}
// Sort intersections
intersections.sort((a, b) => a - b);
// Fill between pairs of intersections
for (let i = 0; i < intersections.length; i += 2) {
if (i + 1 < intersections.length) {
const startX = Math.max(minX, Math.floor(intersections[i]));
const endX = Math.min(maxX, Math.ceil(intersections[i + 1]));
for (let x = startX; x < endX; x++) {
mask.data[y * canvasWidth + x] = 255;
}
}
}
}
mask.bounds = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
return mask;
}
/**
* Create a magic wand selection mask using flood fill
*/
export function createMagicWandMask(
startX: number,
startY: number,
imageData: ImageData,
tolerance: number
): SelectionMask {
const { width, height, data } = imageData;
const mask = createEmptyMask(width, height);
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
return mask;
}
const startIdx = (startY * width + startX) * 4;
const targetR = data[startIdx];
const targetG = data[startIdx + 1];
const targetB = data[startIdx + 2];
const targetA = data[startIdx + 3];
const visited = new Set<string>();
const stack: [number, number][] = [[startX, startY]];
const isColorMatch = (x: number, y: number): boolean => {
const idx = (y * width + x) * 4;
const r = data[idx];
const g = data[idx + 1];
const b = data[idx + 2];
const a = data[idx + 3];
const diff = Math.sqrt(
Math.pow(r - targetR, 2) +
Math.pow(g - targetG, 2) +
Math.pow(b - targetB, 2) +
Math.pow(a - targetA, 2)
);
return diff <= tolerance;
};
let minX = startX,
minY = startY,
maxX = startX,
maxY = startY;
while (stack.length > 0) {
const [x, y] = stack.pop()!;
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= width || y < 0 || y >= height) continue;
if (!isColorMatch(x, y)) continue;
visited.add(key);
mask.data[y * width + x] = 255;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
stack.push([x + 1, y]);
stack.push([x - 1, y]);
stack.push([x, y + 1]);
stack.push([x, y - 1]);
}
mask.bounds = {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
return mask;
}
/**
* Combine two selection masks based on mode
*/
export function combineMasks(
mask1: SelectionMask,
mask2: SelectionMask,
mode: SelectionMode
): SelectionMask {
const width = Math.max(mask1.width, mask2.width);
const height = Math.max(mask1.height, mask2.height);
const result = createEmptyMask(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const val1 = x < mask1.width && y < mask1.height ? mask1.data[idx] : 0;
const val2 = x < mask2.width && y < mask2.height ? mask2.data[idx] : 0;
switch (mode) {
case 'new':
result.data[idx] = val2;
break;
case 'add':
result.data[idx] = Math.max(val1, val2);
break;
case 'subtract':
result.data[idx] = val1 > 0 && val2 === 0 ? val1 : 0;
break;
case 'intersect':
result.data[idx] = val1 > 0 && val2 > 0 ? 255 : 0;
break;
}
}
}
// Recalculate bounds
let minX = width,
minY = height,
maxX = 0,
maxY = 0;
let hasSelection = false;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (result.data[y * width + x] > 0) {
hasSelection = true;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (hasSelection) {
result.bounds = {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
}
return result;
}
/**
* Apply feathering to a selection mask
*/
export function featherMask(mask: SelectionMask, radius: number): SelectionMask {
if (radius <= 0) return mask;
const { width, height, data } = mask;
const result = createEmptyMask(width, height);
result.bounds = { ...mask.bounds };
// Simple box blur for feathering
const kernelSize = Math.ceil(radius) * 2 + 1;
const tempData = new Uint8Array(width * height);
// Horizontal pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0;
let count = 0;
for (let k = -Math.ceil(radius); k <= Math.ceil(radius); k++) {
const nx = x + k;
if (nx >= 0 && nx < width) {
sum += data[y * width + nx];
count++;
}
}
tempData[y * width + x] = sum / count;
}
}
// Vertical pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0;
let count = 0;
for (let k = -Math.ceil(radius); k <= Math.ceil(radius); k++) {
const ny = y + k;
if (ny >= 0 && ny < height) {
sum += tempData[ny * width + x];
count++;
}
}
result.data[y * width + x] = sum / count;
}
}
return result;
}
/**
* Invert a selection mask
*/
export function invertMask(mask: SelectionMask): SelectionMask {
const result = createEmptyMask(mask.width, mask.height);
for (let i = 0; i < mask.data.length; i++) {
result.data[i] = mask.data[i] > 0 ? 0 : 255;
}
// Recalculate bounds
let minX = mask.width,
minY = mask.height,
maxX = 0,
maxY = 0;
let hasSelection = false;
for (let y = 0; y < mask.height; y++) {
for (let x = 0; x < mask.width; x++) {
if (result.data[y * mask.width + x] > 0) {
hasSelection = true;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (hasSelection) {
result.bounds = {
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1,
};
} else {
result.bounds = { x: 0, y: 0, width: mask.width, height: mask.height };
}
return result;
}
/**
* Check if a point is inside the selection
*/
export function isPointInSelection(
x: number,
y: number,
selection: Selection
): boolean {
const { mask } = selection;
if (x < 0 || x >= mask.width || y < 0 || y >= mask.height) {
return false;
}
const value = mask.data[Math.floor(y) * mask.width + Math.floor(x)];
return selection.inverted ? value === 0 : value > 0;
}
/**
* Create a selection object
*/
export function createSelection(
layerId: string,
mask: SelectionMask,
feather: number = 0
): Selection {
const featheredMask = feather > 0 ? featherMask(mask, feather) : mask;
return {
id: uuidv4(),
layerId,
mask: featheredMask,
inverted: false,
feather,
createdAt: Date.now(),
};
}
/**
* Draw marching ants around selection
*/
export function drawMarchingAnts(
ctx: CanvasRenderingContext2D,
mask: SelectionMask,
offset: number = 0
): void {
const { width, height, data } = mask;
ctx.save();
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.lineDashOffset = -offset;
// Find edges and draw them
ctx.beginPath();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (data[idx] === 0) continue;
// Check if this pixel is on the edge
const isEdge =
x === 0 ||
x === width - 1 ||
y === 0 ||
y === height - 1 ||
data[idx - 1] === 0 || // left
data[idx + 1] === 0 || // right
data[idx - width] === 0 || // top
data[idx + width] === 0; // bottom
if (isEdge) {
// Draw pixel outline
ctx.rect(x, y, 1, 1);
}
}
}
ctx.stroke();
// Draw white dashes on top
ctx.strokeStyle = '#fff';
ctx.lineDashOffset = -offset + 4;
ctx.stroke();
ctx.restore();
}