手机端canvas实现单指拖动,双指放大,图片裁剪,压缩上传

580 阅读15分钟

哈喽啊,大家。今天我来介绍下我的这个代码需求,代码我放最后面了,leader要我实现一个拍照,然后图片会有一个预览效果,然后可以拖动预览的照片,在方框内,单指拖动,双指放大,然后截取框内的照片进行压缩,转化为base64数据进行上传。

这是我写这篇文章前学到最多的文章,包含文件和canvas处理,点击查看

不知道是项目要求还是啥,要我用原生的html实现,并且不使用第三方的插件。说实话,还是挺难实现的,看了下掘友们写的文章,自己也有了点灵感,然后自己也开始逐步的去一个一个实现对应的功能。写这个还真不能心急,用ai生成的可以说是漏洞百出,自己一个一个的修改,根本就不知道错误点在哪,花了半天都没弄好。最后直接全部重写,一个一个需求去实现,发现这样的话会快很多。

我讲下我大概的思路,我的思路是用canvas,让图片生成在canvas的中间,然后手指触摸图片就会计算位置,重新生成图片,并且限制图片位置,让截图框不能超出图片。还写了个后端测试图片上传,也放后面了。

大概效果,全部代码放最后面。 image.png

首先介绍下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
    1. 必须设置了canvas的长宽,在style上设置的长宽会导致canvas的图片模糊之类的各种问题
    2. 设置了状态管理上的canvas的长宽,方便后面计算时使用
 function initializeCanvas() {
     canvas.width = uploadBox.clientWidth;
     canvas.height = uploadBox.clientHeight;
     canvasObject.width = uploadBox.clientWidth;
     canvasObject.height = uploadBox.clientHeight;
   }
  • drawImage 重绘图片
    • 作用:在图片上传到input上后,被调用,和图片拖动时被重新调用,将图片绘制在canvas上,并记录属性
    1. 用clearReact清理画布
    2. 用rotate旋转坐标对应的角度,这里并不会转动,因为当时说要双指旋转,但做不好,就去掉了。
    3. 用drawImage绘制图片,drawImage上用的单位也是px
      1. 第一个属性是一个Image对象,存放着图片
      2. 第二三个属性是移动和的位置,这里的数据我进行的计算,为了居中
      3. 最后两个属性是绘制的大小,最好按原比例,要不然会变形
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,最后绘制。其中还有一些属性的计算
    1. 获取到图片文件
    2. 创建一个FileReader实现文件读取
    3. 利用FileReader的方法readAsDataURL实现文件转为url地址放在图片上
    4. 你可能迷惑中间的代码,中间还有个reader.onload,这个是在readAsDataURL后执行的异步代码
    5. reader.onload
      1. 创建一个image对象,用于重绘
      2. 将image上的src改为转化好的URL,就是e.target.result
      3. 你可能会疑惑中间的一大段代码,img.onload是在image获取到图片后执行,是个异步
      4. img.onload
        1. canvas初始化
        2. 将img存放,用于图片绘制
        3. 计算缩放比,让图片尽可能的贴合裁剪框
        4. 然后就是属性的计算,要讲下平移位置translateX,(canvasObject.width - imageObject.width) / 2刚好就能居中
        5. 绘制图片
        6. 开放上传和重拍按钮
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();
    })

裁剪,压缩上传
  1. 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 字符串。
  1. 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永远神。并且还对图片压缩,和发送理解加深了少。 还有文件类型的转换。

掘友们要继续学习哦,完结洒花。

images.jpg