小程序canvas2d实现横版全屏和竖版逐字的签名组件(字帖式米字格签名组件)

发布于:2025-02-15 ⋅ 阅读:(16) ⋅ 点赞:(0)

01 功能说明

技术栈:uniapp、vue、canvas 2d

需求

  • 实现横版的全名字帖式米字格签名组件,竖版逐字的字帖式米字格签名组件;
  • 支持配置文字描述、画笔颜色、画笔大小等;
  • 提供 submit 事件,当点击提交按钮时触发,回调参数是canvas转化为图片的地址;

02 效果预览

2.1 横版

横版截图
在这里插入图片描述

2.2 竖版

竖版截图
在这里插入图片描述

03 使用方式

// 使用横向签名------------------------
<template>
  <HorizontalSign signText="赵钱孙" @submit="handleSubmit" /> 
</template>

<script>
import HorizontalSign from '@/components/HorizontalSign.vue';

export default {
  components: { HorizontalSign },
  methods: {
    handleSubmit(imagePath) {
      console.log('--image--', imagePath);
    },
  },
} 
<script> 

// 使用竖向签名------------------------
<template>
  <VerticalSign signText="赵钱孙"  @submit="handleSubmit" />
</template>

<script>
import VerticalSign from '@/components/VerticalSign.vue';

export default {
  components: { VerticalSign },
  methods: {
    handleSubmit(imagePath) {
      console.log('--image--', imagePath);
    },
  },
}  
<script> 

04 横向签名组件源码

4.1 html 代码

<template>
  <view class="wrapping">
    <!-- header 定位后以左上角顺时针旋转 90° -->
    <view class="header-title flex col-center">
      <!-- <text> 签名:</text> -->
      <!-- 预览图片(图片本身是正向的,但由于父元素旋转了90°所以正好能横向观看) -->
      <!-- <image :src="previewImage" mode="aspectFit" class="small-preview" /> -->
      <text class="desc-text">{{ description }}</text>
    </view>
    <!-- 实际保持直立正向 canvas 容器 -->
    <view class="canvas-wrapper">
      <!-- 只展示限制数量文字的米字格,超过配置数量文字则不展示 -->
      <view class="char-group flex-col flex-center" v-if="signText && signText.length <= riceGridLimit">
        <view class="char-box" v-for="(item, index) in signText" :key="index">
          {{ item }}
        </view>
      </view>
      <canvas
        id="signatureCanvas"
        type="2d"
        class="signature-canvas"
        @touchstart="handleTouchStart"
        @touchmove="handleTouchMove"
        @touchend="handleTouchEnd"
        @touchcancel="handleTouchEnd"
        disable-scroll
      ></canvas>
    </view>
    <!-- footer 定位后以右下角顺时针旋转 90° -->
    <view class="footer-btn flex">
      <view class="action-btn" @click="resetCanvas">重签</view>
      <view class="action-btn submit-btn" @click="handleSubmit">{{ submitText }}</view>
    </view>
    <!--用于绘制并生成旋转为正向签名图片的 canvas 容器-->
    <canvas id="previewCanvas" type="2d" class="preview-canvas"></canvas>
  </view>
</template>

4.2 业务 Js

