使用 HTML5 Canvas 实现用户自定义裁剪图片
在Web开发中,经常需要处理用户上传的图片,其中一个常见的需求是允许用户选择并裁剪图片。本文将介绍如何使用HTML、CSS和JavaScript实现一个简单的图片裁剪工具。
步骤概览
- 创建HTML结构,包含文件上传控件、裁剪前的图片显示区域,选择裁剪区域、Canvas和显示裁剪后图片的标签。
- 在
uploadFile.onchange
中创建一个Image
对象,并在onload
事件中获取图片的实际尺寸(image.width
和image.height
),用于计算并设置图片在裁剪容器中的显示尺寸,以保持图片的宽高比例。 - 在图片上实现裁剪功能,其实就是一个拖拽,鼠标按下可以拖动裁剪区域,松开就禁止拖拽。用户选择完裁剪区域后,我们先计算出裁剪框在原图片中的位置和尺寸,根据这些信息绘制裁剪后的图片到Canvas上。
- 根据裁剪框在原图片中的位置和尺寸与Canvas上图片尺寸根据比列关联起来。
- 将裁剪后的图片转换为Blob对象,并显示在页面上。
- 将Blob对象上传到服务器。
一.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并设置给裁剪后的图片元素。
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 绘制的图片都乘以宽高比,算出从哪里开始裁剪和画布的尺寸大小。
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>