| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- <template>
- <view class="wd-signature">
- <view class="wd-signature__content">
- <!-- #ifdef MP-WEIXIN -->
- <canvas
- class="wd-signature__content-canvas"
- :style="canvasStyle"
- :width="canvasState.canvasWidth"
- :height="canvasState.canvasHeight"
- :canvas-id="canvasId"
- :id="canvasId"
- :disable-scroll="disableScroll"
- @touchstart="startDrawing"
- @touchend="stopDrawing"
- @touchmove="draw"
- type="2d"
- />
- <!-- #endif -->
- <!-- #ifndef MP-WEIXIN -->
- <canvas
- class="wd-signature__content-canvas"
- :canvas-id="canvasId"
- :style="canvasStyle"
- :width="canvasState.canvasWidth"
- :height="canvasState.canvasHeight"
- :id="canvasId"
- :disable-scroll="disableScroll"
- @touchstart="startDrawing"
- @touchend="stopDrawing"
- @touchmove="draw"
- />
- <!-- #endif -->
- </view>
- <view class="wd-signature__footer">
- <slot
- name="footer"
- :clear="clear"
- :confirm="confirmSignature"
- :current-step="currentStep"
- :revoke="revoke"
- :restore="restore"
- :can-undo="lines.length > 0"
- :can-redo="redoLines.length > 0"
- :history-list="lines"
- >
- <block v-if="enableHistory">
- <wd-button size="small" plain @click="revoke" :disabled="lines.length <= 0">
- {{ revokeText || translate('revokeText') }}
- </wd-button>
- <wd-button size="small" plain @click="restore" :disabled="redoLines.length <= 0">
- {{ restoreText || translate('restoreText') }}
- </wd-button>
- </block>
- <wd-button size="small" plain @click="clear">{{ clearText || translate('clearText') }}</wd-button>
- <wd-button size="small" @click="confirmSignature">{{ confirmText || translate('confirmText') }}</wd-button>
- </slot>
- </view>
- </view>
- </template>
- <script lang="ts">
- export default {
- name: 'wd-signature',
- options: {
- addGlobalClass: true,
- virtualHost: true,
- styleIsolation: 'shared'
- }
- }
- </script>
- <script lang="ts" setup>
- import { computed, getCurrentInstance, onBeforeMount, onMounted, reactive, ref, watch, type CSSProperties } from 'vue'
- import { addUnit, getRect, isDef, objToStyle, uuid } from '../common/util'
- import { signatureProps, type SignatureExpose, type SignatureResult, type Point, type Line } from './types'
- import { useTranslate } from '../composables/useTranslate'
- // #ifdef MP-WEIXIN
- import { canvas2dAdapter } from '../common/canvasHelper'
- // #endif
- const props = defineProps(signatureProps)
- const emit = defineEmits(['start', 'end', 'signing', 'confirm', 'clear'])
- const { translate } = useTranslate('signature')
- const { proxy } = getCurrentInstance() as any
- const canvasId = ref<string>(`signature${uuid()}`) // canvas 组件的唯一标识符
- let canvas: null = null //canvas对象 微信小程序生成图片必须传入
- const drawing = ref<boolean>(false) // 是否正在绘制
- const pixelRatio = ref<number>(1) // 像素比
- const canvasState = reactive({
- canvasWidth: 0,
- canvasHeight: 0,
- ctx: null as UniApp.CanvasContext | null // canvas上下文
- })
- /**
- * 判断颜色是否为透明色
- * @param color 颜色值(支持 rgba/hsla/hex/transparent)
- */
- function isTransparentColor(color: string | undefined): boolean {
- if (!color) return true
- const transparentKeywords = ['transparent', '#0000', '#00000000']
- // 标准透明关键字
- if (transparentKeywords.includes(color.toLowerCase())) {
- return true
- }
- // 匹配 rgba(r, g, b, a)
- const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*(\d*\.?\d+))?\)$/i)
- if (rgbaMatch) {
- const alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1
- return alpha === 0
- }
- // 匹配 hsla(h, s, l, a)
- const hslaMatch = color.match(/^hsla?\(\s*(\d+)(?:deg)?\s*,\s*(\d+)%\s*,\s*(\d+)%(?:\s*,\s*(\d*\.?\d+))?\)$/i)
- if (hslaMatch) {
- const alpha = hslaMatch[4] ? parseFloat(hslaMatch[4]) : 1
- return alpha === 0
- }
- // 匹配 #RRGGBBAA 或 #RGBA
- const hexMatch = color.match(/^#([0-9a-f]{8}|[0-9a-f]{4})$/i)
- if (hexMatch) {
- const hex = hexMatch[1]
- const alphaHex = hex.length === 8 ? hex.slice(6, 8) : hex.slice(3, 4).repeat(2)
- const alpha = parseInt(alphaHex, 16)
- return alpha === 0
- }
- return false
- }
- watch(
- () => props.penColor,
- () => {
- setLine()
- }
- )
- watch(
- () => props.lineWidth,
- () => {
- setLine()
- }
- )
- const canvasStyle = computed(() => {
- const style: CSSProperties = {}
- if (isDef(props.width)) {
- style.width = addUnit(props.width)
- }
- if (isDef(props.height)) {
- style.height = addUnit(props.height)
- }
- return `${objToStyle(style)}`
- })
- const disableScroll = computed(() => props.disableScroll)
- const enableHistory = computed(() => props.enableHistory)
- const lines = ref<Line[]>([]) // 保存所有线条
- const redoLines = ref<Line[]>([]) // 保存撤销的线条
- const currentLine = ref<Line>() // 当前正在绘制的线
- const currentStep = ref(0) // 当前步骤
- // 添加计算笔画宽度的方法
- function calculateLineWidth(speed: number): number {
- if (!props.pressure) return props.lineWidth
- const minSpeed = props.minSpeed || 1.5
- const limitedSpeed = Math.min(minSpeed * 10, Math.max(minSpeed, speed))
- const addWidth = ((props.maxWidth - props.minWidth) * (limitedSpeed - minSpeed)) / minSpeed
- const lineWidth = Math.max(props.maxWidth - addWidth, props.minWidth)
- return Math.min(lineWidth, props.maxWidth)
- }
- /* 获取默认笔画宽度 */
- const getDefaultLineWidth = () => {
- if (props.pressure) {
- // 在压感模式下,使用最大和最小宽度的平均值作为默认值
- return (props.maxWidth + props.minWidth) / 2
- }
- return props.lineWidth
- }
- /* 开始画线 */
- const startDrawing = (e: any) => {
- e.preventDefault()
- drawing.value = true
- setLine()
- emit('start', e)
- // 创建新线条,同时保存当前的所有绘制参数
- const { x, y } = e.touches[0]
- currentLine.value = {
- points: [
- {
- x,
- y,
- t: Date.now() // 使用 t 替换 width
- }
- ],
- color: props.penColor,
- width: getDefaultLineWidth(),
- backgroundColor: props.backgroundColor,
- isPressure: props.pressure // 添加笔锋模式标记
- }
- // 清空重做记录
- redoLines.value = []
- draw(e)
- }
- /* 结束画线 */
- const stopDrawing = (e: TouchEvent) => {
- e.preventDefault()
- drawing.value = false
- if (currentLine.value) {
- // 保存完整的线条信息,包括所有点的参数
- lines.value.push({
- ...currentLine.value,
- points: currentLine.value.points.map((point) => ({
- ...point,
- t: point.t,
- speed: point.speed,
- distance: point.distance,
- lineWidth: point.lineWidth,
- lastX1: point.lastX1,
- lastY1: point.lastY1,
- lastX2: point.lastX2,
- lastY2: point.lastY2,
- isFirstPoint: point.isFirstPoint
- }))
- })
- currentStep.value = lines.value.length
- }
- currentLine.value = undefined
- const { ctx } = canvasState
- if (ctx) ctx.beginPath()
- emit('end', e)
- }
- /**
- * 初始化 canvas
- * @param forceUpdate 是否强制更新
- */
- const initCanvas = (forceUpdate: boolean = false) => {
- // 如果不是强制更新,且已经初始化过 canvas,则不再重复初始化
- if (!forceUpdate && canvasState.canvasHeight && canvasState.canvasWidth) {
- return
- }
- getContext().then(() => {
- const { ctx } = canvasState
- if (ctx && isDef(props.backgroundColor)) {
- ctx.setFillStyle(props.backgroundColor)
- ctx.fillRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
- ctx.draw()
- }
- })
- }
- // 清空 canvas
- const clear = () => {
- lines.value = []
- redoLines.value = []
- currentStep.value = 0
- clearCanvas()
- emit('clear')
- }
- // 确认签名
- const confirmSignature = () => {
- canvasToImage()
- }
- //canvas划线
- const draw = (e: any) => {
- e.preventDefault()
- const { ctx } = canvasState
- if (!drawing.value || props.disabled || !ctx) return
- const { x, y } = e.touches[0]
- const point: Point = {
- x,
- y,
- t: Date.now()
- }
- if (currentLine.value) {
- const points = currentLine.value.points
- const prePoint = points[points.length - 1]
- if (prePoint.t === point.t || (prePoint.x === x && prePoint.y === y)) {
- return
- }
- // 计算点的速度和距离
- point.distance = Math.sqrt(Math.pow(point.x - prePoint.x, 2) + Math.pow(point.y - prePoint.y, 2))
- point.speed = point.distance / (point.t - prePoint.t || 0.1)
- if (props.pressure) {
- point.lineWidth = calculateLineWidth(point.speed)
- // 处理线宽变化率限制
- if (points.length >= 2) {
- const prePoint2 = points[points.length - 2]
- if (prePoint2.lineWidth && prePoint.lineWidth) {
- const rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth
- const maxRate = 0.2 // 最大变化率20%
- if (Math.abs(rate) > maxRate) {
- const per = rate > 0 ? maxRate : -maxRate
- point.lineWidth = prePoint.lineWidth * (1 + per)
- }
- }
- }
- }
- points.push(point)
- // 非笔锋模式直接使用线段连接
- if (!props.pressure) {
- ctx.beginPath()
- ctx.moveTo(prePoint.x, prePoint.y)
- ctx.lineTo(point.x, point.y)
- ctx.stroke()
- ctx.draw(true)
- } else if (points.length >= 2) {
- // 笔锋模式使用贝塞尔曲线
- drawSmoothLine(prePoint, point)
- }
- }
- emit('signing', e)
- }
- /* 重绘整个画布 */
- const redrawCanvas = () => {
- const { ctx } = canvasState
- if (!ctx) return
- // 清除画布并设置背景
- if (!isTransparentColor(props.backgroundColor)) {
- ctx.setFillStyle(props.backgroundColor as string)
- ctx.fillRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
- } else {
- ctx.clearRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
- }
- // 如果没有线条,只需要清空画布
- if (lines.value.length === 0) {
- ctx.draw()
- return
- }
- // 收集所有绘制操作,最后一次性 draw
- lines.value.forEach((line) => {
- if (!line.points.length) return
- ctx.setStrokeStyle(line.color)
- ctx.setLineJoin('round')
- ctx.setLineCap('round')
- if (line.isPressure && props.pressure) {
- // 笔锋模式的重绘
- line.points.forEach((point, index) => {
- if (index === 0) return
- const prePoint = line.points[index - 1]
- const dis_x = point.x - prePoint.x
- const dis_y = point.y - prePoint.y
- const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
- if (distance <= 2) {
- point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
- point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
- } else {
- const speed = point.speed || 0
- const minSpeed = props.minSpeed || 1.5
- const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
- point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
- point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
- point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
- point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
- }
- const lineWidth = point.lineWidth || line.width
- if (typeof prePoint.lastX1 === 'number') {
- ctx.setLineWidth(lineWidth)
- ctx.beginPath()
- ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
- ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
- ctx.stroke()
- if (!prePoint.isFirstPoint) {
- ctx.beginPath()
- ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
- ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
- ctx.stroke()
- }
- } else {
- point.isFirstPoint = true
- }
- })
- } else {
- // 非笔锋模式的重绘
- ctx.setLineWidth(line.width)
- line.points.forEach((point, index) => {
- if (index === 0) return
- const prePoint = line.points[index - 1]
- ctx.beginPath()
- ctx.moveTo(prePoint.x, prePoint.y)
- ctx.lineTo(point.x, point.y)
- ctx.stroke()
- })
- }
- })
- // 所有线条绘制完成后,一次性更新画布
- ctx.draw()
- }
- // 修改撤销功能
- const revoke = () => {
- if (!lines.value.length) return
- const step = Math.min(props.step, lines.value.length)
- const removedLines = lines.value.splice(lines.value.length - step)
- redoLines.value.push(...removedLines)
- currentStep.value = Math.max(0, currentStep.value - step)
- redrawCanvas()
- }
- // 修改恢复功能
- const restore = () => {
- if (!redoLines.value.length) return
- const step = Math.min(props.step, redoLines.value.length)
- const restoredLines = redoLines.value.splice(redoLines.value.length - step)
- lines.value.push(...restoredLines)
- currentStep.value = Math.min(lines.value.length, currentStep.value + step)
- redrawCanvas()
- }
- // 添加平滑线条绘制方法
- function drawSmoothLine(prePoint: Point, point: Point) {
- const { ctx } = canvasState
- if (!ctx) return
- // 计算两点间距离
- const dis_x = point.x - prePoint.x
- const dis_y = point.y - prePoint.y
- const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
- if (distance <= 2) {
- // 对于非常近的点,直接使用中点
- point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
- point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
- } else {
- // 根据点的速度计算控制点的偏移程度
- const speed = point.speed || 0
- const minSpeed = props.minSpeed || 1.5
- const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
- // 计算控制点
- point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
- point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
- point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
- point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
- }
- // 计算线宽
- const lineWidth = point.lineWidth || props.lineWidth
- // 绘制贝塞尔曲线
- if (typeof prePoint.lastX1 === 'number') {
- // 设置线宽
- ctx.setLineWidth(lineWidth)
- // 绘制第一段曲线
- ctx.beginPath()
- ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
- ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
- ctx.stroke()
- if (!prePoint.isFirstPoint) {
- // 绘制连接段曲线
- ctx.beginPath()
- ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
- ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
- ctx.stroke()
- }
- // 批量更新绘制内容
- ctx.draw(true)
- } else {
- point.isFirstPoint = true
- }
- }
- onMounted(() => {
- initCanvas()
- })
- onBeforeMount(() => {
- // #ifdef MP
- pixelRatio.value = uni.getSystemInfoSync().pixelRatio
- // #endif
- })
- /**
- * 获取canvas上下文
- */
- function getContext() {
- return new Promise<UniApp.CanvasContext>((resolve) => {
- const { ctx } = canvasState
- if (ctx) {
- return resolve(ctx)
- }
- // #ifndef MP-WEIXIN
- getRect(`#${canvasId.value}`, false, proxy).then((canvasRect) => {
- setcanvasState(canvasRect.width!, canvasRect.height!)
- canvasState.ctx = uni.createCanvasContext(canvasId.value, proxy)
- if (canvasState.ctx) {
- canvasState.ctx.scale(pixelRatio.value, pixelRatio.value)
- }
- resolve(canvasState.ctx)
- })
- // #endif
- // #ifdef MP-WEIXIN
- getRect(`#${canvasId.value}`, false, proxy, true).then((canvasRect: any) => {
- if (canvasRect && canvasRect.node && canvasRect.width && canvasRect.height) {
- const canvasInstance = canvasRect.node
- canvasState.ctx = canvas2dAdapter(canvasInstance.getContext('2d') as CanvasRenderingContext2D)
- canvasInstance.width = canvasRect.width * pixelRatio.value
- canvasInstance.height = canvasRect.height * pixelRatio.value
- canvasState.ctx.scale(pixelRatio.value, pixelRatio.value)
- canvas = canvasInstance
- setcanvasState(canvasRect.width, canvasRect.height)
- resolve(canvasState.ctx)
- }
- })
- // #endif
- })
- }
- /**
- * 设置 canvasState
- */
- function setcanvasState(width: number, height: number) {
- canvasState.canvasHeight = height * pixelRatio.value
- canvasState.canvasWidth = width * pixelRatio.value
- }
- /* 设置线段 */
- function setLine() {
- const { ctx } = canvasState
- if (ctx) {
- ctx.setLineWidth(getDefaultLineWidth()) // 使用新的默认宽度
- ctx.setStrokeStyle(props.penColor)
- ctx.setLineJoin('round')
- ctx.setLineCap('round')
- }
- }
- /**
- * canvas 绘制图片输出成文件类型
- */
- function canvasToImage() {
- const { fileType, quality, exportScale } = props
- const { canvasWidth, canvasHeight } = canvasState
- uni.canvasToTempFilePath(
- {
- width: canvasWidth,
- height: canvasHeight,
- destWidth: canvasWidth * exportScale,
- destHeight: canvasHeight * exportScale,
- fileType,
- quality,
- canvasId: canvasId.value,
- canvas: canvas,
- success: (res) => {
- const result: SignatureResult = {
- tempFilePath: res.tempFilePath,
- width: (canvasWidth * exportScale) / pixelRatio.value,
- height: (canvasHeight * exportScale) / pixelRatio.value,
- success: true
- }
- // #ifdef MP-DINGTALK
- result.tempFilePath = (res as any).filePath
- // #endif
- emit('confirm', result)
- },
- fail: () => {
- const result: SignatureResult = {
- tempFilePath: '',
- width: (canvasWidth * exportScale) / pixelRatio.value,
- height: (canvasHeight * exportScale) / pixelRatio.value,
- success: false
- }
- emit('confirm', result)
- }
- },
- proxy
- )
- }
- function clearCanvas() {
- const { canvasWidth, canvasHeight, ctx } = canvasState
- if (ctx) {
- ctx.clearRect(0, 0, canvasWidth, canvasHeight)
- if (!isTransparentColor(props.backgroundColor)) {
- ctx.setFillStyle(props.backgroundColor as string)
- ctx.fillRect(0, 0, canvasWidth, canvasHeight)
- }
- ctx.draw()
- }
- }
- defineExpose<SignatureExpose>({
- init: initCanvas,
- clear,
- confirm: confirmSignature,
- restore,
- revoke
- })
- </script>
- <style scoped lang="scss">
- @import './index.scss';
- </style>
|