vue3 smooth-signature 带笔锋手写签名

mini-smooth-signature 小程序版带笔锋手写签名,支持多平台小程序使用

参考:GitHub - linjc/smooth-signature: H5带笔锋手写签名,支持PC端和移动端,任何前端框架均可使用

一、安装

npm install smooth-signature
# 或
yarn add smooth-signature
 

或通过<script>引用,全局变量 window.SmoothSignature

<script src="https://unpkg.com/smooth-signature/dist/index.umd.min.js" />
 

也可自行下载smooth-signature.js到本地引用

二、使用

<div>
    <canvas />
</div>
 
import SmoothSignature from "smooth-signature";

const canvas = document.querySelector("canvas");
const signature = new SmoothSignature(canvas);

// 生成PNG
signature.getPNG() // 或者 signature.toDataURL()

// 生成JPG
signature.getJPG() // 或者 signature.toDataURL('image/jpeg')

// 清屏
signature.clear()

// 撤销
signature.undo()

// 是否为空
signature.isEmpty()

// 生成旋转后的新画布 -90/90/-180/180
signature.getRotateCanvas(90)
 

配置[options]

所有配置项均是可选的

const signature = new SmoothSignature(canvas, {
    width: 1000,
    height: 600,
    scale: 2,
    minWidth: 4,
    maxWidth: 10,
    color: '#1890ff',
    bgColor: '#efefef'
});
 

options.width

画布在页面实际渲染的宽度(px)

  • Type: number
  • Default:canvas.clientWidth || 320

options.height

画布在页面实际渲染的高度(px)

  • Type: number
  • Default:canvas.clientHeight || 200

options.scale

画布缩放,可用于提高清晰度

  • Type: number
  • Default:window.devicePixelRatio || 1

options.color

画笔颜色

  • Type: string
  • Default:black

options.bgColor

画布背景颜色,默认透明

  • Type: string
  • Default:

options.openSmooth

是否开启笔锋效果,默认开启

  • Type: boolean
  • Default:true

options.minWidth

画笔最小宽度(px),开启笔锋时画笔最小宽度

  • Type: number
  • Default:2

options.maxWidth

画笔最大宽度(px),开启笔锋时画笔最大宽度,或未开启笔锋时画笔正常宽度

  • Type: number
  • Default:6

options.minSpeed

画笔达到最小宽度所需最小速度(px/ms),取值范围1.0-10.0,值越小,画笔越容易变细,笔锋效果会比较明显,可以自行调整查看效果,选出自己满意的值。

  • Type: number
  • Default:1.5

options.maxWidthDiffRate

相邻两线宽度增(减)量最大百分比,取值范围1-100,为了达到笔锋效果,画笔宽度会随画笔速度而改变,如果相邻两线宽度差太大,过渡效果就会很突兀,使用maxWidthDiffRate限制宽度差,让过渡效果更自然。可以自行调整查看效果,选出自己满意的值。

  • Type: number
  • Default:20

options.maxHistoryLength

限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能

  • Type: number
  • Default:20

options.onStart

绘画开始回调函数

  • Type: function

options.onEnd

绘画结束回调函数

  • Type: function

三、实现原理

我们平时纸上写字,细看会发现笔画的粗细是不均匀的,这是写字过程中,笔的按压力度和移动速度不同而形成的。而在电脑手机浏览器上,虽然我们无法获取到触摸的压力,但可以通过画笔移动的速度来实现不均匀的笔画效果,让字体看起来和纸上写字一样有“笔锋”。下面介绍具体实现过程(以下展示代码只为方便理解,非最终实现代码)。

1、采集画笔经过的点坐标和时间

通过监听画布move事件采集移动经过的点坐标,并记录当前时间,然后保存到points数组中。

function onMove(event) {
    const e = event.touches && event.touches[0] || event;
    const rect = this.canvas.getBoundingClientRect();
    const point = {
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
        t: Date.now()
    }
    points.push(point);
}
 

2、计算两点之间移动速度

通过两点坐标计算出两点距离,再除以时间差,即可得到移动速度。

const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
const speed = distance / (end.t - start.t);
 

3、计算两点之间线的宽度

得到两点间移动速度,接下来通过简单算法计算出线的宽度,其中maxWidth、minWidth、minSpeed为配置项

const addWidth = (maxWidth - minWidth) * speed / minSpeed;
const lineWidth = Math.min(Math.max(maxWidth - addWidth, minWidth), maxWidth);
 

另外,为了防止相邻两条线宽度差太大,而出现突兀的过渡效果,需要做下限制,其中maxWidthDiffRate为配置项,preLineWidth为上一条线的宽度

const rate = (lineWidth - preLineWidth) / preLineWidth;
const maxRate = maxWidthDiffRate / 100;
if (Math.abs(rate) > maxRate) {
    const per = rate > 0 ? maxRate : -maxRate;
    lineWidth = preLineWidth * (1 + per);
}
 

4、画线

