feat(cropper): expand image export API

This commit is contained in:
Maofeng
2026-09-29 17:46:16 +08:00
parent f56397fb97
commit 24b32ea5cc
13 changed files with 421 additions and 51 deletions
@@ -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.
+4 -1
View File
@@ -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`,保证图片始终覆盖整个视口。
+4 -1
View File
@@ -21,9 +21,12 @@ toc:
- 双指、滚轮、双击和键盘缩放。
- 受控与非受控状态。
- 矩形和圆形视口。
- 支持数字或 `"宽:高"` 格式的视口比例。
- 支持 URL 和本地 `Blob` 图片源,并自动释放临时 object URL。
- 响应容器尺寸变化并自动约束图片位置。
- 独立的纯几何函数。
- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob。
- 通过组件 ref 重置、读取区域并导出结果。
- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob/base64。
## 数据模型 {#data-model}
+77 -2
View File
@@ -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()
})
})
+136 -10
View File
@@ -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
}
+36 -1
View File
@@ -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)
})
})
+46
View File
@@ -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")
}
}
+14 -4
View File
@@ -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,