diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index 70c44d1b8a1..84d019f174c 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -12,11 +12,11 @@ import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ImageInput from '@/app/components/base/app-icon-picker/ImageInput' -import getCroppedImg from '@/app/components/base/app-icon-picker/utils' import Divider from '@/app/components/base/divider' import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' import { updateUserProfile } from '@/service/common' +import { createCroppedAvatarImage } from './avatar-image' type InputImageInfo = | { file: File } @@ -106,7 +106,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { handleLocalFileUpload(inputImageInfo.file) return } - const blob = await getCroppedImg( + const blob = await createCroppedAvatarImage( inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName, diff --git a/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx b/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx new file mode 100644 index 00000000000..f57b5ac4328 --- /dev/null +++ b/web/app/account/(commonLayout)/account-page/__tests__/AvatarWithEdit.spec.tsx @@ -0,0 +1,92 @@ +import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' +import type { ImageFile } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createCroppedAvatarImage } from '../avatar-image' +import AvatarWithEdit from '../AvatarWithEdit' + +type LocalFileUploaderOptions = { + onUpload: (imageFile: ImageFile) => void +} + +const mocks = vi.hoisted(() => ({ + animatedFile: new File(['animated'], 'avatar.gif', { type: 'image/gif' }), + handleLocalFileUpload: vi.fn<(file: File) => void>(), +})) + +vi.mock('@/config', () => ({ DISABLE_UPLOAD_IMAGE_AS_ICON: false })) + +vi.mock('@/app/components/base/app-icon-picker/ImageInput', () => ({ + default: ({ onImageInput }: { onImageInput?: OnImageInput }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/image-uploader/hooks', () => ({ + useLocalFileUploader: (_options: LocalFileUploaderOptions) => ({ + handleLocalFileUpload: mocks.handleLocalFileUpload, + }), +})) + +vi.mock('../avatar-image', () => ({ + createCroppedAvatarImage: vi.fn(), +})) + +const mockedCreateCroppedAvatarImage = vi.mocked(createCroppedAvatarImage) + +describe('AvatarWithEdit', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uploads the bounded crop for a static avatar', async () => { + const user = userEvent.setup() + const blob = new Blob(['bounded-avatar'], { type: 'image/png' }) + mockedCreateCroppedAvatarImage.mockResolvedValue(blob) + render() + + await user.click(screen.getByRole('button', { name: /avatar\.editAction/i })) + await user.click(screen.getByRole('button', { name: 'Select static avatar' })) + await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i })) + + await waitFor(() => { + expect(mockedCreateCroppedAvatarImage).toHaveBeenCalledWith( + 'blob:static-avatar', + { x: 10, y: 20, width: 1000, height: 1000 }, + 'avatar.png', + ) + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith( + expect.objectContaining({ name: 'avatar.png', type: 'image/png' }), + ) + }) + }) + + it('keeps the existing original-file upload path for animated avatars', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /avatar\.editAction/i })) + await user.click(screen.getByRole('button', { name: 'Select animated avatar' })) + await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i })) + + expect(mockedCreateCroppedAvatarImage).not.toHaveBeenCalled() + expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(mocks.animatedFile) + }) +}) diff --git a/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts b/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts new file mode 100644 index 00000000000..74308136813 --- /dev/null +++ b/web/app/account/(commonLayout)/account-page/__tests__/avatar-image.spec.ts @@ -0,0 +1,61 @@ +import type { Area } from 'react-easy-crop' +import { createImage } from '@/app/components/base/app-icon-picker/utils' +import { createCroppedAvatarImage, getBoundedAvatarImageSize } from '../avatar-image' + +vi.mock('@/app/components/base/app-icon-picker/utils', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + createImage: vi.fn(), + } +}) + +const mockedCreateImage = vi.mocked(createImage) + +describe('avatar image', () => { + describe('getBoundedAvatarImageSize', () => { + it('downsamples a large square crop to 256 pixels', () => { + expect(getBoundedAvatarImageSize({ width: 1000, height: 1000 })).toEqual({ + width: 256, + height: 256, + }) + }) + + it('does not upscale a crop that is already within the bound', () => { + expect(getBoundedAvatarImageSize({ width: 128, height: 128 })).toEqual({ + width: 128, + height: 128, + }) + }) + }) + + it('draws the selected crop into a bounded high-quality canvas', async () => { + const image = {} as HTMLImageElement + const expectedBlob = new Blob(['avatar'], { type: 'image/png' }) + const context = { + drawImage: vi.fn(), + imageSmoothingEnabled: false, + imageSmoothingQuality: 'low', + } as unknown as CanvasRenderingContext2D + const canvas = { + width: 0, + height: 0, + getContext: vi.fn(() => context), + toBlob: vi.fn((callback: BlobCallback) => callback(expectedBlob)), + } as unknown as HTMLCanvasElement + vi.spyOn(document, 'createElement').mockReturnValue(canvas) + mockedCreateImage.mockResolvedValue(image) + const crop: Area = { x: 40, y: 20, width: 1000, height: 1000 } + + const result = await createCroppedAvatarImage('blob:avatar', crop, 'avatar.png') + + expect(result).toBe(expectedBlob) + expect(canvas.width).toBe(256) + expect(canvas.height).toBe(256) + expect(context.imageSmoothingEnabled).toBe(true) + expect(context.imageSmoothingQuality).toBe('high') + expect(context.drawImage).toHaveBeenCalledWith(image, 40, 20, 1000, 1000, 0, 0, 256, 256) + expect(canvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', 0.85) + }) +}) diff --git a/web/app/account/(commonLayout)/account-page/avatar-image.ts b/web/app/account/(commonLayout)/account-page/avatar-image.ts new file mode 100644 index 00000000000..84240d4211a --- /dev/null +++ b/web/app/account/(commonLayout)/account-page/avatar-image.ts @@ -0,0 +1,57 @@ +import type { Area } from 'react-easy-crop' +import { createImage, getMimeType } from '@/app/components/base/app-icon-picker/utils' + +const AVATAR_IMAGE_MAX_SIZE = 256 +const AVATAR_IMAGE_QUALITY = 0.85 + +export const getBoundedAvatarImageSize = ( + crop: Pick, + maxSize = AVATAR_IMAGE_MAX_SIZE, +) => { + const scale = Math.min(1, maxSize / Math.max(crop.width, crop.height)) + + return { + width: Math.max(1, Math.round(crop.width * scale)), + height: Math.max(1, Math.round(crop.height * scale)), + } +} + +export const createCroppedAvatarImage = async ( + imageSrc: string, + pixelCrop: Area, + fileName: string, +): Promise => { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) throw new Error('Could not create a canvas context') + + const outputSize = getBoundedAvatarImageSize(pixelCrop) + canvas.width = outputSize.width + canvas.height = outputSize.height + context.imageSmoothingEnabled = true + context.imageSmoothingQuality = 'high' + context.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + outputSize.width, + outputSize.height, + ) + + return new Promise((resolve, reject) => { + canvas.toBlob( + (file) => { + if (file) resolve(file) + else reject(new Error('Could not create a blob')) + }, + getMimeType(fileName), + AVATAR_IMAGE_QUALITY, + ) + }) +}