456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
import type { ProfileShape } from './dees-input-profilepicture.js';
|
|
|
|
export interface CropperOptions {
|
|
container: HTMLElement;
|
|
image: string;
|
|
shape: ProfileShape;
|
|
aspectRatio: number;
|
|
minSize?: number;
|
|
outputSize?: number;
|
|
outputQuality?: number;
|
|
}
|
|
|
|
export class ImageCropper {
|
|
private options: CropperOptions;
|
|
private canvas: HTMLCanvasElement;
|
|
private ctx: CanvasRenderingContext2D;
|
|
private img: HTMLImageElement;
|
|
private overlayCanvas: HTMLCanvasElement;
|
|
private overlayCtx: CanvasRenderingContext2D;
|
|
|
|
// Crop area properties
|
|
private cropX: number = 0;
|
|
private cropY: number = 0;
|
|
private cropSize: number = 200;
|
|
private minCropSize: number = 50;
|
|
|
|
// Interaction state
|
|
private isDragging: boolean = false;
|
|
private isResizing: boolean = false;
|
|
private dragStartX: number = 0;
|
|
private dragStartY: number = 0;
|
|
private resizeHandle: string = '';
|
|
|
|
// Image properties
|
|
private imageScale: number = 1;
|
|
private imageOffsetX: number = 0;
|
|
private imageOffsetY: number = 0;
|
|
|
|
constructor(options: CropperOptions) {
|
|
this.options = {
|
|
minSize: 50,
|
|
outputSize: 800, // Higher default resolution
|
|
outputQuality: 0.95, // Higher quality
|
|
...options
|
|
};
|
|
|
|
this.canvas = document.createElement('canvas');
|
|
this.ctx = this.canvas.getContext('2d')!;
|
|
|
|
this.overlayCanvas = document.createElement('canvas');
|
|
this.overlayCtx = this.overlayCanvas.getContext('2d')!;
|
|
|
|
this.img = new Image();
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
// Load image
|
|
await this.loadImage();
|
|
|
|
// Setup canvases
|
|
this.setupCanvases();
|
|
|
|
// Setup event listeners
|
|
this.setupEventListeners();
|
|
|
|
// Initial render
|
|
this.render();
|
|
}
|
|
|
|
private async loadImage(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
this.img.onload = () => resolve();
|
|
this.img.onerror = reject;
|
|
this.img.src = this.options.image;
|
|
});
|
|
}
|
|
|
|
private setupCanvases(): void {
|
|
const container = this.options.container;
|
|
const containerSize = Math.min(container.clientWidth, container.clientHeight);
|
|
|
|
// Set canvas sizes
|
|
this.canvas.width = containerSize;
|
|
this.canvas.height = containerSize;
|
|
this.canvas.style.width = '100%';
|
|
this.canvas.style.height = '100%';
|
|
this.canvas.style.position = 'absolute';
|
|
this.canvas.style.top = '0';
|
|
this.canvas.style.left = '0';
|
|
|
|
this.overlayCanvas.width = containerSize;
|
|
this.overlayCanvas.height = containerSize;
|
|
this.overlayCanvas.style.width = '100%';
|
|
this.overlayCanvas.style.height = '100%';
|
|
this.overlayCanvas.style.position = 'absolute';
|
|
this.overlayCanvas.style.top = '0';
|
|
this.overlayCanvas.style.left = '0';
|
|
this.overlayCanvas.style.cursor = 'move';
|
|
|
|
container.appendChild(this.canvas);
|
|
container.appendChild(this.overlayCanvas);
|
|
|
|
// Calculate image scale to fit within container (not fill)
|
|
const scale = Math.min(
|
|
containerSize / this.img.width,
|
|
containerSize / this.img.height
|
|
);
|
|
|
|
this.imageScale = scale;
|
|
this.imageOffsetX = (containerSize - this.img.width * scale) / 2;
|
|
this.imageOffsetY = (containerSize - this.img.height * scale) / 2;
|
|
|
|
// Initialize crop area
|
|
// Make the crop area fit within the actual image bounds
|
|
const scaledImageWidth = this.img.width * scale;
|
|
const scaledImageHeight = this.img.height * scale;
|
|
const maxCropSize = Math.min(scaledImageWidth, scaledImageHeight, containerSize * 0.8);
|
|
|
|
this.cropSize = maxCropSize * 0.8; // Start at 80% of max possible size
|
|
this.cropX = (containerSize - this.cropSize) / 2;
|
|
this.cropY = (containerSize - this.cropSize) / 2;
|
|
}
|
|
|
|
private setupEventListeners(): void {
|
|
this.overlayCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
|
this.overlayCanvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
this.overlayCanvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
|
this.overlayCanvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
|
|
|
// Touch events
|
|
this.overlayCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
|
this.overlayCanvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
|
|
this.overlayCanvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
|
}
|
|
|
|
private handleMouseDown(e: MouseEvent): void {
|
|
const rect = this.overlayCanvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
|
|
|
const handle = this.getResizeHandle(x, y);
|
|
|
|
if (handle) {
|
|
this.isResizing = true;
|
|
this.resizeHandle = handle;
|
|
} else if (this.isInsideCropArea(x, y)) {
|
|
this.isDragging = true;
|
|
}
|
|
|
|
this.dragStartX = x;
|
|
this.dragStartY = y;
|
|
}
|
|
|
|
private handleMouseMove(e: MouseEvent): void {
|
|
const rect = this.overlayCanvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
|
|
|
// Update cursor
|
|
const handle = this.getResizeHandle(x, y);
|
|
if (handle) {
|
|
this.overlayCanvas.style.cursor = this.getResizeCursor(handle);
|
|
} else if (this.isInsideCropArea(x, y)) {
|
|
this.overlayCanvas.style.cursor = 'move';
|
|
} else {
|
|
this.overlayCanvas.style.cursor = 'default';
|
|
}
|
|
|
|
// Handle dragging
|
|
if (this.isDragging) {
|
|
const dx = x - this.dragStartX;
|
|
const dy = y - this.dragStartY;
|
|
|
|
// Constrain crop area to image bounds
|
|
const minX = this.imageOffsetX;
|
|
const maxX = this.imageOffsetX + this.img.width * this.imageScale - this.cropSize;
|
|
const minY = this.imageOffsetY;
|
|
const maxY = this.imageOffsetY + this.img.height * this.imageScale - this.cropSize;
|
|
|
|
this.cropX = Math.max(minX, Math.min(maxX, this.cropX + dx));
|
|
this.cropY = Math.max(minY, Math.min(maxY, this.cropY + dy));
|
|
|
|
this.dragStartX = x;
|
|
this.dragStartY = y;
|
|
this.render();
|
|
}
|
|
|
|
// Handle resizing
|
|
if (this.isResizing) {
|
|
this.handleResize(x, y);
|
|
this.dragStartX = x;
|
|
this.dragStartY = y;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private handleMouseUp(): void {
|
|
this.isDragging = false;
|
|
this.isResizing = false;
|
|
this.resizeHandle = '';
|
|
}
|
|
|
|
private handleTouchStart(e: TouchEvent): void {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
const mouseEvent = new MouseEvent('mousedown', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
});
|
|
this.handleMouseDown(mouseEvent);
|
|
}
|
|
|
|
private handleTouchMove(e: TouchEvent): void {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
const mouseEvent = new MouseEvent('mousemove', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
});
|
|
this.handleMouseMove(mouseEvent);
|
|
}
|
|
|
|
private handleTouchEnd(e: TouchEvent): void {
|
|
e.preventDefault();
|
|
this.handleMouseUp();
|
|
}
|
|
|
|
private getResizeHandle(x: number, y: number): string {
|
|
const handleSize = 20;
|
|
const handles = {
|
|
'nw': { x: this.cropX, y: this.cropY },
|
|
'ne': { x: this.cropX + this.cropSize, y: this.cropY },
|
|
'sw': { x: this.cropX, y: this.cropY + this.cropSize },
|
|
'se': { x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
|
};
|
|
|
|
for (const [key, pos] of Object.entries(handles)) {
|
|
if (Math.abs(x - pos.x) < handleSize && Math.abs(y - pos.y) < handleSize) {
|
|
return key;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private getResizeCursor(handle: string): string {
|
|
const cursors: Record<string, string> = {
|
|
'nw': 'nw-resize',
|
|
'ne': 'ne-resize',
|
|
'sw': 'sw-resize',
|
|
'se': 'se-resize'
|
|
};
|
|
return cursors[handle] || 'default';
|
|
}
|
|
|
|
private isInsideCropArea(x: number, y: number): boolean {
|
|
return x >= this.cropX && x <= this.cropX + this.cropSize &&
|
|
y >= this.cropY && y <= this.cropY + this.cropSize;
|
|
}
|
|
|
|
private handleResize(x: number, y: number): void {
|
|
const dx = x - this.dragStartX;
|
|
const dy = y - this.dragStartY;
|
|
|
|
// Get image bounds
|
|
const imgLeft = this.imageOffsetX;
|
|
const imgTop = this.imageOffsetY;
|
|
const imgRight = this.imageOffsetX + this.img.width * this.imageScale;
|
|
const imgBottom = this.imageOffsetY + this.img.height * this.imageScale;
|
|
|
|
switch (this.resizeHandle) {
|
|
case 'se':
|
|
this.cropSize = Math.max(this.minCropSize, Math.min(
|
|
this.cropSize + Math.max(dx, dy),
|
|
Math.min(
|
|
imgRight - this.cropX,
|
|
imgBottom - this.cropY
|
|
)
|
|
));
|
|
break;
|
|
case 'nw':
|
|
const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy));
|
|
const sizeDiff = this.cropSize - newSize;
|
|
const newX = this.cropX + sizeDiff;
|
|
const newY = this.cropY + sizeDiff;
|
|
if (newX >= imgLeft && newY >= imgTop) {
|
|
this.cropX = newX;
|
|
this.cropY = newY;
|
|
this.cropSize = newSize;
|
|
}
|
|
break;
|
|
case 'ne':
|
|
const neSizeDx = Math.max(dx, -dy);
|
|
const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx);
|
|
const neSizeDiff = neNewSize - this.cropSize;
|
|
const neNewY = this.cropY - neSizeDiff;
|
|
if (neNewY >= imgTop && this.cropX + neNewSize <= imgRight) {
|
|
this.cropY = neNewY;
|
|
this.cropSize = neNewSize;
|
|
}
|
|
break;
|
|
case 'sw':
|
|
const swSizeDx = Math.max(-dx, dy);
|
|
const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx);
|
|
const swSizeDiff = swNewSize - this.cropSize;
|
|
const swNewX = this.cropX - swSizeDiff;
|
|
if (swNewX >= imgLeft && this.cropY + swNewSize <= imgBottom) {
|
|
this.cropX = swNewX;
|
|
this.cropSize = swNewSize;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private render(): void {
|
|
// Clear canvases
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
|
|
|
// Fill background
|
|
this.ctx.fillStyle = '#000000';
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
// Draw image
|
|
this.ctx.drawImage(
|
|
this.img,
|
|
this.imageOffsetX,
|
|
this.imageOffsetY,
|
|
this.img.width * this.imageScale,
|
|
this.img.height * this.imageScale
|
|
);
|
|
|
|
// Draw overlay only over the image area
|
|
this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
this.overlayCtx.fillRect(
|
|
this.imageOffsetX,
|
|
this.imageOffsetY,
|
|
this.img.width * this.imageScale,
|
|
this.img.height * this.imageScale
|
|
);
|
|
|
|
// Clear crop area
|
|
this.overlayCtx.save();
|
|
|
|
if (this.options.shape === 'round') {
|
|
this.overlayCtx.beginPath();
|
|
this.overlayCtx.arc(
|
|
this.cropX + this.cropSize / 2,
|
|
this.cropY + this.cropSize / 2,
|
|
this.cropSize / 2,
|
|
0,
|
|
Math.PI * 2
|
|
);
|
|
this.overlayCtx.clip();
|
|
} else {
|
|
this.overlayCtx.beginPath();
|
|
this.overlayCtx.rect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
|
this.overlayCtx.clip();
|
|
}
|
|
|
|
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
|
this.overlayCtx.restore();
|
|
|
|
// Draw crop border
|
|
this.overlayCtx.strokeStyle = 'white';
|
|
this.overlayCtx.lineWidth = 2;
|
|
|
|
if (this.options.shape === 'round') {
|
|
this.overlayCtx.beginPath();
|
|
this.overlayCtx.arc(
|
|
this.cropX + this.cropSize / 2,
|
|
this.cropY + this.cropSize / 2,
|
|
this.cropSize / 2,
|
|
0,
|
|
Math.PI * 2
|
|
);
|
|
this.overlayCtx.stroke();
|
|
} else {
|
|
this.overlayCtx.strokeRect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
|
}
|
|
|
|
// Draw resize handles
|
|
this.drawResizeHandles();
|
|
}
|
|
|
|
private drawResizeHandles(): void {
|
|
const handleSize = 8;
|
|
const handles = [
|
|
{ x: this.cropX, y: this.cropY },
|
|
{ x: this.cropX + this.cropSize, y: this.cropY },
|
|
{ x: this.cropX, y: this.cropY + this.cropSize },
|
|
{ x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
|
];
|
|
|
|
this.overlayCtx.fillStyle = 'white';
|
|
|
|
handles.forEach(handle => {
|
|
this.overlayCtx.beginPath();
|
|
this.overlayCtx.arc(handle.x, handle.y, handleSize, 0, Math.PI * 2);
|
|
this.overlayCtx.fill();
|
|
});
|
|
}
|
|
|
|
async getCroppedImage(): Promise<string> {
|
|
const cropCanvas = document.createElement('canvas');
|
|
const cropCtx = cropCanvas.getContext('2d')!;
|
|
|
|
// Calculate the actual crop size in original image pixels
|
|
const scale = 1 / this.imageScale;
|
|
const originalCropSize = this.cropSize * scale;
|
|
|
|
// Use requested output size, but warn if upscaling
|
|
const outputSize = this.options.outputSize!;
|
|
|
|
if (outputSize > originalCropSize) {
|
|
console.info(`Profile picture: Upscaling from ${Math.round(originalCropSize)}px to ${outputSize}px`);
|
|
}
|
|
|
|
cropCanvas.width = outputSize;
|
|
cropCanvas.height = outputSize;
|
|
|
|
// Calculate source coordinates
|
|
const sx = (this.cropX - this.imageOffsetX) * scale;
|
|
const sy = (this.cropY - this.imageOffsetY) * scale;
|
|
const sSize = this.cropSize * scale;
|
|
|
|
// Apply shape mask if round
|
|
if (this.options.shape === 'round') {
|
|
cropCtx.beginPath();
|
|
cropCtx.arc(outputSize / 2, outputSize / 2, outputSize / 2, 0, Math.PI * 2);
|
|
cropCtx.clip();
|
|
}
|
|
|
|
// Enable image smoothing for quality
|
|
cropCtx.imageSmoothingEnabled = true;
|
|
cropCtx.imageSmoothingQuality = 'high';
|
|
|
|
// Draw cropped image
|
|
cropCtx.drawImage(
|
|
this.img,
|
|
sx, sy, sSize, sSize,
|
|
0, 0, outputSize, outputSize
|
|
);
|
|
|
|
// Detect format from original image
|
|
const isPng = this.options.image.includes('image/png');
|
|
const format = isPng ? 'image/png' : 'image/jpeg';
|
|
|
|
return cropCanvas.toDataURL(format, this.options.outputQuality);
|
|
}
|
|
|
|
destroy(): void {
|
|
this.canvas.remove();
|
|
this.overlayCanvas.remove();
|
|
}
|
|
} |