wangguoyu 2 주 전
부모
커밋
db3f73689e

+ 81 - 0
components/writeSign/index.vue

@@ -0,0 +1,81 @@
+<template>
+	<view style="width: 750rpx ;height: 500rpx;">
+		<jp-signature ref="signatureRef"></jp-signature>
+	</view>
+	<image :src="url" alt="" />
+	<view>
+		<button @click="clear">清空</button>
+		<button @click="undo">撤消</button>
+		<button @click="save">保存</button>
+	</view>
+</template>
+
+<script>
+	export default {
+		data() {
+			return {
+				url: '',
+			}
+		},
+		methods: {
+			save() {
+				this.$refs.signatureRef.canvasToTempFilePath({
+					success: (res) => {
+						// 是否为空画板 无签名
+						console.log(res.isEmpty)
+						console.log(res)
+						this.base64(res.tempFilePath)
+
+						// 生成图片的临时路径
+						// H5 生成的是base64
+						//	this.url = res.tempFilePath
+					}
+				})
+			},
+			// 转换	 app
+			base64(tempFilePath) {
+				let base64Code;
+				let path = plus.io.convertLocalFileSystemURL(tempFilePath);
+				console.log('tempFilePath',tempFilePath);
+				console.log('path',path);
+				let fileReader = new plus.io.FileReader();
+				fileReader.readAsDataURL(path);
+				fileReader.onloadend = res => {
+					console.log('res',res);
+					base64Code = res.target.result;
+					this.url = res.target.result
+					this.$emit('getBase64',this.url)
+					console.log('base64Code',base64Code);
+				}
+		
+			},
+			// miniProgramConvertToBase64(tempFilePath) {
+			// 				uni.getFileSystemManager().readFile({
+			// 					filePath: tempFilePath,
+			// 					encoding: 'base64',
+			// 					success: (res) => {
+			// 						let base64 = 'data:image/png;base64,' + res.data
+			// 						console.log('小程序base64转换成功,长度:', base64.length)
+			// 						this.url = base64
+			// 					},
+			// 					fail: (err) => {
+			// 						console.error('小程序转换失败:', err)
+			// 						// 转换失败时保持使用临时路径
+			// 						uni.showToast({
+			// 							title: '转换失败,使用原图',
+			// 							icon: 'none'
+			// 						})
+			// 					}
+			// 				})
+			// 			},
+		
+			clear() {
+				this.$refs.signatureRef.clear()
+			},
+			undo() {
+				this.$refs.signatureRef.undo()
+			},
+
+		}
+	}
+</script>

+ 22 - 15
pages/demo/demo2.vue

@@ -1,21 +1,28 @@
 <template>
-	<button @click="handleClis">激活</button>
-	<selectZyLevel ref="selectRef" @confirm-btn="onConfirm" :id="13"></selectZyLevel>
-</template>
+	<view>
+		<writeSign @getBase64="getBase64"></writeSign>
+	</view>
 
-<script setup>
-import selectZyLevel from "@/components/selectZyLevel/index.vue"
-import {ref} from "vue"
+</template>
 
-const selectRef = ref(null)
-function handleClis() {
-	selectRef.value.handleShow()
-}
-function onConfirm(list) {
-	console.log('list', list)
-}
+<script>
+	import writeSign from "@/components/writeSign/index.vue"
+	export default {
+		data() {
+			return {
+				url: '',
+			}
+		},
+		components: {
+			writeSign
+		},
+		methods: {
+			getBase64(data) {
+					console.log('success',data);
+			}
+		}
+	}
 </script>
-
 <style>
 
-</style>
+</style>

+ 24 - 0
uni_modules/jp-signature/changelog.md

@@ -0,0 +1,24 @@
+## 3.2.0(2024-08-27)
+修复全屏签名图片未正确旋转问题
+## 3.1.0(2024-06-03)
+修复签名部分BUG
+## 3.0.4(2024-05-28)
+修改部分文档内容
+## 3.0.0(2024-05-28)
+新增jp-merge组件,及修复jp-signature-popup组件签字滚动问题
+## 2.5.2(2023-12-19)
+弹窗签名新增boundingBox属性,文档新增vue3使用方法
+## 2.5.1(2023-11-17)
+修复vue3兼容问题,新增点击图片除非事件
+## 2.5(2023-11-14)
+优化代码部分能力
+## 2.4(2023-11-14)
+新增弹框签名笔锋配置
+## 2.3(2023-11-14)
+修复滚动无法签名问题,弹框签名样式改变
+## 2.2(2023-11-14)
+修复app滚动无法签名等问题,修改了弹框签名样式
+## 2.1(2023-08-15)
+修改弹窗高度,修改实例项目
+## 2.0(2023-08-15)
+修复签名不兼容问题,改变签名方式,使用uni_modules方式

+ 103 - 0
uni_modules/jp-signature/components/jp-merge/jp-merge.vue

@@ -0,0 +1,103 @@
+<template>
+	<view class="share">
+		<canvas
+		    canvas-id="shareCanvas"
+		    class="canvas"
+		    bindlongpress="saveImg"
+		    catchtouchmove="true"
+			style="position:fixed;left:500%"
+			:style="{height: canvasHeight+'px',width:canvasWidth+'px'}"
+		   >
+		</canvas>
+	</view>
+</template>
+<!-- 有项目需要开发的请联系 扣 - 371524845 -->
+<script>
+export default {
+	props: {
+		canvasHeight: {
+			type: Number,
+			default: 400,
+		},
+		canvasWidth: {
+			type: Number,
+			default: 400,
+		},
+		width: {
+			type: Number,
+			default: 80,
+		},
+		height: {
+			type: Number,
+			default: 50,
+		},
+		left: {
+			type: Number,
+			default: 300,
+		},
+		top: {
+			type: Number,
+			default: 320,
+		},
+		bgImage: {
+			type: String,
+			default: '',
+		},
+	},
+	data(){
+		return {
+			ctx:null
+		}
+	},
+	created() {
+	//初始化画布
+	  this.ctx = wx.createCanvasContext('shareCanvas',this)
+    },
+	methods:{
+		//获取图片的基本信息,即将网络图片转成本地图片,
+		getImageInfo(src) {
+			return new Promise((resolve, reject) => {
+				wx.getImageInfo({
+					src,
+					success: (res) => resolve(res),
+					fail: (res) => reject(res)
+				})
+			});
+		},
+		exportPost(image2){
+			let that  =  this
+		   return new Promise(function (resolve, reject) {
+			let image =  that.bgImage
+			//获取系统的基本信息,为后期的画布和底图适配宽高
+			 uni.getSystemInfo({
+				success: function (res) {
+				Promise.all([that.getImageInfo(image),that.getImageInfo(image2)]).then(res=>{
+		        //获取底图和二维码图片的基本信息,通常前端导出的二维码是base64格式的,所以要转成图片格式的才可以获取图片的基本信息			
+				that.ctx.drawImage(res[0].path,0 , 0,that.canvasWidth,that.canvasHeight);
+				that.ctx.drawImage(res[1].path,that.left,that.top,that.width, that.height);
+					  that.ctx.draw(false,function(){
+						  wx.canvasToTempFilePath({
+							  x: 0,
+							  y: 0,
+							  width:that.canvasWidth,
+							  height:that.canvasHeight,
+							  destWidth:that.canvasWidth*2,//这里乘以2是为了保证合成图片的清晰度
+							  destHeight:that.canvasHeight*2,
+							  canvasId: 'shareCanvas',
+							  fileType:'jpg',//设置导出图片的后缀名
+							  success: function (res) {
+								  resolve(res.tempFilePath)
+							  },
+							  fail: function (res) {
+								  reject(res)
+							  },
+						  })   
+					  });
+					})     
+				}
+			})
+		   })
+		},
+	},
+}
+</script>

+ 328 - 0
uni_modules/jp-signature/components/jp-signature-popup/jp-signature-popup.vue

@@ -0,0 +1,328 @@
+<template>
+	<div class="signature">
+		<div class="inputs" v-if="!popup">
+			<div class="label" :class="required?'labelqr':''">{{label}}</div>
+			<div>
+				<div v-if="value" class="images">
+					<image @tap="toImg"  class="images" mode="aspectFit" :src="value"></image>
+					<view v-if="!readonly" @click="toDeleteImg" class="icons">
+						<view class="Deletes">×</view>
+					</view>
+				</div>
+				<div v-if="!value && !readonly" class="explain" @click="toPop">
+					{{placeholder?placeholder:'点击签名'}}
+				</div>
+			</div>
+		</div>
+		<view class="bottomPopup" v-if="showPopup" @touchmove.stop.prevent="moveHandle">
+				<transition name="slide-up" appear>
+					<view class="popup-content">
+						<view class="popup">
+							<div class="hader" v-if="!isHeight">
+								<div @click="toclear">取消</div>
+								<div class="text">{{label}}</div>
+								<div @click="isEmpty">确定</div>
+							</div>
+							<div :class="isHeight?'wgSignatureq':'wgSignature'">
+								<div v-if="isHeight" key="999" style="width: 750rpx ;height: 100vh;">
+									<jp-signature  :beforeDelay="200" :landscape="true" disableScroll ref="signatureRef" :openSmooth="openSmooth" :penSize="6" :bounding-box="boundingBox"></jp-signature>
+								</div>
+								<div v-else key="888" style="width: 750rpx ;height: 35vh;">
+									<jp-signature :beforeDelay="200"  disableScroll ref="signatureRef" :openSmooth="openSmooth" :bounding-box="boundingBox"  :penSize="3"></jp-signature>
+								</div>
+								  <div v-if="!isHeight" class="appBut" >
+									  <div class="buts" @click="undo" >撤销</div>
+									  <div class="buts" @click="deleteImg" >清除</div>
+									  <div class="buts" style="background-color: #55aaff;color: #fff;" @click="Tomagnify" >全屏</div>
+								  </div>
+								  <div v-else class="appBut" style="height: 80px;">
+									  <div class="butx" @click="undo" >撤销</div>
+									  <div class="butx" @click="deleteImg">清除</div>
+									  <div class="butx" style="background-color: #55aaff;color: #fff;" @click="Tomagnify" >小屏</div>
+									  <div class="butx" @click="toclear">取消</div>
+									  <div class="butx" style="background-color: #E59C36;color: #fff;"  @click="isEmpty">完成</div>
+								  </div>
+							</div>
+						</view>
+					</view>
+				</transition>
+			</view>
+		
+	</div>
+</template>
+<!-- 有项目需要开发的请联系 扣 - 371524845 -->
+<script>
+	/**
+	 * 手写签名组件
+	 * 用于手写签名(弹框签名支持小屏和全屏)
+	 *
+	 *********参数********
+	 * label        选项名称
+	 * value        初始值String(支持bas64,url 等图片显示)
+	 * required     是否显示必填
+	 * placeholder  默认值
+	 * readonly     是否只读
+	 *
+	 * *********回调********
+	 * @input(e)   点击确认   e生成的图片数据(bas64)
+	 *
+	 *********方法********
+	 * isEmpty()     生成图片
+	 * deleteImg()   删除图片
+	 */
+	export default {
+		props: {
+			popup: {
+				type: [Boolean, String],
+				default: false,
+			},
+			label: {
+				type: String,
+				default: '手写签名',
+			},
+			value: {
+				type: String,
+				default: '',
+			},
+			required: {
+				type: [Boolean, String],
+				default: false,
+			},
+			placeholder: {
+				type: String,
+				default: '点击签名',
+			},
+			readonly: {
+				type: [Boolean, String],
+				default: false,
+			},
+			openSmooth: {
+				type: [Boolean, String],
+				default: true,
+			},
+			boundingBox: {
+				type: [Boolean, String],
+				default: true,
+			},
+		},
+		data() {
+			return {
+				showPopup: false,
+				isHeight: false,
+				height1: uni.getSystemInfoSync().windowWidth / 2,
+				width: uni.getSystemInfoSync().windowWidth, //实时屏幕宽度
+				height: uni.getSystemInfoSync().windowHeight, //实时屏幕高度
+				showPicker: false
+			}
+		},
+		methods: {
+			moveHandle(){
+				
+			},
+			toImg(){
+				this.$emit('toImg',this.value)
+			},
+			undo() {
+				this.$refs.signatureRef.undo()
+			},
+			toPop() {
+				this.showPopup = true
+			},
+			toDeleteImg() {
+				// #ifndef VUE3
+				this.$emit('input','')
+				// #endif
+				// #ifdef VUE3
+				this.$emit('update:value','')
+				// #endif
+			},
+			toclear() {
+				this.isHeight = false
+				this.showPopup = false
+			},
+			close() {
+				this.isHeight = false
+				this.showPopup = false
+				const {signatureRef} = this.$refs
+				signatureRef.clear()
+			},
+			deleteImg() {
+				const {signatureRef} = this.$refs
+				signatureRef.clear()
+			},
+			toDataURL(url) {
+				// #ifndef VUE3
+				this.$emit('input',url)
+				// #endif
+				// #ifdef VUE3
+				this.$emit('update:value',url)
+				// #endif
+				this.showPicker = false
+			},
+			Tomagnify() {
+				this.isHeight = !this.isHeight
+				const {signatureRef} = this.$refs
+				signatureRef.clear()
+			},
+			isEmpty() {
+				const {signatureRef} = this.$refs
+				signatureRef.canvasToTempFilePath({
+					quality: 0.8,
+					success: (res) => {
+						if (this.required) {
+							if (!res.isEmpty) {
+								// #ifndef VUE3
+								this.$emit('input', res.tempFilePath)
+								// #endif
+								// #ifdef VUE3
+								this.$emit('update:value',res.tempFilePath)
+								// #endif
+								this.$emit('change', res.tempFilePath)
+								this.isHeight = false
+								this.showPopup = false
+							} else {
+								uni.showToast({
+									title: '请先签名',
+									icon: 'none'
+								});
+							}
+						} else {
+							// #ifndef VUE3
+							this.$emit('input', res.tempFilePath)
+							// #endif
+							// #ifdef VUE3
+							this.$emit('update:value',res.tempFilePath)
+							// #endif
+							this.$emit('change', res.tempFilePath)
+							this.isHeight = false
+							this.showPopup = false
+						}
+
+					}
+				})
+			},
+		},
+		beforeCreate() {},
+		created() {}
+	}
+</script>
+
+<style scoped lang="scss">
+	.wgSignatureq{
+		
+	}
+	.appBut{
+		display: flex;justify-content: flex-start;align-items: center;text-align: center;height: 50px;line-height: 35px;
+	 .buts{
+		 color: #333;flex: 1;margin: 0 15px;background-color: #ccc;border-radius: 5px;height: 35px;
+	  }
+	  .butx{
+		  color: #333;flex: 1;margin: 0 5px;background-color: #ccc;border-radius: 5px;height: 35px;
+		  transform: rotate(90deg);
+	  }
+	}
+	
+	.bottomPopup {
+		position: fixed;
+		left: 0;
+		top: 0;
+		bottom: 0;
+		right: 0;
+		z-index: 999;
+		background-color: rgba(0, 0, 0, 0.5);
+
+		.popup-content {
+			position: fixed;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			// top: 0;
+			background-color: #ffffff;
+		}
+
+		.slide-up-enter-active,
+		.slide-up-leave-active {
+			transition: all .3s ease;
+		}
+
+		.slide-up-enter,
+		.slide-up-leave-to {
+			transform: translateY(100%);
+		}
+	}
+
+	.signature {
+		.inputs {
+			background-color: #fff;
+			padding: 10px 16px;
+
+			.label {
+				line-height: 35px;
+				position: relative;
+			}
+
+			.labelqr:before {
+				content: "*";
+				color: #f00;
+			}
+
+			.explain {
+				width: 100%;
+				background-color: #f1f1f1;
+				text-align: center;
+				line-height: 40px;
+				border: 1px dotted #ccc;
+				color: #999;
+			}
+
+			.Deletes {
+				border: 1px solid #f00;
+				width: 30rpx;
+				height: 30rpx;
+				border-radius: 50%;
+				color: #f00;
+				text-align: center;
+				font-size: 30rpx;
+				line-height: 30rpx;
+			}
+		}
+
+		.images {
+			width: 300rpx;
+			height: 150rpx;
+			position: relative;
+
+			.icons {
+				position: absolute;
+				top: 0;
+				right: 0;
+			}
+		}
+	}
+
+	.popup {
+		background-color: #fff;
+	}
+
+	.hader {
+		display: flex;
+		justify-content: center;
+		text-align: center;
+		height: 45px;
+		border-bottom: 1px solid #f5f5f5;
+		align-items: center;
+
+		div {
+			text-align: center;
+			width: 80px;
+			color: #E59C36;
+		}
+
+		.text {
+			color: #333;
+			flex: 1;
+		}
+	}
+  
+  
+</style>

