Files
paint-ui/tools/crop-tool.ts
Sebastian Krüger c3ce440d48 fix: resolve crop tool visual feedback issue
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>
2025-11-21 20:46:59 +01:00

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