From 841d6ca0a53c1d7678a379a7c2487ab29bae17f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 19:54:05 +0100 Subject: [PATCH] feat(phase-13): implement crop tool with visual guides and handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive crop tool with visual guides, resize handles, and Apply/Cancel UI. Features: - Interactive crop area selection with drag-to-create - 8 resize handles (corners and edges) for precise cropping - Visual overlay with dimmed areas outside crop region - Rule of thirds grid overlay for composition guidance - Drag crop area to reposition - Apply/Cancel buttons in tool options - White border and handles for clear visibility Changes: - Created tools/crop-tool.ts with CropTool class - Added crop tool to lib/tool-loader.ts - Added Crop icon and button to tool palette with 'C' shortcut - Added crop tool options UI in components/editor/tool-options.tsx - Exported CropTool from tools/index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/editor/tool-options.tsx | 28 ++- components/tools/tool-palette.tsx | 2 + lib/tool-loader.ts | 5 + tools/crop-tool.ts | 376 +++++++++++++++++++++++++++++ tools/index.ts | 1 + 5 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 tools/crop-tool.ts diff --git a/components/editor/tool-options.tsx b/components/editor/tool-options.tsx index feb4ef2..7f747e9 100644 --- a/components/editor/tool-options.tsx +++ b/components/editor/tool-options.tsx @@ -52,6 +52,9 @@ export function ToolOptions() { // Gradient tool const isGradientTool = activeTool === 'gradient'; + // Crop tool + const isCropTool = activeTool === 'crop'; + // Shape tool const isShapeTool = activeTool === 'shape'; @@ -62,7 +65,7 @@ export function ToolOptions() { const isTextTool = activeTool === 'text'; // Don't show options bar if no options available - if (!isDrawingTool && !isFillTool && !isGradientTool && !isShapeTool && !isSelectionTool && !isTextTool) { + if (!isDrawingTool && !isFillTool && !isGradientTool && !isCropTool && !isShapeTool && !isSelectionTool && !isTextTool) { return null; } @@ -205,6 +208,29 @@ export function ToolOptions() { )} + {/* Crop Tool Options */} + {isCropTool && ( + <> +
+
+ Drag to define crop area. Click and drag handles to resize. +
+ + +
+ + )} + {/* Gradient Tool Options */} {isGradientTool && ( <> diff --git a/components/tools/tool-palette.tsx b/components/tools/tool-palette.tsx index 342ed0c..cac424d 100644 --- a/components/tools/tool-palette.tsx +++ b/components/tools/tool-palette.tsx @@ -14,6 +14,7 @@ import { Stamp, Droplet, Sun, + Crop, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -26,6 +27,7 @@ const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: s { type: 'eyedropper', icon: , label: 'Eyedropper', shortcut: '5' }, { type: 'text', icon: , label: 'Text', shortcut: '6' }, { type: 'select', icon: , label: 'Select', shortcut: '7' }, + { type: 'crop', icon: , label: 'Crop', shortcut: 'C' }, { type: 'clone', icon: , label: 'Clone Stamp (Alt+Click source)', shortcut: '8' }, { type: 'smudge', icon: , label: 'Smudge', shortcut: '9' }, { type: 'dodge', icon: , label: 'Dodge/Burn (Alt for burn)', shortcut: '0' }, diff --git a/lib/tool-loader.ts b/lib/tool-loader.ts index dfda827..1c0ba3c 100644 --- a/lib/tool-loader.ts +++ b/lib/tool-loader.ts @@ -113,6 +113,11 @@ async function loadTool(toolKey: string): Promise { tool = new DodgeBurnTool(); break; } + case 'crop': { + const { CropTool } = await import('@/tools/crop-tool'); + tool = new CropTool(); + break; + } default: { // Fallback to pencil tool const { PencilTool } = await import('@/tools/pencil-tool'); diff --git a/tools/crop-tool.ts b/tools/crop-tool.ts new file mode 100644 index 0000000..43e8a57 --- /dev/null +++ b/tools/crop-tool.ts @@ -0,0 +1,376 @@ +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 overlayCanvas: HTMLCanvasElement | null = null; + private handleSize = 8; + + constructor() { + super('Crop'); + } + + onActivate(): void { + // Initialize crop rect to full canvas size when activated + if (this.overlayCanvas) { + this.cropRect = { + x: 0, + y: 0, + width: this.overlayCanvas.width, + height: this.overlayCanvas.height, + }; + } + } + + onPointerDown( + pointer: PointerState, + ctx: CanvasRenderingContext2D, + settings: ToolSettings + ): void { + // Create overlay canvas if not exists + if (!this.overlayCanvas) { + this.overlayCanvas = document.createElement('canvas'); + this.overlayCanvas.width = ctx.canvas.width; + this.overlayCanvas.height = ctx.canvas.height; + this.cropRect = { + x: 0, + y: 0, + width: ctx.canvas.width, + height: ctx.canvas.height, + }; + } + + // 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 { + if (!this.overlayCanvas) return; + + const overlayCtx = this.overlayCanvas.getContext('2d'); + if (!overlayCtx) return; + + // Clear overlay + overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height); + + // Draw darkened areas outside crop rect + overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + + // Top + overlayCtx.fillRect(0, 0, this.overlayCanvas.width, this.cropRect.y); + + // Bottom + overlayCtx.fillRect( + 0, + this.cropRect.y + this.cropRect.height, + this.overlayCanvas.width, + this.overlayCanvas.height - (this.cropRect.y + this.cropRect.height) + ); + + // Left + overlayCtx.fillRect( + 0, + this.cropRect.y, + this.cropRect.x, + this.cropRect.height + ); + + // Right + overlayCtx.fillRect( + this.cropRect.x + this.cropRect.width, + this.cropRect.y, + this.overlayCanvas.width - (this.cropRect.x + this.cropRect.width), + this.cropRect.height + ); + + // Draw crop rect border + overlayCtx.strokeStyle = '#ffffff'; + overlayCtx.lineWidth = 2; + overlayCtx.strokeRect( + this.cropRect.x, + this.cropRect.y, + this.cropRect.width, + this.cropRect.height + ); + + // Draw rule of thirds grid + overlayCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + overlayCtx.lineWidth = 1; + + // Vertical lines + overlayCtx.beginPath(); + overlayCtx.moveTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y); + overlayCtx.lineTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y + this.cropRect.height); + overlayCtx.moveTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y); + overlayCtx.lineTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y + this.cropRect.height); + overlayCtx.stroke(); + + // Horizontal lines + overlayCtx.beginPath(); + overlayCtx.moveTo(this.cropRect.x, this.cropRect.y + this.cropRect.height / 3); + overlayCtx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + this.cropRect.height / 3); + overlayCtx.moveTo(this.cropRect.x, this.cropRect.y + (this.cropRect.height * 2) / 3); + overlayCtx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + (this.cropRect.height * 2) / 3); + overlayCtx.stroke(); + + // Draw resize handles + const handles = this.getHandlePositions(); + overlayCtx.fillStyle = '#ffffff'; + overlayCtx.strokeStyle = '#000000'; + overlayCtx.lineWidth = 1; + + for (const pos of Object.values(handles)) { + overlayCtx.fillRect( + pos.x - this.handleSize / 2, + pos.y - this.handleSize / 2, + this.handleSize, + this.handleSize + ); + overlayCtx.strokeRect( + pos.x - this.handleSize / 2, + pos.y - this.handleSize / 2, + this.handleSize, + this.handleSize + ); + } + + // Draw overlay on main canvas + const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.putImageData(imageData, 0, 0); + ctx.drawImage(this.overlayCanvas, 0, 0); + } + + 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.overlayCanvas = null; + } +} diff --git a/tools/index.ts b/tools/index.ts index 94eff6a..d997e22 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -16,3 +16,4 @@ export * from './text-tool'; export * from './clone-stamp-tool'; export * from './smudge-tool'; export * from './dodge-burn-tool'; +export * from './crop-tool';