基于react-easy-crop实现imgUrl 16:9 9:16裁剪

392 阅读2分钟

业务场景: 拿到封面图的同时 将图片按照16:9或9:16的比例进行裁剪,如何拿到裁剪后的图片url

参考: demo 示例

// CropImg 组件
import React, {
	useState,
	useMemo,
	forwardRef,
	useImperativeHandle,
	useRef,
	useCallback,
} from "react";
import type { MutableRefObject } from "react";

import Cropper from "react-easy-crop";
import type { Area } from "react-easy-crop/types";

import { uploadFile } from "@/services/PGCService/file";
import { getCroppedImg, dataURLtoFile, ImageTypes } from "./canvasUtils";

interface CropImgProps {
	imageSrc: string;
	rotation?: number; // 旋转角度
	aspectWidth?: number; // 宽
	aspectHeight?: number; // 高  两个参数即位宽高比
	onCropReady?: () => void;
	onImageDrawSuccess?: () => void;
	onImageDrawFail?: () => void;
	onUploadSuccess?: (imgSrc: string) => void; //
	onUploadFail?: () => void;
}

export type CropImgRef = {
	onCrop: () => Area;
	cropUploaded: () => Promise<string>;
	cropPixelsRef: MutableRefObject<Area>; // 裁剪区域
	getFile: () => Promise<File>;
};

const CropImg = forwardRef<CropImgRef, CropImgProps>(
	(
		{
			imageSrc,
			rotation = 0,
			aspectWidth = 16,
			aspectHeight = 9,
			onCropReady,
			onImageDrawSuccess,
			onImageDrawFail,
			onUploadSuccess,
			onUploadFail,
		},
		ref,
	) => {
		const [crop, setCrop] = useState({ x: 0, y: 0 });
		const cropPixelsRef = useRef<Area>({ width: 0, height: 0, x: 0, y: 0 });

		const onCropComplete = useCallback(
			(_: Area, croppedAreaPixels: Area) => {
                        cropPixelsRef.current = croppedAreaPixels;
                        onCropReady?.();
			},
			[onCropReady],
		);

		// 简易的文件类型截取,不一定是真实的图片类型
		const imgType = useMemo<string>(() => {
                    if (!imageSrc) return "";
                    const [imgPath] = imageSrc.split("?");
                    return imgPath.substring(imgPath.lastIndexOf(".") + 1);
		}, [imageSrc]);

		const getFile = async (): Promise<File> => {
                // console.time('a')
                if (!cropPixelsRef.current.width) {
                        onImageDrawFail?.();
                        return null;
                }
                const croppedImage = await getCroppedImg({
                        imageSrc,
                        pixelCrop: cropPixelsRef.current,
                        imgType: imgType as ImageTypes,
                        quality: 0.8,
                });
                // 简单粗暴的判断当前裁剪的是否有内容
                if (croppedImage.length < 50) {
                        onImageDrawFail?.();
                        return null;
                }
                onImageDrawSuccess?.();
                // console.timeEnd('a')
                return dataURLtoFile({
                    base64Data: croppedImage,
                    fileName: `${Date.now()}.${imgType}`,
                    type: imgType,
                });
		};

		const cropUploaded = async (): Promise<string> => {
			// console.time('b')
			const file = await getFile();
			if (!file) return undefined;
			const formdata = new FormData();
			formdata.append("file", file);
			const res = await uploadFile(formdata, 1);
			// console.timeEnd('b')
			if (res.code === 0) {
				onUploadSuccess?.(res.result?.url);
				return res.result?.url;
			}
			onUploadFail?.();
			return undefined;
		};

		useImperativeHandle(ref, () => ({
			onCrop: () => cropPixelsRef.current,
			cropPixelsRef,
			cropUploaded,
			getFile,
		}));

		return (
			<div style={{ visibility: "hidden", opacity: 0, zIndex: -1 }}>
				{imageSrc ? (
                                <Cropper
                                    image={imageSrc}
                                    crop={crop}
                                    rotation={rotation}
                                    aspect={aspectWidth / aspectHeight}
                                    onCropChange={setCrop}
                                    onCropComplete={onCropComplete}
                                />
				) : null}
			</div>
		);
	},
);
export default React.memo(CropImg);


// canvasUtils.ts
import type { Area } from "react-easy-crop/types";

export const createImage = (url: string): Promise<HTMLImageElement> =>
	new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener("load", () => resolve(image));
    image.addEventListener("error", (error) => reject(error));
    image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
    image.src = url;
	});
// 旋转角度
export function getRadianAngle(degreeValue: number) {
	return (degreeValue * Math.PI) / 180;
}

/**
 * Returns the new bounding area of a rotated rectangle.
 */

interface RotateSizeParams {
	width: number;
	height: number;
	rotation: number;
}

export function rotateSize({ width, height, rotation }: RotateSizeParams): RotateSizeParams {
	const rotRad = getRadianAngle(rotation);

	return {
        width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
        height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
        rotation,
	};
}

