diff --git a/.changeset/mosaic-avatar-crop.md b/.changeset/mosaic-avatar-crop.md new file mode 100644 index 00000000000..95180675442 --- /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 to a square before it uploads. Animated images upload as picked. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 50b8f72e92b..237a7616ca8 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..eedb0c27946 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/image.ts @@ -0,0 +1,32 @@ +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 }); +} + +/** A PNG with an animation control chunk, which browsers play as an animated PNG. */ +export async function animatedPngFile(name: string): Promise { + const still = new Uint8Array(await (await imageFile(name)).arrayBuffer()); + const afterHeader = 8 + 25; + const control = new Uint8Array([0, 0, 0, 8, ...[...'acTL'].map(char => char.charCodeAt(0)), 0, 0, 0, 1, 0, 0, 0, 0]); + return new File([still.slice(0, afterHeader), control, new Uint8Array(4), still.slice(afterHeader)], name, { + type: 'image/png', + }); +} + +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..90c9b7ce0a6 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, 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_UPLOAD, cropAvatar, 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) { - onChange?.(file); + if (!file) { + return; } + void skipsCrop(file) + .catch(() => false) + .then(skip => { + if (skip) { + onChange?.(file); + } else { + setPick(current => ({ id: (current?.id ?? 0) + 1, file, open: true })); + } + }); }} /> @@ -151,12 +205,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 cropAvatar(src, crop, file.type); + 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..8334f897bfd 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 { animatedPngFile, 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,160 @@ 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.jpg', type: 'image/jpeg' }); + const image = await createImageBitmap(sent); + expect([image.width, image.height]).toEqual([100, 100]); + }); + + it('keeps a picked WebP a WebP', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + + await actor.upload(fileInput(container), await imageFile('me.webp', { type: 'image/webp' })); + await saveCrop(actor, 'Adjust profile picture'); + + await waitFor(() => expect(upload.requests).toHaveLength(1)); + expect((await upload.requests[0].formData()).get('file')).toMatchObject({ name: 'me.webp', type: 'image/webp' }); + upload.release(); + }); + + it('says a picture is too large when its crop cannot be saved under the limit, and uploads nothing', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + const toBlob = vi + .spyOn(HTMLCanvasElement.prototype, 'toBlob') + .mockImplementation(callback => + callback(new Blob([new Uint8Array(10 * 1000 * 1000 + 1)], { type: 'image/png' })), + ); + + 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')).toHaveTextContent( + 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + ), + ); + expect(toBlob).toHaveBeenCalledTimes(3); + expect(upload.requests).toHaveLength(0); + upload.release(); + }); + + 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('uploads an animated PNG as picked, without cropping it', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + + await actor.upload(fileInput(container), await animatedPngFile('me.png')); + + await waitFor(() => expect(upload.requests).toHaveLength(1)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect((await upload.requests[0].formData()).get('file')).toMatchObject({ name: 'me.png', type: 'image/png' }); + 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 +327,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 +354,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/primitives/image-cropper/README.md b/packages/mosaic/src/primitives/image-cropper/README.md index 04a5ed852d0..2a96a6a51a6 100644 --- a/packages/mosaic/src/primitives/image-cropper/README.md +++ b/packages/mosaic/src/primitives/image-cropper/README.md @@ -37,7 +37,7 @@ function Cropper({ src, onSave }: { src: string; onSave: (blob: Blob) => void }) + Rotate Reset - + ); } @@ -83,10 +83,13 @@ fraction of the frame. `zoom` 1 is the smallest scale that still covers the fram Other parts take no additional props. All parts except `Root` accept a `render` prop and standard HTML attributes for their default element. -### `cropImage(src, crop, { size, type })` +### `cropImage(src, crop, { type, size })` -Returns a `Promise` holding the square the crop shows, `size` pixels wide, encoded as `type`. -It draws to a canvas, so an animated image keeps only its first frame. +Returns a `Promise` holding the square the crop shows, encoded as `type`. A browser that +cannot encode `type` returns `image/png`, so read the type from the blob. The square keeps the +image's own resolution unless `size` says otherwise: it gets the square's side in the image's pixels +and returns the side to draw, such as `side => Math.min(side, 1024)` to cap it. Colors outside sRGB +are kept. It draws to a canvas, so an animated image keeps only its first frame. ## Keyboard diff --git a/packages/mosaic/src/primitives/image-cropper/__tests__/crop-image.browser.test.tsx b/packages/mosaic/src/primitives/image-cropper/__tests__/crop-image.browser.test.tsx index 4e6718e84c0..b2ad4cc1a93 100644 --- a/packages/mosaic/src/primitives/image-cropper/__tests__/crop-image.browser.test.tsx +++ b/packages/mosaic/src/primitives/image-cropper/__tests__/crop-image.browser.test.tsx @@ -21,50 +21,126 @@ function halves(width: number, height: number): string { return canvas.toDataURL('image/png'); } -async function pixels(blob: Blob) { +interface SolidOptions { + colorSpace?: PredefinedColorSpace; +} + +function leftHalf(fill: string, { colorSpace = 'srgb' }: SolidOptions = {}): string { + const width = 100; + const height = 100; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { colorSpace }); + if (!context) { + throw new Error('no canvas'); + } + context.fillStyle = fill; + context.fillRect(0, 0, width / 2, height); + return canvas.toDataURL('image/png'); +} + +async function pixels(blob: Blob, colorSpace: PredefinedColorSpace = 'srgb') { const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); - const context = canvas.getContext('2d'); + const context = canvas.getContext('2d', { colorSpace }); if (!context) { throw new Error('no canvas'); } context.drawImage(bitmap, 0, 0); - const at = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data.slice(0, 3)]; + const at = (x: number, y: number) => [...context.getImageData(x, y, 1, 1, { colorSpace }).data]; return { width: bitmap.width, height: bitmap.height, at }; } describe('cropImage', () => { it('draws the square the frame shows at the requested size and type', async () => { - const blob = await cropImage(halves(200, 100), INITIAL_CROP, { size: 64, type: 'image/png' }); + const blob = await cropImage(halves(200, 100), INITIAL_CROP, { type: 'image/png', size: () => 64 }); const image = await pixels(blob); expect(blob.type).toBe('image/png'); expect([image.width, image.height]).toEqual([64, 64]); - expect(image.at(8, 32)).toEqual(RED); - expect(image.at(56, 32)).toEqual(BLUE); + expect(image.at(8, 32)).toEqual([...RED, 255]); + expect(image.at(56, 32)).toEqual([...BLUE, 255]); + }); + + it('draws at the size of the square in the image, without scaling it, by default', async () => { + const blob = await cropImage(halves(200, 100), INITIAL_CROP, { type: 'image/png' }); + const image = await pixels(blob); + + expect([image.width, image.height]).toEqual([100, 100]); + }); + + it('draws a zoomed crop at the size it covers in the image', async () => { + const blob = await cropImage(halves(200, 100), { ...INITIAL_CROP, zoom: 3 }, { type: 'image/png' }); + const image = await pixels(blob); + + expect([image.width, image.height]).toEqual([33, 33]); + }); + + it('passes the size of the square in the image to size', async () => { + const sides: number[] = []; + await cropImage( + halves(200, 100), + { ...INITIAL_CROP, zoom: 2 }, + { + type: 'image/png', + size: side => { + sides.push(side); + return side; + }, + }, + ); + + expect(sides).toEqual([50]); + }); + + it('encodes the requested type', async () => { + const blob = await cropImage(halves(200, 100), INITIAL_CROP, { type: 'image/jpeg' }); + + expect(blob.type).toBe('image/jpeg'); + }); + + it('keeps transparency', async () => { + const blob = await cropImage(leftHalf('rgb(255 0 0)'), INITIAL_CROP, { type: 'image/png' }); + const image = await pixels(blob); + + expect(image.at(10, 50)).toEqual([...RED, 255]); + expect(image.at(90, 50)).toEqual([0, 0, 0, 0]); + }); + + it('keeps colors outside sRGB', async () => { + const src = leftHalf('color(display-p3 1 0 0)', { colorSpace: 'display-p3' }); + const blob = await cropImage(src, INITIAL_CROP, { type: 'image/png' }); + const image = await pixels(blob, 'display-p3'); + + expect(image.at(10, 50)).toEqual([...RED, 255]); }); it('follows a pan', async () => { - const blob = await cropImage(halves(200, 100), { ...INITIAL_CROP, x: 0.5 }, { size: 64, type: 'image/png' }); + const blob = await cropImage(halves(200, 100), { ...INITIAL_CROP, x: 0.5 }, { type: 'image/png', size: () => 64 }); const image = await pixels(blob); - expect(image.at(8, 32)).toEqual(RED); - expect(image.at(56, 32)).toEqual(RED); + expect(image.at(8, 32)).toEqual([...RED, 255]); + expect(image.at(56, 32)).toEqual([...RED, 255]); }); it('follows a quarter turn', async () => { - const blob = await cropImage(halves(200, 100), { ...INITIAL_CROP, rotation: 90 }, { size: 64, type: 'image/png' }); + const blob = await cropImage( + halves(200, 100), + { ...INITIAL_CROP, rotation: 90 }, + { type: 'image/png', size: () => 64 }, + ); const image = await pixels(blob); - expect(image.at(32, 8)).toEqual(RED); - expect(image.at(32, 56)).toEqual(BLUE); + expect(image.at(32, 8)).toEqual([...RED, 255]); + expect(image.at(32, 56)).toEqual([...BLUE, 255]); }); it('clamps a crop that would leave part of the square outside the image', async () => { - const blob = await cropImage(halves(100, 100), { ...INITIAL_CROP, x: 0.5 }, { size: 64, type: 'image/png' }); + const blob = await cropImage(halves(100, 100), { ...INITIAL_CROP, x: 0.5 }, { type: 'image/png', size: () => 64 }); const image = await pixels(blob); - expect(image.at(8, 32)).toEqual(RED); - expect(image.at(56, 32)).toEqual(BLUE); + expect(image.at(8, 32)).toEqual([...RED, 255]); + expect(image.at(56, 32)).toEqual([...BLUE, 255]); }); }); diff --git a/packages/mosaic/src/primitives/image-cropper/crop-image.ts b/packages/mosaic/src/primitives/image-cropper/crop-image.ts index 5fc2e359a2b..5c5f8b5f93a 100644 --- a/packages/mosaic/src/primitives/image-cropper/crop-image.ts +++ b/packages/mosaic/src/primitives/image-cropper/crop-image.ts @@ -1,19 +1,23 @@ import { clampCrop, type Crop, pixelCrop, turned } from './crop'; export interface CropImageOptions { - /** Width and height of the output, in pixels. */ - size: number; - /** Image type to encode, such as `image/png`. */ + /** Image type to encode, such as `image/png`. A browser that cannot encode it falls back to `image/png`. */ type: string; + /** Width and height of the output, given the side of the square in the image's own pixels. Defaults to that side. */ + size?: (side: number) => number; } /** * Draws the square a crop shows into a new image. * * @example - * const blob = await cropImage(objectUrl, crop, { size: 512, type: 'image/png' }); + * const blob = await cropImage(objectUrl, crop, { type: file.type, size: side => Math.min(side, 1024) }); */ -export async function cropImage(src: string, crop: Crop, { size, type }: CropImageOptions): Promise { +export async function cropImage( + src: string, + crop: Crop, + { type, size = side => side }: CropImageOptions, +): Promise { const image = new Image(); image.src = src; await image.decode(); @@ -22,15 +26,16 @@ export async function cropImage(src: string, crop: Crop, { size, type }: CropIma const area = pixelCrop(clampCrop(crop, natural, Number.POSITIVE_INFINITY), natural); const upright = turned(natural, crop.rotation); + const side = Math.max(1, Math.round(size(area.size))); const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); + canvas.width = side; + canvas.height = side; + const context = canvas.getContext('2d', { colorSpace: 'display-p3' }); if (!context) { throw new Error('Canvas 2D context is unavailable'); } context.imageSmoothingQuality = 'high'; - context.scale(size / area.size, size / area.size); + context.scale(side / area.size, side / area.size); context.translate(upright.width / 2 - area.x, upright.height / 2 - area.y); context.rotate((crop.rotation * Math.PI) / 180); context.drawImage(image, -natural.width / 2, -natural.height / 2); diff --git a/packages/mosaic/src/utils/__tests__/avatar-upload.browser.test.tsx b/packages/mosaic/src/utils/__tests__/avatar-upload.browser.test.tsx new file mode 100644 index 00000000000..f983ee4b699 --- /dev/null +++ b/packages/mosaic/src/utils/__tests__/avatar-upload.browser.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { croppedFile, skipsCrop } from '../avatar-upload'; + +const ascii = (text: string) => [...text].map(char => char.charCodeAt(0)); +const uint32 = (value: number) => [value >>> 24, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; + +function png(...chunks: Array<[type: string, length: number]>): File { + const bytes = [0x89, ...ascii('PNG'), 0x0d, 0x0a, 0x1a, 0x0a]; + for (const [type, length] of chunks) { + bytes.push(...uint32(length), ...ascii(type), ...new Array(length).fill(0), 0, 0, 0, 0); + } + return new File([new Uint8Array(bytes)], 'me.png', { type: 'image/png' }); +} + +function webp(chunk: string, flags = 0): File { + const bytes = [...ascii('RIFF'), 0, 0, 0, 0, ...ascii('WEBP'), ...ascii(chunk), 10, 0, 0, 0, flags, 0, 0, 0]; + return new File([new Uint8Array(bytes)], 'me.webp', { type: 'image/webp' }); +} + +describe('skipsCrop', () => { + it.each([ + ['a GIF', true, new File(['GIF89a'], 'me.gif', { type: 'image/gif' })], + ['an animated PNG', true, png(['IHDR', 13], ['acTL', 8], ['IDAT', 4], ['IEND', 0])], + ['an animated PNG with chunks before acTL', true, png(['IHDR', 13], ['iCCP', 300], ['acTL', 8], ['IDAT', 4])], + ['a still PNG', false, png(['IHDR', 13], ['IDAT', 4], ['IEND', 0])], + ['a PNG with acTL after its image data', false, png(['IHDR', 13], ['IDAT', 4], ['acTL', 8])], + [ + 'a cut-off PNG', + false, + new File([png(['IHDR', 13], ['iCCP', 300]).slice(0, 40)], 'me.png', { type: 'image/png' }), + ], + ['an animated WebP', true, webp('VP8X', 0x02)], + ['an extended WebP that does not animate', false, webp('VP8X', 0x10)], + ['a lossy WebP', false, webp('VP8 ')], + ['a lossless WebP', false, webp('VP8L')], + ['a JPEG', false, new File([new Uint8Array([0xff, 0xd8, 0xff])], 'me.jpg', { type: 'image/jpeg' })], + ])('for %s is %s', async (_, expected, file) => { + expect(await skipsCrop(file)).toBe(expected); + }); +}); + +describe('croppedFile', () => { + it.each([ + ['me.jpeg', 'image/jpeg', 'image/jpeg', 'me.jpeg'], + ['me.webp', 'image/webp', 'image/webp', 'me.webp'], + ['me.webp', 'image/webp', 'image/png', 'me.png'], + ['me.PNG', 'image/png', 'image/png', 'me.PNG'], + ['photo.final.jpg', 'image/jpeg', 'image/jpeg', 'photo.final.jpg'], + ['me', 'image/webp', 'image/png', 'me.png'], + ])('names a crop of %s (%s) saved as %s %j', (name, pickedType, savedType, expected) => { + const file = croppedFile(new File(['x'], name, { type: pickedType }), new Blob(['y'], { type: savedType })); + + expect(file).toMatchObject({ name: expected, type: savedType }); + }); +}); diff --git a/packages/mosaic/src/utils/__tests__/crop-avatar.browser.test.tsx b/packages/mosaic/src/utils/__tests__/crop-avatar.browser.test.tsx new file mode 100644 index 00000000000..b6ccc6d1684 --- /dev/null +++ b/packages/mosaic/src/utils/__tests__/crop-avatar.browser.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { INITIAL_CROP } from '../../primitives/image-cropper'; +import { AVATAR_UPLOAD, cropAvatar } from '../avatar-upload'; + +async function noise(side: number, type: string, quality?: number): Promise { + const canvas = new OffscreenCanvas(side, side); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('expected a 2D canvas'); + } + const image = context.createImageData(side, side); + for (let at = 0; at < image.data.length; at += 65536) { + crypto.getRandomValues(image.data.subarray(at, at + 65536)); + } + for (let at = 3; at < image.data.length; at += 4) { + image.data[at] = 255; + } + context.putImageData(image, 0, 0); + return URL.createObjectURL(await canvas.convertToBlob({ type, quality })); +} + +async function solid(side: number): Promise { + const canvas = new OffscreenCanvas(side, side); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('expected a 2D canvas'); + } + context.fillStyle = 'rgb(255 0 0)'; + context.fillRect(0, 0, side, side); + return URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' })); +} + +async function side(blob: Blob): Promise { + const image = await createImageBitmap(blob); + return image.width; +} + +describe('cropAvatar', () => { + it('keeps a crop that fits at the size of the square in the image', async () => { + const blob = await cropAvatar(await solid(300), INITIAL_CROP, 'image/png'); + + expect(await side(blob)).toBe(300); + }); + + it('shrinks a large image to 4096 pixels a side', async () => { + const blob = await cropAvatar(await solid(6000), INITIAL_CROP, 'image/png'); + + expect(await side(blob)).toBe(4096); + }); + + it('shrinks a crop that encodes over the size limit until it fits', async () => { + const blob = await cropAvatar(await noise(2000, 'image/png'), INITIAL_CROP, 'image/png'); + + expect(blob.size).toBeLessThanOrEqual(AVATAR_UPLOAD.maxSize); + expect(blob.type).toBe('image/png'); + expect(await side(blob)).toBeGreaterThan(1000); + expect(await side(blob)).toBeLessThan(2000); + }); + + it('fits a crop the browser saves as PNG because it cannot encode the picked type', async () => { + const blob = await cropAvatar(await noise(2000, 'image/jpeg', 0.5), INITIAL_CROP, 'image/webp'); + + expect(blob.size).toBeLessThanOrEqual(AVATAR_UPLOAD.maxSize); + }); + + it('fits a heavily compressed photo at the largest size', async () => { + const blob = await cropAvatar(await noise(4096, 'image/jpeg', 0.3), INITIAL_CROP, 'image/jpeg'); + + expect(blob.size).toBeLessThanOrEqual(AVATAR_UPLOAD.maxSize); + expect(blob.type).toBe('image/jpeg'); + }); +}); diff --git a/packages/mosaic/src/utils/__tests__/crop-avatar.webkit.test.tsx b/packages/mosaic/src/utils/__tests__/crop-avatar.webkit.test.tsx new file mode 100644 index 00000000000..d12f47c07d3 --- /dev/null +++ b/packages/mosaic/src/utils/__tests__/crop-avatar.webkit.test.tsx @@ -0,0 +1 @@ +import './crop-avatar.browser.test'; diff --git a/packages/mosaic/src/utils/avatar-upload.ts b/packages/mosaic/src/utils/avatar-upload.ts index f7d209749ed..b8e1d01b24c 100644 --- a/packages/mosaic/src/utils/avatar-upload.ts +++ b/packages/mosaic/src/utils/avatar-upload.ts @@ -1,5 +1,7 @@ import type { LocalizableError } from '../localization'; import type { FileRejectionReason } from '../primitives/file-upload'; +import { type Crop, cropImage } from '../primitives/image-cropper'; +import { SaveError } from './errors'; export const AVATAR_UPLOAD = { accept: 'image/png,image/jpeg,image/gif,image/webp', @@ -7,6 +9,69 @@ export const AVATAR_UPLOAD = { maxSize: 10 * 1000 * 1000, } as const; +/** A crop is never drawn larger than the image, and a side never passes `maxSide`, which phones can still draw. */ +const AVATAR_CROP = { maxSide: 4096, attempts: 3 } as const; + +/** + * Crops the picked image in its own format and at its own resolution. A crop that encodes over + * `AVATAR_UPLOAD.maxSize` is drawn smaller until it fits, or fails with the size error after `AVATAR_CROP.attempts` tries. + */ +export async function cropAvatar(src: string, crop: Crop, type: string): Promise { + let scale = 1; + for (let attempt = 1; ; attempt++) { + const blob = await cropImage(src, crop, { type, size: side => Math.min(side, AVATAR_CROP.maxSide) * scale }); + if (blob.size <= AVATAR_UPLOAD.maxSize) { + return blob; + } + if (attempt === AVATAR_CROP.attempts) { + throw new SaveError({ global: AVATAR_REJECTION_ERRORS.size }); + } + scale *= Math.sqrt(AVATAR_UPLOAD.maxSize / blob.size) * 0.9; + } +} + +/** A GIF, animated PNG or animated WebP is uploaded as picked, because drawing it to a canvas keeps only its first frame. */ +export async function skipsCrop(file: File): Promise { + if (file.type === 'image/gif') { + return true; + } + if (file.type !== 'image/png' && file.type !== 'image/webp') { + return false; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + return file.type === 'image/png' ? isAnimatedPng(bytes) : isAnimatedWebp(bytes); +} + +/** The cropped image as a file named after the one the user picked, with an extension for the type it was saved as. */ +export function croppedFile(picked: File, blob: Blob): File { + if (blob.type === picked.type) { + return new File([blob], picked.name, { type: blob.type }); + } + const base = picked.name.replace(/\.[^.]+$/, ''); + return new File([blob], `${base}.${blob.type.replace('image/', '')}`, { type: blob.type }); +} + +const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to)); + +function isAnimatedPng(bytes: Uint8Array): boolean { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let at = 8; at + 8 <= bytes.length; at += 12 + view.getUint32(at)) { + const chunk = ascii(bytes, at + 4, at + 8); + if (chunk === 'acTL') { + return true; + } + if (chunk === 'IDAT') { + return false; + } + } + return false; +} + +function isAnimatedWebp(bytes: Uint8Array): boolean { + const animationFlag = 0x02; + return ascii(bytes, 12, 16) === 'VP8X' && ((bytes[20] ?? 0) & animationFlag) !== 0; +} + /** 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' }, diff --git a/packages/swingset/src/stories/image-cropper.mdx b/packages/swingset/src/stories/image-cropper.mdx index a48c7f3ee68..a9a49aef04d 100644 --- a/packages/swingset/src/stories/image-cropper.mdx +++ b/packages/swingset/src/stories/image-cropper.mdx @@ -43,7 +43,7 @@ const [crop, setCrop] = useState(INITIAL_CROP); Reset ; -const blob = await cropImage(src, crop, { size: 512, type: 'image/png' }); +const blob = await cropImage(src, crop, { type: 'image/png' }); ``` ### A crop area inside a larger frame @@ -97,10 +97,13 @@ fraction of the frame. `zoom` 1 is the smallest scale that still covers the fram Other parts take no additional props beyond standard HTML attributes for their default element. -### `cropImage(src, crop, { size, type })` +### `cropImage(src, crop, { type, size })` -Returns a `Promise` holding the square the crop shows, `size` pixels wide, encoded as `type`. It -draws to a canvas, so an animated image keeps only its first frame. +Returns a `Promise` holding the square the crop shows, encoded as `type`. A browser that +cannot encode `type` returns `image/png`, so read the type from the blob. The square keeps the +image's own resolution unless `size` says otherwise: it gets the square's side in the image's pixels +and returns the side to draw, such as `side => Math.min(side, 1024)` to cap it. Colors outside sRGB +are kept. It draws to a canvas, so an animated image keeps only its first frame. ### Keyboard diff --git a/packages/swingset/src/stories/image-cropper.stories.tsx b/packages/swingset/src/stories/image-cropper.stories.tsx index a7fd899d177..d23fbc614ab 100644 --- a/packages/swingset/src/stories/image-cropper.stories.tsx +++ b/packages/swingset/src/stories/image-cropper.stories.tsx @@ -54,7 +54,7 @@ export function Default() {