l-signature.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. <template>
  2. <view class="lime-signature" v-if="show" :style="[canvasStyle, styles]" ref="limeSignature">
  3. <!-- #ifndef APP-VUE || APP-NVUE -->
  4. <canvas v-if="useCanvas2d" class="lime-signature__canvas" :id="canvasId" type="2d"
  5. :disableScroll="disableScroll" @touchstart="touchStart" @touchmove="touchMove"
  6. @touchend="touchEnd"></canvas>
  7. <canvas v-else :disableScroll="disableScroll" class="lime-signature__canvas" :canvas-id="canvasId"
  8. :id="canvasId" :width="canvasWidth" :height="canvasHeight" @touchstart="touchStart" @touchmove="touchMove"
  9. @touchend="touchEnd" @mousedown="touchStart" @mousemove="touchMove" @mouseup="touchEnd"></canvas>
  10. <canvas class="offscreen" canvas-id="offscreen" id="offscreen"
  11. :style="'width:' + offscreenSize[0] + 'px;height:' + offscreenSize[1] + 'px'" :width="offscreenSize[0]"
  12. :height="offscreenSize[1]">
  13. </canvas>
  14. <view v-if="showMask" class="mask" @touchstart="touchStart" @touchmove.stop.prevent="touchMove" @touchend="touchEnd"></view>
  15. <!-- #endif -->
  16. <!-- #ifdef APP-VUE -->
  17. <view :id="canvasId" :disableScroll="disableScroll" :rparam="param" :change:rparam="sign.update"
  18. :rclear="rclear"
  19. :change:rclear="sign.clear"
  20. :rundo="rundo"
  21. :rredo="rredo"
  22. :change:rredo="sign.redo"
  23. :change:rundo="sign.undo"
  24. :rsave="rsave"
  25. :rmask="rmask"
  26. :change:rsave="sign.save"
  27. :change:rmask="sign.mask"
  28. :rdestroy="rdestroy"
  29. :change:rdestroy="sign.destroy"
  30. :rempty="rempty"
  31. :change:rempty="sign.isEmpty">
  32. </view>
  33. <!-- #endif -->
  34. <!-- #ifdef APP-NVUE -->
  35. <web-view src="/uni_modules/lime-signature/hybrid/html/index.html" class="lime-signature__canvas" ref="webview"
  36. @pagefinish="onPageFinish" @error="onError" @onPostMessage="onMessage"></web-view>
  37. <!-- #endif -->
  38. </view>
  39. </template>
  40. <script>
  41. // #ifndef APP-NVUE
  42. import { canIUseCanvas2d, wrapEvent, requestAnimationFrame, sleep, isTransparent} from './utils'
  43. import {Signature} from './signature.js'
  44. // import {Signature} from '@signature';
  45. import { uniContext, createImage, toDataURL } from './context'
  46. // #endif
  47. import props from './props';
  48. import { base64ToPath, getRect } from './utils'
  49. /**
  50. * LimeSignature 手写板签名
  51. * @description 手写板签名插件:一款能跑在uniapp各端中的签名插件,支持横屏、背景色、笔画颜色、笔画大小等功能,可生成有内容的区域,减小图片尺寸,节省空间。
  52. * @tutorial https://ext.dcloud.net.cn/plugin?id=4354
  53. * @property {Number} penSize 画笔大小
  54. * @property {Number} minLineWidth 线条最小宽
  55. * @property {Number} maxLineWidth 线条最大宽
  56. * @property {String} penColor 画笔颜色
  57. * @property {String} backgroundColor 背景颜色,不填则为透明
  58. * @property {type} 指定 canvas 类型
  59. * @value 2d canvas 2d
  60. * @value '' 非 canvas 2d 旧接口,微信不再维护
  61. * @property {Boolean} openSmooth 模拟笔锋
  62. * @property {Number} beforeDelay 延时初始化,在放在弹窗里可以使用 (毫秒)
  63. * @property {Number} maxHistoryLength 限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能
  64. * @property {Boolean} landscape 横屏,使用后在最后生成图片时会图片旋转90度
  65. * @property {Boolean} disableScroll 当在写字时,禁止屏幕滚动以及下拉刷新,nvue无效
  66. * @property {Boolean} boundingBox 只生成内容区域,即未画部分不生成,有性能的损耗
  67. */
  68. export default {
  69. props,
  70. data() {
  71. return {
  72. canvasWidth: null,
  73. canvasHeight: null,
  74. offscreenWidth: null,
  75. offscreenHeight: null,
  76. useCanvas2d: true,
  77. show: true,
  78. offscreenStyles: '',
  79. showMask: false,
  80. isPC: false,
  81. // #ifdef APP-PLUS
  82. rclear: 0,
  83. rdestroy: 0,
  84. rundo: 0,
  85. rredo: 0,
  86. rsave: JSON.stringify({
  87. n: 0,
  88. fileType: 'png',
  89. quality: 1,
  90. destWidth: 0,
  91. destHeight: 0,
  92. }),
  93. rmask: JSON.stringify({
  94. n: 0,
  95. destWidth: 0,
  96. destHeight: 0,
  97. }),
  98. rempty: 0,
  99. risEmpty: true,
  100. toDataURL: null,
  101. tempFilePath: [],
  102. // #endif
  103. }
  104. },
  105. computed: {
  106. canvasId() {
  107. // #ifdef VUE2
  108. return `lime-signature${this._uid}`
  109. // #endif
  110. // #ifdef VUE3
  111. return `lime-signature${this._.uid}`
  112. // #endif
  113. },
  114. offscreenId() {
  115. return this.canvasId + 'offscreen'
  116. },
  117. offscreenSize() {
  118. const {offscreenWidth,offscreenHeight} = this
  119. return this.landscape ? [offscreenHeight, offscreenWidth] : [offscreenWidth, offscreenHeight]
  120. },
  121. canvasStyle() {
  122. const { canvasWidth, canvasHeight, backgroundColor } = this
  123. return {
  124. width: canvasWidth && (canvasWidth + 'px'),
  125. height: canvasHeight && (canvasHeight + 'px'),
  126. background: backgroundColor
  127. }
  128. },
  129. param() {
  130. const {
  131. penColor,
  132. penSize,
  133. backgroundColor,
  134. backgroundImage,
  135. landscape,
  136. boundingBox,
  137. openSmooth,
  138. minLineWidth,
  139. maxLineWidth,
  140. minSpeed,
  141. maxWidthDiffRate,
  142. maxHistoryLength,
  143. disableScroll,
  144. disabled
  145. } = this
  146. return JSON.parse(JSON.stringify({
  147. penColor,
  148. penSize,
  149. backgroundColor,
  150. backgroundImage,
  151. landscape,
  152. boundingBox,
  153. openSmooth,
  154. minLineWidth,
  155. maxLineWidth,
  156. minSpeed,
  157. maxWidthDiffRate,
  158. maxHistoryLength,
  159. disableScroll,
  160. disabled
  161. }))
  162. }
  163. },
  164. // #ifdef APP-NVUE
  165. watch: {
  166. param(v) {
  167. this.$refs.webview.evalJS(`update(${JSON.stringify(v)})`)
  168. }
  169. },
  170. // #endif
  171. // #ifndef APP-PLUS
  172. created() {
  173. const {platform} = uni.getSystemInfoSync()
  174. this.isPC = /windows|mac/.test(platform)
  175. this.useCanvas2d = this.type == '2d' && canIUseCanvas2d() && !this.isPC
  176. // #ifndef H5
  177. this.showMask = this.isPC
  178. // #endif
  179. },
  180. // #endif
  181. // #ifndef APP-PLUS
  182. async mounted() {
  183. if (this.beforeDelay) {
  184. await sleep(this.beforeDelay)
  185. }
  186. const config = await this.getContext()
  187. this.signature = new Signature(config)
  188. this.canvasEl = this.signature.canvas.get('el')
  189. this.offscreenWidth = this.canvasWidth = this.signature.canvas.get('width')
  190. this.offscreenHeight = this.canvasHeight = this.signature.canvas.get('height')
  191. this.stopWatch = this.$watch('param', (v) => {
  192. this.signature.pen.setOption(v)
  193. }, {
  194. immediate: true
  195. })
  196. },
  197. // #endif
  198. // #ifndef APP-PLUS
  199. // #ifdef VUE3
  200. beforeUnmount() {
  201. this.stopWatch && this.stopWatch()
  202. this.signature.destroy()
  203. this.signature = null
  204. this.show = false;
  205. // #ifdef APP-VUE || APP-NVUE
  206. this.rdestroy++
  207. // #endif
  208. },
  209. // #endif
  210. // #ifdef VUE2
  211. beforeDestroy() {
  212. this.stopWatch && this.stopWatch()
  213. this.signature.destroy()
  214. this.show = false;
  215. this.signature = null
  216. // #ifdef APP-VUE || APP-NVUE
  217. this.rdestroy++
  218. // #endif
  219. },
  220. // #endif
  221. // #endif
  222. methods: {
  223. // #ifdef MP-QQ
  224. // toJSON() { return this },
  225. // #endif
  226. // #ifdef APP-PLUS
  227. onPageFinish() {
  228. this.$refs.webview.evalJS(`update(${JSON.stringify(this.param)})`)
  229. },
  230. onMessage(e = {}) {
  231. const {
  232. detail: {
  233. data: [res]
  234. }
  235. } = e
  236. if (res.event?.save) {
  237. this.toDataURL = res.event.save
  238. }
  239. if (res.event?.changeSize) {
  240. const {
  241. width,
  242. height
  243. } = res.event.changeSize
  244. }
  245. if (res.event.hasOwnProperty('isEmpty')) {
  246. this.risEmpty = res.event.isEmpty
  247. }
  248. if (res.event?.file) {
  249. this.tempFilePath.push(res.event.file)
  250. if (this.tempFilePath.length > 7) {
  251. this.tempFilePath.shift()
  252. }
  253. return
  254. }
  255. if (res.event?.success) {
  256. if (res.event.success) {
  257. this.tempFilePath.push(res.event.success)
  258. if (this.tempFilePath.length > 8) {
  259. this.tempFilePath.shift()
  260. }
  261. this.toDataURL = this.tempFilePath.join('')
  262. this.tempFilePath = []
  263. } else {
  264. this.$emit('fail', 'canvas no data')
  265. }
  266. return
  267. }
  268. },
  269. // #endif
  270. redo(){
  271. // #ifdef APP-VUE || APP-NVUE
  272. this.rredo += 1
  273. // #endif
  274. // #ifdef APP-NVUE
  275. this.$refs.webview.evalJS(`redo()`)
  276. // #endif
  277. // #ifndef APP-VUE
  278. if (this.signature)
  279. this.signature.redo()
  280. // #endif
  281. },
  282. restore() {
  283. this.redo()
  284. },
  285. undo() {
  286. // #ifdef APP-VUE || APP-NVUE
  287. this.rundo += 1
  288. // #endif
  289. // #ifdef APP-NVUE
  290. this.$refs.webview.evalJS(`undo()`)
  291. // #endif
  292. // #ifndef APP-VUE
  293. if (this.signature)
  294. this.signature.undo()
  295. // #endif
  296. },
  297. clear() {
  298. // #ifdef APP-VUE || APP-NVUE
  299. this.rclear += 1
  300. // #endif
  301. // #ifdef APP-NVUE
  302. this.$refs.webview.evalJS(`clear()`)
  303. // #endif
  304. // #ifndef APP-VUE
  305. if (this.signature)
  306. this.signature.clear()
  307. // #endif
  308. },
  309. isEmpty() {
  310. // #ifdef APP-NVUE
  311. this.$refs.webview.evalJS(`isEmpty()`)
  312. // #endif
  313. // #ifdef APP-VUE || APP-NVUE
  314. this.rempty += 1
  315. // #endif
  316. // #ifndef APP-VUE || APP-NVUE
  317. return this.signature.isEmpty()
  318. // #endif
  319. },
  320. canvasToMaskPath(param = {}){
  321. const isEmpty = this.isEmpty()
  322. // #ifdef APP-NVUE
  323. this.$refs.webview.evalJS(`mask(${JSON.stringify(param)})`)
  324. // #endif
  325. // #ifdef APP-VUE || APP-NVUE
  326. const stopURLWatch = this.$watch('toDataURL', (v, n) => {
  327. if (v && v !== n) {
  328. // if(param.pathType == 'url') {
  329. base64ToPath(v).then(res => {
  330. param.success({
  331. tempFilePath: res,
  332. isEmpty: this.risEmpty
  333. })
  334. })
  335. // } else {
  336. // param.success({tempFilePath: v,isEmpty: this.risEmpty })
  337. // }
  338. this.toDataURL = ''
  339. }
  340. stopURLWatch && stopURLWatch()
  341. })
  342. const {
  343. fileType,
  344. quality
  345. } = param
  346. const rmask = JSON.parse(this.rmask)
  347. rmask.n++
  348. rmask.destWidth = param.destWidth??0
  349. rmask.destHeight = param.destHeight??0
  350. // rmask.fileType = fileType
  351. // rmask.quality = quality
  352. this.rmask = JSON.stringify(rmask)
  353. // #endif
  354. // #ifndef APP-VUE || APP-NVUE
  355. let width = this.signature.canvas.get('width')
  356. let height = this.signature.canvas.get('height')
  357. let {pixelRatio} = uni.getSystemInfoSync()
  358. if(this.useCanvas2d){
  359. this.offscreenWidth = width * pixelRatio
  360. this.offscreenHeight = height * pixelRatio
  361. }
  362. const context = uni.createCanvasContext('offscreen', this)
  363. const success = (success) => param.success && param.success(success)
  364. const fail = (fail) => param.fail && param.fail(fail)
  365. this.signature.pen.getMaskedImageData((imageData)=>{
  366. uni.canvasPutImageData({
  367. canvasId: 'offscreen',
  368. x: 0,
  369. y: 0,
  370. width:Math.floor(this.offscreenWidth),
  371. height:Math.floor(this.offscreenHeight),
  372. data: imageData,
  373. fail(err){
  374. fail(err)
  375. },
  376. success: (res) => {
  377. toDataURL('offscreen', this, param).then((res) => {
  378. const size = Math.max(this.offscreenWidth, this.offscreenHeight)
  379. context.restore()
  380. context.clearRect(0, 0, size, size)
  381. this.offscreenWidth = width
  382. this.offscreenHeight = height
  383. success({
  384. tempFilePath: res,
  385. isEmpty
  386. })
  387. })
  388. }
  389. }, this)
  390. })
  391. // #endif
  392. },
  393. canvasToTempFilePath(param = {}) {
  394. const isEmpty = this.isEmpty()
  395. // #ifdef APP-NVUE
  396. this.$refs.webview.evalJS(`save(${JSON.stringify(param)})`)
  397. // #endif
  398. // #ifdef APP-VUE || APP-NVUE
  399. const stopURLWatch = this.$watch('toDataURL', (v, n) => {
  400. if (v && v !== n) {
  401. if(this.preferToDataURL){
  402. param.success({tempFilePath: v,isEmpty: this.risEmpty })
  403. } else {
  404. base64ToPath(v).then(res => {
  405. param.success({
  406. tempFilePath: res,
  407. isEmpty: this.risEmpty
  408. })
  409. })
  410. }
  411. this.toDataURL = ''
  412. }
  413. stopURLWatch && stopURLWatch()
  414. })
  415. const {
  416. fileType,
  417. quality
  418. } = param
  419. const rsave = JSON.parse(this.rsave)
  420. rsave.n++
  421. rsave.fileType = fileType
  422. rsave.quality = quality
  423. rsave.destWidth = param.destWidth??0
  424. rsave.destHeight = param.destHeight??0
  425. this.rsave = JSON.stringify(rsave)
  426. // #endif
  427. // #ifndef APP-VUE || APP-NVUE
  428. const useCanvas2d = this.useCanvas2d
  429. const success = (success) => param.success && param.success(success)
  430. const fail = (err) => param.fail && param.fail(err)
  431. const { canvas } = this.signature.canvas.get('el')
  432. const {
  433. backgroundColor,
  434. landscape,
  435. boundingBox
  436. } = this
  437. let width = this.signature.canvas.get('width')
  438. let height = this.signature.canvas.get('height')
  439. let x = 0
  440. let y = 0
  441. const devtools = uni.getSystemInfoSync().platform == 'devtools'
  442. let preferToDataURL = this.preferToDataURL
  443. let scale = 1
  444. // #ifdef MP-TOUTIAO
  445. scale = devtools ? uni.getSystemInfoSync().pixelRatio : scale
  446. // 由于抖音不支持canvasToTempFilePath故优先使用createOffscreenCanvas
  447. preferToDataURL = true
  448. // #endif
  449. const canvasToTempFilePath = (image) => {
  450. const createCanvasContext = ()=>{
  451. const useOffscreen = (useCanvas2d && !!uni.createOffscreenCanvas && preferToDataURL)
  452. if(useOffscreen && !devtools){
  453. const offCanvas = uni.createOffscreenCanvas({type: '2d'});
  454. offCanvas.width = this.offscreenSize[0]*scale
  455. offCanvas.height = this.offscreenSize[1]*scale
  456. const context = offCanvas.getContext("2d");
  457. return [context, offCanvas]
  458. } else {
  459. const context = uni.createCanvasContext('offscreen', this)
  460. return [context]
  461. }
  462. }
  463. const [context, offCanvas] = createCanvasContext()
  464. context.save()
  465. context.setTransform(1, 0, 0, 1, 0, 0)
  466. if (landscape) {
  467. context.translate(0, width*scale)
  468. context.rotate(-Math.PI / 2)
  469. }
  470. if (backgroundColor && !isTransparent(backgroundColor)) {
  471. context.fillStyle = backgroundColor
  472. context.fillRect(0, 0, width, height)
  473. }
  474. if(offCanvas){
  475. const img = canvas.createImage();
  476. img.src = image
  477. img.onload = () => {
  478. context.drawImage(img, 0, 0, width*scale, height*scale);
  479. const tempFilePath = offCanvas.toDataURL()
  480. success({
  481. tempFilePath,
  482. isEmpty
  483. })
  484. }
  485. } else {
  486. context.drawImage(image, 0, 0, width*scale, height*scale);
  487. context.draw(false, () => {
  488. toDataURL('offscreen', this, param).then((res) => {
  489. const size = Math.max(width, height)
  490. context.restore()
  491. context.clearRect(0, 0, size, size)
  492. success({
  493. tempFilePath: res,
  494. isEmpty
  495. })
  496. })
  497. })
  498. }
  499. }
  500. const next = async () => {
  501. if(this.offscreenWidth != width || this.offscreenHeight != height) {
  502. this.offscreenWidth = width
  503. this.offscreenHeight = height
  504. await sleep(100)
  505. }
  506. // #ifndef MP-WEIXIN
  507. const param = { x, y, width, height, canvas, preferToDataURL }
  508. // #endif
  509. // #ifdef MP-WEIXIN
  510. const param = { x, y, width, height, canvas: useCanvas2d ? canvas : null, preferToDataURL }
  511. // #endif
  512. toDataURL(this.canvasId, this, param).then(canvasToTempFilePath).catch(fail)
  513. }
  514. // PC端小程序获取不到 ImageData 数据,长度为0
  515. if (boundingBox && !this.isPC) {
  516. this.signature.getContentBoundingBox(async res => {
  517. this.offscreenWidth = width = res.width
  518. this.offscreenHeight = height = res.height
  519. x = res.startX
  520. y = res.startY
  521. next()
  522. })
  523. } else {
  524. next()
  525. }
  526. // #endif
  527. },
  528. // #ifndef APP-PLUS
  529. getContext() {
  530. return getRect(`#${this.canvasId}`, {
  531. context: this,
  532. type: this.useCanvas2d ? 'fields' : 'boundingClientRect'
  533. }).then(res => {
  534. if (res) {
  535. let { width, height, node: canvas, left, top, right} = res
  536. let {pixelRatio} = uni.getSystemInfoSync()
  537. let context;
  538. if (canvas) {
  539. context = canvas.getContext('2d')
  540. canvas.width = width * pixelRatio;
  541. canvas.height = height * pixelRatio;
  542. } else {
  543. pixelRatio = 1
  544. context = uniContext(this.canvasId, this)
  545. canvas = {
  546. getContext: (type)=> type=='2d' ? context : null,
  547. createImage,
  548. toDataURL: () => toDataURL(this.canvasId, this),
  549. requestAnimationFrame
  550. }
  551. }
  552. // 支付宝小程序 使用stroke有个默认背景色
  553. context.clearRect(0, 0, width, height)
  554. return {
  555. left,
  556. top,
  557. right,
  558. width,
  559. height,
  560. context,
  561. canvas,
  562. pixelRatio
  563. };
  564. }
  565. })
  566. },
  567. getTouch(e) {
  568. if(this.isPC && this.canvasRect) {
  569. e.touches = e.touches.map(item => {
  570. return {
  571. ...item,
  572. x: item.clientX - this.canvasRect.left,
  573. y: item.clientY - this.canvasRect.top,
  574. }
  575. })
  576. }
  577. return e
  578. },
  579. touchStart(e) {
  580. if (!this.canvasEl ) return
  581. this.isStart = true
  582. // 微信小程序PC端不支持事件,使用这方法模拟一下
  583. if(this.isPC) {
  584. getRect(`#${this.canvasId}`, {context: this}).then(res => {
  585. this.canvasRect = res
  586. this.canvasEl.dispatchEvent('touchstart', wrapEvent(this.getTouch(e)))
  587. })
  588. return
  589. }
  590. this.canvasEl.dispatchEvent('touchstart', wrapEvent(e))
  591. },
  592. touchMove(e) {
  593. if (!this.canvasEl || !this.isStart && this.canvasEl ) return
  594. this.canvasEl.dispatchEvent('touchmove', wrapEvent(this.getTouch(e)))
  595. },
  596. touchEnd(e) {
  597. if (!this.canvasEl ) return
  598. this.isStart = false
  599. this.canvasEl.dispatchEvent('touchend', wrapEvent(e))
  600. },
  601. // #endif
  602. }
  603. }
  604. </script>
  605. <!-- #ifdef APP-VUE -->
  606. <script module="sign" lang="renderjs">
  607. import sign from './render'
  608. export default sign
  609. </script>
  610. <!-- #endif -->
  611. <style lang="scss">
  612. .lime-signature,
  613. .lime-signature__canvas {
  614. /* #ifndef APP-NVUE */
  615. position: relative;
  616. width: 100%;
  617. height: 100%;
  618. /* #endif */
  619. /* #ifdef APP-NVUE */
  620. flex: 1;
  621. /* #endif */
  622. }
  623. .mask {
  624. position: absolute;
  625. left: 0;
  626. right: 0;
  627. bottom: 0;
  628. top: 0;
  629. }
  630. .offscreen {
  631. position: fixed;
  632. top: 0;
  633. left: 9999px;
  634. }
  635. </style>