From dcd2fc01a9252a762642c554ff2a1088c72ea575 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 12 Nov 2026 09:41:28 -0500 Subject: [PATCH 1/5] feat(mosaic): add ImageCropper primitive --- .changeset/mosaic-image-cropper-primitive.md | 2 + .../src/primitives/image-cropper/README.md | 114 ++++++++ .../__tests__/crop-image.browser.test.tsx | 62 +++++ .../image-cropper/__tests__/crop.test.ts | 78 ++++++ .../__tests__/image-cropper.test.tsx | 258 ++++++++++++++++++ .../primitives/image-cropper/crop-image.ts | 41 +++ .../src/primitives/image-cropper/crop.ts | 104 +++++++ .../image-cropper/image-cropper-area.tsx | 22 ++ .../image-cropper/image-cropper-context.ts | 28 ++ .../image-cropper/image-cropper-image.tsx | 56 ++++ .../image-cropper/image-cropper-reset.tsx | 33 +++ .../image-cropper/image-cropper-root.tsx | 67 +++++ .../image-cropper/image-cropper-rotate.tsx | 33 +++ .../image-cropper/image-cropper-viewport.tsx | 153 +++++++++++ .../image-cropper/image-cropper-zoom-step.tsx | 52 ++++ .../image-cropper/image-cropper-zoom.tsx | 37 +++ .../src/primitives/image-cropper/index.ts | 15 + .../src/primitives/image-cropper/parts.ts | 13 + 18 files changed, 1168 insertions(+) create mode 100644 .changeset/mosaic-image-cropper-primitive.md create mode 100644 packages/mosaic/src/primitives/image-cropper/README.md create mode 100644 packages/mosaic/src/primitives/image-cropper/__tests__/crop-image.browser.test.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/__tests__/crop.test.ts create mode 100644 packages/mosaic/src/primitives/image-cropper/__tests__/image-cropper.test.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/crop-image.ts create mode 100644 packages/mosaic/src/primitives/image-cropper/crop.ts create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-area.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-context.ts create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-image.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-reset.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-root.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-rotate.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-viewport.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-zoom-step.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/image-cropper-zoom.tsx create mode 100644 packages/mosaic/src/primitives/image-cropper/index.ts create mode 100644 packages/mosaic/src/primitives/image-cropper/parts.ts diff --git a/.changeset/mosaic-image-cropper-primitive.md b/.changeset/mosaic-image-cropper-primitive.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-image-cropper-primitive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/primitives/image-cropper/README.md b/packages/mosaic/src/primitives/image-cropper/README.md new file mode 100644 index 00000000000..7b60f90ae72 --- /dev/null +++ b/packages/mosaic/src/primitives/image-cropper/README.md @@ -0,0 +1,114 @@ +# ImageCropper + +A headless image cropper. The user pans the image by dragging or with the arrow keys, zooms with the +wheel, a pinch, the keyboard, a slider or step buttons, turns it a quarter at a time, and resets it. +The crop is a square. `cropImage` draws the square the user chose into a new image. + +## When to Use + +- Cropping a picked image to a square before uploading it, such as an avatar or a logo. + +The primitive emits zero styles. The caller sizes the frame, draws any mask over it, and styles the +controls through `data-*` attributes. + +## Usage + +```tsx +import { type Crop, cropImage, ImageCropper, INITIAL_CROP } from '../../primitives/image-cropper'; + +function Cropper({ src, onSave }: { src: string; onSave: (blob: Blob) => void }) { + const [crop, setCrop] = useState(INITIAL_CROP); + + return ( + + + + + + + − + + + + Rotate + Reset + + + ); +} +``` + +`Area` is optional. Without it the whole `Viewport` is the crop. With it, the crop is the `Area`, and +the `Viewport` around it shows the rest of the image for context. Drags are measured against the +`Area`, so the image follows the pointer either way. + +## Parts + +| Part | Default Element | Description | +| ----------------------- | ---------------------- | ---------------------------------------------------------------------- | +| `ImageCropper.Root` | none | Owns the crop and the image size, provides context | +| `ImageCropper.Viewport` | `
` | The frame: pans on drag and arrow keys, zooms on wheel, pinch and keys | +| `ImageCropper.Area` | `
` | The square that is cropped, when it is smaller than the frame | +| `ImageCropper.Image` | `` | The image, placed by the crop. Reports its size on load | +| `ImageCropper.Zoom` | `` | Sets the zoom, from 1 to `maxZoom` | +| `ImageCropper.ZoomIn` | ` +
+ + {result ? ( + Cropped result + ) : null} +
+ ); +} From f80d585dce328367b2d5194e44eefa950761921b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 12 Nov 2026 10:31:59 -0500 Subject: [PATCH 5/5] fix(mosaic): address ImageCropper review feedback - clamp a crop passed in, on screen and in cropImage - convert wheel deltas in lines or pages to pixels - skip a pinch that starts with zero distance - leave the wheel and touch to the page until the image loads - leave arrow keys to controls placed inside the viewport - import useState in the usage examples --- .../src/primitives/image-cropper/README.md | 2 + .../__tests__/crop-image.browser.test.tsx | 8 +++ .../__tests__/image-cropper.test.tsx | 67 +++++++++++++++++++ .../primitives/image-cropper/crop-image.ts | 4 +- .../image-cropper/image-cropper-root.tsx | 3 +- .../image-cropper/image-cropper-viewport.tsx | 30 ++++++--- .../swingset/src/stories/image-cropper.mdx | 4 +- 7 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/mosaic/src/primitives/image-cropper/README.md b/packages/mosaic/src/primitives/image-cropper/README.md index 7b60f90ae72..04a5ed852d0 100644 --- a/packages/mosaic/src/primitives/image-cropper/README.md +++ b/packages/mosaic/src/primitives/image-cropper/README.md @@ -14,6 +14,8 @@ controls through `data-*` attributes. ## Usage ```tsx +import { useState } from 'react'; + import { type Crop, cropImage, ImageCropper, INITIAL_CROP } from '../../primitives/image-cropper'; function Cropper({ src, onSave }: { src: string; onSave: (blob: Blob) => void }) { 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 89d23340835..4e6718e84c0 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 @@ -59,4 +59,12 @@ describe('cropImage', () => { expect(image.at(32, 8)).toEqual(RED); expect(image.at(32, 56)).toEqual(BLUE); }); + + 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 image = await pixels(blob); + + expect(image.at(8, 32)).toEqual(RED); + expect(image.at(56, 32)).toEqual(BLUE); + }); }); diff --git a/packages/mosaic/src/primitives/image-cropper/__tests__/image-cropper.test.tsx b/packages/mosaic/src/primitives/image-cropper/__tests__/image-cropper.test.tsx index c211d863e9b..e0f11efd802 100644 --- a/packages/mosaic/src/primitives/image-cropper/__tests__/image-cropper.test.tsx +++ b/packages/mosaic/src/primitives/image-cropper/__tests__/image-cropper.test.tsx @@ -245,6 +245,73 @@ describe('ImageCropper', () => { expect(lastCrop(onValueChange)?.zoom).toBe(2); }); + it('keeps a pinch that starts with both fingers on one point from breaking the crop', () => { + const onValueChange = vi.fn<(crop: Crop) => void>(); + render(); + load(100, 100); + const viewport = screen.getByRole('application', { name: 'Crop' }); + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ width: 200, height: 200 })); + + fireEvent.pointerDown(viewport, { pointerId: 1, clientX: 100, clientY: 100, button: 0 }); + fireEvent.pointerDown(viewport, { pointerId: 2, clientX: 100, clientY: 100, button: 0 }); + fireEvent.pointerMove(viewport, { pointerId: 2, clientX: 200, clientY: 100 }); + + expect(lastCrop(onValueChange)?.zoom).toBe(1); + }); + + it('zooms the same amount whether the wheel reports pixels or lines', () => { + const onValueChange = vi.fn<(crop: Crop) => void>(); + render(); + load(100, 100); + const viewport = screen.getByRole('application', { name: 'Crop' }); + + fireEvent.wheel(viewport, { deltaY: -48, deltaMode: WheelEvent.DOM_DELTA_PIXEL }); + const byPixels = lastCrop(onValueChange)?.zoom; + fireEvent.keyDown(viewport, { key: '0' }); + fireEvent.wheel(viewport, { deltaY: -3, deltaMode: WheelEvent.DOM_DELTA_LINE }); + + expect(lastCrop(onValueChange)?.zoom).toBeCloseTo(byPixels ?? Number.NaN); + }); + + it('leaves the wheel and touch to the page until the image has loaded', () => { + render(); + const viewport = screen.getByRole('application', { name: 'Crop' }); + + expect(fireEvent.wheel(viewport, { deltaY: -48 })).toBe(true); + expect(viewport.style.touchAction).toBe(''); + + load(100, 100); + + expect(fireEvent.wheel(viewport, { deltaY: -48 })).toBe(false); + expect(viewport.style.touchAction).toBe('none'); + }); + + it('leaves arrow keys to a slider placed inside the frame', () => { + const onValueChange = vi.fn<(crop: Crop) => void>(); + render( + + + + + + , + ); + load(100, 100); + + expect(fireEvent.keyDown(screen.getByRole('slider', { name: 'Zoom' }), { key: 'ArrowLeft' })).toBe(true); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('clamps a crop passed in, so the image still covers the frame', () => { + render(); + const image = load(100, 100); + + expect(image.style.left).toBe('50%'); + }); + it('ignores every gesture while disabled', async () => { const user = userEvent.setup(); const onValueChange = vi.fn<(crop: Crop) => void>(); diff --git a/packages/mosaic/src/primitives/image-cropper/crop-image.ts b/packages/mosaic/src/primitives/image-cropper/crop-image.ts index 3904b49cfd5..5fc2e359a2b 100644 --- a/packages/mosaic/src/primitives/image-cropper/crop-image.ts +++ b/packages/mosaic/src/primitives/image-cropper/crop-image.ts @@ -1,4 +1,4 @@ -import { type Crop, pixelCrop, turned } from './crop'; +import { clampCrop, type Crop, pixelCrop, turned } from './crop'; export interface CropImageOptions { /** Width and height of the output, in pixels. */ @@ -19,7 +19,7 @@ export async function cropImage(src: string, crop: Crop, { size, type }: CropIma await image.decode(); const natural = { width: image.naturalWidth, height: image.naturalHeight }; - const area = pixelCrop(crop, natural); + const area = pixelCrop(clampCrop(crop, natural, Number.POSITIVE_INFINITY), natural); const upright = turned(natural, crop.rotation); const canvas = document.createElement('canvas'); diff --git a/packages/mosaic/src/primitives/image-cropper/image-cropper-root.tsx b/packages/mosaic/src/primitives/image-cropper/image-cropper-root.tsx index 4eeac191035..a08604c73fe 100644 --- a/packages/mosaic/src/primitives/image-cropper/image-cropper-root.tsx +++ b/packages/mosaic/src/primitives/image-cropper/image-cropper-root.tsx @@ -31,8 +31,9 @@ export function ImageCropperRoot({ disabled = false, children, }: ImageCropperRootProps) { - const [crop, setCrop] = useControllableState(value, defaultValue, onValueChange); + const [stored, setCrop] = useControllableState(value, defaultValue, onValueChange); const [size, setSize] = useState(); + const crop = useMemo(() => (size ? clampCrop(stored, size, maxZoom) : stored), [stored, size, maxZoom]); const area = useRef(null); const latest = useRef(crop); // eslint-disable-next-line react/refs -- This ref holds an imperative resource or the latest callback outside React state. diff --git a/packages/mosaic/src/primitives/image-cropper/image-cropper-viewport.tsx b/packages/mosaic/src/primitives/image-cropper/image-cropper-viewport.tsx index 7bbafffb42e..c22a9c71e73 100644 --- a/packages/mosaic/src/primitives/image-cropper/image-cropper-viewport.tsx +++ b/packages/mosaic/src/primitives/image-cropper/image-cropper-viewport.tsx @@ -16,6 +16,7 @@ interface Point { const KEY_PAN = 0.02; const KEY_PAN_LARGE = 0.1; const WHEEL_ZOOM = 0.002; +const WHEEL_LINE = 16; export const ImageCropperViewport = React.forwardRef( function ImageCropperViewport(props, ref) { @@ -25,18 +26,20 @@ export const ImageCropperViewport = React.forwardRef()); const [dragging, setDragging] = useState(false); + const ready = size !== undefined && !disabled; + useEffect(() => { const element = elementRef.current; - if (!element) { + if (!element || !ready) { return; } const onWheel = (event: WheelEvent) => { event.preventDefault(); - zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM)); + zoomBy(Math.exp(-wheelPixels(event, element.clientHeight) * WHEEL_ZOOM)); }; element.addEventListener('wheel', onWheel, { passive: false }); return () => element.removeEventListener('wheel', onWheel); - }, [zoomBy]); + }, [ready, zoomBy]); const release = (pointerId: number) => { pointers.current.delete(pointerId); @@ -45,12 +48,10 @@ export const ImageCropperViewport = React.forwardRef { if (!ready || event.button !== 0) { return; @@ -76,15 +77,16 @@ export const ImageCropperViewport = React.forwardRef 0) { + zoomBy(distance(c, d) / start); } }, onPointerUp: event => release(event.pointerId), onPointerCancel: event => release(event.pointerId), onLostPointerCapture: event => release(event.pointerId), onKeyDown: event => { - if (!ready) { + if (!ready || event.target !== event.currentTarget) { return; } const step = event.shiftKey ? KEY_PAN_LARGE : KEY_PAN; @@ -143,6 +145,16 @@ function keyAction(key: string, step: number): KeyAction | undefined { } } +function wheelPixels(event: WheelEvent, page: number): number { + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + return event.deltaY * WHEEL_LINE; + } + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return event.deltaY * page; + } + return event.deltaY; +} + function midpoint(points: Point[]): Point { const sum = points.reduce((total, point) => ({ x: total.x + point.x, y: total.y + point.y }), { x: 0, y: 0 }); return { x: sum.x / points.length, y: sum.y / points.length }; diff --git a/packages/swingset/src/stories/image-cropper.mdx b/packages/swingset/src/stories/image-cropper.mdx index 31930bf95fb..a48c7f3ee68 100644 --- a/packages/swingset/src/stories/image-cropper.mdx +++ b/packages/swingset/src/stories/image-cropper.mdx @@ -22,6 +22,8 @@ result from `cropImage`. ## Usage ```tsx +import { useState } from 'react'; + import { type Crop, cropImage, ImageCropper, INITIAL_CROP } from '@clerk/mosaic/primitives/image-cropper'; const [crop, setCrop] = useState(INITIAL_CROP); @@ -114,7 +116,7 @@ On a focused `Viewport`: ## Styling The primitive sets only the `Image` placement (absolute, centred, scaled and rotated by the crop) and -`touch-action: none` on the `Viewport`. Give the `Viewport` a size, `position: relative` and +`touch-action: none` on the `Viewport` once the image has loaded. Give the `Viewport` a size, `position: relative` and `overflow: hidden`. | Attribute | Applies To | Description |