<script>
export default {
  props: {
    description: {
      type: String,
      default: '请使用正楷字体,逐字签写', //  文字描述
    },
    submitText: {
      type: String,
      default: '提交', // 提交按钮文字
    },
    dotSize: {
      type: Number,
      default: 4, // 签名笔大小
    },
    penColor: {
      type: String,
      default: '#000000', // 签名笔颜色
    },
    signText: {
      type: String,
      default: '', // 签名文字
    },
    riceGridLimit: {
      type: Number,
      default: 3, // 米字格展示字数最大限制
    },
  },
  data() {
    return {
      mainCtx: null,
      mainCanvas: null,
      isDrawing: false,
      touchPoints: [],
      signIsMove: false,
      previewImage: '',
      canvasRatio: 1,
    };
  },
  mounted() {
    this.canvasRatio = uni.getWindowInfo().pixelRatio ?? 1;
    this.initCanvas();
  },
  methods: {
    initCanvas() {
      const domItem = uni.createSelectorQuery().in(this).select('#signatureCanvas');
      domItem.fields({ node: true, size: true }).exec((res) => {
        // Canvas 对象
        this.mainCanvas = res[0]?.node;
        // 渲染上下文
        this.mainCtx = this.mainCanvas.getContext('2d');
        // Canvas 画布的实际绘制宽高
        const width = res[0].width;
        const height = res[0].height;
        // 初始化画布大小
        this.mainCanvas.width = width * this.canvasRatio;
        this.mainCanvas.height = height * this.canvasRatio;
        this.mainCtx.scale(this.canvasRatio, this.canvasRatio);
        this.setPen();
      });
    },
    setPen() {
      this.mainCtx.strokeStyle = this.penColor;
      this.mainCtx.lineWidth = this.dotSize;
      this.mainCtx.lineCap = 'round';
      this.mainCtx.lineJoin = 'round';
    },
    handleTouchStart(e) {
      const point = {
        x: e.changedTouches[0].x,
        y: e.changedTouches[0].y,
      };
      this.touchPoints.push(point);
      this.isDrawing = true;
    },
    handleTouchMove(e) {
      if (!this.isDrawing) return;
      const point = {
        x: e.touches[0].x,
        y: e.touches[0].y,
      };
      this.touchPoints.push(point);
      const len = this.touchPoints.length;
      if (len >= 2) {
        const prevPoint = this.touchPoints[len - 2];
        const currentPoint = this.touchPoints[len - 1];
        this.mainCtx.beginPath();
        this.mainCtx.moveTo(prevPoint.x, prevPoint.y);
        this.mainCtx.lineTo(currentPoint.x, currentPoint.y);
        this.mainCtx.stroke();
        this.signIsMove = true;
      }
    },
    handleTouchEnd() {
      this.isDrawing = false;
      this.touchPoints = [];
    },
    resetCanvas() {
      if (!this.signIsMove) {
        return;
      }
      this.mainCtx.clearRect(0, 0, 1000, 1000);
      this.setPen();
      this.touchPoints = [];
      this.previewImage = '';
      this.signIsMove = false;
    },
    async handleSubmit() {
      if (!this.signIsMove) {
        uni.showToast({ title: '请先完成签名', icon: 'none' });
        return;
      }
      try {
        const _this = this;
        uni.canvasToTempFilePath({
          canvas: this.mainCanvas,
          quality: 1,
          fileType: 'png',
          success: (res) => {
            let path = res.tempFilePath;
            _this.handlePreviewImage(path);
          },
          fail: (res) => {
            uni.showToast({ title: '提交失败,请重新尝试', icon: 'none' });
          },
        });
      } catch (err) {
        uni.showToast({ title: '签名失败,请重试', icon: 'none' });
      } finally {
        uni.hideLoading();
      }
    },
    handlePreviewImage(imagePath) {
      const _this = this;
      const previewDom = uni.createSelectorQuery().in(_this).select('#previewCanvas');
      previewDom.fields({ node: true, size: true }).exec((res) => {
        // Canvas 对象
        const canvas = res[0]?.node;
        // 渲染上下文
        const previewCtx = canvas.getContext('2d');
        const image = canvas.createImage();
        image.src = imagePath;
        image.onload = () => {
          let { width, height } = image;
          // 获取图片的宽高初始画布,canvas交换宽高
          canvas.width = height;
          canvas.height = width;
          // 设置白色背景
          previewCtx.fillStyle = '#FFFFFF';
          previewCtx.fillRect(0, 0, height, width);
          // 图片逆时针旋转90度,且换为弧度
          previewCtx.rotate((-90 * Math.PI) / 180);
          // 旋转后调整绘制的位置下移一个宽度的距离
          previewCtx.drawImage(image, -width, 0);
        };
        // 最终导出
        setTimeout(() => {
          uni.canvasToTempFilePath(
            {
              canvas,
              fileType: 'png', // 指定文件类型
              quality: 1, // 最高质量
              success: (res) => {
                _this.previewImage = res.tempFilePath;
                uni.previewImage({ urls: [res.tempFilePath], current: 0 });
                _this.$emit('submit', res.tempFilePath);
              },
              fail: (err) => {
                uni.showToast({ title: '合成失败,请重试', icon: 'none' });
              },
            },
            _this
          );
        }, 300); // 增加最终导出前的延迟
      });
    },
  },
};
</script>

4.3 样式 Css

<style scoped>
.wrapping {
  position: relative;
  padding: 20rpx;
  margin: 20rpx;
  background-color: #fff;
  box-sizing: border-box;
}

