feat(cropper): add image cropping primitives
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
@source "../";
|
||||
@@ -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
|
||||
}>
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user