使用 HTML + JavaScript 实现手写签名功能

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>手写签名</title>
  <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
  <style>
      * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
      }
      body {
          background-color: #f5f5f5;
          min-height: 100vh;
          padding: 20px;
      }

      .container {
          max-width: 800px;
          margin: 0 auto;
          background: white;
          border-radius: 15px;
          box-shadow: 0 20px 40px rgba(0,0,0,0.1);
          overflow: hidden;
      }

      .header {
          background: #4299e1;
          color: white;
          padding: 20px;
          text-align: center;
      }
      .header h1 {
          font-size: 28px;
          font-weight: 500;
      }
      #paper {
          position: relative;
          margin: 20px auto;
          border: 1px solid #ccc;
          background: #fff;
      }

      canvas {
          position: absolute;
          left: 0;
          top: 0;
      }

      #guide {
          z-index: 1;
      }

      #user {
          z-index: 2;
          touch-action: none;
      }

      .ctrl {
          text-align: center;
          margin: 18px 0;
      }

      button {
          padding: 7px 16px;
          margin: 0 6px;
          font-size: 15px;
      }
  </style>
</head>
<body>
<div class="container">
  <div class="header">
    <h1>手写签名</h1>
  </div>
  <div id="paper">
    <canvas id="guide"></canvas>
    <canvas id="user"></canvas>
  </div>
  <div class="ctrl">
    <button id="clearBtn">清空重写</button>
    <button id="downBtn">下载保存</button>
  </div>
</div>


<script>
  // 配置
  var CONFIG = {
    chars: '海纳百川', // 范字字符串
    cols: 4, // 每行格数
    cellPx: 180, // 每格像素
    lineW: 2, // 米字/边框线宽
    guideColor: 'rgba(180,180,180,.5)', // 底稿颜色
    userColor: '#000000', // 用户笔迹颜色
    penWidth: 5, // 笔尖宽度
  };
  // 计算布局
  var rows = Math.ceil(CONFIG.chars.length / CONFIG.cols);
  var W = CONFIG.cellPx * CONFIG.cols;
  var H = CONFIG.cellPx * rows;
  document.getElementById('paper').style.width = W + 'px';
  document.getElementById('paper').style.height = H + 'px';

  // 底稿 canvas
  var gCanvas = document.getElementById('guide');
  var gCtx = gCanvas.getContext('2d');
  gCanvas.width = W;
  gCanvas.height = H;

  // 用户 canvas
  var uCanvas = document.getElementById('user');
  var uCtx = uCanvas.getContext('2d');
  uCanvas.width = W;
  uCanvas.height = H;

  // 画底稿(米字格+范字)
  gCtx.lineWidth = CONFIG.lineW;
  gCtx.strokeStyle = CONFIG.guideColor;
  gCtx.font = `${CONFIG.cellPx * 0.75}px 楷体`;
  gCtx.textAlign = 'center';
  gCtx.textBaseline = 'middle';

  for (var i = 0; i < CONFIG.chars.length; i++) {
    var r = Math.floor(i / CONFIG.cols);
    var c = i % CONFIG.cols;
    var x = c * CONFIG.cellPx;
    var y = r * CONFIG.cellPx;
    var cx = x + CONFIG.cellPx / 2;
    var cy = y + CONFIG.cellPx / 2;

    // 米字格
    gCtx.beginPath();
    gCtx.rect(x, y, CONFIG.cellPx, CONFIG.cellPx); // 外框
    gCtx.moveTo(x, y);
    gCtx.lineTo(x + CONFIG.cellPx, y + CONFIG.cellPx); // 左斜
    gCtx.moveTo(x + CONFIG.cellPx, y);
    gCtx.lineTo(x, y + CONFIG.cellPx); // 右斜
    gCtx.moveTo(cx, y);
    gCtx.lineTo(cx, y + CONFIG.cellPx);// 竖
    gCtx.moveTo(x, cy);
    gCtx.lineTo(x + CONFIG.cellPx, cy);// 横
    gCtx.stroke();

    // 范字
    gCtx.strokeText(CONFIG.chars[i], cx, cy);
  }

  // 手写层
  uCtx.strokeStyle = CONFIG.userColor;
  uCtx.lineWidth = CONFIG.penWidth;
  uCtx.lineCap = uCtx.lineJoin = 'round';

  var drawing = false, lastX, lastY;

  function pos(e) {
    var rect = uCanvas.getBoundingClientRect();
    var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
    var y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top;
    return [x, y];
  }

  function start(e) {
    drawing = true;
    [lastX, lastY] = pos(e);
    uCtx.beginPath();
    uCtx.moveTo(lastX, lastY);
  }

  function move(e) {
    if (!drawing) return;
    var [x, y] = pos(e);
    uCtx.quadraticCurveTo(lastX, lastY, (lastX + x) / 2, (lastY + y) / 2);
    uCtx.stroke();
    [lastX, lastY] = [x, y];
  }

  function stop() {
    drawing = false;
  }

  uCanvas.addEventListener('mousedown', start);
  uCanvas.addEventListener('mousemove', move);
  window.addEventListener('mouseup', stop);

  uCanvas.addEventListener('touchstart', e => {
    e.preventDefault();
    start(e);
  });
  uCanvas.addEventListener('touchmove', e => {
    e.preventDefault();
    move(e);
  });
  uCanvas.addEventListener('touchend', e => {
    e.preventDefault();
    stop();
  });

  // 控制按钮
  document.getElementById('clearBtn').onclick = () => uCtx.clearRect(0, 0, W, H);

  document.getElementById('downBtn').onclick = () => {
    var a = document.createElement('a');
    a.download = `签名_${Date.now()}.png`;
    a.href = uCanvas.toDataURL('image/png');
    a.click();
  };
</script>
</body>
</html>
相关推荐
燕山石头1 小时前
jeecg统一异常处理根据不同模块返回指定响应信息
前端
PyHaVolask1 小时前
CSRF跨站请求伪造
android·前端·csrf
程序员海军1 小时前
我的2025:做项目、跑副业、见人、奔波、搬家、维权、再回上海
前端·程序员·年终总结
我来整一篇2 小时前
[Razor] ASP.NET Core MVC 前端组件快速使用总结
前端·asp.net·mvc
2501_948122632 小时前
React Native for OpenHarmony 实战:Steam 资讯 App 隐私政策实现
javascript·react native·react.js·游戏·ecmascript·harmonyos
2501_948122632 小时前
React Native for OpenHarmony 实战:Steam 资讯 App 主题设置实现
javascript·react native·react.js·游戏·ecmascript·harmonyos
P7Dreamer2 小时前
微信小程序处理Range分片视频播放问题:前端调试全记录
前端·微信小程序
RedHeartWWW2 小时前
初识next-auth,和在实际应用中的几个基本场景(本文以v5为例,v4和v5的差别主要是在个别显式配置和api,有兴趣的同学可以看官网教程学习)
前端·next.js
C_心欲无痕2 小时前
前端页面中,如何让用户回到上次阅读的位置
前端
C_心欲无痕2 小时前
前端本地开发构建和更新的过程
前端