/**
 * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
 */
/**
 * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
 */
interface CroppedImageParams {
	imageSrc: string;
	pixelCrop: Area;
	imgType: ImageTypes;
	rotation?: number;
	flip?: { horizontal: boolean; vertical: boolean };
	quality?: number;
}
export async function getCroppedImg({
	imageSrc,
	pixelCrop,
	imgType,
	rotation = 0,
	flip = { horizontal: false, vertical: false },
	quality = 1,
}: CroppedImageParams): Promise<string> {
	const image = await createImage(imageSrc);
	const canvas = document.createElement("canvas");
	const ctx = canvas.getContext("2d");

	if (!ctx) {
        return "";
	}

	const rotRad = getRadianAngle(rotation);

	// calculate bounding box of the rotated image
	const { width: bBoxWidth, height: bBoxHeight } = rotateSize({
		width: image.width,
		height: image.height,
		rotation,
	});

	// set canvas size to match the bounding box
	canvas.width = bBoxWidth;
	canvas.height = bBoxHeight;

	// translate canvas context to a central location to allow rotating and flipping around the center
	ctx.translate(bBoxWidth / 2, bBoxHeight / 2);
	ctx.rotate(rotRad);
	ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1);
	ctx.translate(-image.width / 2, -image.height / 2);

	// draw rotated image
	ctx.drawImage(image, 0, 0);

	const croppedCanvas = document.createElement("canvas");

	const croppedCtx = croppedCanvas.getContext("2d") as CanvasRenderingContext2D;

	if (!croppedCtx) {
		return "";
	}

	// Set the size of the cropped canvas
	croppedCanvas.width = pixelCrop.width;
	croppedCanvas.height = pixelCrop.height;

	// Draw the cropped image onto the new canvas
	croppedCtx.drawImage(
		canvas,
		pixelCrop.x,
		pixelCrop.y,
		pixelCrop.width,
		pixelCrop.height,
		0,
		0,
		pixelCrop.width,
		pixelCrop.height,
	);

	// As Base64 string
	return croppedCanvas.toDataURL(`image/${imgType}`, quality);

	// As a blob
	// return new Promise((resolve, reject) => {
	// 	croppedCanvas.toBlob((file) => {
	// 		resolve(URL.createObjectURL(file));
	// 	}, "image/jpeg");
	// });
}

export async function getRotatedImage(imageSrc, rotation = 0) {
	const image = await createImage(imageSrc);
	const canvas = document.createElement("canvas");
	const ctx = canvas.getContext("2d");

	const orientationChanged =
		rotation === 90 || rotation === -90 || rotation === 270 || rotation === -270;
	if (orientationChanged) {
		canvas.width = image.height;
		canvas.height = image.width;
	} else {
		canvas.width = image.width;
		canvas.height = image.height;
	}

	ctx.translate(canvas.width / 2, canvas.height / 2);
	ctx.rotate((rotation * Math.PI) / 180);
	ctx.drawImage(image, -image.width / 2, -image.height / 2);

	return new Promise((resolve) => {
		canvas.toBlob((file) => {
			resolve(URL.createObjectURL(file));
		}, "image/png");
	});
}

export enum ImageTypes {
	JPG = "jpg",
	PNG = "png",
	JPEG = "jpeg",
}

export interface DataURLFileProps {
	base64Data: string;
	fileName: string;
	type?: string | ImageTypes;
}

/**
 * @desc app chooseImage base64 -> File
 * @param {base64Data, fileName, type} DataURLFileProps
 */
export function dataURLtoFile({
	base64Data,
	fileName,
	type = ImageTypes.JPG,
}: DataURLFileProps): File {
	const arr = base64Data.split(",");
	const bstr = window.atob(arr[1]);
	let n = bstr.length;
	const u8arr = new Uint8Array(n);

	while (n--) {
		u8arr[n] = bstr.charCodeAt(n);
	}
	const file = new File([u8arr], `${fileName}.${type}`, {
		type: `image/${type}`,
	});
	return file;
}

业务场景实现

 import CropImg, { CropImgRef } from "./CropImg";
    const cropRef = useRef<CropImgRef>();
    const [imageSrc, setImageSrc] = useState("");
    const [videoCoverState, setVideoCoverState] = useState(false)
        
    const onVideoCoverReady = async () => {
        const videoCover = form.getFieldValue("videoCover");
        // 封面图无 并且正在上传中 则不替换
        try {
            if (!videoCover && !videoCoverState) {
                setVideoCoverState(true);
                const result = await cropRef.current.cropUploaded();
                form.setFieldsValue({
                      videoCover: result,
                });
            }
        } finally {
            setVideoCoverState(false);
        }
    };

        
	<CropImg
        imageSrc={imageSrc}
        ref={cropRef}
        aspectWidth={16}
        aspectHeight={9}
        onCropReady={onVideoCoverReady}
  />