From 24b32ea5cc06edf8cf9bc5ac928f22550bbd9a0d Mon Sep 17 00:00:00 2001 From: Maofeng Date: Tue, 29 Sep 2026 17:46:16 +0800 Subject: [PATCH] feat(cropper): expand image export API --- .../en-US/advanced/geometry-and-export.mdx | 42 ++++- .../docs/en-US/examples/avatar-crop.mdx | 18 +-- .../cropper/docs/en-US/guide/basic-usage.mdx | 8 +- packages/cropper/docs/en-US/overview.mdx | 5 +- .../zh-Hans/advanced/geometry-and-export.mdx | 42 ++++- .../docs/zh-Hans/examples/avatar-crop.mdx | 18 +-- .../docs/zh-Hans/guide/basic-usage.mdx | 8 +- packages/cropper/docs/zh-Hans/overview.mdx | 5 +- packages/cropper/src/cropper.test.tsx | 79 +++++++++- packages/cropper/src/cropper.tsx | 146 ++++++++++++++++-- packages/cropper/src/export-image.test.ts | 37 ++++- packages/cropper/src/export-image.ts | 46 ++++++ packages/cropper/src/index.ts | 18 ++- 13 files changed, 421 insertions(+), 51 deletions(-) diff --git a/packages/cropper/docs/en-US/advanced/geometry-and-export.mdx b/packages/cropper/docs/en-US/advanced/geometry-and-export.mdx index 003f30d..70ba41f 100644 --- a/packages/cropper/docs/en-US/advanced/geometry-and-export.mdx +++ b/packages/cropper/docs/en-US/advanced/geometry-and-export.mdx @@ -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(null) + +const result = await cropperRef.current?.getCropImage({ + outputType: "base64", + type: "image/jpeg", + quality: 0.9, + width: 1200, +}) + + +``` + +`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} diff --git a/packages/cropper/docs/en-US/examples/avatar-crop.mdx b/packages/cropper/docs/en-US/examples/avatar-crop.mdx index 7586cc5..df04cb4 100644 --- a/packages/cropper/docs/en-US/examples/avatar-crop.mdx +++ b/packages/cropper/docs/en-US/examples/avatar-crop.mdx @@ -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() + const cropperRef = React.useRef(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 (
- -
diff --git a/packages/cropper/docs/en-US/guide/basic-usage.mdx b/packages/cropper/docs/en-US/guide/basic-usage.mdx index 3d96543..23bff8e 100644 --- a/packages/cropper/docs/en-US/guide/basic-usage.mdx +++ b/packages/cropper/docs/en-US/guide/basic-usage.mdx @@ -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 }) { @@ -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. diff --git a/packages/cropper/docs/en-US/overview.mdx b/packages/cropper/docs/en-US/overview.mdx index ff698ff..6f10ce5 100644 --- a/packages/cropper/docs/en-US/overview.mdx +++ b/packages/cropper/docs/en-US/overview.mdx @@ -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} diff --git a/packages/cropper/docs/zh-Hans/advanced/geometry-and-export.mdx b/packages/cropper/docs/zh-Hans/advanced/geometry-and-export.mdx index e3a9794..14c8c08 100644 --- a/packages/cropper/docs/zh-Hans/advanced/geometry-and-export.mdx +++ b/packages/cropper/docs/zh-Hans/advanced/geometry-and-export.mdx @@ -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(null) + +const result = await cropperRef.current?.getCropImage({ + outputType: "base64", + type: "image/jpeg", + quality: 0.9, + width: 1200, +}) + + +``` + +`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} diff --git a/packages/cropper/docs/zh-Hans/examples/avatar-crop.mdx b/packages/cropper/docs/zh-Hans/examples/avatar-crop.mdx index 1e72563..a7f5d6f 100644 --- a/packages/cropper/docs/zh-Hans/examples/avatar-crop.mdx +++ b/packages/cropper/docs/zh-Hans/examples/avatar-crop.mdx @@ -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() + const cropperRef = React.useRef(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 (
- -
diff --git a/packages/cropper/docs/zh-Hans/guide/basic-usage.mdx b/packages/cropper/docs/zh-Hans/guide/basic-usage.mdx index 7d461ef..ee0b6b5 100644 --- a/packages/cropper/docs/zh-Hans/guide/basic-usage.mdx +++ b/packages/cropper/docs/zh-Hans/guide/basic-usage.mdx @@ -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 }) { @@ -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`,保证图片始终覆盖整个视口。 diff --git a/packages/cropper/docs/zh-Hans/overview.mdx b/packages/cropper/docs/zh-Hans/overview.mdx index 2dab544..f7008d4 100644 --- a/packages/cropper/docs/zh-Hans/overview.mdx +++ b/packages/cropper/docs/zh-Hans/overview.mdx @@ -21,9 +21,12 @@ toc: - 双指、滚轮、双击和键盘缩放。 - 受控与非受控状态。 - 矩形和圆形视口。 +- 支持数字或 `"宽:高"` 格式的视口比例。 +- 支持 URL 和本地 `Blob` 图片源,并自动释放临时 object URL。 - 响应容器尺寸变化并自动约束图片位置。 - 独立的纯几何函数。 -- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob。 +- 通过组件 ref 重置、读取区域并导出结果。 +- 使用 Canvas 导出 PNG、JPEG 或 WebP Blob/base64。 ## 数据模型 {#data-model} diff --git a/packages/cropper/src/cropper.test.tsx b/packages/cropper/src/cropper.test.tsx index 469849f..d096387 100644 --- a/packages/cropper/src/cropper.test.tsx +++ b/packages/cropper/src/cropper.test.tsx @@ -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() + 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( + + ) + 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() + }) }) diff --git a/packages/cropper/src/cropper.tsx b/packages/cropper/src/cropper.tsx index 11fa385..4e99345 100644 --- a/packages/cropper/src/cropper.tsx +++ b/packages/cropper/src/cropper.tsx @@ -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 + /** 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 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) => void onImageLoad?: (size: CropperSize) => void onInteractionChange?: (interacting: boolean) => void onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void + rootRef?: React.Ref shape?: "circle" | "rectangle" showGrid?: boolean - src: string + src: CropperSource value?: CropperValue + width?: React.CSSProperties["width"] zoomStep?: number } type PointerPositions = Map const emptySize: CropperSize = { height: 0, width: 0 } -export const Cropper = React.forwardRef( +export const Cropper = React.forwardRef( function Cropper( { alt = "", @@ -69,36 +93,46 @@ export const Cropper = React.forwardRef( 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(null) + const imageRef = React.useRef(null) const pointersRef = React.useRef(new Map()) const wheelEndTimerRef = React.useRef(undefined) const interactingRef = React.useRef(false) const [image, setImage] = React.useState(emptySize) const [viewport, setViewport] = React.useState(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( () => ({ image, maxZoom, minZoom, viewport }), @@ -118,9 +152,9 @@ export const Cropper = React.forwardRef( 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( [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( 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( 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( "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( {...props} > {alt}( )} 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( width: event.currentTarget.naturalWidth, } setImage(nextImage) + setImageStatus("ready") onImageLoad?.(nextImage) }} + onError={(event) => { + setImage(emptySize) + setImageStatus("error") + onImageError?.(event) + }} /> {showGrid && } @@ -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() + + 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 | undefined, minZoom: number, @@ -508,7 +634,7 @@ function finitePositive(value: number | undefined, fallback: number) { : fallback } -function assignRef(ref: React.ForwardedRef, value: T | null) { +function assignRef(ref: React.ForwardedRef | undefined, value: T | null) { if (typeof ref === "function") ref(value) else if (ref) ref.current = value } diff --git a/packages/cropper/src/export-image.test.ts b/packages/cropper/src/export-image.test.ts index bb125da..db00ffa 100644 --- a/packages/cropper/src/export-image.test.ts +++ b/packages/cropper/src/export-image.test.ts @@ -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) + }) }) diff --git a/packages/cropper/src/export-image.ts b/packages/cropper/src/export-image.ts index 677213f..a267f76 100644 --- a/packages/cropper/src/export-image.ts +++ b/packages/cropper/src/export-image.ts @@ -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 { + 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 { + 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") + } +} diff --git a/packages/cropper/src/index.ts b/packages/cropper/src/index.ts index b49b696..f45cdcd 100644 --- a/packages/cropper/src/index.ts +++ b/packages/cropper/src/index.ts @@ -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,