feat(cropper): add image cropping primitives
This commit is contained in:
@@ -139,6 +139,23 @@
|
|||||||
"vitest": "^4.1.10",
|
"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": {
|
"packages/i18n": {
|
||||||
"name": "@workspace/i18n",
|
"name": "@workspace/i18n",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -1114,6 +1131,8 @@
|
|||||||
|
|
||||||
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
|
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
|
||||||
|
|
||||||
|
"@workspace/cropper": ["@workspace/cropper@workspace:packages/cropper"],
|
||||||
|
|
||||||
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
||||||
|
|
||||||
"@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"],
|
"@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