现在已经知道每两点间线的宽度,接下来就是画线了。为了让线条看起来圆润以及线粗细过渡更自然,我把两点之间的线平均成三段,其中:

  1. 第一段(x0,y0 - x1,y1)线宽设置为当前线宽和上一条线宽的平均值lineWidth1 = (preLineWidth + lineWidth) / 2
  2. 第二段(x1,y1 - x2,y2)
  3. 第三段(x2,y2 - next_x0,next_y0)线宽设置为当前线宽和下一条线宽的平均值lineWidth3 = (nextLineWidth + lineWidth) / 2

开始画线,先来看第一段线,因为第一段线和上一条线相交,为了保证两条线过渡比较圆润,采用二次贝塞尔曲线,起点为上一条线的第三段起点(pre_x2, pre_y2)

ctx.lineWidth = lineWidth1
ctx.beginPath();
ctx.moveTo(pre_x2, pre_y2);
ctx.quadraticCurveTo(x0, y0, x1, y1);
ctx.stroke();
 

第二段线为承接第一段和第三段的过渡线,由于第一段和第三段线宽有差异,所以第二段线使用梯形填充,让过渡效果更自然。

ctx.beginPath();
ctx.moveTo(point1.x, point1.y);
ctx.lineTo(point2.x, point2.y);
ctx.lineTo(point3.x, point3.y);
ctx.lineTo(point4.x, point4.y);
ctx.fill();
 

第三段等画下一条线时重复上述操作即可。

四、例子

(1)PC版

<template>
  <a-modal :default-visible="true" width="880px" :footer="false">
    <template #title>签约</template>
    <div class="wrapper">
      <canvas id="drag-canvas" canvas-id="drag-canvas" style="width: 800px; height: 400px" />
      <div class="actions">
        <a-button class="btn" @click="handleClear">清空</a-button>
        <a-button class="btn" @click="handleUndo">撤销</a-button>
        <a-button class="btn" type="primary" @click="handlePreview">确认</a-button>
      </div>
    </div>
  </a-modal>
</template>

<script setup lang="ts">
import SmoothSignature from 'smooth-signature';
import { Message } from '@arco-design/web-vue';

let canvas = {} as HTMLCanvasElement;
let signature = {} as SmoothSignature;
const init = () => {
  canvas = document.querySelector('canvas') as HTMLCanvasElement;
  const options = {
    width: 800,
    height: 400,
    minWidth: 4,
    maxWidth: 12,
    bgColor: '#f6f6f6',
  };
  signature = new SmoothSignature(canvas, options);
};

const handleClear = () => {
  signature.clear();
};
const handleUndo = () => {
  signature.undo();
};
const emits = defineEmits(['handlePreview']);
const handlePreview = () => {
  const isEmpty = signature.isEmpty();
  if (isEmpty) {
    Message.warning('签名不得为空');
    return;
  }
  const pngUrl = signature.getPNG();
  emits('handlePreview', pngUrl);
};
setTimeout(() => {
  init();
}, 1000);
</script>

<style scoped lang="less">
.wrapper {
  padding: 15px 20px;
  canvas {
    border: 2px dashed #ccc;
    cursor: crosshair;
    margin: auto;
    display: block;
  }
  .actions {
    margin: 30px 0 0px;
    display: flex;
    text-align: center;
    .btn {
      flex: 1;
      margin-right: 20px;
      font-size: 18px !important;
      height: 56px !important;
      padding: 0 20px;
      &:last-child {
        margin-right: 0;
      }
    }
  }
  .tip {
    color: #108eff;
  }
}
</style>

 

 (2)小程序版

<template>
  <!-- 签名  -->
  <view class="sign-box" v-if="signShow">
    <view class="btn">
      <button class="item" @click="cancel">
        <text class="text">取消</text>
      </button>
      <button class="item" @click="clear">
        <text class="text">清空</text>
      </button>
      <button class="item active" @click="save">
        <text class="text">确定</text>
      </button>
    </view>
    <canvas
      class="canvas"
      disable-scroll="true"
      :style="{ width: width + 'px', height: height + 'px' }"
      canvas-id="designature"
      id="designature"
      @touchstart="start"
      @touchmove="move"
      @touchend="end"
    ></canvas>

    <view class="title">
      <text class="text">请签字</text>
    </view>
  </view>
  <!--  -->
  <view
    class="sign-small-box"
    :class="(isSign == false && signImage == '') || sign_image_url ? 'border' : ''"
    @click="show"
  >
    <view class="tip" v-if="isSign == false && signImage == ''">点击这里签署您的姓名 </view>
    <image class="sign-img" :src="signImage" v-else mode="heightFix" />
  </view>
  <view class="bottom-tip" v-if="sign_image_url">请点击上方签名图片重新签名</view>
  <!-- 旋转画布 -->
  <view />
</template>