+ 199 - 0
uni_modules/jp-signature/components/jp-signature/context.js

@@ -0,0 +1,199 @@
+const uniPlatform = uni.getSystemInfoSync().uniPlatform
+
+export const uniContext = (canvasId, context) => {
+	let ctx = uni.createCanvasContext(canvasId, context)
+	if (!ctx.uniDrawImage) {
+		ctx.uniDrawImage = ctx.drawImage
+		ctx.drawImage = (image, ...agrs) => {
+			ctx.uniDrawImage(image.src, ...agrs)
+		}
+	}
+
+	if (!ctx.getImageData) {
+		ctx.getImageData = (x, y, width, height) => {
+			return new Promise((resolve, reject) => {
+				// #ifdef MP || VUE2
+				if (context.proxy) context = context.proxy
+				// #endif
+				uni.canvasGetImageData({
+					canvasId,
+					x,
+					y,
+					width:parseInt(width),
+					height:parseInt(height),
+					success(res) {
+						resolve(res)
+					},
+					fail(error) {
+						reject(error)
+					}
+				}, context)
+			})
+		}
+	} else {
+		ctx._getImageData = ctx.getImageData
+		ctx.getImageData = (x, y, width, height) => {
+			return new Promise((resolve, reject) => {
+				ctx._getImageData({
+					x,
+					y,
+					width: parseInt(width) ,
+					height:parseInt(height),
+					success(res) {
+						resolve(res)
+					},
+					fail(error) {
+						reject(error)
+					}
+				})
+			})
+		}
+	}
+
+	return ctx
+}
+
+class Image {
+	constructor() {
+		this.currentSrc = null
+		this.naturalHeight = 0
+		this.naturalWidth = 0
+		this.width = 0
+		this.height = 0
+		this.tagName = 'IMG'
+	}
+	onerror() {}
+	onload() {}
+	set src(src) {
+		this.currentSrc = src
+		uni.getImageInfo({
+			src,
+			success: (res) => {
+				this.naturalWidth = this.width = res.width
+				this.naturalHeight = this.height = res.height
+				this.onload()
+			},
+			fail: () => {
+				this.onerror()
+			}
+		})
+	}
+	get src() {
+		return this.currentSrc
+	}
+}
+
+export const createImage = () => {
+	return new Image()
+}
+export function useCurrentPage() {
+	const pages = getCurrentPages();
+	return pages[pages.length - 1];
+}
+export const toDataURL = (canvasId, context, options = {}) => {
+	// #ifdef MP-QQ
+	// context = context.$scope
+	// #endif
+	// #ifdef MP-ALIPAY
+	context = ''
+	// #endif
+
+	return new Promise((resolve, reject) => {
+		let {
+			canvas,
+			width,
+			height,
+			destWidth = 0,
+			destHeight = 0,
+			x = 0,
+			y = 0,
+			preferToDataURL
+		} = options
+		const {
+			pixelRatio
+		} = uni.getSystemInfoSync()
+		
+		// #ifdef MP-ALIPAY
+		const isDD = typeof dd != 'undefined'
+		if (!isDD && (!destWidth || !destHeight)) {
+			destWidth = width * pixelRatio;
+			destHeight = height * pixelRatio;
+			width = destWidth;
+			height = destHeight;
+			x = x * pixelRatio
+			y = y * pixelRatio
+		}
+		// #endif
+		const params = {
+			...options,
+			canvasId,
+			id: canvasId,
+			// #ifdef MP-ALIPAY
+			x,
+			y,
+			width,
+			height,
+			destWidth,
+			destHeight,
+			// #endif
+			canvas,
+			success: (res) => {
+				resolve(res.tempFilePath)
+			},
+			fail: (err) => {
+				reject(err)
+			}
+		}
+		// 抖音小程序canvas 2d不支持canvasToTempFilePath
+		if (canvas && canvas.toDataURL && preferToDataURL) {
+			let next = true
+			const devtools = uni.getSystemInfoSync().platform == 'devtools'
+			// #ifdef MP-TOUTIAO
+			next = uni.getSystemInfoSync().platform != 'devtools'
+			if (!next) {
+				console.warn('[lime-signature] 抖音开发工具不支持bbox')
+			}
+			// #endif
+			if ((x || y) && next) {
+				const offCanvas = uni.createOffscreenCanvas({
+					type: '2d'
+				});
+				const ctx = offCanvas.getContext("2d");
+				const destWidth = Math.floor(width * pixelRatio)
+				const destHeight = Math.floor(height * pixelRatio)
+				offCanvas.width = destWidth // canvas.width;
+				offCanvas.height = destHeight // canvas.height;
+				// ctx.scale(pixelRatio, pixelRatio)
+				// ctx.drawImage(canvas, Math.floor(x*pixelRatio), Math.floor(y*pixelRatio), destWidth, destHeight, 0,0, destWidth, destHeight);
+				// 抖音不能在drawImage使用canvas
+				const image = canvas.createImage()
+				image.onload = () => {
+					ctx.drawImage(image, Math.floor(x * pixelRatio), Math.floor(y * pixelRatio),
+						destWidth, destHeight, 0, 0, destWidth, destHeight)
+					const tempFilePath = offCanvas.toDataURL();
+					resolve(tempFilePath)
+					if (params.success) {
+						params.success({
+							tempFilePath
+						})
+					}
+				}
+				image.src = canvas.toDataURL()
+
+			} else {
+				const tempFilePath = canvas.toDataURL()
+				resolve(tempFilePath)
+				if (params.success) {
+					params.success({
+						tempFilePath
+					})
+				}
+			}
+		} else if (canvas && canvas.toTempFilePath) {
+			canvas.toTempFilePath(params)
+		} else {
+			uni.canvasToTempFilePath(params, context)
+		}
+	})
+
+}

+ 364 - 0
uni_modules/jp-signature/components/jp-signature/jp-signature.uvue

