feat(cropper): expand image export API
This commit is contained in:
@@ -9,6 +9,8 @@ toc:
|
||||
title: Geometry functions
|
||||
- id: export
|
||||
title: Exporting an image
|
||||
- id: api
|
||||
title: API
|
||||
- id: cross-origin
|
||||
title: Cross-origin images
|
||||
---
|
||||
@@ -34,6 +36,27 @@ const [value, setValue] = React.useState({ x: 0, y: 0, zoom: 1 })
|
||||
|
||||
## Exporting an image {#export}
|
||||
|
||||
The shortest path exports the current result through the component ref:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
const result = await cropperRef.current?.getCropImage({
|
||||
outputType: "base64",
|
||||
type: "image/jpeg",
|
||||
quality: 0.9,
|
||||
width: 1200,
|
||||
})
|
||||
|
||||
<Cropper ref={cropperRef} src={src} />
|
||||
```
|
||||
|
||||
`getCropImage()` returns `undefined` until the image is ready. `outputType` defaults to `"blob"`; `"base64"` returns a data URL suitable for previews or form values. Quality must be between `0` and `1`.
|
||||
|
||||
Use the pure function when no component instance is available:
|
||||
|
||||
```tsx
|
||||
const blob = await cropImage(src, area, {
|
||||
type: "image/webp",
|
||||
@@ -42,7 +65,24 @@ const blob = await cropImage(src, area, {
|
||||
})
|
||||
```
|
||||
|
||||
Supplying only `width` or `height` preserves the crop aspect ratio. `shape: "circle"` clips through an ellipse; provide `background` when an opaque JPEG background is required.
|
||||
Supplying only `width` or `height` preserves the crop aspect ratio. `shape: "circle"` clips through an ellipse; provide `background` when an opaque JPEG background is required. `getCropImage(source, area, options)` is the corresponding pure function for Blob/base64 output.
|
||||
|
||||
## API {#api}
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------------------------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `src` | `string \| Blob` | An image URL, Blob, or File. |
|
||||
| `aspectRatio` | `number \| \`${number}:${number}\`` | Crop viewport ratio; defaults to `1`. |
|
||||
| `width` / `height` | matching `CSSProperties` value | Optional inline container dimensions. |
|
||||
| `value` / `defaultValue` | `CropperValue` | Controlled or uncontrolled translation and zoom. |
|
||||
| `minZoom` / `maxZoom` | `number` | Zoom range; defaults to `1` through `4`. |
|
||||
| `shape` | `"rectangle" \| "circle"` | Viewport shape. |
|
||||
| `showGrid` | `boolean` | Whether to render the rule-of-thirds grid. |
|
||||
| `onCropAreaChange` | `(area) => void` | Runs when the valid source-image crop changes. |
|
||||
| `onCropComplete` | `(area, value) => void` | Runs after an interaction or reset completes. |
|
||||
| `onImageLoad` / `onImageError` | callbacks | Observe image loading state. |
|
||||
|
||||
`CropperHandle` exposes `element`, `getCropArea()`, `getCropImage(options)`, and `reset()`. Use `rootRef` when a raw DOM ref is needed; the component `ref` is reserved for the imperative handle.
|
||||
|
||||
## Cross-origin images {#cross-origin}
|
||||
|
||||
|
||||
@@ -10,31 +10,25 @@ toc:
|
||||
## Example {#example}
|
||||
|
||||
```tsx
|
||||
import { Cropper, cropImage, type CropArea } from "@workspace/cropper"
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
function AvatarCropper({ src }: { src: string }) {
|
||||
const [area, setArea] = React.useState<CropArea>()
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
async function saveAvatar() {
|
||||
if (!area) return
|
||||
const blob = await cropImage(src, area, {
|
||||
const blob = await cropperRef.current?.getCropImage({
|
||||
height: 512,
|
||||
shape: "circle",
|
||||
type: "image/png",
|
||||
width: 512,
|
||||
})
|
||||
await uploadAvatar(blob)
|
||||
if (blob instanceof Blob) await uploadAvatar(blob)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Cropper
|
||||
src={src}
|
||||
shape="circle"
|
||||
className="size-72"
|
||||
onCropAreaChange={setArea}
|
||||
/>
|
||||
<button type="button" disabled={!area} onClick={saveAvatar}>
|
||||
<Cropper ref={cropperRef} src={src} shape="circle" className="size-72" />
|
||||
<button type="button" onClick={saveAvatar}>
|
||||
Save avatar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ Add the package source to the application's Tailwind stylesheet:
|
||||
|
||||
## Cropper {#cropper}
|
||||
|
||||
Give the component an explicit width; `aspectRatio` determines its height:
|
||||
Use classes or pass `width` and `height` directly. `aspectRatio` accepts a number or a `"width:height"` string:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropArea } from "@workspace/cropper"
|
||||
@@ -33,7 +33,7 @@ function ProductImageCropper({ src }: { src: string }) {
|
||||
<Cropper
|
||||
src={src}
|
||||
alt="Product"
|
||||
aspectRatio={4 / 3}
|
||||
aspectRatio="4:3"
|
||||
className="w-full max-w-xl rounded-xl"
|
||||
onCropAreaChange={setArea}
|
||||
/>
|
||||
@@ -43,6 +43,8 @@ function ProductImageCropper({ src }: { src: string }) {
|
||||
|
||||
Users can drag the image, scroll to zoom, or focus the Cropper and use the arrow, `+`, `-`, `0`, and `Home` keys.
|
||||
|
||||
`src` can also be a `Blob` or `File` from a file picker. The component creates a temporary object URL and revokes it when the source changes or the component unmounts.
|
||||
|
||||
## External controls {#controls}
|
||||
|
||||
Control the crop with `value` and `onValueChange` to connect a Slider or reset button. `minZoom` is clamped to at least `1`, ensuring that the image always covers the viewport.
|
||||
Control the crop with `value` and `onValueChange` to connect a Slider. For a quick reset, call `reset()` on the component ref. `minZoom` is clamped to at least `1`, ensuring that the image always covers the viewport.
|
||||
|
||||
@@ -21,9 +21,12 @@ toc:
|
||||
- Pinch, wheel, double-click, and keyboard zooming.
|
||||
- Controlled and uncontrolled state.
|
||||
- Rectangular and circular viewports.
|
||||
- Numeric and `"width:height"` viewport ratios.
|
||||
- URL and local `Blob` image sources with automatic object URL cleanup.
|
||||
- Resize-aware image constraints.
|
||||
- Standalone pure geometry functions.
|
||||
- Canvas export to PNG, JPEG, or WebP Blob.
|
||||
- Imperative reset, crop-area access, and export through a component ref.
|
||||
- Canvas export to PNG, JPEG, or WebP Blob/base64 output.
|
||||
|
||||
## Data model {#data-model}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ toc:
|
||||
title: 几何函数
|
||||
- id: export
|
||||
title: 导出图片
|
||||
- id: api
|
||||
title: API
|
||||
- id: cross-origin
|
||||
title: 跨域图片
|
||||
---
|
||||
@@ -34,6 +36,27 @@ const [value, setValue] = React.useState({ x: 0, y: 0, zoom: 1 })
|
||||
|
||||
## 导出图片 {#export}
|
||||
|
||||
最短路径是通过组件 ref 导出当前结果:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
const result = await cropperRef.current?.getCropImage({
|
||||
outputType: "base64",
|
||||
type: "image/jpeg",
|
||||
quality: 0.9,
|
||||
width: 1200,
|
||||
})
|
||||
|
||||
<Cropper ref={cropperRef} src={src} />
|
||||
```
|
||||
|
||||
`getCropImage()` 在图片尚未加载时返回 `undefined`。`outputType` 默认为 `"blob"`;设为 `"base64"` 时返回可直接用于预览或表单值的 data URL。质量必须位于 `0` 到 `1` 之间。
|
||||
|
||||
需要脱离组件使用时,继续使用纯函数:
|
||||
|
||||
```tsx
|
||||
const blob = await cropImage(src, area, {
|
||||
type: "image/webp",
|
||||
@@ -42,7 +65,24 @@ const blob = await cropImage(src, area, {
|
||||
})
|
||||
```
|
||||
|
||||
只指定 `width` 或 `height` 时会保持裁剪区域宽高比。`shape: "circle"` 会使用椭圆路径裁剪;需要 JPEG 背景色时可以提供 `background`。
|
||||
只指定 `width` 或 `height` 时会保持裁剪区域宽高比。`shape: "circle"` 会使用椭圆路径裁剪;需要 JPEG 背景色时可以提供 `background`。`getCropImage(source, area, options)` 是支持 Blob/base64 返回值的对应纯函数。
|
||||
|
||||
## API {#api}
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| ------------------------------ | ----------------------------------- | ------------------------------ |
|
||||
| `src` | `string \| Blob` | 图片 URL、Blob 或 File。 |
|
||||
| `aspectRatio` | `number \| \`${number}:${number}\`` | 裁剪视口比例,默认为 `1`。 |
|
||||
| `width` / `height` | `CSSProperties` 对应值 | 可选的内联容器尺寸。 |
|
||||
| `value` / `defaultValue` | `CropperValue` | 受控或非受控的平移、缩放状态。 |
|
||||
| `minZoom` / `maxZoom` | `number` | 缩放范围,默认 `1` 到 `4`。 |
|
||||
| `shape` | `"rectangle" \| "circle"` | 视口形状。 |
|
||||
| `showGrid` | `boolean` | 是否显示三等分辅助线。 |
|
||||
| `onCropAreaChange` | `(area) => void` | 有效原图裁剪区域变化时触发。 |
|
||||
| `onCropComplete` | `(area, value) => void` | 一次交互或重置完成时触发。 |
|
||||
| `onImageLoad` / `onImageError` | 回调函数 | 监听图片加载状态。 |
|
||||
|
||||
`CropperHandle` 提供 `element`、`getCropArea()`、`getCropImage(options)` 和 `reset()`。需要原始 DOM ref 时使用 `rootRef`,组件 `ref` 专用于命令式句柄。
|
||||
|
||||
## 跨域图片 {#cross-origin}
|
||||
|
||||
|
||||
@@ -10,31 +10,25 @@ toc:
|
||||
## 示例 {#example}
|
||||
|
||||
```tsx
|
||||
import { Cropper, cropImage, type CropArea } from "@workspace/cropper"
|
||||
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||
|
||||
function AvatarCropper({ src }: { src: string }) {
|
||||
const [area, setArea] = React.useState<CropArea>()
|
||||
const cropperRef = React.useRef<CropperHandle>(null)
|
||||
|
||||
async function saveAvatar() {
|
||||
if (!area) return
|
||||
const blob = await cropImage(src, area, {
|
||||
const blob = await cropperRef.current?.getCropImage({
|
||||
height: 512,
|
||||
shape: "circle",
|
||||
type: "image/png",
|
||||
width: 512,
|
||||
})
|
||||
await uploadAvatar(blob)
|
||||
if (blob instanceof Blob) await uploadAvatar(blob)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Cropper
|
||||
src={src}
|
||||
shape="circle"
|
||||
className="size-72"
|
||||
onCropAreaChange={setArea}
|
||||
/>
|
||||
<button type="button" disabled={!area} onClick={saveAvatar}>
|
||||
<Cropper ref={cropperRef} src={src} shape="circle" className="size-72" />
|
||||
<button type="button" onClick={saveAvatar}>
|
||||
保存头像
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ toc:
|
||||
|
||||
## 裁剪器 {#cropper}
|
||||
|
||||
组件需要明确的宽度,`aspectRatio` 会确定高度:
|
||||
可以使用 class,也可以直接传入 `width`、`height`。`aspectRatio` 接受数字或 `"宽:高"` 字符串:
|
||||
|
||||
```tsx
|
||||
import { Cropper, type CropArea } from "@workspace/cropper"
|
||||
@@ -33,7 +33,7 @@ function ProductImageCropper({ src }: { src: string }) {
|
||||
<Cropper
|
||||
src={src}
|
||||
alt="商品图片"
|
||||
aspectRatio={4 / 3}
|
||||
aspectRatio="4:3"
|
||||
className="w-full max-w-xl rounded-xl"
|
||||
onCropAreaChange={setArea}
|
||||
/>
|
||||
@@ -43,6 +43,8 @@ function ProductImageCropper({ src }: { src: string }) {
|
||||
|
||||
用户可以拖拽图片、滚动缩放,或者在 Cropper 聚焦时使用方向键、`+`、`-`、`0` 和 `Home`。
|
||||
|
||||
`src` 也可以直接传入文件选择器获得的 `Blob` 或 `File`。组件会创建临时 object URL,并在图片改变或卸载时自动释放。
|
||||
|
||||
## 外部控件 {#controls}
|
||||
|
||||
通过 `value` 和 `onValueChange` 控制裁剪状态,即可接入 Slider 或重置按钮。`minZoom` 最小为 `1`,保证图片始终覆盖整个视口。
|
||||
通过 `value` 和 `onValueChange` 控制裁剪状态,即可接入 Slider。快速重置可以直接调用组件 ref 的 `reset()`;`minZoom` 最小为 `1`,保证图片始终覆盖整个视口。
|
||||
|
||||
@@ -21,9 +21,12 @@ toc:
|
||||
- 双指、滚轮、双击和键盘缩放。
|
||||
- 受控与非受控状态。
|
||||
- 矩形和圆形视口。
|
||||
- 支持数字或 `"宽:高"` 格式的视口比例。
|
||||
- 支持 URL 和本地 `Blob` 图片源,并自动释放临时 object URL。
|
||||
- 响应容器尺寸变化并自动约束图片位置。
|
||||
- 独立的纯几何函数。
|
||||
- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob。
|
||||
- 通过组件 ref 重置、读取区域并导出结果。
|
||||
- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob/base64。
|
||||
|
||||
## 数据模型 {#data-model}
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { Cropper } from "@workspace/cropper"
|
||||
import {
|
||||
Cropper,
|
||||
parseCropperAspectRatio,
|
||||
type CropperHandle,
|
||||
} from "@workspace/cropper"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
class ResizeObserverStub {
|
||||
@@ -26,7 +30,10 @@ beforeAll(() => {
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Cropper", () => {
|
||||
it("reports the source crop and supports keyboard control", async () => {
|
||||
@@ -57,6 +64,7 @@ describe("Cropper", () => {
|
||||
|
||||
const image = container.querySelector("img")!
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 500 },
|
||||
naturalWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
@@ -91,4 +99,71 @@ describe("Cropper", () => {
|
||||
expect(screen.getByText("Overlay")).toBeTruthy()
|
||||
expect(document.querySelector('[data-slot="cropper-grid"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("exposes export and reset commands through its handle", async () => {
|
||||
const ref = React.createRef<CropperHandle>()
|
||||
const onValueChange = vi.fn()
|
||||
const drawImage = vi.fn()
|
||||
const context = {
|
||||
drawImage,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
|
||||
(callback) => callback(new Blob(["crop"], { type: "image/png" }))
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
bottom: 300,
|
||||
height: 300,
|
||||
left: 0,
|
||||
right: 300,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<Cropper
|
||||
ref={ref}
|
||||
src="image.jpg"
|
||||
aspectRatio="16:9"
|
||||
width={500}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
const root = screen.getByRole("group")
|
||||
const image = container.querySelector("img")!
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 500 },
|
||||
naturalWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
fireEvent.load(image)
|
||||
|
||||
await waitFor(() => expect(root.dataset.status).toBe("ready"))
|
||||
expect(ref.current?.element).toBe(root)
|
||||
expect(root.style.aspectRatio).toBe(`${16 / 9} / 1`)
|
||||
expect(root.style.width).toBe("500px")
|
||||
|
||||
const result = await ref.current?.getCropImage({ width: 200 })
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(drawImage).toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(root, { key: "+" })
|
||||
ref.current?.reset()
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(
|
||||
{ x: 0, y: 0, zoom: 1 },
|
||||
{ reason: "reset" }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCropperAspectRatio", () => {
|
||||
it("supports numeric and colon-separated ratios", () => {
|
||||
expect(parseCropperAspectRatio(1.5)).toBe(1.5)
|
||||
expect(parseCropperAspectRatio("16:9")).toBe(16 / 9)
|
||||
expect(parseCropperAspectRatio(" 3 : 4 ")).toBe(3 / 4)
|
||||
expect(parseCropperAspectRatio("0:1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
zoomCropperValueAtPoint,
|
||||
type CropperGeometryOptions,
|
||||
} from "./geometry"
|
||||
import {
|
||||
getCropImage as renderCropImage,
|
||||
type GetCropImageOptions,
|
||||
} from "./export-image"
|
||||
import type {
|
||||
CropArea,
|
||||
CropperChangeDetails,
|
||||
@@ -33,33 +37,53 @@ type CropperElementProps = Omit<
|
||||
| "onWheel"
|
||||
>
|
||||
|
||||
export type CropperAspectRatio = number | `${number}:${number}`
|
||||
export type CropperSource = Blob | string
|
||||
|
||||
export interface CropperHandle {
|
||||
/** The root DOM element, for measuring or integrating third-party controls. */
|
||||
readonly element: HTMLDivElement | null
|
||||
/** Returns the current crop in source-image pixels. */
|
||||
getCropArea: () => CropArea | undefined
|
||||
/** Renders the current crop as a Blob or base64 data URL. */
|
||||
getCropImage: (
|
||||
options?: GetCropImageOptions
|
||||
) => Promise<Blob | string | undefined>
|
||||
/** Restores translation and zoom to their initial value. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface CropperProps extends CropperElementProps {
|
||||
alt?: string
|
||||
aspectRatio?: number
|
||||
aspectRatio?: CropperAspectRatio
|
||||
children?: React.ReactNode
|
||||
crossOrigin?: "anonymous" | "use-credentials"
|
||||
defaultValue?: Partial<CropperValue>
|
||||
disabled?: boolean
|
||||
height?: React.CSSProperties["height"]
|
||||
imageClassName?: string
|
||||
keyboardStep?: number
|
||||
maxZoom?: number
|
||||
minZoom?: number
|
||||
onCropAreaChange?: (area: CropArea) => void
|
||||
onCropComplete?: (area: CropArea, value: CropperValue) => void
|
||||
onImageError?: (event: React.SyntheticEvent<HTMLImageElement>) => void
|
||||
onImageLoad?: (size: CropperSize) => void
|
||||
onInteractionChange?: (interacting: boolean) => void
|
||||
onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void
|
||||
rootRef?: React.Ref<HTMLDivElement>
|
||||
shape?: "circle" | "rectangle"
|
||||
showGrid?: boolean
|
||||
src: string
|
||||
src: CropperSource
|
||||
value?: CropperValue
|
||||
width?: React.CSSProperties["width"]
|
||||
zoomStep?: number
|
||||
}
|
||||
|
||||
type PointerPositions = Map<number, CropperPoint>
|
||||
const emptySize: CropperSize = { height: 0, width: 0 }
|
||||
|
||||
export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
export const Cropper = React.forwardRef<CropperHandle, CropperProps>(
|
||||
function Cropper(
|
||||
{
|
||||
alt = "",
|
||||
@@ -69,36 +93,46 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
crossOrigin,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
height,
|
||||
imageClassName,
|
||||
keyboardStep = 10,
|
||||
maxZoom = 4,
|
||||
minZoom = 1,
|
||||
onCropAreaChange,
|
||||
onCropComplete,
|
||||
onImageError,
|
||||
onImageLoad,
|
||||
onInteractionChange,
|
||||
onValueChange,
|
||||
rootRef: externalRootRef,
|
||||
shape = "rectangle",
|
||||
showGrid = true,
|
||||
src,
|
||||
style,
|
||||
value: controlledValue,
|
||||
width,
|
||||
zoomStep = 0.1,
|
||||
...props
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null)
|
||||
const pointersRef = React.useRef<PointerPositions>(new Map())
|
||||
const wheelEndTimerRef = React.useRef<number | undefined>(undefined)
|
||||
const interactingRef = React.useRef(false)
|
||||
const [image, setImage] = React.useState<CropperSize>(emptySize)
|
||||
const [viewport, setViewport] = React.useState<CropperSize>(emptySize)
|
||||
const [interacting, setInteracting] = React.useState(false)
|
||||
const [imageStatus, setImageStatus] = React.useState<
|
||||
"error" | "loading" | "ready"
|
||||
>("loading")
|
||||
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
|
||||
createInitialValue(defaultValue, minZoom, maxZoom)
|
||||
)
|
||||
const isControlled = controlledValue !== undefined
|
||||
const imageSource = useImageSource(src)
|
||||
const resolvedAspectRatio = parseCropperAspectRatio(aspectRatio) ?? 1
|
||||
const rawValue = controlledValue ?? uncontrolledValue
|
||||
const geometry = React.useMemo<CropperGeometryOptions>(
|
||||
() => ({ image, maxZoom, minZoom, viewport }),
|
||||
@@ -118,9 +152,9 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
const setRoot = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
rootRef.current = node
|
||||
assignRef(forwardedRef, node)
|
||||
assignRef(externalRootRef, node)
|
||||
},
|
||||
[forwardedRef]
|
||||
[externalRootRef]
|
||||
)
|
||||
|
||||
const commitValue = React.useCallback(
|
||||
@@ -135,6 +169,49 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
[geometry, isControlled, onValueChange]
|
||||
)
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
const nextValue = constrainCropperValue(
|
||||
createInitialValue(defaultValue, minZoom, maxZoom),
|
||||
geometry
|
||||
)
|
||||
const changed = !isCropperValueEqual(valueRef.current, nextValue)
|
||||
|
||||
valueRef.current = nextValue
|
||||
if (!isControlled) setUncontrolledValue(nextValue)
|
||||
if (changed) onValueChange?.(nextValue, { reason: "reset" })
|
||||
|
||||
const area = getCropArea(nextValue, viewport, image)
|
||||
if (area) onCropComplete?.(area, nextValue)
|
||||
}, [
|
||||
defaultValue,
|
||||
geometry,
|
||||
image,
|
||||
isControlled,
|
||||
maxZoom,
|
||||
minZoom,
|
||||
onCropComplete,
|
||||
onValueChange,
|
||||
viewport,
|
||||
])
|
||||
|
||||
React.useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
get element() {
|
||||
return rootRef.current
|
||||
},
|
||||
getCropArea: () => getCropArea(valueRef.current, viewport, image),
|
||||
getCropImage: async (options) => {
|
||||
const element = imageRef.current
|
||||
const area = getCropArea(valueRef.current, viewport, image)
|
||||
if (!element || !area || imageStatus !== "ready") return undefined
|
||||
return renderCropImage(element, area, options)
|
||||
},
|
||||
reset,
|
||||
}),
|
||||
[image, imageStatus, reset, viewport]
|
||||
)
|
||||
|
||||
const finishInteraction = React.useCallback(() => {
|
||||
if (!interactingRef.current) return
|
||||
|
||||
@@ -185,8 +262,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
|
||||
React.useEffect(() => {
|
||||
setImage(emptySize)
|
||||
setImageStatus("loading")
|
||||
pointersRef.current.clear()
|
||||
}, [src])
|
||||
if (!isControlled) {
|
||||
const nextValue = createInitialValue(defaultValue, minZoom, maxZoom)
|
||||
valueRef.current = nextValue
|
||||
setUncontrolledValue(nextValue)
|
||||
}
|
||||
}, [defaultValue, imageSource, isControlled, maxZoom, minZoom])
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
@@ -373,7 +456,8 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
ref={setRoot}
|
||||
data-disabled={disabled || undefined}
|
||||
data-interacting={interacting || undefined}
|
||||
data-ready={image.width > 0 || undefined}
|
||||
data-ready={imageStatus === "ready" || undefined}
|
||||
data-status={imageStatus}
|
||||
data-shape={shape}
|
||||
data-slot="cropper"
|
||||
role="group"
|
||||
@@ -384,7 +468,8 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
"relative isolate cursor-grab touch-none overflow-hidden bg-muted outline-none select-none focus-visible:ring-2 focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[interacting]:cursor-grabbing data-[shape=circle]:rounded-full",
|
||||
className
|
||||
)}
|
||||
style={{ aspectRatio, ...style }}
|
||||
aria-busy={imageStatus === "loading" || undefined}
|
||||
style={{ aspectRatio: resolvedAspectRatio, height, width, ...style }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
@@ -395,6 +480,7 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
{...props}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"pointer-events-none absolute start-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 transition-opacity will-change-[width,height,transform] select-none",
|
||||
@@ -402,7 +488,7 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
)}
|
||||
crossOrigin={crossOrigin}
|
||||
draggable={false}
|
||||
src={src}
|
||||
src={imageSource}
|
||||
style={{
|
||||
height: renderedImage.height || undefined,
|
||||
opacity: image.width > 0 ? 1 : 0,
|
||||
@@ -415,8 +501,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
width: event.currentTarget.naturalWidth,
|
||||
}
|
||||
setImage(nextImage)
|
||||
setImageStatus("ready")
|
||||
onImageLoad?.(nextImage)
|
||||
}}
|
||||
onError={(event) => {
|
||||
setImage(emptySize)
|
||||
setImageStatus("error")
|
||||
onImageError?.(event)
|
||||
}}
|
||||
/>
|
||||
|
||||
{showGrid && <CropperGrid />}
|
||||
@@ -441,6 +533,40 @@ function CropperGrid() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses numeric ratios and strings such as "16:9" into a CSS ratio. */
|
||||
export function parseCropperAspectRatio(
|
||||
value: CropperAspectRatio | undefined
|
||||
): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
if (value === undefined) return undefined
|
||||
|
||||
const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/.exec(value)
|
||||
if (!match) return undefined
|
||||
|
||||
const width = Number(match[1])
|
||||
const height = Number(match[2])
|
||||
return width > 0 && height > 0 ? width / height : undefined
|
||||
}
|
||||
|
||||
function useImageSource(source: CropperSource) {
|
||||
const [objectUrl, setObjectUrl] = React.useState<string>()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof source === "string") {
|
||||
setObjectUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(source)
|
||||
setObjectUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [source])
|
||||
|
||||
return typeof source === "string" ? source : objectUrl
|
||||
}
|
||||
|
||||
function createInitialValue(
|
||||
value: Partial<CropperValue> | undefined,
|
||||
minZoom: number,
|
||||
@@ -508,7 +634,7 @@ function finitePositive(value: number | undefined, fallback: number) {
|
||||
: fallback
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
|
||||
function assignRef<T>(ref: React.ForwardedRef<T> | undefined, value: T | null) {
|
||||
if (typeof ref === "function") ref(value)
|
||||
else if (ref) ref.current = value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cropImage } from "@workspace/cropper"
|
||||
import { cropImage, getCropImage } from "@workspace/cropper"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -63,4 +63,39 @@ describe("cropImage", () => {
|
||||
cropImage("image.jpg", { height: 0, width: 100, x: 0, y: 0 })
|
||||
).rejects.toThrow(RangeError)
|
||||
})
|
||||
|
||||
it("returns a base64 data URL when requested", async () => {
|
||||
const context = {
|
||||
drawImage: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
|
||||
(callback) => callback(new Blob(["crop"], { type: "image/png" }))
|
||||
)
|
||||
|
||||
const image = document.createElement("img")
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 100 },
|
||||
naturalWidth: { configurable: true, value: 100 },
|
||||
})
|
||||
|
||||
const result = await getCropImage(
|
||||
image,
|
||||
{ height: 50, width: 50, x: 0, y: 0 },
|
||||
{ outputType: "base64" }
|
||||
)
|
||||
|
||||
expect(result).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("rejects quality values outside the Canvas range", async () => {
|
||||
await expect(
|
||||
cropImage(
|
||||
"image.jpg",
|
||||
{ height: 100, width: 100, x: 0, y: 0 },
|
||||
{ quality: 2 }
|
||||
)
|
||||
).rejects.toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { CropArea } from "./types"
|
||||
|
||||
export type CropImageSource = Blob | HTMLImageElement | string
|
||||
|
||||
export type CropImageOutputType = "base64" | "blob"
|
||||
|
||||
export type CropImageOptions = Readonly<{
|
||||
background?: string
|
||||
crossOrigin?: "anonymous" | "use-credentials"
|
||||
@@ -12,6 +14,11 @@ export type CropImageOptions = Readonly<{
|
||||
width?: number
|
||||
}>
|
||||
|
||||
export type GetCropImageOptions = CropImageOptions &
|
||||
Readonly<{
|
||||
outputType?: CropImageOutputType
|
||||
}>
|
||||
|
||||
/** Renders a source-image crop to a browser Blob using the Canvas API. */
|
||||
export async function cropImage(
|
||||
source: CropImageSource,
|
||||
@@ -22,6 +29,7 @@ export async function cropImage(
|
||||
throw new Error("cropImage requires a browser Canvas environment")
|
||||
}
|
||||
assertCropArea(area)
|
||||
assertQuality(options.quality)
|
||||
|
||||
const loaded = await resolveImage(source, options.crossOrigin)
|
||||
|
||||
@@ -78,6 +86,35 @@ export async function cropImage(
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a crop and returns either a Blob or a base64 data URL. */
|
||||
export async function getCropImage(
|
||||
source: CropImageSource,
|
||||
area: CropArea,
|
||||
options: GetCropImageOptions = {}
|
||||
): Promise<Blob | string> {
|
||||
const blob = await cropImage(source, area, options)
|
||||
return options.outputType === "base64" ? blobToDataUrl(blob) : blob
|
||||
}
|
||||
|
||||
/** Converts a Blob into a data URL suitable for image previews or form values. */
|
||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
if (typeof FileReader === "undefined") {
|
||||
throw new Error("blobToDataUrl requires a browser FileReader environment")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") resolve(reader.result)
|
||||
else reject(new Error("Unable to encode Blob as a data URL"))
|
||||
})
|
||||
reader.addEventListener("error", () => {
|
||||
reject(reader.error ?? new Error("Unable to read Blob"))
|
||||
})
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
function getOutputSize(area: CropArea, options: CropImageOptions) {
|
||||
const aspectRatio = area.width / area.height
|
||||
const requestedWidth = positiveInteger(options.width)
|
||||
@@ -196,3 +233,12 @@ function assertCropArea(area: CropArea) {
|
||||
throw new RangeError("Crop area must contain finite, positive dimensions")
|
||||
}
|
||||
}
|
||||
|
||||
function assertQuality(quality: number | undefined) {
|
||||
if (
|
||||
quality !== undefined &&
|
||||
(!Number.isFinite(quality) || quality < 0 || quality > 1)
|
||||
) {
|
||||
throw new RangeError("Image quality must be between 0 and 1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
export { Cropper } from "./cropper"
|
||||
export type { CropperProps } from "./cropper"
|
||||
export { cropImage } from "./export-image"
|
||||
export type { CropImageOptions, CropImageSource } from "./export-image"
|
||||
export { Cropper, parseCropperAspectRatio } from "./cropper"
|
||||
export type {
|
||||
CropperAspectRatio,
|
||||
CropperHandle,
|
||||
CropperProps,
|
||||
CropperSource,
|
||||
} from "./cropper"
|
||||
export { blobToDataUrl, cropImage, getCropImage } from "./export-image"
|
||||
export type {
|
||||
CropImageOptions,
|
||||
CropImageOutputType,
|
||||
CropImageSource,
|
||||
GetCropImageOptions,
|
||||
} from "./export-image"
|
||||
export {
|
||||
constrainCropperValue,
|
||||
defaultCropperValue,
|
||||
|
||||
Reference in New Issue
Block a user