fix(avatar-upload): cropping functionality keep aspect

- Added a helper function `constrainCropData` to ensure crop dimensions remain within image boundaries and maintain a 1:1 aspect ratio.
- Updated image loading and cropping logic to utilize the new helper function for better validation of crop data.
- Improved mouse event handling for cropping to ensure accurate resizing and positioning of the crop area.
- Adjusted crop style calculations to account for the actual display size of the image within its container.

These changes aim to provide a more robust and user-friendly experience when uploading and cropping avatars.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-18 13:47:56 +08:00
parent 71dc626996
commit b72ab7dffd
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
3 changed files with 131 additions and 50 deletions

View File

@ -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])

View File

@ -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"

View File

@ -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<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => {
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 <LoginButton {...props} />
}
const renderUserData = userId ? profile.data : session?.user
const renderUserData = userId ? profile.data : whoami
const randomColor = stringToHue(renderUserData?.name || "")
return (
<div