diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index ed7b957..602ab8d 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -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 = { 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(); diff --git a/components/editor/editor-layout.tsx b/components/editor/editor-layout.tsx index 224860a..02ac085 100644 --- a/components/editor/editor-layout.tsx +++ b/components/editor/editor-layout.tsx @@ -10,6 +10,7 @@ import { FileMenu } from './file-menu'; import { ToolPalette, ToolSettings } from '@/components/tools'; import { ColorPanel } from '@/components/colors'; import { FilterPanel } from '@/components/filters'; +import { SelectionPanel } from '@/components/selection'; import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useFileOperations } from '@/hooks/use-file-operations'; import { useDragDrop } from '@/hooks/use-drag-drop'; @@ -179,6 +180,9 @@ export function EditorLayout() { {/* Filter Panel */} + {/* Selection Panel */} + + {/* Canvas area */}
diff --git a/components/selection/index.ts b/components/selection/index.ts new file mode 100644 index 0000000..f00450f --- /dev/null +++ b/components/selection/index.ts @@ -0,0 +1 @@ +export * from './selection-panel'; diff --git a/components/selection/selection-panel.tsx b/components/selection/selection-panel.tsx new file mode 100644 index 0000000..e4580f2 --- /dev/null +++ b/components/selection/selection-panel.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState } from 'react'; +import { useSelectionStore } from '@/store/selection-store'; +import { useToolStore } from '@/store/tool-store'; +import { useHistoryStore } from '@/store/history-store'; +import { + copySelection, + cutSelection, + deleteSelection, + fillSelection, + strokeSelection, + pasteCanvas, +} from '@/lib/selection-operations'; +import { + ClearSelectionCommand, + InvertSelectionCommand, +} from '@/core/commands/selection-command'; +import type { SelectionType, SelectionMode } from '@/types/selection'; +import { + Square, + Circle, + Lasso, + Wand2, + Copy, + Scissors, + Trash2, + FlipVertical, + X, + PlusSquare, + MinusSquare, + Layers, + Paintbrush, + Clipboard, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const SELECTION_TOOLS: Array<{ + type: SelectionType; + label: string; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { type: 'rectangular', label: 'Rectangle', icon: Square }, + { type: 'elliptical', label: 'Ellipse', icon: Circle }, + { type: 'lasso', label: 'Lasso', icon: Lasso }, + { type: 'magic-wand', label: 'Magic Wand', icon: Wand2 }, +]; + +const SELECTION_MODES: Array<{ + mode: SelectionMode; + label: string; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { mode: 'new', label: 'New', icon: Square }, + { mode: 'add', label: 'Add', icon: PlusSquare }, + { mode: 'subtract', label: 'Subtract', icon: MinusSquare }, + { mode: 'intersect', label: 'Intersect', icon: Layers }, +]; + +export function SelectionPanel() { + const { + activeSelection, + selectionType, + selectionMode, + feather, + tolerance, + setSelectionType, + setSelectionMode, + setFeather, + setTolerance, + clearSelection, + invertSelection, + } = useSelectionStore(); + + const { setActiveTool, settings } = useToolStore(); + const { executeCommand } = useHistoryStore(); + const [copiedCanvas, setCopiedCanvas] = useState( + null + ); + + const hasSelection = !!activeSelection; + + const handleToolSelect = (type: SelectionType) => { + setSelectionType(type); + setActiveTool('select'); + }; + + const handleCopy = () => { + const canvas = copySelection(); + if (canvas) { + setCopiedCanvas(canvas); + } + }; + + const handleCut = () => { + const canvas = cutSelection(); + if (canvas) { + setCopiedCanvas(canvas); + } + }; + + const handlePaste = () => { + if (copiedCanvas) { + pasteCanvas(copiedCanvas); + } + }; + + const handleDelete = () => { + deleteSelection(); + }; + + const handleClear = () => { + const command = new ClearSelectionCommand(); + executeCommand(command); + }; + + const handleInvert = () => { + const command = new InvertSelectionCommand(); + executeCommand(command); + }; + + const handleFill = () => { + fillSelection(settings.color); + }; + + const handleStroke = () => { + strokeSelection(settings.color, 2); + }; + + return ( +
+ {/* Header */} +
+ +

Selection

+
+ + {/* Selection Tools */} +
+

+ Tools +

+
+ {SELECTION_TOOLS.map((tool) => ( + + ))} +
+
+ + {/* Selection Modes */} +
+

+ Mode +

+
+ {SELECTION_MODES.map((mode) => ( + + ))} +
+
+ + {/* Selection Parameters */} +
+

+ Parameters +

+ + {/* Feather */} +
+ + setFeather(Number(e.target.value))} + className="w-full" + /> +
+ {feather}px +
+
+ + {/* Tolerance (for magic wand) */} + {selectionType === 'magic-wand' && ( +
+ + setTolerance(Number(e.target.value))} + className="w-full" + /> +
+ {tolerance} +
+
+ )} +
+ + {/* Selection Operations */} +
+

+ Operations +

+ + + + + + + + + +
+ + + + + +
+ + + + +
+ + {!hasSelection && ( +
+

+ Use selection tools to create a selection +

+
+ )} +
+ ); +} diff --git a/core/commands/selection-command.ts b/core/commands/selection-command.ts new file mode 100644 index 0000000..b2c21f4 --- /dev/null +++ b/core/commands/selection-command.ts @@ -0,0 +1,54 @@ +import { BaseCommand } from './base-command'; +import type { Selection } from '@/types/selection'; +import { useSelectionStore } from '@/store/selection-store'; + +export class CreateSelectionCommand extends BaseCommand { + private previousSelection: Selection | null; + private newSelection: Selection; + + constructor(newSelection: Selection) { + super('Create Selection'); + this.previousSelection = useSelectionStore.getState().activeSelection; + this.newSelection = newSelection; + } + + execute(): void { + useSelectionStore.getState().setActiveSelection(this.newSelection); + } + + undo(): void { + useSelectionStore.getState().setActiveSelection(this.previousSelection); + } +} + +export class ClearSelectionCommand extends BaseCommand { + private previousSelection: Selection | null; + + constructor() { + super('Clear Selection'); + this.previousSelection = useSelectionStore.getState().activeSelection; + } + + execute(): void { + useSelectionStore.getState().clearSelection(); + } + + undo(): void { + useSelectionStore.getState().setActiveSelection(this.previousSelection); + } +} + +export class InvertSelectionCommand extends BaseCommand { + constructor() { + super('Invert Selection'); + } + + execute(): void { + useSelectionStore.getState().invertSelection(); + } + + undo(): void { + // Invert is its own inverse + useSelectionStore.getState().invertSelection(); + } +} diff --git a/lib/selection-operations.ts b/lib/selection-operations.ts new file mode 100644 index 0000000..576a127 --- /dev/null +++ b/lib/selection-operations.ts @@ -0,0 +1,287 @@ +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'); +} diff --git a/lib/selection-utils.ts b/lib/selection-utils.ts new file mode 100644 index 0000000..fc62af0 --- /dev/null +++ b/lib/selection-utils.ts @@ -0,0 +1,496 @@ +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(); + 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(); +} diff --git a/store/canvas-store.ts b/store/canvas-store.ts index 6ba0916..da046d6 100644 --- a/store/canvas-store.ts +++ b/store/canvas-store.ts @@ -1,9 +1,9 @@ import { create } from 'zustand'; -import type { CanvasState, Selection, Point } from '@/types'; +import type { CanvasState, CanvasSelection, Point } from '@/types'; interface CanvasStore extends CanvasState { /** Selection state */ - selection: Selection; + selection: CanvasSelection; /** Set canvas dimensions */ setDimensions: (width: number, height: number) => void; @@ -34,7 +34,7 @@ interface CanvasStore extends CanvasState { /** Set background color */ setBackgroundColor: (color: string) => void; /** Set selection */ - setSelection: (selection: Partial) => void; + setSelection: (selection: Partial) => void; /** Clear selection */ clearSelection: () => void; /** Convert screen coordinates to canvas coordinates */ diff --git a/store/index.ts b/store/index.ts index 1981e7b..d941eb3 100644 --- a/store/index.ts +++ b/store/index.ts @@ -4,3 +4,4 @@ export * from './tool-store'; export * from './filter-store'; export * from './history-store'; export * from './color-store'; +export * from './selection-store'; diff --git a/store/selection-store.ts b/store/selection-store.ts new file mode 100644 index 0000000..8208c0c --- /dev/null +++ b/store/selection-store.ts @@ -0,0 +1,75 @@ +import { create } from 'zustand'; +import type { + Selection, + SelectionType, + SelectionMode, + SelectionState, +} from '@/types/selection'; + +interface SelectionStore extends SelectionState { + setActiveSelection: (selection: Selection | null) => void; + setSelectionType: (type: SelectionType) => void; + setSelectionMode: (mode: SelectionMode) => void; + setFeather: (feather: number) => void; + setTolerance: (tolerance: number) => void; + setMarching: (isMarching: boolean) => void; + clearSelection: () => void; + invertSelection: () => void; +} + +export const useSelectionStore = create((set) => ({ + activeSelection: null, + selectionType: 'rectangular', + selectionMode: 'new', + feather: 0, + tolerance: 32, + isMarching: true, + + setActiveSelection: (selection) => + set({ + activeSelection: selection, + }), + + setSelectionType: (type) => + set({ + selectionType: type, + }), + + setSelectionMode: (mode) => + set({ + selectionMode: mode, + }), + + setFeather: (feather) => + set({ + feather: Math.max(0, Math.min(250, feather)), + }), + + setTolerance: (tolerance) => + set({ + tolerance: Math.max(0, Math.min(255, tolerance)), + }), + + setMarching: (isMarching) => + set({ + isMarching, + }), + + clearSelection: () => + set({ + activeSelection: null, + }), + + invertSelection: () => + set((state) => { + if (state.activeSelection) { + return { + activeSelection: { + ...state.activeSelection, + inverted: !state.activeSelection.inverted, + }, + }; + } + return state; + }), +})); diff --git a/tools/elliptical-selection-tool.ts b/tools/elliptical-selection-tool.ts new file mode 100644 index 0000000..8d52995 --- /dev/null +++ b/tools/elliptical-selection-tool.ts @@ -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); + } +} diff --git a/tools/index.ts b/tools/index.ts index 576b75d..298bf35 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -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'; diff --git a/tools/lasso-selection-tool.ts b/tools/lasso-selection-tool.ts new file mode 100644 index 0000000..25bdb99 --- /dev/null +++ b/tools/lasso-selection-tool.ts @@ -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); + } +} diff --git a/tools/magic-wand-tool.ts b/tools/magic-wand-tool.ts new file mode 100644 index 0000000..f583300 --- /dev/null +++ b/tools/magic-wand-tool.ts @@ -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); + } +} diff --git a/tools/rectangular-selection-tool.ts b/tools/rectangular-selection-tool.ts new file mode 100644 index 0000000..1e1f172 --- /dev/null +++ b/tools/rectangular-selection-tool.ts @@ -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); + } +} diff --git a/types/canvas.ts b/types/canvas.ts index ab08c6b..ec10e8d 100644 --- a/types/canvas.ts +++ b/types/canvas.ts @@ -25,9 +25,9 @@ export interface CanvasState { } /** - * Selection interface for selected regions + * Canvas selection interface for selected regions (deprecated - use Selection from selection.ts) */ -export interface Selection { +export interface CanvasSelection { /** Is there an active selection */ active: boolean; /** Selection bounds */ diff --git a/types/index.ts b/types/index.ts index ef8f596..10d3d90 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3,3 +3,4 @@ export * from './layer'; export * from './tool'; export * from './history'; export * from './filter'; +export * from './selection'; diff --git a/types/selection.ts b/types/selection.ts new file mode 100644 index 0000000..723427b --- /dev/null +++ b/types/selection.ts @@ -0,0 +1,47 @@ +export type SelectionType = 'rectangular' | 'elliptical' | 'lasso' | 'magic-wand'; + +export type SelectionMode = 'new' | 'add' | 'subtract' | 'intersect'; + +export interface SelectionBounds { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Selection mask is a 2D boolean array representing selected pixels + * true = selected, false = not selected + */ +export interface SelectionMask { + width: number; + height: number; + data: Uint8Array; // 1 byte per pixel (0 = not selected, 255 = selected) + bounds: SelectionBounds; +} + +export interface Selection { + id: string; + layerId: string; + mask: SelectionMask; + inverted: boolean; + feather: number; // Feather radius in pixels + createdAt: number; +} + +export interface SelectionState { + activeSelection: Selection | null; + selectionType: SelectionType; + selectionMode: SelectionMode; + feather: number; + tolerance: number; // For magic wand (0-255) + isMarching: boolean; // Marching ants animation +} + +/** + * Point for lasso selection + */ +export interface LassoPoint { + x: number; + y: number; +}