wd-signature.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. <template>
  2. <view class="wd-signature">
  3. <view class="wd-signature__content">
  4. <!-- #ifdef MP-WEIXIN -->
  5. <canvas
  6. class="wd-signature__content-canvas"
  7. :style="canvasStyle"
  8. :width="canvasState.canvasWidth"
  9. :height="canvasState.canvasHeight"
  10. :canvas-id="canvasId"
  11. :id="canvasId"
  12. :disable-scroll="disableScroll"
  13. @touchstart="startDrawing"
  14. @touchend="stopDrawing"
  15. @touchmove="draw"
  16. type="2d"
  17. />
  18. <!-- #endif -->
  19. <!-- #ifndef MP-WEIXIN -->
  20. <canvas
  21. class="wd-signature__content-canvas"
  22. :canvas-id="canvasId"
  23. :style="canvasStyle"
  24. :width="canvasState.canvasWidth"
  25. :height="canvasState.canvasHeight"
  26. :id="canvasId"
  27. :disable-scroll="disableScroll"
  28. @touchstart="startDrawing"
  29. @touchend="stopDrawing"
  30. @touchmove="draw"
  31. />
  32. <!-- #endif -->
  33. </view>
  34. <view class="wd-signature__footer">
  35. <slot
  36. name="footer"
  37. :clear="clear"
  38. :confirm="confirmSignature"
  39. :current-step="currentStep"
  40. :revoke="revoke"
  41. :restore="restore"
  42. :can-undo="lines.length > 0"
  43. :can-redo="redoLines.length > 0"
  44. :history-list="lines"
  45. >
  46. <block v-if="enableHistory">
  47. <wd-button size="small" plain @click="revoke" :disabled="lines.length <= 0">
  48. {{ revokeText || translate('revokeText') }}
  49. </wd-button>
  50. <wd-button size="small" plain @click="restore" :disabled="redoLines.length <= 0">
  51. {{ restoreText || translate('restoreText') }}
  52. </wd-button>
  53. </block>
  54. <wd-button size="small" plain @click="clear">{{ clearText || translate('clearText') }}</wd-button>
  55. <wd-button size="small" @click="confirmSignature">{{ confirmText || translate('confirmText') }}</wd-button>
  56. </slot>
  57. </view>
  58. </view>
  59. </template>
  60. <script lang="ts">
  61. export default {
  62. name: 'wd-signature',
  63. options: {
  64. addGlobalClass: true,
  65. virtualHost: true,
  66. styleIsolation: 'shared'
  67. }
  68. }
  69. </script>
  70. <script lang="ts" setup>
  71. import { computed, getCurrentInstance, onBeforeMount, onMounted, reactive, ref, watch, type CSSProperties } from 'vue'
  72. import { addUnit, getRect, isDef, objToStyle, uuid } from '../common/util'
  73. import { signatureProps, type SignatureExpose, type SignatureResult, type Point, type Line } from './types'
  74. import { useTranslate } from '../composables/useTranslate'
  75. // #ifdef MP-WEIXIN
  76. import { canvas2dAdapter } from '../common/canvasHelper'
  77. // #endif
  78. const props = defineProps(signatureProps)
  79. const emit = defineEmits(['start', 'end', 'signing', 'confirm', 'clear'])
  80. const { translate } = useTranslate('signature')
  81. const { proxy } = getCurrentInstance() as any
  82. const canvasId = ref<string>(`signature${uuid()}`) // canvas 组件的唯一标识符
  83. let canvas: null = null //canvas对象 微信小程序生成图片必须传入
  84. const drawing = ref<boolean>(false) // 是否正在绘制
  85. const pixelRatio = ref<number>(1) // 像素比
  86. const canvasState = reactive({
  87. canvasWidth: 0,
  88. canvasHeight: 0,
  89. ctx: null as UniApp.CanvasContext | null // canvas上下文
  90. })
  91. /**
  92. * 判断颜色是否为透明色
  93. * @param color 颜色值(支持 rgba/hsla/hex/transparent)
  94. */
  95. function isTransparentColor(color: string | undefined): boolean {
  96. if (!color) return true
  97. const transparentKeywords = ['transparent', '#0000', '#00000000']
  98. // 标准透明关键字
  99. if (transparentKeywords.includes(color.toLowerCase())) {
  100. return true
  101. }
  102. // 匹配 rgba(r, g, b, a)
  103. const rgbaMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*(\d*\.?\d+))?\)$/i)
  104. if (rgbaMatch) {
  105. const alpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1
  106. return alpha === 0
  107. }
  108. // 匹配 hsla(h, s, l, a)
  109. const hslaMatch = color.match(/^hsla?\(\s*(\d+)(?:deg)?\s*,\s*(\d+)%\s*,\s*(\d+)%(?:\s*,\s*(\d*\.?\d+))?\)$/i)
  110. if (hslaMatch) {
  111. const alpha = hslaMatch[4] ? parseFloat(hslaMatch[4]) : 1
  112. return alpha === 0
  113. }
  114. // 匹配 #RRGGBBAA 或 #RGBA
  115. const hexMatch = color.match(/^#([0-9a-f]{8}|[0-9a-f]{4})$/i)
  116. if (hexMatch) {
  117. const hex = hexMatch[1]
  118. const alphaHex = hex.length === 8 ? hex.slice(6, 8) : hex.slice(3, 4).repeat(2)
  119. const alpha = parseInt(alphaHex, 16)
  120. return alpha === 0
  121. }
  122. return false
  123. }
  124. watch(
  125. () => props.penColor,
  126. () => {
  127. setLine()
  128. }
  129. )
  130. watch(
  131. () => props.lineWidth,
  132. () => {
  133. setLine()
  134. }
  135. )
  136. const canvasStyle = computed(() => {
  137. const style: CSSProperties = {}
  138. if (isDef(props.width)) {
  139. style.width = addUnit(props.width)
  140. }
  141. if (isDef(props.height)) {
  142. style.height = addUnit(props.height)
  143. }
  144. return `${objToStyle(style)}`
  145. })
  146. const disableScroll = computed(() => props.disableScroll)
  147. const enableHistory = computed(() => props.enableHistory)
  148. const lines = ref<Line[]>([]) // 保存所有线条
  149. const redoLines = ref<Line[]>([]) // 保存撤销的线条
  150. const currentLine = ref<Line>() // 当前正在绘制的线
  151. const currentStep = ref(0) // 当前步骤
  152. // 添加计算笔画宽度的方法
  153. function calculateLineWidth(speed: number): number {
  154. if (!props.pressure) return props.lineWidth
  155. const minSpeed = props.minSpeed || 1.5
  156. const limitedSpeed = Math.min(minSpeed * 10, Math.max(minSpeed, speed))
  157. const addWidth = ((props.maxWidth - props.minWidth) * (limitedSpeed - minSpeed)) / minSpeed
  158. const lineWidth = Math.max(props.maxWidth - addWidth, props.minWidth)
  159. return Math.min(lineWidth, props.maxWidth)
  160. }
  161. /* 获取默认笔画宽度 */
  162. const getDefaultLineWidth = () => {
  163. if (props.pressure) {
  164. // 在压感模式下,使用最大和最小宽度的平均值作为默认值
  165. return (props.maxWidth + props.minWidth) / 2
  166. }
  167. return props.lineWidth
  168. }
  169. /* 开始画线 */
  170. const startDrawing = (e: any) => {
  171. e.preventDefault()
  172. drawing.value = true
  173. setLine()
  174. emit('start', e)
  175. // 创建新线条,同时保存当前的所有绘制参数
  176. const { x, y } = e.touches[0]
  177. currentLine.value = {
  178. points: [
  179. {
  180. x,
  181. y,
  182. t: Date.now() // 使用 t 替换 width
  183. }
  184. ],
  185. color: props.penColor,
  186. width: getDefaultLineWidth(),
  187. backgroundColor: props.backgroundColor,
  188. isPressure: props.pressure // 添加笔锋模式标记
  189. }
  190. // 清空重做记录
  191. redoLines.value = []
  192. draw(e)
  193. }
  194. /* 结束画线 */
  195. const stopDrawing = (e: TouchEvent) => {
  196. e.preventDefault()
  197. drawing.value = false
  198. if (currentLine.value) {
  199. // 保存完整的线条信息,包括所有点的参数
  200. lines.value.push({
  201. ...currentLine.value,
  202. points: currentLine.value.points.map((point) => ({
  203. ...point,
  204. t: point.t,
  205. speed: point.speed,
  206. distance: point.distance,
  207. lineWidth: point.lineWidth,
  208. lastX1: point.lastX1,
  209. lastY1: point.lastY1,
  210. lastX2: point.lastX2,
  211. lastY2: point.lastY2,
  212. isFirstPoint: point.isFirstPoint
  213. }))
  214. })
  215. currentStep.value = lines.value.length
  216. }
  217. currentLine.value = undefined
  218. const { ctx } = canvasState
  219. if (ctx) ctx.beginPath()
  220. emit('end', e)
  221. }
  222. /**
  223. * 初始化 canvas
  224. * @param forceUpdate 是否强制更新
  225. */
  226. const initCanvas = (forceUpdate: boolean = false) => {
  227. // 如果不是强制更新,且已经初始化过 canvas,则不再重复初始化
  228. if (!forceUpdate && canvasState.canvasHeight && canvasState.canvasWidth) {
  229. return
  230. }
  231. getContext().then(() => {
  232. const { ctx } = canvasState
  233. if (ctx && isDef(props.backgroundColor)) {
  234. ctx.setFillStyle(props.backgroundColor)
  235. ctx.fillRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
  236. ctx.draw()
  237. }
  238. })
  239. }
  240. // 清空 canvas
  241. const clear = () => {
  242. lines.value = []
  243. redoLines.value = []
  244. currentStep.value = 0
  245. clearCanvas()
  246. emit('clear')
  247. }
  248. // 确认签名
  249. const confirmSignature = () => {
  250. canvasToImage()
  251. }
  252. //canvas划线
  253. const draw = (e: any) => {
  254. e.preventDefault()
  255. const { ctx } = canvasState
  256. if (!drawing.value || props.disabled || !ctx) return
  257. const { x, y } = e.touches[0]
  258. const point: Point = {
  259. x,
  260. y,
  261. t: Date.now()
  262. }
  263. if (currentLine.value) {
  264. const points = currentLine.value.points
  265. const prePoint = points[points.length - 1]
  266. if (prePoint.t === point.t || (prePoint.x === x && prePoint.y === y)) {
  267. return
  268. }
  269. // 计算点的速度和距离
  270. point.distance = Math.sqrt(Math.pow(point.x - prePoint.x, 2) + Math.pow(point.y - prePoint.y, 2))
  271. point.speed = point.distance / (point.t - prePoint.t || 0.1)
  272. if (props.pressure) {
  273. point.lineWidth = calculateLineWidth(point.speed)
  274. // 处理线宽变化率限制
  275. if (points.length >= 2) {
  276. const prePoint2 = points[points.length - 2]
  277. if (prePoint2.lineWidth && prePoint.lineWidth) {
  278. const rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth
  279. const maxRate = 0.2 // 最大变化率20%
  280. if (Math.abs(rate) > maxRate) {
  281. const per = rate > 0 ? maxRate : -maxRate
  282. point.lineWidth = prePoint.lineWidth * (1 + per)
  283. }
  284. }
  285. }
  286. }
  287. points.push(point)
  288. // 非笔锋模式直接使用线段连接
  289. if (!props.pressure) {
  290. ctx.beginPath()
  291. ctx.moveTo(prePoint.x, prePoint.y)
  292. ctx.lineTo(point.x, point.y)
  293. ctx.stroke()
  294. ctx.draw(true)
  295. } else if (points.length >= 2) {
  296. // 笔锋模式使用贝塞尔曲线
  297. drawSmoothLine(prePoint, point)
  298. }
  299. }
  300. emit('signing', e)
  301. }
  302. /* 重绘整个画布 */
  303. const redrawCanvas = () => {
  304. const { ctx } = canvasState
  305. if (!ctx) return
  306. // 清除画布并设置背景
  307. if (!isTransparentColor(props.backgroundColor)) {
  308. ctx.setFillStyle(props.backgroundColor as string)
  309. ctx.fillRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
  310. } else {
  311. ctx.clearRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
  312. }
  313. // 如果没有线条,只需要清空画布
  314. if (lines.value.length === 0) {
  315. ctx.draw()
  316. return
  317. }
  318. // 收集所有绘制操作,最后一次性 draw
  319. lines.value.forEach((line) => {
  320. if (!line.points.length) return
  321. ctx.setStrokeStyle(line.color)
  322. ctx.setLineJoin('round')
  323. ctx.setLineCap('round')
  324. if (line.isPressure && props.pressure) {
  325. // 笔锋模式的重绘
  326. line.points.forEach((point, index) => {
  327. if (index === 0) return
  328. const prePoint = line.points[index - 1]
  329. const dis_x = point.x - prePoint.x
  330. const dis_y = point.y - prePoint.y
  331. const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
  332. if (distance <= 2) {
  333. point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
  334. point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
  335. } else {
  336. const speed = point.speed || 0
  337. const minSpeed = props.minSpeed || 1.5
  338. const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
  339. point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
  340. point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
  341. point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
  342. point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
  343. }
  344. const lineWidth = point.lineWidth || line.width
  345. if (typeof prePoint.lastX1 === 'number') {
  346. ctx.setLineWidth(lineWidth)
  347. ctx.beginPath()
  348. ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
  349. ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
  350. ctx.stroke()
  351. if (!prePoint.isFirstPoint) {
  352. ctx.beginPath()
  353. ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
  354. ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
  355. ctx.stroke()
  356. }
  357. } else {
  358. point.isFirstPoint = true
  359. }
  360. })
  361. } else {
  362. // 非笔锋模式的重绘
  363. ctx.setLineWidth(line.width)
  364. line.points.forEach((point, index) => {
  365. if (index === 0) return
  366. const prePoint = line.points[index - 1]
  367. ctx.beginPath()
  368. ctx.moveTo(prePoint.x, prePoint.y)
  369. ctx.lineTo(point.x, point.y)
  370. ctx.stroke()
  371. })
  372. }
  373. })
  374. // 所有线条绘制完成后,一次性更新画布
  375. ctx.draw()
  376. }
  377. // 修改撤销功能
  378. const revoke = () => {
  379. if (!lines.value.length) return
  380. const step = Math.min(props.step, lines.value.length)
  381. const removedLines = lines.value.splice(lines.value.length - step)
  382. redoLines.value.push(...removedLines)
  383. currentStep.value = Math.max(0, currentStep.value - step)
  384. redrawCanvas()
  385. }
  386. // 修改恢复功能
  387. const restore = () => {
  388. if (!redoLines.value.length) return
  389. const step = Math.min(props.step, redoLines.value.length)
  390. const restoredLines = redoLines.value.splice(redoLines.value.length - step)
  391. lines.value.push(...restoredLines)
  392. currentStep.value = Math.min(lines.value.length, currentStep.value + step)
  393. redrawCanvas()
  394. }
  395. // 添加平滑线条绘制方法
  396. function drawSmoothLine(prePoint: Point, point: Point) {
  397. const { ctx } = canvasState
  398. if (!ctx) return
  399. // 计算两点间距离
  400. const dis_x = point.x - prePoint.x
  401. const dis_y = point.y - prePoint.y
  402. const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
  403. if (distance <= 2) {
  404. // 对于非常近的点,直接使用中点
  405. point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
  406. point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
  407. } else {
  408. // 根据点的速度计算控制点的偏移程度
  409. const speed = point.speed || 0
  410. const minSpeed = props.minSpeed || 1.5
  411. const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
  412. // 计算控制点
  413. point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
  414. point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
  415. point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
  416. point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
  417. }
  418. // 计算线宽
  419. const lineWidth = point.lineWidth || props.lineWidth
  420. // 绘制贝塞尔曲线
  421. if (typeof prePoint.lastX1 === 'number') {
  422. // 设置线宽
  423. ctx.setLineWidth(lineWidth)
  424. // 绘制第一段曲线
  425. ctx.beginPath()
  426. ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
  427. ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
  428. ctx.stroke()
  429. if (!prePoint.isFirstPoint) {
  430. // 绘制连接段曲线
  431. ctx.beginPath()
  432. ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
  433. ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
  434. ctx.stroke()
  435. }
  436. // 批量更新绘制内容
  437. ctx.draw(true)
  438. } else {
  439. point.isFirstPoint = true
  440. }
  441. }
  442. onMounted(() => {
  443. initCanvas()
  444. })
  445. onBeforeMount(() => {
  446. // #ifdef MP
  447. pixelRatio.value = uni.getSystemInfoSync().pixelRatio
  448. // #endif
  449. })
  450. /**
  451. * 获取canvas上下文
  452. */
  453. function getContext() {
  454. return new Promise<UniApp.CanvasContext>((resolve) => {
  455. const { ctx } = canvasState
  456. if (ctx) {
  457. return resolve(ctx)
  458. }
  459. // #ifndef MP-WEIXIN
  460. getRect(`#${canvasId.value}`, false, proxy).then((canvasRect) => {
  461. setcanvasState(canvasRect.width!, canvasRect.height!)
  462. canvasState.ctx = uni.createCanvasContext(canvasId.value, proxy)
  463. if (canvasState.ctx) {
  464. canvasState.ctx.scale(pixelRatio.value, pixelRatio.value)
  465. }
  466. resolve(canvasState.ctx)
  467. })
  468. // #endif
  469. // #ifdef MP-WEIXIN
  470. getRect(`#${canvasId.value}`, false, proxy, true).then((canvasRect: any) => {
  471. if (canvasRect && canvasRect.node && canvasRect.width && canvasRect.height) {
  472. const canvasInstance = canvasRect.node
  473. canvasState.ctx = canvas2dAdapter(canvasInstance.getContext('2d') as CanvasRenderingContext2D)
  474. canvasInstance.width = canvasRect.width * pixelRatio.value
  475. canvasInstance.height = canvasRect.height * pixelRatio.value
  476. canvasState.ctx.scale(pixelRatio.value, pixelRatio.value)
  477. canvas = canvasInstance
  478. setcanvasState(canvasRect.width, canvasRect.height)
  479. resolve(canvasState.ctx)
  480. }
  481. })
  482. // #endif
  483. })
  484. }
  485. /**
  486. * 设置 canvasState
  487. */
  488. function setcanvasState(width: number, height: number) {
  489. canvasState.canvasHeight = height * pixelRatio.value
  490. canvasState.canvasWidth = width * pixelRatio.value
  491. }
  492. /* 设置线段 */
  493. function setLine() {
  494. const { ctx } = canvasState
  495. if (ctx) {
  496. ctx.setLineWidth(getDefaultLineWidth()) // 使用新的默认宽度
  497. ctx.setStrokeStyle(props.penColor)
  498. ctx.setLineJoin('round')
  499. ctx.setLineCap('round')
  500. }
  501. }
  502. /**
  503. * canvas 绘制图片输出成文件类型
  504. */
  505. function canvasToImage() {
  506. const { fileType, quality, exportScale } = props
  507. const { canvasWidth, canvasHeight } = canvasState
  508. uni.canvasToTempFilePath(
  509. {
  510. width: canvasWidth,
  511. height: canvasHeight,
  512. destWidth: canvasWidth * exportScale,
  513. destHeight: canvasHeight * exportScale,
  514. fileType,
  515. quality,
  516. canvasId: canvasId.value,
  517. canvas: canvas,
  518. success: (res) => {
  519. const result: SignatureResult = {
  520. tempFilePath: res.tempFilePath,
  521. width: (canvasWidth * exportScale) / pixelRatio.value,
  522. height: (canvasHeight * exportScale) / pixelRatio.value,
  523. success: true
  524. }
  525. // #ifdef MP-DINGTALK
  526. result.tempFilePath = (res as any).filePath
  527. // #endif
  528. emit('confirm', result)
  529. },
  530. fail: () => {
  531. const result: SignatureResult = {
  532. tempFilePath: '',
  533. width: (canvasWidth * exportScale) / pixelRatio.value,
  534. height: (canvasHeight * exportScale) / pixelRatio.value,
  535. success: false
  536. }
  537. emit('confirm', result)
  538. }
  539. },
  540. proxy
  541. )
  542. }
  543. function clearCanvas() {
  544. const { canvasWidth, canvasHeight, ctx } = canvasState
  545. if (ctx) {
  546. ctx.clearRect(0, 0, canvasWidth, canvasHeight)
  547. if (!isTransparentColor(props.backgroundColor)) {
  548. ctx.setFillStyle(props.backgroundColor as string)
  549. ctx.fillRect(0, 0, canvasWidth, canvasHeight)
  550. }
  551. ctx.draw()
  552. }
  553. }
  554. defineExpose<SignatureExpose>({
  555. init: initCanvas,
  556. clear,
  557. confirm: confirmSignature,
  558. restore,
  559. revoke
  560. })
  561. </script>
  562. <style scoped lang="scss">
  563. @import './index.scss';
  564. </style>