mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-02 06:14:48 +02:00
feat(profile): add image cropping (#2252)
Co-authored-by: Chubby Granny Chaser <hydralauncher@proton.me>
This commit is contained in:
committed by
GitHub
parent
fa859ca240
commit
0c6fe57f6c
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./crop-profile-image";
|
||||
import "./get-me";
|
||||
import "./get-profile-image-metadata";
|
||||
import "./process-profile-image";
|
||||
import "./update-profile";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+15
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
+288
@@ -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>
|
||||
);
|
||||
}
|
||||
+77
-36
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user