Files
taimed-international-app/uni_modules/wot-design-uni/components/wd-signature/wd-signature.vue
2025-11-04 12:37:04 +08:00

631 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>