Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-avatar-crop.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 22 additions & 0 deletions packages/mosaic/src/__tests__/feature/image.ts
Original file line number Diff line number Diff line change
@@ -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<File> {
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<void> {
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());
}
108 changes: 107 additions & 1 deletion packages/mosaic/src/blocks/avatar-upload/avatar-upload.styles.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
},
});
Loading
Loading