feat(cropper): add image cropping primitives

This commit is contained in:
Maofeng
2026-09-29 17:09:26 +08:00
parent acbc62463e
commit 23e0867027
12 changed files with 1228 additions and 0 deletions
+19
View File
@@ -139,6 +139,23 @@
"vitest": "^4.1.10",
},
},
"packages/cropper": {
"name": "@workspace/cropper",
"version": "0.0.0",
"dependencies": {
"@workspace/ui": "workspace:*",
"react": "^19.2.6",
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10",
},
},
"packages/i18n": {
"name": "@workspace/i18n",
"version": "0.0.0",
@@ -1114,6 +1131,8 @@
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
"@workspace/cropper": ["@workspace/cropper@workspace:packages/cropper"],
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
"@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"],
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@workspace/cropper",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./globals.css": "./src/styles/globals.css"
}
}
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import * as React from "react"
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { Cropper } from "@workspace/cropper"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
class ResizeObserverStub {
constructor(private readonly callback: ResizeObserverCallback) {}
observe() {
this.callback([], this as unknown as ResizeObserver)
}
disconnect() {}
unobserve() {}
}
beforeAll(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
})
afterEach(cleanup)
describe("Cropper", () => {
it("reports the source crop and supports keyboard control", async () => {
const onCropAreaChange = vi.fn()
const onValueChange = vi.fn()
const boundsSpy = 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
src="image.jpg"
aspectRatio={1}
onCropAreaChange={onCropAreaChange}
onValueChange={onValueChange}
/>
)
const root = screen.getByRole("group")
const image = container.querySelector("img")!
Object.defineProperties(image, {
naturalHeight: { configurable: true, value: 500 },
naturalWidth: { configurable: true, value: 1000 },
})
fireEvent.load(image)
await waitFor(() => {
expect(onCropAreaChange).toHaveBeenLastCalledWith({
height: 500,
width: 500,
x: 250,
y: 0,
})
})
fireEvent.keyDown(root, { key: "+" })
expect(onValueChange).toHaveBeenLastCalledWith(
{ x: 0, y: 0, zoom: 1.1 },
{ reason: "zoom" }
)
boundsSpy.mockRestore()
})
it("supports a circular viewport and custom overlays", () => {
render(
<Cropper src="avatar.jpg" shape="circle" showGrid={false}>
<span>Overlay</span>
</Cropper>
)
const root = screen.getByRole("group")
expect(root.dataset.shape).toBe("circle")
expect(screen.getByText("Overlay")).toBeTruthy()
expect(document.querySelector('[data-slot="cropper-grid"]')).toBeNull()
})
})
+514
View File
@@ -0,0 +1,514 @@
import * as React from "react"
import { cn } from "@workspace/ui/lib/utils"
import {
constrainCropperValue,
defaultCropperValue,
getCropArea,
getRenderedImageSize,
isCropperValueEqual,
zoomCropperValueAtPoint,
type CropperGeometryOptions,
} from "./geometry"
import type {
CropArea,
CropperChangeDetails,
CropperChangeReason,
CropperPoint,
CropperSize,
CropperValue,
} from "./types"
type CropperElementProps = Omit<
React.ComponentProps<"div">,
| "children"
| "defaultValue"
| "onChange"
| "onDoubleClick"
| "onKeyDown"
| "onPointerCancel"
| "onPointerDown"
| "onPointerMove"
| "onPointerUp"
| "onWheel"
>
export interface CropperProps extends CropperElementProps {
alt?: string
aspectRatio?: number
children?: React.ReactNode
crossOrigin?: "anonymous" | "use-credentials"
defaultValue?: Partial<CropperValue>
disabled?: boolean
imageClassName?: string
keyboardStep?: number
maxZoom?: number
minZoom?: number
onCropAreaChange?: (area: CropArea) => void
onCropComplete?: (area: CropArea, value: CropperValue) => void
onImageLoad?: (size: CropperSize) => void
onInteractionChange?: (interacting: boolean) => void
onValueChange?: (value: CropperValue, details: CropperChangeDetails) => void
shape?: "circle" | "rectangle"
showGrid?: boolean
src: string
value?: CropperValue
zoomStep?: number
}
type PointerPositions = Map<number, CropperPoint>
const emptySize: CropperSize = { height: 0, width: 0 }
export const Cropper = React.forwardRef<HTMLDivElement, CropperProps>(
function Cropper(
{
alt = "",
aspectRatio = 1,
children,
className,
crossOrigin,
defaultValue,
disabled = false,
imageClassName,
keyboardStep = 10,
maxZoom = 4,
minZoom = 1,
onCropAreaChange,
onCropComplete,
onImageLoad,
onInteractionChange,
onValueChange,
shape = "rectangle",
showGrid = true,
src,
style,
value: controlledValue,
zoomStep = 0.1,
...props
},
forwardedRef
) {
const rootRef = React.useRef<HTMLDivElement | 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 [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
createInitialValue(defaultValue, minZoom, maxZoom)
)
const isControlled = controlledValue !== undefined
const rawValue = controlledValue ?? uncontrolledValue
const geometry = React.useMemo<CropperGeometryOptions>(
() => ({ image, maxZoom, minZoom, viewport }),
[image, maxZoom, minZoom, viewport]
)
const currentValue = React.useMemo(
() => constrainCropperValue(rawValue, geometry),
[geometry, rawValue]
)
const valueRef = React.useRef(currentValue)
valueRef.current = currentValue
const cropArea = React.useMemo(
() => getCropArea(currentValue, viewport, image),
[currentValue, image, viewport]
)
const setRoot = React.useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node
assignRef(forwardedRef, node)
},
[forwardedRef]
)
const commitValue = React.useCallback(
(candidate: Partial<CropperValue>, reason: CropperChangeReason) => {
const nextValue = constrainCropperValue(candidate, geometry)
if (isCropperValueEqual(valueRef.current, nextValue)) return
valueRef.current = nextValue
if (!isControlled) setUncontrolledValue(nextValue)
onValueChange?.(nextValue, { reason })
},
[geometry, isControlled, onValueChange]
)
const finishInteraction = React.useCallback(() => {
if (!interactingRef.current) return
interactingRef.current = false
setInteracting(false)
onInteractionChange?.(false)
const area = getCropArea(valueRef.current, viewport, image)
if (area) onCropComplete?.(area, valueRef.current)
}, [image, onCropComplete, onInteractionChange, viewport])
const startInteraction = React.useCallback(() => {
if (interactingRef.current) return
interactingRef.current = true
setInteracting(true)
onInteractionChange?.(true)
}, [onInteractionChange])
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const updateSize = () => {
const bounds = root.getBoundingClientRect()
setViewport((current) => {
const next = { height: bounds.height, width: bounds.width }
return sameSize(current, next) ? current : next
})
}
updateSize()
const observer = new ResizeObserver(updateSize)
observer.observe(root)
return () => observer.disconnect()
}, [])
React.useEffect(() => {
if (!cropArea) return
onCropAreaChange?.(cropArea)
}, [cropArea, onCropAreaChange])
React.useEffect(() => {
if (isCropperValueEqual(rawValue, currentValue)) return
if (!isControlled) setUncontrolledValue(currentValue)
onValueChange?.(currentValue, { reason: "resize" })
}, [currentValue, isControlled, onValueChange, rawValue])
React.useEffect(() => {
setImage(emptySize)
pointersRef.current.clear()
}, [src])
React.useEffect(
() => () => {
if (wheelEndTimerRef.current !== undefined) {
window.clearTimeout(wheelEndTimerRef.current)
}
},
[]
)
const renderedImage = getRenderedImageSize(
viewport,
image,
currentValue.zoom
)
function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) {
return
}
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
pointersRef.current.set(event.pointerId, pointFromPointer(event))
startInteraction()
}
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
const pointers = pointersRef.current
if (disabled || !pointers.has(event.pointerId)) return
event.preventDefault()
const previous = new Map(pointers)
pointers.set(event.pointerId, pointFromPointer(event))
if (pointers.size === 1) {
const before = previous.get(event.pointerId)
const after = pointers.get(event.pointerId)
if (!before || !after) return
commitValue(
{
...valueRef.current,
x: valueRef.current.x + after.x - before.x,
y: valueRef.current.y + after.y - before.y,
},
"drag"
)
return
}
const before = firstTwoPoints(previous)
const after = firstTwoPoints(pointers)
const root = rootRef.current
if (!before || !after || !root) return
const previousDistance = distanceBetween(...before)
if (previousDistance === 0) return
const bounds = root.getBoundingClientRect()
const previousMidpoint = relativeToCenter(midpoint(...before), bounds)
const nextMidpoint = relativeToCenter(midpoint(...after), bounds)
const zoom =
valueRef.current.zoom * (distanceBetween(...after) / previousDistance)
const zoomedValue = zoomCropperValueAtPoint(
valueRef.current,
zoom,
previousMidpoint,
geometry
)
commitValue(
{
...zoomedValue,
x: zoomedValue.x + nextMidpoint.x - previousMidpoint.x,
y: zoomedValue.y + nextMidpoint.y - previousMidpoint.y,
},
"pinch"
)
}
function handlePointerEnd(event: React.PointerEvent<HTMLDivElement>) {
if (!pointersRef.current.delete(event.pointerId)) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (pointersRef.current.size === 0) finishInteraction()
}
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
if (disabled) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
const point = relativeToCenter(
{ x: event.clientX, y: event.clientY },
bounds
)
const delta = normalizeWheelDelta(event, viewport.height)
const zoom = valueRef.current.zoom * Math.exp(-delta * zoomStep * 0.01)
const nextValue = zoomCropperValueAtPoint(
valueRef.current,
zoom,
point,
geometry
)
startInteraction()
commitValue(nextValue, "wheel")
if (wheelEndTimerRef.current !== undefined) {
window.clearTimeout(wheelEndTimerRef.current)
}
wheelEndTimerRef.current = window.setTimeout(finishInteraction, 140)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return
const step = event.shiftKey ? keyboardStep * 0.25 : keyboardStep
let nextValue: Partial<CropperValue> | undefined
let reason: CropperChangeReason = "keyboard"
if (event.key === "ArrowLeft") {
nextValue = { ...valueRef.current, x: valueRef.current.x - step }
} else if (event.key === "ArrowRight") {
nextValue = { ...valueRef.current, x: valueRef.current.x + step }
} else if (event.key === "ArrowUp") {
nextValue = { ...valueRef.current, y: valueRef.current.y - step }
} else if (event.key === "ArrowDown") {
nextValue = { ...valueRef.current, y: valueRef.current.y + step }
} else if (event.key === "+" || event.key === "=") {
nextValue = {
...valueRef.current,
zoom: valueRef.current.zoom + zoomStep,
}
reason = "zoom"
} else if (event.key === "-") {
nextValue = {
...valueRef.current,
zoom: valueRef.current.zoom - zoomStep,
}
reason = "zoom"
} else if (event.key === "0" || event.key === "Home") {
nextValue = { x: 0, y: 0, zoom: Math.max(1, minZoom) }
reason = "reset"
}
if (!nextValue) return
event.preventDefault()
commitValue(nextValue, reason)
const area = getCropArea(
constrainCropperValue(nextValue, geometry),
viewport,
image
)
if (area) onCropComplete?.(area, valueRef.current)
}
function handleDoubleClick(event: React.MouseEvent<HTMLDivElement>) {
if (disabled) return
const bounds = event.currentTarget.getBoundingClientRect()
const point = relativeToCenter(
{ x: event.clientX, y: event.clientY },
bounds
)
const minimum = Math.max(1, minZoom)
const zoom =
currentValue.zoom > minimum
? minimum
: Math.min(Math.max(minimum * 2, minimum + zoomStep), maxZoom)
commitValue(
zoomCropperValueAtPoint(currentValue, zoom, point, geometry),
"zoom"
)
const area = getCropArea(valueRef.current, viewport, image)
if (area) onCropComplete?.(area, valueRef.current)
}
return (
<div
ref={setRoot}
data-disabled={disabled || undefined}
data-interacting={interacting || undefined}
data-ready={image.width > 0 || undefined}
data-shape={shape}
data-slot="cropper"
role="group"
tabIndex={disabled ? undefined : 0}
aria-disabled={disabled || undefined}
aria-label={props["aria-label"] ?? "Image cropper"}
className={cn(
"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 }}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onWheel={handleWheel}
{...props}
>
<img
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",
imageClassName
)}
crossOrigin={crossOrigin}
draggable={false}
src={src}
style={{
height: renderedImage.height || undefined,
opacity: image.width > 0 ? 1 : 0,
transform: `translate(-50%, -50%) translate3d(${currentValue.x}px, ${currentValue.y}px, 0)`,
width: renderedImage.width || undefined,
}}
onLoad={(event) => {
const nextImage = {
height: event.currentTarget.naturalHeight,
width: event.currentTarget.naturalWidth,
}
setImage(nextImage)
onImageLoad?.(nextImage)
}}
/>
{showGrid && <CropperGrid />}
{children}
</div>
)
}
)
function CropperGrid() {
return (
<div
data-slot="cropper-grid"
className="pointer-events-none absolute inset-0 z-10 opacity-60"
aria-hidden="true"
>
<span className="absolute inset-y-0 start-1/3 border-s border-white/70 mix-blend-difference" />
<span className="absolute inset-y-0 start-2/3 border-s border-white/70 mix-blend-difference" />
<span className="absolute inset-x-0 top-1/3 border-t border-white/70 mix-blend-difference" />
<span className="absolute inset-x-0 top-2/3 border-t border-white/70 mix-blend-difference" />
</div>
)
}
function createInitialValue(
value: Partial<CropperValue> | undefined,
minZoom: number,
maxZoom: number
): CropperValue {
const minimum = Math.max(1, finitePositive(minZoom, 1))
return {
x: finite(value?.x, defaultCropperValue.x),
y: finite(value?.y, defaultCropperValue.y),
zoom: Math.min(
Math.max(finitePositive(value?.zoom, minimum), minimum),
Math.max(minimum, finitePositive(maxZoom, 4))
),
}
}
function pointFromPointer(event: React.PointerEvent) {
return { x: event.clientX, y: event.clientY }
}
function firstTwoPoints(points: PointerPositions) {
const values = [...points.values()]
return values.length >= 2
? ([values[0], values[1]] as [CropperPoint, CropperPoint])
: undefined
}
function midpoint(first: CropperPoint, second: CropperPoint) {
return {
x: (first.x + second.x) / 2,
y: (first.y + second.y) / 2,
}
}
function distanceBetween(first: CropperPoint, second: CropperPoint) {
return Math.hypot(second.x - first.x, second.y - first.y)
}
function relativeToCenter(point: CropperPoint, bounds: DOMRect) {
return {
x: point.x - bounds.left - bounds.width / 2,
y: point.y - bounds.top - bounds.height / 2,
}
}
function normalizeWheelDelta(event: React.WheelEvent, viewportHeight: number) {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * viewportHeight
}
return event.deltaY
}
function sameSize(first: CropperSize, second: CropperSize) {
return first.width === second.width && first.height === second.height
}
function finite(value: number | undefined, fallback: number) {
return Number.isFinite(value) ? (value ?? fallback) : fallback
}
function finitePositive(value: number | undefined, fallback: number) {
return Number.isFinite(value) && (value ?? 0) > 0
? (value ?? fallback)
: fallback
}
function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
if (typeof ref === "function") ref(value)
else if (ref) ref.current = value
}
+66
View File
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { cropImage } from "@workspace/cropper"
import { afterEach, describe, expect, it, vi } from "vitest"
afterEach(() => vi.restoreAllMocks())
describe("cropImage", () => {
it("draws source pixels at the requested output size", async () => {
const drawImage = vi.fn()
const context = {
beginPath: vi.fn(),
clip: vi.fn(),
drawImage,
ellipse: vi.fn(),
fillRect: vi.fn(),
restore: vi.fn(),
save: 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: 600 },
naturalWidth: { configurable: true, value: 1200 },
})
const result = await cropImage(
image,
{ height: 200, width: 400, x: 20, y: 30 },
{ shape: "circle", width: 200 }
)
expect(result.type).toBe("image/png")
expect(context.ellipse).toHaveBeenCalledWith(
100,
50,
100,
50,
0,
0,
Math.PI * 2
)
expect(drawImage).toHaveBeenCalledWith(
image,
20,
30,
400,
200,
0,
0,
200,
100
)
})
it("rejects an invalid crop rectangle", async () => {
await expect(
cropImage("image.jpg", { height: 0, width: 100, x: 0, y: 0 })
).rejects.toThrow(RangeError)
})
})
+198
View File
@@ -0,0 +1,198 @@
import type { CropArea } from "./types"
export type CropImageSource = Blob | HTMLImageElement | string
export type CropImageOptions = Readonly<{
background?: string
crossOrigin?: "anonymous" | "use-credentials"
height?: number
quality?: number
shape?: "circle" | "rectangle"
type?: string
width?: number
}>
/** Renders a source-image crop to a browser Blob using the Canvas API. */
export async function cropImage(
source: CropImageSource,
area: CropArea,
options: CropImageOptions = {}
): Promise<Blob> {
if (typeof document === "undefined" || typeof Image === "undefined") {
throw new Error("cropImage requires a browser Canvas environment")
}
assertCropArea(area)
const loaded = await resolveImage(source, options.crossOrigin)
try {
const output = getOutputSize(area, options)
const canvas = document.createElement("canvas")
canvas.width = output.width
canvas.height = output.height
const context = canvas.getContext("2d")
if (!context) throw new Error("Canvas 2D context is unavailable")
if (options.background) {
context.fillStyle = options.background
context.fillRect(0, 0, output.width, output.height)
}
if (options.shape === "circle") {
context.save()
context.beginPath()
context.ellipse(
output.width / 2,
output.height / 2,
output.width / 2,
output.height / 2,
0,
0,
Math.PI * 2
)
context.clip()
}
context.drawImage(
loaded.image,
area.x,
area.y,
area.width,
area.height,
0,
0,
output.width,
output.height
)
if (options.shape === "circle") context.restore()
return await canvasToBlob(
canvas,
options.type ?? "image/png",
options.quality
)
} finally {
loaded.dispose()
}
}
function getOutputSize(area: CropArea, options: CropImageOptions) {
const aspectRatio = area.width / area.height
const requestedWidth = positiveInteger(options.width)
const requestedHeight = positiveInteger(options.height)
if (requestedWidth && requestedHeight) {
return { height: requestedHeight, width: requestedWidth }
}
if (requestedWidth) {
return {
height: Math.max(1, Math.round(requestedWidth / aspectRatio)),
width: requestedWidth,
}
}
if (requestedHeight) {
return {
height: requestedHeight,
width: Math.max(1, Math.round(requestedHeight * aspectRatio)),
}
}
return {
height: Math.max(1, Math.round(area.height)),
width: Math.max(1, Math.round(area.width)),
}
}
async function resolveImage(
source: CropImageSource,
crossOrigin: CropImageOptions["crossOrigin"]
) {
if (
typeof HTMLImageElement !== "undefined" &&
source instanceof HTMLImageElement
) {
await ensureImageReady(source)
return { dispose() {}, image: source }
}
const objectUrl =
source instanceof Blob ? URL.createObjectURL(source) : undefined
const image = new Image()
if (crossOrigin) image.crossOrigin = crossOrigin
image.decoding = "async"
image.src = typeof source === "string" ? source : objectUrl!
try {
await ensureImageReady(image)
return {
dispose() {
if (objectUrl) URL.revokeObjectURL(objectUrl)
},
image,
}
} catch (error) {
if (objectUrl) URL.revokeObjectURL(objectUrl)
throw error
}
}
async function ensureImageReady(image: HTMLImageElement) {
if (!image.complete || image.naturalWidth === 0) {
await new Promise<void>((resolve, reject) => {
image.addEventListener("load", () => resolve(), { once: true })
image.addEventListener(
"error",
() => reject(new Error("Unable to load image")),
{
once: true,
}
)
})
}
if (image.decode) {
await image.decode().catch(() => undefined)
}
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
throw new Error("Image has no intrinsic dimensions")
}
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality?: number
) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error("Unable to encode cropped image"))
},
type,
quality
)
})
}
function positiveInteger(value: number | undefined) {
return Number.isFinite(value) && (value ?? 0) > 0
? Math.max(1, Math.round(value ?? 1))
: undefined
}
function assertCropArea(area: CropArea) {
if (
!Number.isFinite(area.x) ||
!Number.isFinite(area.y) ||
!Number.isFinite(area.width) ||
!Number.isFinite(area.height) ||
area.width <= 0 ||
area.height <= 0
) {
throw new RangeError("Crop area must contain finite, positive dimensions")
}
}
+68
View File
@@ -0,0 +1,68 @@
import {
constrainCropperValue,
getCoverScale,
getCropArea,
getCropperBounds,
zoomCropperValueAtPoint,
} from "@workspace/cropper"
import { describe, expect, it } from "vitest"
const viewport = { height: 300, width: 300 }
const landscape = { height: 500, width: 1000 }
describe("cropper geometry", () => {
it("fits an image so the viewport is always covered", () => {
expect(getCoverScale(viewport, landscape)).toBe(0.6)
expect(getCropperBounds(viewport, landscape, 1)).toEqual({
maxX: 150,
maxY: 0,
})
})
it("constrains zoom and translation to the covered image", () => {
expect(
constrainCropperValue(
{ x: 5000, y: -500, zoom: 10 },
{ image: landscape, maxZoom: 4, minZoom: 1, viewport }
)
).toEqual({ x: 1050, y: -450, zoom: 4 })
})
it("preserves an initial translation until dimensions are available", () => {
expect(
constrainCropperValue(
{ x: 40, y: -20, zoom: 2 },
{ image: { height: 0, width: 0 }, viewport }
)
).toEqual({ x: 40, y: -20, zoom: 2 })
})
it("returns a source-pixel crop rectangle", () => {
expect(getCropArea({ x: 0, y: 0, zoom: 1 }, viewport, landscape)).toEqual({
height: 500,
width: 500,
x: 250,
y: 0,
})
expect(getCropArea({ x: 150, y: 0, zoom: 1 }, viewport, landscape)).toEqual(
{
height: 500,
width: 500,
x: 0,
y: 0,
}
)
})
it("keeps the source point beneath the zoom origin stationary", () => {
expect(
zoomCropperValueAtPoint(
{ x: 0, y: 0, zoom: 1 },
2,
{ x: 100, y: 0 },
{ image: landscape, viewport }
)
).toEqual({ x: -100, y: 0, zoom: 2 })
})
})
+162
View File
@@ -0,0 +1,162 @@
import type { CropArea, CropperPoint, CropperSize, CropperValue } from "./types"
export type CropperBounds = Readonly<{
maxX: number
maxY: number
}>
export type CropperGeometryOptions = Readonly<{
image: CropperSize
maxZoom?: number
minZoom?: number
viewport: CropperSize
}>
export const defaultCropperValue: CropperValue = {
x: 0,
y: 0,
zoom: 1,
}
export function getCoverScale(viewport: CropperSize, image: CropperSize) {
if (!isPositiveSize(viewport) || !isPositiveSize(image)) return 0
return Math.max(viewport.width / image.width, viewport.height / image.height)
}
export function getRenderedImageSize(
viewport: CropperSize,
image: CropperSize,
zoom: number
): CropperSize {
const scale = getCoverScale(viewport, image) * sanitizeZoom(zoom, 1)
return {
height: image.height * scale,
width: image.width * scale,
}
}
export function getCropperBounds(
viewport: CropperSize,
image: CropperSize,
zoom: number
): CropperBounds {
const rendered = getRenderedImageSize(viewport, image, zoom)
return {
maxX: Math.max(0, (rendered.width - viewport.width) / 2),
maxY: Math.max(0, (rendered.height - viewport.height) / 2),
}
}
export function constrainCropperValue(
value: Partial<CropperValue> | undefined,
options: CropperGeometryOptions
): CropperValue {
const { maxZoom, minZoom } = getZoomRange(options)
const zoom = clamp(sanitizeZoom(value?.zoom, minZoom), minZoom, maxZoom)
if (!isPositiveSize(options.viewport) || !isPositiveSize(options.image)) {
return {
x: sanitizeCoordinate(value?.x),
y: sanitizeCoordinate(value?.y),
zoom,
}
}
const bounds = getCropperBounds(options.viewport, options.image, zoom)
return {
x: clamp(sanitizeCoordinate(value?.x), -bounds.maxX, bounds.maxX),
y: clamp(sanitizeCoordinate(value?.y), -bounds.maxY, bounds.maxY),
zoom,
}
}
export function getCropArea(
value: CropperValue,
viewport: CropperSize,
image: CropperSize
): CropArea | undefined {
const scale = getCoverScale(viewport, image) * value.zoom
if (!Number.isFinite(scale) || scale <= 0) return undefined
const width = Math.min(image.width, viewport.width / scale)
const height = Math.min(image.height, viewport.height / scale)
return {
x: clamp(
(image.width - width) / 2 - value.x / scale,
0,
image.width - width
),
y: clamp(
(image.height - height) / 2 - value.y / scale,
0,
image.height - height
),
width,
height,
}
}
/** Keeps the source pixel below a viewport point stationary while zooming. */
export function zoomCropperValueAtPoint(
value: CropperValue,
zoom: number,
point: CropperPoint,
options: CropperGeometryOptions
): CropperValue {
const { maxZoom, minZoom } = getZoomRange(options)
const currentZoom = clamp(sanitizeZoom(value.zoom, minZoom), minZoom, maxZoom)
const nextZoom = clamp(sanitizeZoom(zoom, value.zoom), minZoom, maxZoom)
const ratio = nextZoom / currentZoom
return constrainCropperValue(
{
x: point.x - (point.x - value.x) * ratio,
y: point.y - (point.y - value.y) * ratio,
zoom: nextZoom,
},
options
)
}
export function isCropperValueEqual(first: CropperValue, second: CropperValue) {
return (
first.x === second.x && first.y === second.y && first.zoom === second.zoom
)
}
function getZoomRange(options: CropperGeometryOptions) {
const minZoom = Math.max(1, sanitizeZoom(options.minZoom, 1))
const maxZoom = Math.max(
minZoom,
sanitizeZoom(options.maxZoom, Math.max(minZoom, 4))
)
return { maxZoom, minZoom }
}
function isPositiveSize(size: CropperSize) {
return (
Number.isFinite(size.width) &&
size.width > 0 &&
Number.isFinite(size.height) &&
size.height > 0
)
}
function sanitizeCoordinate(value: number | undefined) {
return Number.isFinite(value) ? (value ?? 0) : 0
}
function sanitizeZoom(value: number | undefined, fallback: number) {
return Number.isFinite(value) && (value ?? 0) > 0
? (value ?? fallback)
: fallback
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
}
+23
View File
@@ -0,0 +1,23 @@
export { Cropper } from "./cropper"
export type { CropperProps } from "./cropper"
export { cropImage } from "./export-image"
export type { CropImageOptions, CropImageSource } from "./export-image"
export {
constrainCropperValue,
defaultCropperValue,
getCoverScale,
getCropArea,
getCropperBounds,
getRenderedImageSize,
isCropperValueEqual,
zoomCropperValueAtPoint,
} from "./geometry"
export type { CropperBounds, CropperGeometryOptions } from "./geometry"
export type {
CropArea,
CropperChangeDetails,
CropperChangeReason,
CropperPoint,
CropperSize,
CropperValue,
} from "./types"
+1
View File
@@ -0,0 +1 @@
@source "../";
+37
View File
@@ -0,0 +1,37 @@
export type CropperPoint = Readonly<{
x: number
y: number
}>
export type CropperSize = Readonly<{
height: number
width: number
}>
/** Translation is expressed in viewport pixels; zoom is relative to cover fit. */
export type CropperValue = Readonly<{
x: number
y: number
zoom: number
}>
/** A crop rectangle expressed in source-image pixels. */
export type CropArea = Readonly<{
height: number
width: number
x: number
y: number
}>
export type CropperChangeReason =
| "drag"
| "keyboard"
| "pinch"
| "reset"
| "resize"
| "wheel"
| "zoom"
export type CropperChangeDetails = Readonly<{
reason: CropperChangeReason
}>
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/cropper": ["./src/index.ts"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}