mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-25 20:01:03 +00:00
Compare commits
4 Commits
main
...
feat/resiz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f272c162 | ||
|
|
363e52cdb6 | ||
|
|
e04a94d10d | ||
|
|
6733a3e5b0 |
@@ -677,6 +677,18 @@
|
|||||||
"upload_banner": "Upload banner",
|
"upload_banner": "Upload banner",
|
||||||
"uploading_banner": "Uploading banner…",
|
"uploading_banner": "Uploading banner…",
|
||||||
"background_image_updated": "Background image updated",
|
"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",
|
"stats": "Stats",
|
||||||
"achievements": "achievements",
|
"achievements": "achievements",
|
||||||
"games": "Games",
|
"games": "Games",
|
||||||
|
|||||||
107
src/renderer/src/components/image-cropper/image-cropper.scss
Normal file
107
src/renderer/src/components/image-cropper/image-cropper.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
730
src/renderer/src/components/image-cropper/image-cropper.tsx
Normal file
730
src/renderer/src/components/image-cropper/image-cropper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,3 +19,4 @@ export * from "./context-menu/context-menu";
|
|||||||
export * from "./game-context-menu/game-context-menu";
|
export * from "./game-context-menu/game-context-menu";
|
||||||
export * from "./game-context-menu/use-game-actions";
|
export * from "./game-context-menu/use-game-actions";
|
||||||
export * from "./star-rating/star-rating";
|
export * from "./star-rating/star-rating";
|
||||||
|
export * from "./image-cropper/image-cropper";
|
||||||
|
|||||||
158
src/renderer/src/helpers/image-cropper.ts
Normal file
158
src/renderer/src/helpers/image-cropper.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext, useEffect } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -10,8 +10,12 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ModalProps,
|
ModalProps,
|
||||||
TextField,
|
TextField,
|
||||||
|
ImageCropper,
|
||||||
|
CropArea,
|
||||||
} from "@renderer/components";
|
} from "@renderer/components";
|
||||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||||
|
import { cropImage } from "@renderer/helpers/image-cropper";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
|
||||||
@@ -63,6 +67,66 @@ export function EditProfileModal(
|
|||||||
|
|
||||||
const { showSuccessToast, showErrorToast } = useToast();
|
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) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
return patchUser(values)
|
return patchUser(values)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -99,19 +163,9 @@ export function EditProfileModal(
|
|||||||
|
|
||||||
if (filePaths && filePaths.length > 0) {
|
if (filePaths && filePaths.length > 0) {
|
||||||
const path = filePaths[0];
|
const path = filePaths[0];
|
||||||
|
setSelectedImagePath(path);
|
||||||
if (!hasActiveSubscription) {
|
setOnImageChange(() => onChange);
|
||||||
const { imagePath } = await window.electron
|
setShowCropper(true);
|
||||||
.processProfileImage(path)
|
|
||||||
.catch(() => {
|
|
||||||
showErrorToast(t("image_process_failure"));
|
|
||||||
return { imagePath: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
onChange(imagePath);
|
|
||||||
} else {
|
|
||||||
onChange(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +209,22 @@ export function EditProfileModal(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<small className="edit-profile-modal__hint">
|
||||||
<Trans i18nKey="privacy_hint" ns="user_profile">
|
<Trans i18nKey="privacy_hint" ns="user_profile">
|
||||||
<Link to="/settings" />
|
<Link to="/settings" />
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { UploadIcon } from "@primer/octicons-react";
|
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 { useContext, useState } from "react";
|
||||||
import { userProfileContext } from "@renderer/context";
|
import { userProfileContext } from "@renderer/context";
|
||||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cropImage } from "@renderer/helpers/image-cropper";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
import "./upload-background-image-button.scss";
|
import "./upload-background-image-button.scss";
|
||||||
|
|
||||||
export function UploadBackgroundImageButton() {
|
export function UploadBackgroundImageButton() {
|
||||||
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
|
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [showCropper, setShowCropper] = useState(false);
|
||||||
|
const [selectedImagePath, setSelectedImagePath] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const { hasActiveSubscription } = useUserDetails();
|
const { hasActiveSubscription } = useUserDetails();
|
||||||
|
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
@@ -16,47 +22,100 @@ export function UploadBackgroundImageButton() {
|
|||||||
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
|
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
|
||||||
const { patchUser, fetchUserDetails } = useUserDetails();
|
const { patchUser, fetchUserDetails } = useUserDetails();
|
||||||
|
|
||||||
const { showSuccessToast } = useToast();
|
const { showSuccessToast, showErrorToast } = useToast();
|
||||||
|
|
||||||
const handleChangeCoverClick = async () => {
|
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 {
|
try {
|
||||||
const { filePaths } = await window.electron.showOpenDialog({
|
setIsUploadingBackgorundImage(true);
|
||||||
properties: ["openFile"],
|
setShowCropper(false);
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "Image",
|
|
||||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filePaths && filePaths.length > 0) {
|
const imagePathForCrop = selectedImagePath.startsWith("local:")
|
||||||
const path = filePaths[0];
|
? selectedImagePath.slice(6)
|
||||||
|
: selectedImagePath;
|
||||||
|
const imageData = await cropImage(
|
||||||
|
imagePathForCrop,
|
||||||
|
cropArea,
|
||||||
|
"image/png"
|
||||||
|
);
|
||||||
|
|
||||||
setSelectedBackgroundImage(path);
|
const tempFileName = `cropped-background-${Date.now()}.png`;
|
||||||
setIsUploadingBackgorundImage(true);
|
const croppedPath = await window.electron.saveTempFile(
|
||||||
|
tempFileName,
|
||||||
|
imageData
|
||||||
|
);
|
||||||
|
|
||||||
await patchUser({ backgroundImageUrl: path });
|
setSelectedBackgroundImage(croppedPath);
|
||||||
|
await patchUser({ backgroundImageUrl: croppedPath });
|
||||||
|
|
||||||
showSuccessToast(t("background_image_updated"));
|
showSuccessToast(t("background_image_updated"));
|
||||||
await fetchUserDetails();
|
await fetchUserDetails();
|
||||||
}
|
|
||||||
|
setSelectedImagePath(null);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to crop background image:", error);
|
||||||
|
showErrorToast(t("image_crop_failure"));
|
||||||
|
setSelectedImagePath(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingBackgorundImage(false);
|
setIsUploadingBackgorundImage(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelCrop = () => {
|
||||||
|
setShowCropper(false);
|
||||||
|
setSelectedImagePath(null);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isMe || !hasActiveSubscription) return null;
|
if (!isMe || !hasActiveSubscription) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
theme="outline"
|
<Button
|
||||||
className="upload-background-image-button"
|
theme="outline"
|
||||||
onClick={handleChangeCoverClick}
|
className="upload-background-image-button"
|
||||||
disabled={isUploadingBackgroundImage}
|
onClick={handleChangeCoverClick}
|
||||||
>
|
disabled={isUploadingBackgroundImage}
|
||||||
<UploadIcon />
|
>
|
||||||
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")}
|
<UploadIcon />
|
||||||
</Button>
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user