Compare commits

...

4 Commits

Author SHA1 Message Date
Moyasee
f0f272c162 fix: handling exceptions 2025-11-04 22:25:32 +02:00
Moyasee
363e52cdb6 fix: duplication 2025-11-04 22:22:02 +02:00
Moyasee
e04a94d10d fix: duplacation and formatting 2025-11-04 22:10:07 +02:00
Moyasee
6733a3e5b0 feat: ability to crop/resize picture before applying 2025-11-04 21:50:14 +02:00
8 changed files with 2449 additions and 1377 deletions

View File

@@ -677,6 +677,18 @@
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
"crop_profile_image": "Crop Profile Image",
"crop_background_image": "Crop Background Image",
"crop": "Crop",
"cropping": "Cropping…",
"crop_area": "Crop area",
"resize_handle_nw": "Resize handle - northwest corner",
"resize_handle_ne": "Resize handle - northeast corner",
"resize_handle_sw": "Resize handle - southwest corner",
"resize_handle_se": "Resize handle - southeast corner",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"image_crop_failure": "Failed to crop image. Please try again.",
"stats": "Stats",
"achievements": "achievements",
"games": "Games",

View File

@@ -0,0 +1,107 @@
@use "../../scss/globals.scss";
.image-cropper {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
min-height: 500px;
max-height: 600px;
&__container {
flex: 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background-color: transparent;
border-radius: 4px;
position: relative;
min-height: 400px;
}
&__image-wrapper {
position: relative;
display: inline-block;
}
&__image {
display: block;
user-select: none;
pointer-events: none;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
&__crop-overlay {
position: absolute;
border: 2px solid globals.$brand-teal;
cursor: move;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
background-color: transparent;
&--circular {
border-radius: 50%;
}
}
&__crop-handle {
position: absolute;
width: 12px;
height: 12px;
background-color: globals.$brand-teal;
border: 2px solid globals.$background-color;
border-radius: 2px;
cursor: nwse-resize;
&--nw {
top: -6px;
left: -6px;
cursor: nwse-resize;
}
&--ne {
top: -6px;
right: -6px;
cursor: nesw-resize;
}
&--sw {
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
&--se {
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
}
&__controls {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__zoom-controls {
display: flex;
align-items: center;
justify-content: center;
gap: calc(globals.$spacing-unit * 2);
}
&__zoom-value {
min-width: 60px;
text-align: center;
color: globals.$body-color;
}
&__actions {
display: flex;
gap: calc(globals.$spacing-unit * 2);
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,730 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../button/button";
import { useTranslation } from "react-i18next";
import { logger } from "@renderer/logger";
import "./image-cropper.scss";
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
export interface ImageCropperProps {
imagePath: string;
onCrop: (cropArea: CropArea) => Promise<void>;
onCancel: () => void;
aspectRatio?: number;
circular?: boolean;
minCropSize?: number;
}
export function ImageCropper({
imagePath,
onCrop,
onCancel,
aspectRatio,
circular = false,
minCropSize = 50,
}: Readonly<ImageCropperProps>) {
const { t } = useTranslation("user_profile");
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const zoom = 0.5;
const [cropArea, setCropArea] = useState<CropArea>({
x: 0,
y: 0,
width: 0,
height: 0,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeHandle, setResizeHandle] = useState<string | null>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [cropStart, setCropStart] = useState<CropArea | null>(null);
const [isCropping, setIsCropping] = useState(false);
const getImageSrc = () => {
return imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`;
};
const calculateContainerBounds = useCallback(() => {
const container = containerRef.current;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return null;
const computedStyle = globalThis.getComputedStyle(container);
const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0;
return {
maxWidth: containerRect.width - paddingLeft - paddingRight,
maxHeight: containerRect.height - paddingTop - paddingBottom,
};
}, []);
const calculateDisplayDimensions = useCallback(
(bounds: { maxWidth: number; maxHeight: number } | null) => {
if (!imageLoaded) return { width: 0, height: 0 };
if (!bounds) {
return {
width: imageSize.width * zoom,
height: imageSize.height * zoom,
};
}
const imageAspect = imageSize.width / imageSize.height;
let displayWidth = imageSize.width * zoom;
let displayHeight = imageSize.height * zoom;
if (displayWidth > bounds.maxWidth) {
displayWidth = bounds.maxWidth;
displayHeight = displayWidth / imageAspect;
}
if (displayHeight > bounds.maxHeight) {
displayHeight = bounds.maxHeight;
displayWidth = displayHeight * imageAspect;
}
return { width: displayWidth, height: displayHeight };
},
[imageLoaded, imageSize]
);
const calculateInitialCropArea = useCallback(() => {
const bounds = calculateContainerBounds();
if (!bounds || bounds.maxWidth <= 0 || bounds.maxHeight <= 0) return;
const { width: displayWidth, height: displayHeight } =
calculateDisplayDimensions(bounds);
const effectiveAspectRatio = circular ? 1 : aspectRatio;
let cropWidth: number;
let cropHeight: number;
if (effectiveAspectRatio) {
const displayAspect = displayWidth / displayHeight;
if (displayAspect > effectiveAspectRatio) {
cropHeight = displayHeight * 0.8;
cropWidth = cropHeight * effectiveAspectRatio;
} else {
cropWidth = displayWidth * 0.8;
cropHeight = cropWidth / effectiveAspectRatio;
}
} else {
const cropSize = Math.min(displayWidth, displayHeight) * 0.8;
cropWidth = cropSize;
cropHeight = cropSize;
}
setCropArea({
x: (displayWidth - cropWidth) / 2,
y: (displayHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
});
}, [
calculateContainerBounds,
calculateDisplayDimensions,
aspectRatio,
circular,
]);
const getDisplaySize = useCallback(() => {
const bounds = calculateContainerBounds();
return calculateDisplayDimensions(bounds);
}, [calculateContainerBounds, calculateDisplayDimensions]);
useEffect(() => {
const img = new Image();
const handleImageLoad = () => {
setImageSize({ width: img.width, height: img.height });
setImageLoaded(true);
};
const handleImageError = (error: Event | string) => {
logger.error("Failed to load image:", { src: getImageSrc(), error });
};
img.onload = handleImageLoad;
img.onerror = handleImageError;
img.src = getImageSrc();
}, [imagePath]);
useEffect(() => {
if (!imageLoaded || imageSize.width === 0 || imageSize.height === 0) return;
const performDoubleAnimationFrame = () => {
calculateInitialCropArea();
};
const handleAnimationFrame = () => {
requestAnimationFrame(performDoubleAnimationFrame);
};
const calculateWithDelay = () => {
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return;
requestAnimationFrame(handleAnimationFrame);
};
const handleResize = () => {
calculateWithDelay();
};
const timeoutId = setTimeout(calculateWithDelay, 200);
const resizeObserver = new ResizeObserver(handleResize);
const container = containerRef.current;
if (container) {
resizeObserver.observe(container);
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [imageLoaded, imageSize, calculateInitialCropArea]);
const getRealCropArea = (): CropArea => {
if (!imageLoaded || imageSize.width === 0 || imageSize.height === 0) {
return cropArea;
}
const displaySize = getDisplaySize();
if (displaySize.width === 0 || displaySize.height === 0) {
return cropArea;
}
const scaleX = imageSize.width / displaySize.width;
const scaleY = imageSize.height / displaySize.height;
return {
x: cropArea.x * scaleX,
y: cropArea.y * scaleY,
width: cropArea.width * scaleX,
height: cropArea.height * scaleY,
};
};
const enforceAspectRatio = (
width: number,
height: number,
ratio: number
): { width: number; height: number } => {
const currentRatio = width / height;
if (currentRatio > ratio) {
return { width, height: width / ratio };
}
return { width: height * ratio, height };
};
const calculateMinSize = useCallback(
(effectiveAspectRatio: number | undefined) => {
if (!effectiveAspectRatio) return minCropSize;
return effectiveAspectRatio > 1
? minCropSize * effectiveAspectRatio
: minCropSize / effectiveAspectRatio;
},
[minCropSize]
);
const getImageWrapperBounds = useCallback(() => {
const imageWrapper = imageRef.current?.parentElement;
if (!imageWrapper) return { width: 0, height: 0 };
const rect = imageWrapper.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}, []);
const constrainCropArea = useCallback(
(area: CropArea): CropArea => {
const displaySize = getDisplaySize();
const wrapperBounds = getImageWrapperBounds();
const actualBounds = {
width:
wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width,
height:
wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height,
};
let { x, y, width, height } = area;
const effectiveAspectRatio = circular ? 1 : aspectRatio;
const minSize = calculateMinSize(effectiveAspectRatio);
if (effectiveAspectRatio) {
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
const maxWidth = Math.min(
actualBounds.width,
actualBounds.height * effectiveAspectRatio
);
const maxHeight = Math.min(
actualBounds.height,
actualBounds.width / effectiveAspectRatio
);
width = Math.max(minSize, Math.min(width, maxWidth));
height = Math.max(minSize, Math.min(height, maxHeight));
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
width = Math.min(width, actualBounds.width);
height = Math.min(height, actualBounds.height);
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
} else {
width = Math.max(minSize, Math.min(width, actualBounds.width));
height = Math.max(minSize, Math.min(height, actualBounds.height));
}
x = Math.max(0, Math.min(x, actualBounds.width - width));
y = Math.max(0, Math.min(y, actualBounds.height - height));
return { x, y, width, height };
},
[
getDisplaySize,
getImageWrapperBounds,
circular,
aspectRatio,
calculateMinSize,
]
);
const getRelativeCoordinates = (
e: MouseEvent | React.MouseEvent
): { x: number; y: number } | null => {
const imageWrapper = imageRef.current?.parentElement;
if (!imageWrapper) return null;
const rect = imageWrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
return { x, y };
};
const handleDrag = useCallback(
(coords: { x: number; y: number }) => {
const newX = coords.x - dragStart.x;
const newY = coords.y - dragStart.y;
setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY }));
},
[dragStart, constrainCropArea]
);
const calculateResizeDeltas = (
resizeHandle: string,
coords: { x: number; y: number },
cropStart: CropArea
) => {
const handleMap: Record<
string,
(
coords: { x: number; y: number },
cropStart: CropArea
) => {
deltaX: number;
deltaY: number;
newX: number;
newY: number;
}
> = {
"resize-se": (c, cs) => ({
deltaX: c.x - (cs.x + cs.width),
deltaY: c.y - (cs.y + cs.height),
newX: cs.x,
newY: cs.y,
}),
"resize-sw": (c, cs) => ({
deltaX: cs.x - c.x,
deltaY: c.y - (cs.y + cs.height),
newX: c.x,
newY: cs.y,
}),
"resize-ne": (c, cs) => ({
deltaX: c.x - (cs.x + cs.width),
deltaY: cs.y - c.y,
newX: cs.x,
newY: c.y,
}),
"resize-nw": (c, cs) => ({
deltaX: cs.x - c.x,
deltaY: cs.y - c.y,
newX: c.x,
newY: c.y,
}),
};
const handler = handleMap[resizeHandle];
if (handler) {
return handler(coords, cropStart);
}
return {
deltaX: 0,
deltaY: 0,
newX: cropStart.x,
newY: cropStart.y,
};
};
const adjustPositionForHandle = (
resizeHandle: string,
cropStart: CropArea,
adjustedWidth: number,
adjustedHeight: number
) => {
let adjustedX = cropStart.x;
let adjustedY = cropStart.y;
if (resizeHandle === "resize-nw" || resizeHandle === "resize-sw") {
adjustedX = cropStart.x + cropStart.width - adjustedWidth;
}
if (resizeHandle === "resize-nw" || resizeHandle === "resize-ne") {
adjustedY = cropStart.y + cropStart.height - adjustedHeight;
}
return { x: adjustedX, y: adjustedY };
};
const applyCircularConstraint = (
newWidth: number,
newHeight: number,
cropStart: CropArea,
resizeHandle: string
) => {
const size = Math.min(newWidth, newHeight);
const deltaSize = size - Math.min(cropStart.width, cropStart.height);
const adjustedWidth = cropStart.width + deltaSize;
const adjustedHeight = cropStart.height + deltaSize;
const { x, y } = adjustPositionForHandle(
resizeHandle,
cropStart,
adjustedWidth,
adjustedHeight
);
return { width: adjustedWidth, height: adjustedHeight, x, y };
};
const applyAspectRatioConstraint = (
newWidth: number,
newHeight: number,
cropStart: CropArea,
resizeHandle: string,
aspectRatio: number
) => {
const deltaX = Math.abs(newWidth - cropStart.width);
const deltaY = Math.abs(newHeight - cropStart.height);
let adjustedWidth: number;
let adjustedHeight: number;
if (deltaX > deltaY) {
adjustedWidth = newWidth;
adjustedHeight = newWidth / aspectRatio;
} else {
adjustedHeight = newHeight;
adjustedWidth = newHeight * aspectRatio;
}
const wrapperBounds = getImageWrapperBounds();
const displaySize = getDisplaySize();
const actualBounds = {
width: wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width,
height:
wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height,
};
const maxWidth = Math.min(
actualBounds.width,
actualBounds.height * aspectRatio
);
const maxHeight = Math.min(
actualBounds.height,
actualBounds.width / aspectRatio
);
adjustedWidth = Math.min(adjustedWidth, maxWidth);
adjustedHeight = Math.min(adjustedHeight, maxHeight);
const finalRatio = adjustedWidth / adjustedHeight;
if (Math.abs(finalRatio - aspectRatio) > 0.001) {
if (finalRatio > aspectRatio) {
adjustedHeight = adjustedWidth / aspectRatio;
} else {
adjustedWidth = adjustedHeight * aspectRatio;
}
}
const { x, y } = adjustPositionForHandle(
resizeHandle,
cropStart,
adjustedWidth,
adjustedHeight
);
return { width: adjustedWidth, height: adjustedHeight, x, y };
};
const handleResize = useCallback(
(coords: { x: number; y: number }) => {
if (!cropStart || !resizeHandle) return;
const { deltaX, deltaY, newX, newY } = calculateResizeDeltas(
resizeHandle,
coords,
cropStart
);
let adjustedWidth = cropStart.width + deltaX;
let adjustedHeight = cropStart.height + deltaY;
let adjustedX = newX;
let adjustedY = newY;
if (circular) {
const constrained = applyCircularConstraint(
adjustedWidth,
adjustedHeight,
cropStart,
resizeHandle
);
adjustedWidth = constrained.width;
adjustedHeight = constrained.height;
adjustedX = constrained.x;
adjustedY = constrained.y;
} else if (aspectRatio) {
const constrained = applyAspectRatioConstraint(
adjustedWidth,
adjustedHeight,
cropStart,
resizeHandle,
aspectRatio
);
adjustedWidth = constrained.width;
adjustedHeight = constrained.height;
adjustedX = constrained.x;
adjustedY = constrained.y;
}
const newCropArea = constrainCropArea({
x: adjustedX,
y: adjustedY,
width: adjustedWidth,
height: adjustedHeight,
});
setCropArea(newCropArea);
},
[cropStart, resizeHandle, circular, aspectRatio, constrainCropArea]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!imageLoaded) return;
e.preventDefault();
e.stopPropagation();
const coords = getRelativeCoordinates(e);
if (!coords) return;
const handle = (e.target as HTMLElement)?.dataset?.handle;
if (handle?.startsWith("resize-")) {
setIsResizing(true);
setResizeHandle(handle);
setCropStart(cropArea);
} else if (
coords.x >= cropArea.x &&
coords.x <= cropArea.x + cropArea.width &&
coords.y >= cropArea.y &&
coords.y <= cropArea.y + cropArea.height
) {
setIsDragging(true);
setDragStart({ x: coords.x - cropArea.x, y: coords.y - cropArea.y });
}
},
[imageLoaded, cropArea]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!imageLoaded) return;
const coords = getRelativeCoordinates(e);
if (!coords) return;
if (isDragging && cropStart === null) {
handleDrag(coords);
} else if (isResizing && cropStart && resizeHandle) {
handleResize(coords);
}
},
[
imageLoaded,
isDragging,
isResizing,
cropStart,
resizeHandle,
handleDrag,
handleResize,
]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeHandle(null);
setCropStart(null);
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
}
}, []);
const handleOverlayKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
const keyMap: Record<
string,
(area: CropArea) => { x: number; y: number }
> = {
ArrowLeft: (area) => ({ x: area.x - step, y: area.y }),
ArrowRight: (area) => ({ x: area.x + step, y: area.y }),
ArrowUp: (area) => ({ x: area.x, y: area.y - step }),
ArrowDown: (area) => ({ x: area.x, y: area.y + step }),
};
const handler = keyMap[e.key];
if (!handler) return;
e.preventDefault();
const { x: newX, y: newY } = handler(cropArea);
setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY }));
},
[cropArea, constrainCropArea]
);
useEffect(() => {
if (!isDragging && !isResizing) return;
const cleanup = () => {
globalThis.removeEventListener("mousemove", handleMouseMove);
globalThis.removeEventListener("mouseup", handleMouseUp);
};
globalThis.addEventListener("mousemove", handleMouseMove);
globalThis.addEventListener("mouseup", handleMouseUp);
return cleanup;
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleCrop = async () => {
setIsCropping(true);
try {
const realCropArea = getRealCropArea();
await onCrop(realCropArea);
} finally {
setIsCropping(false);
}
};
const displaySize = getDisplaySize();
return (
<div className="image-cropper">
<div className="image-cropper__container" ref={containerRef}>
<div
className="image-cropper__image-wrapper"
style={{
width: `${displaySize.width}px`,
height: `${displaySize.height}px`,
maxWidth: "100%",
maxHeight: "100%",
}}
>
{imageLoaded && (
<img
ref={imageRef}
src={getImageSrc()}
alt="Crop"
className="image-cropper__image"
style={{
width: `${displaySize.width}px`,
height: `${displaySize.height}px`,
maxWidth: "100%",
maxHeight: "100%",
}}
/>
)}
{imageLoaded && cropArea.width > 0 && cropArea.height > 0 && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<section
className={`image-cropper__crop-overlay ${circular ? "image-cropper__crop-overlay--circular" : ""}`}
style={{
left: `${cropArea.x}px`,
top: `${cropArea.y}px`,
width: `${cropArea.width}px`,
height: `${cropArea.height}px`,
}}
aria-label={t("crop_area")}
onMouseDown={handleMouseDown}
onKeyDown={handleOverlayKeyDown}
>
{(["nw", "ne", "sw", "se"] as const).map((position) => (
<button
key={position}
type="button"
className={`image-cropper__crop-handle image-cropper__crop-handle--${position}`}
data-handle={`resize-${position}`}
aria-label={t(`resize_handle_${position}`)}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
/>
))}
</section>
)}
</div>
</div>
<div className="image-cropper__controls">
<div className="image-cropper__actions">
<Button theme="outline" onClick={onCancel} disabled={isCropping}>
{t("cancel")}
</Button>
<Button
theme="primary"
onClick={handleCrop}
disabled={isCropping || !imageLoaded}
>
{isCropping ? t("cropping") : t("crop")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -19,3 +19,4 @@ export * from "./context-menu/context-menu";
export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";
export * from "./image-cropper/image-cropper";

View File

@@ -0,0 +1,158 @@
import type { CropArea } from "@renderer/components";
type DrawImageCallback = (
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
cropArea: CropArea
) => void;
const loadImageAndProcess = async (
imagePath: string,
cropArea: CropArea,
outputFormat: string,
drawCallback: DrawImageCallback
): Promise<Uint8Array> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Failed to get canvas context"));
return;
}
drawCallback(ctx, img, cropArea);
const convertBlobToUint8Array = async (
blob: Blob
): Promise<Uint8Array> => {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
};
const handleBlob = (blob: Blob | null) => {
if (!blob) {
reject(new Error("Failed to create blob from canvas"));
return;
}
convertBlobToUint8Array(blob).then(resolve).catch(reject);
};
canvas.toBlob(handleBlob, outputFormat, 0.95);
};
img.onerror = () => {
reject(new Error("Failed to load image"));
};
img.src = imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`;
});
};
const setCanvasDimensions = (
canvas: HTMLCanvasElement,
width: number,
height: number
): void => {
canvas.width = width;
canvas.height = height;
};
type DrawImageParams = {
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
destWidth: number;
destHeight: number;
};
const drawCroppedImage = (
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
params: DrawImageParams
): void => {
ctx.drawImage(
img,
params.sourceX,
params.sourceY,
params.sourceWidth,
params.sourceHeight,
0,
0,
params.destWidth,
params.destHeight
);
};
/**
* Crops an image using HTML5 Canvas API
* @param imagePath - Path to the image file
* @param cropArea - Crop area coordinates and dimensions
* @param outputFormat - Output image format (default: 'image/png')
* @returns Promise resolving to cropped image as Uint8Array
*/
export async function cropImage(
imagePath: string,
cropArea: CropArea,
outputFormat: string = "image/png"
): Promise<Uint8Array> {
return loadImageAndProcess(
imagePath,
cropArea,
outputFormat,
(ctx, img, area) => {
const canvas = ctx.canvas;
setCanvasDimensions(canvas, area.width, area.height);
drawCroppedImage(ctx, img, {
sourceX: area.x,
sourceY: area.y,
sourceWidth: area.width,
sourceHeight: area.height,
destWidth: area.width,
destHeight: area.height,
});
}
);
}
/**
* Crops an image to a circular shape
* @param imagePath - Path to the image file
* @param cropArea - Crop area coordinates and dimensions (should be square for circle)
* @param outputFormat - Output image format (default: 'image/png')
* @returns Promise resolving to cropped circular image as Uint8Array
*/
export async function cropImageToCircle(
imagePath: string,
cropArea: CropArea,
outputFormat: string = "image/png"
): Promise<Uint8Array> {
return loadImageAndProcess(
imagePath,
cropArea,
outputFormat,
(ctx, img, area) => {
const size = Math.min(area.width, area.height);
const canvas = ctx.canvas;
setCanvasDimensions(canvas, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
drawCroppedImage(ctx, img, {
sourceX: area.x,
sourceY: area.y,
sourceWidth: size,
sourceHeight: size,
destWidth: size,
destHeight: size,
});
}
);
}

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from "react";
import { useContext, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
@@ -10,8 +10,12 @@ import {
Modal,
ModalProps,
TextField,
ImageCropper,
CropArea,
} from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { cropImage } from "@renderer/helpers/image-cropper";
import { logger } from "@renderer/logger";
import { yupResolver } from "@hookform/resolvers/yup";
@@ -63,6 +67,66 @@ export function EditProfileModal(
const { showSuccessToast, showErrorToast } = useToast();
const [showCropper, setShowCropper] = useState(false);
const [selectedImagePath, setSelectedImagePath] = useState<string | null>(
null
);
const [onImageChange, setOnImageChange] = useState<
((value: string) => void) | null
>(null);
const handleCrop = async (cropArea: CropArea) => {
if (!selectedImagePath || !onImageChange) return;
try {
const imagePathForCrop = selectedImagePath.startsWith("local:")
? selectedImagePath.slice(6)
: selectedImagePath;
const imageData = await cropImage(
imagePathForCrop,
cropArea,
"image/png"
);
const tempFileName = `cropped-profile-${Date.now()}.png`;
const croppedPath = await window.electron.saveTempFile(
tempFileName,
imageData
);
if (!hasActiveSubscription) {
const { imagePath } = await window.electron
.processProfileImage(croppedPath)
.catch(() => {
showErrorToast(t("image_process_failure"));
return { imagePath: null };
});
if (imagePath) {
onImageChange(imagePath);
}
} else {
onImageChange(croppedPath);
}
setShowCropper(false);
setSelectedImagePath(null);
setOnImageChange(null);
} catch (error) {
logger.error("Failed to crop profile image:", error);
showErrorToast(t("image_crop_failure"));
setShowCropper(false);
setSelectedImagePath(null);
setOnImageChange(null);
}
};
const handleCancelCrop = () => {
setShowCropper(false);
setSelectedImagePath(null);
setOnImageChange(null);
};
const onSubmit = async (values: FormValues) => {
return patchUser(values)
.then(async () => {
@@ -99,19 +163,9 @@ export function EditProfileModal(
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
if (!hasActiveSubscription) {
const { imagePath } = await window.electron
.processProfileImage(path)
.catch(() => {
showErrorToast(t("image_process_failure"));
return { imagePath: null };
});
onChange(imagePath);
} else {
onChange(path);
}
setSelectedImagePath(path);
setOnImageChange(() => onChange);
setShowCropper(true);
}
};
@@ -155,6 +209,22 @@ export function EditProfileModal(
/>
</div>
{showCropper && selectedImagePath && (
<Modal
visible={showCropper}
title={t("crop_profile_image")}
onClose={handleCancelCrop}
large
>
<ImageCropper
imagePath={selectedImagePath}
onCrop={handleCrop}
onCancel={handleCancelCrop}
aspectRatio={1}
/>
</Modal>
)}
<small className="edit-profile-modal__hint">
<Trans i18nKey="privacy_hint" ns="user_profile">
<Link to="/settings" />

View File

@@ -1,14 +1,20 @@
import { UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { Button, Modal, ImageCropper, CropArea } from "@renderer/components";
import { useContext, useState } from "react";
import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { cropImage } from "@renderer/helpers/image-cropper";
import { logger } from "@renderer/logger";
import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false);
const [showCropper, setShowCropper] = useState(false);
const [selectedImagePath, setSelectedImagePath] = useState<string | null>(
null
);
const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile");
@@ -16,47 +22,100 @@ export function UploadBackgroundImageButton() {
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast();
const { showSuccessToast, showErrorToast } = useToast();
const handleChangeCoverClick = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setSelectedImagePath(path);
setShowCropper(true);
}
};
const handleCrop = async (cropArea: CropArea) => {
if (!selectedImagePath) return;
try {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
setIsUploadingBackgorundImage(true);
setShowCropper(false);
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
const imagePathForCrop = selectedImagePath.startsWith("local:")
? selectedImagePath.slice(6)
: selectedImagePath;
const imageData = await cropImage(
imagePathForCrop,
cropArea,
"image/png"
);
setSelectedBackgroundImage(path);
setIsUploadingBackgorundImage(true);
const tempFileName = `cropped-background-${Date.now()}.png`;
const croppedPath = await window.electron.saveTempFile(
tempFileName,
imageData
);
await patchUser({ backgroundImageUrl: path });
setSelectedBackgroundImage(croppedPath);
await patchUser({ backgroundImageUrl: croppedPath });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
}
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
setSelectedImagePath(null);
} catch (error) {
logger.error("Failed to crop background image:", error);
showErrorToast(t("image_crop_failure"));
setSelectedImagePath(null);
} finally {
setIsUploadingBackgorundImage(false);
}
};
const handleCancelCrop = () => {
setShowCropper(false);
setSelectedImagePath(null);
};
if (!isMe || !hasActiveSubscription) return null;
return (
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleChangeCoverClick}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")}
</Button>
<>
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleChangeCoverClick}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
{showCropper && selectedImagePath && (
<Modal
visible={showCropper}
title={t("crop_background_image")}
onClose={handleCancelCrop}
large
>
<ImageCropper
imagePath={selectedImagePath}
onCrop={handleCrop}
onCancel={handleCancelCrop}
aspectRatio={21 / 9}
/>
</Modal>
)}
</>
);
}

2605
yarn.lock

File diff suppressed because it is too large Load Diff