immich/web/src/lib/modals/ProfileImageCropperModal.svelte
Aditya Gaurav 3c77c724c5
fix(web): Ensure profile picture is cropped to 1:1 ratio (#25892)
* fix(web): Ensure profile picture is cropped to 1:1 ratio

Fixes #20097

The profile picture was being captured from the PhotoViewer element
which could have non-square dimensions based on the original image.

Changed to capture from the crop container element which has the
aspect-square class, ensuring the output is always 1:1 ratio.

* fix: remove trailing whitespace to pass prettier check

---------

Co-authored-by: Aditya Gaurav <aditya-ai-architect@users.noreply.github.com>
2026-02-05 11:45:06 -06:00

95 lines
3 KiB
Svelte

<script lang="ts">
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { createProfileImage, type AssetResponseDto } from '@immich/sdk';
import { FormModal, toastManager } from '@immich/ui';
import domtoimage from 'dom-to-image';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import PhotoViewer from '../components/asset-viewer/photo-viewer.svelte';
interface Props {
asset: AssetResponseDto;
onClose: () => void;
}
let { asset, onClose }: Props = $props();
let imgElement: HTMLDivElement | undefined = $state();
let cropContainer: HTMLDivElement | undefined = $state();
onMount(() => {
if (!imgElement) {
return;
}
imgElement.style.width = '100%';
});
const hasTransparentPixels = async (blob: Blob) => {
const img = new Image();
img.src = URL.createObjectURL(blob);
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not get canvas context.');
}
context.drawImage(img, 0, 0);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData?.data;
if (!data) {
throw new Error('Could not get image data.');
}
for (let index = 0; index < data.length; index += 4) {
if (data[index + 3] < 255) {
return true;
}
}
return false;
};
const onSubmit = async () => {
if (!cropContainer) {
return;
}
try {
// Get the container dimensions (which is always square due to aspect-square class)
const containerSize = cropContainer.offsetWidth;
// Capture the crop container which maintains 1:1 aspect ratio
const blob = await domtoimage.toBlob(cropContainer, {
width: containerSize,
height: containerSize,
});
if (await hasTransparentPixels(blob)) {
toastManager.danger($t('errors.profile_picture_transparent_pixels'));
return;
}
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
toastManager.success($t('profile_picture_set'));
$user.profileImagePath = profileImagePath;
$user.profileChangedAt = profileChangedAt;
onClose();
} catch (error) {
handleError(error, $t('errors.unable_to_set_profile_picture'));
}
};
</script>
<FormModal size="small" title={$t('set_profile_picture')} {onClose} {onSubmit}>
<div class="flex place-items-center items-center justify-center">
<div
bind:this={cropContainer}
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
>
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
</div>
</div>
</FormModal>