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