feat(tools): implement advanced brush tools - Clone Stamp, Smudge, and Dodge/Burn
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 <noreply@anthropic.com>
This commit is contained in:
160
tools/dodge-burn-tool.ts
Normal file
160
tools/dodge-burn-tool.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user