diff --git a/.changeset/mosaic-avatar-crop.md b/.changeset/mosaic-avatar-crop.md new file mode 100644 index 00000000000..15ed4b8a19c --- /dev/null +++ b/.changeset/mosaic-avatar-crop.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +Picking a profile picture in `UserProfile`, or a logo in `OrganizationProfile` or `CreateOrganization`, now opens a dialog to crop it before it uploads. Users can drag, zoom, rotate a quarter turn and reset, then save a 512px square PNG. Cancelling uploads nothing. GIFs upload as picked, so their animation is kept. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 03e42907fa7..3b1045491cb 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -85,7 +85,7 @@ Most problems a feature hits already have a shared answer. Find the line for you - An inline message row; height is undefined until measured → `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` - Data read from a Clerk resource that should be cached across mounts → A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) - The seconds left on a resend cooldown, for display → `useResendSeconds` (`src/hooks/`), from a `resendAvailableAt` time kept in the machine -- An avatar or logo row: picker, drop target, Upload button or manage menu → `AvatarUpload` (`src/blocks/avatar-upload/`), laid out by the caller +- An avatar or logo row: picker, drop target, crop dialog, Upload button or manage menu → `AvatarUpload` (`src/blocks/avatar-upload/`), laid out by the caller - Any other file picker or drop zone, such as a metadata or key file → `FileUpload` (`src/primitives/file-upload/`), see its `README.md` - Cropping an image to a square: pan, zoom, rotate, then draw the result → `ImageCropper` and `cropImage` (`src/primitives/image-cropper/`), see its `README.md` - An inline notice inside content: info, warning, error or success → `Banner` (`src/components/banner/`); a form-level error on a card uses `Card.Banner` diff --git a/packages/mosaic/src/__tests__/feature/image.ts b/packages/mosaic/src/__tests__/feature/image.ts new file mode 100644 index 00000000000..9e59f2bec30 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/image.ts @@ -0,0 +1,22 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; +import { expect } from 'vitest'; + +export async function imageFile(name: string, { width = 200, height = 100, type = 'image/png' } = {}): Promise { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('expected a 2D canvas'); + } + context.fillStyle = 'rgb(255 0 0)'; + context.fillRect(0, 0, width, height); + const blob = await canvas.convertToBlob({ type }); + return new File([blob], name, { type }); +} + +export async function saveCrop(actor: UserEvent, title: string): Promise { + const dialog = await screen.findByRole('dialog', { name: title }); + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Rotate' })).toBeEnabled()); + await actor.click(within(dialog).getByRole('button', { name: 'Save' })); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); +} diff --git a/packages/mosaic/src/blocks/avatar-upload/avatar-upload.styles.ts b/packages/mosaic/src/blocks/avatar-upload/avatar-upload.styles.ts index 924532d9c10..24561f1e19f 100644 --- a/packages/mosaic/src/blocks/avatar-upload/avatar-upload.styles.ts +++ b/packages/mosaic/src/blocks/avatar-upload/avatar-upload.styles.ts @@ -1,8 +1,13 @@ import * as stylex from '@stylexjs/stylex'; -import { durationVars, easingVars, space } from '../../tokens.stylex'; +import { colorVars, durationVars, easingVars, radiusVars, shadowVars, space } from '../../tokens.stylex'; import { avatarUploadDropzoneScope } from './avatar-upload.markers.stylex'; +const SCRIM = 'color-mix(in oklab, oklch(0 0 0) 55%, transparent)'; +const RING = 'oklch(1 0 0 / 0.85)'; +const THUMB = '16px'; +const TRACK = '4px'; + export const styles = stylex.create({ dropzone: { alignItems: 'center', @@ -45,4 +50,105 @@ export const styles = stylex.create({ [stylex.when.ancestor('[data-drag-rejected]', avatarUploadDropzoneScope)]: 'inline-block', }, }, + cropContent: { + display: 'flex', + flexDirection: 'column', + rowGap: space['4'], + }, + stage: { + position: 'relative', + }, + frame: { + borderRadius: radiusVars['--cl-radius-lg'], + overflow: 'hidden', + aspectRatio: '1', + backgroundColor: colorVars['--cl-color-surface-subtle'], + cursor: { + default: 'grab', + ':where([data-disabled])': 'default', + ':where([data-dragging])': 'grabbing', + }, + position: 'relative', + userSelect: 'none', + width: '100%', + }, + area: { + inset: '7%', + position: 'absolute', + }, + mask: { + inset: 0, + boxShadow: `0 0 0 100vmax ${SCRIM}, inset 0 0 0 2px ${RING}`, + pointerEvents: 'none', + position: 'absolute', + }, + circleMask: { + borderRadius: '50%', + }, + rotate: { + insetBlockStart: space['2'], + insetInlineEnd: space['2'], + position: 'absolute', + }, + clockwise: { + transform: 'scaleX(-1)', + }, + zoomRow: { + alignItems: 'center', + columnGap: space['2'], + display: 'flex', + }, + zoom: { + margin: 0, + borderRadius: radiusVars['--cl-radius-full'], + appearance: 'none', + backgroundColor: 'transparent', + cursor: { + default: 'pointer', + ':disabled': 'default', + }, + flexGrow: 1, + opacity: { + default: 1, + ':disabled': 0.5, + }, + height: THUMB, + minWidth: 0, + '::-moz-range-thumb': { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: 'oklch(1 0 0)', + boxShadow: shadowVars['--cl-shadow-sm'], + height: THUMB, + width: THUMB, + }, + '::-moz-range-track': { + borderRadius: radiusVars['--cl-radius-full'], + backgroundColor: colorVars['--cl-color-neutral-alpha-300'], + height: TRACK, + }, + '::-webkit-slider-runnable-track': { + borderRadius: radiusVars['--cl-radius-full'], + backgroundColor: colorVars['--cl-color-neutral-alpha-300'], + height: TRACK, + }, + '::-webkit-slider-thumb': { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + appearance: 'none', + backgroundColor: 'oklch(1 0 0)', + boxShadow: shadowVars['--cl-shadow-sm'], + height: THUMB, + marginTop: `calc((${TRACK} - ${THUMB}) / 2)`, + width: THUMB, + }, + }, + footerActions: { + columnGap: space['2'], + display: 'flex', + }, }); diff --git a/packages/mosaic/src/blocks/avatar-upload/avatar-upload.tsx b/packages/mosaic/src/blocks/avatar-upload/avatar-upload.tsx index 89b39ea9aca..a7032df3388 100644 --- a/packages/mosaic/src/blocks/avatar-upload/avatar-upload.tsx +++ b/packages/mosaic/src/blocks/avatar-upload/avatar-upload.tsx @@ -1,22 +1,30 @@ import * as stylex from '@stylexjs/stylex'; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useState } from 'react'; import type { ActionMenuAction } from '../../components/action-menu'; import { ActionMenu } from '../../components/action-menu'; import type { AvatarProps } from '../../components/avatar'; import { Avatar } from '../../components/avatar'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; import { useOptionalFieldControlProps } from '../../components/field/field.context'; import { Icon } from '../../components/icon'; +import { type PendingAction, usePendingAction } from '../../hooks/use-pending-action'; import { ArrowUp } from '../../icons/glyphs/arrow-up'; import { Block } from '../../icons/glyphs/block'; +import { Minus } from '../../icons/glyphs/minus'; +import { Plus } from '../../icons/glyphs/plus'; +import { RotateAntiClockwise } from '../../icons/glyphs/rotate-anti-clockwise'; import type { FileUploadProps } from '../../primitives/file-upload'; import { FileUpload } from '../../primitives/file-upload'; +import { type Crop, cropImage, ImageCropper, INITIAL_CROP } from '../../primitives/image-cropper'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; -import { AVATAR_UPLOAD } from '../../utils/avatar-upload'; +import { AVATAR_CROP, AVATAR_UPLOAD, croppedFile, skipsCrop } from '../../utils/avatar-upload'; import { avatarUploadDropzoneScope } from './avatar-upload.markers.stylex'; import { styles } from './avatar-upload.styles'; @@ -46,18 +54,55 @@ export interface AvatarUploadActionsProps extends Omit(); return ( - + setPick(current => (current ? { ...current, open: false } : current)), + }} + > { const file = files[0]; - if (file) { + if (!file) { + return; + } + if (skipsCrop(file)) { onChange?.(file); + } else { + setPick(current => ({ id: (current?.id ?? 0) + 1, file, open: true })); } }} /> @@ -151,12 +201,193 @@ function Actions({ labels, render, ...rest }: AvatarUploadActionsProps) { return control ? element : null; } +function Crop({ labels, shape = 'circle' }: AvatarUploadCropProps) { + const { pick, closeCrop } = useAvatarUploadContext(); + const src = FileUpload.useObjectUrl(pick?.file); + const save = usePendingAction(); + + return ( + { + if (!open && !save.isPending) { + save.reset(); + closeCrop(); + } + }} + shouldCloseOnInteractOutside={false} + > + + {pick && src ? ( + + ) : null} + + + ); +} + +interface CropStepProps extends Required { + file: File; + src: string; + save: PendingAction; +} + +function CropStep({ file, src, labels, shape, save }: CropStepProps) { + const { onChange, closeCrop } = useAvatarUploadContext(); + const [crop, setCrop] = useState(INITIAL_CROP); + + const apply = () => + save.run('crop', async () => { + const blob = await cropImage(src, crop, AVATAR_CROP); + onChange?.(croppedFile(file, blob)); + closeCrop(); + }); + + return ( + + + {labels.title} + + + {save.error} + + + +
+ + + + + + + + } + > + + +
+
+ + } + > + + + + + } + > + + +
+
+ + + } + > + {labels.reset} + +
+ + } + > + {labels.cancel} + + void apply()} + > + {labels.save} + +
+
+
+
+ ); +} + interface AvatarUploadContextValue { name: string; imageUrl: string | undefined; hasImage: boolean; canChange: boolean; + onChange: ((file: File) => void) | undefined; onRemove: (() => void) | undefined; + pick: Pick | undefined; + closeCrop: () => void; } const AvatarUploadContext = createContext(null); diff --git a/packages/mosaic/src/blocks/avatar-upload/index.ts b/packages/mosaic/src/blocks/avatar-upload/index.ts index db386f55e06..60b25e9fa7a 100644 --- a/packages/mosaic/src/blocks/avatar-upload/index.ts +++ b/packages/mosaic/src/blocks/avatar-upload/index.ts @@ -2,6 +2,8 @@ export { AvatarUpload } from './avatar-upload'; export type { AvatarUploadActionsLabels, AvatarUploadActionsProps, + AvatarUploadCropLabels, + AvatarUploadCropProps, AvatarUploadDropzoneProps, AvatarUploadImageProps, AvatarUploadRootProps, diff --git a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx index adb8b52aebf..bc48b1fce56 100644 --- a/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx +++ b/packages/mosaic/src/features/create-organization/__tests__/create-organization.feature.test.tsx @@ -13,6 +13,7 @@ import { fapiSession, fapiUser, } from '../../../__tests__/feature/fapi'; +import { imageFile, saveCrop } from '../../../__tests__/feature/image'; import { renderWithClerk } from '../../../__tests__/feature/render'; import type { CreateOrganizationProps } from '../create-organization'; import { CreateOrganization } from '../create-organization'; @@ -179,7 +180,8 @@ describe('CreateOrganization', () => { return element as HTMLInputElement; }); - await user.upload(input, new File(['logo'], 'logo.png', { type: 'image/png' })); + await user.upload(input, await imageFile('logo.png')); + await saveCrop(user, 'Adjust logo'); await create(user, 'Acme'); expect(await screen.findByRole('heading', { name: 'Invite new members' })).toBeVisible(); @@ -191,7 +193,8 @@ describe('CreateOrganization', () => { const user = userEvent.setup(); const row = await screen.findByText('Logo'); - dropFiles(row, new File(['logo'], 'logo.png', { type: 'image/png' })); + dropFiles(row, await imageFile('logo.png')); + await saveCrop(user, 'Adjust logo'); expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Manage logo' })); diff --git a/packages/mosaic/src/features/create-organization/create-organization.messages.ts b/packages/mosaic/src/features/create-organization/create-organization.messages.ts index 3a98f88c5a7..c6337805ad6 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.messages.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.messages.ts @@ -8,6 +8,17 @@ export const createOrganizationMessages = { manage: 'Manage logo', change: 'Change logo', remove: 'Remove logo', + crop: { + title: 'Adjust logo', + frame: 'Logo crop. Drag or use the arrow keys to move, plus and minus to zoom.', + zoom: 'Zoom', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + rotate: 'Rotate', + reset: 'Reset', + cancel: 'Cancel', + save: 'Save', + }, }, nameLabel: 'Name', namePlaceholder: 'Organization name', diff --git a/packages/mosaic/src/features/create-organization/create-organization.view.tsx b/packages/mosaic/src/features/create-organization/create-organization.view.tsx index 3df5037414f..dcba239e590 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.view.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.view.tsx @@ -207,6 +207,10 @@ function LogoField({ + diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx index de788ee8b6a..98392120310 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.feature.test.tsx @@ -14,6 +14,7 @@ import { fapiSession, fapiUser, } from '../../../__tests__/feature/fapi'; +import { imageFile, saveCrop } from '../../../__tests__/feature/image'; import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicProvider } from '../../../mosaic-provider'; @@ -362,7 +363,8 @@ describe('OrganizationProfileGeneralPanel', () => { throw new Error('Logo upload input is missing'); } const upload = holdRequests('post', '/v1/organizations/:organizationId/logo'); - await user.upload(input, new File(['image'], 'logo.png', { type: 'image/png' })); + await user.upload(input, await imageFile('logo.png')); + await saveCrop(user, 'Adjust logo'); await waitFor(() => expect(upload.requests).toHaveLength(1)); expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled(); expect(screen.queryByRole('button', { name: 'Manage logo' })).toBeNull(); @@ -386,14 +388,16 @@ describe('OrganizationProfileGeneralPanel', () => { it('marks the row while a file is dragged over it and uploads the file dropped on it', async () => { const { fapi } = await renderPanel(); + const user = userEvent.setup(); const row = await screen.findByText('Logo'); - const file = new File(['image'], 'logo.png', { type: 'image/png' }); + const file = await imageFile('logo.png'); dragFilesOver(row, file); expect(row.closest('[data-dragging]')).not.toBeNull(); dropFiles(row, file); expect(row.closest('[data-dragging]')).toBeNull(); + await saveCrop(user, 'Adjust logo'); await waitFor(() => expect(fapi.memberships[0]?.organization.has_image).toBe(true)); }); @@ -440,12 +444,9 @@ describe('OrganizationProfileGeneralPanel', () => { throw new Error('Logo upload input is missing'); } expect(container.querySelector('img[alt="Acme"]')).toBeNull(); - const png = Uint8Array.from( - atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/zXcAAAAASUVORK5CYII='), - character => character.charCodeAt(0), - ); const upload = holdRequests('post', '/v1/organizations/:organizationId/logo'); - await user.upload(input, new File([png], 'logo.png', { type: 'image/png' })); + await user.upload(input, await imageFile('logo.png')); + await saveCrop(user, 'Adjust logo'); await waitFor(() => expect(upload.requests).toHaveLength(1)); await waitFor(() => expect(container.querySelector('img[alt="Acme"]')?.getAttribute('src')).toMatch(/^blob:/)); upload.fail('image_invalid', 'Server rejected logo'); @@ -453,7 +454,8 @@ describe('OrganizationProfileGeneralPanel', () => { await waitFor(() => expect(screen.getByText('Server rejected logo')).toBeVisible()); await waitFor(() => expect(container.querySelector('img[alt="Acme"]')).toBeNull()); const retry = serveFapi(signedIn()); - await user.upload(input, new File(['image2'], 'second.png', { type: 'image/png' })); + await user.upload(input, await imageFile('second.png')); + await saveCrop(user, 'Adjust logo'); await waitFor(() => expect(retry.memberships[0]?.organization.has_image).toBe(true)); expect(await screen.findByRole('button', { name: 'Manage logo' })).toBeVisible(); }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx index 17788bd4baa..c050d6e9f49 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-logo-row.view.tsx @@ -80,6 +80,10 @@ export function OrganizationProfileLogoRowView({ /> {error} + ); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts index e1b8bcc2b7e..f9c09fb4e14 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts @@ -7,6 +7,17 @@ export const organizationProfileProfileSectionMessages = { manage: 'Manage logo', change: 'Change logo', remove: 'Remove logo', + crop: { + title: 'Adjust logo', + frame: 'Logo crop. Drag or use the arrow keys to move, plus and minus to zoom.', + zoom: 'Zoom', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + rotate: 'Rotate', + reset: 'Reset', + cancel: 'Cancel', + save: 'Save', + }, }, name: { label: 'Name', diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx index 20359af94fd..48984d0b196 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx @@ -1,7 +1,7 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { dragFilesOver, dropFiles } from '../../../../__tests__/feature/drag'; import { @@ -21,6 +21,7 @@ import { fapiSession, fapiUser, } from '../../../../__tests__/feature/fapi'; +import { imageFile, saveCrop } from '../../../../__tests__/feature/image'; import { openMenu } from '../../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../../__tests__/feature/render'; import { UserProfileProfileSection } from '../user-profile-profile-section'; @@ -141,7 +142,8 @@ describe('the user profile profile section', () => { const { actor, container } = await renderSection(); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).not.toBeInTheDocument(); - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await actor.upload(fileInput(container), await imageFile('me.png')); + await saveCrop(actor, 'Adjust profile picture'); await openMenu(actor, await screen.findByRole('button', { name: 'Manage profile picture' })); expect(screen.getByRole('menuitem', { name: 'Remove avatar' })).toBeInTheDocument(); @@ -150,16 +152,112 @@ describe('the user profile profile section', () => { expect(screen.getByRole('menuitem', { name: 'Remove avatar' })).toHaveAttribute('data-color', 'negative'); }); - it('marks the row while a file is dragged over it and uploads the file dropped on it', async () => { - await renderSection(); + it('crops a picked picture to a square before uploading it', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + + await actor.upload(fileInput(container), await imageFile('me.jpg', { type: 'image/jpeg' })); + await screen.findByRole('dialog', { name: 'Adjust profile picture' }); + expect(upload.requests).toHaveLength(0); + + await saveCrop(actor, 'Adjust profile picture'); + await waitFor(() => expect(upload.requests).toHaveLength(1)); + const sent = (await upload.requests[0].formData()).get('file'); + upload.release(); + + if (!(sent instanceof File)) { + throw new Error('expected an uploaded file'); + } + expect(sent).toMatchObject({ name: 'me.png', type: 'image/png' }); + const image = await createImageBitmap(sent); + expect([image.width, image.height]).toEqual([512, 512]); + }); + + it('uploads nothing when the crop is cancelled', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + + await actor.upload(fileInput(container), await imageFile('me.png')); + const dialog = await screen.findByRole('dialog', { name: 'Adjust profile picture' }); + await actor.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + expect(upload.requests).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).not.toBeInTheDocument(); + upload.release(); + }); + + it('keeps the crop open until a pending save finishes', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + const encodes: Array<() => void> = []; + const toBlob = HTMLCanvasElement.prototype.toBlob; + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function ( + this: HTMLCanvasElement, + ...args: Parameters + ) { + encodes.push(() => toBlob.apply(this, args)); + }); + + await actor.upload(fileInput(container), await imageFile('me.png')); + const dialog = await screen.findByRole('dialog', { name: 'Adjust profile picture' }); + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Rotate' })).toBeEnabled()); + await actor.click(within(dialog).getByRole('button', { name: 'Save' })); + await waitFor(() => expect(encodes).toHaveLength(1)); + + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await actor.keyboard('{Escape}'); + expect(dialog).toBeInTheDocument(); + expect(upload.requests).toHaveLength(0); + + encodes[0](); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await waitFor(() => expect(upload.requests).toHaveLength(1)); + upload.release(); + }); + + it('shows why a crop failed, and clears it for the next pick', async () => { + const { actor, container } = await renderSection(); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementationOnce(callback => callback(null)); + + await actor.upload(fileInput(container), await imageFile('me.png')); + const dialog = await screen.findByRole('dialog', { name: 'Adjust profile picture' }); + await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Rotate' })).toBeEnabled()); + await actor.click(within(dialog).getByRole('button', { name: 'Save' })); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveAttribute('data-open')); + + await actor.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await actor.upload(fileInput(container), await imageFile('me.png')); + const next = await screen.findByRole('dialog', { name: 'Adjust profile picture' }); + expect(within(next).getByRole('alert')).not.toHaveAttribute('data-open'); + expect(within(next).getByRole('alert').textContent).toBe(''); + }); + + it('uploads a GIF as picked, without cropping it', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + const gif = new File(['x'], 'me.gif', { type: 'image/gif' }); + + await actor.upload(fileInput(container), gif); + + await waitFor(() => expect(upload.requests).toHaveLength(1)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect((await upload.requests[0].formData()).get('file')).toMatchObject({ name: 'me.gif', type: 'image/gif' }); + upload.release(); + }); + + it('marks the row while a file is dragged over it and crops the file dropped on it', async () => { + const { actor } = await renderSection(); const row = screen.getByText('Profile picture'); - const file = new File(['x'], 'me.png', { type: 'image/png' }); + const file = await imageFile('me.png'); dragFilesOver(row, file); expect(row.closest('[data-dragging]')).not.toBeNull(); dropFiles(row, file); expect(row.closest('[data-dragging]')).toBeNull(); + await saveCrop(actor, 'Adjust profile picture'); expect(await screen.findByRole('button', { name: 'Manage profile picture' })).toBeInTheDocument(); }); @@ -181,7 +279,8 @@ describe('the user profile profile section', () => { const { actor, container } = await renderSection(); failsWith('/v1/me/profile_image', { code: 'avatar_file_size_exceeded', message: 'Too large' }, 413); - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await actor.upload(fileInput(container), await imageFile('me.png')); + await saveCrop(actor, 'Adjust profile picture'); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent( @@ -207,13 +306,15 @@ describe('the user profile profile section', () => { await actor.upload(fileInput(container), oversized()); expect(screen.getByRole('alert')).toBeInTheDocument(); - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await actor.upload(fileInput(container), await imageFile('me.png')); + await saveCrop(actor, 'Adjust profile picture'); await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); }); it('replaces a rejected pick with why removing the picture failed', async () => { const { actor, container } = await renderSection(); - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await actor.upload(fileInput(container), await imageFile('me.png')); + await saveCrop(actor, 'Adjust profile picture'); await screen.findByRole('button', { name: 'Manage profile picture' }); await waitFor(() => expect(fileInput(container)).toBeEnabled()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx index 3d67b7cd693..2e676e46d79 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx @@ -48,6 +48,7 @@ export function UserProfilePictureRowView({ /> {error} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts index daf0d07ebd6..4b1314ae5e5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts @@ -7,6 +7,17 @@ export const userProfileProfileSectionMessages = { manage: 'Manage profile picture', change: 'Change avatar', remove: 'Remove avatar', + crop: { + title: 'Adjust profile picture', + frame: 'Profile picture crop. Drag or use the arrow keys to move, plus and minus to zoom.', + zoom: 'Zoom', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + rotate: 'Rotate', + reset: 'Reset', + cancel: 'Cancel', + save: 'Save', + }, }, name: { label: 'Name', diff --git a/packages/mosaic/src/utils/avatar-upload.ts b/packages/mosaic/src/utils/avatar-upload.ts index f7d209749ed..67e1004ccd0 100644 --- a/packages/mosaic/src/utils/avatar-upload.ts +++ b/packages/mosaic/src/utils/avatar-upload.ts @@ -7,6 +7,20 @@ export const AVATAR_UPLOAD = { maxSize: 10 * 1000 * 1000, } as const; +/** A crop is saved as a square PNG, which keeps a logo's transparency. */ +export const AVATAR_CROP = { size: 512, type: 'image/png' } as const; + +/** A GIF is uploaded as picked, because drawing it to a canvas keeps only its first frame. */ +export function skipsCrop(file: File): boolean { + return file.type === 'image/gif'; +} + +/** The cropped image as a file named after the one the user picked. */ +export function croppedFile(picked: File, blob: Blob): File { + const base = picked.name.replace(/\.[^.]+$/, ''); + return new File([blob], `${base}.png`, { type: blob.type }); +} + /** Rejecting a pick locally reads the same as the server rejecting the upload. */ export const AVATAR_REJECTION_ERRORS: Record = { accept: { code: 'avatar_file_type_invalid' },