使用 HTML5 Canvas 实现用户自定义裁剪图片
在Web开发中,经常需要处理用户上传的图片,其中一个常见的需求是允许用户选择并裁剪图片。本文将介绍如何使用HTML、CSS和JavaScript实现一个简单的图片裁剪工具。
步骤概览
- 创建HTML结构,包含文件上传控件、裁剪前的图片显示区域,选择裁剪区域、Canvas和显示裁剪后图片的标签。
- 在
uploadFile.onchange
中创建一个Image
对象,并在onload
事件中获取图片的实际尺寸(image.width
和image.height
),用于计算并设置图片在裁剪容器中的显示尺寸,以保持图片的宽高比例。 - 在图片上实现裁剪功能,其实就是一个拖拽,鼠标按下可以拖动裁剪区域,松开就禁止拖拽。用户选择完裁剪区域后,我们先计算出裁剪框在原图片中的位置和尺寸。
- 根据裁剪框在原图片中的位置和尺寸与Canvas上图片尺寸根据比列关联起来(这是重点比较复杂,下面详细说明)。
- 将裁剪后的图片转换为Blob对象,并显示在页面上。
- 将Blob对象上传到服务器。
核心详解
这个功能核心步骤是计算裁剪框和图片的关联。下面是裁剪框和图片的公式。裁剪框的宽高其实就是裁剪后的图片宽高所以可以理解为最后要显示的宽和高。图片裁剪前在页面上的显示我们默认是有一个放大缩小的。我们要计算出这个放大缩小比。
这个比值
图片的放大宽高比计算
计算公式:原始宽/裁剪前的宽 = 宽的放大倍数,原始高/裁剪前的高 = 高的放大倍数。
宽和高的放大倍数可以小于1,小于时表示缩小否则放大。
最后显示的尺寸计算
计算公式:裁剪框的宽 * 宽的放大倍数 = 最后图片显示的宽度,裁剪框的高 * 高的放大倍数 = 最后图片显示的高度。
最后会用这个最后图片显示的宽度和高度做为画布的宽和高。再调用 drawImage 就可以绘制了。但是还有个偏移量问题没有解决,绘制的不精确。
偏移量计算
因为画布的坐标(0,0)是以画布左上角开始,不是以页面左上角开始,而裁剪框是以页面左上角(0,0)开始的。他们的坐标不对应,会造成偏差绘制不精确。
计算公式:
裁剪框距离页面左边距离 - 显示图片距离页面左边距离 = 裁剪框的左边缘相对于图片左边缘的距离
裁剪框距离页面顶部距离 - 显示图片距离页面顶部距离 = 裁剪框的顶部缘相对于图片顶部边缘的距离
这一步计算的是裁剪框相对于图片在页面坐标系中的水平偏移量,即裁剪框在图片上的位置(原始图片的坐标系)。
计算公式
最后再乘上宽和高放大倍数是为了将这个位置从显示尺寸(即页面坐标系)转换到实际图片尺寸的坐标系,以便正确地在画布上绘制
裁剪框的左边缘相对于图片左边缘的距离 * 宽的放大倍数 = x轴坐标位置
裁剪框的顶部边缘相对于图片顶部边缘的距离 * 高的放大倍数 = y轴坐标位置
裁剪后绘制的图片宽高就是画布的宽高,所以我们只需要5个值就可以了。 调用画布的drawImage方法把图片,偏移值,画布的宽高传入即可完成精确裁剪。canvas画布的绘制方法drawImage文档地址。
一.HTML和CSS结构
首先,我们需要一个HTML结构来容纳上传的图片和裁剪区域。以下是一个基本的HTML结构:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <style> * { margin: 0; } body { color: #fff; background: rgba(0, 0, 0, 0.8); } .wrap { width: 500px; margin: 50px; } canvas { display: none; border: 1px solid red; } .upload { width: 150px; height: 30px; border: 1px solid; margin: 20px auto; position: relative; } .upload-file { position: absolute; left: 0; top: 0; width: 100%; height: 100%; opacity: 0; z-index: 1; cursor: pointer; } .upload-btn { position: absolute; left: 0; top: 0; width: 100%; height: 100%; text-align: center; line-height: 1.5; font-size: 20px; color: #fff; border-radius: 5px; } .clip-area { text-align: center; } .clip-wrap { display: none; width: 500px; height: 500px; background: #000; position: relative; } .clip-wrap .clip-img { position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; } .clip-box { display: none; position: absolute; width: 300px; height: 300px; background: rgba(0, 0, 0, 0.5); box-sizing: border-box; border: 1px solid #fff; } .clip-after { text-align: center; } .clip-after h1 { margin: 20px 0; } .clip-after-img { width: 300px; } .save-btn { width: 100px; height: 30px; line-height: 30px; text-align: center; color: #fff; background: transparent; border: 1px solid #fff; cursor: pointer; margin-top: 20px; } </style> </head> <body> <div class="wrap"> <div class="upload"> <input type="file" class="upload-file"> <div class="upload-btn">图片上传</div> </div> <!-- 显示上传的图片并选择裁剪区域 --> <div class="clip-area"> <h1>裁剪区域</h1> <div class="clip-wrap"> <img src="" class="clip-img" style="width: 300px;"> <div class="clip-box"></div> </div> </div> <!-- 裁剪后的图片 --> <div class="clip-after"> <h1> 裁剪后的图片</h1> <div class="clip-after-content"> <img src="" class="clip-after-img"> </div> <canvas id="canvas"></canvas> <button class="save-btn">确认</button> </div> </div> </body> </html>
二.JS逻辑
1、实现选择图片并把图片等比例显示出来
实现一个简单的选择图片,再创建了一个图片实例 image
,然后使用 FileReader
对用户上传的图片进行读取,并将读取结果赋值给 image
的 src
属性。接着,通过获取 image
的宽度和高度,我们可以得知原始图片的尺寸。基于这些尺寸信息,在裁剪前先把图片等比例显示出来
const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 300; canvas.height = 300; const uploadFile = document.querySelector('.upload-file'); const clipAfterImg = document.querySelector('.clip-after-img'); let imageBlob; //上传图片 uploadFile.onchange = (e) => { clipWrap.style.display = 'block'; const fileData = e.target.files[0]; const reader = new FileReader(); const image = new Image(); reader.readAsDataURL(fileData); // 异步读取文件内容,结果用data:url的字符串形式表示 reader.onload = function (e) { const imgUrl = this.result; image.src = this.result; image.onload = function () { // 计算图片绘制区域,确保图片保持比例 const aspectRatio = image.width / image.height; computeSize(aspectRatio, imgUrl); }; } } //计算上传图片要显示的尺寸 function computeSize(aspectRatio, imgUrl) { let drawWidth, drawHeight; if (aspectRatio > 1) { // 图片更宽 drawWidth = clipWrap.offsetWidth; drawHeight = drawWidth / aspectRatio; } else { // 图片更高 drawHeight = clipWrap.offsetHeight; drawWidth = clipWrap.offsetHeight * aspectRatio; } clipImg.src = imgUrl clipImg.style.width = `${drawWidth}px`; clipImg.style.height = `${drawHeight}px` }
每次选择完图片后显示选择区域并默认居中。
//裁剪选择块的位置居中 function centerClipBox() { clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`; clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`; clipBox.style.display = 'block'; }
2、实现拖拽功能,用拖拽选择区域来选择内容
只有在鼠标按下 mousedown 事件才允许拖拽,鼠标松开 mouseup 禁止拖拽,再鼠标移动事件 mousemove 中计算拖拽的位置。并添加范围限制。
// 拖拽选择裁剪区域 const clipWrap = document.querySelector('.clip-wrap'); const clipImg = document.querySelector('.clip-img'); const clipBox = document.querySelector('.clip-box'); let isTtrue = false; let initX; let initY; //初始化裁剪选择块的位置 centerClipBox() //裁剪选择块的位置居中 function centerClipBox() { clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`; clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`; clipBox.style.display = 'block'; } clipBox.addEventListener('mousedown', (e) => { isTtrue = true; initX = e.clientX - clipWrap.offsetLeft - clipBox.offsetLeft; initY = e.clientY - clipWrap.offsetLeft - clipBox.offsetTop; }) //鼠标移动选择裁剪区域,并添加节流优化 document.addEventListener('mousemove', (e) => { if (isTtrue) { moveX = e.clientX - initX; let moveY = e.clientY - initY; let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft; let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop; let minX = clipImg.offsetLeft; let minY = clipImg.offsetTop; moveX = Math.max(minX, Math.min(moveX, maxX)); moveY = Math.max(minY, Math.min(moveY, maxY)); clipBox.style.left = moveX + 'px'; clipBox.style.top = moveY + 'px'; } }) document.addEventListener('mouseup', () => { isTtrue = false; });
3、计算选择区域在图片上的位置和尺寸
drawImage
函数绘制裁剪后的图片,并将其显示在页面上。首先获取裁剪框和图片的位置和尺寸信息,然后根据这些信息绘制裁剪后的图片到Canvas上。最后通过 canvas.toBlob
方法将Canvas内容转换为Blob对象,创建图片URL并设置给裁剪后的图片元素。
宽高计算:
原始宽/裁剪前的宽 = 宽的放大倍数,原始高/裁剪前的高 = 高的放大倍数。
裁剪框的宽 * 宽的放大倍数 = 最后图片显示的宽度,裁剪框的高 * 高的放大倍数 = 最后图片显示的高度。
偏移量计算:
裁剪框距离页面左边距离 - 显示图片距离页面左边距离 = 裁剪框的左边缘相对于图片左边缘的距离
裁剪框距离页面顶部距离 - 显示图片距离页面顶部距离 = 裁剪框的顶部缘相对于图片顶部边缘的距离
这一步计算的是裁剪框相对于图片在页面坐标系中的水平偏移量,即裁剪框在图片上的位置(原始图片的坐标系)。
最后再乘上宽和高放大倍数是为了将这个位置从显示尺寸(即页面坐标系)转换到实际图片尺寸的坐标系,以便正确地在画布上绘制。
裁剪框的左边缘相对于图片左边缘的距离 * 宽的放大倍数 = x轴坐标位置
裁剪框的顶部边缘相对于图片顶部边缘的距离 * 高的放大倍数 = y轴坐标位置
function drawImage() { // 获取裁剪框和图片的位置和尺寸信息 const clipRect = clipBox.getBoundingClientRect(); const imgRect = clipImg.getBoundingClientRect(); const scaleX = clipImg.naturalWidth / imgRect.width; const scaleY = clipImg.naturalHeight / imgRect.height; const cropX = (clipRect.left - imgRect.left) * scaleX; const cropY = (clipRect.top - imgRect.top) * scaleY; const cropWidth = clipBox.clientWidth * scaleX; const cropHeight = clipBox.clientHeight * scaleY; // 调整画布尺寸 canvas.width = cropWidth; canvas.height = cropHeight; // 绘制裁剪后的图片 ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage( clipImg, cropX, cropY, cropWidth, cropHeight, 0, 0, canvas.width, canvas.height ); // 将画布内容转换为Blob对象 canvas.toBlob(function (blob) { // 创建图片URL并设置给裁剪后的图片元素 const url = URL.createObjectURL(blob); clipAfterImg.src = url; imageBlob = blob; }, 'image/png'); }
通过 getBoundingClientRect () 获取图片和选择块的位置,再通过图片的原始尺寸和显示的尺寸算出宽高比,移动距离和 canvas 绘制的图片都乘以宽高比,算出从哪里开始裁剪和画布的尺寸大小。canvas画布的绘制方法drawImage文档地址。
5、再图片 onchange 和拖拽触发 mousemove 调用drawImage来绘制图片,并添加节流事件防止频繁触发。
//计算上传图片要显示的尺寸 function computeSize(aspectRatio, imgUrl) { let drawWidth, drawHeight; if (aspectRatio > 1) { // 图片更宽 drawWidth = clipWrap.offsetWidth; drawHeight = drawWidth / aspectRatio; } else { // 图片更高 drawHeight = clipWrap.offsetHeight; drawWidth = clipWrap.offsetHeight * aspectRatio; } clipImg.src = imgUrl clipImg.style.width = `${drawWidth}px`; clipImg.style.height = `${drawHeight}px` clipImg.onload = () => { // 在计算完大小后居中显示clipBox centerClipBox(); drawImage() } } document.addEventListener('mousemove', throttle((e) => { if (isTtrue) { moveX = e.clientX - initX; let moveY = e.clientY - initY; let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft; let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop; let minX = clipImg.offsetLeft; let minY = clipImg.offsetTop; moveX = Math.max(minX, Math.min(moveX, maxX)); moveY = Math.max(minY, Math.min(moveY, maxY)); clipBox.style.left = moveX + 'px'; clipBox.style.top = moveY + 'px'; //裁剪区域移动重新绘制图片 drawImage(); } }, 50)); // 节流 function throttle(fn, delay = 300) { let timer; return function (...args) { if (timer) return; timer = setTimeout(() => { fn(...args); timer = null; }, delay); } }
效果
全部代码
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <style> * { margin: 0; } body { color: #fff; background: rgba(0, 0, 0, 0.8); } .wrap { width: 500px; margin: 50px; } canvas { display: none; border: 1px solid red; } .upload { width: 150px; height: 30px; border: 1px solid; margin: 20px auto; position: relative; } .upload-file { position: absolute; left: 0; top: 0; width: 100%; height: 100%; opacity: 0; z-index: 1; cursor: pointer; } .upload-btn { position: absolute; left: 0; top: 0; width: 100%; height: 100%; text-align: center; line-height: 1.5; font-size: 20px; color: #fff; border-radius: 5px; } .clip-area { text-align: center; } .clip-wrap { display: none; width: 500px; height: 500px; background: #000; position: relative; } .clip-wrap .clip-img { position: absolute; left: 0; right: 0; bottom: 0; top: 0; margin: auto; } .clip-box { display: none; position: absolute; width: 300px; height: 300px; background: rgba(0, 0, 0, 0.5); box-sizing: border-box; border: 1px solid #fff; } .clip-after { text-align: center; } .clip-after h1 { margin: 20px 0; } .clip-after-img { width: 300px; } .save-btn { width: 100px; height: 30px; line-height: 30px; text-align: center; color: #fff; background: transparent; border: 1px solid #fff; cursor: pointer; margin-top: 20px; } </style> </head> <body> <div class="wrap"> <div class="upload"> <input type="file" class="upload-file"> <div class="upload-btn">图片上传</div> </div> <!-- 显示上传的图片并选择裁剪区域 --> <div class="clip-area"> <h1>裁剪区域</h1> <div class="clip-wrap"> <img src="" class="clip-img" style="width: 300px;"> <div class="clip-box"></div> </div> </div> <!-- 裁剪后的图片 --> <div class="clip-after"> <h1> 裁剪后的图片</h1> <div class="clip-after-content"> <img src="" class="clip-after-img"> </div> <canvas id="canvas"></canvas> <button class="save-btn">确认</button> </div> </div> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 300; canvas.height = 300; //上传按钮和裁剪前显示的图片 const uploadFile = document.querySelector('.upload-file'); const clipAfterImg = document.querySelector('.clip-after-img'); //保存按钮盒图片 const saveBtn = document.querySelector('.save-btn') let imageBlob; // 拖拽选择裁剪区域参数 const clipWrap = document.querySelector('.clip-wrap'); const clipImg = document.querySelector('.clip-img'); const clipBox = document.querySelector('.clip-box'); let isTtrue = false; let initX; let initY; //上传图片 uploadFile.onchange = (e) => { clipWrap.style.display = 'block'; const fileData = e.target.files[0]; const reader = new FileReader(); const image = new Image(); reader.readAsDataURL(fileData); // 异步读取文件内容,结果用data:url的字符串形式表示 reader.onload = function (e) { const imgUrl = this.result; image.src = this.result; image.onload = function () { // 计算图片绘制区域,确保图片保持比例 const aspectRatio = image.width / image.height; computeSize(aspectRatio, imgUrl); }; } } //计算上传图片要显示的尺寸 function computeSize(aspectRatio, imgUrl) { let drawWidth, drawHeight; if (aspectRatio > 1) { // 图片更宽 drawWidth = clipWrap.offsetWidth; drawHeight = drawWidth / aspectRatio; } else { // 图片更高 drawHeight = clipWrap.offsetHeight; drawWidth = clipWrap.offsetHeight * aspectRatio; } clipImg.src = imgUrl clipImg.style.width = `${drawWidth}px`; clipImg.style.height = `${drawHeight}px` clipImg.onload = () => { // 在计算完大小后居中显示clipBox centerClipBox(); drawImage() } } // 拖拽选择裁剪区域 //初始化裁剪选择块的位置 centerClipBox() //裁剪选择块的位置居中 function centerClipBox() { clipBox.style.left = `${(clipWrap.clientWidth - clipBox.clientWidth) / 2}px`; clipBox.style.top = `${(clipWrap.clientHeight - clipBox.clientHeight) / 2}px`; clipBox.style.display = 'block'; } clipBox.addEventListener('mousedown', (e) => { isTtrue = true; initX = e.clientX - clipWrap.offsetLeft - clipBox.offsetLeft; initY = e.clientY - clipWrap.offsetLeft - clipBox.offsetTop; }) //鼠标移动选择裁剪区域,并添加节流优化 document.addEventListener('mousemove', throttle((e) => { if (isTtrue) { moveX = e.clientX - initX; let moveY = e.clientY - initY; let maxX = clipImg.offsetWidth - clipBox.offsetWidth + clipImg.offsetLeft; let maxY = clipImg.offsetHeight - clipBox.offsetHeight + clipImg.offsetTop; let minX = clipImg.offsetLeft; let minY = clipImg.offsetTop; moveX = Math.max(minX, Math.min(moveX, maxX)); moveY = Math.max(minY, Math.min(moveY, maxY)); clipBox.style.left = moveX + 'px'; clipBox.style.top = moveY + 'px'; //裁剪区域移动重新绘制图片 drawImage() } }, 50)) document.addEventListener('mouseup', () => { isTtrue = false; }); //在canvas上绘制选择的区域并转为图片 function drawImage() { const clipRect = clipBox.getBoundingClientRect(); const imgRect = clipImg.getBoundingClientRect(); const scaleX = clipImg.naturalWidth / imgRect.width; const scaleY = clipImg.naturalHeight / imgRect.height; const cropX = (clipRect.left - imgRect.left) * scaleX; const cropY = (clipRect.top - imgRect.top) * scaleY; const cropWidth = clipBox.clientWidth * scaleX; const cropHeight = clipBox.clientHeight * scaleY; // 调整画布尺寸 canvas.width = cropWidth; canvas.height = cropHeight; // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); // 将画布剪裁为圆形区域 ctx.beginPath(); ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); // 绘制图片 ctx.drawImage( clipImg, cropX, cropY, cropWidth, cropHeight, 0, 0, canvas.width, canvas.height ); // 将画布内容转换为图片 canvas.toBlob(function (blob) { const url = URL.createObjectURL(blob); clipAfterImg.src = url; imageBlob = blob; }, 'image/png'); } //图片上传 saveBtn.addEventListener('click', () => { if (imageBlob) { const formData = new FormData(); formData.append('image', imageBlob, 'image.png'); fetch('/upload', { method: 'POST', body: formData, }) .then(response => response.json()) .then(data => { console.log('Success:', data); }) .catch((error) => { console.error('Error:', error); }); } else { console.error('No image available to upload.'); } }); // 节流 function throttle(fn, delay = 300) { let timer; return function (...args) { if (timer) return; timer = setTimeout(() => { fn(...args); timer = null; }, delay); } } </script> </body> </html>