feat(cropper): expand image export API
This commit is contained in:
@@ -8,7 +8,11 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { Cropper } from "@workspace/cropper"
|
||||
import {
|
||||
Cropper,
|
||||
parseCropperAspectRatio,
|
||||
type CropperHandle,
|
||||
} from "@workspace/cropper"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
|
||||
class ResizeObserverStub {
|
||||
@@ -26,7 +30,10 @@ beforeAll(() => {
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Cropper", () => {
|
||||
it("reports the source crop and supports keyboard control", async () => {
|
||||
@@ -57,6 +64,7 @@ describe("Cropper", () => {
|
||||
|
||||
const image = container.querySelector("img")!
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 500 },
|
||||
naturalWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
@@ -91,4 +99,71 @@ describe("Cropper", () => {
|
||||
expect(screen.getByText("Overlay")).toBeTruthy()
|
||||
expect(document.querySelector('[data-slot="cropper-grid"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("exposes export and reset commands through its handle", async () => {
|
||||
const ref = React.createRef<CropperHandle>()
|
||||
const onValueChange = vi.fn()
|
||||
const drawImage = vi.fn()
|
||||
const context = {
|
||||
drawImage,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
|
||||
(callback) => callback(new Blob(["crop"], { type: "image/png" }))
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
bottom: 300,
|
||||
height: 300,
|
||||
left: 0,
|
||||
right: 300,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<Cropper
|
||||
ref={ref}
|
||||
src="image.jpg"
|
||||
aspectRatio="16:9"
|
||||
width={500}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
const root = screen.getByRole("group")
|
||||
const image = container.querySelector("img")!
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 500 },
|
||||
naturalWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
fireEvent.load(image)
|
||||
|
||||
await waitFor(() => expect(root.dataset.status).toBe("ready"))
|
||||
expect(ref.current?.element).toBe(root)
|
||||
expect(root.style.aspectRatio).toBe(`${16 / 9} / 1`)
|
||||
expect(root.style.width).toBe("500px")
|
||||
|
||||
const result = await ref.current?.getCropImage({ width: 200 })
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(drawImage).toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(root, { key: "+" })
|
||||
ref.current?.reset()
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(
|
||||
{ x: 0, y: 0, zoom: 1 },
|
||||
{ reason: "reset" }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCropperAspectRatio", () => {
|
||||
it("supports numeric and colon-separated ratios", () => {
|
||||
expect(parseCropperAspectRatio(1.5)).toBe(1.5)
|
||||
expect(parseCropperAspectRatio("16:9")).toBe(16 / 9)
|
||||
expect(parseCropperAspectRatio(" 3 : 4 ")).toBe(3 / 4)
|
||||
expect(parseCropperAspectRatio("0:1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
zoomCropperValueAtPoint,
|
||||
type CropperGeometryOptions,
|
||||
} from "./geometry"
|
||||
import {
|
||||
getCropImage as renderCropImage,
|
||||
type GetCropImageOptions,
|
||||
} from "./export-image"
|
||||
import type {
|
||||
CropArea,
|
||||
CropperChangeDetails,
|
||||
@@ -33,33 +37,53 @@ type CropperElementProps = Omit<
|
||||
| "onWheel"
|
||||
>
|
||||
|
||||
export type CropperAspectRatio = number | `${number}:${number}`
|
||||
export type CropperSource = Blob | string
|
||||
|
||||
export interface CropperHandle {
|
||||
/** The root DOM element, for measuring or integrating third-party controls. */
|
||||
readonly element: HTMLDivElement | null
|
||||
/** Returns the current crop in source-image pixels. */
|
||||
getCropArea: () => CropArea | undefined
|
||||
/** Renders the current crop as a Blob or base64 data URL. */
|
||||
getCropImage: (
|
||||
options?: GetCropImageOptions
|
||||
) => Promise<Blob | string | undefined>
|
||||
/** Restores translation and zoom to their initial value. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export interface CropperProps extends CropperElementProps {
|
||||
alt?: string
|
||||
aspectRatio?: number
|
||||
aspectRatio?: CropperAspectRatio
|
||||
children?: React.ReactNode
|
||||
crossOrigin?: "anonymous" | "use-credentials"
|
||||
defaultValue?: Partial<CropperValue>
|
||||
disabled?: boolean
|
||||
height?: React.CSSProperties["height"]
|
||||
imageClassName?: string
|
||||
keyboardStep?: number
|
||||
maxZoom?: number
|
||||
minZoom?: number
|
||||
onCropAreaChange?: (area: CropArea) => void
|
||||
onCropComplete?: (area: CropArea, value: CropperValue) => void
|
||||
onImageError?: (event: React.SyntheticEvent<HTMLImageElement>) => void
|
||||
onImageLoad?: (size: CropperSize) => void
|
||||
onInteractionChange?: (interacting: boolean) => void
|
||||
onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void
|
||||
rootRef?: React.Ref<HTMLDivElement>
|
||||
shape?: "circle" | "rectangle"
|
||||
showGrid?: boolean
|
||||
src: string
|
||||
src: CropperSource
|
||||
value?: CropperValue
|
||||
width?: React.CSSProperties["width"]
|
||||
zoomStep?: number
|
||||
}
|
||||
|
||||
type PointerPositions = Map<number, CropperPoint>
|
||||
const emptySize: CropperSize = { height: 0, width: 0 }
|
||||
|
||||
export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
export const Cropper = React.forwardRef<CropperHandle, CropperProps>(
|
||||
function Cropper(
|
||||
{
|
||||
alt = "",
|
||||
@@ -69,36 +93,46 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
crossOrigin,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
height,
|
||||
imageClassName,
|
||||
keyboardStep = 10,
|
||||
maxZoom = 4,
|
||||
minZoom = 1,
|
||||
onCropAreaChange,
|
||||
onCropComplete,
|
||||
onImageError,
|
||||
onImageLoad,
|
||||
onInteractionChange,
|
||||
onValueChange,
|
||||
rootRef: externalRootRef,
|
||||
shape = "rectangle",
|
||||
showGrid = true,
|
||||
src,
|
||||
style,
|
||||
value: controlledValue,
|
||||
width,
|
||||
zoomStep = 0.1,
|
||||
...props
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const imageRef = React.useRef<HTMLImageElement | null>(null)
|
||||
const pointersRef = React.useRef<PointerPositions>(new Map())
|
||||
const wheelEndTimerRef = React.useRef<number | undefined>(undefined)
|
||||
const interactingRef = React.useRef(false)
|
||||
const [image, setImage] = React.useState<CropperSize>(emptySize)
|
||||
const [viewport, setViewport] = React.useState<CropperSize>(emptySize)
|
||||
const [interacting, setInteracting] = React.useState(false)
|
||||
const [imageStatus, setImageStatus] = React.useState<
|
||||
"error" | "loading" | "ready"
|
||||
>("loading")
|
||||
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
|
||||
createInitialValue(defaultValue, minZoom, maxZoom)
|
||||
)
|
||||
const isControlled = controlledValue !== undefined
|
||||
const imageSource = useImageSource(src)
|
||||
const resolvedAspectRatio = parseCropperAspectRatio(aspectRatio) ?? 1
|
||||
const rawValue = controlledValue ?? uncontrolledValue
|
||||
const geometry = React.useMemo<CropperGeometryOptions>(
|
||||
() => ({ image, maxZoom, minZoom, viewport }),
|
||||
@@ -118,9 +152,9 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
const setRoot = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
rootRef.current = node
|
||||
assignRef(forwardedRef, node)
|
||||
assignRef(externalRootRef, node)
|
||||
},
|
||||
[forwardedRef]
|
||||
[externalRootRef]
|
||||
)
|
||||
|
||||
const commitValue = React.useCallback(
|
||||
@@ -135,6 +169,49 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
[geometry, isControlled, onValueChange]
|
||||
)
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
const nextValue = constrainCropperValue(
|
||||
createInitialValue(defaultValue, minZoom, maxZoom),
|
||||
geometry
|
||||
)
|
||||
const changed = !isCropperValueEqual(valueRef.current, nextValue)
|
||||
|
||||
valueRef.current = nextValue
|
||||
if (!isControlled) setUncontrolledValue(nextValue)
|
||||
if (changed) onValueChange?.(nextValue, { reason: "reset" })
|
||||
|
||||
const area = getCropArea(nextValue, viewport, image)
|
||||
if (area) onCropComplete?.(area, nextValue)
|
||||
}, [
|
||||
defaultValue,
|
||||
geometry,
|
||||
image,
|
||||
isControlled,
|
||||
maxZoom,
|
||||
minZoom,
|
||||
onCropComplete,
|
||||
onValueChange,
|
||||
viewport,
|
||||
])
|
||||
|
||||
React.useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
get element() {
|
||||
return rootRef.current
|
||||
},
|
||||
getCropArea: () => getCropArea(valueRef.current, viewport, image),
|
||||
getCropImage: async (options) => {
|
||||
const element = imageRef.current
|
||||
const area = getCropArea(valueRef.current, viewport, image)
|
||||
if (!element || !area || imageStatus !== "ready") return undefined
|
||||
return renderCropImage(element, area, options)
|
||||
},
|
||||
reset,
|
||||
}),
|
||||
[image, imageStatus, reset, viewport]
|
||||
)
|
||||
|
||||
const finishInteraction = React.useCallback(() => {
|
||||
if (!interactingRef.current) return
|
||||
|
||||
@@ -185,8 +262,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
|
||||
React.useEffect(() => {
|
||||
setImage(emptySize)
|
||||
setImageStatus("loading")
|
||||
pointersRef.current.clear()
|
||||
}, [src])
|
||||
if (!isControlled) {
|
||||
const nextValue = createInitialValue(defaultValue, minZoom, maxZoom)
|
||||
valueRef.current = nextValue
|
||||
setUncontrolledValue(nextValue)
|
||||
}
|
||||
}, [defaultValue, imageSource, isControlled, maxZoom, minZoom])
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
@@ -373,7 +456,8 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
ref={setRoot}
|
||||
data-disabled={disabled || undefined}
|
||||
data-interacting={interacting || undefined}
|
||||
data-ready={image.width > 0 || undefined}
|
||||
data-ready={imageStatus === "ready" || undefined}
|
||||
data-status={imageStatus}
|
||||
data-shape={shape}
|
||||
data-slot="cropper"
|
||||
role="group"
|
||||
@@ -384,7 +468,8 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
"relative isolate cursor-grab touch-none overflow-hidden bg-muted outline-none select-none focus-visible:ring-2 focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[interacting]:cursor-grabbing data-[shape=circle]:rounded-full",
|
||||
className
|
||||
)}
|
||||
style={{ aspectRatio, ...style }}
|
||||
aria-busy={imageStatus === "loading" || undefined}
|
||||
style={{ aspectRatio: resolvedAspectRatio, height, width, ...style }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
@@ -395,6 +480,7 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
{...props}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"pointer-events-none absolute start-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 transition-opacity will-change-[width,height,transform] select-none",
|
||||
@@ -402,7 +488,7 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
)}
|
||||
crossOrigin={crossOrigin}
|
||||
draggable={false}
|
||||
src={src}
|
||||
src={imageSource}
|
||||
style={{
|
||||
height: renderedImage.height || undefined,
|
||||
opacity: image.width > 0 ? 1 : 0,
|
||||
@@ -415,8 +501,14 @@ export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
|
||||
width: event.currentTarget.naturalWidth,
|
||||
}
|
||||
setImage(nextImage)
|
||||
setImageStatus("ready")
|
||||
onImageLoad?.(nextImage)
|
||||
}}
|
||||
onError={(event) => {
|
||||
setImage(emptySize)
|
||||
setImageStatus("error")
|
||||
onImageError?.(event)
|
||||
}}
|
||||
/>
|
||||
|
||||
{showGrid && <CropperGrid />}
|
||||
@@ -441,6 +533,40 @@ function CropperGrid() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses numeric ratios and strings such as "16:9" into a CSS ratio. */
|
||||
export function parseCropperAspectRatio(
|
||||
value: CropperAspectRatio | undefined
|
||||
): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
if (value === undefined) return undefined
|
||||
|
||||
const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/.exec(value)
|
||||
if (!match) return undefined
|
||||
|
||||
const width = Number(match[1])
|
||||
const height = Number(match[2])
|
||||
return width > 0 && height > 0 ? width / height : undefined
|
||||
}
|
||||
|
||||
function useImageSource(source: CropperSource) {
|
||||
const [objectUrl, setObjectUrl] = React.useState<string>()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof source === "string") {
|
||||
setObjectUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(source)
|
||||
setObjectUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [source])
|
||||
|
||||
return typeof source === "string" ? source : objectUrl
|
||||
}
|
||||
|
||||
function createInitialValue(
|
||||
value: Partial<CropperValue> | undefined,
|
||||
minZoom: number,
|
||||
@@ -508,7 +634,7 @@ function finitePositive(value: number | undefined, fallback: number) {
|
||||
: fallback
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
|
||||
function assignRef<T>(ref: React.ForwardedRef<T> | undefined, value: T | null) {
|
||||
if (typeof ref === "function") ref(value)
|
||||
else if (ref) ref.current = value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cropImage } from "@workspace/cropper"
|
||||
import { cropImage, getCropImage } from "@workspace/cropper"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -63,4 +63,39 @@ describe("cropImage", () => {
|
||||
cropImage("image.jpg", { height: 0, width: 100, x: 0, y: 0 })
|
||||
).rejects.toThrow(RangeError)
|
||||
})
|
||||
|
||||
it("returns a base64 data URL when requested", async () => {
|
||||
const context = {
|
||||
drawImage: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
|
||||
(callback) => callback(new Blob(["crop"], { type: "image/png" }))
|
||||
)
|
||||
|
||||
const image = document.createElement("img")
|
||||
Object.defineProperties(image, {
|
||||
complete: { configurable: true, value: true },
|
||||
naturalHeight: { configurable: true, value: 100 },
|
||||
naturalWidth: { configurable: true, value: 100 },
|
||||
})
|
||||
|
||||
const result = await getCropImage(
|
||||
image,
|
||||
{ height: 50, width: 50, x: 0, y: 0 },
|
||||
{ outputType: "base64" }
|
||||
)
|
||||
|
||||
expect(result).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("rejects quality values outside the Canvas range", async () => {
|
||||
await expect(
|
||||
cropImage(
|
||||
"image.jpg",
|
||||
{ height: 100, width: 100, x: 0, y: 0 },
|
||||
{ quality: 2 }
|
||||
)
|
||||
).rejects.toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { CropArea } from "./types"
|
||||
|
||||
export type CropImageSource = Blob | HTMLImageElement | string
|
||||
|
||||
export type CropImageOutputType = "base64" | "blob"
|
||||
|
||||
export type CropImageOptions = Readonly<{
|
||||
background?: string
|
||||
crossOrigin?: "anonymous" | "use-credentials"
|
||||
@@ -12,6 +14,11 @@ export type CropImageOptions = Readonly<{
|
||||
width?: number
|
||||
}>
|
||||
|
||||
export type GetCropImageOptions = CropImageOptions &
|
||||
Readonly<{
|
||||
outputType?: CropImageOutputType
|
||||
}>
|
||||
|
||||
/** Renders a source-image crop to a browser Blob using the Canvas API. */
|
||||
export async function cropImage(
|
||||
source: CropImageSource,
|
||||
@@ -22,6 +29,7 @@ export async function cropImage(
|
||||
throw new Error("cropImage requires a browser Canvas environment")
|
||||
}
|
||||
assertCropArea(area)
|
||||
assertQuality(options.quality)
|
||||
|
||||
const loaded = await resolveImage(source, options.crossOrigin)
|
||||
|
||||
@@ -78,6 +86,35 @@ export async function cropImage(
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders a crop and returns either a Blob or a base64 data URL. */
|
||||
export async function getCropImage(
|
||||
source: CropImageSource,
|
||||
area: CropArea,
|
||||
options: GetCropImageOptions = {}
|
||||
): Promise<Blob | string> {
|
||||
const blob = await cropImage(source, area, options)
|
||||
return options.outputType === "base64" ? blobToDataUrl(blob) : blob
|
||||
}
|
||||
|
||||
/** Converts a Blob into a data URL suitable for image previews or form values. */
|
||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
if (typeof FileReader === "undefined") {
|
||||
throw new Error("blobToDataUrl requires a browser FileReader environment")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") resolve(reader.result)
|
||||
else reject(new Error("Unable to encode Blob as a data URL"))
|
||||
})
|
||||
reader.addEventListener("error", () => {
|
||||
reject(reader.error ?? new Error("Unable to read Blob"))
|
||||
})
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
function getOutputSize(area: CropArea, options: CropImageOptions) {
|
||||
const aspectRatio = area.width / area.height
|
||||
const requestedWidth = positiveInteger(options.width)
|
||||
@@ -196,3 +233,12 @@ function assertCropArea(area: CropArea) {
|
||||
throw new RangeError("Crop area must contain finite, positive dimensions")
|
||||
}
|
||||
}
|
||||
|
||||
function assertQuality(quality: number | undefined) {
|
||||
if (
|
||||
quality !== undefined &&
|
||||
(!Number.isFinite(quality) || quality < 0 || quality > 1)
|
||||
) {
|
||||
throw new RangeError("Image quality must be between 0 and 1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
export { Cropper } from "./cropper"
|
||||
export type { CropperProps } from "./cropper"
|
||||
export { cropImage } from "./export-image"
|
||||
export type { CropImageOptions, CropImageSource } from "./export-image"
|
||||
export { Cropper, parseCropperAspectRatio } from "./cropper"
|
||||
export type {
|
||||
CropperAspectRatio,
|
||||
CropperHandle,
|
||||
CropperProps,
|
||||
CropperSource,
|
||||
} from "./cropper"
|
||||
export { blobToDataUrl, cropImage, getCropImage } from "./export-image"
|
||||
export type {
|
||||
CropImageOptions,
|
||||
CropImageOutputType,
|
||||
CropImageSource,
|
||||
GetCropImageOptions,
|
||||
} from "./export-image"
|
||||
export {
|
||||
constrainCropperValue,
|
||||
defaultCropperValue,
|
||||
|
||||
Reference in New Issue
Block a user