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>
497 lines
11 KiB
TypeScript
497 lines
11 KiB
TypeScript
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();
|
|
}
|