Files
paint-ui/tools/crop-tool.ts

356 lines
8.9 KiB
TypeScript
Raw Normal View History

import { BaseTool } from './base-tool';
import type { PointerState, ToolSettings } from '@/types';
/**
* Crop tool - Select and crop canvas area
*/
export class CropTool extends BaseTool {
private cropRect = { x: 0, y: 0, width: 0, height: 0 };
private isDefiningCrop = false;
private isDraggingHandle = false;
private activeHandle: string | null = null;
private startX = 0;
private startY = 0;
private handleSize = 8;
private initialized = false;
constructor() {
super('Crop');
}
onPointerDown(
pointer: PointerState,
ctx: CanvasRenderingContext2D,
settings: ToolSettings
): void {
// Initialize crop rect to full canvas on first use
if (!this.initialized) {
this.cropRect = {
x: 0,
y: 0,
width: ctx.canvas.width,
height: ctx.canvas.height,
};
this.initialized = true;
this.drawCropOverlay(ctx);
}
// Check if clicking on a resize handle
const handle = this.getHandleAtPoint(pointer.x, pointer.y);
if (handle) {
this.isDraggingHandle = true;
this.activeHandle = handle;
this.startX = pointer.x;
this.startY = pointer.y;
return;
}
// Check if clicking inside crop rect to drag
if (this.isInsideCropRect(pointer.x, pointer.y)) {
this.isDrawing = true;
this.startX = pointer.x;
this.startY = pointer.y;
return;
}
// Start defining new crop rect
this.isDefiningCrop = true;
this.startX = pointer.x;
this.startY = pointer.y;
this.cropRect = { x: pointer.x, y: pointer.y, width: 0, height: 0 };
}
onPointerMove(
pointer: PointerState,
ctx: CanvasRenderingContext2D,
settings: ToolSettings
): void {
if (this.isDraggingHandle && this.activeHandle) {
this.resizeCropRect(pointer.x, pointer.y);
this.drawCropOverlay(ctx);
return;
}
if (this.isDrawing) {
// Move crop rect
const dx = pointer.x - this.startX;
const dy = pointer.y - this.startY;
this.cropRect.x = Math.max(0, Math.min(ctx.canvas.width - this.cropRect.width, this.cropRect.x + dx));
this.cropRect.y = Math.max(0, Math.min(ctx.canvas.height - this.cropRect.height, this.cropRect.y + dy));
this.startX = pointer.x;
this.startY = pointer.y;
this.drawCropOverlay(ctx);
return;
}
if (this.isDefiningCrop) {
// Update crop rect dimensions
const x = Math.min(this.startX, pointer.x);
const y = Math.min(this.startY, pointer.y);
const width = Math.abs(pointer.x - this.startX);
const height = Math.abs(pointer.y - this.startY);
this.cropRect = { x, y, width, height };
this.drawCropOverlay(ctx);
}
}
onPointerUp(
pointer: PointerState,
ctx: CanvasRenderingContext2D,
settings: ToolSettings
): void {
this.isDefiningCrop = false;
this.isDrawing = false;
this.isDraggingHandle = false;
this.activeHandle = null;
}
private isInsideCropRect(x: number, y: number): boolean {
return (
x >= this.cropRect.x &&
x <= this.cropRect.x + this.cropRect.width &&
y >= this.cropRect.y &&
y <= this.cropRect.y + this.cropRect.height
);
}
private getHandleAtPoint(x: number, y: number): string | null {
const handles = this.getHandlePositions();
const threshold = this.handleSize;
for (const [name, pos] of Object.entries(handles)) {
if (
x >= pos.x - threshold &&
x <= pos.x + threshold &&
y >= pos.y - threshold &&
y <= pos.y + threshold
) {
return name;
}
}
return null;
}
private getHandlePositions(): Record<string, { x: number; y: number }> {
const { x, y, width, height } = this.cropRect;
return {
nw: { x, y },
ne: { x: x + width, y },
sw: { x, y: y + height },
se: { x: x + width, y: y + height },
n: { x: x + width / 2, y },
s: { x: x + width / 2, y: y + height },
e: { x: x + width, y: y + height / 2 },
w: { x, y: y + height / 2 },
};
}
private resizeCropRect(x: number, y: number): void {
if (!this.activeHandle) return;
const { x: rx, y: ry, width: rw, height: rh } = this.cropRect;
switch (this.activeHandle) {
case 'nw':
this.cropRect = {
x: x,
y: y,
width: rw + (rx - x),
height: rh + (ry - y),
};
break;
case 'ne':
this.cropRect = {
x: rx,
y: y,
width: x - rx,
height: rh + (ry - y),
};
break;
case 'sw':
this.cropRect = {
x: x,
y: ry,
width: rw + (rx - x),
height: y - ry,
};
break;
case 'se':
this.cropRect = {
x: rx,
y: ry,
width: x - rx,
height: y - ry,
};
break;
case 'n':
this.cropRect = {
x: rx,
y: y,
width: rw,
height: rh + (ry - y),
};
break;
case 's':
this.cropRect = {
x: rx,
y: ry,
width: rw,
height: y - ry,
};
break;
case 'e':
this.cropRect = {
x: rx,
y: ry,
width: x - rx,
height: rh,
};
break;
case 'w':
this.cropRect = {
x: x,
y: ry,
width: rw + (rx - x),
height: rh,
};
break;
}
// Ensure minimum size
if (this.cropRect.width < 10) this.cropRect.width = 10;
if (this.cropRect.height < 10) this.cropRect.height = 10;
}
private drawCropOverlay(ctx: CanvasRenderingContext2D): void {
// Save context state
ctx.save();
// Draw darkened areas outside crop rect
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
// Top
ctx.fillRect(0, 0, ctx.canvas.width, this.cropRect.y);
// Bottom
ctx.fillRect(
0,
this.cropRect.y + this.cropRect.height,
ctx.canvas.width,
ctx.canvas.height - (this.cropRect.y + this.cropRect.height)
);
// Left
ctx.fillRect(
0,
this.cropRect.y,
this.cropRect.x,
this.cropRect.height
);
// Right
ctx.fillRect(
this.cropRect.x + this.cropRect.width,
this.cropRect.y,
ctx.canvas.width - (this.cropRect.x + this.cropRect.width),
this.cropRect.height
);
// Draw crop rect border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeRect(
this.cropRect.x,
this.cropRect.y,
this.cropRect.width,
this.cropRect.height
);
// Draw rule of thirds grid
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 1;
// Vertical lines
ctx.beginPath();
ctx.moveTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y);
ctx.lineTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y + this.cropRect.height);
ctx.moveTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y);
ctx.lineTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y + this.cropRect.height);
ctx.stroke();
// Horizontal lines
ctx.beginPath();
ctx.moveTo(this.cropRect.x, this.cropRect.y + this.cropRect.height / 3);
ctx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + this.cropRect.height / 3);
ctx.moveTo(this.cropRect.x, this.cropRect.y + (this.cropRect.height * 2) / 3);
ctx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + (this.cropRect.height * 2) / 3);
ctx.stroke();
// Draw resize handles
const handles = this.getHandlePositions();
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
for (const pos of Object.values(handles)) {
ctx.fillRect(
pos.x - this.handleSize / 2,
pos.y - this.handleSize / 2,
this.handleSize,
this.handleSize
);
ctx.strokeRect(
pos.x - this.handleSize / 2,
pos.y - this.handleSize / 2,
this.handleSize,
this.handleSize
);
}
// Restore context state
ctx.restore();
}
getCursor(): string {
return 'crosshair';
}
/**
* Get current crop rectangle
*/
getCropRect(): { x: number; y: number; width: number; height: number } {
return { ...this.cropRect };
}
/**
* Apply crop to canvas
*/
applyCrop(ctx: CanvasRenderingContext2D): void {
if (this.cropRect.width === 0 || this.cropRect.height === 0) return;
// Extract cropped region
const croppedData = ctx.getImageData(
this.cropRect.x,
this.cropRect.y,
this.cropRect.width,
this.cropRect.height
);
// Resize canvas
ctx.canvas.width = this.cropRect.width;
ctx.canvas.height = this.cropRect.height;
// Draw cropped data
ctx.putImageData(croppedData, 0, 0);
// Reset crop rect
this.cropRect = {
x: 0,
y: 0,
width: ctx.canvas.width,
height: ctx.canvas.height,
};
this.initialized = true;
}
}