Vite 构建 Vue3 组件库之路: 手写签名组件

344 阅读6分钟

场景应用示例

image.png

需求分析

签名组件在许多应用场景中都非常有用,比如在线合同签署、电子表格签名等. 如何在网页上实现手写签名模拟功能,主要涉及以下几个方面:

  1. 创建画布
    • 提供一个可进行手写操作的画布,该画布应具备自动适应父级组件宽度的特性。
    • 用户可以自定义画布的填充颜色,以满足不同的界面设计需求。
  2. 线条绘制(模拟手写操作)
    • 允许用户在画布上绘制线条,线条的外观需要支持自定义,包括但不限于:
      • 宽度自定义:用户可以调整线条的宽度,根据自己的喜好和需求来控制签名的粗细。
      • 颜色自定义:用户可以选择不同的颜色进行签名,以实现个性化效果。
      • 样式自定义:用户可以通过 lineStyle 函数对 CanvasRenderingContext2D 进行更高级的线条样式设置,如虚线、点线等,以提供更大的灵活性。
  3. 设备兼容性
    • 该签名组件需要同时支持 PC 端和移动端,确保用户在不同设备上都能流畅使用。这就需要处理不同的输入事件,包括鼠标事件(如 mousedownmousemovemouseupmouseleave)和触摸事件(如 touchstarttouchmovetouchend),以保证在不同的输入设备上都能正常进行手写操作。
  4. 操作功能
    • 重置画布:提供一个功能,让用户可以一键清除画布上已有的签名,将画布恢复到初始状态。这对于用户想要重新签名或修改签名时非常有用。
    • 获取签名结果:能够获取最终的签名结果,以 image/png 的格式将签名作为图像数据输出,方便后续的处理和存储,例如将签名保存到服务器或在页面上显示签名的预览。

实现细节

定义 Props

首先,我们定义了一个接口 SignaturePadProps 来接收组件的属性,这些属性将影响签名组件的外观和行为:

interface SignaturePadProps {
  className?: string;
  fillStyle?: string;
  strokeStyle?: string;
  lineWidth?: number;
  lineStyle?: (ctx: CanvasRenderingContext2D) => void
}

这里,className 允许用户为签名组件添加自定义类名,以便应用额外的CSS样式。fillStyle 用于设置画布的填充颜色,strokeStyle 用于设置线条的颜色,lineWidth 用于设置线条的宽度,而 lineStyle 是一个函数,用户可以通过它对 CanvasRenderingContext2D 进行更高级的线条样式设置,例如:

const customLineStyle = (ctx: CanvasRenderingContext2D) => {
  // 示例:设置为虚线
  ctx.setLineDash([5, 15]); 
};

通过 withDefaults(defineProps<SignaturePadProps>(), {...}) 为这些属性设置默认值,确保即使用户未传入相应属性,组件也能正常工作:

const props = withDefaults(defineProps<SignaturePadProps>(), {
  fillStyle: "#f8f9fa",
  strokeStyle: "#dc3545",
  lineWidth: 3,
});

绘制画布和画线方法

  1. 创建画布
const drawSignaturePad = () => {
  const canvas = signaturePad.value;
  if (canvas) {
    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;
    canvas2D.value = canvas.getContext("2d");
    if (canvas2D.value) {
      canvas2D.value.fillStyle = props.fillStyle;
      canvas2D.value.fillRect(0, 0, canvas.width, canvas.height);
    }
  }
};

在 drawSignaturePad 函数中,首先获取 canvas 元素的引用。然后,将其宽度和高度设置为 offsetWidth 和 offsetHeight,确保画布自适应父级元素的宽度,并根据父级元素的大小进行调整。接着,通过 getContext("2d") 获取 2D 绘图上下文。如果成功获取到上下文,将填充颜色设置为 props.fillStyle,并使用 fillRect 方法填充整个画布。

  1. 绘制线条
const writing = (
  beginX: number,
  beginY: number,
  stopX: number,
  stopY: number,
  ctx: CanvasRenderingContext2D | null,
) => {
  if (!ctx) return;
  ctx.beginPath();
  ctx.lineWidth = props.lineWidth;
  ctx.strokeStyle = props.strokeStyle;
  props.lineStyle?.(ctx);
  ctx.moveTo(beginX, beginY);
  ctx.lineTo(stopX, stopY);
  ctx.closePath();
  ctx.stroke();
};

writing 函数用于绘制线条。它接收起始坐标(beginX 和 beginY)和结束坐标(stopX 和 stopY)以及 CanvasRenderingContext2D 上下文作为参数。首先检查上下文是否存在,如果存在,开始绘制路径,设置线条宽度和颜色,调用 lineStyle 函数进行自定义样式设置(如果有的话),将路径移动到起始坐标,绘制直线到结束坐标,最后通过 stroke 方法绘制线条。

