Adds an AvatarCropDialog step (react-easy-crop, cropShape="round") between file selection and the presigned-URL upload in AvatarUpload: the user crops/zooms a circular region, which is re-encoded via canvas to the original file's mime type before the existing upload flow runs. File-size/type validation moved from uploadFile into the new selectFile gate that opens the dialog. Gitea #13
55 lines
1.3 KiB
TypeScript
55 lines
1.3 KiB
TypeScript
import type { Area } from "react-easy-crop";
|
|
|
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.addEventListener("load", () => resolve(image));
|
|
image.addEventListener("error", reject);
|
|
image.crossOrigin = "anonymous";
|
|
image.src = url;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders the cropped region of an image onto an off-screen canvas and
|
|
* returns it as a Blob — the standard react-easy-crop output recipe.
|
|
*/
|
|
export async function getCroppedImageBlob(
|
|
imageSrc: string,
|
|
cropAreaPixels: Area,
|
|
mimeType: string
|
|
): Promise<Blob> {
|
|
const image = await loadImage(imageSrc);
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = cropAreaPixels.width;
|
|
canvas.height = cropAreaPixels.height;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
throw new Error("Canvas 2D context not available");
|
|
}
|
|
|
|
ctx.drawImage(
|
|
image,
|
|
cropAreaPixels.x,
|
|
cropAreaPixels.y,
|
|
cropAreaPixels.width,
|
|
cropAreaPixels.height,
|
|
0,
|
|
0,
|
|
cropAreaPixels.width,
|
|
cropAreaPixels.height
|
|
);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (blob) resolve(blob);
|
|
else reject(new Error("Canvas produced an empty blob"));
|
|
},
|
|
mimeType,
|
|
0.92
|
|
);
|
|
});
|
|
}
|