@@ -0,0 +1,364 @@
+<template>
+	<view class="l-signature" ref="signatureRef" :style="drawableStyle">
+		<!-- #ifdef APP -->
+		<view class="l-signature-landscape" ref="signatureLandscapeRef" v-if="landscape && url !=''"
+			:style="landscapeStyle">
+			<image class="l-signature-image" :style="landscapeImageStyle" :src="url"></image>
+		</view>
+		<!-- #endif -->
+		<!-- #ifdef WEB -->
+		<!-- #endif -->
+	</view>
+</template>
+<script lang="uts" setup>
+	// @ts-nocheck
+	// #ifdef APP
+	import { Signature } from './signature.uts'
+	// #endif
+	// #ifndef APP
+	import { Signature } from './signature.js'
+	// #endif
+	import { nextTick } from 'vue'
+	import { LSignatureToTempFilePathOptions, LSignatureToFileSuccess, LSignatureOptions } from '../../index.uts'
+	// type SignatureToFileSuccessCallback = (res : UTSJSONObject) => void
+	// type SignatureToFileFailCallback = (res : TakeSnapshotFail) => void
+	// type SignatureToFileCompleteCallback = (res : any) => void
+
+	/**
+	 * LimeSignature 手写板签名
+	 * @description 手写板签名插件,uvue专用版。
+	 * @tutorial https://ext.dcloud.net.cn/plugin?id=4354
+	 * @property {Number} penSize 画笔大小
+	 * @property {String} penColor 画笔颜色 
+	 * @property {String} backgroundColor 背景颜色,不填则为透明
+	 * @property {Boolean} disableScroll 当在写字时,禁止屏幕滚动以及下拉刷新,nvue无效
+	 */
+
+	const props = defineProps({
+		styles: {
+			type: String,
+			default: ''
+		},
+		penColor: {
+			type: String,
+			default: 'black'
+		},
+		penSize: {
+			type: Number,
+			default: 2
+		},
+		backgroundColor: {
+			type: String,
+			default: ''
+		},
+		openSmooth: {
+			type: Boolean,
+			default: false
+		},
+		minLineWidth: {
+			type: Number,
+			default: 2
+		},
+		maxLineWidth: {
+			type: Number,
+			default: 6
+		},
+		minSpeed: {
+			type: Number,
+			default: 1.5
+		},
+		maxWidthDiffRate: {
+			type: Number,
+			default: 20
+		},
+		maxHistoryLength: {
+			type: Number,
+			default: 20
+		},
+		disableScroll: {
+			type: Boolean,
+			default: true
+		},
+		disabled: {
+			type: Boolean,
+			default: false
+		},
+		landscape: {
+			type: Boolean,
+			default: false
+		},
+	})
+
+	const drawableStyle = computed<string>(() : string => {
+		let style : string = ''
+
+		if (props.backgroundColor != '') {
+			style += `background-color: ${props.backgroundColor};`
+		}
+		if (props.styles != '') {
+			style += props.styles
+		}
+		return style
+	})
+	const signatureRef = ref<UniElement | null>(null)
+	let signatureLandscapeRef = ref<UniElement | null>(null)
+	let landscapeStyle = ref<Map<string, string>>(new Map())
+	let landscapeImageStyle = ref<Map<string, string>>(new Map())
+
+	let signature : Signature | null = null
+	let url = ref('')
+	// #ifdef WEB
+	let canvas : HTMLCanvasElement | null = null
+	let touchstart,touchmove,touchend
+	// #endif
+
+	const clear = () => {
+		signature?.clear()
+	}
+	const redo = () => {
+		signature?.redo()
+	}
+	const undo = () => {
+		signature?.undo()
+	}
+	const canvasToTempFilePath = (options : LSignatureToTempFilePathOptions) => {
+		const success = options.success // as SignatureToFileSuccessCallback | null
+		const fail = options.fail // as SignatureToFileFailCallback | null
+		const complete = options.complete// as SignatureToFileCompleteCallback | null
+		const format = options.format ?? 'png'
+		// #ifdef APP
+		signatureRef.value?.takeSnapshot({
+			format,
+			success: (res) => {
+				if (props.landscape) {
+					url.value = res.tempFilePath;
+					setTimeout(() => {
+						signatureLandscapeRef.value?.takeSnapshot({
+							format,
+							success: (res2) => {
+								success?.({
+									tempFilePath: res2.tempFilePath,
+									isEmpty: signature?.isEmpty ?? false
+								} as LSignatureToFileSuccess)
+							}
+						})
+					}, 300)
+
+				} else {
+					success?.({
+						tempFilePath: res.tempFilePath,
+						isEmpty: signature?.isEmpty ?? false
+					} as LSignatureToFileSuccess)
+				}
+			},
+			fail: (res) => {
+				fail?.(res)
+			},
+			complete: (res) => {
+				complete?.(res)
+			}
+		} as TakeSnapshotOptions)
+		// #endif
+
+		// #ifdef WEB
+		// @ts-ignore
+		const { backgroundColor, backgroundImage, landscape, boundingBox } = props
+		const { quality = 1 } = options
+		const flag = landscape || backgroundColor || boundingBox
+		const type = `image/${format}`.replace(/jpg/, 'jpeg');
+		const image = canvas?.toDataURL(!flag && type, !flag && quality)
+
+		if (flag) {
+			// @ts-ignore
+			const canvas = document.createElement('canvas')
+			// @ts-ignore
+			const pixelRatio = signature?.canvas.get('pixelRatio')
+			// @ts-ignore
+			let width = signature?.canvas.get('width')
+			// @ts-ignore
+			let height = signature?.canvas.get('height')
+			let x = 0
+			let y = 0
+			// @ts-ignore
+			const next = () => {
+				const size = [width, height]
+				if (landscape) {
+					size.reverse()
+				}
+				canvas.width = size[0] * pixelRatio
+				canvas.height = size[1] * pixelRatio
+				const param = [x, y, width, height, 0, 0, width, height].map(item => item * pixelRatio)
+				const context = canvas.getContext('2d')
+				if (landscape) {
+					context.translate(0, width * pixelRatio)
+					context.rotate(-Math.PI / 2)
+				}
+				if (backgroundColor) {
+					context.fillStyle = backgroundColor
+					context.fillRect(0, 0, width * pixelRatio, height * pixelRatio)
+				}
+				const drawImage = () => {
+					// @ts-ignore
+					context.drawImage(signature?.canvas!.get('el'), ...param)
+					success?.({
+						tempFilePath: canvas.toDataURL(type, quality),
+						// @ts-ignore
+						isEmpty: signature?.isEmpty() ?? false
+					} as LSignatureToFileSuccess)
+					canvas.remove()
+				}
+				if (backgroundImage) {
+					const img = new Image();
+					img.onload = () => {
+						context.drawImage(img, ...param)
+						drawImage()
+					}
+					img.src = backgroundImage
+				}
+				if (!backgroundImage) {
+					drawImage()
+				}
+			}
+			if (boundingBox) {
+				// @ts-ignore
+				const res = signature?.getContentBoundingBox()
+				width = res.width
+				height = res.height
+				x = res.startX
+				y = res.startY
+				next()
+			} else {
+				next()
+			}
+		} else {
+			success?.({
+				tempFilePath: image,
+				// @ts-ignore
+				isEmpty: signature?.isEmpty() ?? false
+			} as LSignatureToFileSuccess)
+		}
+		// #endif
+	}
+	defineExpose({
+		clear,
+		redo,
+		undo,
+		canvasToTempFilePath,
+	})
+	onMounted(() => {
+		nextTick(() => {
+			const width = signatureRef.value?.offsetWidth
+			const height = signatureRef.value?.offsetHeight
+			// #ifdef APP
+			landscapeStyle.value.set('width', `${height}px`)
+			landscapeStyle.value.set('height', `${width}px`)
+			landscapeImageStyle.value.set('width', `${width}px`)
+			landscapeImageStyle.value.set('height', `${height}px`)
+			landscapeImageStyle.value.set('transform', `rotate(-90deg) translateY(${width}px)`)
+
+			signature = new Signature(signatureRef.value!)
+			// #endif
+			// #ifdef WEB
+			canvas = document.createElement('canvas')
+			canvas!.style = 'width: 100%; height: 100%;'
+			signatureRef.value?.appendChild(canvas as UniElement)
+			// @ts-ignore
+			signature = new Signature({ el: canvas })
+			let isTouch = false
+			touchstart = (event: UniMouseEvent) => {
+				isTouch = true
+				const rect = canvas?.getBoundingClientRect()
+				// @ts-ignore
+				signature!.canvas.emit('touchstart', {
+					points: [
+						{
+							x:  event.clientX -  rect.left,
+							y:  event.clientY - rect.top
+						}
+					]
+				})
+			}
+			touchmove = (event: UniMouseEvent) => {
+				if(!isTouch) return
+				const rect = canvas?.getBoundingClientRect()
+				// @ts-ignore
+				signature!.canvas.emit('touchmove', {
+					points: [
+						{
+							x:  event.clientX - rect.left,
+							y:  event.clientY - rect.top
+						}
+					]
+				})
+			}
+			touchend = (event: UniMouseEvent) => {
+				isTouch = false
+				const rect = canvas?.getBoundingClientRect();
+				// @ts-ignore
+				signature!.canvas.emit('touchend', {
+					points: [
+						{
+							x:  event.clientX -  rect.left,
+							y:  event.clientY - rect.top
+						}
+					]
+				})
+			}
+			canvas?.addEventListener('mousedown', touchstart)
+			canvas?.addEventListener('mousemove', touchmove)
+			canvas?.addEventListener('mouseup', touchend)
+			canvas?.addEventListener('mouseleave', touchend)
+			
+			
+			// #endif
+
+			watchEffect(() => {
+				const options : LSignatureOptions = {
+					penColor: props.penColor,
+					openSmooth: props.openSmooth,
+					disableScroll: props.disableScroll,
+					disabled: props.disabled,
+					penSize: props.penSize,
+					minLineWidth: props.minLineWidth,
+					maxLineWidth: props.maxLineWidth,
+					minSpeed: props.minSpeed,
+					maxWidthDiffRate: props.maxWidthDiffRate,
+					maxHistoryLength: props.maxHistoryLength
+				}
+				// #ifdef APP
+				signature?.setOption(options)
+				// #endif
+				// #ifdef WEB
+				// @ts-ignore
+				signature?.pen.setOption(options)
+				// #endif
+			})
+		})
+	})
+	
+	onUnmounted(()=>{
+		// #ifdef WEB
+		canvas?.removeEventListener('mousedown', touchstart)
+		canvas?.removeEventListener('mousemove', touchmove)
+		canvas?.removeEventListener('mouseup', touchend)
+		canvas?.removeEventListener('mouseleave', touchend)
+		canvas?.remove()
+		// #endif
+		
+	})
+</script>
+<style lang="scss">
+	.l-signature {
+		flex: 1;
+
+		&-landscape {
+			position: absolute;
+			pointer-events: none;
+			left: 1000rpx;
+		}
+
+		&-image {
+			transform-origin: 0% 0%;
+		}
+	}
+</style>

+ 728 - 0
uni_modules/jp-signature/components/jp-signature/jp-signature.vue

