| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  | import type { ProfileShape } from './dees-input-profilepicture.js'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export interface CropperOptions { | 
					
						
							|  |  |  |   container: HTMLElement; | 
					
						
							|  |  |  |   image: string; | 
					
						
							|  |  |  |   shape: ProfileShape; | 
					
						
							|  |  |  |   aspectRatio: number; | 
					
						
							|  |  |  |   minSize?: number; | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |   outputSize?: number; | 
					
						
							|  |  |  |   outputQuality?: number; | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 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, | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |       outputSize: 800, // Higher default resolution
 | 
					
						
							|  |  |  |       outputQuality: 0.95, // Higher quality
 | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |       ...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); | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // Calculate image scale to fit within container (not fill)
 | 
					
						
							|  |  |  |     const scale = Math.min( | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |       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
 | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // 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
 | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     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; | 
					
						
							|  |  |  |        | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |       // 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)); | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |        | 
					
						
							|  |  |  |       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; | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // 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; | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     switch (this.resizeHandle) { | 
					
						
							|  |  |  |       case 'se': | 
					
						
							|  |  |  |         this.cropSize = Math.max(this.minCropSize, Math.min( | 
					
						
							|  |  |  |           this.cropSize + Math.max(dx, dy), | 
					
						
							|  |  |  |           Math.min( | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |             imgRight - this.cropX, | 
					
						
							|  |  |  |             imgBottom - this.cropY | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |           ) | 
					
						
							|  |  |  |         )); | 
					
						
							|  |  |  |         break; | 
					
						
							|  |  |  |       case 'nw': | 
					
						
							|  |  |  |         const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy)); | 
					
						
							|  |  |  |         const sizeDiff = this.cropSize - newSize; | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |         const newX = this.cropX + sizeDiff; | 
					
						
							|  |  |  |         const newY = this.cropY + sizeDiff; | 
					
						
							|  |  |  |         if (newX >= imgLeft && newY >= imgTop) { | 
					
						
							|  |  |  |           this.cropX = newX; | 
					
						
							|  |  |  |           this.cropY = newY; | 
					
						
							|  |  |  |           this.cropSize = newSize; | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |         break; | 
					
						
							|  |  |  |       case 'ne': | 
					
						
							|  |  |  |         const neSizeDx = Math.max(dx, -dy); | 
					
						
							|  |  |  |         const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx); | 
					
						
							|  |  |  |         const neSizeDiff = neNewSize - this.cropSize; | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |         const neNewY = this.cropY - neSizeDiff; | 
					
						
							|  |  |  |         if (neNewY >= imgTop && this.cropX + neNewSize <= imgRight) { | 
					
						
							|  |  |  |           this.cropY = neNewY; | 
					
						
							|  |  |  |           this.cropSize = neNewSize; | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |         break; | 
					
						
							|  |  |  |       case 'sw': | 
					
						
							|  |  |  |         const swSizeDx = Math.max(-dx, dy); | 
					
						
							|  |  |  |         const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx); | 
					
						
							|  |  |  |         const swSizeDiff = swNewSize - this.cropSize; | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |         const swNewX = this.cropX - swSizeDiff; | 
					
						
							|  |  |  |         if (swNewX >= imgLeft && this.cropY + swNewSize <= imgBottom) { | 
					
						
							|  |  |  |           this.cropX = swNewX; | 
					
						
							|  |  |  |           this.cropSize = swNewSize; | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |         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); | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // Fill background
 | 
					
						
							|  |  |  |     this.ctx.fillStyle = '#000000'; | 
					
						
							|  |  |  |     this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     // Draw image
 | 
					
						
							|  |  |  |     this.ctx.drawImage( | 
					
						
							|  |  |  |       this.img, | 
					
						
							|  |  |  |       this.imageOffsetX, | 
					
						
							|  |  |  |       this.imageOffsetY, | 
					
						
							|  |  |  |       this.img.width * this.imageScale, | 
					
						
							|  |  |  |       this.img.height * this.imageScale | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // Draw overlay only over the image area
 | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)'; | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     this.overlayCtx.fillRect( | 
					
						
							|  |  |  |       this.imageOffsetX, | 
					
						
							|  |  |  |       this.imageOffsetY, | 
					
						
							|  |  |  |       this.img.width * this.imageScale, | 
					
						
							|  |  |  |       this.img.height * this.imageScale | 
					
						
							|  |  |  |     ); | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |      | 
					
						
							|  |  |  |     // 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')!; | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // 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`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     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(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // Enable image smoothing for quality
 | 
					
						
							|  |  |  |     cropCtx.imageSmoothingEnabled = true; | 
					
						
							|  |  |  |     cropCtx.imageSmoothingQuality = 'high'; | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |     // Draw cropped image
 | 
					
						
							|  |  |  |     cropCtx.drawImage( | 
					
						
							|  |  |  |       this.img, | 
					
						
							|  |  |  |       sx, sy, sSize, sSize, | 
					
						
							|  |  |  |       0, 0, outputSize, outputSize | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |      | 
					
						
							| 
									
										
										
										
											2025-06-30 12:02:02 +00:00
										 |  |  |     // 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); | 
					
						
							| 
									
										
										
										
											2025-06-30 11:35:38 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   destroy(): void { | 
					
						
							|  |  |  |     this.canvas.remove(); | 
					
						
							|  |  |  |     this.overlayCanvas.remove(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } |