最近在项目中需要一个签名框的需求,原本想用直接使用类似于 wot-ui 之类组件库里的签名框,但是后来放到界面上发现,组件的样式并不好更改,即使强行修改也总会有些问题。于是,就直接用 Uniapp 自带的 Canvas 实现一个签名组件。
此文章已在9月22日更新,最近因为项目需要支持微信小程序,所以做了一下适配
前言
uniapp 和 H5 中的 Canvas 存在一些差异,为了在 uniapp 中获取到 Canvas 的上下文,需要借助 canvas-id 属性,因此这个属性是必须指定的。
<canvas canvas-id="signatureCanvas"></canvas>
此外,uniapp 还在 Canvas 上绑定了几个触摸事件,通过这些事件,我们就能处理和控制 Canvas 的绘制样式和路径。
| 事件 | 说明 |
|---|---|
| @touchstart | 手指触摸动作开始 |
| @touchmove | 手指触摸后移动 |
| @touchend | 手指触摸动作结束 |
| @touchcancel | 手指触摸动作被打断,如来电提醒,弹窗 |
Canvas 绘制
手写签名的绘制实际上就是在 Canvas 上的线条绘制,因此在绘制线条开始时,我们首先需要确定两个东西。手放在屏幕上的位置,也就是起点,手移动过程中的位置,也就是终点。那么起点和终点怎么标识呢,我们可以定义一个 (x,y) 的坐标点。
type Point = {
X: number;
Y: number;
};
同时,我们将其放进一个坐标列表中,用于移动时的坐标点的更新和移除。
const points = ref<Point[]>([]);
准备工作都做好了,我们直接把画布拿过来吧,顺带设置一下全屏宽高和白底背景。
const instance = getCurrentInstance();
const ctx = ref<UniNamespace.CanvasContext>();
onLoad(() => {
//创建绘图对象
ctx.value = uni.createCanvasContext("signatureCanvas");
uni.getSystemInfo({
success: (res) => {
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.setFillStyle("white");
ctx.value.fillRect(0, 0, width, height);
},
});
});
首先我们把手放在屏幕上,这个时候画布就可以得到起始坐标点了,我们直接把它装进刚才准备好的坐标点列表里面。至于画笔的样式,如果你需要控制绘制线条的粗细,那么可以在每次开始绘制时,设置好画笔的粗细。
function touchstart(e: any) {
let startX = e.changedTouches[0].x;
let startY = e.changedTouches[0].y;
let startPoint = { X: startX, Y: startY };
//uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
points.value.push(startPoint);
//设置画笔样式
ctx.value.lineWidth = 4;
ctx.value.lineCap = "round";
ctx.value.lineJoin = "round";
}
然后开始移动你的手指,触摸移动的时候会记录下每个移动位置的坐标点,所以每一个移动位置的坐标点都是终点,我们也把这个终点存起来。有了起点和终点能做什么?当然是绘制成一条线啦。
function touchmove(e: any) {
let moveX = e.changedTouches[0].x;
let moveY = e.changedTouches[0].y;
let movePoint = { X: moveX, Y: moveY };
//存点
points.value.push(movePoint);
//绘制路径
draw();
}
function draw() {
if (points.value.length < 2) {
return;
}
isDraw.value = true;
const [start, end] = points.value;
points.value.shift();
ctx.value.beginPath();
ctx.value.moveTo(start.X, start.Y);
ctx.value.lineTo(end.X, end.Y);
ctx.value.stroke();
ctx.value.draw(true);
}
每次绘制完成后,我们把最后前一个起点擦掉,把上一个终点设置为起点,这样交替存储起点和终点的列表,不停的绘制线条,这样我们就得到一条路径轨迹。可以确定的是,这个起点和终点的列表始终会是两个,当列表中的值变成一个,即起点时,说明手指触摸停止了,这个时候我们的绘制也就结束了。
绘制停止后,我们也要移除起点,当然最直接的就是清空这个起点和终点的列表。
function touchend() {
points.value = [];
}
最后,我们把事件绑定到 Canvas 的事件上就行了。这里要注意的一点是,最好把 disable-scroll 属性设置为 true,这样在绘制签名的时候屏幕就不会滚动了,如果屏幕一旦滚动,那么绘制的线条就会变的断断续续,所以最好还是加上。
<canvas
class="signature-canvas"
canvas-id="signatureCanvas"
:disable-scroll="true"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
></canvas>
Canvas 获取
Canvas 获取只需要使用 uni.canvasToTempFilePath 方法传入 canvasId 就能够获取到签名图片的临时文件路径了,而这个路径在 H5 中是一串 base64 字符,在非 H5 中则是字符路径。
uni.canvasToTempFilePath({
canvasId: "signatureCanvas",
success: async (res) => {
const path = res.tempFilePath;
// #ifndef H5
uni.compressImage({
src: path,
rotate: 270,
success: (response) => {
const tempFilePath = response.tempFilePath;
console.log(tempFilePath);
//...
},
});
},
}, instance);
但是这样获取的图片实际上是竖屏的,因为是画布并没有横屏,所以我们需要处理一下图片,很简单逆时针旋转 90 度就行了。
方法有两种,一种是在布局中多增加一个隐藏的 Canvas 用于绘制和旋转图片,另一种是是用兼容性写法。这里我感觉前者需要新增一个多余的 Canvas 侵入性太强,因此我选择了用兼容性的方法实现。
从官方文档中,我们可以看到 uni.compressImage 的兼容性较为全面,只需要单独处理 H5 下的图片旋转就能够实现前者的相同的功能。但是,如果需要兼容鸿蒙下的元服务,那么请使用隐藏 Canvas 处理图片旋转。
在 H5 中可以使用 Javascript 创建一个原生的 Canvas 进行处理,而非 H5 中可以使用 uniapp API的 uni.compressImage 中的 rotate 参数对图片进行旋转。
这里需要注意的是uni.compressImage 中的 rotate 属性并不支持负数,所以我们只能顺时针旋转 270 度。
虽然 uni.compressImage 支持微信小程序,但是可惜的是其中的关键参数 rotate 在微信小程序上并不支持,所以我们还需要做单独的适配。
但是写到这里也不想多此一举用多余的 Canvas 处理绘制的图片。我们就只能使 uni.createOffscreenCanvas 和 用微信小程序原生的wx.canvasToTempFilePath API 创建一个离屏 Canvas 导入图片绘制,然后进行旋转图片再导出。这和增加一个额外的 canvas 是一样的,但是我们不需要处理这个 Canvas 的显示问题。
唯一的不足就是 uni.canvasToTempFilePath 不支持传入 Canvas 对象,只能使用不统一的
wx.canvasToTempFilePathAPI,而且在 TypeScript 中会提示类型警告,需要自行处理 wx 类型提示。
uni.canvasToTempFilePath(
{
canvasId: "signatureCanvas",
success: async (res) => {
const path = res.tempFilePath;
/* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
uni.compressImage({
src: path,
rotate: 270,
success: (response) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
/* #endif */
/* #ifdef H5 */
previewImage.value = await rotateToBase64File(path);
isShowModal.value = false;
/* #endif */
/* #ifdef MP-WEIXIN */
const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
const ctx2d = offCanvas.getContext("2d");
const img = offCanvas.createImage();
img.src = path;
img.onload = async () => {
const w = img.width;
const h = img.height;
offCanvas.width = h;
offCanvas.height = w;
ctx2d.translate(h / 2, w / 2);
ctx2d.rotate(-90);
ctx2d.drawImage(img, -w / 2, -h / 2, w, h);
// eslint-disable-next-line no-undef
wx.canvasToTempFilePath({
canvas: offCanvas,
success: (response: { tempFilePath: string }) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
};
/* #endif */
},
},
instance
);
// #ifdef H5
/**
* 将 Base64 图片逆时针旋转90度并转换为 File 对象
* @param {string} base64 - Base64 字符串
* @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
* @returns {Promise<string>} - 旋转后的 base64 图片
*/
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
const img = new Image();
img.src = base64;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(new Error("图片加载失败"));
});
const canvas = document.createElement("canvas");
canvas.width = img.height;
canvas.height = img.width;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法获取画布上下文");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return canvas.toDataURL(mimeType);
}
// #endif
Canvas 清除
当我们需要重新绘制 Canvas 时,需要 clearRect 方法清除所有内容,该方法需要传入整个画布的长宽高,这里我们可以简单通过 uni.getSystemInfo API 获取整个屏幕的宽高直接传入全屏清除。然后,这里我还进行了白色背景的填充,因为 Canvas 背景默认是透明的,所有原先我们填充的背景也会被清除掉,这里我们给它再填充回去。或者,不清除画布,直接填充白色背景应该也能达到同样的效果。
function clear() {
isDraw.value = false;
uni.getSystemInfo({
success: (res) => {
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.clearRect(0, 0, width, height);
ctx.value.setFillStyle("white");
ctx.value.fillRect(0, 0, width, height);
ctx.value.draw(true);
},
});
}
纯界面签名布局实例
一个界面中的 Canvas 全屏签名布局就做好了,完整代码如下
<template>
<view class="signature-modal" :style="{ height: remainingHeight + 'px' }">
<!-- 左侧工具栏 -->
<view class="tool-bar">
<view class="tool-wrapper">
<!-- 同意协议 -->
<view class="agreement-check" @click="changeAgreeChecked">
<view class="check-icon">
<uni-icons
:type="isAgree ? 'checkbox-filled' : 'circle'"
:color="isAgree ? '#2d99a1' : '#a6a6a6'"
size="16"
/>
</view>
<view class="agreement-label">我已认真阅读并同意</view>
<view class="agreement-link" @click.stop="openAgreement">《使用同意书》</view>
</view>
<!-- 按钮组 -->
<view class="btn-group">
<view class="btn btn-cancel" @click="cancel">取消</view>
<view class="btn btn-clear" @click="clear">重写</view>
<view class="btn btn-submit" @click="submit">提交</view>
</view>
</view>
</view>
<!-- 签名画布 -->
<canvas
class="signature-canvas"
canvas-id="signatureCanvas"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
></canvas>
<view class="canvas-title">
<text>手写签名</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";
import { ref, onMounted } from "vue";
import { getCurrentInstance } from "vue";
type Point = {
X: number;
Y: number;
};
const isAgree = ref<boolean>(false);
const remainingHeight = ref<number>(0);
const isDraw = ref<boolean>(false);
const points = ref<Point[]>([]);
const instance = getCurrentInstance();
const ctx = ref<UniNamespace.CanvasContext>();
onShow(() => {
isAgree.value = uni.getStorageSync("agreeOrNot");
if (!isAgree.value) {
return;
}
uni.setStorageSync("agreeOrNotNum", true);
});
onLoad(() => {
//创建绘图对象
ctx.value = uni.createCanvasContext("signatureCanvas", instance);
ctx.value.setFillStyle("white");
uni.getSystemInfo({
success: (res) => {
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.fillRect(0, 0, width, height);
},
});
//设置画笔样式
ctx.value.lineWidth = 4;
ctx.value.lineCap = "round";
ctx.value.lineJoin = "round";
});
onMounted(() => {
uni.getSystemInfo({
success(res) {
const screenHeight = res.windowHeight || res.screenHeight;
uni
.createSelectorQuery()
.select(".signature-modal")
.boundingClientRect((data) => {
remainingHeight.value = screenHeight - data.top;
})
.exec();
},
});
});
// 改变是否同意协议
function changeAgreeChecked() {
isAgree.value = !isAgree.value;
}
// 打开协议
function openAgreement() {
// uni.navigateTo({});
}
//触摸开始,获取到起点
function touchstart(e: any) {
let startX = e.changedTouches[0].x;
let startY = e.changedTouches[0].y;
let startPoint = { X: startX, Y: startY };
//uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
points.value.push(startPoint);
}
//触摸移动,获取路径点
function touchmove(e: any) {
let moveX = e.changedTouches[0].x;
let moveY = e.changedTouches[0].y;
let movePoint = { X: moveX, Y: moveY };
//存点
points.value.push(movePoint);
//绘制路径
draw();
}
// 触摸结束,将未绘制的点清空防止对后续路径产生干扰
function touchend() {
points.value = [];
}
/**
* 实时绘制笔迹
* 1. 实时性:必须在手指移动的同时立即绘制,否则会出现“断笔”或延迟。
* 2. 连续性:用“滑动窗口”思想推进绘制——每次只取路径数组前两个点,
* 将第一个点作为 moveTo 起点,第二个点作为 lineTo 终点,
* 绘制完成后把第一个点移除,使上一次的终点自然成为下一次的起点。
*
* - 本函数只负责“画一段”,外层应循环调用 draw() 直到 points.value.length < 2。
*/
function draw() {
if (points.value.length < 2) {
return;
}
isDraw.value = true;
const [start, end] = points.value;
points.value.shift();
ctx.value.beginPath();
ctx.value.moveTo(start.X, start.Y);
ctx.value.lineTo(end.X, end.Y);
ctx.value.stroke();
ctx.value.draw(true);
}
// 取消
function cancel() {
// uni.navigateTo({});
clear();
}
// 重写
function clear() {
isDraw.value = false;
uni.getSystemInfo({
success: (res) => {
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.clearRect(0, 0, width, height);
ctx.value.setFillStyle("white");
ctx.value.fillRect(0, 0, width, height);
ctx.value.draw(true);
},
});
}
// 提交
function submit() {
if (isDraw.value == false) {
uni.showToast({
title: "请先签名",
icon: "none",
});
return;
}
uni.canvasToTempFilePath(
{
canvasId: "signatureCanvas",
success: async (res) => {
const path = res.tempFilePath;
/* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
uni.compressImage({
src: path,
rotate: 270,
success: (response) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
/* #endif */
/* #ifdef H5 */
previewImage.value = await rotateToBase64File(path);
isShowModal.value = false;
/* #endif */
/* #ifdef MP-WEIXIN */
const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
const ctx2d = offCanvas.getContext("2d");
const img = offCanvas.createImage();
img.src = path;
img.onload = async () => {
const w = img.width;
const h = img.height;
offCanvas.width = h;
offCanvas.height = w;
ctx2d.translate(h / 2, w / 2);
ctx2d.rotate(-90);
ctx2d.drawImage(img, -w / 2, -h / 2, w, h);
// eslint-disable-next-line no-undef
wx.canvasToTempFilePath({
canvas: offCanvas,
success: (response: { tempFilePath: string }) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
};
/* #endif */
},
},
instance,
);
}
// #ifdef H5
/**
* 将 Base64 图片逆时针旋转90度并转换为 File 对象
* @param {string} base64 - Base64 字符串
* @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
* @returns {Promise<string>} - 旋转后的 base64 图片
*/
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
const img = new Image();
img.src = base64;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(new Error("图片加载失败"));
});
const canvas = document.createElement("canvas");
canvas.width = img.height;
canvas.height = img.width;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法获取画布上下文");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return canvas.toDataURL(mimeType);
}
// #endif
</script>
<style lang="scss" scoped>
.signature-modal {
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
background: #f5f5f5;
padding: 15rpx;
height: 100%;
/* 左侧工具栏 */
.tool-bar {
position: relative;
width: 100rpx;
height: 100rpx;
transform: rotate(90deg);
margin-right: 10rpx;
display: flex;
align-items: center;
justify-content: center;
.tool-wrapper {
display: flex;
.agreement-check {
width: 500rpx;
display: flex;
justify-content: center;
align-items: center;
.check-icon {
margin-right: 15rpx;
}
.agreement-label {
font-size: 26rpx;
color: #a6a6a6;
}
.agreement-link {
font-size: 26rpx;
color: #1684fc;
}
}
.btn-group {
display: flex;
.btn {
white-space: nowrap;
padding: 10rpx 50rpx;
border-radius: 28rpx;
font-size: 26rpx;
font-weight: 500;
margin-right: 47rpx;
display: flex;
justify-content: center;
align-items: center;
&.btn-cancel {
background: #ffffff;
color: #666666;
}
&.btn-clear {
background: #959595;
color: #ffffff;
margin-right: 15rpx;
}
&.btn-submit {
background: #2d99a1;
color: #ffffff;
}
}
}
}
}
/* 签名画布 */
.signature-canvas {
flex: 1;
height: 100%;
background-color: #fff;
border-radius: 5px;
}
/* 右侧竖排标题 */
.canvas-title {
transform: translateY(-50%) rotate(90deg);
font-size: 26rpx;
font-weight: 400;
color: #333;
}
}
</style>
预览效果:
不过,现在只有一个界面,如果直接使用那么还需要对界面进行二次跳转回传签名数据,用户使用体验并不好。所以,还是建议封装成一个签名组件,全屏签名用弹窗的形式显示,签名完成后保留一个签名预览和重新签名的界面。
封装签名组件
模拟横屏 Toast
首先,因为 uniapp 的 toast 并不支持横屏,因此这里我们需要专门为横屏签名写一个横屏的 Toast 提示,模拟横屏签名下的横屏 Toast 显示。
<template>
<!-- 横屏 Toast -->
<view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
<view class="toast-content">
<text>{{ toastMsg }}</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
const isShowToast = ref<boolean>(false);
const toastMsg = ref<string>("");
function showToast(msg: string) {
toastMsg.value = msg;
isShowToast.value = true;
setTimeout(() => (isShowToast.value = false), 1500);
}
<style lang="scss" scoped>
/* 横屏Toast */
.landscape-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
background-color: #585858;
color: #ffffff;
padding: 12px 20px;
border-radius: 5px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
z-index: 9999;
pointer-events: none;
.toast-content {
display: flex;
align-items: center;
gap: 8px;
}
}
</style>
增加签名预览
为了更好地显示签名效果,可以为其增加空白签名界面作为入口,签名完成时再显示签名图片预览。原有的横屏签名默认隐藏,当点击空白区域开始签名时固定覆盖显示在最上层。
<template>
<view class="signature-container">
<!-- 签名空白区域 -->
<view class="signature-empty" v-if="!previewImage">
<view class="empty-blank" @click="() => (isShowModal = true)">点击此处签名</view>
</view>
<!-- 签名预览区域 -->
<view class="signature-preview" v-else>
<view class="tool-bar">
<text>签名预览:</text>
<text class="resign-btn" @click="reSign">重签</text>
</view>
<image :src="previewImage" mode="scaleToFill" />
</view>
</view>
<!-- 全屏签名弹窗 -->
<view class="signature-modal" v-if="isShowModal" :style="{ height: remainingHeight + 'px' }">
...
</view>
<!-- 横屏 Toast -->
<view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
<view class="toast-content">
<text>{{ toastMsg }}</text>
</view>
</view>
</template>
组件传参
然后我们为组件定义两个参数,用来传递签名图片和协议同意书的界面跳转链接。
const { agreeLink } = defineProps<{ agreeLink: string }>();
const previewImage = defineModel<string>({ default: "" });
最后,我们只需要在父组件中引入签名组件就好了。
<template>
<!-- 签名板 -->
<signature v-model="previewImage" :agree-link="agreeLink" />
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import signature from '@/components/signature.vue';
const agreeLink = ref('pages/user/consentForm');
/** 签名图片(base64 或 临时文件路径 tempPath) */
const previewImage = ref('');
</script>
组件完整代码
<template>
<!-- #ifdef MP-WEIXIN -->
<page-meta :page-style="isShowModal ? 'overflow:hidden;' : 'overflow:auto'">
<!-- #endif -->
<view class="signature-container">
<!-- 签名空白区域 -->
<view class="signature-empty" v-if="!previewImage">
<view class="empty-blank" @click="reSign">点击此处签名</view>
</view>
<!-- 签名预览区域 -->
<view class="signature-preview" v-else>
<view class="tool-bar">
<text>签名预览:</text>
<text class="resign-btn" @click="reSign">重签</text>
</view>
<image :src="previewImage" mode="scaleToFill" />
</view>
</view>
<!-- 全屏签名弹窗 -->
<view
class="signature-modal"
v-if="isShowModal"
:style="{ height: remainingHeight + 'px' }"
@touchmove.prevent
>
<!-- 左侧工具栏 -->
<view class="tool-bar">
<view class="tool-wrapper">
<!-- 同意协议 -->
<view class="agreement-check" @click="changeAgreeChecked">
<view class="check-icon">
<uni-icons
:type="isAgree ? 'checkbox-filled' : 'circle'"
:color="isAgree ? '#2d99a1' : '#a6a6a6'"
size="16"
/>
</view>
<view class="agreement-label">我已认真阅读并同意</view>
<view class="agreement-link" @click.stop="openAgreement">《使用同意书》</view>
</view>
<!-- 按钮组 -->
<view class="btn-group">
<view class="btn btn-cancel" @click="cancel">取消</view>
<view class="btn btn-clear" @click="clear">重写</view>
<view class="btn btn-submit" @click="submit">提交</view>
</view>
</view>
</view>
<!-- 签名画布 -->
<canvas
class="signature-canvas"
canvas-id="signatureCanvas"
:disable-scroll="true"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
></canvas>
<view class="canvas-title">
<text>手写签名</text>
</view>
</view>
<!-- 横屏 Toast -->
<view id="landscapeToast" class="landscape-toast" v-if="isShowToast">
<view class="toast-content">
<text>{{ toastMsg }}</text>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
</page-meta>
<!-- #endif -->
</template>
<script lang="ts" setup>
import { onLoad, onShow } from "@dcloudio/uni-app";
import { ref, onMounted, type ComponentInternalInstance, watch } from "vue";
import { getCurrentInstance } from "vue";
type Point = {
X: number;
Y: number;
};
const { agreeLink } = defineProps<{ agreeLink: string }>();
const previewImage = defineModel<string>({ default: "" });
const toastMsg = ref<string>("");
const remainingHeight = ref<number>(0);
const isDraw = ref<boolean>(false);
const isAgree = ref<boolean>(false);
const isShowToast = ref<boolean>(false);
const isShowModal = ref<boolean>(false);
const points = ref<Point[]>([]);
const ctx = ref<UniNamespace.CanvasContext>();
const instance: Readonly<ComponentInternalInstance | null> = getCurrentInstance();
watch(isShowModal, (val) => (val ? lockScroll() : unlockScroll()));
onShow(() => {
isAgree.value = uni.getStorageSync("agreeOrNot");
if (!isAgree.value) {
return;
}
uni.setStorageSync("agreeOrNotNum", true);
});
onLoad(() => {
//创建绘图对象
ctx.value = uni.createCanvasContext("signatureCanvas", instance);
uni.getSystemInfo({
success: (res) => {
if (!ctx.value) {
return;
}
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.setFillStyle("white");
ctx.value.fillRect(0, 0, width, height);
},
});
});
onMounted(() => {
uni.getSystemInfo({
success(res) {
const screenHeight = res.windowHeight || res.screenHeight;
uni
.createSelectorQuery()
.select(".signature-container")
.boundingClientRect((data) => {
remainingHeight.value = screenHeight - data.top;
})
.exec();
},
});
});
// 改变是否同意协议
function changeAgreeChecked() {
isAgree.value = !isAgree.value;
}
// 打开协议
function openAgreement() {
uni.navigateTo({ url: agreeLink });
}
//触摸开始,获取到起点
function touchstart(e: any) {
if (!ctx.value) {
return;
}
let startX = e.changedTouches[0].x;
let startY = e.changedTouches[0].y;
let startPoint = { X: startX, Y: startY };
//uni对canvas的实现有所不同,需要把起点先存起来,不然会少一段起点绘制
points.value.push(startPoint);
//设置画笔样式
ctx.value.lineWidth = 4;
ctx.value.lineCap = "round";
ctx.value.lineJoin = "round";
}
//触摸移动,获取路径点
function touchmove(e: any) {
let moveX = e.changedTouches[0].x;
let moveY = e.changedTouches[0].y;
let movePoint = { X: moveX, Y: moveY };
//存点
points.value.push(movePoint);
//绘制路径
draw();
}
// 触摸结束,将未绘制的点清空防止对后续路径产生干扰
function touchend() {
points.value = [];
}
/**
* 实时绘制笔迹
* 1. 实时性:必须在手指移动的同时立即绘制,否则会出现“断笔”或延迟。
* 2. 连续性:用“滑动窗口”思想推进绘制——每次只取路径数组前两个点,
* 将第一个点作为 moveTo 起点,第二个点作为 lineTo 终点,
* 绘制完成后把第一个点移除,使上一次的终点自然成为下一次的起点。
*
* - 本函数只负责“画一段”,外层应循环调用 draw() 直到 points.value.length < 2。
*/
function draw() {
if (points.value.length < 2) {
return;
}
if (!ctx.value) {
return;
}
isDraw.value = true;
const [start, end] = points.value;
points.value.shift();
ctx.value.beginPath();
ctx.value.moveTo(start.X, start.Y);
ctx.value.lineTo(end.X, end.Y);
ctx.value.stroke();
ctx.value.draw(true);
}
// 取消
function cancel() {
isShowModal.value = false;
clear();
}
// 重写
function clear() {
isDraw.value = false;
uni.getSystemInfo({
success: (res) => {
if (!ctx.value) {
return;
}
const width = res.windowWidth;
const height = res.windowHeight;
ctx.value.clearRect(0, 0, width, height);
ctx.value.setFillStyle("white");
ctx.value.fillRect(0, 0, width, height);
ctx.value.draw(true);
},
});
}
// 提交
function submit() {
if (isDraw.value == false) {
showToast("请先签名");
return;
}
if (!isAgree.value) {
showToast("请先勾选签名同意书");
return;
}
uni.canvasToTempFilePath(
{
canvasId: "signatureCanvas",
success: async (res) => {
const path = res.tempFilePath;
/* #ifdef APP-NVUE ||APP-PLUS ||APP-PLUS-NVUE */
uni.compressImage({
src: path,
rotate: 270,
success: (response) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
/* #endif */
/* #ifdef H5 */
previewImage.value = await rotateToBase64File(path);
isShowModal.value = false;
/* #endif */
/* #ifdef MP-WEIXIN */
// 创建离屏Canvas
const offCanvas: any = uni.createOffscreenCanvas({ type: "2d" });
const ctx2d = offCanvas.getContext("2d");
// 加载图片
const img = offCanvas.createImage();
img.src = path;
img.onload = async () => {
const w = img.width;
const h = img.height;
// 画布尺寸交换
offCanvas.width = h;
offCanvas.height = w;
// 逆时针 90°
ctx2d.translate(h / 2, w / 2);
ctx2d.rotate(-90);
ctx2d.drawImage(img, -w / 2, -h / 2, w, h);
// 导出临时文件
// eslint-disable-next-line no-undef
wx.canvasToTempFilePath({
canvas: offCanvas,
success: (response: { tempFilePath: string }) => {
isShowModal.value = false;
previewImage.value = response.tempFilePath;
},
});
};
/* #endif */
},
},
instance,
);
}
//重签
function reSign() {
isDraw.value = false;
isShowModal.value = true;
previewImage.value = "";
clear();
}
// #ifdef H5
/**
* 将 Base64 图片逆时针旋转90度并转换为 File 对象
* @param {string} base64 - Base64 字符串
* @param {string} [mimeType] - 文件类型(可选,默认为 image/png)
* @returns {Promise<string>} - 旋转后的 base64 图片
*/
async function rotateToBase64File(base64: string, mimeType: string = "image/png"): Promise<string> {
const img = new Image();
img.src = base64;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("图片加载失败"));
});
const canvas = document.createElement("canvas");
canvas.width = img.height;
canvas.height = img.width;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法获取画布上下文");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return canvas.toDataURL(mimeType);
}
// #endif
function showToast(msg: string) {
toastMsg.value = msg;
isShowToast.value = true;
setTimeout(() => (isShowToast.value = false), 1500);
}
/* 禁止/恢复页面滚动 */
function lockScroll() {
/* #ifdef H5 */
document.body.classList.add("lock-scroll");
/* #endif */
}
function unlockScroll() {
/* #ifdef H5 */
document.body.classList.remove("lock-scroll");
/* #endif */
}
</script>
<style lang="scss" scoped>
.signature-empty {
width: 100%;
box-sizing: border-box;
padding: 20rpx;
.empty-blank {
width: 100%;
height: 400rpx;
border: 4rpx dashed #c4c4c4;
box-sizing: border-box;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #c4c4c4;
}
}
.signature-preview {
width: 100%;
box-sizing: border-box;
padding: 20rpx;
.tool-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15rpx;
.resign-btn {
background-color: #2d99a1;
color: #ffffff;
font-size: 24rpx;
padding: 10rpx 25rpx;
border-radius: 40rpx;
}
}
image {
width: 100%;
height: 400rpx;
border: 4rpx dashed #c4c4c4;
box-sizing: border-box;
border-radius: 20rpx;
}
}
.signature-modal {
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
background: #f5f5f5;
padding: 15rpx;
height: 100%;
z-index: 10;
position: fixed;
top: 0;
/* 左侧工具栏 */
.tool-bar {
position: relative;
width: 100rpx;
height: 100rpx;
transform: rotate(90deg);
margin-right: 10rpx;
display: flex;
align-items: center;
justify-content: center;
.tool-wrapper {
display: flex;
.agreement-check {
width: 500rpx;
display: flex;
justify-content: center;
align-items: center;
.check-icon {
margin-right: 15rpx;
}
.agreement-label {
font-size: 26rpx;
color: #a6a6a6;
}
.agreement-link {
font-size: 26rpx;
color: #1684fc;
}
}
.btn-group {
display: flex;
.btn {
white-space: nowrap;
padding: 10rpx 50rpx;
border-radius: 28rpx;
font-size: 26rpx;
font-weight: 500;
margin-right: 47rpx;
display: flex;
justify-content: center;
align-items: center;
&.btn-cancel {
background: #ffffff;
color: #666666;
}
&.btn-clear {
background: #959595;
color: #ffffff;
margin-right: 15rpx;
}
&.btn-submit {
background: #2d99a1;
color: #ffffff;
}
}
}
}
}
/* 签名画布 */
.signature-canvas {
flex: 1;
height: 100%;
background-color: #fff;
border-radius: 20rpx;
}
/* 右侧竖排标题 */
.canvas-title {
transform: translateY(-50%) rotate(90deg);
font-size: 26rpx;
font-weight: 400;
color: #333;
}
}
/* 横屏Toast */
.landscape-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
background-color: #585858;
color: #ffffff;
padding: 12px 20px;
border-radius: 5px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
z-index: 9999;
pointer-events: none;
s .toast-content {
display: flex;
align-items: center;
gap: 8px;
}
}
.lock-scroll {
overflow: hidden;
height: 100vh;
}
</style>
实现效果预览: