一. 技术背景
cropper 是一款基于 JavaScript 的图片裁剪插件,通过 Canvas 技术实现了图片的裁剪和截图功能。Canvas 是 HTML5 中新增的一个标签,用于在网页上绘制图形、动画和游戏等交互式内容。它的历史背景可以追溯到2004年,当时苹果公司推出了一款名为 WebKit 的浏览器引擎,它支持使用 JavaScript 和 CSS 来创建动态效果。随着Web技术的不断发展,人们对于在网页上实现更加复杂的图形和动画效果的需求也越来越高,于是 HTML5 标准化组织开始研发Canvas标签,以满足这一需求。Canvas 标签成为了Web开发中不可或缺的一部分,被广泛应用于游戏、数据可视化、图形编辑等领域。
二. 技术依赖
cropper 依赖于 canvas 标签的原生API,其中包含 drawImage(),strokeRect(), getImageData(), toDataUrl() 方法等。
1. getContext()
返回 canvas 的上下文,如果没有定义则返回 null。
语法:
var ctx = canvas.getContext(contextType); var ctx = canvas.getContext(contextType, contextAttributes);
参数: 这里我们只介绍 "
2d"
:建立一个CanvasRenderingContext2D 二维渲染上下文。
2. drawImage()
提供多种在画布(canvas)上绘制图像的方式。
语法:
drawImage(image, dx, dy)
drawImage(image, dx, dy, dWidth, dHeight)
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
|
参数:
这里我们介绍 drawImage(image, dx, dy, dWidth, dHeight) 的参数,
image: 绘制到上下文的元素。允许任何的画布图像源;
dx:image的左上角在目标画布上 X 轴坐标。
dy:image的左上角在目标画布上 Y 轴坐标。
dWidth:image在目标画布上绘制的宽度。
dHeight:image在目标画布上绘制的高度。
复制代码
3. strokeRect()
使用当前的绘画样式,描绘一个起点在(x, y)、宽度为 w、高度为 h 的矩形方法。
语法:void ctx.strokeRect(x, y, width, height);
参数:
x:矩形起点的 x 轴坐标。
y:矩形起点的 y 轴坐标。
width:矩形的宽度。正值在右侧,负值在左侧。
height:矩形的高度。正值在下,负值在上。
复制代码
4. getImageData()
返回一个 ImageData 对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为*(sx, sy)、宽为sw、高为sh。
语法:ImageData ctx.getImageData(sx, sy, sw, sh);
参数:
sx:将要被提取的图像数据矩形区域的左上角 x 坐标。
sy:将要被提取的图像数据矩形区域的左上角 y 坐标。
sw:将要被提取的图像数据矩形区域的宽度。
sh:将要被提取的图像数据矩形区域的高度。
复制代码
5. toDataUrl()
方法返回一个包含图片展示的 data url。
语法:canvas.toDataURL(type, encoderOptions);
参数:
type 可选:图片格式,默认为 image/png。
encoderOptions` 可选:在指定图片格式为 `image/jpeg` 或 `image/webp` 的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 `0.92`。其他参数会被忽略。
复制代码
三. 实现流程
具体来说,cropper 主要是利用 Canvas 技术实现了以下几个方面的功能:
1. Canvas 绘制图片
cropper 可以将要裁剪的图片渲染到canvas中,通过 Canvas API 中的 drawImage() 方法实现。这样就可以在 Canvas 中显示图片,方便用户进行裁剪操作。
举个🌰:canvas 渲染图片的过程。
export default function Canvas(){
const canvasRef = useRef<HTMLCanvasElement>(null);
const loadImg = () => { const elem = canvasRef.current;
const canvas = elem?.getContext('2d');
const img = document.createElement('img');
console.log(elem?.height,elem?.width);
// 加载背景图片
img.onload = () => {
elem?.height!= 200
elem?.width!= 300
canvas?.drawImage(img,0,0) } img.src = src;
}
useEffect(() => {
loadImg();
})
return (
<>
<Group position='center'>
<Stack spacing={0}>
<Text>要使用的图片:</Text>
<Image id="scream" src={src} />
</Stack>
<Stack spacing={0} mt={20}>
<Text>画布:</Text>
<canvas ref={canvasRef} width={300} height={300} style={{border:'1px solid #d3d3d3'}} />
</Stack>
</Group>
</>
)}
复制代码
渲染到画布上的效果图:
2. 裁剪区域的计算
实现裁剪选择框,并自由移动的流程图如下:
比较常用的方式大概是这个样子:
如何将这几层图像按照需求正确的叠在一起呢? 这里我们用到的是 canvas API 中的 globalCompositeOperation() 方法。利用这个 API 我们也可以实现刮刮卡抽奖的效果。
当我们点下鼠标,就能够通过 event 事件对象获取鼠标点击位置,e.clientX 和 e.clientY ; 当鼠标进行移动的时候,也能通过 event 获取鼠标的位置,通过两次鼠标位置的改变,就能够获取鼠标移动的距离。即:初始的x轴位置为 initX = e.clientX,initY = e.clientY;
移动到某个点的位置为:endX = e.clientX,endY = e.clientY;
因此裁剪区域的宽Tx:endX - initX;高Ty:endY - initY;
举个🌰 :这个实例中,我们使用canvas API 绘制了一个裁剪框,并计算出了裁剪区域的坐标和大小。具体来说,我们在鼠标的移动时间中计算出了裁剪框的宽度和高度,然后使用 strokeRect() 方法绘制了一个红色的矩形框。最后,我们根据裁剪框的位置和大小计算出了裁剪区域的坐标和大小。
export default function Cropper() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const dragStartRef = useRef<any>(null);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 将图片绘制在Canvas上
canvas.width = img.width;
canvas.height = img.height;
ctx!.drawImage(img, 0, 0);
};
img.src = src;
// 绑定鼠标事件
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
return () => {
// 解绑鼠标事件
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp); }; }, [src]);
const handleMouseDown = (event: any) => {
dragStartRef.current = {
x: event.clientX,
y: event.clientY,
};
};
const handleMouseMove = (event: any) => {
if (!dragStartRef.current) return;
}
const canvas = canvasRef.current!;
const ctx = canvas!.getContext('2d');
const dragEnd = { x: event.clientX, y: event.clientY, };
const width = dragEnd.x - dragStartRef.current.x;
const height = dragEnd.y - dragStartRef.current.y;
// 清除Canvas上的内容
ctx!.clearRect(0, 0, canvas.width, canvas.height);
// 绘制原始图片
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx!.drawImage(img, 0, 0);
// 绘制裁剪框
ctx!.strokeStyle = '#39f';
ctx!.lineWidth = 2;
ctx!.strokeRect(dragStartRef.current.x, dragStartRef.current.y, width, height);
//绘制蒙层
ctx!.save();
ctx!.fillStyle = 'rgba(0,0,0,0.6)';// 蒙层颜色
ctx!.fillRect(0, 0, 500, 500);
//将蒙层凿开
ctx!.globalCompositeOperation = 'source-atop';
ctx!.clearRect(dragStartRef.current.x, dragStartRef.current.y, width, height)
// 绘制8个边框像素点并保存坐标信息以及事件参数
ctx!.globalCompositeOperation = 'source-over';
ctx!.fillStyle = '#fc178f';
let size = 5;//定义像素点大小
//逆时针写的点儿 ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x + width / 2, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y + height / 2, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y + height / 2, size, size);
ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y + height - size, size, size);
ctx!.fillRect(dragStartRef.current.x + width / 2, dragStartRef.current.y + height - size, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y + height - size, size, size);
ctx!.restore();
//再次使用drawImage 将图片绘制到蒙层下方
ctx!.save();
ctx!.globalCompositeOperation = 'destination-over';
ctx!.drawImage(img, 0, 0, canvas.width, canvas.height);
ctx!.restore();
// 获取裁剪区域的像素数据
const imageData = ctx!.getImageData(dragStartRef.current.x, dragStartRef.current.y, width, height);
console.log(imageData);
};
img.src = src;
};
const handleMouseUp = () => {
dragStartRef.current = null;
};
return (
<canvas ref={canvasRef} width={500} height={500} />
);
}
复制代码
计算裁剪区域大小效果图:
3. 像素数据处理
cropper 可以利用 Canvas API 中的 getImageData() 方法获取裁剪区域的像素数据,然后对该像素数据进行处理,如旋转、缩放、裁剪等操作。这样就可以根据用户的需求对图片进行精细化处理。
举个🌰:这个实例主要实现点击按钮后,对像素数据进行旋转,缩放处理。
export default function Cropper() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleClick = (type?: string) => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = src;
img.onload = () => {
// 将图片绘制在Canvas上
ctx!.drawImage(img, 0, 0);
// 缩放 Canvas/旋转Canvas
type === 'rotate' ? ctx!.rotate(Math.PI / 12): ctx!.scale(0.7, 0.7);
// 获取旋转/缩放后的像素数据
var imageData = ctx!.getImageData(0, 0, canvas.width, canvas.height);
// 将像素数据绘制到 Canvas 上
ctx!.putImageData(imageData, 0, 0); };
}
return (
<Group position="center">
<canvas ref={canvasRef} width={400} height={400} style={{border:'1pc solid #333'}} />
<Button onClick={() => handleClick('rotate')}>
旋转
</Button>
<Button onClick={() => handleClick('scale')}>
缩放
</Button>
</Group>
);
}
复制代码
旋转、缩放效果图:
4. 输出裁剪结果
利用 Canvas API 中的 toDataUrl() 方法实现裁剪内容输出,toDataUrl() 方法可以将处理后的像素数据转换为 base64 编码的字符串。
为什么要转换成 base64 位编码呢?
- 将图片转换成 base64 位后,图片会跟随代码(html、css、js)一起请求加载,不会再单独进行请求加载;
- 可以防止由于图片路径错误导致加载失败的问题;
举个🌰:我们通过之前的步骤,可以拿到裁剪后的内容,那么这一步我们要做到的是输出这个内容,并且展示在 img 标签中。主要使用的是自定义的 getCropData 函数 ,里面利用 canvas API 的 toDataUrl() 方法拿到裁剪内容,并赋值给变量 cropData ,拿到 cropData 作为 img 标签的 src,即可展示裁剪内容的图片;
export default function ResultData() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const dragStartRef = useRef<any>(null);
const newCanvasRef = useRef<HTMLCanvasElement>(null);
const [cropData, setCropData] = useState('#');
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// 将图片绘制在Canvas上
canvas.width = img.width;
canvas.height = img.height;
ctx!.drawImage(img, 0, 0);
};
img.src = src;
// 绑定鼠标事件
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
return () => {
// 解绑鼠标事件
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
};
}, [src]);
const handleMouseDown = (event: any) => {
dragStartRef.current = {
x: event.clientX,
y: event.clientY,
};
};
const handleMouseMove = (event: any) => {
if (!dragStartRef.current) { return; }
const canvas = canvasRef.current!;
const ctx = canvas!.getContext('2d');
const newCanvas = newCanvasRef.current!;
const newCtx = newCanvas.getContext('2d');
const dragEnd = {
x: event.clientX,
y: event.clientY,
};
const width = dragEnd.x - dragStartRef.current.x;
const height = dragEnd.y - dragStartRef.current.y;
// 清除Canvas上的内容
ctx!.clearRect(0, 0, canvas.width, canvas.height);
// 绘制原始图片
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx!.drawImage(img, 0, 0);
// 绘制裁剪框
ctx!.strokeStyle = '#39f';
//描述画笔(绘制图形)颜色
ctx!.lineWidth = 2;
ctx!.strokeRect(dragStartRef.current.x, dragStartRef.current.y, width, height);
//绘制蒙层
ctx!.save();
ctx!.fillStyle = 'rgba(0,0,0,0.6)'; // 蒙层颜色
ctx!.fillRect(0, 0, 500, 500);
//将蒙层凿开
ctx!.globalCompositeOperation = 'source-atop'; ctx!.clearRect(dragStartRef.current.x, dragStartRef.current.y, width, height)
// 绘制8个边框像素点并保存坐标信息以及事件参数
ctx!.globalCompositeOperation = 'source-over';
ctx!.fillStyle = '#fc178f';
let size = 5;//定义像素点大小
//逆时针写的点儿
ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x + width / 2, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y, size, size);
ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y + height / 2, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y + height / 2, size, size);
ctx!.fillRect(dragStartRef.current.x, dragStartRef.current.y + height - size, size, size);
ctx!.fillRect(dragStartRef.current.x + width / 2, dragStartRef.current.y + height - size, size, size);
ctx!.fillRect(dragStartRef.current.x + width - size, dragStartRef.current.y + height - size, size, size);
ctx!.restore();
//再次使用drawImage 将图片绘制到蒙层下方
ctx!.save();
ctx!.globalCompositeOperation = 'destination-over';
ctx!.drawImage(img, 0, 0, canvas.width, canvas.height);
ctx!.restore();
// 获取裁剪区域的像素数据
const imageData = ctx!.getImageData(dragStartRef.current.x, dragStartRef.current.y, width, height);
//输出在另一个canvas上
newCtx!.clearRect(0, 0, 300, 300);
newCtx!.putImageData(imageData, 0, 0);
};
img.src = src;
};
const handleMouseUp = () => {
dragStartRef.current = null;
};
const getCropData = () => {
const newCanvas = newCanvasRef.current!;
const dataUrl = newCanvas.toDataURL();
setCropData(dataUrl);
}
return (
<>
<canvas ref={canvasRef} />
<Group>
<canvas ref={newCanvasRef} width={300} height={300} style={{ border: '1px solid #d3d3d3' }} />
<Stack mt={50}>
<Box sx={{ width:300, height: 300, border: '1px solid #d3d3d3'}}>
<img src={cropData} />
</Box>
<Button onClick={getCropData}>确认裁剪</Button>
</Stack>
</Group>
</>
);
}
复制代码
裁剪内容输出效果图:
四. react-cropper 实现裁剪图片
前面我们了解到,canvas 内部做了的操作,那么现在我们用 react-cropper 实现以上所有步骤。
export default function Cropper() {
const [image, setImage] = useState('');
const [cropData, setCropData] = useState('#');
const cropperRef = useRef<ReactCropperElement>(null);
const onChange = (e: any) => {
e.preventDefault();
let files;
if (e.dataTransfer) {
files = e.dataTransfer.files;
} else if (e.target) {
files = e.target.files;
}
const reader = new FileReader();
reader.onload = () => {
setImage(reader.result as any);
};
reader.readAsDataURL(files[0]);
};
const getCropData = () => {
if (typeof cropperRef.current?.cropper !== 'undefined') {
setCropData(cropperRef.current?.cropper.getCroppedCanvas().toDataURL());
}
};
return (
<div>
<div style={{ width: '100%' }}>
<Input type="file" onChange={onChange} />
<Cropper
ref={cropperRef}
style={{ height: 400, width: '100%' }}
zoomTo={0.5}
initialAspectRatio={1}
aspectRatio={1}
preview=".img-preview"
src={image}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
autoCropArea={1}
checkOrientation={false}
guides={true}
/>
</div>
<div>
<div
style={{
width: '50%',
float: 'right',
display: 'inline-block',
padding: 10,
boxSizing: 'border-box',
}}>
<h1>Preview</h1>
<div
className="img-preview"
style={{ width: '100%', float: 'left', height: '300px', overflow: 'hidden' }}
/>
</div>
<div className="box" style={{ width: '50%', float: 'right', height: '300px' }}>
<h1>
<span>Crop</span>
<Button style={{ float: 'right' }} onClick={getCropData}>
Crop Image
</Button>
</h1>
<Image style={{ width: '40%' }} src={cropData} alt="cropped" />
</div>
</div>
</div>
);
}
复制代码
效果图如下:
五. 总结
以上就是 cropper 基于 canvas API 实现的全部过程啦,cropper 只是将实现流程中的关键四步封装起来,便于使用。这个插件,可以实现上传图片前进行裁剪得到想要的图片,比如上传头像。