.header-title {
  position: absolute;
  right: 20rpx;
  top: 20rpx;
  height: 50rpx;
  z-index: 1000;
  transform-origin: top left;
  transform: translateX(100%) rotate(90deg);
  font-size: 32rpx;
  color: #333;
}

.desc-text {
  color: #969799;
}

.small-preview {
  width: 100rpx;
  height: 50rpx;
  border-bottom: 1px solid #333;
}

.canvas-wrapper {
  position: relative;
  margin: auto;
  width: 60%;
  height: 80vh;
  background: #f7f8fa;
}

.char-group {
  position: absolute;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  pointer-events: none;
  user-select: none;
  z-index: 1;
  gap: 20rpx;
}

.char-box {
  padding: 36rpx;
  width: 30vw;
  height: 30vw;
  transform: rotate(90deg);
  font-size: 30vw;
  line-height: 30vw;
  text-align: center;
  color: #eeeeee;
  /* 使用虚线边框框住字体 */
  /* border: 1px dashed #ccc; */
  /* 使用米字格照片当背景图 */
  background: url('https://img1.baidu.com/it/u=2622499137,3527900847&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500') no-repeat;
  background-size: 100%;
  text-shadow: 1px 1px black, -1px -1px black, 1px -1px black, -1px 1px black;
}

.signature-canvas {
  position: relative;
  width: 100%;
  height: 100%;
  z-index: 2;
}

.footer-btn {
  position: absolute;
  left: 20rpx;
  bottom: 20rpx;
  transform-origin: bottom right;
  transform: translateX(-100%) rotate(90deg);
  z-index: 1000;
  gap: 32rpx;
}

.action-btn {
  text-align: center;
  width: 200rpx;
  height: 96rpx;
  border-radius: 100rpx;
  font-size: 32rpx;
  line-height: 96rpx;
  color: #3874f6;
  border: 2rpx solid #3874f6;
  background: #fff;
}

.submit-btn {
  color: #fff;
  border: 2rpx solid #3874f6;
  background: #3874f6;
}

.preview-canvas {
  visibility: hidden;
  position: fixed;
  /* 将画布移出展示区域 */
  top: 100vh;
  left: 100vw;
  opacity: 0;
  z-index: 0;
}
</style>

05 竖向签名组件源码

5.1 布局 Html

<template>
  <view class="signature-container">
    <view class="desc-text">{{ description }}</view>
    <view class="signature-area">
      <view class="canvas-wrapper">
        <!-- 逐字展示文字 -->
        <view class="char-box" v-if="signText && currentCharIndex < signText.length">
          {{ signText[currentCharIndex] }}
        </view>
        <canvas
          id="signatureCanvas"
          class="signature-canvas"
          type="2d"
          @touchstart="handleTouchStart"
          @touchmove="handleTouchMove"
          @touchend="handleTouchEnd"
          @touchcancel="handleTouchEnd"
          disable-scroll
        ></canvas>
      </view>
      <view class="action-box">
        <view class="action-btn" v-if="currentCharIndex > 0" @click="prevChar">上一字</view>
        <view class="action-btn" @click="resetCanvas">清空画板</view>
        <view class="action-btn" v-if="currentCharIndex < signText.length" @click="nextChar">
          {{ currentCharIndex < signText.length - 1 ? '下一字' : '确认' }}
        </view>
      </view>
    </view>

    <view class="preview-title">逐字预览</view>
    <view class="preview-content">
      <image v-for="(img, index) in previewImages" :key="index" :src="img" mode="aspectFit" class="preview-char" />
    </view>

    <view class="action-box">
      <view class="action-btn submit-btn" @click="resetAllRecord">全部重签</view>
      <view class="action-btn submit-btn" @click="handleSubmit">{{ submitText }}</view>
    </view>

    <!--用于拼接合并为完整签名图片的 canvas 容器-->
    <canvas id="previewCanvas" type="2d" class="preview-canvas"></canvas>
  </view>
</template>

5.2 业务 Js