<script setup lang="ts">
  import { nextTick, ref } from 'vue';
  import SmoothSignature from 'mini-smooth-signature';
  import { onLoad } from '@dcloudio/uni-app';

  const signShow = ref(false);
  const signImage = ref('');
  const width = ref(window.innerWidth - 110); //
  const height = ref(window.innerHeight - 100);
  const line = ref([]);
  const scale = ref(1);
  const isSign = ref(false);
  const signature = ref(null);
  const props = defineProps({
    sign_image_url: {
      type: String,
    },
  });

  const show = () => {
    signShow.value = true;
    nextTick(() => {
      initSignature();
    });
  };
  const cancel = () => {
    uni.navigateBack();
  };

  const distance = (a: any, b: any) => {
    let x = b.x - a.x;
    let y = b.y - a.y;
    return Math.sqrt(x * x + y * y);
  };
  // 初始化
  const initSignature = () => {
    const ctx = uni.createCanvasContext('designature');
    signature.value = new SmoothSignature(ctx, {
      minWidth: 4,
      maxWidth: 10,
      width: width.value,
      height: height.value,
      scale: scale.value,
      getImagePath: () => {
        return new Promise((resolve) => {
          uni.canvasToTempFilePath(
            {
              canvasId: 'designature',
              fileType: 'png',
              quality: 1, //图片质量
              success(res) {
                resolve(res.tempFilePath);
              },
            },
            this,
          );
        });
      },
    });
  };

  // 绑定touchstart事件
  const start = (e: any) => {
    isSign.value = false;
    const pos = e.touches[0];
    signature.value.onDrawStart(pos.x, pos.y);
  };
  // 绑定touchmove事件
  const move = (e: any) => {
    const pos = e.touches[0];
    signature.value.onDrawMove(pos.x, pos.y);
  };
  // 绑定touchend/touchcancel事件
  const end = () => {
    signature.value.onDrawEnd();
    isSign.value = true;
  };
  const clear = () => {
    signature.value.clear();
    isSign.value = false;
  };
  const emit = defineEmits(['backImage']);
  const save = () => {
    if (!isSign.value) {
      uni.showToast({
        title: '请先签字再保存',
        icon: 'none',
      });
      return;
    }
    signShow.value = false;

    signature.value.getImagePath().then((imageUrl) => {
      signImage.value = imageUrl;
      emit('backImage', imageUrl);
    });
  };
  onLoad(() => {
    if (props.sign_image_url) {
      signImage.value = props.sign_image_url;
    }
    uni.getSystemInfo({
      success: (res) => {
        width.value = res.windowWidth - 110;
        height.value = res.windowHeight - 100;
      },
    });
  });
</script>

<style lang="scss" scoped>
  .sign-small-box {
    overflow: hidden;
    width: 90%;
    height: 340rpx;
    cursor: pointer;
    margin: 0 auto 30rpx;
    display: flex;
    align-items: center;
    justify-content: center;

    &.border {
      border: 2rpx dashed #ccc;
      border-radius: 20rpx;
    }

    .tip {
      display: flex;
      align-items: center;
      justify-content: center;
      color: #999;
      width: 100%;
      height: 100%;
      font-size: 28rpx;
    }

    .sign-img {
      transform: rotate(-90deg);
    }
  }
  .bottom-tip {
    font-size: 26rpx;
    display: block;
    color: #999;
    width: 90%;
    margin: 0 auto 30rpx;
  }

  .sign-box {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1;
    background: #fff;

    .title {
      font-size: 40rpx;
      font-weight: bold;
      white-space: nowrap;
      letter-spacing: 12rpx;
      width: 110rpx;
      min-width: 110rpx;
      display: flex;
      align-items: center;
      justify-content: center;
      position: relative;

      .text {
        position: absolute;
        top: 0;
        bottom: 0;
        right: -60rpx;
        transform: rotate(90deg);
        width: 200rpx;
      }
    }

    .canvas {
      border: 2rpx dashed #ccc;
      border-radius: 20rpx;
      overflow: hidden;
      cursor: crosshair;
    }

    .btn {
      width: 120rpx;
      min-width: 120rpx;
      white-space: nowrap;
      height: 100%;
      display: grid;

      .item {
        flex: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 34rpx;
        background: none;
        box-sizing: border-box;
        padding: 0;
        letter-spacing: 10rpx;
        position: relative;
        width: 100%;
        color: #666;
        cursor: pointer;

        .text {
          display: block;
          transform: rotate(90deg);
        }

        &.active {
          color: $uni-main-color;
        }

        &:after {
          content: '';
          position: absolute;
          top: 0;
          background: #eee;
          width: 100%;
          left: 50%;
          transform: translate(-50%, 0%);
          height: 1rpx;
          display: block;
        }

        &:first-child {
          &:after {
            display: none;
          }
        }
      }
    }
  }
</style>

 

posted @ 2024-07-11 11:39  蓦然JL  阅读(223)  评论(0编辑  收藏  举报
访问主页
关注我
关注微博
私信我
返回顶部