@@ -0,0 +1,728 @@
+<template>
+	<view class="lime-signature" v-if="show" :style="[canvasStyle, styles]" ref="limeSignature">
+		<!-- #ifndef APP-VUE || APP-NVUE -->
+		<canvas v-if="useCanvas2d" class="lime-signature__canvas" :id="canvasId" type="2d"
+			:disableScroll="disableScroll" @touchstart="touchStart" @touchmove="touchMove"
+			@touchend="touchEnd"></canvas>
+		<canvas v-else :disableScroll="disableScroll" class="lime-signature__canvas" :canvas-id="canvasId"
+			:id="canvasId" :width="canvasWidth" :height="canvasHeight" @touchstart="touchStart" @touchmove="touchMove"
+			@touchend="touchEnd" @mousedown="touchStart" @mousemove="touchMove" @mouseup="touchEnd"></canvas>
+		<canvas v-if="showOffscreen" class="offscreen" canvas-id="offscreen" id="offscreen"
+			:style="'width:' + offscreenSize[0] + 'px;height:' + offscreenSize[1] + 'px'" :width="offscreenSize[0]"
+			:height="offscreenSize[1]">
+		</canvas>
+		<view v-if="showMask" class="mask" @touchstart="touchStart" @touchmove.stop.prevent="touchMove"
+			@touchend="touchEnd"></view>
+		<!-- #endif -->
+		<!-- #ifdef APP-VUE -->
+		<view :id="canvasId" :disableScroll="disableScroll" :rparam="param" :change:rparam="sign.update"
+			:rclear="rclear" :change:rclear="sign.clear" :rundo="rundo" :rredo="rredo" :change:rredo="sign.redo"
+			:change:rundo="sign.undo" :rsave="rsave" :rmask="rmask" :change:rsave="sign.save" :change:rmask="sign.mask"
+			:rdestroy="rdestroy" :change:rdestroy="sign.destroy" :rempty="rempty" :change:rempty="sign.isEmpty">
+		</view>
+		<!-- #endif -->
+		<!-- #ifdef APP-NVUE -->
+		<web-view src="/uni_modules/lime-signature/hybrid/html/index.html" class="lime-signature__canvas" ref="webview"
+			@pagefinish="onPageFinish" @error="onError" @onPostMessage="onMessage"></web-view>
+		<!-- #endif -->
+	</view>
+</template>
+
+<script>
+	// #ifndef APP-NVUE
+	import {
+		canIUseCanvas2d,
+		wrapEvent,
+		requestAnimationFrame,
+		sleep,
+		isTransparent
+	} from './utils'
+	import {
+		Signature
+	} from './signature.js'
+	// import {Signature} from '@signature';
+	import {
+		uniContext,
+		createImage,
+		toDataURL
+	} from './context'
+	// #endif
+	import props from './props';
+	import {
+		base64ToPath,
+		getRect
+	} from './utils'
+import { nextTick } from 'vue';
+
+	/**
+	 * LimeSignature 手写板签名
+	 * @description 手写板签名插件:一款能跑在uniapp各端中的签名插件,支持横屏、背景色、笔画颜色、笔画大小等功能,可生成有内容的区域,减小图片尺寸,节省空间。
+	 * @property {Number} penSize 画笔大小
+	 * @property {Number} minLineWidth 线条最小宽
+	 * @property {Number} maxLineWidth 线条最大宽 
+	 * @property {String} penColor 画笔颜色 
+	 * @property {String} backgroundColor 背景颜色,不填则为透明
+	 * @property {type} 指定 canvas 类型
+	 * @value 2d canvas 2d 
+	 * @value '' 非 canvas 2d 旧接口,微信不再维护
+	 * @property {Boolean} openSmooth 模拟笔锋 
+	 * @property {Number} beforeDelay 延时初始化,在放在弹窗里可以使用 (毫秒)  
+	 * @property {Number} maxHistoryLength 限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能 
+	 * @property {Boolean} landscape 横屏,使用后在最后生成图片时会图片旋转90度
+	 * @property {Boolean} disableScroll 当在写字时,禁止屏幕滚动以及下拉刷新,nvue无效
+	 * @property {Boolean} boundingBox 只生成内容区域,即未画部分不生成,有性能的损耗
+	 */
+	export default {
+		props,
+		data() {
+			return {
+				canvasWidth: null,
+				canvasHeight: null,
+				offscreenWidth: null,
+				offscreenHeight: null,
+				useCanvas2d: true,
+				show: true,
+				offscreenStyles: '',
+				showMask: false,
+				showOffscreen: false,
+				isPC: false,
+				// #ifdef APP-PLUS
+				rclear: 0,
+				rdestroy: 0,
+				rundo: 0,
+				rredo: 0,
+				rsave: JSON.stringify({
+					n: 0,
+					fileType: 'png',
+					quality: 1,
+					destWidth: 0,
+					destHeight: 0,
+				}),
+				rmask: JSON.stringify({
+					n: 0,
+					destWidth: 0,
+					destHeight: 0,
+				}),
+				rempty: 0,
+				risEmpty: true,
+				toDataURL: null,
+				tempFilePath: [],
+				// #endif
+			}
+		},
+		computed: {
+			canvasId() {
+				// #ifdef VUE2
+				return `lime-signature${this._uid}`
+				// #endif
+				// #ifdef VUE3
+				return `lime-signature${this._.uid}`
+				// #endif
+			},
+			offscreenId() {
+				return this.canvasId + 'offscreen'
+			},
+			offscreenSize() {
+				const {
+					offscreenWidth,
+					offscreenHeight
+				} = this
+				return this.landscape ? [offscreenHeight, offscreenWidth] : [offscreenWidth, offscreenHeight]
+			},
+			canvasStyle() {
+				const {
+					canvasWidth,
+					canvasHeight,
+					backgroundColor
+				} = this
+				return {
+					width: canvasWidth && (canvasWidth + 'px'),
+					height: canvasHeight && (canvasHeight + 'px'),
+					background: backgroundColor
+				}
+			},
+			param() {
+				const {
+					penColor,
+					penSize,
+					backgroundColor,
+					backgroundImage,
+					landscape,
+					boundingBox,
+					openSmooth,
+					minLineWidth,
+					maxLineWidth,
+					minSpeed,
+					maxWidthDiffRate,
+					maxHistoryLength,
+					disableScroll,
+					disabled
+				} = this
+				return JSON.parse(JSON.stringify({
+					penColor,
+					penSize,
+					backgroundColor,
+					backgroundImage,
+					landscape,
+					boundingBox,
+					openSmooth,
+					minLineWidth,
+					maxLineWidth,
+					minSpeed,
+					maxWidthDiffRate,
+					maxHistoryLength,
+					disableScroll,
+					disabled
+				}))
+			}
+		},
+		// #ifdef APP-NVUE
+		watch: {
+			param(v) {
+				this.$refs.webview.evalJS(`update(${JSON.stringify(v)})`)
+			}
+		},
+		// #endif
+		// #ifndef APP-PLUS
+		created() {
+			const {
+				platform
+			} = uni.getSystemInfoSync()
+			this.isPC = /windows|mac/.test(platform)
+			this.useCanvas2d = this.type == '2d' && canIUseCanvas2d() && !this.isPC
+			// #ifndef H5
+			this.showMask = this.isPC
+			// #endif
+
+		},
+		// #endif
+		// #ifndef APP-PLUS
+		async mounted() {
+			if (this.beforeDelay) {
+				await sleep(this.beforeDelay)
+			}
+			const config = await this.getContext()
+			this.signature = new Signature(config)
+			this.canvasEl = this.signature.canvas.get('el')
+			this.offscreenWidth = this.canvasWidth = this.signature.canvas.get('width')
+			this.offscreenHeight = this.canvasHeight = this.signature.canvas.get('height')
+
+			this.stopWatch = this.$watch('param', (v) => {
+				this.signature.pen.setOption(v)
+			}, {
+				immediate: true
+			})
+		},
+		// #endif
+		// #ifndef APP-PLUS
+		// #ifdef VUE3
+		beforeUnmount() {
+			this.stopWatch && this.stopWatch()
+			this.signature.destroy()
+			this.signature = null
+			this.show = false;
+			// #ifdef APP-VUE || APP-NVUE
+			this.rdestroy++
+			// #endif
+		},
+		// #endif
+		// #ifdef VUE2
+		beforeDestroy() {
+			this.stopWatch && this.stopWatch()
+			this.signature.destroy()
+			this.show = false;
+			this.signature = null
+			// #ifdef APP-VUE || APP-NVUE
+			this.rdestroy++
+			// #endif
+		},
+		// #endif
+		// #endif
+		methods: {
+			// #ifdef MP-QQ
+			// toJSON() { return this },
+			// #endif
+			// #ifdef APP-PLUS
+			onPageFinish() {
+				this.$refs.webview.evalJS(`update(${JSON.stringify(this.param)})`)
+			},
+			onMessage(e = {}) {
+				const {
+					detail: {
+						data: [res]
+					}
+				} = e
+				if (res.event?.save) {
+					this.toDataURL = res.event.save
+				}
+				if (res.event?.changeSize) {
+					const {
+						width,
+						height
+					} = res.event.changeSize
+				}
+				if (res.event.hasOwnProperty('isEmpty')) {
+					this.risEmpty = res.event.isEmpty
+				}
+				if (res.event?.file) {
+					this.tempFilePath.push(res.event.file)
+					if (this.tempFilePath.length > 7) {
+						this.tempFilePath.shift()
+					}
+					return
+				}
+				if (res.event?.success) {
+					if (res.event.success) {
+						this.tempFilePath.push(res.event.success)
+						if (this.tempFilePath.length > 8) {
+							this.tempFilePath.shift()
+						}
+						this.toDataURL = this.tempFilePath.join('')
+						this.tempFilePath = []
+					} else {
+						this.$emit('fail', 'canvas no data')
+					}
+					return
+				}
+			},
+			// #endif
+			redo() {
+				// #ifdef APP-VUE || APP-NVUE
+				this.rredo += 1
+				// #endif
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`redo()`)
+				// #endif
+				// #ifndef APP-VUE
+				if (this.signature)
+					this.signature.redo()
+				// #endif
+			},
+			restore() {
+				this.redo()
+			},
+			undo() {
+				// #ifdef APP-VUE || APP-NVUE
+				this.rundo += 1
+				// #endif
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`undo()`)
+				// #endif
+				// #ifndef APP-VUE
+				if (this.signature)
+					this.signature.undo()
+				// #endif
+			},
+			clear() {
+				// #ifdef APP-VUE || APP-NVUE
+				this.rclear += 1
+				// #endif
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`clear()`)
+				// #endif
+				// #ifndef APP-VUE
+				if (this.signature)
+					this.signature.clear()
+				// #endif
+			},
+			isEmpty() {
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`isEmpty()`)
+				// #endif
+				// #ifdef APP-VUE || APP-NVUE
+				this.rempty += 1
+				// #endif
+				// #ifndef APP-VUE || APP-NVUE
+				return this.signature.isEmpty()
+				// #endif
+			},
+			async canvasToMaskPath(param = {}) {
+				const isEmpty = this.isEmpty()
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`mask(${JSON.stringify(param)})`)
+				// #endif
+				// #ifdef APP-VUE || APP-NVUE
+				const stopURLWatch = this.$watch('toDataURL', (v, n) => {
+					if (v && v !== n) {
+						// if(param.pathType == 'url') {
+						base64ToPath(v).then(res => {
+							param.success({
+								tempFilePath: res,
+								isEmpty: this.risEmpty
+							})
+						})
+						// } else {
+						// 	param.success({tempFilePath: v,isEmpty: this.risEmpty })
+						// }
+						this.toDataURL = ''
+					}
+					stopURLWatch && stopURLWatch()
+				})
+				const {
+					fileType,
+					quality
+				} = param
+				const rmask = JSON.parse(this.rmask)
+				rmask.n++
+				rmask.destWidth = param.destWidth ?? 0
+				rmask.destHeight = param.destHeight ?? 0
+				// rmask.fileType = fileType
+				// rmask.quality = quality
+				this.rmask = JSON.stringify(rmask)
+				// #endif
+				// #ifndef APP-VUE || APP-NVUE
+				this.showOffscreen = true
+				
+				let width = this.signature.canvas.get('width')
+				let height = this.signature.canvas.get('height')
+				let {
+					pixelRatio
+				} = uni.getSystemInfoSync()
+				if (this.useCanvas2d) {
+					this.offscreenWidth = width * pixelRatio
+					this.offscreenHeight = height * pixelRatio
+				} else {
+					this.offscreenWidth = width
+					this.offscreenHeight = height
+				}
+				await sleep(100)
+				const context = uni.createCanvasContext('offscreen', this)
+				const size = Math.max(this.offscreenWidth, this.offscreenHeight)
+				const success = (success) => param.success && param.success(success)
+				const fail = (fail) => param.fail && param.fail(fail)
+
+				this.signature.pen.getMaskedImageData((imageData) => {
+					let canvasPutImageData = (options, comp) => {
+						if (uni.canvasPutImageData) {
+							uni.canvasPutImageData(options, comp)
+						} else if (context.putImageData) {
+							context.putImageData(options)
+						}
+					}
+					canvasPutImageData({
+						canvasId: 'offscreen',
+						x: 0,
+						y: 0,
+						width: width,
+						height:height,
+						data: imageData,
+						fail(err) {
+							fail(err)
+						},
+						success: (re) => {
+							toDataURL('offscreen', this, param).then((res) => {
+								context.restore()
+								context.clearRect(0, 0, size, size)
+								this.offscreenWidth = width
+								this.offscreenHeight = height
+								this.showOffscreen = false
+								success({
+									tempFilePath: res,
+									isEmpty
+								})
+							})
+						}
+					}, this)
+					
+				})
+				// #endif
+			},
+			canvasToTempFilePath(param = {}) {
+				
+				const isEmpty = this.isEmpty()
+				// #ifdef APP-NVUE
+				this.$refs.webview.evalJS(`save(${JSON.stringify(param)})`)
+				// #endif
+				// #ifdef APP-VUE || APP-NVUE
+				const stopURLWatch = this.$watch('toDataURL', (v, n) => {
+					if (v && v !== n) {
+						if (this.preferToDataURL) {
+							param.success({
+								tempFilePath: v,
+								isEmpty: this.risEmpty
+							})
+						} else {
+							base64ToPath(v).then(res => {
+								param.success({
+									tempFilePath: res,
+									isEmpty: this.risEmpty
+								})
+							})
+						}
+						this.toDataURL = ''
+					}
+					stopURLWatch && stopURLWatch()
+				})
+				const {
+					fileType,
+					quality
+				} = param
+				const rsave = JSON.parse(this.rsave)
+				rsave.n++
+				rsave.fileType = fileType
+				rsave.quality = quality
+				rsave.destWidth = param.destWidth ?? 0
+				rsave.destHeight = param.destHeight ?? 0
+				this.rsave = JSON.stringify(rsave)
+				// #endif
+				// #ifndef APP-VUE || APP-NVUE
+				const useCanvas2d = this.useCanvas2d
+				const success = (success) => param.success && param.success(success)
+				const fail = (err) => param.fail && param.fail(err)
+				const {
+					canvas
+				} = this.signature.canvas.get('el')
+				const {
+					backgroundColor,
+					landscape,
+					boundingBox
+				} = this
+				let width = this.signature.canvas.get('width')
+				let height = this.signature.canvas.get('height')
+				let x = 0
+				let y = 0
+				const devtools = uni.getSystemInfoSync().platform == 'devtools'
+				let preferToDataURL = this.preferToDataURL
+				let scale = 1
+				// #ifdef MP-TOUTIAO
+				scale = devtools ? uni.getSystemInfoSync().pixelRatio : scale
+				// 由于抖音不支持canvasToTempFilePath故优先使用createOffscreenCanvas
+				preferToDataURL = true
+				// #endif
+				const canvasToTempFilePath = async (image) => {
+					const createCanvasContext = () => {
+						const useOffscreen = (useCanvas2d && !!uni.createOffscreenCanvas && preferToDataURL)
+						if (useOffscreen && !devtools) {
+							const offCanvas = uni.createOffscreenCanvas({
+								type: '2d'
+							});
+							offCanvas.width = this.offscreenSize[0] * scale
+							offCanvas.height = this.offscreenSize[1] * scale
+							const context = offCanvas.getContext("2d");
+							return [context, offCanvas]
+						} else {
+							const context = uni.createCanvasContext('offscreen', this)
+							return [context]
+						}
+					}
+
+					if (boundingBox && !this.isPC || landscape || backgroundColor && !isTransparent(backgroundColor)) {
+						
+						this.showOffscreen = true
+						await sleep(100)
+						const [context, offCanvas] = createCanvasContext()
+						context.save()
+						context.setTransform(1, 0, 0, 1, 0, 0)
+						if (landscape) {
+							context.translate(0, width * scale)
+							context.rotate(-Math.PI / 2)
+						}
+						if (backgroundColor && !isTransparent(backgroundColor)) {
+							context.fillStyle = backgroundColor
+							context.fillRect(0, 0, width, height)
+						}
+						if (offCanvas) {
+							const img = canvas.createImage();
+							img.src = image
+							img.onload = () => {
+								context.drawImage(img, 0, 0, width * scale, height * scale);
+								const tempFilePath = offCanvas.toDataURL()
+								this.showOffscreen = false
+								success({
+									tempFilePath,
+									isEmpty
+								})
+							}
+
+						} else {
+							context.drawImage(image, 0, 0, width * scale, height * scale);
+							context.draw(false, () => {
+								toDataURL('offscreen', this, param).then((res) => {
+									const size = Math.max(width, height)
+									context.restore()
+									context.clearRect(0, 0, size, size)
+									this.showOffscreen = false
+									success({
+										tempFilePath: res,
+										isEmpty
+									})
+								})
+							})
+						}
+					} else {
+						success({
+							tempFilePath: image,
+							isEmpty
+						})
+					}
+				}
+				const next = async () => {
+					if (this.offscreenWidth != width || this.offscreenHeight != height) {
+						this.offscreenWidth = width
+						this.offscreenHeight = height
+						await sleep(100)
+					}
+
+					// #ifndef MP-WEIXIN
+					const param = {
+						x,
+						y,
+						width,
+						height,
+						canvas,
+						preferToDataURL
+					}
+					// #endif
+
+					// #ifdef MP-WEIXIN
+					const param = {
+						x,
+						y,
+						width,
+						height,
+						canvas: useCanvas2d ? canvas : null,
+						preferToDataURL
+					}
+					// #endif
+					toDataURL(this.canvasId, this, param).then(canvasToTempFilePath).catch(fail)
+				}
+				// PC端小程序获取不到 ImageData 数据,长度为0
+				if (boundingBox && !this.isPC) {
+					this.signature.getContentBoundingBox(async res => {
+						this.offscreenWidth = width = res.width
+						this.offscreenHeight = height = res.height
+
+						x = res.startX
+						y = res.startY
+
+						next()
+					})
+				} else {
+					next()
+				}
+				// #endif
+			},
+			// #ifndef APP-PLUS
+			getContext() {
+				return getRect(`#${this.canvasId}`, {
+					context: this,
+					type: this.useCanvas2d ? 'fields' : 'boundingClientRect'
+				}).then(res => {
+					if (res) {
+						let {
+							width,
+							height,
+							node: canvas,
+							left,
+							top,
+							right
+						} = res
+						let {
+							pixelRatio
+						} = uni.getSystemInfoSync()
+						let context;
+						if (canvas) {
+							context = canvas.getContext('2d')
+							canvas.width = width * pixelRatio;
+							canvas.height = height * pixelRatio;
+						} else {
+							pixelRatio = 1
+							context = uniContext(this.canvasId, this)
+							canvas = {
+								getContext: (type) => type == '2d' ? context : null,
+								createImage,
+								toDataURL: () => toDataURL(this.canvasId, this),
+								requestAnimationFrame
+							}
+						}
+						// 支付宝小程序 使用stroke有个默认背景色
+						context.clearRect(0, 0, width, height)
+						return {
+							left,
+							top,
+							right,
+							width,
+							height,
+							context,
+							canvas,
+							pixelRatio
+						};
+					}
+				})
+			},
+			getTouch(e) {
+				if (this.isPC && this.canvasRect) {
+					e.touches = e.touches.map(item => {
+						return {
+							...item,
+							x: item.clientX - this.canvasRect.left,
+							y: item.clientY - this.canvasRect.top,
+						}
+					})
+				}
+				return e
+			},
+			touchStart(e) {
+				if (!this.canvasEl) return
+				this.isStart = true
+				// 微信小程序PC端不支持事件,使用这方法模拟一下
+				if (this.isPC) {
+					getRect(`#${this.canvasId}`, {
+						context: this
+					}).then(res => {
+						this.canvasRect = res
+						this.canvasEl.dispatchEvent('touchstart', wrapEvent(this.getTouch(e)))
+					})
+					return
+				}
+				this.canvasEl.dispatchEvent('touchstart', wrapEvent(e))
+			},
+			touchMove(e) {
+				if (!this.canvasEl || !this.isStart && this.canvasEl) return
+				this.canvasEl.dispatchEvent('touchmove', wrapEvent(this.getTouch(e)))
+			},
+			touchEnd(e) {
+				if (!this.canvasEl) return
+				this.isStart = false
+				this.canvasEl.dispatchEvent('touchend', wrapEvent(e))
+			},
+			// #endif
+		}
+	}
+</script>
+<!-- #ifdef APP-VUE -->
+<script module="sign" lang="renderjs">
+	import sign from './render'
+	export default sign
+</script>
+<!-- #endif -->
+<style lang="scss">
+	.lime-signature,
+	.lime-signature__canvas {
+		/* #ifndef APP-NVUE */
+		position: relative;
+		width: 100%;
+		height: 100%;
+		/* #endif */
+		/* #ifdef APP-NVUE */
+		flex: 1;
+		/* #endif */
+	}
+
+	.mask {
+		position: absolute;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		top: 0;
+	}
+
+	.offscreen {
+		position: fixed;
+		top: 0;
+		// left: 0;
+		pointer-events:none;
+		// background: rgba(0,255,0,0.5);
+		left: 9999px;
+	}
+</style>