<script>
export default {
  props: {
    description: {
      type: String,
      default: '请使用正楷字体,逐字签写', // 文字描述
    },
    submitText: {
      type: String,
      default: '提交', // 提交按钮文字
    },
    dotSize: {
      type: Number,
      default: 4, // 签名笔大小
    },
    penColor: {
      type: String,
      default: '#000000', // 签名笔颜色
    },
    signText: {
      type: String,
      default: '', // 签名文字
    },
  },
  data() {
    return {
      mainCtx: null,
      mainCanvas: null,
      isDrawing: false,
      touchPoints: [],
      allTouchPoints: [],
      signIsMove: false,
      currentCharIndex: 0,
      canvasRatio: 1,
      previewImages: [],
    };
  },
  mounted() {
    this.canvasRatio = uni.getWindowInfo().pixelRatio ?? 1;
    this.initCanvas();
  },
  methods: {
    initCanvas() {
      const domItem = uni.createSelectorQuery().in(this).select('#signatureCanvas');
      domItem.fields({ node: true, size: true }).exec((res) => {
        // Canvas 对象
        this.mainCanvas = res[0]?.node;
        // 渲染上下文
        this.mainCtx = this.mainCanvas.getContext('2d');
        // Canvas 画布的实际绘制宽高
        const width = res[0].width;
        const height = res[0].height;
        // 初始化画布大小
        this.mainCanvas.width = width * this.canvasRatio;
        this.mainCanvas.height = height * this.canvasRatio;
        this.mainCtx.scale(this.canvasRatio, this.canvasRatio);
        this.setPen();
      });
    },
    setPen() {
      this.mainCtx.strokeStyle = this.penColor;
      this.mainCtx.lineWidth = this.dotSize;
      this.mainCtx.lineCap = 'round';
      this.mainCtx.lineJoin = 'round';
    },
    handleTouchStart(e) {
      const point = {
        x: e.changedTouches[0].x,
        y: e.changedTouches[0].y,
      };
      this.touchPoints.push(point);
      this.allTouchPoints.push(point);
      this.isDrawing = true;
    },
    handleTouchMove(e) {
      if (!this.isDrawing) return;
      const point = {
        x: e.touches[0].x,
        y: e.touches[0].y,
      };
      this.touchPoints.push(point);
      this.allTouchPoints.push(point);
      const len = this.touchPoints.length;
      if (len >= 2) {
        const prevPoint = this.touchPoints[len - 2];
        const currentPoint = this.touchPoints[len - 1];
        this.mainCtx.beginPath();
        this.mainCtx.moveTo(prevPoint.x, prevPoint.y);
        this.mainCtx.lineTo(currentPoint.x, currentPoint.y);
        this.mainCtx.stroke();
        this.signIsMove = true;
      }
    },
    handleTouchEnd() {
      this.isDrawing = false;
      this.touchPoints = [];
    },
    getRectangle(points) {
      // 计算每个字符的实际大小
      let minX = Number.POSITIVE_INFINITY;
      let minY = Number.POSITIVE_INFINITY;
      let maxX = Number.NEGATIVE_INFINITY;
      let maxY = Number.NEGATIVE_INFINITY;
      for (let point of points) {
        minX = Math.min(minX, point.x);
        minY = Math.min(minY, point.y);
        maxX = Math.max(maxX, point.x);
        maxY = Math.max(maxY, point.y);
      }
      return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
    },
    prevChar() {
      if (this.previewImages.length > 0) {
        this.previewImages.pop();
        this.currentCharIndex--;
        this.resetCanvas();
      }
    },
    nextChar() {
      if (!this.signIsMove) {
        uni.showToast({ title: '请先完成签名', icon: 'none' });
        return;
      }
      try {
        const { x, y, width, height } = this.getRectangle(this.allTouchPoints);
        const offset = 10;
        const _this = this;
        uni.canvasToTempFilePath(
          {
            canvas: this.mainCanvas,
            x: x - offset,
            y: y - offset,
            width: width + offset * 2,
            height: height + offset * 2,
            success: (res) => {
              _this.previewImages.push(res.tempFilePath);
              _this.currentCharIndex++;
              _this.resetCanvas();
            },
            fail: () => {
              uni.showToast({ title: '提交失败,请重新尝试', icon: 'none' });
            },
          },
          _this
        );
      } catch (err) {
        uni.showToast({ title: '保存失败,请重试', icon: 'none' });
      }
    },
    resetCanvas() {
      this.mainCtx.clearRect(0, 0, 1000, 1000);
      this.setPen();
      this.touchPoints = [];
      this.allTouchPoints = [];
      this.signIsMove = false;
    },
    resetAllRecord() {
      this.previewImages = [];
      this.currentCharIndex = 0;
      this.resetCanvas();
    },
    async handleSubmit() {
      if (this.previewImages.length <= 0) {
        uni.showToast({ title: '请至少签写一个字', icon: 'none' });
        return;
      }
      try {
        this.handlePreviewImage();
      } catch (err) {
        uni.showToast({ title: '合成失败,请重试', icon: 'none' });
      }
    },
    handlePreviewImage() {
      const _this = this;
      const previewDom = uni.createSelectorQuery().in(_this).select('#previewCanvas');
      previewDom.fields({ node: true, size: true }).exec((res) => {
        // Canvas 对象
        const canvas = res[0]?.node;
        // 渲染上下文
        const previewCtx = canvas.getContext('2d');
        // 计算总宽度和单个字的尺寸
        const charWidth = 300 / this.previewImages.length;
        const charHeight = 300 / this.previewImages.length;
        const totalWidth = charWidth * this.previewImages.length;
        // 设置白色背景
        previewCtx.fillStyle = '#FFFFFF';
        previewCtx.fillRect(0, 0, totalWidth, charHeight);
        // 按顺序绘制每个图片
        for (let i = 0; i < this.previewImages.length; i++) {
          const image = canvas.createImage();
          image.src = this.previewImages[i];
          image.onload = () => {
            const x = i * charWidth;
            // 绘制当前图片
            previewCtx.drawImage(image, x, 0, charWidth, charHeight);
          };
        }
        // 最终导出
        setTimeout(() => {
          uni.canvasToTempFilePath(
            {
              canvas,
              x: 0,
              y: 0,
              width: totalWidth,
              height: charHeight,
              fileType: 'png', // 指定文件类型
              quality: 1, // 最高质量
              success: (res) => {
                uni.previewImage({ urls: [res.tempFilePath], current: 0 });
                _this.$emit('submit', res.tempFilePath);
              },
              fail: (err) => {
                uni.showToast({ title: '合成失败,请重试', icon: 'none' });
              },
            },
            _this
          );
        }, 300); // 增加最终导出前的延迟
      });
    },
  },
};
</script>

