u-signature.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. <template>
  2. <view
  3. class="u-signature"
  4. :class="[landscape ? 'u-signature-landscape' : '']"
  5. :style="[containerStyle, $u.addStyle(customStyle)]"
  6. @touchmove.prevent.stop
  7. @wheel.prevent.stop
  8. >
  9. <view v-if="showTitle" class="u-signature__title" :class="[{'u-signature__title-fixed': fixed}]">
  10. <slot name="title">
  11. <text class="u-signature__title-text">{{ title }}</text>
  12. </slot>
  13. </view>
  14. <view class="u-signature__canvas">
  15. <u-canvas
  16. ref="canvasRef"
  17. width="100%"
  18. height="100%"
  19. :disable-scroll="disableScroll"
  20. @onTouchstart="onCanvasTouchStart"
  21. @onTouchmove="onCanvasTouchMove"
  22. @onTouchend="onCanvasTouchEnd"
  23. @onTouchcancel="onCanvasTouchCancel"
  24. :customStyle="{
  25. display: 'block',
  26. touchAction: 'none',
  27. flex: 1,
  28. backgroundColor: backgroundColor
  29. }"
  30. />
  31. </view>
  32. <view v-if="showToolbar"
  33. class="u-signature__toolbar"
  34. :class="[{'u-signature__toolbar-fixed': fixed}]"
  35. :style="[$u.addStyle(toolbarStyle)]"
  36. >
  37. <slot name="toolbar">
  38. <view class="u-signature__toolbar-left">
  39. <view v-if="showColorList" class="u-signature__toolbar-item">
  40. <view class="u-signature__toolbar-color-list">
  41. <view
  42. v-for="(item, index) in penColorList"
  43. :key="index"
  44. class="u-signature__toolbar-color"
  45. :style="[
  46. {
  47. backgroundColor: penColorInner === item ? 'transparent' : item,
  48. borderColor: item,
  49. },
  50. ]"
  51. @click="handlePenColor(item)"
  52. ></view>
  53. </view>
  54. </view>
  55. </view>
  56. <view class="u-signature__toolbar-right">
  57. <view v-if="showClose" class="u-signature__toolbar-item">
  58. <u-button
  59. type="info"
  60. icon="close-circle"
  61. @click="handleClose"
  62. :text="closeText"
  63. />
  64. </view>
  65. <view v-if="showClear" class="u-signature__toolbar-item">
  66. <u-button
  67. type="info"
  68. icon="trash"
  69. @click="handleClear"
  70. :text="clearText"
  71. />
  72. </view>
  73. <view v-if="showUndo" class="u-signature__toolbar-item">
  74. <u-button
  75. type="info"
  76. icon="back"
  77. @click="handleUndo"
  78. :text="undoText"
  79. />
  80. </view>
  81. <view class="u-signature__toolbar-item">
  82. <u-button
  83. type="primary"
  84. @click="handleConfirm"
  85. :text="confirmText"
  86. />
  87. </view>
  88. </view>
  89. </slot>
  90. </view>
  91. </view>
  92. </template>
  93. <script>
  94. import props from './props.js';
  95. import mixin from '../../libs/mixin/mixin';
  96. import mpMixin from '../../libs/mixin/mpMixin';
  97. /**
  98. * Signature 签名组件
  99. * @description 可用于业务签名等场景
  100. * @tutorial https://uview.d3u.cn/components/signature.html
  101. *
  102. * @property {Number} penSize 画笔大小 (默认 2 )
  103. * @property {Number} minLineWidth 线条最小宽度 (默认 2 )
  104. * @property {Number} maxLineWidth 线条最大宽度 (默认 6 )
  105. * @property {String} penColor 画笔颜色 (默认 'black' )
  106. * @property {String} backgroundColor 背景颜色 (默认 '' )
  107. * @property {String} type canvas类型 (默认 '2d' )
  108. * @property {Boolean} openSmooth 是否开启压感 (默认 false )
  109. * @property {Number} maxHistoryLength 最大历史记录数 (默认 20 )
  110. * @property {Boolean} landscape 是否横屏 (默认 false )
  111. * @property {Boolean} disableScroll 是否禁用滚动 (默认 true )
  112. * @property {Boolean} disabled 是否禁用 (默认 false )
  113. * @property {Boolean} boundingBox 只生成内容区域 (默认 false )
  114. * @property {Object} customStyle 自定义样式
  115. * @property {String} closeText 关闭按钮文本 (默认 '关闭' )
  116. * @property {String} clearText 清空按钮文本 (默认 '清空' )
  117. * @property {String} undoText 撤销按钮文本 (默认 '撤销' )
  118. * @property {String} confirmText 确认按钮文本 (默认 '确认' )
  119. * @event {Function} undo 撤销方法
  120. * @event {Function} clear 清空方法
  121. * @event {Function} getImage 保存方法
  122. * @example <u-signature :penColor="penColor" :penSize="penSize" ref="signatureRef"></u-signature>
  123. */
  124. let canvasObj = {};
  125. export default {
  126. name: 'u-signature',
  127. mixins: [mpMixin, mixin, props],
  128. data() {
  129. return {
  130. canvasId: 'signature' + uni.$u.guid(),
  131. ctx: null,
  132. canvas: null,
  133. canvasWidth: 0,
  134. canvasHeight: 0,
  135. isDrawing: false,
  136. lastPoint: null,
  137. currentStroke: [],
  138. history: [],
  139. isEmpty: true,
  140. velocityFilterWeight: 0.7,
  141. minVelocity: 0.25,
  142. currentVelocity: 0,
  143. lastTimestamp: 0,
  144. penColorInner: ''
  145. };
  146. },
  147. computed: {
  148. containerStyle() {
  149. const style = {
  150. width: '100%',
  151. height: '100%',
  152. };
  153. return style;
  154. },
  155. canvasStyle() {
  156. const style = {
  157. width: '100%',
  158. height: '100%',
  159. display: 'block',
  160. };
  161. if (
  162. this.backgroundColor &&
  163. this.backgroundColor !== 'transparent'
  164. ) {
  165. style.backgroundColor = this.backgroundColor;
  166. }
  167. return style;
  168. },
  169. },
  170. watch: {
  171. penColor: {
  172. immediate: true,
  173. handler(newVal) {
  174. this.penColorInner = newVal;
  175. },
  176. }
  177. },
  178. mounted() {
  179. this.init();
  180. },
  181. // #ifdef VUE3
  182. emits: ['clear', 'undo', 'confirm','close'],
  183. // #endif
  184. methods: {
  185. async init() {
  186. await this.$nextTick();
  187. const { canvas, width, height } = await this.$refs.canvasRef.getCanvasContext();
  188. this.ctx = canvas;
  189. this.canvasWidth = width;
  190. this.canvasHeight = height;
  191. // 设置画笔样式
  192. this.ctx.lineCap = 'round';
  193. this.ctx.lineJoin = 'round';
  194. this.ctx.strokeStyle = this.penColorInner;
  195. this.ctx.lineWidth = this.penSize;
  196. // 绘制背景和水印
  197. this.drawBackgroundAndWatermark();
  198. },
  199. // 开始绘制
  200. onCanvasTouchStart(e) {
  201. if (this.disabled) return;
  202. const touch = e.touches[0];
  203. const point = this.getTouchPoint(touch);
  204. this.isDrawing = true;
  205. this.lastPoint = point;
  206. this.currentStroke = [point];
  207. this.lastTimestamp = Date.now();
  208. this.currentVelocity = 0;
  209. },
  210. // 绘制中
  211. onCanvasTouchMove(e) {
  212. if (this.disabled || !this.isDrawing) return;
  213. const touch = e.touches[0];
  214. const point = this.getTouchPoint(touch);
  215. const now = Date.now();
  216. // 计算速度(用于压感)
  217. if (this.openSmooth && this.lastPoint) {
  218. const distance = this.getDistance(this.lastPoint, point);
  219. const timeDelta = now - this.lastTimestamp;
  220. const velocity = distance / timeDelta;
  221. this.currentVelocity =
  222. this.velocityFilterWeight * velocity +
  223. (1 - this.velocityFilterWeight) * this.currentVelocity;
  224. }
  225. this.drawLine(this.lastPoint, point);
  226. this.lastPoint = point;
  227. this.currentStroke.push(point);
  228. this.lastTimestamp = now;
  229. this.isEmpty = false;
  230. },
  231. // 结束绘制
  232. onCanvasTouchEnd(e) {
  233. if (this.disabled || !this.isDrawing) return;
  234. this.isDrawing = false;
  235. // 保存到历史记录
  236. if (this.currentStroke.length > 0) {
  237. this.saveToHistory();
  238. }
  239. },
  240. // 取消绘制
  241. onCanvasTouchCancel(e) {
  242. this.onCanvasTouchEnd(e);
  243. },
  244. // 获取触摸点坐标
  245. getTouchPoint(touch) {
  246. return {
  247. x: touch.x,
  248. y: touch.y,
  249. timestamp: Date.now(),
  250. };
  251. },
  252. // 绘制线条
  253. drawLine(from, to) {
  254. if (!this.ctx || !from || !to) return;
  255. let lineWidth = this.penSize;
  256. // 压感效果
  257. if (this.openSmooth) {
  258. const velocity = Math.max(
  259. this.currentVelocity,
  260. this.minVelocity,
  261. );
  262. lineWidth = Math.max(
  263. this.minLineWidth,
  264. Math.min(this.maxLineWidth, this.penSize / velocity),
  265. );
  266. }
  267. this.ctx.beginPath();
  268. this.ctx.moveTo(from.x, from.y);
  269. this.ctx.lineTo(to.x, to.y);
  270. this.ctx.strokeStyle = this.penColorInner;
  271. this.ctx.lineWidth = lineWidth;
  272. this.ctx.stroke();
  273. this.ctx.draw(true);
  274. },
  275. // 计算两点距离
  276. getDistance(p1, p2) {
  277. return Math.sqrt(
  278. Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
  279. );
  280. },
  281. // 绘制水印
  282. drawWatermark() {
  283. if (!this.ctx || !this.watermark.text) return;
  284. const ctx = this.ctx;
  285. const text = this.watermark.text;
  286. const fontSize = parseInt(this.watermark.fontSize) || 16;
  287. const fontFamily = this.watermark.fontFamily || 'Arial';
  288. const color = this.watermark.color || 'rgba(0, 0, 0, 0.2)';
  289. const rotate = this.watermark.rotate || -30;
  290. const spacing = this.watermark.spacing || 100;
  291. const bold = this.watermark.bold || false;
  292. const single = this.watermark.single || false;
  293. // 设置水印样式
  294. ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${fontFamily}`;
  295. ctx.fillStyle = color;
  296. ctx.textAlign = 'center';
  297. ctx.textBaseline = 'middle';
  298. if (single) {
  299. // 绘制单个居中水印
  300. const centerX = this.canvasWidth / 2;
  301. const centerY = this.canvasHeight / 2;
  302. ctx.save();
  303. ctx.translate(centerX, centerY);
  304. ctx.rotate((rotate * Math.PI) / 180);
  305. ctx.fillText(text, 0, 0);
  306. ctx.restore();
  307. } else {
  308. // 绘制网格水印
  309. const cols = Math.ceil(this.canvasWidth / spacing) + 1;
  310. const rows = Math.ceil(this.canvasHeight / spacing) + 1;
  311. // 绘制水印网格
  312. for (let row = 0; row < rows; row++) {
  313. for (let col = 0; col < cols; col++) {
  314. const x = col * spacing;
  315. const y = row * spacing;
  316. ctx.save();
  317. ctx.translate(x, y);
  318. ctx.rotate((rotate * Math.PI) / 180);
  319. ctx.fillText(text, 0, 0);
  320. ctx.restore();
  321. }
  322. }
  323. }
  324. ctx.draw(true);
  325. },
  326. // 重绘历史记录
  327. redrawHistory() {
  328. if (!this.ctx || !this.history.length) return;
  329. this.history.forEach((item) => {
  330. if (item.stroke && item.stroke.length > 1) {
  331. this.ctx.beginPath();
  332. this.ctx.moveTo(item.stroke[0].x, item.stroke[0].y);
  333. for (let i = 1; i < item.stroke.length; i++) {
  334. this.ctx.lineTo(item.stroke[i].x, item.stroke[i].y);
  335. }
  336. this.ctx.strokeStyle = this.penColorInner;
  337. this.ctx.lineWidth = this.penSize;
  338. this.ctx.stroke();
  339. }
  340. });
  341. },
  342. // 清空画布并设置背景
  343. clearCanvas() {
  344. if (!this.ctx) return;
  345. // 清空画布
  346. this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  347. // 重新设置背景色
  348. if (
  349. this.backgroundColor &&
  350. this.backgroundColor !== 'transparent'
  351. ) {
  352. this.ctx.fillStyle = this.backgroundColor;
  353. this.ctx.fillRect(
  354. 0,
  355. 0,
  356. this.canvasWidth,
  357. this.canvasHeight,
  358. );
  359. }
  360. },
  361. // 绘制背景和水印
  362. drawBackgroundAndWatermark() {
  363. this.clearCanvas();
  364. // 重新绘制水印
  365. if (this.showWatermark && this.watermark.text) {
  366. this.drawWatermark();
  367. }
  368. },
  369. // 恢复水印
  370. restoreWatermark() {
  371. if (!this.ctx) return;
  372. // 绘制背景和水印
  373. this.drawBackgroundAndWatermark();
  374. // 重绘所有笔画
  375. this.redrawHistory();
  376. // 对于非2d canvas,需要调用draw方法
  377. this.ctx.draw(true);
  378. },
  379. // 保存到历史记录
  380. saveToHistory() {
  381. if (this.maxHistoryLength <= 0) return;
  382. const imageData = {
  383. stroke: [...this.currentStroke],
  384. timestamp: Date.now(),
  385. };
  386. this.history.push(imageData);
  387. // 限制历史记录数量
  388. if (this.history.length > this.maxHistoryLength) {
  389. this.history.shift();
  390. }
  391. this.currentStroke = [];
  392. },
  393. handleClose(){
  394. this.$emit('close');
  395. },
  396. handlePenColor(color){
  397. this.penColorInner = color;
  398. this.ctx.strokeStyle = color;
  399. },
  400. handleClear(){
  401. this.clear();
  402. this.$emit('clear');
  403. },
  404. handleUndo(){
  405. this.undo();
  406. this.$emit('undo');
  407. },
  408. handleConfirm(){
  409. this.getImage().then(res => {
  410. this.$emit('confirm', res);
  411. });
  412. },
  413. // 撤销
  414. undo() {
  415. if (this.history.length === 0) return;
  416. this.history.pop();
  417. this.redrawFromHistory();
  418. },
  419. // 重做(从历史记录重绘)
  420. redrawFromHistory() {
  421. if (!this.ctx) return;
  422. // 绘制背景和水印
  423. this.drawBackgroundAndWatermark();
  424. // 重绘所有笔画
  425. this.redrawHistory();
  426. this.ctx.draw(true);
  427. this.isEmpty = this.history.length === 0;
  428. },
  429. // 清空
  430. clear() {
  431. if (!this.ctx) return;
  432. // 绘制背景和水印
  433. this.drawBackgroundAndWatermark();
  434. this.ctx.draw(true);
  435. this.history = [];
  436. this.currentStroke = [];
  437. this.isEmpty = true;
  438. // 触发清空事件
  439. this.$emit('clear');
  440. },
  441. // 导出图片
  442. getImage() {
  443. return new Promise((resolve, reject) => {
  444. if (this.isEmpty) {
  445. uni.showToast({
  446. title: '签名板为空',
  447. icon: 'none',
  448. });
  449. reject('签名板为空');
  450. return;
  451. }
  452. // 如果保存时不显示水印,需要临时隐藏水印
  453. let needRestoreWatermark = false;
  454. if (this.showWatermark && !this.watermark.showOnSave) {
  455. needRestoreWatermark = true;
  456. // 临时隐藏水印,只绘制背景和笔画
  457. this.clearCanvas();
  458. this.redrawHistory();
  459. this.ctx.draw(true);
  460. }
  461. let params = {
  462. width: this.canvasWidth,
  463. height: this.canvasHeight,
  464. fileType: this.fileType,
  465. quality: this.quality
  466. };
  467. // 处理boundingBox
  468. if (this.boundingBox) {
  469. params.x = 0;
  470. params.y = 0;
  471. }
  472. this.$refs.canvasRef.canvasToTempFilePath(params).then(res => {
  473. if (needRestoreWatermark) {
  474. this.restoreWatermark();
  475. }
  476. resolve(res);
  477. }).catch(err => {
  478. if (needRestoreWatermark) {
  479. this.restoreWatermark();
  480. }
  481. reject(err);
  482. });
  483. });
  484. },
  485. },
  486. };
  487. </script>
  488. <style lang="scss" scoped>
  489. @import '../../libs/css/components.scss';
  490. .u-signature {
  491. position: relative;
  492. width: 100%;
  493. height: 100%;
  494. overflow: hidden;
  495. display: flex;
  496. flex-direction: column;
  497. // 横屏模式
  498. &-landscape {
  499. flex-direction: row-reverse;
  500. }
  501. &__title{
  502. text-align: center;
  503. font-size: 15px;
  504. color: #333;
  505. padding: 10px;
  506. display: flex;
  507. align-items: center;
  508. justify-content: center;
  509. &-fixed{
  510. position: absolute;
  511. top: 0;
  512. left: 0;
  513. right: 0;
  514. z-index: 9999;
  515. background-color: rgba(255, 255, 255, 0.8);
  516. }
  517. // 横屏模式标题样式
  518. .u-signature-landscape & {
  519. width: 26px;
  520. height: 100%;
  521. flex-direction: column;
  522. align-items: center;
  523. flex-shrink: 0;
  524. &-text {
  525. width: 100vh;
  526. transform: rotate(90deg);
  527. transform-origin: center center;
  528. }
  529. }
  530. }
  531. &__canvas {
  532. width: 100%;
  533. display: block;
  534. touch-action: none;
  535. flex: 1;
  536. }
  537. &__toolbar {
  538. display: flex;
  539. flex-direction: row;
  540. justify-content: space-between;
  541. align-items: center;
  542. padding: 10px 10px 20px 10px;
  543. &-fixed{
  544. position: absolute;
  545. bottom: 0;
  546. left: 0;
  547. right: 0;
  548. z-index: 9999;
  549. background-color: rgba(255, 255, 255, 0.8);
  550. }
  551. // 横屏模式工具栏样式
  552. .u-signature-landscape & {
  553. padding: 10px;
  554. width: 34px;
  555. height: 100%;
  556. flex-direction: column;
  557. align-items: center;
  558. flex-shrink: 0;
  559. }
  560. &-color-list{
  561. display: flex;
  562. flex-direction: row;
  563. align-items: center;
  564. // 横屏模式颜色列表样式
  565. .u-signature-landscape & {
  566. flex-direction: column;
  567. margin-bottom: 10px;
  568. }
  569. }
  570. &-color{
  571. width: 15px;
  572. height: 15px;
  573. border-radius: 100px;
  574. margin: 0 3px;
  575. border: 6px solid #fff;
  576. box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
  577. // 横屏模式下的颜色选择器样式
  578. .u-signature-landscape & {
  579. margin: 5px 0;
  580. }
  581. }
  582. &-left {
  583. display: flex;
  584. flex-direction: row;
  585. align-items: center;
  586. // 横屏模式左侧工具栏样式
  587. .u-signature-landscape & {
  588. flex-direction: column;
  589. margin-top: 50px;
  590. }
  591. }
  592. &-right {
  593. display: flex;
  594. flex-direction: row;
  595. //align-items: center;
  596. // 横屏模式右侧工具栏样式
  597. .u-signature-landscape & {
  598. flex-direction: column;
  599. margin-bottom: 30px;
  600. .u-signature__toolbar-item {
  601. margin-right: 0;
  602. margin-left: -35px;
  603. height: 90px;
  604. transform: rotate(90deg);
  605. transform-origin: center center;
  606. }
  607. }
  608. }
  609. &-item {
  610. margin-right: 10px;
  611. }
  612. &-item:last-child {
  613. margin-right: 0;
  614. }
  615. }
  616. }
  617. </style>