+ 64 - 0
uni_modules/jp-signature/components/jp-signature/props.js

@@ -0,0 +1,64 @@
+export default {
+	styles: String,
+	disableScroll: {
+		type: Boolean,
+		default: true
+	},
+	type: {
+		type: String,
+		default: '2d'
+	},
+	// 画笔颜色
+	penColor: {
+		type: String,
+		default: 'black'
+	},
+	penSize: {
+		type: Number,
+		default: 2
+	},
+	// 画板背景颜色
+	backgroundColor: String,
+	backgroundImage: String,
+	// 笔锋
+	openSmooth: Boolean,
+	// 画笔最小值
+	minLineWidth: {
+		type: Number,
+		default: 2
+	},
+	// 画笔最大值
+	maxLineWidth: {
+		type: Number,
+		default: 6
+	},
+	// 画笔达到最小宽度所需最小速度(px/ms),取值范围1.0-10.0,值越小,画笔越容易变细,笔锋效果会比较明显,可以自行调整查看效果,选出自己满意的值。
+	minSpeed: {
+		type: Number,
+		default: 1.5
+	},
+	// 相邻两线宽度增(减)量最大百分比,取值范围1-100,为了达到笔锋效果,画笔宽度会随画笔速度而改变,如果相邻两线宽度差太大,过渡效果就会很突兀,使用maxWidthDiffRate限制宽度差,让过渡效果更自然。可以自行调整查看效果,选出自己满意的值。
+	maxWidthDiffRate: {
+		type: Number,
+		default: 20
+	},
+	// 限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能
+	maxHistoryLength: {
+		type: Number,
+		default: 20
+	},
+	beforeDelay: {
+		type: Number,
+		default: 0
+	},
+	landscape: {
+		type: Boolean
+	},
+	boundingBox: {
+		type: Boolean
+	},
+	disabled: {
+		type: Boolean
+	},
+	preferToDataURL: Boolean
+}

+ 228 - 0
uni_modules/jp-signature/components/jp-signature/render.js

@@ -0,0 +1,228 @@
+// #ifdef APP-VUE 
+// import { Signature } from '@signature'
+import {
+	Signature
+} from './signature.js'
+import {
+	isTransparent
+} from './utils'
+export default {
+	data() {
+		return {
+			canvasid: null,
+			signature: null,
+			observer: null,
+			options: {},
+			saveCount: 0,
+		}
+	},
+	mounted() {
+		this.$nextTick(this.init)
+	},
+	methods: {
+		init() {
+			const el = this.$refs.limeSignature || this.$ownerInstance.$el;
+			this.canvas = document.createElement('canvas')
+			this.canvas.style = 'width: 100%; height: 100%;'
+			el.appendChild(this.canvas)
+			this.signature = new Signature({
+				el: this.canvas
+			})
+			this.signature.pen.setOption(this.options)
+			const width = this.signature.canvas.get('width')
+			const height = this.signature.canvas.get('height')
+
+			this.emit({
+				changeSize: {
+					width,
+					height
+				}
+			})
+		},
+		redo(v) {
+			if (v && this.signature) {
+				this.signature.redo()
+			}
+		},
+		undo(v) {
+			if (v && this.signature) {
+				this.signature.undo()
+			}
+		},
+		clear(v) {
+			if (v && this.signature) {
+				this.signature.clear()
+			}
+		},
+		destroy() {
+			if (this.canvas) {
+				this.canvas.remove()
+			}
+		},
+		mask(param={}) {
+			if (this.signature) {
+				let {destWidth=0, destHeight=0} = JSON.parse(param)
+				let canvas = document.createElement('canvas')
+				const ctx = canvas.getContext('2d');
+				const pixelRatio = this.signature.canvas.get('pixelRatio')
+				let width = this.signature.canvas.get('width')
+				let height = this.signature.canvas.get('height')
+				let context = this.signature.canvas.get('context')
+				canvas.width = width * pixelRatio
+				canvas.height = height * pixelRatio
+
+				const imageData = context.getImageData(0, 0, width * pixelRatio, height * pixelRatio);
+				for (let i = 0; i < imageData.data.length; i += 4) {
+					// 判断当前像素是否透明
+					const isTransparent = imageData.data[i + 3] === 0;
+				
+					if (isTransparent) {
+						// 将透明像素设置为黑色背景
+						imageData.data[i] = 0;
+						imageData.data[i + 1] = 0;
+						imageData.data[i + 2] = 0;
+					} else {
+						// 将非透明像素设置为白色内容
+						imageData.data[i] = 255;
+						imageData.data[i + 1] = 255;
+						imageData.data[i + 2] = 255;
+					}
+				}
+				ctx.putImageData(imageData, 0, 0);
+				if(destWidth&&destHeight){
+					const _canvas = document.createElement('canvas')
+					_canvas.width = destWidth
+					_canvas.height = destHeight
+					const _context = _canvas.getContext('2d')
+					_context.drawImage(canvas, 0, 0, destWidth, destHeight)
+					canvas.remove()
+					canvas = _canvas	
+				}
+				this.emit({
+					save: canvas.toDataURL()
+				})
+				canvas.remove()
+			}
+
+		},
+		save(param) {
+			let {
+				fileType = 'png', 
+				quality = 1, 
+				n,
+				destWidth = 0,
+				destHeight = 0,
+			} = JSON.parse(param)
+			const type = `image/${fileType}`.replace(/jpg/, 'jpeg');
+			if (n !== this.saveCount) {
+				this.saveCount = n;
+				const {
+					backgroundColor,
+					backgroundImage,
+					landscape,
+					boundingBox
+				} = this.options
+				const flag = landscape || backgroundColor || boundingBox||destWidth&&destHeight
+				const image = this.signature.canvas.get('el').toDataURL(!flag && type, !flag && quality)
+				if (flag) {
+					let canvas = document.createElement('canvas')
+					const pixelRatio = this.signature.canvas.get('pixelRatio')
+					let width = this.signature.canvas.get('width')
+					let height = this.signature.canvas.get('height')
+					let x = 0
+					let y = 0
+
+					const next = () => {
+						const size = [width, height]
+						if (landscape) {
+							size.reverse()
+						}
+						canvas.width =  size[0] * pixelRatio
+						canvas.height = size[1] * pixelRatio
+						const param = [x, y, width, height, 0, 0, width, height].map(item => item * pixelRatio)
+						const context = canvas.getContext('2d')
+						if (landscape) {
+							context.translate(0, width * pixelRatio)
+							context.rotate(-Math.PI / 2)
+						}
+						if (backgroundColor && !isTransparent(backgroundColor)) {
+							context.fillStyle = backgroundColor
+							context.fillRect(0, 0, width * pixelRatio, height * pixelRatio)
+						}
+						const drawImage = () => {
+							// param
+							context.drawImage(this.signature.canvas.get('el'), ...param)
+							if(destWidth&&destHeight){
+								const _canvas = document.createElement('canvas')
+								_canvas.width = destWidth
+								_canvas.height = destHeight
+								const _context = _canvas.getContext('2d')
+								_context.drawImage(canvas, 0, 0, destWidth, destHeight)
+								canvas.remove()
+								canvas = _canvas	
+							}
+							this.emit({
+								save: canvas.toDataURL(type, quality)
+							})
+							canvas.remove()
+						}
+						if (backgroundImage) {
+							const img = new Image();
+							img.onload = () => {
+								context.drawImage(img, ...param)
+								drawImage()
+							}
+							img.src = backgroundImage
+						}
+						if (!backgroundImage) {
+							drawImage()
+						}
+					}
+					if (boundingBox) {
+						const res = this.signature.getContentBoundingBox()
+						width = res.width
+						height = res.height
+						x = res.startX
+						y = res.startY
+						next()
+					} else {
+						next()
+					}
+
+				} else {
+					this.emit({
+						save: image
+					})
+				}
+			}
+		},
+		isEmpty(v) {
+			if (v && this.signature) {
+				const isEmpty = this.signature.isEmpty()
+				this.emit({
+					isEmpty
+				})
+			}
+		},
+		emit(event) {
+			this.$ownerInstance.callMethod('onMessage', {
+				detail: {
+					data: [{
+						event
+					}]
+				}
+			})
+		},
+		update(v) {
+			if (v) {
+				if (this.signature) {
+					this.options = v
+					this.signature.pen.setOption(v)
+				} else {
+					this.options = v
+				}
+			}
+		}
+	}
+}
+// #endif

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
uni_modules/jp-signature/components/jp-signature/signature.js