5.3 样式 Css

<style scoped>
.signature-container {
  padding: 0 20rpx 40rpx 20rpx;
  background-color: #f5f5f5;
  box-sizing: border-box;
}

.signature-area {
  padding: 50rpx;
  background-color: #fff;
  box-sizing: border-box;
}

.desc-text {
  padding: 20rpx 0;
  font-size: 32rpx;
  color: #333;
  text-align: center;
  box-sizing: border-box;
}

.canvas-wrapper {
  position: relative;
  width: 100%;
  /* 保持宽高比 */
  aspect-ratio: 1;
  /* height: 600rpx; */
  background: #fff;
  /* 使用虚线边框框住字体 */
  /* border: 1px dashed #ccc; */
  /* 使用米字格照片当背景图 */
  background: url('https://img1.baidu.com/it/u=2622499137,3527900847&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500') no-repeat;
  background-size: 100%;
}

.char-box {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 400rpx;
  text-shadow: 1px 1px black, -1px -1px black, 1px -1px black, -1px 1px black;
  color: #eeeeee;
  pointer-events: none;
  user-select: none;
  z-index: 1;
}

.signature-canvas {
  position: relative;
  width: 100%;
  height: 100%;
  z-index: 2;
}

.action-box {
  display: flex;
  margin-top: 32rpx;
  gap: 20rpx;
}

.action-btn {
  flex: 1;
  text-align: center;
  padding: 16rpx 30rpx;
  font-size: 28rpx;
  color: #3874f6;
  border: 2rpx solid #3874f6;
  border-radius: 80rpx;
  box-sizing: border-box;
}

.submit-btn {
  background: #3874f6;
  color: #fff;
}

.preview-title {
  margin-top: 32rpx;
  width: 100%;
  text-align: center;
  font-size: 28rpx;
  color: #666;
}

.preview-content {
  display: flex;
  flex-wrap: wrap;
  margin-top: 20rpx;
  background-color: #fff;
  padding: 20rpx 20rpx 0 20rpx;
  min-height: 190rpx;
  box-sizing: border-box;
}

.preview-char {
  width: 150rpx;
  height: 150rpx;
  margin-right: 19rpx;
  margin-bottom: 20rpx;
}

.preview-canvas {
  position: fixed;
  left: -2000px;
  width: 300px;
  height: 300px;
}
</style>