diff --git a/components/tools/tool-palette.tsx b/components/tools/tool-palette.tsx
index 3f9aa52..55d2549 100644
--- a/components/tools/tool-palette.tsx
+++ b/components/tools/tool-palette.tsx
@@ -10,6 +10,9 @@ import {
MousePointer,
Pipette,
Type,
+ Stamp,
+ Droplet,
+ Sun,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -21,6 +24,9 @@ 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: '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' },
];
export function ToolPalette() {
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
index 93bebfc..ad71785 100644
--- a/hooks/use-keyboard-shortcuts.ts
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -28,6 +28,9 @@ const TOOL_SHORTCUTS: Record = {
'5': 'eyedropper',
'6': 'text',
'7': 'select',
+ '8': 'clone',
+ '9': 'smudge',
+ '0': 'dodge',
};
/**
diff --git a/lib/tool-loader.ts b/lib/tool-loader.ts
index e95775b..ed79eaa 100644
--- a/lib/tool-loader.ts
+++ b/lib/tool-loader.ts
@@ -93,6 +93,21 @@ async function loadTool(toolKey: string): Promise {
tool = new TextTool();
break;
}
+ case 'clone': {
+ const { CloneStampTool } = await import('@/tools/clone-stamp-tool');
+ tool = new CloneStampTool();
+ break;
+ }
+ case 'smudge': {
+ const { SmudgeTool } = await import('@/tools/smudge-tool');
+ tool = new SmudgeTool();
+ break;
+ }
+ case 'dodge': {
+ const { DodgeBurnTool } = await import('@/tools/dodge-burn-tool');
+ tool = new DodgeBurnTool();
+ break;
+ }
default: {
// Fallback to pencil tool
const { PencilTool } = await import('@/tools/pencil-tool');
diff --git a/tools/clone-stamp-tool.ts b/tools/clone-stamp-tool.ts
new file mode 100644
index 0000000..2dc96d3
--- /dev/null
+++ b/tools/clone-stamp-tool.ts
@@ -0,0 +1,203 @@
+import { BaseTool } from './base-tool';
+import type { PointerState, ToolSettings } from '@/types';
+import { distance } from '@/lib/utils';
+
+/**
+ * Clone Stamp Tool - Sample and paint from one location to another
+ */
+export class CloneStampTool extends BaseTool {
+ private lastX = 0;
+ private lastY = 0;
+ private sourceX: number | null = null;
+ private sourceY: number | null = null;
+ private offsetX = 0;
+ private offsetY = 0;
+ private sourceCanvas: HTMLCanvasElement | null = null;
+
+ constructor() {
+ super('Clone Stamp');
+ }
+
+ /**
+ * Set the source point for cloning
+ */
+ setSourcePoint(x: number, y: number, ctx: CanvasRenderingContext2D): void {
+ this.sourceX = x;
+ this.sourceY = y;
+
+ // Create a canvas to store the source image data
+ if (!this.sourceCanvas) {
+ this.sourceCanvas = document.createElement('canvas');
+ }
+ this.sourceCanvas.width = ctx.canvas.width;
+ this.sourceCanvas.height = ctx.canvas.height;
+
+ const sourceCtx = this.sourceCanvas.getContext('2d');
+ if (sourceCtx) {
+ sourceCtx.drawImage(ctx.canvas, 0, 0);
+ }
+ }
+
+ onPointerDown(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ // Alt+Click to set source point
+ if (pointer.altKey) {
+ this.setSourcePoint(pointer.x, pointer.y, ctx);
+ return;
+ }
+
+ // Can't clone without a source point
+ if (this.sourceX === null || this.sourceY === null || !this.sourceCanvas) {
+ return;
+ }
+
+ this.isDrawing = true;
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+
+ // Calculate offset between source and destination
+ this.offsetX = pointer.x - this.sourceX;
+ this.offsetY = pointer.y - this.sourceY;
+
+ // Draw initial stamp
+ this.drawStamp(pointer.x, pointer.y, ctx, settings);
+ }
+
+ onPointerMove(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ if (!this.isDrawing) return;
+ if (this.sourceX === null || this.sourceY === null || !this.sourceCanvas) return;
+
+ // Calculate distance from last point
+ const dist = distance(this.lastX, this.lastY, pointer.x, pointer.y);
+ const spacing = settings.size * settings.spacing;
+
+ if (dist >= spacing) {
+ // Interpolate between points for smooth stroke
+ const steps = Math.ceil(dist / spacing);
+
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const x = this.lastX + (pointer.x - this.lastX) * t;
+ const y = this.lastY + (pointer.y - this.lastY) * t;
+
+ this.drawStamp(x, y, ctx, settings);
+ }
+
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+ }
+ }
+
+ onPointerUp(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ this.isDrawing = false;
+ }
+
+ /**
+ * Draw a single clone stamp
+ */
+ private drawStamp(
+ destX: number,
+ destY: number,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ if (!this.sourceCanvas || this.sourceX === null || this.sourceY === null) return;
+
+ const size = settings.size;
+ const opacity = settings.opacity * settings.flow;
+ const hardness = settings.hardness;
+
+ // Calculate source position (moves with destination)
+ const currentOffsetX = destX - this.sourceX;
+ const currentOffsetY = destY - this.sourceY;
+ const srcX = this.sourceX + currentOffsetX;
+ const srcY = this.sourceY + currentOffsetY;
+
+ // Create a temporary canvas for the stamp
+ const stampCanvas = document.createElement('canvas');
+ stampCanvas.width = size;
+ stampCanvas.height = size;
+ const stampCtx = stampCanvas.getContext('2d');
+
+ if (!stampCtx) return;
+
+ // Draw the source area to the stamp canvas
+ stampCtx.drawImage(
+ this.sourceCanvas,
+ srcX - size / 2,
+ srcY - size / 2,
+ size,
+ size,
+ 0,
+ 0,
+ size,
+ size
+ );
+
+ // Apply soft brush effect if hardness < 1
+ if (hardness < 1) {
+ // Create a mask for soft edges
+ const maskCanvas = document.createElement('canvas');
+ maskCanvas.width = size;
+ maskCanvas.height = size;
+ const maskCtx = maskCanvas.getContext('2d');
+
+ if (maskCtx) {
+ const gradient = maskCtx.createRadialGradient(
+ size / 2,
+ size / 2,
+ 0,
+ size / 2,
+ size / 2,
+ size / 2
+ );
+
+ gradient.addColorStop(0, 'rgba(0, 0, 0, 1)');
+ gradient.addColorStop(hardness, 'rgba(0, 0, 0, 1)');
+ gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ maskCtx.fillStyle = gradient;
+ maskCtx.fillRect(0, 0, size, size);
+
+ // Apply mask using composite operation
+ stampCtx.globalCompositeOperation = 'destination-in';
+ stampCtx.drawImage(maskCanvas, 0, 0);
+ stampCtx.globalCompositeOperation = 'source-over';
+ }
+ }
+
+ // Draw the stamp to the canvas
+ ctx.save();
+ ctx.globalAlpha = opacity;
+ ctx.drawImage(stampCanvas, destX - size / 2, destY - size / 2);
+ ctx.restore();
+ }
+
+ /**
+ * Reset the source point when tool is deactivated
+ */
+ onDeactivate(): void {
+ super.onDeactivate();
+ this.sourceX = null;
+ this.sourceY = null;
+ this.sourceCanvas = null;
+ }
+
+ getCursor(settings: ToolSettings): string {
+ if (this.sourceX === null || this.sourceY === null) {
+ return 'crosshair'; // No source set yet
+ }
+ return 'crosshair'; // Has source, ready to clone
+ }
+}
diff --git a/tools/dodge-burn-tool.ts b/tools/dodge-burn-tool.ts
new file mode 100644
index 0000000..1a8a503
--- /dev/null
+++ b/tools/dodge-burn-tool.ts
@@ -0,0 +1,160 @@
+import { BaseTool } from './base-tool';
+import type { PointerState, ToolSettings } from '@/types';
+import { distance } from '@/lib/utils';
+
+/**
+ * Dodge/Burn Tool - Lighten or darken specific areas
+ * Hold Alt to switch between Dodge (lighten) and Burn (darken)
+ */
+export class DodgeBurnTool extends BaseTool {
+ private lastX = 0;
+ private lastY = 0;
+ private mode: 'dodge' | 'burn' = 'dodge';
+
+ constructor() {
+ super('Dodge/Burn');
+ }
+
+ onPointerDown(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ this.isDrawing = true;
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+
+ // Alt key switches to burn mode
+ this.mode = pointer.altKey ? 'burn' : 'dodge';
+
+ // Apply initial effect
+ this.applyEffect(pointer.x, pointer.y, ctx, settings);
+ }
+
+ onPointerMove(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ if (!this.isDrawing) return;
+
+ // Update mode based on Alt key
+ this.mode = pointer.altKey ? 'burn' : 'dodge';
+
+ // Calculate distance from last point
+ const dist = distance(this.lastX, this.lastY, pointer.x, pointer.y);
+ const spacing = settings.size * settings.spacing;
+
+ if (dist >= spacing) {
+ // Interpolate between points for smooth stroke
+ const steps = Math.ceil(dist / spacing);
+
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const x = this.lastX + (pointer.x - this.lastX) * t;
+ const y = this.lastY + (pointer.y - this.lastY) * t;
+
+ this.applyEffect(x, y, ctx, settings);
+ }
+
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+ }
+ }
+
+ onPointerUp(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ this.isDrawing = false;
+ }
+
+ /**
+ * Apply dodge or burn effect
+ */
+ private applyEffect(
+ x: number,
+ y: number,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ const size = Math.ceil(settings.size);
+ const halfSize = Math.floor(size / 2);
+
+ // Clamp to canvas bounds
+ const sx = Math.max(0, Math.floor(x - halfSize));
+ const sy = Math.max(0, Math.floor(y - halfSize));
+ const sw = Math.min(size, ctx.canvas.width - sx);
+ const sh = Math.min(size, ctx.canvas.height - sy);
+
+ if (sw <= 0 || sh <= 0) return;
+
+ // Get image data
+ const imageData = ctx.getImageData(sx, sy, sw, sh);
+ const data = imageData.data;
+
+ // Calculate effect parameters
+ const strength = settings.flow; // Use flow as effect strength
+ const hardness = settings.hardness;
+ const centerX = sw / 2;
+ const centerY = sh / 2;
+ const maxRadius = Math.min(centerX, centerY);
+
+ // Apply effect to each pixel
+ for (let py = 0; py < sh; py++) {
+ for (let px = 0; px < sw; px++) {
+ const i = (py * sw + px) * 4;
+
+ // Calculate distance from center for soft brush effect
+ const dx = px - centerX;
+ const dy = py - centerY;
+ const distFromCenter = Math.sqrt(dx * dx + dy * dy);
+ const normalizedDist = Math.min(distFromCenter / maxRadius, 1);
+
+ // Apply hardness to create soft/hard brush
+ let falloff = 1;
+ if (normalizedDist > hardness) {
+ falloff = 1 - (normalizedDist - hardness) / (1 - hardness);
+ falloff = Math.max(0, Math.min(1, falloff));
+ }
+
+ const effectiveStrength = strength * falloff;
+
+ // Get current RGB values
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+
+ if (this.mode === 'dodge') {
+ // Dodge: Lighten the image
+ // Formula: result = color + (255 - color) * strength
+ data[i] = Math.min(255, r + (255 - r) * effectiveStrength);
+ data[i + 1] = Math.min(255, g + (255 - g) * effectiveStrength);
+ data[i + 2] = Math.min(255, b + (255 - b) * effectiveStrength);
+ } else {
+ // Burn: Darken the image
+ // Formula: result = color - color * strength
+ data[i] = Math.max(0, r - r * effectiveStrength);
+ data[i + 1] = Math.max(0, g - g * effectiveStrength);
+ data[i + 2] = Math.max(0, b - b * effectiveStrength);
+ }
+ // Alpha channel remains unchanged
+ }
+ }
+
+ // Put modified image data back
+ ctx.putImageData(imageData, sx, sy);
+ }
+
+ getCursor(settings: ToolSettings): string {
+ return 'crosshair';
+ }
+
+ /**
+ * Get current mode for UI display
+ */
+ getMode(): 'dodge' | 'burn' {
+ return this.mode;
+ }
+}
diff --git a/tools/index.ts b/tools/index.ts
index 9485c31..447f152 100644
--- a/tools/index.ts
+++ b/tools/index.ts
@@ -12,3 +12,6 @@ export * from './move-tool';
export * from './free-transform-tool';
export * from './shape-tool';
export * from './text-tool';
+export * from './clone-stamp-tool';
+export * from './smudge-tool';
+export * from './dodge-burn-tool';
diff --git a/tools/smudge-tool.ts b/tools/smudge-tool.ts
new file mode 100644
index 0000000..6f2a322
--- /dev/null
+++ b/tools/smudge-tool.ts
@@ -0,0 +1,214 @@
+import { BaseTool } from './base-tool';
+import type { PointerState, ToolSettings } from '@/types';
+import { distance } from '@/lib/utils';
+
+/**
+ * Smudge Tool - Smears and blends colors for paint-like effects
+ */
+export class SmudgeTool extends BaseTool {
+ private lastX = 0;
+ private lastY = 0;
+ private smudgeBuffer: ImageData | null = null;
+ private isFirstStroke = true;
+
+ constructor() {
+ super('Smudge');
+ }
+
+ onPointerDown(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ this.isDrawing = true;
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+ this.isFirstStroke = true;
+
+ // Sample initial area
+ this.sampleArea(pointer.x, pointer.y, ctx, settings);
+ }
+
+ onPointerMove(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ if (!this.isDrawing) return;
+
+ // Calculate distance from last point
+ const dist = distance(this.lastX, this.lastY, pointer.x, pointer.y);
+ const spacing = settings.size * settings.spacing * 0.5; // Closer spacing for smoother smudge
+
+ if (dist >= spacing) {
+ // Interpolate between points for smooth stroke
+ const steps = Math.ceil(dist / spacing);
+
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const x = this.lastX + (pointer.x - this.lastX) * t;
+ const y = this.lastY + (pointer.y - this.lastY) * t;
+
+ this.smudge(x, y, ctx, settings);
+ }
+
+ this.lastX = pointer.x;
+ this.lastY = pointer.y;
+ }
+ }
+
+ onPointerUp(
+ pointer: PointerState,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ this.isDrawing = false;
+ this.smudgeBuffer = null;
+ this.isFirstStroke = true;
+ }
+
+ /**
+ * Sample the area under the brush
+ */
+ private sampleArea(
+ x: number,
+ y: number,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ const size = Math.ceil(settings.size);
+ const halfSize = Math.floor(size / 2);
+
+ // Clamp to canvas bounds
+ const sx = Math.max(0, Math.floor(x - halfSize));
+ const sy = Math.max(0, Math.floor(y - halfSize));
+ const sw = Math.min(size, ctx.canvas.width - sx);
+ const sh = Math.min(size, ctx.canvas.height - sy);
+
+ if (sw > 0 && sh > 0) {
+ this.smudgeBuffer = ctx.getImageData(sx, sy, sw, sh);
+ }
+ }
+
+ /**
+ * Apply smudge effect
+ */
+ private smudge(
+ x: number,
+ y: number,
+ ctx: CanvasRenderingContext2D,
+ settings: ToolSettings
+ ): void {
+ if (!this.smudgeBuffer) {
+ this.sampleArea(x, y, ctx, settings);
+ return;
+ }
+
+ const size = Math.ceil(settings.size);
+ const halfSize = Math.floor(size / 2);
+
+ // Get current area
+ const sx = Math.max(0, Math.floor(x - halfSize));
+ const sy = Math.max(0, Math.floor(y - halfSize));
+ const sw = Math.min(size, ctx.canvas.width - sx);
+ const sh = Math.min(size, ctx.canvas.height - sy);
+
+ if (sw <= 0 || sh <= 0) return;
+
+ const currentArea = ctx.getImageData(sx, sy, sw, sh);
+
+ // Blend smudge buffer with current area
+ const strength = settings.flow; // Use flow as smudge strength
+ const blendedData = this.blendImageData(
+ this.smudgeBuffer,
+ currentArea,
+ strength,
+ settings
+ );
+
+ // Draw blended result
+ ctx.putImageData(blendedData, sx, sy);
+
+ // Update smudge buffer for next stroke (smear effect)
+ // Mix more of the current area into the buffer for progressive smudging
+ if (!this.isFirstStroke) {
+ this.smudgeBuffer = this.blendImageData(currentArea, this.smudgeBuffer, 0.3, settings);
+ } else {
+ this.smudgeBuffer = currentArea;
+ this.isFirstStroke = false;
+ }
+ }
+
+ /**
+ * Blend two ImageData objects
+ */
+ private blendImageData(
+ source: ImageData,
+ target: ImageData,
+ strength: number,
+ settings: ToolSettings
+ ): ImageData {
+ const result = new ImageData(target.width, target.height);
+ const srcData = source.data;
+ const tgtData = target.data;
+ const resData = result.data;
+
+ const hardness = settings.hardness;
+ const centerX = target.width / 2;
+ const centerY = target.height / 2;
+ const maxRadius = Math.min(centerX, centerY);
+
+ for (let i = 0; i < tgtData.length; i += 4) {
+ const pixelIndex = i / 4;
+ const px = pixelIndex % target.width;
+ const py = Math.floor(pixelIndex / target.width);
+
+ // Calculate distance from center for soft brush effect
+ const dx = px - centerX;
+ const dy = py - centerY;
+ const distFromCenter = Math.sqrt(dx * dx + dy * dy);
+ const normalizedDist = Math.min(distFromCenter / maxRadius, 1);
+
+ // Apply hardness to create soft/hard brush
+ let falloff = 1;
+ if (normalizedDist > hardness) {
+ falloff = 1 - (normalizedDist - hardness) / (1 - hardness);
+ falloff = Math.max(0, Math.min(1, falloff));
+ }
+
+ const effectiveStrength = strength * falloff;
+
+ // Ensure we don't go out of bounds
+ if (i < srcData.length) {
+ // Blend RGB channels
+ resData[i] = tgtData[i] * (1 - effectiveStrength) + srcData[i] * effectiveStrength;
+ resData[i + 1] =
+ tgtData[i + 1] * (1 - effectiveStrength) + srcData[i + 1] * effectiveStrength;
+ resData[i + 2] =
+ tgtData[i + 2] * (1 - effectiveStrength) + srcData[i + 2] * effectiveStrength;
+ resData[i + 3] = tgtData[i + 3]; // Preserve alpha
+ } else {
+ // If source is smaller, use target values
+ resData[i] = tgtData[i];
+ resData[i + 1] = tgtData[i + 1];
+ resData[i + 2] = tgtData[i + 2];
+ resData[i + 3] = tgtData[i + 3];
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Reset when tool is deactivated
+ */
+ onDeactivate(): void {
+ super.onDeactivate();
+ this.smudgeBuffer = null;
+ this.isFirstStroke = true;
+ }
+
+ getCursor(settings: ToolSettings): string {
+ return 'crosshair';
+ }
+}
diff --git a/types/tool.ts b/types/tool.ts
index 3aa9b09..d076cd7 100644
--- a/types/tool.ts
+++ b/types/tool.ts
@@ -15,6 +15,8 @@ export type ToolType =
| 'shape'
| 'crop'
| 'clone'
+ | 'smudge'
+ | 'dodge'
| 'blur'
| 'sharpen';