diff --git a/apps/desktop/layer/renderer/src/components/ui/avatar-upload-modal.tsx b/apps/desktop/layer/renderer/src/components/ui/crop/AvatarUploadModal.tsx similarity index 67% rename from apps/desktop/layer/renderer/src/components/ui/avatar-upload-modal.tsx rename to apps/desktop/layer/renderer/src/components/ui/crop/AvatarUploadModal.tsx index 957a88389..e4d36b065 100644 --- a/apps/desktop/layer/renderer/src/components/ui/avatar-upload-modal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/crop/AvatarUploadModal.tsx @@ -40,6 +40,36 @@ export const AvatarUploadModal = ({ cropHeight: 0, }) + // Helper function: ensure the crop data is within the image boundaries and maintain the 1:1 ratio + const constrainCropData = useCallback( + (newCropData: typeof cropData, imageWidth: number, imageHeight: number) => { + const { x, y, width, height } = newCropData + + // Ensure it's a square, use the larger value to avoid shrinking + const size = Math.max(width, height) + + // Ensure the minimum size + const minSize = 50 + let finalSize = Math.max(size, minSize) + + // Ensure it's not out of bounds, if it is, shrink it to the appropriate size + const maxSize = Math.min(imageWidth, imageHeight) + finalSize = Math.min(finalSize, maxSize) + + // Adjust the position to ensure it's within the boundaries + const maxX = imageWidth - finalSize + const maxY = imageHeight - finalSize + + return { + x: Math.max(0, Math.min(x, maxX)), + y: Math.max(0, Math.min(y, maxY)), + width: finalSize, + height: finalSize, + } + }, + [], + ) + const handleFileSelect = useCallback( (files: FileList) => { const file = files[0] @@ -68,15 +98,26 @@ export const AvatarUploadModal = ({ const handleImageLoad = useCallback(() => { if (imageRef.current) { const img = imageRef.current - const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8 - setCropData({ + // Use the smaller side's 80% as the initial size + const maxSize = Math.min(img.naturalWidth, img.naturalHeight) + const size = maxSize * 0.8 + + const initialCropData = { x: (img.naturalWidth - size) / 2, y: (img.naturalHeight - size) / 2, width: size, height: size, - }) + } + + // Use the helper function to ensure the data is valid + const constrainedData = constrainCropData( + initialCropData, + img.naturalWidth, + img.naturalHeight, + ) + setCropData(constrainedData) } - }, []) + }, [constrainCropData]) const handleCropMouseDown = useCallback( (e: React.MouseEvent) => { @@ -119,9 +160,30 @@ export const AvatarUploadModal = ({ if (!imageRef.current || !containerRef.current) return const img = imageRef.current - const rect = containerRef.current.getBoundingClientRect() - const scaleX = img.naturalWidth / rect.width - const scaleY = img.naturalHeight / rect.height + const container = containerRef.current + const containerRect = container.getBoundingClientRect() + + // Calculate the actual display size and position of the image in the container + const containerWidth = containerRect.width + const containerHeight = containerRect.height + const imageAspectRatio = img.naturalWidth / img.naturalHeight + const containerAspectRatio = containerWidth / containerHeight + + let displayWidth = 0, + displayHeight = 0 + + if (imageAspectRatio > containerAspectRatio) { + // The image is wider, use the container width + displayWidth = containerWidth + displayHeight = containerWidth / imageAspectRatio + } else { + // The image is taller, use the container height + displayHeight = containerHeight + displayWidth = containerHeight * imageAspectRatio + } + + const scaleX = img.naturalWidth / displayWidth + const scaleY = img.naturalHeight / displayHeight const deltaX = e.clientX - dragStart.x const deltaY = e.clientY - dragStart.y @@ -144,54 +206,38 @@ export const AvatarUploadModal = ({ newY += deltaY * scaleY } + // Keep the aspect ratio, use the larger change value const size = Math.max(newWidth, newHeight) + // Update the coordinates based on the position of the resize handle if (resizeHandle.includes("t")) newY = cropY + cropHeight - size if (resizeHandle.includes("l")) newX = cropX + cropWidth - size - newWidth = size - newHeight = size - - // Boundary checks - if (newWidth < 50) newWidth = 50 - if (newHeight < 50) newHeight = 50 - - if (newX < 0) { - newWidth += newX - newX = 0 - } - if (newY < 0) { - newHeight += newY - newY = 0 + const newCropData = { + x: newX, + y: newY, + width: size, + height: size, } - if (newX + newWidth > img.naturalWidth) { - newWidth = img.naturalWidth - newX - } - if (newY + newHeight > img.naturalHeight) { - newHeight = img.naturalHeight - newY - } - - const finalSize = Math.min(newWidth, newHeight) - - setCropData({ - width: finalSize, - height: finalSize, - x: resizeHandle.includes("l") ? newX + (newWidth - finalSize) : newX, - y: resizeHandle.includes("t") ? newY + (newHeight - finalSize) : newY, - }) + // Use the helper function to ensure the data is valid + const constrainedData = constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) + setCropData(constrainedData) } else if (isDragging) { const newX = dragStart.cropX + deltaX * scaleX const newY = dragStart.cropY + deltaY * scaleY - setCropData((prev) => ({ - ...prev, - x: Math.max(0, Math.min(newX, img.naturalWidth - prev.width)), - y: Math.max(0, Math.min(newY, img.naturalHeight - prev.height)), - })) + setCropData((prev) => { + const newCropData = { + ...prev, + x: newX, + y: newY, + } + return constrainCropData(newCropData, img.naturalWidth, img.naturalHeight) + }) } }, - [isDragging, resizeHandle, dragStart], + [isDragging, resizeHandle, dragStart, constrainCropData], ) const handleCropMouseUp = useCallback(() => { @@ -249,13 +295,46 @@ export const AvatarUploadModal = ({ }, [selectedImage, cropImage, onConfirm, t]) const cropStyle = useMemo(() => { - if (!imageRef.current) return {} + if (!imageRef.current || !containerRef.current) return {} + const img = imageRef.current + const container = containerRef.current + const containerRect = container.getBoundingClientRect() + + // Calculate the actual display size and position of the image in the container + const containerWidth = containerRect.width + const containerHeight = containerRect.height + const imageAspectRatio = img.naturalWidth / img.naturalHeight + const containerAspectRatio = containerWidth / containerHeight + + let displayWidth = 0, + displayHeight = 0, + offsetX = 0, + offsetY = 0 + + if (imageAspectRatio > containerAspectRatio) { + // The image is wider, use the container width + displayWidth = containerWidth + displayHeight = containerWidth / imageAspectRatio + offsetX = 0 + offsetY = (containerHeight - displayHeight) / 2 + } else { + // The image is taller, use the container height + displayHeight = containerHeight + displayWidth = containerHeight * imageAspectRatio + offsetX = (containerWidth - displayWidth) / 2 + offsetY = 0 + } + + // Calculate the scale ratio + const scaleX = displayWidth / img.naturalWidth + const scaleY = displayHeight / img.naturalHeight + return { - left: `${(cropData.x / img.naturalWidth) * 100}%`, - top: `${(cropData.y / img.naturalHeight) * 100}%`, - width: `${(cropData.width / img.naturalWidth) * 100}%`, - height: `${(cropData.height / img.naturalHeight) * 100}%`, + left: `${offsetX + cropData.x * scaleX}px`, + top: `${offsetY + cropData.y * scaleY}px`, + width: `${cropData.width * scaleX}px`, + height: `${cropData.height * scaleY}px`, } }, [cropData]) diff --git a/apps/desktop/layer/renderer/src/modules/profile/profile-setting-form.tsx b/apps/desktop/layer/renderer/src/modules/profile/profile-setting-form.tsx index 68f31e32b..d9f5fbfed 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/profile-setting-form.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/profile-setting-form.tsx @@ -19,7 +19,7 @@ import { toast } from "sonner" import { z } from "zod" import { setWhoami, useWhoami } from "~/atoms/user" -import { AvatarUploadModal } from "~/components/ui/avatar-upload-modal" +import { AvatarUploadModal } from "~/components/ui/crop/AvatarUploadModal" import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { updateUser } from "~/lib/auth" import { uploadAvatarBlob } from "~/lib/avatar-upload" diff --git a/apps/desktop/layer/renderer/src/modules/user/UserAvatar.tsx b/apps/desktop/layer/renderer/src/modules/user/UserAvatar.tsx index 6fdbed698..7525a5723 100644 --- a/apps/desktop/layer/renderer/src/modules/user/UserAvatar.tsx +++ b/apps/desktop/layer/renderer/src/modules/user/UserAvatar.tsx @@ -3,6 +3,7 @@ import { usePrefetchUser } from "@follow/store/user/hooks" import { getColorScheme, stringToHue } from "@follow/utils/color" import { cn } from "@follow/utils/utils" +import { useWhoami } from "~/atoms/user" import { replaceImgUrlIfNeed } from "~/lib/img-proxy" import { usePresentUserProfileModal } from "~/modules/profile/hooks" import { useSession } from "~/queries/auth" @@ -28,9 +29,10 @@ export const UserAvatar = ({ enableModal?: boolean } & LoginProps & React.HTMLAttributes & { ref?: React.Ref }) => { - const { session, status } = useSession({ + const { status } = useSession({ enabled: !userId, }) + const whoami = useWhoami() const presentUserProfile = usePresentUserProfileModal("drawer") const profile = usePrefetchUser(userId) @@ -39,7 +41,7 @@ export const UserAvatar = ({ return } - const renderUserData = userId ? profile.data : session?.user + const renderUserData = userId ? profile.data : whoami const randomColor = stringToHue(renderUserData?.name || "") return (