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 { // Load image await this.loadImage(); // Setup canvases this.setupCanvases(); // Setup event listeners this.setupEventListeners(); // Initial render this.render(); } private async loadImage(): Promise { 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 = { '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 { 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(); } }