+ 165 - 0
uni_modules/jp-signature/components/jp-signature/signature.uts

@@ -0,0 +1,165 @@
+import { LSignatureOptions, Point, Line } from '../../index.uts'
+
+let points : Line = []
+let undoStack : Line[] = [];
+let redoStack : Line[] = [];
+let lastX = 0;
+let lastY = 0;
+
+
+export class Signature {
+	el : UniElement
+	options : LSignatureOptions = {
+		penColor: 'black',
+		openSmooth: true,
+		disableScroll: true,
+		disabled: false,
+		penSize: 2,
+		minLineWidth: 2,
+		maxLineWidth: 6,
+		minSpeed: 1.5,
+		maxWidthDiffRate: 20,
+		maxHistoryLength: 20
+	} as LSignatureOptions
+	ctx : DrawableContext
+	isEmpty : boolean = true
+	isDrawing : boolean = false
+	// historyList : Point[][] = []
+	// id : string
+	// instance : ComponentPublicInstance
+	touchstartCallbackWrapper: UniCallbackWrapper|null = null
+	touchmoveCallbackWrapper: UniCallbackWrapper|null= null
+	touchendCallbackWrapper: UniCallbackWrapper|null= null
+	constructor(el : UniElement) {
+		this.el = el
+		this.ctx = el.getDrawableContext() as DrawableContext
+		this.init()
+	}
+	init() {
+		this.touchstartCallbackWrapper = this.el.addEventListener('touchstart', this.onTouchStart)
+		this.touchmoveCallbackWrapper = this.el.addEventListener('touchmove', this.onTouchMove)
+		this.touchendCallbackWrapper = this.el.addEventListener('touchend', this.onTouchEnd)
+	}
+	remove() {
+		if(this.touchstartCallbackWrapper == null) return
+		this.el.removeEventListener('touchstart', this.touchstartCallbackWrapper!)
+		this.el.removeEventListener('touchmove', this.touchmoveCallbackWrapper!)
+		this.el.removeEventListener('touchend', this.touchendCallbackWrapper!)
+	}
+	setOption(options : LSignatureOptions) {
+		this.options = options
+	}
+	disableScroll(event : UniTouchEvent) {
+		event.stopPropagation()
+		if (this.options.disableScroll) {
+			{
+				event.preventDefault()
+			}
+		}
+	}
+	getTouchPoint(event : UniTouchEvent) : Point {
+		const rect = this.el.getBoundingClientRect()
+		const touche = event.touches[0];
+		const x = touche.clientX
+		const y = touche.clientY
+		// const force = touche.force
+		return {
+			x: x - rect.left,
+			y: y - rect.top
+		} as Point
+	}
+	onTouchStart: (event : UniTouchEvent) => void = (event : UniTouchEvent) =>{
+		if (this.options.disabled) {
+			return
+		}
+		this.disableScroll(event)
+		const { x, y } = this.getTouchPoint(event)
+		this.isDrawing = true;
+		this.isEmpty = false
+		lastX = x
+		lastY = y
+		points.push({ x, y } as Point);
+	}
+	onTouchMove: (event : UniTouchEvent) => void = (event : UniTouchEvent) =>{
+		if (this.options.disabled || !this.isDrawing) {
+			return
+		}
+		this.disableScroll(event)
+		const { x, y } = this.getTouchPoint(event)
+		const lineWidth = this.options.penSize
+		const strokeStyle = this.options.penColor
+		const point = { x, y } as Point
+		const last = { x: lastX, y: lastY } as Point
+		this.drawLine(point, last, lineWidth, strokeStyle)
+
+		lastX = x
+		lastY = y
+		points.push({ x, y, c: strokeStyle, w: lineWidth } as Point);
+	}
+	onTouchEnd: (event : UniTouchEvent) => void = (event : UniTouchEvent) =>{
+		this.disableScroll(event)
+		this.isDrawing = false;
+		undoStack.push(points);
+		redoStack = [] as Line[];
+		points = [] as Point[];
+	}
+	drawLine(point : Point, last : Point, lineWidth : number, strokeStyle : string) {
+		const ctx = this.ctx
+		ctx.lineWidth = lineWidth
+		ctx.strokeStyle = strokeStyle
+		ctx.lineCap = 'round'
+		ctx.lineJoin = 'round'
+		ctx.beginPath()
+		ctx.moveTo(last.x, last.y)
+		ctx.lineTo(point.x, point.y)
+		ctx.stroke()
+		ctx.update()
+	}
+	// addHistory() { }
+	clear() {
+		this.ctx.reset()
+		this.ctx.update()
+		this.isEmpty = true
+		undoStack = [] as Line[];
+		redoStack = [] as Line[];
+		points = [] as Point[];
+	}
+	undo() {
+		if(redoStack.length == this.options.maxHistoryLength && this.options.maxHistoryLength != 0){
+			return
+		}
+		this.ctx.reset()
+		if(undoStack.length > 0){
+			const lastPath : Line = undoStack.pop()!;
+			redoStack.push(lastPath);
+			if(undoStack.length == 0){
+				this.isEmpty = true
+				this.ctx.update()
+				return
+			}
+			for (let l = 0; l < undoStack.length; l++) {
+				for (let i = 1; i < undoStack[l].length; i++) {
+					const last  = undoStack[l][i - 1]
+					const point = undoStack[l][i]
+					this.drawLine(point, last, point.w!, point.c!)
+				}
+			}
+		} else {
+			this.ctx.update()
+		}
+	}
+	redo() {
+		if(redoStack.length < 1) return
+		const lastPath : Line = redoStack.pop()!;
+		undoStack.push(lastPath);
+		this.isEmpty = false
+		for (let l = 0; l < undoStack.length; l++) {
+			for (let i = 1; i < undoStack[l].length; i++) {
+				const last  = undoStack[l][i - 1]
+				const point = undoStack[l][i]
+				this.drawLine(point, last, point.w!, point.c!)
+			}
+		}
+	}
+	// restore() { }
+}

+ 181 - 0
uni_modules/jp-signature/components/jp-signature/utils.js

@@ -0,0 +1,181 @@
+export function compareVersion(v1, v2) {
+	v1 = v1.split('.')
+	v2 = v2.split('.')
+	const len = Math.max(v1.length, v2.length)
+	while (v1.length < len) {
+		v1.push('0')
+	}
+	while (v2.length < len) {
+		v2.push('0')
+	}
+	for (let i = 0; i < len; i++) {
+		const num1 = parseInt(v1[i], 10)
+		const num2 = parseInt(v2[i], 10)
+
+		if (num1 > num2) {
+			return 1
+		} else if (num1 < num2) {
+			return -1
+		}
+	}
+	return 0
+}
+
+function gte(version) {
+	let { SDKVersion } = uni.getSystemInfoSync() 
+  // #ifdef MP-ALIPAY
+  SDKVersion = my.SDKVersion
+  // #endif
+  return compareVersion(SDKVersion, version) >= 0;
+}
+
+export function canIUseCanvas2d() {
+	// #ifdef MP-WEIXIN
+	return gte('2.9.0');
+	// #endif
+	// #ifdef MP-ALIPAY
+	return gte('2.7.0');
+	// #endif
+	// #ifdef MP-TOUTIAO
+	return gte('1.78.0');
+	// #endif
+	return false
+}
+
+
+export const wrapEvent = (e) => {
+  if (!e) return;
+  if (!e.preventDefault) {
+    e.preventDefault = function() {};
+  }
+  return e;
+}
+
+export const requestAnimationFrame = (cb) => {
+	setTimeout(cb, 30)
+}
+
+// #ifdef MP
+export const prefix = () => {
+	// #ifdef MP-TOUTIAO
+	return tt
+	// #endif
+	// #ifdef MP-WEIXIN
+	return wx
+	// #endif
+	// #ifdef MP-BAIDU
+	return swan
+	// #endif
+	// #ifdef MP-ALIPAY
+	return my
+	// #endif
+	// #ifdef MP-QQ
+	return qq
+	// #endif
+	// #ifdef MP-360
+	return qh
+	// #endif
+}
+// #endif
+
+/**
+ * base64转路径
+ * @param {Object} base64
+ */
+export function base64ToPath(base64) {
+	const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64) || [];
+	return new Promise((resolve, reject) => {
+		// #ifdef MP
+		const p = prefix()
+		const fs = p.getFileSystemManager()
+		//自定义文件名
+		if (!format) {
+			reject(new Error('ERROR_BASE64SRC_PARSE'))
+		}
+		const time = new Date().getTime();
+		const filePath = `${p.env.USER_DATA_PATH}/${time}.${format}`;
+		fs.writeFile({
+			filePath,
+			data: base64.split(',')[1],
+			encoding: 'base64',
+			success() {
+				resolve(filePath)
+			},
+			fail(err) {
+				reject(err)
+			}
+		})
+		// #endif
+		// #ifdef APP-PLUS
+		const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
+		bitmap.loadBase64Data(base64, () => {
+			if (!format) {
+				reject(new Error('ERROR_BASE64SRC_PARSE'))
+			}
+			const time = new Date().getTime();
+			const filePath = `_doc/uniapp_temp/${time}.${format}`
+			bitmap.save(filePath, {},
+				() => {
+					bitmap.clear()
+					resolve(filePath)
+				},
+				(error) => {
+					bitmap.clear()
+					reject(error)
+				})
+		}, (error) => {
+			bitmap.clear()
+			reject(error)
+		})
+		// #endif
+	})
+}
+
+
+export function sleep(delay) {
+	return new Promise(resolve => setTimeout(resolve, delay))
+}
+
+export function getRect(selector, options = {}) {
+	const typeDefault = 'boundingClientRect'
+	const { context, type = typeDefault} = options
+	return new Promise((resolve, reject) => {
+		const dom = uni.createSelectorQuery().in(context).select(selector);
+		const result = (rect) => {
+			if(rect) {
+				 resolve(rect)
+			} else {
+				reject()
+			}
+		}
+		if(type == typeDefault) {
+			dom[type](result).exec()
+		} else {
+			dom[type]({
+				node: true,
+				size: true,
+				rect: true
+			}, result).exec()
+		}
+	});
+};
+
+export function isTransparent(color) {
+  // 判断颜色是否为 transparent
+  if (color === 'transparent') {
+    return true;
+  }
+
+  // 判断颜色是否为 rgba 的 a 为 0
+  if (color.startsWith('rgba')) {
+    const regex = /\d+(\.\d+)?/g;
+    const matches = color.match(regex);
+    if (matches !== null) {
+      const alpha = parseFloat(matches[3]);
+      if (alpha === 0) {
+        return true;
+      }
+    }
+  }
+  return false;
+}

