From 7f4d574c64fe60facc6d9a2ef4b9fb4c53435cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 16:27:02 +0100 Subject: [PATCH] feat(tools): implement advanced brush tools - Clone Stamp, Smudge, and Dodge/Burn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added three professional-grade image manipulation tools to complete Feature 4: Clone Stamp Tool (Shortcut: 8) - Sample from source location with Alt+Click - Paint sampled content to destination - Maintains relative offset for natural cloning - Supports soft/hard brush with hardness setting Smudge Tool (Shortcut: 9) - Creates realistic paint-smearing effects - Progressively blends colors for natural smudging - Uses flow setting to control smudge strength - Soft brush falloff for smooth blending Dodge/Burn Tool (Shortcut: 0) - Dodge mode: Lightens image areas (default) - Burn mode: Darkens image areas (Alt key) - Professional photography exposure adjustment - Respects hardness setting for precise control All tools: - Fully integrated with tool palette and keyboard shortcuts - Support smooth interpolation for fluid strokes - Use existing tool settings (size, opacity, hardness, flow, spacing) - Lazy-loaded via code splitting system - Icons from Lucide React (Stamp, Droplet, Sun) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/tools/tool-palette.tsx | 6 + hooks/use-keyboard-shortcuts.ts | 3 + lib/tool-loader.ts | 15 +++ tools/clone-stamp-tool.ts | 203 ++++++++++++++++++++++++++++ tools/dodge-burn-tool.ts | 160 ++++++++++++++++++++++ tools/index.ts | 3 + tools/smudge-tool.ts | 214 ++++++++++++++++++++++++++++++ types/tool.ts | 2 + 8 files changed, 606 insertions(+) create mode 100644 tools/clone-stamp-tool.ts create mode 100644 tools/dodge-burn-tool.ts create mode 100644 tools/smudge-tool.ts 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';