哈喽啊,大家。今天我来介绍下我的这个代码需求,代码我放最后面了,leader要我实现一个拍照,然后图片会有一个预览效果,然后可以拖动预览的照片,在方框内,单指拖动,双指放大,然后截取框内的照片进行压缩,转化为base64数据进行上传。
这是我写这篇文章前学到最多的文章,包含文件和canvas处理,点击查看
不知道是项目要求还是啥,要我用原生的html实现,并且不使用第三方的插件。说实话,还是挺难实现的,看了下掘友们写的文章,自己也有了点灵感,然后自己也开始逐步的去一个一个实现对应的功能。写这个还真不能心急,用ai生成的可以说是漏洞百出,自己一个一个的修改,根本就不知道错误点在哪,花了半天都没弄好。最后直接全部重写,一个一个需求去实现,发现这样的话会快很多。
我讲下我大概的思路,我的思路是用canvas,让图片生成在canvas的中间,然后手指触摸图片就会计算位置,重新生成图片,并且限制图片位置,让截图框不能超出图片。还写了个后端测试图片上传,也放后面了。
大概效果,全部代码放最后面。
首先介绍下html部分
<!-- 主题 -->
<main>
<div class="title">
开启相机权限,<br />
上传一张照片给我,立马变懂您!
</div>
<label class="upload-box" id="uploadBox">
<div class="upload-text" id="uploadText">点击上传照片</div>
<input type="file" id="fileInput" accept="image/*" style="display:none">
<img src="./camera--v1.png" alt="上传图标" id="uploadIcon" />
<!-- 预览图 -->
<canvas id="canvas" class="look"></canvas>
<!-- 预览图结束 -->
</label>
<button class="btn-1" id="takePhoto" style="display: none;">重新拍摄</button>
<button class="btn-2" id="loadPhoto" style="display: none;">开始推荐</button>
</main>
<!-- 加载遮罩层 -->
我定义了一个盒子uploadBox,然后里面放了文字,图片还有输入框,输入框大小等于uploadBox,且只能获取文件。
然后介绍下css
body {
font-family: "Arial", sans-serif;
margin: 0;
padding: 0;
background-color: rgb(239, 239, 239);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
color: #666;
}
.title {
text-align: center;
color: rgb(203, 203, 203);
font-size: 14px;
}
.upload-box {
position: relative;
/* 修改这里,设置为16:9的比例 */
margin: 30px;
width: 180px;
/* --------------------------------------------- 显示屏的比例修改这里 */
/* 宽度 */
height: 320px;
/* 高度 */
border: 1px solid #00b386;
border-radius: 13px;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
#uploadIcon {
width: 40px;
opacity: 0.4;
margin-bottom: 8px;
}
.look {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 13px;
/* 保持圆角一致 */
z-index: 1;
/* 确保图片在上层 */
}
/* 添加预览时隐藏上传图标和文字的样式 */
.upload-box.preview-mode #uploadIcon,
.upload-box.preview-mode .upload-text {
display: none;
}
.upload-text {
color: #aaa;
font-size: 14px;
}
.btn-1 {
width: 180px;
color: #00b386;
border: 2px solid #00b386;
border-radius: 4px;
padding: 6px 60px;
font-size: 16px;
cursor: pointer;
display: block;
/* 添加这行,使margin auto生效 */
margin: 20px auto 0;
/* 修改这里 */
font-size: 14px;
box-sizing: border-box;
}
.btn-2 {
width: 180px;
background-color: #00b386;
color: white;
border: none;
border-radius: 4px;
padding: 6px 60px;
font-size: 16px;
cursor: pointer;
display: block;
/* 添加这行,使margin auto生效 */
margin: 20px auto 0;
/* 修改这里 */
font-size: 14px;
}
.btn:hover {
background-color: #009f72;
}
.preview-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
color: white;
text-align: center;
padding: 8px;
font-size: 12px;
}
用了flex布局进行了居中,然后是盒子upload-box,这里去设置它的长宽,我当时设置的是180px*180px,后面改成了9比16。其实没什么特别的,主要还是js部分。
js代码分析
对需要用到的dom先进行获取,设置下canvas获取上下文
const fileInput = document.getElementById("fileInput");
const loadPhoto = document.getElementById("loadPhoto");
const takePhoto = document.getElementById("takePhoto");
const canvas = document.getElementById("canvas");
const uploadBox = document.getElementById("uploadBox");
const ctx = canvas.getContext("2d");
状态的管理
这里我对canvas和canvas上面的图片上的参数进行计算和保存,方便图片重新绘制,图片大小位置进行一个获取。
这里自己要写的话,一定要一步一步的写,我当时ai生成就在这吃了大亏,很多参数调用很乱,不知道改哪里,我的建议是自己从头开始写,需要什么参数写什么参数,缺的话再补上
const canvasObject = {
height: 0,
width: 0,
centerPoint: { x: 1, y: 1 },
isDragging: false,//是否有手指接触
startPoint: { x: 0, y: 0 },//手指接触的位置
isPinching: false,//是否有两个手指接触
startDistance: 0,//两个手指接触的距离
startScale: 1,
}
const imageObject = {
image: null,
height: 0,//缩小后的高
width: 0,
translateX: 0,
translateY: 0,
centerpoint: 0,
rotation: 0,//旋转角度
scale: 0,//缩小比例
minScale: 0,//最小缩放比例
}
图片在canvas上的绘制
这里代码比较长依次介绍下
initializeCanvas初始化canvas- 初始化canvas
- 必须设置了canvas的长宽,在style上设置的长宽会导致canvas的图片模糊之类的各种问题
- 设置了状态管理上的canvas的长宽,方便后面计算时使用
function initializeCanvas() {
canvas.width = uploadBox.clientWidth;
canvas.height = uploadBox.clientHeight;
canvasObject.width = uploadBox.clientWidth;
canvasObject.height = uploadBox.clientHeight;
}
drawImage重绘图片- 作用:在图片上传到input上后,被调用,和图片拖动时被重新调用,将图片绘制在canvas上,并记录属性
- 用clearReact清理画布
- 用rotate旋转坐标对应的角度,这里并不会转动,因为当时说要双指旋转,但做不好,就去掉了。
- 用drawImage绘制图片,drawImage上用的单位也是px
- 第一个属性是一个Image对象,存放着图片
- 第二三个属性是移动和的位置,这里的数据我进行的计算,为了居中
- 最后两个属性是绘制的大小,最好按原比例,要不然会变形
function drawImage() {
try {
if (!imageObject.image) return;
ctx.clearRect(0, 0, canvasObject.width, canvasObject.height);
ctx.save();
// ctx.translate(canvasObject.height / 2, canvasObject.width / 2);
ctx.rotate(imageObject.rotation);
ctx.drawImage(
imageObject.image,
imageObject.translateX,
imageObject.translateY,
// 0,
// 0,
imageObject.width,
imageObject.height,
);
ctx.restore();
console.log(imageObject)
} catch (e) {
console.error("Error drawing image:", e);
}
}
监听图片文件修改- 作用:读取图片,然后初始化canvas,最后绘制。其中还有一些属性的计算
- 获取到图片文件
- 创建一个FileReader实现文件读取
- 利用FileReader的方法readAsDataURL实现文件转为url地址放在图片上
- 你可能迷惑中间的代码,中间还有个reader.onload,这个是在readAsDataURL后执行的异步代码
- reader.onload
- 创建一个image对象,用于重绘
- 将image上的src改为转化好的URL,就是e.target.result
- 你可能会疑惑中间的一大段代码,img.onload是在image获取到图片后执行,是个异步
- img.onload
- canvas初始化
- 将img存放,用于图片绘制
- 计算缩放比,让图片尽可能的贴合裁剪框
- 然后就是属性的计算,要讲下平移位置translateX,(canvasObject.width - imageObject.width) / 2刚好就能居中
- 绘制图片
- 开放上传和重拍按钮
fileInput.addEventListener("change", function (e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = () => {
// 初始化 canvas 和 canvasObject 的宽高
initializeCanvas(); // ← 必须先调用
imageObject.image = img;
imageObject.scale = Math.max(
canvasObject.width / img.width,
canvasObject.height / img.height
);
imageObject.minScale = imageObject.scale;
imageObject.width = img.width * imageObject.scale;
imageObject.height = img.height * imageObject.scale;
imageObject.translateX = (canvasObject.width - imageObject.width) / 2;
imageObject.translateY = (canvasObject.height - imageObject.height) / 2;
drawImage();
console.log(imageObject);
// 显示拍照和开始推荐按钮
takePhoto.style.display = 'block'; // 显示拍照按钮
loadPhoto.style.display = 'block'; // 显示开始推荐按钮
};
img.src = e.target.result;
}
reader.readAsDataURL(file);
})
单指和和双指操作
这里不过多解释,没什么难点,三个事件,开始按,滑动中,手指离开。 添加了些位置的参数,和手指是否在屏幕上,和手指的根数。 和限位函数limitImageWithinCanvas,防止图片移出裁剪框。
function calculateDistance(p1, p2) {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}
// 计算两点形成的角度
function calculateAngle(p1, p2) {
return Math.atan2(p2.y - p1.y, p2.x - p1.x);
}
// 计算两点的中点
function calculateMidpoint(p1, p2) {
return {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
}
function handleTouchStart(e) {
if (!imageObject.image) return;
e.preventDefault();
const touches = e.touches;
if (touches.length === 1) {
// 单指拖动
canvasObject.isDragging = true;
canvasObject.startPoint = { x: touches[0].clientX, y: touches[0].clientY };
} else if (touches.length === 2) {
// 双指缩放
canvasObject.isPinching = true;
canvasObject.startDistance = calculateDistance(
{ x: touches[0].clientX, y: touches[0].clientY },
{ x: touches[1].clientX, y: touches[1].clientY }
);
canvasObject.startScale = imageObject.scale;
}
}
function handleTouchMove(e) {
if (!imageObject.image) return;
e.preventDefault();
const touches = e.touches;
if (canvasObject.isDragging && touches.length === 1) {
const currentX = touches[0].clientX;
const currentY = touches[0].clientY;
const deltaX = currentX - canvasObject.startPoint.x;
const deltaY = currentY - canvasObject.startPoint.y;
imageObject.translateX += deltaX;
imageObject.translateY += deltaY;
canvasObject.startPoint = { x: currentX, y: currentY };
limitImageWithinCanvas();
drawImage();
}
if (canvasObject.isPinching && touches.length === 2) {
const p1 = { x: touches[0].clientX, y: touches[0].clientY };
const p2 = { x: touches[1].clientX, y: touches[1].clientY };
const newDistance = calculateDistance(p1, p2);
const scaleChange = newDistance / canvasObject.startDistance;
let newScale = canvasObject.startScale * scaleChange;
if (newScale < imageObject.minScale) {
newScale = imageObject.minScale;
}
// 计算缩放中心点(双指中点)
const midpoint = calculateMidpoint(p1, p2);
// 转换为 canvas 内部相对位置
const rect = canvas.getBoundingClientRect();
const midX = midpoint.x - rect.left;
const midY = midpoint.y - rect.top;
// 缩放前:中点相对于图片的偏移(缩放前坐标)
const offsetX = midX - imageObject.translateX;
const offsetY = midY - imageObject.translateY;
const scaleRatio = newScale / imageObject.scale;
// 缩放后:保持手指不动,需要调整 translate
imageObject.translateX = midX - offsetX * scaleRatio;
imageObject.translateY = midY - offsetY * scaleRatio;
// 更新 scale 和尺寸
imageObject.scale = newScale;
imageObject.width = imageObject.image.width * imageObject.scale;
imageObject.height = imageObject.image.height * imageObject.scale;
limitImageWithinCanvas();
drawImage();
}
}
function handleTouchEnd(e) {
if (imageObject.image) e.preventDefault();
canvasObject.isDragging = false;
canvasObject.isPinching = false;
}
function limitImageWithinCanvas() {
const minX = Math.min(0, canvasObject.width - imageObject.width);
const maxX = 0;
const minY = Math.min(0, canvasObject.height - imageObject.height);
const maxY = 0;
if (imageObject.translateX < minX) imageObject.translateX = minX;
if (imageObject.translateX > maxX) imageObject.translateX = maxX;
if (imageObject.translateY < minY) imageObject.translateY = minY;
if (imageObject.translateY > maxY) imageObject.translateY = maxY;
}
uploadBox.addEventListener('touchstart', handleTouchStart);
uploadBox.addEventListener('touchmove', handleTouchMove);
uploadBox.addEventListener('touchend', handleTouchEnd);
takePhoto.addEventListener("click", function () {
fileInput.click();
})
裁剪,压缩上传
resizeImage函数 这个函数的主要作用是将传入的文件(通常是图像文件)读取并调整到指定的宽度和高度,然后转换为 Base64 格式。
function resizeImage(file, maxWidth, maxHeight) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = maxWidth;
canvas.height = maxHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, maxWidth, maxHeight);
const dataURL = canvas.toDataURL("image/jpeg");
resolve(dataURL);
};
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
压缩逻辑:
- 读取文件:使用 FileReader 将传入的 file 读取为 DataURL。
- 创建图像对象:将读取到的 DataURL 赋值给 Image 对象,等待图像加载完成。
- 创建画布:创建一个 canvas 元素,并将其宽度和高度设置为传入的 maxWidth 和 maxHeight。
- 绘制图像:使用 ctx.drawImage 方法将图像绘制到 canvas 上,同时将图像调整到 canvas 的尺寸。
- 转换为 Base64:使用 canvas.toDataURL 方法将 canvas 上的图像转换为 image/jpeg 格式的 Base64 字符串。
compressCanvasToMaxSize函数 这个函数的作用是将 canvas 上的图像压缩到指定的大小(默认 100KB)。
async function compressCanvasToMaxSize(canvas, maxSizeKB = 100) {
let quality = 0.9;
let blob;
while (quality > 0) {
blob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/jpeg', quality)
);
if (blob.size <= maxSizeKB * 1024) break;
quality -= 0.05;
}
if (blob.size > maxSizeKB * 1024) {
throw new Error("压缩失败:无法压缩到100KB以下");
}
return blob;
}
压缩逻辑 :
- 初始化质量参数 :将压缩质量 quality 初始化为 0.9。
- 循环压缩 :使用 while 循环,不断调用 canvas.toBlob 方法将 canvas 上的图像转换为 Blob 对象,每次压缩时降低 quality 值(每次减少 0.05)。
- 判断大小 :每次压缩后检查 Blob 对象的大小,如果小于等于指定的大小( maxSizeKB * 1024 字节),则跳出循环。
- 异常处理 :如果循环结束后 Blob 对象的大小仍然超过指定大小,则抛出错误。
具体代码
我删掉了些重要信息,这是改进前的代码,没做屏幕适配,可以直接使用
<!--
文件名曾:Upload.html
功能描述:图片上传页面
作者:邹嘉炜
创建时间:2025-05-8
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SUSE Summit 上传页面</title>
<link rel="stylesheet" href="./Upload.css">
<!-- Cropper 样式 -->
<link href="https://unpkg.com/cropperjs/dist/cropper.min.css" rel="stylesheet" />
<style>
body {
font-family: "Arial", sans-serif;
margin: 0;
padding: 0;
background-color: rgb(239, 239, 239);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
color: #666;
}
.header {
position: absolute;
top: 20px;
display: flex;
justify-content: space-between;
/* 修改这里 */
align-items: center;
/* 修改这里 */
width: 100%;
}
.header-left,
.header-right {
display: flex;
align-items: center;
}
.logo {
height: 24px;
display: flex;
align-items: center;
justify-self: center;
}
.header img {
height: 200%;
}
.deep {
color: rgb(11, 11, 11);
}
.step {
font-size: 24px;
color: #000;
border: 3px solid #000;
border-radius: 50%;
width: 36px;
height: 36px;
text-align: center;
line-height: 36px;
margin-right: 10px;
}
.title {
text-align: center;
color: rgb(203, 203, 203);
font-size: 14px;
}
.upload-box {
position: relative;
/* 修改这里,设置为16:9的比例 */
margin: 30px;
width: 180px;
/* --------------------------------------------- 显示屏的比例修改这里 */
/* 宽度 */
height: 320px;
/* 高度 */
border: 1px solid #00b386;
border-radius: 13px;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
#uploadIcon {
width: 40px;
opacity: 0.4;
margin-bottom: 8px;
}
.look {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 13px;
/* 保持圆角一致 */
z-index: 1;
/* 确保图片在上层 */
}
/* 添加预览时隐藏上传图标和文字的样式 */
.upload-box.preview-mode #uploadIcon,
.upload-box.preview-mode .upload-text {
display: none;
}
.upload-text {
color: #aaa;
font-size: 14px;
}
.btn-1 {
width: 180px;
color: #00b386;
border: 2px solid #00b386;
border-radius: 4px;
padding: 6px 60px;
font-size: 16px;
cursor: pointer;
display: block;
/* 添加这行,使margin auto生效 */
margin: 20px auto 0;
/* 修改这里 */
font-size: 14px;
box-sizing: border-box;
}
.btn-2 {
width: 180px;
background-color: #00b386;
color: white;
border: none;
border-radius: 4px;
padding: 6px 60px;
font-size: 16px;
cursor: pointer;
display: block;
/* 添加这行,使margin auto生效 */
margin: 20px auto 0;
/* 修改这里 */
font-size: 14px;
}
.btn:hover {
background-color: #009f72;
}
.preview-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
color: white;
text-align: center;
padding: 8px;
font-size: 12px;
}
</style>
</head>
<body>
<!-- 导航栏开始 -->
<!-- 导航栏结束 -->
<!-- 主题 -->
<main>
<div class="title">
开启相机权限,<br />
上传一张照片给我,立马变懂您!
</div>
<label class="upload-box" id="uploadBox">
<div class="upload-text" id="uploadText">点击上传照片</div>
<input type="file" id="fileInput" accept="image/*" style="display:none">
<img src="./camera--v1.png" alt="上传图标" id="uploadIcon" />
<div class="preview-container" id="previewContainer" style="display:none;"></div>
<!-- 预览图 -->
<canvas id="canvas" class="look"></canvas>
<!-- 预览图结束 -->
</label>
<button class="btn-1" id="takePhoto" style="display: none;">重新拍摄</button>
<button class="btn-2" id="loadPhoto" style="display: none;">开始推荐</button>
</main>
<!-- 加载遮罩层 -->
<div id="loadingOverlay" style="
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: none;
z-index: 9999;
justify-content: center;
align-items: center;
font-size: 20px;
color: #00b386;
">
正在识别中,请稍候...
</div>
<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
<script>
const fileInput = document.getElementById("fileInput");
const loadPhoto = document.getElementById("loadPhoto");
const takePhoto = document.getElementById("takePhoto");
const canvas = document.getElementById("canvas");
const uploadBox = document.getElementById("uploadBox");
const ctx = canvas.getContext("2d");
// 状态管理
const canvasObject = {
height: 0,
width: 0,
centerPoint: { x: 1, y: 1 },
isDragging: false,//是否有手指接触
startPoint: { x: 0, y: 0 },//手指接触的位置
isPinching: false,//是否有两个手指接触
startDistance: 0,//两个手指接触的距离
startScale: 1,
}
const imageObject = {
image: null,
height: 0,//缩小后的高
width: 0,
translateX: 0,
translateY: 0,
centerpoint: 0,
rotation: 0,//旋转角度
scale: 0,//缩小比例
minScale: 0,//最小缩放比例
}
takePhoto.addEventListener("click", function () {
fileInput.click();
})
function initializeCanvas() {
canvas.width = uploadBox.clientWidth;
canvas.height = uploadBox.clientHeight;
canvasObject.width = uploadBox.clientWidth;
canvasObject.height = uploadBox.clientHeight;
}
fileInput.addEventListener("change", function (e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = () => {
// 初始化 canvas 和 canvasObject 的宽高
initializeCanvas(); // ← 必须先调用
imageObject.image = img;
imageObject.scale = Math.max(
canvasObject.width / img.width,
canvasObject.height / img.height
);
imageObject.minScale = imageObject.scale;
imageObject.width = img.width * imageObject.scale;
imageObject.height = img.height * imageObject.scale;
imageObject.translateX = (canvasObject.width - imageObject.width) / 2;
imageObject.translateY = (canvasObject.height - imageObject.height) / 2;
drawImage();
console.log(imageObject);
// 显示拍照和开始推荐按钮
takePhoto.style.display = 'block'; // 显示拍照按钮
loadPhoto.style.display = 'block'; // 显示开始推荐按钮
};
img.src = e.target.result;
}
reader.readAsDataURL(file);
})
function drawImage() {
try {
if (!imageObject.image) return;
ctx.clearRect(0, 0, canvasObject.width, canvasObject.height);
ctx.save();
// ctx.translate(canvasObject.height / 2, canvasObject.width / 2);
ctx.rotate(imageObject.rotation);
ctx.drawImage(
imageObject.image,
imageObject.translateX,
imageObject.translateY,
// 0,
// 0,
imageObject.width,
imageObject.height,
);
ctx.restore();
console.log(imageObject)
} catch (e) {
console.error("Error drawing image:", e);
}
}
function calculateDistance(p1, p2) {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}
// 计算两点形成的角度
function calculateAngle(p1, p2) {
return Math.atan2(p2.y - p1.y, p2.x - p1.x);
}
// 计算两点的中点
function calculateMidpoint(p1, p2) {
return {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
}
function handleTouchStart(e) {
if (!imageObject.image) return;
e.preventDefault();
const touches = e.touches;
if (touches.length === 1) {
// 单指拖动
canvasObject.isDragging = true;
canvasObject.startPoint = { x: touches[0].clientX, y: touches[0].clientY };
} else if (touches.length === 2) {
// 双指缩放
canvasObject.isPinching = true;
canvasObject.startDistance = calculateDistance(
{ x: touches[0].clientX, y: touches[0].clientY },
{ x: touches[1].clientX, y: touches[1].clientY }
);
canvasObject.startScale = imageObject.scale;
}
}
function handleTouchMove(e) {
if (!imageObject.image) return;
e.preventDefault();
const touches = e.touches;
if (canvasObject.isDragging && touches.length === 1) {
const currentX = touches[0].clientX;
const currentY = touches[0].clientY;
const deltaX = currentX - canvasObject.startPoint.x;
const deltaY = currentY - canvasObject.startPoint.y;
imageObject.translateX += deltaX;
imageObject.translateY += deltaY;
canvasObject.startPoint = { x: currentX, y: currentY };
limitImageWithinCanvas();
drawImage();
}
if (canvasObject.isPinching && touches.length === 2) {
const p1 = { x: touches[0].clientX, y: touches[0].clientY };
const p2 = { x: touches[1].clientX, y: touches[1].clientY };
const newDistance = calculateDistance(p1, p2);
const scaleChange = newDistance / canvasObject.startDistance;
let newScale = canvasObject.startScale * scaleChange;
if (newScale < imageObject.minScale) {
newScale = imageObject.minScale;
}
// 计算缩放中心点(双指中点)
const midpoint = calculateMidpoint(p1, p2);
// 转换为 canvas 内部相对位置
const rect = canvas.getBoundingClientRect();
const midX = midpoint.x - rect.left;
const midY = midpoint.y - rect.top;
// 缩放前:中点相对于图片的偏移(缩放前坐标)
const offsetX = midX - imageObject.translateX;
const offsetY = midY - imageObject.translateY;
const scaleRatio = newScale / imageObject.scale;
// 缩放后:保持手指不动,需要调整 translate
imageObject.translateX = midX - offsetX * scaleRatio;
imageObject.translateY = midY - offsetY * scaleRatio;
// 更新 scale 和尺寸
imageObject.scale = newScale;
imageObject.width = imageObject.image.width * imageObject.scale;
imageObject.height = imageObject.image.height * imageObject.scale;
limitImageWithinCanvas();
drawImage();
}
}
function handleTouchEnd(e) {
if (imageObject.image) e.preventDefault();
canvasObject.isDragging = false;
canvasObject.isPinching = false;
}
function limitImageWithinCanvas() {
const minX = Math.min(0, canvasObject.width - imageObject.width);
const maxX = 0;
const minY = Math.min(0, canvasObject.height - imageObject.height);
const maxY = 0;
if (imageObject.translateX < minX) imageObject.translateX = minX;
if (imageObject.translateX > maxX) imageObject.translateX = maxX;
if (imageObject.translateY < minY) imageObject.translateY = minY;
if (imageObject.translateY > maxY) imageObject.translateY = maxY;
}
uploadBox.addEventListener('touchstart', handleTouchStart);
uploadBox.addEventListener('touchmove', handleTouchMove);
uploadBox.addEventListener('touchend', handleTouchEnd);
loadPhoto.addEventListener("click", async () => {
if (!imageObject.image) return alert("请先上传照片");
try {
// 压缩 canvas 至 100KB
const blob = await compressCanvasToMaxSize(canvas, 100);
const base64 = await blobToBase64(blob);
const suseKey = getCookie("SuseUserKey") || "testUser123";
document.getElementById("loadingOverlay").style.display = "flex";
const res = await fetch("http://localhost:3000/api/facial_recognize/face_save", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
SuseFace: base64,
SuseKey: suseKey
})
});
const result = await res.json();
if (result.success === true) {
alert("识别成功!");
} else {
alert("识别失败,请重试");
}
} catch (err) {
alert("上传失败:" + err.message);
} finally {
document.getElementById("loadingOverlay").style.display = "none";
}
});
// 读取并压缩图像为 base64
function resizeImage(file, maxWidth, maxHeight) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = maxWidth;
canvas.height = maxHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, maxWidth, maxHeight);
const dataURL = canvas.toDataURL("image/jpeg");
resolve(dataURL);
};
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 获取 Cookie 值
function getCookie(name) {
const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
return match ? decodeURIComponent(match[2]) : null;
}
async function compressCanvasToMaxSize(canvas, maxSizeKB = 100) {
let quality = 0.9;
let blob;
while (quality > 0) {
blob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/jpeg', quality)
);
if (blob.size <= maxSizeKB * 1024) break;
quality -= 0.05;
}
if (blob.size > maxSizeKB * 1024) {
throw new Error("压缩失败:无法压缩到100KB以下");
}
return blob;
}
// 👇 添加:Blob 转 Base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
</script>
</body>
</html>
测试后端
这里要初始化下 npm i
安装依赖
npm install express body-parser cors
作用接受前端传来的数据,并存储
const express = require("express");
const bodyParser = require("body-parser");
const cors = require("cors");
const fs = require("fs");
const path = require("path");
const app = express();
const port = 3000;
app.use(cors({
origin: "http://127.0.0.1:5500", // 你前端页面的 origin
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"]
}));
app.options("/api/facial_recognize/face_save", (req, res) => {
res.sendStatus(200);
});
app.use(bodyParser.json({ limit: "10mb" }));
// 工具函数:保存 base64 图片为本地文件
function saveBase64Image(base64Data, userKey) {
// 去掉 data:image/jpeg;base64, 前缀
const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
throw new Error("图片格式错误");
}
const ext = matches[1];
const data = matches[2];
const buffer = Buffer.from(data, "base64");
const filename = `${userKey}_${Date.now()}.${ext}`;
const filePath = path.join(__dirname, "uploads", filename);
fs.writeFileSync(filePath, buffer);
return filename;
}
// 确保 uploads 文件夹存在
const uploadDir = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
app.post("/api/facial_recognize/face_save", (req, res) => {
const { SuseFace, SuseKey } = req.body;
if (!SuseFace || !SuseKey) {
return res.status(400).json({ success: false, message: "缺少必要参数" });
}
try {
const savedFile = saveBase64Image(SuseFace, SuseKey);
console.log(`用户 ${SuseKey} 的照片已保存为 ${savedFile}`);
res.json({
success: true,
message: "照片保存成功",
filename: savedFile
});
} catch (err) {
console.error("保存图片失败:", err);
res.status(500).json({ success: false, message: "服务器错误,保存失败" });
}
console.log('接收到请求:', SuseKey, SuseFace?.substring(0, 30));
});
app.listen(port, () => {
console.log(`服务器已启动:http://localhost:${port}`);
});
结语
写这个,让我对canvas的理解更加深刻,canvas永远神。并且还对图片压缩,和发送理解加深了少。 还有文件类型的转换。
掘友们要继续学习哦,完结洒花。