import { BaseTool } from './base-tool'; import type { PointerState, ToolSettings } from '@/types'; /** * Crop tool - Select and crop canvas area */ export class CropTool extends BaseTool { private cropRect = { x: 0, y: 0, width: 0, height: 0 }; private isDefiningCrop = false; private isDraggingHandle = false; private activeHandle: string | null = null; private startX = 0; private startY = 0; private handleSize = 8; private initialized = false; constructor() { super('Crop'); } onPointerDown( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { // Initialize crop rect to full canvas on first use if (!this.initialized) { this.cropRect = { x: 0, y: 0, width: ctx.canvas.width, height: ctx.canvas.height, }; this.initialized = true; this.drawCropOverlay(ctx); } // Check if clicking on a resize handle const handle = this.getHandleAtPoint(pointer.x, pointer.y); if (handle) { this.isDraggingHandle = true; this.activeHandle = handle; this.startX = pointer.x; this.startY = pointer.y; return; } // Check if clicking inside crop rect to drag if (this.isInsideCropRect(pointer.x, pointer.y)) { this.isDrawing = true; this.startX = pointer.x; this.startY = pointer.y; return; } // Start defining new crop rect this.isDefiningCrop = true; this.startX = pointer.x; this.startY = pointer.y; this.cropRect = { x: pointer.x, y: pointer.y, width: 0, height: 0 }; } onPointerMove( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { if (this.isDraggingHandle && this.activeHandle) { this.resizeCropRect(pointer.x, pointer.y); this.drawCropOverlay(ctx); return; } if (this.isDrawing) { // Move crop rect const dx = pointer.x - this.startX; const dy = pointer.y - this.startY; this.cropRect.x = Math.max(0, Math.min(ctx.canvas.width - this.cropRect.width, this.cropRect.x + dx)); this.cropRect.y = Math.max(0, Math.min(ctx.canvas.height - this.cropRect.height, this.cropRect.y + dy)); this.startX = pointer.x; this.startY = pointer.y; this.drawCropOverlay(ctx); return; } if (this.isDefiningCrop) { // Update crop rect dimensions const x = Math.min(this.startX, pointer.x); const y = Math.min(this.startY, pointer.y); const width = Math.abs(pointer.x - this.startX); const height = Math.abs(pointer.y - this.startY); this.cropRect = { x, y, width, height }; this.drawCropOverlay(ctx); } } onPointerUp( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { this.isDefiningCrop = false; this.isDrawing = false; this.isDraggingHandle = false; this.activeHandle = null; } private isInsideCropRect(x: number, y: number): boolean { return ( x >= this.cropRect.x && x <= this.cropRect.x + this.cropRect.width && y >= this.cropRect.y && y <= this.cropRect.y + this.cropRect.height ); } private getHandleAtPoint(x: number, y: number): string | null { const handles = this.getHandlePositions(); const threshold = this.handleSize; for (const [name, pos] of Object.entries(handles)) { if ( x >= pos.x - threshold && x <= pos.x + threshold && y >= pos.y - threshold && y <= pos.y + threshold ) { return name; } } return null; } private getHandlePositions(): Record { const { x, y, width, height } = this.cropRect; return { nw: { x, y }, ne: { x: x + width, y }, sw: { x, y: y + height }, se: { x: x + width, y: y + height }, n: { x: x + width / 2, y }, s: { x: x + width / 2, y: y + height }, e: { x: x + width, y: y + height / 2 }, w: { x, y: y + height / 2 }, }; } private resizeCropRect(x: number, y: number): void { if (!this.activeHandle) return; const { x: rx, y: ry, width: rw, height: rh } = this.cropRect; switch (this.activeHandle) { case 'nw': this.cropRect = { x: x, y: y, width: rw + (rx - x), height: rh + (ry - y), }; break; case 'ne': this.cropRect = { x: rx, y: y, width: x - rx, height: rh + (ry - y), }; break; case 'sw': this.cropRect = { x: x, y: ry, width: rw + (rx - x), height: y - ry, }; break; case 'se': this.cropRect = { x: rx, y: ry, width: x - rx, height: y - ry, }; break; case 'n': this.cropRect = { x: rx, y: y, width: rw, height: rh + (ry - y), }; break; case 's': this.cropRect = { x: rx, y: ry, width: rw, height: y - ry, }; break; case 'e': this.cropRect = { x: rx, y: ry, width: x - rx, height: rh, }; break; case 'w': this.cropRect = { x: x, y: ry, width: rw + (rx - x), height: rh, }; break; } // Ensure minimum size if (this.cropRect.width < 10) this.cropRect.width = 10; if (this.cropRect.height < 10) this.cropRect.height = 10; } private drawCropOverlay(ctx: CanvasRenderingContext2D): void { // Save context state ctx.save(); // Draw darkened areas outside crop rect ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; // Top ctx.fillRect(0, 0, ctx.canvas.width, this.cropRect.y); // Bottom ctx.fillRect( 0, this.cropRect.y + this.cropRect.height, ctx.canvas.width, ctx.canvas.height - (this.cropRect.y + this.cropRect.height) ); // Left ctx.fillRect( 0, this.cropRect.y, this.cropRect.x, this.cropRect.height ); // Right ctx.fillRect( this.cropRect.x + this.cropRect.width, this.cropRect.y, ctx.canvas.width - (this.cropRect.x + this.cropRect.width), this.cropRect.height ); // Draw crop rect border ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect( this.cropRect.x, this.cropRect.y, this.cropRect.width, this.cropRect.height ); // Draw rule of thirds grid ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; ctx.lineWidth = 1; // Vertical lines ctx.beginPath(); ctx.moveTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y); ctx.lineTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y + this.cropRect.height); ctx.moveTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y); ctx.lineTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y + this.cropRect.height); ctx.stroke(); // Horizontal lines ctx.beginPath(); ctx.moveTo(this.cropRect.x, this.cropRect.y + this.cropRect.height / 3); ctx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + this.cropRect.height / 3); ctx.moveTo(this.cropRect.x, this.cropRect.y + (this.cropRect.height * 2) / 3); ctx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + (this.cropRect.height * 2) / 3); ctx.stroke(); // Draw resize handles const handles = this.getHandlePositions(); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; for (const pos of Object.values(handles)) { ctx.fillRect( pos.x - this.handleSize / 2, pos.y - this.handleSize / 2, this.handleSize, this.handleSize ); ctx.strokeRect( pos.x - this.handleSize / 2, pos.y - this.handleSize / 2, this.handleSize, this.handleSize ); } // Restore context state ctx.restore(); } getCursor(): string { return 'crosshair'; } /** * Get current crop rectangle */ getCropRect(): { x: number; y: number; width: number; height: number } { return { ...this.cropRect }; } /** * Apply crop to canvas */ applyCrop(ctx: CanvasRenderingContext2D): void { if (this.cropRect.width === 0 || this.cropRect.height === 0) return; // Extract cropped region const croppedData = ctx.getImageData( this.cropRect.x, this.cropRect.y, this.cropRect.width, this.cropRect.height ); // Resize canvas ctx.canvas.width = this.cropRect.width; ctx.canvas.height = this.cropRect.height; // Draw cropped data ctx.putImageData(croppedData, 0, 0); // Reset crop rect this.cropRect = { x: 0, y: 0, width: ctx.canvas.width, height: ctx.canvas.height, }; this.initialized = true; } }