feat(profile): add image cropping (#2252)

Co-authored-by: Chubby Granny Chaser <hydralauncher@proton.me>
This commit is contained in:
Chubby Granny Chaser
2026-05-26 13:23:00 +01:00
committed by GitHub
parent fa859ca240
commit 0c6fe57f6c
13 changed files with 1357 additions and 65 deletions
+11
View File
@@ -981,6 +981,17 @@
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"copied": "Copied!",
"crop_profile_picture": "Adjust profile picture",
"crop_profile_banner": "Adjust banner",
"crop_profile_image_description": "Drag the image to reposition it, then use zoom to resize it.",
"zoom": "Zoom",
"reset": "Reset",
"zoom_in": "Zoom in",
"zoom_out": "Zoom out",
"toggle_grid": "Toggle grid",
"rotate": "Rotate",
"applying_crop": "Applying…",
"apply_crop": "Apply",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
+11
View File
@@ -967,6 +967,17 @@
"your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"crop_profile_picture": "Ajustar foto de perfil",
"crop_profile_banner": "Ajustar banner",
"crop_profile_image_description": "Arrastrá la imagen para reposicionarla y usá el zoom para redimensionarla.",
"zoom": "Zoom",
"reset": "Restablecer",
"zoom_in": "Acercar",
"zoom_out": "Alejar",
"toggle_grid": "Mostrar/ocultar cuadrícula",
"rotate": "Girar",
"applying_crop": "Aplicando…",
"apply_crop": "Aplicar",
"upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
+11
View File
@@ -952,6 +952,17 @@
"your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"crop_profile_picture": "Ajustar foto de perfil",
"crop_profile_banner": "Ajustar banner",
"crop_profile_image_description": "Arraste a imagem para reposicioná-la e use o zoom para redimensioná-la.",
"zoom": "Zoom",
"reset": "Redefinir",
"zoom_in": "Aumentar zoom",
"zoom_out": "Diminuir zoom",
"toggle_grid": "Mostrar/ocultar grade",
"rotate": "Girar",
"applying_crop": "Aplicando…",
"apply_crop": "Aplicar",
"upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
+11
View File
@@ -977,6 +977,17 @@
"your_friend_code": "Ваш код друга:",
"copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"crop_profile_picture": "Настроить фото профиля",
"crop_profile_banner": "Настроить баннер",
"crop_profile_image_description": "Перетащите изображение, чтобы изменить его положение, затем используйте масштаб для изменения размера.",
"zoom": "Масштаб",
"reset": "Сбросить",
"zoom_in": "Увеличить",
"zoom_out": "Уменьшить",
"toggle_grid": "Показать/скрыть сетку",
"rotate": "Повернуть",
"applying_crop": "Применение...",
"apply_crop": "Применить",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"change_banner": "Изменить баннер",
@@ -0,0 +1,136 @@
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
import sharp from "sharp";
import { registerEvent } from "../register-event";
import { logger } from "@main/services";
export interface CropProfileImageParams {
/** Crop rectangle in source-image (per-frame) pixels, in the rotated
* orientation the user sees in the editor. */
left: number;
top: number;
width: number;
height: number;
/** Final output dimensions. */
outputWidth: number;
outputHeight: number;
/** Clockwise rotation applied before cropping (0 | 90 | 180 | 270). */
rotation?: number;
}
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
/**
* Crops + resizes a profile image while preserving animation for GIF/WebP.
*
* Reads the source with `{ animated: true }` so `extract` operates per frame
* (libvips treats each animation frame as a page), then outputs an animated
* WebP. Rotation needs special handling: libvips cannot rotate a multi-page
* image in one pass, so animated sources are rotated/cropped frame-by-frame
* and re-joined.
*/
const cropProfileImage = async (
_event: Electron.IpcMainInvokeEvent,
sourcePath: string,
params: CropProfileImageParams
): Promise<{ imagePath: string }> => {
try {
return await cropProfileImageInternal(sourcePath, params);
} catch (error) {
logger.error("Failed to crop profile image", sourcePath, params, error);
throw error;
}
};
const cropProfileImageInternal = async (
sourcePath: string,
params: CropProfileImageParams
): Promise<{ imagePath: string }> => {
const { outputWidth, outputHeight } = params;
const rotation = (((params.rotation ?? 0) % 360) + 360) % 360;
const baseMetadata = await sharp(sourcePath, { animated: true }).metadata();
const sourceWidth = baseMetadata.width ?? 0;
// For animated input `pageHeight` is the per-frame height; for static
// images it is undefined, so fall back to the full height.
const sourceHeight = baseMetadata.pageHeight ?? baseMetadata.height ?? 0;
if (!sourceWidth || !sourceHeight) {
throw new Error("Could not read source image dimensions");
}
// After rotation by 90°/270° the frame's width/height swap.
const isQuarterTurn = rotation === 90 || rotation === 270;
const frameWidth = isQuarterTurn ? sourceHeight : sourceWidth;
const frameHeight = isQuarterTurn ? sourceWidth : sourceHeight;
const left = clamp(Math.round(params.left), 0, frameWidth - 1);
const top = clamp(Math.round(params.top), 0, frameHeight - 1);
const width = clamp(Math.round(params.width), 1, frameWidth - left);
const height = clamp(Math.round(params.height), 1, frameHeight - top);
const extractRegion = { left, top, width, height };
const pages =
baseMetadata.pages && baseMetadata.pages > 1 ? baseMetadata.pages : 1;
let buffer: Buffer;
if (rotation === 0) {
// Single pass handles both static and animated inputs.
buffer = await sharp(sourcePath, { animated: true })
.extract(extractRegion)
.resize(outputWidth, outputHeight, { fit: "fill" })
.webp({ quality: 90, effort: 4 })
.toBuffer();
} else if (pages === 1) {
// Rotate to its own buffer first so the extract runs in rotated space
// (sharp's internal op ordering doesn't guarantee rotate-before-extract
// when chained directly).
const rotated = await sharp(sourcePath).rotate(rotation).toBuffer();
buffer = await sharp(rotated)
.extract(extractRegion)
.resize(outputWidth, outputHeight, { fit: "fill" })
.webp({ quality: 90, effort: 4 })
.toBuffer();
} else {
// Animated + rotation: rotate/crop each frame, then re-join.
const frames: Buffer[] = [];
for (let page = 0; page < pages; page++) {
const rotated = await sharp(sourcePath, { page, pages: 1 })
.rotate(rotation)
.toBuffer();
frames.push(
await sharp(rotated)
.extract(extractRegion)
.resize(outputWidth, outputHeight, { fit: "fill" })
.png()
.toBuffer()
);
}
buffer = await sharp(frames, { join: { animated: true } })
.webp({
quality: 90,
effort: 4,
loop: baseMetadata.loop ?? 0,
delay: baseMetadata.delay,
})
.toBuffer();
}
const tempFilePath = path.join(
app.getPath("temp"),
`hydra-temp-${Date.now()}-profile-crop.webp`
);
fs.writeFileSync(tempFilePath, buffer);
return { imagePath: tempFilePath };
};
registerEvent("cropProfileImage", cropProfileImage);
@@ -0,0 +1,97 @@
import fs from "node:fs";
import { fileTypeFromFile } from "file-type";
import { registerEvent } from "../register-event";
export interface ProfileImageMetadata {
mimeType: string | null;
isAnimated: boolean;
}
const isGifAnimated = (buffer: Buffer) => {
if (buffer.length < 13) return false;
let frameCount = 0;
let offset = 13;
const packedField = buffer[10];
if (packedField & 0x80) {
offset += 3 * 2 ** ((packedField & 0x07) + 1);
}
const skipSubBlocks = () => {
while (offset < buffer.length) {
const blockSize = buffer[offset];
offset += 1;
if (blockSize === 0) return true;
offset += blockSize;
}
return false;
};
while (offset < buffer.length) {
const marker = buffer[offset];
offset += 1;
if (marker === 0x2c) {
frameCount += 1;
if (frameCount > 1) return true;
if (offset + 9 > buffer.length) return false;
const imagePackedField = buffer[offset + 8];
offset += 9;
if (imagePackedField & 0x80) {
offset += 3 * 2 ** ((imagePackedField & 0x07) + 1);
}
offset += 1;
if (!skipSubBlocks()) return false;
continue;
}
if (marker === 0x21) {
offset += 1;
if (!skipSubBlocks()) return false;
continue;
}
if (marker === 0x3b) break;
}
return false;
};
const isWebpAnimated = (buffer: Buffer) => {
return buffer.includes(Buffer.from("ANIM"));
};
const isPngAnimated = (buffer: Buffer) => {
return buffer.includes(Buffer.from("acTL"));
};
const getProfileImageMetadata = async (
_event: Electron.IpcMainInvokeEvent,
imagePath: string
): Promise<ProfileImageMetadata> => {
const [fileType, buffer] = await Promise.all([
fileTypeFromFile(imagePath).catch(() => null),
fs.promises.readFile(imagePath),
]);
const mimeType = fileType?.mime ?? null;
return {
mimeType,
isAnimated:
(mimeType === "image/gif" && isGifAnimated(buffer)) ||
(mimeType === "image/webp" && isWebpAnimated(buffer)) ||
(mimeType === "image/png" && isPngAnimated(buffer)),
};
};
registerEvent("getProfileImageMetadata", getProfileImageMetadata);
+2
View File
@@ -1,3 +1,5 @@
import "./crop-profile-image";
import "./get-me";
import "./get-profile-image-metadata";
import "./process-profile-image";
import "./update-profile";
+14
View File
@@ -648,8 +648,22 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (updateProfile: UpdateProfileRequest) =>
ipcRenderer.invoke("updateProfile", updateProfile),
getProfileImageMetadata: (imagePath: string) =>
ipcRenderer.invoke("getProfileImageMetadata", imagePath),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
cropProfileImage: (
imagePath: string,
params: {
left: number;
top: number;
width: number;
height: number;
outputWidth: number;
outputHeight: number;
rotation?: number;
}
) => ipcRenderer.invoke("cropProfileImage", imagePath, params),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
+15
View File
@@ -499,9 +499,24 @@ declare global {
updateProfile: UpdateProfileRequest
) => Promise<UserProfile>;
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
getProfileImageMetadata: (
path: string
) => Promise<{ mimeType: string | null; isAnimated: boolean }>;
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
cropProfileImage: (
path: string,
params: {
left: number;
top: number;
width: number;
height: number;
outputWidth: number;
outputHeight: number;
rotation?: number;
}
) => Promise<{ imagePath: string }>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
@@ -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";
@@ -18,6 +18,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { userProfileContext } from "@renderer/context";
import { ProfileImageCropModal } from "../profile-image-crop-modal/profile-image-crop-modal";
import "./edit-profile-modal.scss";
interface FormValues {
@@ -49,6 +50,10 @@ export function EditProfileModal(
});
const { getUserProfile } = useContext(userProfileContext);
const [profileImageToCrop, setProfileImageToCrop] = useState<string | null>(
null
);
const [cropIsAnimated, setCropIsAnimated] = useState(false);
const { userDetails, fetchUserDetails, hasActiveSubscription } =
useUserDetails();
@@ -86,6 +91,38 @@ export function EditProfileModal(
control={control}
name="profileImageUrl"
render={({ field: { value, onChange } }) => {
const handleProfileImagePath = async (path: string) => {
const metadata = await window.electron
.getProfileImageMetadata(path)
.catch(() => null);
if (metadata?.isAnimated && hasActiveSubscription) {
// Crop while preserving animation (handled in main/sharp).
setCropIsAnimated(true);
setProfileImageToCrop(path);
return;
}
if (metadata?.isAnimated && !hasActiveSubscription) {
const { imagePath } = await window.electron
.processProfileImage(path)
.catch(() => {
showErrorToast(t("image_process_failure"));
return { imagePath: null };
});
if (imagePath) {
setCropIsAnimated(false);
setProfileImageToCrop(imagePath);
}
return;
}
setCropIsAnimated(false);
setProfileImageToCrop(path);
};
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
@@ -98,20 +135,7 @@ 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);
}
handleProfileImagePath(filePaths[0]);
}
};
@@ -126,21 +150,35 @@ export function EditProfileModal(
const imageUrl = getImageUrl();
return (
<button
type="button"
className="edit-profile-modal__avatar-container"
onClick={handleChangeProfileAvatar}
>
<Avatar
size={128}
src={imageUrl}
alt={userDetails?.displayName}
/>
<>
<button
type="button"
className="edit-profile-modal__avatar-container"
onClick={handleChangeProfileAvatar}
>
<Avatar
size={128}
src={imageUrl}
alt={userDetails?.displayName}
/>
<div className="edit-profile-modal__avatar-overlay">
<DeviceCameraIcon size={38} />
</div>
</button>
<div className="edit-profile-modal__avatar-overlay">
<DeviceCameraIcon size={38} />
</div>
</button>
<ProfileImageCropModal
visible={!!profileImageToCrop}
imagePath={profileImageToCrop}
variant="avatar"
isAnimated={cropIsAnimated}
onClose={() => setProfileImageToCrop(null)}
onApply={(croppedImagePath) => {
onChange(croppedImagePath);
setProfileImageToCrop(null);
}}
/>
</>
);
}}
/>
@@ -0,0 +1,288 @@
@use "../../../scss/globals.scss";
@keyframes profile-crop-toolbar-in {
0% {
opacity: 0;
transform: translate(-50%, 12px);
}
100% {
opacity: 1;
transform: translate(-50%, 0);
}
}
.profile-image-crop-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
&__stage {
position: relative;
align-self: center;
display: flex;
justify-content: center;
width: 100%;
}
&__frame {
position: relative;
overflow: hidden;
background-color: globals.$dark-background-color;
background-image:
linear-gradient(45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.05) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.05) 75%);
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
background-size: 20px 20px;
border: solid 1px globals.$border-color;
border-radius: 8px;
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.45),
inset 0 0 0 1px rgba(255, 255, 255, 0.02);
cursor: grab;
touch-action: none;
user-select: none;
transition: border-color ease 0.2s;
&:hover {
border-color: rgba(255, 255, 255, 0.16);
}
&:active {
cursor: grabbing;
}
&--avatar {
width: min(420px, 72vw);
aspect-ratio: 1;
}
&--banner {
width: min(720px, 80vw);
aspect-ratio: 4;
}
}
&__image {
position: absolute;
top: 0;
left: 0;
max-width: none;
transform-origin: top left;
will-change: transform;
pointer-events: none;
}
&__grid {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
&--visible {
opacity: 1;
}
}
&__grid-line {
position: absolute;
background-color: rgba(255, 255, 255, 0.28);
box-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
&--v1,
&--v2 {
top: 0;
bottom: 0;
width: 1px;
}
&--v1 {
left: 33.333%;
}
&--v2 {
left: 66.666%;
}
&--h1,
&--h2 {
left: 0;
right: 0;
height: 1px;
}
&--h1 {
top: 33.333%;
}
&--h2 {
top: 66.666%;
}
}
&__toolbar {
position: absolute;
bottom: calc(globals.$spacing-unit * 2);
left: 50%;
transform: translate(-50%, 0);
display: flex;
align-items: center;
gap: globals.$spacing-unit;
max-width: calc(100% - globals.$spacing-unit * 4);
padding: 6px calc(globals.$spacing-unit * 1.5);
background-color: rgba(18, 18, 18, 0.66);
backdrop-filter: blur(12px);
border: solid 1px globals.$border-color;
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: profile-crop-toolbar-in 0.25s cubic-bezier(0.33, 1, 0.68, 1);
}
&__toolbar-divider {
width: 1px;
height: 20px;
background-color: globals.$border-color;
flex-shrink: 0;
}
&__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background-color: transparent;
color: globals.$muted-color;
cursor: pointer;
transition: all ease 0.2s;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:active:not(:disabled) {
opacity: globals.$active-opacity;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&--active {
background-color: rgba(globals.$brand-teal, 0.18);
color: globals.$brand-teal;
&:hover:not(:disabled) {
background-color: rgba(globals.$brand-teal, 0.26);
}
}
}
&__slider {
-webkit-appearance: none;
appearance: none;
width: clamp(120px, 22vw, 200px);
height: 6px;
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.12);
background-image: linear-gradient(
90deg,
globals.$muted-color,
globals.$muted-color
);
background-repeat: no-repeat;
background-size: var(--fill, 0%) 100%;
cursor: pointer;
outline: none;
transition: background-color ease 0.2s;
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
border: solid 2px globals.$muted-color;
background-color: globals.$background-color;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
cursor: pointer;
transition:
transform ease 0.15s,
box-shadow ease 0.15s;
}
&::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
&::-webkit-slider-thumb:active {
transform: scale(0.95);
}
&::-moz-range-track {
height: 6px;
border-radius: 999px;
background-color: transparent;
}
&::-moz-range-progress {
height: 6px;
border-radius: 999px;
background-color: globals.$muted-color;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
border: solid 2px globals.$muted-color;
background-color: globals.$background-color;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
cursor: pointer;
}
&:focus-visible::-webkit-slider-thumb {
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.45),
0 0 0 4px rgba(globals.$brand-teal, 0.4);
}
&:focus-visible::-moz-range-thumb {
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.45),
0 0 0 4px rgba(globals.$brand-teal, 0.4);
}
}
&__zoom-percent {
min-width: 42px;
text-align: right;
font-size: globals.$small-font-size;
font-variant-numeric: tabular-nums;
color: globals.$body-color;
user-select: none;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: globals.$spacing-unit;
padding-top: calc(globals.$spacing-unit * 2);
border-top: solid 1px globals.$border-color;
}
}
@@ -0,0 +1,617 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Grid2x2Icon,
MinusIcon,
PlusIcon,
RotateCcwIcon,
RotateCcwSquareIcon,
} from "lucide-react";
import { Button, Modal } from "@renderer/components";
import { useToast } from "@renderer/hooks";
import "./profile-image-crop-modal.scss";
type CropVariant = "avatar" | "banner";
interface ProfileImageCropModalProps {
visible: boolean;
imagePath: string | null;
variant: CropVariant;
/** When true the source is animated (GIF/WebP) and cropping is done in the
* main process via sharp so animation is preserved. */
isAnimated?: boolean;
onClose: () => void;
onApply: (croppedImagePath: string) => void;
}
const CROP_OUTPUT_SIZE: Record<CropVariant, { width: number; height: number }> =
{
avatar: { width: 512, height: 512 },
banner: { width: 1600, height: 400 },
};
const MAX_ZOOM = 4;
const MIN_ZOOM = 1;
const ZOOM_STEP = 0.25;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export function ProfileImageCropModal({
visible,
imagePath,
variant,
isAnimated = false,
onClose,
onApply,
}: ProfileImageCropModalProps) {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const frameRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const wheelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const [frameSize, setFrameSize] = useState({ width: 0, height: 0 });
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const [position, setPosition] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(MIN_ZOOM);
const [rotation, setRotation] = useState(0);
const [isApplying, setIsApplying] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isInteracting, setIsInteracting] = useState(false);
const [gridPinned, setGridPinned] = useState(false);
const showGrid = gridPinned || isInteracting;
const outputSize = CROP_OUTPUT_SIZE[variant];
// Width/height of the image as the user sees it after rotation: a
// quarter turn swaps the two. All fit/pan/clamp math works in this space.
const isQuarterTurn = rotation === 90 || rotation === 270;
const effImageSize = useMemo(
() => ({
width: isQuarterTurn ? imageSize.height : imageSize.width,
height: isQuarterTurn ? imageSize.width : imageSize.height,
}),
[imageSize, isQuarterTurn]
);
const minScale = useMemo(() => {
if (!frameSize.width || !frameSize.height || !effImageSize.width) return 1;
return Math.max(
frameSize.width / effImageSize.width,
frameSize.height / effImageSize.height
);
}, [frameSize, effImageSize]);
const scale = minScale * zoom;
const clampPosition = useCallback(
(
nextPosition: { x: number; y: number },
nextScale: number
): { x: number; y: number } => {
if (!frameSize.width || !frameSize.height || !effImageSize.width) {
return nextPosition;
}
// Round up so sub-pixel scaling never leaves a hairline gap
// between the image edge and the frame (checkerboard bleed).
const renderedWidth = Math.ceil(effImageSize.width * nextScale);
const renderedHeight = Math.ceil(effImageSize.height * nextScale);
const minX = Math.min(frameSize.width - renderedWidth, 0);
const minY = Math.min(frameSize.height - renderedHeight, 0);
return {
x: clamp(nextPosition.x, minX, 0),
y: clamp(nextPosition.y, minY, 0),
};
},
[frameSize, effImageSize]
);
const centerImage = useCallback(
(nextZoom = MIN_ZOOM) => {
const nextScale = minScale * nextZoom;
const nextPosition = {
x: (frameSize.width - effImageSize.width * nextScale) / 2,
y: (frameSize.height - effImageSize.height * nextScale) / 2,
};
setZoom(nextZoom);
setPosition(clampPosition(nextPosition, nextScale));
},
[clampPosition, frameSize, effImageSize, minScale]
);
const updateZoom = useCallback(
(nextZoom: number, focalPoint?: { x: number; y: number }) => {
const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
const nextScale = minScale * clampedZoom;
const scaleRatio = nextScale / scale;
const point = focalPoint ?? {
x: frameSize.width / 2,
y: frameSize.height / 2,
};
const nextPosition = {
x: point.x - (point.x - position.x) * scaleRatio,
y: point.y - (point.y - position.y) * scaleRatio,
};
setZoom(clampedZoom);
setPosition(clampPosition(nextPosition, nextScale));
},
[clampPosition, frameSize, minScale, position, scale]
);
useEffect(() => {
if (!visible) {
setImageSize({ width: 0, height: 0 });
setFrameSize({ width: 0, height: 0 });
setPosition({ x: 0, y: 0 });
setZoom(MIN_ZOOM);
setRotation(0);
setIsApplying(false);
setIsInteracting(false);
setGridPinned(false);
}
}, [visible]);
useEffect(
() => () => {
if (wheelTimeoutRef.current) clearTimeout(wheelTimeoutRef.current);
},
[]
);
useEffect(() => {
if (!visible || !imagePath) {
setPreviewUrl(null);
return;
}
let objectUrl: string | null = null;
let isMounted = true;
fetch(`local:${imagePath}`)
.then((response) => response.blob())
.then((blob) => {
if (!isMounted) return;
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
})
.catch(() => {
if (isMounted) setPreviewUrl(`local:${imagePath}`);
});
return () => {
isMounted = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [imagePath, visible]);
useEffect(() => {
if (!visible || !frameRef.current) return;
const updateFrameSize = () => {
if (!frameRef.current) return;
// Use clientWidth/clientHeight (layout box) instead of
// getBoundingClientRect: the latter reflects the modal's open
// scale animation transform, which would size the image to a
// stale, mid-animation measurement.
setFrameSize({
width: frameRef.current.clientWidth,
height: frameRef.current.clientHeight,
});
};
updateFrameSize();
const observer = new ResizeObserver(updateFrameSize);
observer.observe(frameRef.current);
return () => observer.disconnect();
}, [visible, variant]);
// Re-fit and re-center whenever the frame, image, or rotation changes
// (rotation flows through minScale/effImageSize).
useEffect(() => {
if (frameSize.width && effImageSize.width) {
const nextScale = minScale * MIN_ZOOM;
const nextPosition = {
x: (frameSize.width - effImageSize.width * nextScale) / 2,
y: (frameSize.height - effImageSize.height * nextScale) / 2,
};
setZoom(MIN_ZOOM);
setPosition(clampPosition(nextPosition, nextScale));
}
}, [clampPosition, frameSize, effImageSize, minScale]);
const handleImageLoad = () => {
if (!imageRef.current) return;
setImageSize({
width: imageRef.current.naturalWidth,
height: imageRef.current.naturalHeight,
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!frameRef.current) return;
event.preventDefault();
frameRef.current.setPointerCapture(event.pointerId);
setIsInteracting(true);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
setPosition(
clampPosition(
{
x: dragState.originX + event.clientX - dragState.startX,
y: dragState.originY + event.clientY - dragState.startY,
},
scale
)
);
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId) return;
dragStateRef.current = null;
setIsInteracting(false);
if (frameRef.current?.hasPointerCapture(event.pointerId)) {
frameRef.current.releasePointerCapture(event.pointerId);
}
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
if (!frameRef.current) return;
event.preventDefault();
const rect = frameRef.current.getBoundingClientRect();
const nextZoom = zoom - event.deltaY * 0.002;
updateZoom(nextZoom, {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
setIsInteracting(true);
if (wheelTimeoutRef.current) clearTimeout(wheelTimeoutRef.current);
wheelTimeoutRef.current = setTimeout(() => setIsInteracting(false), 400);
};
const handleZoomChange = (event: React.ChangeEvent<HTMLInputElement>) => {
updateZoom(Number(event.target.value));
};
// Rotate 90° counter-clockwise; the re-fit effect recenters afterwards.
const rotateImage = () => setRotation((current) => (current + 270) % 360);
const handleReset = () => {
setRotation(0);
centerImage();
};
const handleApply = async () => {
if (!imageRef.current || !imagePath) return;
setIsApplying(true);
try {
// Animated sources (GIF/WebP) can't be cropped on a 2D canvas without
// losing animation, so delegate to the main process (sharp) with the
// crop rectangle expressed in natural source pixels.
// Crop rectangle in the rotated source space the user is looking at.
const sourceX = -position.x / scale;
const sourceY = -position.y / scale;
const sourceWidth = frameSize.width / scale;
const sourceHeight = frameSize.height / scale;
if (isAnimated) {
const { imagePath: croppedImagePath } =
await window.electron.cropProfileImage(imagePath, {
left: sourceX,
top: sourceY,
width: sourceWidth,
height: sourceHeight,
outputWidth: outputSize.width,
outputHeight: outputSize.height,
rotation,
});
onApply(croppedImagePath);
return;
}
const canvas = document.createElement("canvas");
canvas.width = outputSize.width;
canvas.height = outputSize.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create crop canvas");
// When rotated, render the rotated image into an intermediate canvas at
// natural resolution so the crop rectangle (which is in rotated space)
// maps correctly.
let cropSource: CanvasImageSource = imageRef.current;
if (rotation !== 0) {
const rotated = document.createElement("canvas");
rotated.width = effImageSize.width;
rotated.height = effImageSize.height;
const rotatedContext = rotated.getContext("2d");
if (!rotatedContext)
throw new Error("Could not create rotation canvas");
const naturalWidth = imageSize.width;
const naturalHeight = imageSize.height;
if (rotation === 90) rotatedContext.translate(naturalHeight, 0);
else if (rotation === 180)
rotatedContext.translate(naturalWidth, naturalHeight);
else if (rotation === 270) rotatedContext.translate(0, naturalWidth);
rotatedContext.rotate((rotation * Math.PI) / 180);
rotatedContext.drawImage(
imageRef.current,
0,
0,
naturalWidth,
naturalHeight
);
cropSource = rotated;
}
context.drawImage(
cropSource,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
outputSize.width,
outputSize.height
);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", 0.92)
);
if (!blob) throw new Error("Could not export cropped image");
const tempImagePath = await window.electron.saveTempFile(
`profile-${variant}-${Date.now()}.webp`,
new Uint8Array(await blob.arrayBuffer())
);
onApply(tempImagePath);
} catch (error) {
console.error("Failed to crop profile image", error);
showErrorToast(t("image_process_failure"));
} finally {
setIsApplying(false);
}
};
// The <img> keeps its natural orientation/size; rotation is applied via
// transform around the top-left origin, with a translate that lands the
// rotated bounding box's top-left at `position` (the value clampPosition
// works in).
const renderedWidth = Math.ceil(imageSize.width * scale);
const renderedHeight = Math.ceil(imageSize.height * scale);
let translateX = position.x;
let translateY = position.y;
if (rotation === 90) {
translateX += renderedHeight;
} else if (rotation === 180) {
translateX += renderedWidth;
translateY += renderedHeight;
} else if (rotation === 270) {
translateY += renderedWidth;
}
const imageTransform = `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg)`;
return (
<Modal
visible={visible}
title={
variant === "avatar"
? t("crop_profile_picture")
: t("crop_profile_banner")
}
description={t("crop_profile_image_description")}
onClose={onClose}
clickOutsideToClose={false}
large
>
<div className="profile-image-crop-modal">
<div className="profile-image-crop-modal__stage">
<div
ref={frameRef}
className={`profile-image-crop-modal__frame profile-image-crop-modal__frame--${variant}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
{previewUrl && (
<img
ref={imageRef}
src={previewUrl}
alt=""
className="profile-image-crop-modal__image"
style={{
width: renderedWidth,
height: renderedHeight,
transform: imageTransform,
}}
draggable={false}
onLoad={handleImageLoad}
/>
)}
<div
className={`profile-image-crop-modal__grid${
showGrid ? " profile-image-crop-modal__grid--visible" : ""
}`}
aria-hidden="true"
>
<span className="profile-image-crop-modal__grid-line profile-image-crop-modal__grid-line--v1" />
<span className="profile-image-crop-modal__grid-line profile-image-crop-modal__grid-line--v2" />
<span className="profile-image-crop-modal__grid-line profile-image-crop-modal__grid-line--h1" />
<span className="profile-image-crop-modal__grid-line profile-image-crop-modal__grid-line--h2" />
</div>
</div>
<div
className="profile-image-crop-modal__toolbar"
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className={`profile-image-crop-modal__icon-button${
gridPinned
? " profile-image-crop-modal__icon-button--active"
: ""
}`}
onClick={() => setGridPinned((value) => !value)}
disabled={isApplying}
title={t("toggle_grid")}
aria-label={t("toggle_grid")}
aria-pressed={gridPinned}
>
<Grid2x2Icon size={16} />
</button>
<span className="profile-image-crop-modal__toolbar-divider" />
<button
type="button"
className="profile-image-crop-modal__icon-button"
onClick={() => updateZoom(zoom - ZOOM_STEP)}
disabled={isApplying || zoom <= MIN_ZOOM}
title={t("zoom_out")}
aria-label={t("zoom_out")}
>
<MinusIcon size={16} />
</button>
<input
type="range"
className="profile-image-crop-modal__slider"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
onChange={handleZoomChange}
disabled={isApplying}
aria-label={t("zoom")}
style={
{
"--fill": `${
((zoom - MIN_ZOOM) / (MAX_ZOOM - MIN_ZOOM)) * 100
}%`,
} as React.CSSProperties
}
/>
<button
type="button"
className="profile-image-crop-modal__icon-button"
onClick={() => updateZoom(zoom + ZOOM_STEP)}
disabled={isApplying || zoom >= MAX_ZOOM}
title={t("zoom_in")}
aria-label={t("zoom_in")}
>
<PlusIcon size={16} />
</button>
<span className="profile-image-crop-modal__zoom-percent">
{Math.round(zoom * 100)}%
</span>
<span className="profile-image-crop-modal__toolbar-divider" />
<button
type="button"
className="profile-image-crop-modal__icon-button"
onClick={rotateImage}
disabled={isApplying}
title={t("rotate")}
aria-label={t("rotate")}
>
<RotateCcwSquareIcon size={16} />
</button>
<button
type="button"
className="profile-image-crop-modal__icon-button"
onClick={handleReset}
disabled={isApplying}
title={t("reset")}
aria-label={t("reset")}
>
<RotateCcwIcon size={16} />
</button>
</div>
</div>
<div className="profile-image-crop-modal__actions">
<Button
type="button"
theme="outline"
onClick={onClose}
disabled={isApplying}
>
{t("cancel")}
</Button>
<Button
type="button"
onClick={handleApply}
disabled={isApplying || !imageSize.width}
>
{isApplying ? t("applying_crop") : t("apply_crop")}
</Button>
</div>
</div>
</Modal>
);
}
@@ -6,6 +6,7 @@ import { useContext, useEffect, useRef, useState } from "react";
import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { ProfileImageCropModal } from "../profile-image-crop-modal/profile-image-crop-modal";
import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() {
@@ -14,6 +15,10 @@ export function UploadBackgroundImageButton() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const [bannerImageToCrop, setBannerImageToCrop] = useState<string | null>(
null
);
const [cropIsAnimated, setCropIsAnimated] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { hasActiveSubscription } = useUserDetails();
@@ -24,7 +29,7 @@ export function UploadBackgroundImageButton() {
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast();
const { showSuccessToast, showErrorToast } = useToast();
const hasBanner = !!userProfile?.backgroundImageUrl;
@@ -36,36 +41,54 @@ export function UploadBackgroundImageButton() {
}, 150);
};
const handleReplaceBanner = async () => {
closeMenu();
const uploadBanner = async (path: string) => {
try {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
setSelectedBackgroundImage(path);
setIsUploadingBackgorundImage(true);
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
await patchUser({ backgroundImageUrl: path });
setSelectedBackgroundImage(path);
setIsUploadingBackgorundImage(true);
await patchUser({ backgroundImageUrl: path });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
}
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
} catch {
showErrorToast(t("try_again"));
} finally {
setIsUploadingBackgorundImage(false);
}
};
const handleReplaceBanner = async () => {
closeMenu();
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];
const metadata = await window.electron
.getProfileImageMetadata(path)
.catch(() => null);
if (metadata?.isAnimated) {
// Crop while preserving animation (handled in main/sharp).
setCropIsAnimated(true);
setBannerImageToCrop(path);
return;
}
setCropIsAnimated(false);
setBannerImageToCrop(path);
}
};
const handleRemoveBannerClick = () => {
closeMenu();
setShowRemoveBannerModal(true);
@@ -124,22 +147,39 @@ export function UploadBackgroundImageButton() {
if (!isMe || !hasActiveSubscription) return null;
const cropModal = (
<ProfileImageCropModal
visible={!!bannerImageToCrop}
imagePath={bannerImageToCrop}
variant="banner"
isAnimated={cropIsAnimated}
onClose={() => setBannerImageToCrop(null)}
onApply={(croppedImagePath) => {
setBannerImageToCrop(null);
uploadBanner(croppedImagePath);
}}
/>
);
// If no banner exists, show the original upload button
if (!hasBanner) {
return (
<div className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
</div>
<>
{cropModal}
<div className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
</div>
</>
);
}
@@ -190,6 +230,7 @@ export function UploadBackgroundImageButton() {
return (
<>
{cropModal}
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"