feat(cropper): expand image export API
This commit is contained in:
@@ -9,6 +9,8 @@ toc:
|
|||||||
title: Geometry functions
|
title: Geometry functions
|
||||||
- id: export
|
- id: export
|
||||||
title: Exporting an image
|
title: Exporting an image
|
||||||
|
- id: api
|
||||||
|
title: API
|
||||||
- id: cross-origin
|
- id: cross-origin
|
||||||
title: Cross-origin images
|
title: Cross-origin images
|
||||||
---
|
---
|
||||||
@@ -34,6 +36,27 @@ const [value, setValue] = React.useState({ x: 0, y: 0, zoom: 1 })
|
|||||||
|
|
||||||
## Exporting an image {#export}
|
## 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
|
```tsx
|
||||||
const blob = await cropImage(src, area, {
|
const blob = await cropImage(src, area, {
|
||||||
type: "image/webp",
|
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}
|
## Cross-origin images {#cross-origin}
|
||||||
|
|
||||||
|
|||||||
@@ -10,31 +10,25 @@ toc:
|
|||||||
## Example {#example}
|
## Example {#example}
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Cropper, cropImage, type CropArea } from "@workspace/cropper"
|
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||||
|
|
||||||
function AvatarCropper({ src }: { src: string }) {
|
function AvatarCropper({ src }: { src: string }) {
|
||||||
const [area, setArea] = React.useState<CropArea>()
|
const cropperRef = React.useRef<CropperHandle>(null)
|
||||||
|
|
||||||
async function saveAvatar() {
|
async function saveAvatar() {
|
||||||
if (!area) return
|
const blob = await cropperRef.current?.getCropImage({
|
||||||
const blob = await cropImage(src, area, {
|
|
||||||
height: 512,
|
height: 512,
|
||||||
shape: "circle",
|
shape: "circle",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
width: 512,
|
width: 512,
|
||||||
})
|
})
|
||||||
await uploadAvatar(blob)
|
if (blob instanceof Blob) await uploadAvatar(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Cropper
|
<Cropper ref={cropperRef} src={src} shape="circle" className="size-72" />
|
||||||
src={src}
|
<button type="button" onClick={saveAvatar}>
|
||||||
shape="circle"
|
|
||||||
className="size-72"
|
|
||||||
onCropAreaChange={setArea}
|
|
||||||
/>
|
|
||||||
<button type="button" disabled={!area} onClick={saveAvatar}>
|
|
||||||
Save avatar
|
Save avatar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Add the package source to the application's Tailwind stylesheet:
|
|||||||
|
|
||||||
## Cropper {#cropper}
|
## 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
|
```tsx
|
||||||
import { Cropper, type CropArea } from "@workspace/cropper"
|
import { Cropper, type CropArea } from "@workspace/cropper"
|
||||||
@@ -33,7 +33,7 @@ function ProductImageCropper({ src }: { src: string }) {
|
|||||||
<Cropper
|
<Cropper
|
||||||
src={src}
|
src={src}
|
||||||
alt="Product"
|
alt="Product"
|
||||||
aspectRatio={4 / 3}
|
aspectRatio="4:3"
|
||||||
className="w-full max-w-xl rounded-xl"
|
className="w-full max-w-xl rounded-xl"
|
||||||
onCropAreaChange={setArea}
|
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.
|
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}
|
## 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.
|
- Pinch, wheel, double-click, and keyboard zooming.
|
||||||
- Controlled and uncontrolled state.
|
- Controlled and uncontrolled state.
|
||||||
- Rectangular and circular viewports.
|
- 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.
|
- Resize-aware image constraints.
|
||||||
- Standalone pure geometry functions.
|
- 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}
|
## Data model {#data-model}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ toc:
|
|||||||
title: 几何函数
|
title: 几何函数
|
||||||
- id: export
|
- id: export
|
||||||
title: 导出图片
|
title: 导出图片
|
||||||
|
- id: api
|
||||||
|
title: API
|
||||||
- id: cross-origin
|
- id: cross-origin
|
||||||
title: 跨域图片
|
title: 跨域图片
|
||||||
---
|
---
|
||||||
@@ -34,6 +36,27 @@ const [value, setValue] = React.useState({ x: 0, y: 0, zoom: 1 })
|
|||||||
|
|
||||||
## 导出图片 {#export}
|
## 导出图片 {#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
|
```tsx
|
||||||
const blob = await cropImage(src, area, {
|
const blob = await cropImage(src, area, {
|
||||||
type: "image/webp",
|
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}
|
## 跨域图片 {#cross-origin}
|
||||||
|
|
||||||
|
|||||||
@@ -10,31 +10,25 @@ toc:
|
|||||||
## 示例 {#example}
|
## 示例 {#example}
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Cropper, cropImage, type CropArea } from "@workspace/cropper"
|
import { Cropper, type CropperHandle } from "@workspace/cropper"
|
||||||
|
|
||||||
function AvatarCropper({ src }: { src: string }) {
|
function AvatarCropper({ src }: { src: string }) {
|
||||||
const [area, setArea] = React.useState<CropArea>()
|
const cropperRef = React.useRef<CropperHandle>(null)
|
||||||
|
|
||||||
async function saveAvatar() {
|
async function saveAvatar() {
|
||||||
if (!area) return
|
const blob = await cropperRef.current?.getCropImage({
|
||||||
const blob = await cropImage(src, area, {
|
|
||||||
height: 512,
|
height: 512,
|
||||||
shape: "circle",
|
shape: "circle",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
width: 512,
|
width: 512,
|
||||||
})
|
})
|
||||||
await uploadAvatar(blob)
|
if (blob instanceof Blob) await uploadAvatar(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Cropper
|
<Cropper ref={cropperRef} src={src} shape="circle" className="size-72" />
|
||||||
src={src}
|
<button type="button" onClick={saveAvatar}>
|
||||||
shape="circle"
|
|
||||||
className="size-72"
|
|
||||||
onCropAreaChange={setArea}
|
|
||||||
/>
|
|
||||||
<button type="button" disabled={!area} onClick={saveAvatar}>
|
|
||||||
保存头像
|
保存头像
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ toc:
|
|||||||
|
|
||||||
## 裁剪器 {#cropper}
|
## 裁剪器 {#cropper}
|
||||||
|
|
||||||
组件需要明确的宽度,`aspectRatio` 会确定高度:
|
可以使用 class,也可以直接传入 `width`、`height`。`aspectRatio` 接受数字或 `"宽:高"` 字符串:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Cropper, type CropArea } from "@workspace/cropper"
|
import { Cropper, type CropArea } from "@workspace/cropper"
|
||||||
@@ -33,7 +33,7 @@ function ProductImageCropper({ src }: { src: string }) {
|
|||||||
<Cropper
|
<Cropper
|
||||||
src={src}
|
src={src}
|
||||||
alt="商品图片"
|
alt="商品图片"
|
||||||
aspectRatio={4 / 3}
|
aspectRatio="4:3"
|
||||||
className="w-full max-w-xl rounded-xl"
|
className="w-full max-w-xl rounded-xl"
|
||||||
onCropAreaChange={setArea}
|
onCropAreaChange={setArea}
|
||||||
/>
|
/>
|
||||||
@@ -43,6 +43,8 @@ function ProductImageCropper({ src }: { src: string }) {
|
|||||||
|
|
||||||
用户可以拖拽图片、滚动缩放,或者在 Cropper 聚焦时使用方向键、`+`、`-`、`0` 和 `Home`。
|
用户可以拖拽图片、滚动缩放,或者在 Cropper 聚焦时使用方向键、`+`、`-`、`0` 和 `Home`。
|
||||||
|
|
||||||
|
`src` 也可以直接传入文件选择器获得的 `Blob` 或 `File`。组件会创建临时 object URL,并在图片改变或卸载时自动释放。
|
||||||
|
|
||||||
## 外部控件 {#controls}
|
## 外部控件 {#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}
|
## 数据模型 {#data-model}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react"
|
} 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"
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
class ResizeObserverStub {
|
class ResizeObserverStub {
|
||||||
@@ -26,7 +30,10 @@ beforeAll(() => {
|
|||||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe("Cropper", () => {
|
describe("Cropper", () => {
|
||||||
it("reports the source crop and supports keyboard control", async () => {
|
it("reports the source crop and supports keyboard control", async () => {
|
||||||
@@ -57,6 +64,7 @@ describe("Cropper", () => {
|
|||||||
|
|
||||||
const image = container.querySelector("img")!
|
const image = container.querySelector("img")!
|
||||||
Object.defineProperties(image, {
|
Object.defineProperties(image, {
|
||||||
|
complete: { configurable: true, value: true },
|
||||||
naturalHeight: { configurable: true, value: 500 },
|
naturalHeight: { configurable: true, value: 500 },
|
||||||
naturalWidth: { configurable: true, value: 1000 },
|
naturalWidth: { configurable: true, value: 1000 },
|
||||||
})
|
})
|
||||||
@@ -91,4 +99,71 @@ describe("Cropper", () => {
|
|||||||
expect(screen.getByText("Overlay")).toBeTruthy()
|
expect(screen.getByText("Overlay")).toBeTruthy()
|
||||||
expect(document.querySelector('[data-slot="cropper-grid"]')).toBeNull()
|
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,
|
zoomCropperValueAtPoint,
|
||||||
type CropperGeometryOptions,
|
type CropperGeometryOptions,
|
||||||
} from "./geometry"
|
} from "./geometry"
|
||||||
|
import {
|
||||||
|
getCropImage as renderCropImage,
|
||||||
|
type GetCropImageOptions,
|
||||||
|
} from "./export-image"
|
||||||
import type {
|
import type {
|
||||||
CropArea,
|
CropArea,
|
||||||
CropperChangeDetails,
|
CropperChangeDetails,
|
||||||
@@ -33,33 +37,53 @@ type CropperElementProps = Omit<
|
|||||||
| "onWheel"
|
| "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 {
|
export interface CropperProps extends CropperElementProps {
|
||||||
alt?: string
|
alt?: string
|
||||||
aspectRatio?: number
|
aspectRatio?: CropperAspectRatio
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
crossOrigin?: "anonymous" | "use-credentials"
|
crossOrigin?: "anonymous" | "use-credentials"
|
||||||
defaultValue?: Partial<CropperValue>
|
defaultValue?: Partial<CropperValue>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
height?: React.CSSProperties["height"]
|
||||||
imageClassName?: string
|
imageClassName?: string
|
||||||
keyboardStep?: number
|
keyboardStep?: number
|
||||||
maxZoom?: number
|
maxZoom?: number
|
||||||
minZoom?: number
|
minZoom?: number
|
||||||
onCropAreaChange?: (area: CropArea) => void
|
onCropAreaChange?: (area: CropArea) => void
|
||||||
onCropComplete?: (area: CropArea, value: CropperValue) => void
|
onCropComplete?: (area: CropArea, value: CropperValue) => void
|
||||||
|
onImageError?: (event: React.SyntheticEvent<HTMLImageElement>) => void
|
||||||
onImageLoad?: (size: CropperSize) => void
|
onImageLoad?: (size: CropperSize) => void
|
||||||
onInteractionChange?: (interacting: boolean) => void
|
onInteractionChange?: (interacting: boolean) => void
|
||||||
onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void
|
onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void
|
||||||
|
rootRef?: React.Ref<HTMLDivElement>
|
||||||
shape?: "circle" | "rectangle"
|
shape?: "circle" | "rectangle"
|
||||||
showGrid?: boolean
|
showGrid?: boolean
|
||||||
src: string
|
src: CropperSource
|
||||||
value?: CropperValue
|
value?: CropperValue
|
||||||
|
width?: React.CSSProperties["width"]
|
||||||
zoomStep?: number
|
zoomStep?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type PointerPositions = Map<number, CropperPoint>
|
type PointerPositions = Map<number, CropperPoint>
|
||||||
const emptySize: CropperSize = { height: 0, width: 0 }
|
const emptySize: CropperSize = { height: 0, width: 0 }
|
||||||
|
|
||||||
export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
export const Cropper = React.forwardRef<CropperHandle, CropperProps>(
|
||||||
function Cropper(
|
function Cropper(
|
||||||
{
|
{
|
||||||
alt = "",
|
alt = "",
|
||||||
@@ -69,36 +93,46 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
crossOrigin,
|
crossOrigin,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
height,
|
||||||
imageClassName,
|
imageClassName,
|
||||||
keyboardStep = 10,
|
keyboardStep = 10,
|
||||||
maxZoom = 4,
|
maxZoom = 4,
|
||||||
minZoom = 1,
|
minZoom = 1,
|
||||||
onCropAreaChange,
|
onCropAreaChange,
|
||||||
onCropComplete,
|
onCropComplete,
|
||||||
|
onImageError,
|
||||||
onImageLoad,
|
onImageLoad,
|
||||||
onInteractionChange,
|
onInteractionChange,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
rootRef: externalRootRef,
|
||||||
shape = "rectangle",
|
shape = "rectangle",
|
||||||
showGrid = true,
|
showGrid = true,
|
||||||
src,
|
src,
|
||||||
style,
|
style,
|
||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
|
width,
|
||||||
zoomStep = 0.1,
|
zoomStep = 0.1,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) {
|
) {
|
||||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
const imageRef = React.useRef<HTMLImageElement | null>(null)
|
||||||
const pointersRef = React.useRef<PointerPositions>(new Map())
|
const pointersRef = React.useRef<PointerPositions>(new Map())
|
||||||
const wheelEndTimerRef = React.useRef<number | undefined>(undefined)
|
const wheelEndTimerRef = React.useRef<number | undefined>(undefined)
|
||||||
const interactingRef = React.useRef(false)
|
const interactingRef = React.useRef(false)
|
||||||
const [image, setImage] = React.useState<CropperSize>(emptySize)
|
const [image, setImage] = React.useState<CropperSize>(emptySize)
|
||||||
const [viewport, setViewport] = React.useState<CropperSize>(emptySize)
|
const [viewport, setViewport] = React.useState<CropperSize>(emptySize)
|
||||||
const [interacting, setInteracting] = React.useState(false)
|
const [interacting, setInteracting] = React.useState(false)
|
||||||
|
const [imageStatus, setImageStatus] = React.useState<
|
||||||
|
"error" | "loading" | "ready"
|
||||||
|
>("loading")
|
||||||
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
|
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
|
||||||
createInitialValue(defaultValue, minZoom, maxZoom)
|
createInitialValue(defaultValue, minZoom, maxZoom)
|
||||||
)
|
)
|
||||||
const isControlled = controlledValue !== undefined
|
const isControlled = controlledValue !== undefined
|
||||||
|
const imageSource = useImageSource(src)
|
||||||
|
const resolvedAspectRatio = parseCropperAspectRatio(aspectRatio) ?? 1
|
||||||
const rawValue = controlledValue ?? uncontrolledValue
|
const rawValue = controlledValue ?? uncontrolledValue
|
||||||
const geometry = React.useMemo<CropperGeometryOptions>(
|
const geometry = React.useMemo<CropperGeometryOptions>(
|
||||||
() => ({ image, maxZoom, minZoom, viewport }),
|
() => ({ image, maxZoom, minZoom, viewport }),
|
||||||
@@ -118,9 +152,9 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
const setRoot = React.useCallback(
|
const setRoot = React.useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
rootRef.current = node
|
rootRef.current = node
|
||||||
assignRef(forwardedRef, node)
|
assignRef(externalRootRef, node)
|
||||||
},
|
},
|
||||||
[forwardedRef]
|
[externalRootRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
const commitValue = React.useCallback(
|
const commitValue = React.useCallback(
|
||||||
@@ -135,6 +169,49 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
[geometry, isControlled, onValueChange]
|
[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(() => {
|
const finishInteraction = React.useCallback(() => {
|
||||||
if (!interactingRef.current) return
|
if (!interactingRef.current) return
|
||||||
|
|
||||||
@@ -185,8 +262,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setImage(emptySize)
|
setImage(emptySize)
|
||||||
|
setImageStatus("loading")
|
||||||
pointersRef.current.clear()
|
pointersRef.current.clear()
|
||||||
}, [src])
|
if (!isControlled) {
|
||||||
|
const nextValue = createInitialValue(defaultValue, minZoom, maxZoom)
|
||||||
|
valueRef.current = nextValue
|
||||||
|
setUncontrolledValue(nextValue)
|
||||||
|
}
|
||||||
|
}, [defaultValue, imageSource, isControlled, maxZoom, minZoom])
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -373,7 +456,8 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
ref={setRoot}
|
ref={setRoot}
|
||||||
data-disabled={disabled || undefined}
|
data-disabled={disabled || undefined}
|
||||||
data-interacting={interacting || undefined}
|
data-interacting={interacting || undefined}
|
||||||
data-ready={image.width > 0 || undefined}
|
data-ready={imageStatus === "ready" || undefined}
|
||||||
|
data-status={imageStatus}
|
||||||
data-shape={shape}
|
data-shape={shape}
|
||||||
data-slot="cropper"
|
data-slot="cropper"
|
||||||
role="group"
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
style={{ aspectRatio, ...style }}
|
aria-busy={imageStatus === "loading" || undefined}
|
||||||
|
style={{ aspectRatio: resolvedAspectRatio, height, width, ...style }}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPointerCancel={handlePointerEnd}
|
onPointerCancel={handlePointerEnd}
|
||||||
@@ -395,6 +480,7 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
ref={imageRef}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={cn(
|
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",
|
"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}
|
crossOrigin={crossOrigin}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={src}
|
src={imageSource}
|
||||||
style={{
|
style={{
|
||||||
height: renderedImage.height || undefined,
|
height: renderedImage.height || undefined,
|
||||||
opacity: image.width > 0 ? 1 : 0,
|
opacity: image.width > 0 ? 1 : 0,
|
||||||
@@ -415,8 +501,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
|||||||
width: event.currentTarget.naturalWidth,
|
width: event.currentTarget.naturalWidth,
|
||||||
}
|
}
|
||||||
setImage(nextImage)
|
setImage(nextImage)
|
||||||
|
setImageStatus("ready")
|
||||||
onImageLoad?.(nextImage)
|
onImageLoad?.(nextImage)
|
||||||
}}
|
}}
|
||||||
|
onError={(event) => {
|
||||||
|
setImage(emptySize)
|
||||||
|
setImageStatus("error")
|
||||||
|
onImageError?.(event)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showGrid && <CropperGrid />}
|
{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(
|
function createInitialValue(
|
||||||
value: Partial<CropperValue> | undefined,
|
value: Partial<CropperValue> | undefined,
|
||||||
minZoom: number,
|
minZoom: number,
|
||||||
@@ -508,7 +634,7 @@ function finitePositive(value: number | undefined, fallback: number) {
|
|||||||
: fallback
|
: 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)
|
if (typeof ref === "function") ref(value)
|
||||||
else if (ref) ref.current = value
|
else if (ref) ref.current = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { cropImage } from "@workspace/cropper"
|
import { cropImage, getCropImage } from "@workspace/cropper"
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
@@ -63,4 +63,39 @@ describe("cropImage", () => {
|
|||||||
cropImage("image.jpg", { height: 0, width: 100, x: 0, y: 0 })
|
cropImage("image.jpg", { height: 0, width: 100, x: 0, y: 0 })
|
||||||
).rejects.toThrow(RangeError)
|
).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 CropImageSource = Blob | HTMLImageElement | string
|
||||||
|
|
||||||
|
export type CropImageOutputType = "base64" | "blob"
|
||||||
|
|
||||||
export type CropImageOptions = Readonly<{
|
export type CropImageOptions = Readonly<{
|
||||||
background?: string
|
background?: string
|
||||||
crossOrigin?: "anonymous" | "use-credentials"
|
crossOrigin?: "anonymous" | "use-credentials"
|
||||||
@@ -12,6 +14,11 @@ export type CropImageOptions = Readonly<{
|
|||||||
width?: number
|
width?: number
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
export type GetCropImageOptions = CropImageOptions &
|
||||||
|
Readonly<{
|
||||||
|
outputType?: CropImageOutputType
|
||||||
|
}>
|
||||||
|
|
||||||
/** Renders a source-image crop to a browser Blob using the Canvas API. */
|
/** Renders a source-image crop to a browser Blob using the Canvas API. */
|
||||||
export async function cropImage(
|
export async function cropImage(
|
||||||
source: CropImageSource,
|
source: CropImageSource,
|
||||||
@@ -22,6 +29,7 @@ export async function cropImage(
|
|||||||
throw new Error("cropImage requires a browser Canvas environment")
|
throw new Error("cropImage requires a browser Canvas environment")
|
||||||
}
|
}
|
||||||
assertCropArea(area)
|
assertCropArea(area)
|
||||||
|
assertQuality(options.quality)
|
||||||
|
|
||||||
const loaded = await resolveImage(source, options.crossOrigin)
|
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) {
|
function getOutputSize(area: CropArea, options: CropImageOptions) {
|
||||||
const aspectRatio = area.width / area.height
|
const aspectRatio = area.width / area.height
|
||||||
const requestedWidth = positiveInteger(options.width)
|
const requestedWidth = positiveInteger(options.width)
|
||||||
@@ -196,3 +233,12 @@ function assertCropArea(area: CropArea) {
|
|||||||
throw new RangeError("Crop area must contain finite, positive dimensions")
|
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 { Cropper, parseCropperAspectRatio } from "./cropper"
|
||||||
export type { CropperProps } from "./cropper"
|
export type {
|
||||||
export { cropImage } from "./export-image"
|
CropperAspectRatio,
|
||||||
export type { CropImageOptions, CropImageSource } from "./export-image"
|
CropperHandle,
|
||||||
|
CropperProps,
|
||||||
|
CropperSource,
|
||||||
|
} from "./cropper"
|
||||||
|
export { blobToDataUrl, cropImage, getCropImage } from "./export-image"
|
||||||
|
export type {
|
||||||
|
CropImageOptions,
|
||||||
|
CropImageOutputType,
|
||||||
|
CropImageSource,
|
||||||
|
GetCropImageOptions,
|
||||||
|
} from "./export-image"
|
||||||
export {
|
export {
|
||||||
constrainCropperValue,
|
constrainCropperValue,
|
||||||
defaultCropperValue,
|
defaultCropperValue,
|
||||||
|
|||||||
Reference in New Issue
Block a user