定义 defineExpose

defineExpose 方法用于将内部的方法暴露给父组件,以便在不同的业务需求下可以灵活地定制组件的布局和操作。这为组件的复用性和可扩展性提供了很大的便利。

const resetSignaturePad = () => {
  if (canvas2D.value && signaturePad.value) {
    canvas2D.value.clearRect(
      0,
      0,
      signaturePad.value.width,
      signaturePad.value.height,
    );
    drawSignaturePad();
  }
};

const getSignatureDataURL = () => {
  return signaturePad.value?.toDataURL("image/png", 1.0);
};
defineExpose({
  resetSignaturePad,
  getSignatureDataURL,
});
  • resetSignaturePad 方法:用于重置画布。它首先检查 canvas2D 和 signaturePad 是否存在,如果存在,使用 clearRect 方法清除整个画布的内容,然后调用 drawSignaturePad 函数重新绘制画布,将画布恢复到初始状态。
  • getSignatureDataURL 方法:用于获取签名的图像数据。通过 toDataURL 方法将当前画布内容转换为 image/png 格式的数据,该数据可以用于在页面上显示签名的图像或存储签名。

处理事件

可以将签名动作封装成三个方法: handleStart,handleMove,handleEnd

const handleStart = (event: Event) => {
  event.preventDefault();
  if (signaturePad.value === null) return;
  const rect = signaturePad.value.getBoundingClientRect();
  if (event instanceof TouchEvent) {
    beginX.value = event.touches[0].clientX - rect.left;
    beginY.value = event.touches[0].clientY - rect.top;
  } else {
    beginX.value = (event as MouseEvent).clientX - rect.left;
    beginY.value = (event as MouseEvent).clientY - rect.top;
  }
  isWriting.value = true;
};

const handleMove = (event: Event) => {
  event.preventDefault();
  if (isWriting.value) {
    if (signaturePad.value === null) return;
    const rect = signaturePad.value.getBoundingClientRect();
    let stopX: number, stopY: number;
    if (event instanceof TouchEvent) {
      stopX = event.touches[0].clientX - rect.left;
      stopY = event.touches[0].clientY - rect.top;
    } else {
      stopX = (event as MouseEvent).clientX - rect.left;
      stopY = (event as MouseEvent).clientY - rect.top;
    }
    writing(beginX.value, beginY.value, stopX, stopY, canvas2D.value);
    beginX.value = stopX;
    beginY.value = stopY;
  }
};

const handleEnd = () => {
  isWriting.value = false;
};
  • handleStart 方法:处理开始绘制的事件(mousedown 或 touchstart)。首先阻止默认行为,标记 isWriting 为 true 表示开始绘制。根据不同的事件类型(鼠标或触摸)计算起始坐标,考虑了 signaturePad 元素的偏移位置。
  • handleMove 方法:处理绘制过程中的移动事件(mousemove 或 touchmove)。在绘制状态下,根据当前事件类型计算当前坐标,调用 writing 函数绘制线条,并更新起始坐标为当前坐标,以实现连续绘制。
  • handleEnd 方法:处理结束绘制的事件(mouseupmouseleave 或 touchend),将 isWriting 标记为 false,表示绘制结束。

监听事件

import useEventListener from "../../hooks/useEventListener";

useEventListener(
  signaturePad,
  ["mousedown", "mousemove"],
  [[handleStart], [handleMove]],
);
useEventListener(
  signaturePad,
  ["touchstart", "touchmove"],
  [[handleStart], [handleMove]],
  {
    passive: true,
  },
);
useEventListener(
  signaturePad,
  ["mouseup", "mouseleave", "touchend"],
  handleEnd,
);
onMounted(() => {
  drawSignaturePad();
});
  • 在 onMounted 生命周期中,调用 drawSignaturePad 绘制初始画布,然后添加各种鼠标和触摸事件的监听器。
  • 在 onBeforeMount 生命周期中,移除相应的事件监听器,以防止内存泄漏和不必要的事件触发。
  • 使用 useEventListener hook 添加各种鼠标和触摸事件的监听器,减少冗余监听代码,且 hook 内部处理移除事件监听器逻辑.

组件样式

自动适应父级组件宽度,并保持默认的宽高比展示

.ld-signature-pad {
  width: 100%;
  height: auto;
  cursor: pointer;
}

这里,width: 100% 确保签名组件的宽度自适应父级组件,height: auto 让高度根据宽度自动调整,cursor: pointer 将鼠标指针样式设置为指针,提示用户可以进行交互操作。


感谢阅读,敬请斧正!