Files
paint-ui/tools/clone-stamp-tool.ts

204 lines
5.4 KiB
TypeScript
Raw Permalink Normal View History

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
}
}