377 lines
9.9 KiB
TypeScript
377 lines
9.9 KiB
TypeScript
|
|
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 overlayCanvas: HTMLCanvasElement | null = null;
|
||
|
|
private handleSize = 8;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super('Crop');
|
||
|
|
}
|
||
|
|
|
||
|
|
onActivate(): void {
|
||
|
|
// Initialize crop rect to full canvas size when activated
|
||
|
|
if (this.overlayCanvas) {
|
||
|
|
this.cropRect = {
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
width: this.overlayCanvas.width,
|
||
|
|
height: this.overlayCanvas.height,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onPointerDown(
|
||
|
|
pointer: PointerState,
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
settings: ToolSettings
|
||
|
|
): void {
|
||
|
|
// Create overlay canvas if not exists
|
||
|
|
if (!this.overlayCanvas) {
|
||
|
|
this.overlayCanvas = document.createElement('canvas');
|
||
|
|
this.overlayCanvas.width = ctx.canvas.width;
|
||
|
|
this.overlayCanvas.height = ctx.canvas.height;
|
||
|
|
this.cropRect = {
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
width: ctx.canvas.width,
|
||
|
|
height: ctx.canvas.height,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 {
|
||
|
|
if (!this.overlayCanvas) return;
|
||
|
|
|
||
|
|
const overlayCtx = this.overlayCanvas.getContext('2d');
|
||
|
|
if (!overlayCtx) return;
|
||
|
|
|
||
|
|
// Clear overlay
|
||
|
|
overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||
|
|
|
||
|
|
// Draw darkened areas outside crop rect
|
||
|
|
overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||
|
|
|
||
|
|
// Top
|
||
|
|
overlayCtx.fillRect(0, 0, this.overlayCanvas.width, this.cropRect.y);
|
||
|
|
|
||
|
|
// Bottom
|
||
|
|
overlayCtx.fillRect(
|
||
|
|
0,
|
||
|
|
this.cropRect.y + this.cropRect.height,
|
||
|
|
this.overlayCanvas.width,
|
||
|
|
this.overlayCanvas.height - (this.cropRect.y + this.cropRect.height)
|
||
|
|
);
|
||
|
|
|
||
|
|
// Left
|
||
|
|
overlayCtx.fillRect(
|
||
|
|
0,
|
||
|
|
this.cropRect.y,
|
||
|
|
this.cropRect.x,
|
||
|
|
this.cropRect.height
|
||
|
|
);
|
||
|
|
|
||
|
|
// Right
|
||
|
|
overlayCtx.fillRect(
|
||
|
|
this.cropRect.x + this.cropRect.width,
|
||
|
|
this.cropRect.y,
|
||
|
|
this.overlayCanvas.width - (this.cropRect.x + this.cropRect.width),
|
||
|
|
this.cropRect.height
|
||
|
|
);
|
||
|
|
|
||
|
|
// Draw crop rect border
|
||
|
|
overlayCtx.strokeStyle = '#ffffff';
|
||
|
|
overlayCtx.lineWidth = 2;
|
||
|
|
overlayCtx.strokeRect(
|
||
|
|
this.cropRect.x,
|
||
|
|
this.cropRect.y,
|
||
|
|
this.cropRect.width,
|
||
|
|
this.cropRect.height
|
||
|
|
);
|
||
|
|
|
||
|
|
// Draw rule of thirds grid
|
||
|
|
overlayCtx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||
|
|
overlayCtx.lineWidth = 1;
|
||
|
|
|
||
|
|
// Vertical lines
|
||
|
|
overlayCtx.beginPath();
|
||
|
|
overlayCtx.moveTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y);
|
||
|
|
overlayCtx.lineTo(this.cropRect.x + this.cropRect.width / 3, this.cropRect.y + this.cropRect.height);
|
||
|
|
overlayCtx.moveTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y);
|
||
|
|
overlayCtx.lineTo(this.cropRect.x + (this.cropRect.width * 2) / 3, this.cropRect.y + this.cropRect.height);
|
||
|
|
overlayCtx.stroke();
|
||
|
|
|
||
|
|
// Horizontal lines
|
||
|
|
overlayCtx.beginPath();
|
||
|
|
overlayCtx.moveTo(this.cropRect.x, this.cropRect.y + this.cropRect.height / 3);
|
||
|
|
overlayCtx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + this.cropRect.height / 3);
|
||
|
|
overlayCtx.moveTo(this.cropRect.x, this.cropRect.y + (this.cropRect.height * 2) / 3);
|
||
|
|
overlayCtx.lineTo(this.cropRect.x + this.cropRect.width, this.cropRect.y + (this.cropRect.height * 2) / 3);
|
||
|
|
overlayCtx.stroke();
|
||
|
|
|
||
|
|
// Draw resize handles
|
||
|
|
const handles = this.getHandlePositions();
|
||
|
|
overlayCtx.fillStyle = '#ffffff';
|
||
|
|
overlayCtx.strokeStyle = '#000000';
|
||
|
|
overlayCtx.lineWidth = 1;
|
||
|
|
|
||
|
|
for (const pos of Object.values(handles)) {
|
||
|
|
overlayCtx.fillRect(
|
||
|
|
pos.x - this.handleSize / 2,
|
||
|
|
pos.y - this.handleSize / 2,
|
||
|
|
this.handleSize,
|
||
|
|
this.handleSize
|
||
|
|
);
|
||
|
|
overlayCtx.strokeRect(
|
||
|
|
pos.x - this.handleSize / 2,
|
||
|
|
pos.y - this.handleSize / 2,
|
||
|
|
this.handleSize,
|
||
|
|
this.handleSize
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Draw overlay on main canvas
|
||
|
|
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||
|
|
ctx.putImageData(imageData, 0, 0);
|
||
|
|
ctx.drawImage(this.overlayCanvas, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
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.overlayCanvas = null;
|
||
|
|
}
|
||
|
|
}
|