Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
7.7 KiB
TypeScript
279 lines
7.7 KiB
TypeScript
/**
|
|
* 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<HTMLCanvasElement>;
|
|
@ViewChild('previewCanvas') previewCanvas!: ElementRef<HTMLCanvasElement>;
|
|
|
|
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<ImageCropDialogComponent>);
|
|
data = inject<ImageCropData>(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');
|
|
}
|
|
}
|