From 23e086702749dc4d6a4bc9e71c4b2cd5fb7763fe Mon Sep 17 00:00:00 2001 From: Maofeng Date: Tue, 29 Sep 2026 17:09:26 +0800 Subject: [PATCH] feat(cropper): add image cropping primitives --- bun.lock | 19 + packages/cropper/package.json | 27 ++ packages/cropper/src/cropper.test.tsx | 94 ++++ packages/cropper/src/cropper.tsx | 514 ++++++++++++++++++++++ packages/cropper/src/export-image.test.ts | 66 +++ packages/cropper/src/export-image.ts | 198 +++++++++ packages/cropper/src/geometry.test.ts | 68 +++ packages/cropper/src/geometry.ts | 162 +++++++ packages/cropper/src/index.ts | 23 + packages/cropper/src/styles/globals.css | 1 + packages/cropper/src/types.ts | 37 ++ packages/cropper/tsconfig.json | 19 + 12 files changed, 1228 insertions(+) create mode 100644 packages/cropper/package.json create mode 100644 packages/cropper/src/cropper.test.tsx create mode 100644 packages/cropper/src/cropper.tsx create mode 100644 packages/cropper/src/export-image.test.ts create mode 100644 packages/cropper/src/export-image.ts create mode 100644 packages/cropper/src/geometry.test.ts create mode 100644 packages/cropper/src/geometry.ts create mode 100644 packages/cropper/src/index.ts create mode 100644 packages/cropper/src/styles/globals.css create mode 100644 packages/cropper/src/types.ts create mode 100644 packages/cropper/tsconfig.json diff --git a/bun.lock b/bun.lock index 12fbed4..3c6bd98 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/packages/cropper/package.json b/packages/cropper/package.json new file mode 100644 index 0000000..5f3b43d --- /dev/null +++ b/packages/cropper/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/cropper/src/cropper.test.tsx b/packages/cropper/src/cropper.test.tsx new file mode 100644 index 0000000..469849f --- /dev/null +++ b/packages/cropper/src/cropper.test.tsx @@ -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( + + ) + 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( + + Overlay + + ) + + const root = screen.getByRole("group") + expect(root.dataset.shape).toBe("circle") + expect(screen.getByText("Overlay")).toBeTruthy() + expect(document.querySelector('[data-slot="cropper-grid"]')).toBeNull() + }) +}) diff --git a/packages/cropper/src/cropper.tsx b/packages/cropper/src/cropper.tsx new file mode 100644 index 0000000..11fa385 --- /dev/null +++ b/packages/cropper/src/cropper.tsx @@ -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 + 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 +const emptySize: CropperSize = { height: 0, width: 0 } + +export const Cropper = React.forwardRef( + 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(null) + const pointersRef = React.useRef(new Map()) + const wheelEndTimerRef = React.useRef(undefined) + const interactingRef = React.useRef(false) + const [image, setImage] = React.useState(emptySize) + const [viewport, setViewport] = React.useState(emptySize) + const [interacting, setInteracting] = React.useState(false) + const [uncontrolledValue, setUncontrolledValue] = React.useState(() => + createInitialValue(defaultValue, minZoom, maxZoom) + ) + const isControlled = controlledValue !== undefined + const rawValue = controlledValue ?? uncontrolledValue + const geometry = React.useMemo( + () => ({ 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, 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) { + 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) { + 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) { + 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) { + 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) { + if (disabled) return + + const step = event.shiftKey ? keyboardStep * 0.25 : keyboardStep + let nextValue: Partial | 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) { + 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 ( +
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} + > + {alt} 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 && } + {children} +
+ ) + } +) + +function CropperGrid() { + return ( + + ) +} + +function createInitialValue( + value: Partial | 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(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === "function") ref(value) + else if (ref) ref.current = value +} diff --git a/packages/cropper/src/export-image.test.ts b/packages/cropper/src/export-image.test.ts new file mode 100644 index 0000000..bb125da --- /dev/null +++ b/packages/cropper/src/export-image.test.ts @@ -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) + }) +}) diff --git a/packages/cropper/src/export-image.ts b/packages/cropper/src/export-image.ts new file mode 100644 index 0000000..677213f --- /dev/null +++ b/packages/cropper/src/export-image.ts @@ -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 { + 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((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((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") + } +} diff --git a/packages/cropper/src/geometry.test.ts b/packages/cropper/src/geometry.test.ts new file mode 100644 index 0000000..cf4309f --- /dev/null +++ b/packages/cropper/src/geometry.test.ts @@ -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 }) + }) +}) diff --git a/packages/cropper/src/geometry.ts b/packages/cropper/src/geometry.ts new file mode 100644 index 0000000..02cea23 --- /dev/null +++ b/packages/cropper/src/geometry.ts @@ -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 | 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)) +} diff --git a/packages/cropper/src/index.ts b/packages/cropper/src/index.ts new file mode 100644 index 0000000..b49b696 --- /dev/null +++ b/packages/cropper/src/index.ts @@ -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" diff --git a/packages/cropper/src/styles/globals.css b/packages/cropper/src/styles/globals.css new file mode 100644 index 0000000..f3275b1 --- /dev/null +++ b/packages/cropper/src/styles/globals.css @@ -0,0 +1 @@ +@source "../"; diff --git a/packages/cropper/src/types.ts b/packages/cropper/src/types.ts new file mode 100644 index 0000000..b8fccbd --- /dev/null +++ b/packages/cropper/src/types.ts @@ -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 +}> diff --git a/packages/cropper/tsconfig.json b/packages/cropper/tsconfig.json new file mode 100644 index 0000000..ba6f631 --- /dev/null +++ b/packages/cropper/tsconfig.json @@ -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"] +} \ No newline at end of file