Simplified the crop tool overlay rendering to fix the issue where the crop area was not visible during dragging. Changes: - Removed overlayCanvas property and complex image preservation logic - Added initialized flag for lazy initialization on first use - Rewrote drawCropOverlay to use ctx.save()/restore() pattern - Draw overlay elements directly on canvas context (darkened areas, border, rule of thirds grid, and resize handles) The crop tool now properly displays the crop rectangle, handles, and overlay during all interactions (defining, dragging, and resizing). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
356 lines
8.9 KiB
TypeScript
356 lines
8.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 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;
|
|
}
|
|
}
|