+ 224 - 0
uni_modules/jp-signature/hybrid/html/index.html

@@ -0,0 +1,224 @@
+<!DOCTYPE html>
+<html lang="zh">
+	<head>
+		<meta charset="UTF-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+		<meta http-equiv="X-UA-Compatible" content="ie=edge" />
+		<title></title>
+		<style type="text/css">
+			html,
+			body,
+			canvas {
+				padding: 0;
+				margin: 0;
+				width: 100%;
+				height: 100%;
+				overflow-y: hidden;
+				background-color: transparent;
+			}
+		</style>
+	</head>
+
+	<body>
+		<canvas id="lime-signature"></canvas>
+		<script type="text/javascript" src="./uni.webview.1.5.3.js"></script>
+		<script type="text/javascript" src="./signature.js"></script>
+		<script>
+			var signature = null;
+			var timer = null;
+			var isStart = false;
+			var options = null
+			console.log = function(...args) {
+				postMessage(args);
+			};
+			// function stringify(key, value) {
+			// 	if (typeof value === 'object' && value !== null) {
+			// 		if (cache.indexOf(value) !== -1) {
+			// 			return;
+			// 		}
+			// 		cache.push(value);
+			// 	}
+			// 	return value;
+			// };
+			function emit(event, data) {
+				postMessage({
+					event,
+					data: typeof data !== "object" && data !== null ? data : JSON.stringify(data),
+				});
+				// cache = [];
+			}
+
+			function postMessage(data) {
+				uni.postMessage({
+					data
+				});
+			}
+
+			function update(v = {}) {
+				if (signature) {
+					options = v
+					signature.pen.setOption(v);
+				} else {
+					signature = new Signature.Signature({el: "lime-signature"});
+					canvasEl = signature.canvas.get("el");
+					options = v
+					signature.pen.setOption(v)
+					const width = signature.canvas.get("width");
+					const height = signature.canvas.get("height");
+					
+					emit({changeSize: {width,height}})
+				}
+			}
+
+			function clear() {
+				signature.clear()
+			}
+
+			function undo() {
+				signature.undo()
+			}
+			function redo() {
+				signature.redo()
+			}
+			function isEmpty() {
+				const isEmpty = signature.isEmpty()
+				emit({isEmpty});
+			}
+			function isTransparent(color) {
+			  // 判断颜色是否为 transparent
+			  if (color === 'transparent') {
+			    return true;
+			  }
+			
+			  // 判断颜色是否为 rgba 的 a 为 0
+			  if (color.startsWith('rgba')) {
+			    const regex = /\d+(\.\d+)?/g;
+			    const matches = color.match(regex);
+			    if (matches !== null) {
+			      const alpha = parseFloat(matches[3]);
+			      if (alpha === 0) {
+			        return true;
+			      }
+			    }
+			  }
+			  return false;
+			}
+			function mask(param){
+				clearTimeout(timer);
+				let {destWidth=0, destHeight=0} = param
+				let width = this.signature.canvas.get('width')
+				let height = this.signature.canvas.get('height')
+				let canvas = document.createElement('canvas')
+				const ctx = canvas.getContext('2d');
+				const pixelRatio = signature.canvas.get('pixelRatio')
+				canvas.width = width * pixelRatio
+				canvas.height = height * pixelRatio
+				
+				this.signature.pen.getMaskedImageData((imageData)=>{
+					ctx.putImageData(imageData, 0, 0);
+					if(destWidth&&destHeight){
+						const _canvas = document.createElement('canvas')
+						_canvas.width = destWidth
+						_canvas.height = destHeight
+						const _context = _canvas.getContext('2d')
+						_context.drawImage(canvas, 0, 0, destWidth, destHeight)
+						canvas.remove()
+						canvas = _canvas	
+					}
+					const path = canvas.toDataURL();
+					canvas.remove()
+					if (typeof path == "string") {
+						const index = Math.ceil(path.length / 8);
+						for (var i = 0; i < 8; i++) {
+							if (i == 7) {
+								emit({"success": path.substr(i * index, index)});
+							} else {
+								emit({"file": path.substr(i * index, index)});
+							}
+						}
+					} else {
+						console.error("canvas no data");
+						emit({"fail": "canvas no data"});
+					}
+				})
+			}
+			function save(param) {
+				// delete param.success;
+				// delete param.fail;
+				clearTimeout(timer);
+				timer = setTimeout(() => {
+					let {fileType = 'png', quality = 1, n, destWidth=0, destHeight=0} = param
+					const type = `image/${fileType}`.replace(/jpg/, 'jpeg');
+					const {backgroundColor, landscape, boundingBox} = options
+					const flag = backgroundColor || landscape || boundingBox||destWidth&&destHeight
+					let path = canvasEl.toDataURL(!flag && type, !flag && quality)
+					if(flag) {
+						let canvas = document.createElement('canvas')
+						const pixelRatio = signature.canvas.get('pixelRatio')
+						let width = signature.canvas.get('width')
+						let height = signature.canvas.get('height')
+						let x = 0
+						let y = 0
+						
+						const next = () => {
+							const size = [width, height]
+							if(landscape) {size.reverse()}
+							canvas.width = size[0] * pixelRatio
+							canvas.height = size[1] * pixelRatio
+							const param = [x, y, width, height, 0 , 0, width, height].map(item => item * pixelRatio)
+							const context = canvas.getContext('2d')
+							// context.scale(pixelRatio, pixelRatio)
+							if (landscape) {
+								context.translate(0, width * pixelRatio)
+								context.rotate(-Math.PI / 2)
+							}
+							if (backgroundColor && !isTransparent(backgroundColor)) {
+								context.fillStyle = backgroundColor
+								context.fillRect(0, 0, width * pixelRatio, height * pixelRatio)
+							}
+							const drawImage = ()=>{
+								
+							}
+							// param
+							context.drawImage(signature.canvas.get('el'), ...param)
+							if(destWidth&&destHeight){
+								const _canvas = document.createElement('canvas')
+								_canvas.width = destWidth
+								_canvas.height = destHeight
+								const _context = _canvas.getContext('2d')
+								_context.drawImage(canvas, 0, 0, destWidth, destHeight)
+								canvas.remove()
+								canvas = _canvas
+							}
+							path = canvas.toDataURL(type, quality)
+							canvas.remove()
+						}
+						if(boundingBox) {
+							const res = signature.getContentBoundingBox()
+							width = res.width
+							height = res.height
+							x = res.startX
+							y = res.startY
+							next()
+						} else {
+							next()
+						}
+					} 
+					if (typeof path == "string") {
+						const index = Math.ceil(path.length / 8);
+						for (var i = 0; i < 8; i++) {
+							if (i == 7) {
+								emit({"success": path.substr(i * index, index)});
+							} else {
+								emit({"file": path.substr(i * index, index)});
+							}
+						}
+					} else {
+						console.error("canvas no data");
+						emit({"fail": "canvas no data"});
+					}
+				}, 30);
+			}
+		</script>
+	</body>
+</html>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
uni_modules/jp-signature/hybrid/html/signature.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
uni_modules/jp-signature/hybrid/html/uni.webview.1.5.3.js


+ 78 - 0
uni_modules/jp-signature/package.json

@@ -0,0 +1,78 @@
+{
+  "id": "jp-signature",
+  "displayName": "手写签名组件,弹框签名,可配置签名,签名返回base64,签名专用,手写一键使用  文档签字",
+  "version": "3.2.0",
+  "description": "用于手写签名,同时内置了弹框签名组件及文档上签字组件,对于不想布局的同学来说可以开箱即用。",
+  "keywords": [
+    "手写签名",
+    "弹框签名",
+    "手写一键使用",
+    "文档签字",
+    "小白专用签名"
+],
+  "repository": "",
+  "engines": {
+    "HBuilderX": "^3.7.8"
+  },
+"dcloudext": {
+    "sale": {
+      "regular": {
+        "price": "0.00"
+      },
+      "sourcecode": {
+        "price": "0.00"
+      }
+    },
+    "contact": {
+      "qq": ""
+    },
+    "declaration": {
+      "ads": "无",
+      "data": "无",
+      "permissions": "无"
+    },
+    "npmurl": "",
+    "type": "component-vue"
+  },
+  "uni_modules": {
+    "dependencies": [],
+    "encrypt": [],
+    "platforms": {
+      "cloud": {
+        "tcb": "y",
+        "aliyun": "y",
+        "alipay": "n"
+      },
+      "client": {
+        "App": {
+          "app-vue": "y",
+          "app-nvue": "y"
+        },
+        "H5-mobile": {
+          "Safari": "y",
+          "Android Browser": "y",
+          "微信浏览器(Android)": "y",
+          "QQ浏览器(Android)": "y"
+        },
+        "H5-pc": {
+          "Chrome": "n",
+          "IE": "n",
+          "Edge": "n",
+          "Firefox": "n",
+          "Safari": "n"
+        },
+        "小程序": {
+          "微信": "y",
+          "阿里": "u",
+          "百度": "u",
+          "字节跳动": "u",
+          "QQ": "u"
+        },
+        "快应用": {
+          "华为": "u",
+          "联盟": "u"
+        }
+      }
+    }
+  }
+}

+ 214 - 0
uni_modules/jp-signature/readme.md

