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>
215 lines
5.8 KiB
TypeScript
215 lines
5.8 KiB
TypeScript
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';
|
|
}
|
|
}
|