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';