@@ -0,0 +1,214 @@
+## jp-signature 、jp-signature-popup、 jp-merge 写字板
+### jp-signature 写字板,可用业务签名等场景,方便用户自行改造 
+### jp-signature-popup 小白专用弹框签名组件,方便小白开发使用,和输入框一样使用简单
+### jp-merge 图片签字组件,提供用户在图片文档上进行签字
+
+# 有合作需求请私
+## 开发不易,如果帮助到你的,请支持 有问题请留言,作者会积极更新
+
+## 平台兼容
+| H5  | 微信小程序 | 支付宝小程序 | 百度小程序 | 头条小程序 | QQ 小程序 | App |
+| --- | ---------- | ------------ | ---------- | ---------- | --------- | --- |
+| √   | √          | √         | 未测       | 未测          | 未测      | √    |
+
+
+## 代码演示
+
+### jp-signature 基本用法
+```html
+<view style="width: 750rpx ;height: 500rpx;">
+	<jp-signature  ref="signatureRef"   ></jp-signature>
+</view>
+<view>
+	<button @click="clear">清空</button>
+	<button @click="">撤消</button>
+	<button @click="save">保存</button>
+</view>
+
+export default {
+	data() {
+		return {
+			url: '',
+		}
+	},
+	methods: {
+		save(){
+			this.$refs.signatureRef.canvasToTempFilePath({
+				success: (res) => {
+					// 是否为空画板 无签名
+					console.log(res.isEmpty)
+					// 生成图片的临时路径
+					// H5 生成的是base64
+					this.url = res.tempFilePath
+				}
+			})
+		},
+		clear(){
+			this.$refs.signatureRef.clear()
+		},
+		undo(){
+			this.$refs.signatureRef.undo()
+		},
+	}
+}
+
+```
+
+## API
+### Props
+
+| 参数             | 说明                  | 类型              | 默认值        |
+| --------------   | ------------         | ----------------  | ------------ |
+| penSize          | 画笔大小              | <em>number</em>   |    `2`           |
+| minLineWidth     | 线条最小宽            | <em>number</em>    | `2`        |
+| maxLineWidth     | 线条最大宽            | <em>number</em>    | `6`        |
+| penColor         | 画笔颜色              | <em>string</em>    | `black`      |
+| backgroundColor  | 背景颜色              | <em>string</em>    | ``      |
+| type             | 指定 canvas 类型  | <em>string</em> | `2d`  |
+| openSmooth       | 是否模拟压感           | <em>boolean</em>   | `false`       |
+| beforeDelay       | 延时初始化,在放在弹窗里可以使用 (毫秒)          | <em>number</em>   | `0`       |
+| maxHistoryLength   | 限制历史记录数,即最大可撤销数,传入0则关闭历史记录功能           | <em>boolean</em>   | `20`       |
+| landscape        | 横屏           | <em>boolean</em>   | ``       |
+| disableScroll     | 当在写字时,禁止屏幕滚动以及下拉刷新           | <em>boolean</em>   | `true`       |
+| boundingBox     | 只生成内容区域,即未画部分不生成,有性能的损耗(微信小程序pc不支持) | <em>boolean</em>   | `false`       |
+
+
+### 事件 Events
+
+| 事件名  | 说明         | 回调           |
+| ------- | ------------ | -------------- |
+| undo | 撤消,回退到上一步 |  |
+| clear | 清空,清空画板 |  |
+| canvasToTempFilePath | 保存,生成图片,与官方保持一致,但不需要传canvasId |  |
+
+### 常见问题
+- 放在弹窗里时,尺寸不对 可以延时手写板出现时机,给手写板加vif或beforeDelay="300"
+- boundingBox 微信小程序 pc 不支持, 因为获取不到 ImageData 数据
+
+
+
+## jp-signature-popup 基础用法
+```html
+<template>
+	<view class="content">
+		   <!-- #ifdef VUE2 -->
+			<jp-signature-popup v-model="title"  />
+			<!-- #endif -->
+			<!-- #ifdef VUE3 -->
+			<jp-signature-popup  v-model:value="title" />
+			<!-- #endif -->
+			{{title}}
+	</view>
+</template>
+<script>
+	export default {
+		data() {
+			return {
+				title: ''
+			}
+		},
+	}
+</script>
+```
+
+####参数
+| 参数名        | 类型   |  默认值  | 说明  |
+| --------   |  -------- |  --------| --------|
+|   value   |  String  |       |  签名内容,可以通过v-model和v-model:value绑定      |
+| label        |   String   |  手写签名   |          |
+| popup        |   Boolean   |  false   |   是否隐藏原有样式,该模式只使用弹框       |
+| required        |    Boolean    |  false |   |
+| placeholder        |    String    | 点击签名 |  签名说明 |
+| readonly        |     Boolean   |  false |  是否只能可读 |
+| openSmooth        |     Boolean   |  false |  是否开启签名笔锋 |
+| boundingBox     |  boolean| false |   只生成内容区域,即未画部分不生成,有性能的损耗(微信小程序不支持)    |
+
+
+####方法
+| 方法名        | 返回参数   |  说明  |
+| --------   |  -------- |  --------|
+|  toPop    |    |   打开弹窗    |
+| close      |    | 关闭弹窗  |
+| deleteImg      |    | 删除内容  |
+
+####事假
+| 事件名        | 返回参数   |  说明  |
+| --------   |  -------- |  --------|
+|  input    |  签名内容  |    签名内容   |
+|  change    |  签名内容  |  签名内容改变后触发   |
+|  toImg    |  图片编码  |    点击图片时触发   |
+
+
+## jp-merge 基础用法
+```html
+<template>
+	<view class="content">
+		<view>下面是使用 jp-signature-popup 结合 jp-merge  在文档上签字</view>
+		<view style="text-align: center;padding-bottom: 150px;">
+			<image :src="image4" v-if="image4" style="width: 350px;height: 350px;border: 1px solid #ccc;"></image>
+			<image src="../../static/sqs.jpg" v-else style="width: 350px;height: 350px;border: 1px solid #ccc;"></image>
+			<view class="but" style="margin: 0 25px;" @click="toPop">我要在上面签字</view>
+			<jp-signature-popup ref="signature" @change="setImg" popup v-model:value="image3" />
+			<jp-merge bgImage="../../static/sqs.jpg" ref="jpMerge"></jp-merge>
+		</view>
+	</view>
+</template>
+<script>
+	export default {
+		data() {
+			return {
+				image3:'',
+				image4:''
+			}
+		},
+		methods: {
+			setImg(val){
+				if(val){
+					<!-- 生成签字结果的方法可以传入网络及本地图片 -->
+					this.$refs.jpMerge.exportPost(val).then(res => {
+						this.image4 = res
+				    })
+				}
+			},
+			toPop(){
+				this.$refs.signature.toPop()
+			}
+		}
+	}
+</script>
+<style lang="scss">
+	.but{
+		margin: 25px;
+		line-height: 40px;
+		text-align: center;
+		background-color: #55aaff;
+		color: #fff;
+	}
+</style>
+```
+
+####参数
+| 参数名        | 类型   |  默认值  | 说明  |
+| --------   |  -------- |  --------| --------|
+| bgImage  |  String  |       |  文档图片地址,支持本地及线上图片(小程序如果是网络图片需要配置白名单)      |
+| canvasWidth        |   number   |  400    |     生成图片的最终宽,建议长高和文档长高一致     |
+| canvasHeight        |   number   |  400    |     生成图片的最终高,建议长高和文档长高一致     |
+| width        |   number   |  80    |    签字图片宽     |
+| height        |   number   |  80    |    签字图片高     |
+| left        |   number   |  80    |    签字图片距离左边位置     |
+| top        |   number   |  80    |    签字图片距离顶边位置     |
+
+
+####方法
+| 方法名        | 返回参数   |  说明  |
+| --------   |  -------- |  --------|
+|  exportPost    |    |   传入签字图片生成最终结果    |
+
+
+
+### 常见问题
+- 在vue2和vue3中使用v-model有区别,vue2中为v-model,vue3为v-model:value
+- 使用实例已放在 uni_modules/jp-signature/pages 中,可复制后测试
+
+
+

+ 70 - 0
uni_modules/jp-signature/实例/index.vue

@@ -0,0 +1,70 @@
+<template>
+	<view class="h100">
+		<!-- #ifdef VUE2 -->
+		<jp-signature-popup v-model="image" required />
+		<view>{{image}}</view>
+		<view class="but" @click="toPop1">自定义样式,弹框调用</view>
+		<jp-signature-popup ref="signature1" popup v-model="image2" />
+		<image :src="image2" style="width: 200px;" mode="widthFix"></image>
+		<!-- #endif -->
+		<!-- #ifdef VUE3 -->
+		<jp-signature-popup  v-model:value="image" required />
+		<view>{{image}}</view>
+		<view class="but" @click="toPop1">自定义样式,弹框调用</view>
+		<jp-signature-popup ref="signature1" popup v-model:value="image2" />
+		<image :src="image2" style="width: 200px;" mode="widthFix"></image>
+		<!-- #endif -->
+		{{image2}}
+		
+		
+		<view>下面是使用 jp-signature-popup 结合 jp-merge  在文档上签字</view>
+		<view style="text-align: center;padding-bottom: 150px;">
+			<image :src="image4" v-if="image4" style="width: 350px;height: 350px;border: 1px solid #ccc;"></image>
+			<image src="../../static/sqs.jpg" v-else style="width: 350px;height: 350px;border: 1px solid #ccc;"></image>
+			<view class="but" style="margin: 0 25px;" @click="toPop2">我要在上面签字</view>
+			<jp-signature-popup ref="signature2" @change="setImg" popup v-model:value="image3" />
+			<jp-merge bgImage="../../static/sqs.jpg" ref="jpMerge"></jp-merge>
+		</view>
+	</view>
+</template>
+<!-- 有项目需要开发的请联系 扣 - 371524845 -->
+<script>
+	export default {
+		data() {
+			return {
+				image:'',
+				image2:'',
+				image3:'',
+				image4:''
+			}
+		},
+		methods: {
+			setImg(val){
+				if(val){
+					this.$refs.jpMerge.exportPost(val).then(res => {
+						this.image4 = res
+				    })
+				}
+			},
+			toPop1(){
+				this.$refs.signature1.toPop()
+			},
+			toPop2(){
+				this.$refs.signature2.toPop()
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.but{
+		margin: 25px;
+		line-height: 40px;
+		text-align: center;
+		background-color: #55aaff;
+		color: #fff;
+	}
+	.sssv{
+		height: 1150px;
+	}
+</style>

+ 108 - 0
uni_modules/jp-signature/实例/微信小程序签名到指定位置.vue

@@ -0,0 +1,108 @@
+<template>
+	<view class="h100">
+		<view style="text-align: center;padding-bottom: 150px;">
+			<image :src="image" v-if="image" style="width: 200px;height: 200px;border: 1px solid #ccc;"></image>
+			<image :src="imgs" v-else style="width: 200px;height: 200px;border: 1px solid #ccc;"></image>
+			<view class="but" style="margin: 0 25px;" @click="toPop">我要在上面签字</view>
+			<jp-signature-popup ref="signature2" @change="setImg" popup  />
+			<canvas canvas-id="shareCanvas" class="canvas" bindlongpress="saveImg" catchtouchmove="true"
+				style="position:fixed;left:500%"
+				:style="{height: canvasHeight+'px',width:canvasWidth+'px'}">
+			</canvas>
+		</view>
+	</view>
+</template>
+<!-- 有项目需要开发的请联系 扣 - 371524845 -->
+<!-- 
+ 注意:需要采用线上图片且需要配置白名单,未配置手机无法签名,采用真机调试2.0不配置白名单也可以签名,正式版本需要线上图片且需要配置白名单
+ -->
+<script>
+	export default {
+		data() {
+			return {
+				canvasHeight: 400,
+				canvasWidth: 400,
+				width:80,
+				height: 50,
+				left: 20,
+				top: 20,
+				ctx:null,
+				image: '',
+				imgs: 'http://mmbiz.qpic.cn/sz_mmbiz_jpg/GEWVeJPFkSGTfkSpSbg9cHUqcibBv38r8GXDIVy4W6FN7a1TMWf6RSNQLemKBwG8VqjlxUhicIzz3NTONVrD96ibg/0?wx_fmt=jpeg'
+			}
+		},
+		mounted() {
+			//初始化画布
+			this.ctx = wx.createCanvasContext('shareCanvas', this)
+		},
+		methods: {
+			setImg(val) {
+				if (val) {
+					this.exportPost(val).then(res => {
+						this.image = res
+					})
+				}
+			},
+			toPop() {
+				this.$refs.signature2.toPop()
+			},
+			getImageInfo(src) {
+				return new Promise((resolve, reject) => {
+					wx.getImageInfo({
+						src,
+						success: (res) => resolve(res),
+						fail: (res) => reject(res)
+					})
+				});
+			},
+			exportPost(image2){
+				let that  =  this
+			   return new Promise(function (resolve, reject) {
+				let image =  that.imgs
+				//获取系统的基本信息,为后期的画布和底图适配宽高
+				 uni.getSystemInfo({
+					success: function (res) {
+					Promise.all([that.getImageInfo(image),that.getImageInfo(image2)]).then(res=>{
+			        //获取底图和二维码图片的基本信息,通常前端导出的二维码是base64格式的,所以要转成图片格式的才可以获取图片的基本信息			
+					that.ctx.drawImage(res[0].path,0 , 0,that.canvasWidth,that.canvasHeight);
+					that.ctx.drawImage(res[1].path,that.left,that.top,that.width, that.height);
+						  that.ctx.draw(false,function(){
+							  wx.canvasToTempFilePath({
+								  x: 0,
+								  y: 0,
+								  width:that.canvasWidth,
+								  height:that.canvasHeight,
+								  destWidth:that.canvasWidth*2,//这里乘以2是为了保证合成图片的清晰度
+								  destHeight:that.canvasHeight*2,
+								  canvasId: 'shareCanvas',
+								  fileType:'jpg',//设置导出图片的后缀名
+								  success: function (res) {
+									  resolve(res.tempFilePath)
+								  },
+								  fail: function (res) {
+									  reject(res)
+								  },
+							  })   
+						  });
+						})     
+					}
+				})
+			   })
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.but {
+		margin: 25px;
+		line-height: 40px;
+		text-align: center;
+		background-color: #55aaff;
+		color: #fff;
+	}
+
+	.sssv {
+		height: 1150px;
+	}
+</style>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.