/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; export interface ImageCropData { file: File; title: string; aspectRatio: number; // width/height, e.g. 1 for square, 1.5 for 3:2 maxWidth: number; maxHeight: number; } @Component({ selector: 'mifosx-image-crop-dialog', templateUrl: './image-crop-dialog.component.html', styleUrls: ['./image-crop-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, MatDialogContent, MatDialogActions ] }) export class ImageCropDialogComponent implements AfterViewInit { @ViewChild('sourceCanvas') sourceCanvas!: ElementRef; @ViewChild('previewCanvas') previewCanvas!: ElementRef; imageLoaded = false; private img = new Image(); private imgWidth = 0; private imgHeight = 0; // Crop box (relative to displayed image) cropX = 0; cropY = 0; cropW = 200; cropH = 200; // Drag state private dragging = false; private dragStartX = 0; private dragStartY = 0; private dragStartCropX = 0; private dragStartCropY = 0; // Resize state private resizing = false; // Canvas display dimensions private canvasDisplayW = 0; private canvasDisplayH = 0; private scaleX = 1; private scaleY = 1; zoom = 1; dialogRef = inject(MatDialogRef); data = inject(MAT_DIALOG_DATA); ngAfterViewInit() { const reader = new FileReader(); reader.onload = (e: any) => { this.img.onload = () => { this.imageLoaded = true; setTimeout(() => this.initCanvas()); }; this.img.src = e.target.result; }; reader.readAsDataURL(this.data.file); } private initCanvas() { const canvas = this.sourceCanvas.nativeElement; const ctx = canvas.getContext('2d')!; const container = canvas.parentElement!; const maxW = container.clientWidth || 500; const maxH = 400; // Scale image to fit container const ratio = Math.min(maxW / this.img.width, maxH / this.img.height, 1); this.canvasDisplayW = Math.floor(this.img.width * ratio); this.canvasDisplayH = Math.floor(this.img.height * ratio); this.imgWidth = this.img.width; this.imgHeight = this.img.height; canvas.width = this.canvasDisplayW; canvas.height = this.canvasDisplayH; this.scaleX = this.img.width / this.canvasDisplayW; this.scaleY = this.img.height / this.canvasDisplayH; // Init crop box centered const cropSize = Math.min(this.canvasDisplayW, this.canvasDisplayH) * 0.6; if (this.data.aspectRatio >= 1) { this.cropW = cropSize; this.cropH = cropSize / this.data.aspectRatio; } else { this.cropH = cropSize; this.cropW = cropSize * this.data.aspectRatio; } this.cropX = (this.canvasDisplayW - this.cropW) / 2; this.cropY = (this.canvasDisplayH - this.cropH) / 2; this.drawCanvas(); this.updatePreview(); } private drawCanvas() { const canvas = this.sourceCanvas.nativeElement; const ctx = canvas.getContext('2d')!; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(this.img, 0, 0, canvas.width, canvas.height); // Dark overlay outside crop ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Clear crop area ctx.clearRect(this.cropX, this.cropY, this.cropW, this.cropH); ctx.drawImage( this.img, this.cropX * this.scaleX, this.cropY * this.scaleY, this.cropW * this.scaleX, this.cropH * this.scaleY, this.cropX, this.cropY, this.cropW, this.cropH ); // Crop border ctx.strokeStyle = '#1353a0'; ctx.lineWidth = 2; ctx.strokeRect(this.cropX, this.cropY, this.cropW, this.cropH); // Corner handles const hs = 8; ctx.fillStyle = '#1353a0'; const corners = [ [ this.cropX, this.cropY ], [ this.cropX + this.cropW, this.cropY ], [ this.cropX, this.cropY + this.cropH ], [ this.cropX + this.cropW, this.cropY + this.cropH ] ]; corners.forEach( ([ x, y ]) => { ctx.fillRect(x - hs / 2, y - hs / 2, hs, hs); } ); } private updatePreview() { if (!this.previewCanvas) return; const pCanvas = this.previewCanvas.nativeElement; const pCtx = pCanvas.getContext('2d')!; const pw = this.data.maxWidth || 150; const ph = this.data.maxHeight || 150; pCanvas.width = pw; pCanvas.height = ph; pCtx.clearRect(0, 0, pw, ph); pCtx.drawImage( this.img, this.cropX * this.scaleX, this.cropY * this.scaleY, this.cropW * this.scaleX, this.cropH * this.scaleY, 0, 0, pw, ph ); } onMouseDown(event: MouseEvent) { const rect = this.sourceCanvas.nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; // Check if near bottom-right corner for resize const cornerX = this.cropX + this.cropW; const cornerY = this.cropY + this.cropH; if (Math.abs(x - cornerX) < 12 && Math.abs(y - cornerY) < 12) { this.resizing = true; return; } // Check if inside crop box for drag if (x >= this.cropX && x <= this.cropX + this.cropW && y >= this.cropY && y <= this.cropY + this.cropH) { this.dragging = true; this.dragStartX = x; this.dragStartY = y; this.dragStartCropX = this.cropX; this.dragStartCropY = this.cropY; } } onMouseMove(event: MouseEvent) { if (!this.dragging && !this.resizing) return; const rect = this.sourceCanvas.nativeElement.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; if (this.dragging) { this.cropX = Math.max(0, Math.min(this.canvasDisplayW - this.cropW, this.dragStartCropX + (x - this.dragStartX))); this.cropY = Math.max(0, Math.min(this.canvasDisplayH - this.cropH, this.dragStartCropY + (y - this.dragStartY))); } if (this.resizing) { const newW = Math.max(40, Math.min(this.canvasDisplayW - this.cropX, x - this.cropX)); const newH = newW / this.data.aspectRatio; if (this.cropY + newH <= this.canvasDisplayH) { this.cropW = newW; this.cropH = newH; } } this.drawCanvas(); this.updatePreview(); } onMouseUp() { this.dragging = false; this.resizing = false; } confirm() { // Create cropped image as blob const offscreen = document.createElement('canvas'); offscreen.width = this.data.maxWidth || 300; offscreen.height = this.data.maxHeight || 300; const ctx = offscreen.getContext('2d')!; ctx.drawImage( this.img, this.cropX * this.scaleX, this.cropY * this.scaleY, this.cropW * this.scaleX, this.cropH * this.scaleY, 0, 0, offscreen.width, offscreen.height ); offscreen.toBlob((blob) => { if (blob) { const croppedFile = new File([blob], this.data.file.name, { type: 'image/png' }); this.dialogRef.close({ croppedFile, previewUrl: offscreen.toDataURL('image/png') }); } }, 'image/png'); } }