文章标题
01 功能说明
技术栈:uniapp、vue、canvas 2d
需求:
- 实现横版的全名字帖式米字格签名组件,竖版逐字的字帖式米字格签名组件;
- 支持配置文字描述、画笔颜色、画笔大小等;
- 提供 submit 事件,当点击提交按钮时触发,回调参数是canvas转化为图片的地址;
02 效果预览
2.1 横版
2.2 竖版
03 使用方式
// 使用横向签名------------------------
<template>
<HorizontalSign signText="赵钱孙" @submit="handleSubmit" />
</template>
<script>
import HorizontalSign from '@/components/HorizontalSign.vue';
export default {
components: { HorizontalSign },
methods: {
handleSubmit(imagePath) {
console.log('--image--', imagePath);
},
},
}
<script>
// 使用竖向签名------------------------
<template>
<VerticalSign signText="赵钱孙" @submit="handleSubmit" />
</template>
<script>
import VerticalSign from '@/components/VerticalSign.vue';
export default {
components: { VerticalSign },
methods: {
handleSubmit(imagePath) {
console.log('--image--', imagePath);
},
},
}
<script>
04 横向签名组件源码
4.1 html 代码
<template>
<view class="wrapping">
<!-- header 定位后以左上角顺时针旋转 90° -->
<view class="header-title flex col-center">
<!-- <text> 签名:</text> -->
<!-- 预览图片(图片本身是正向的,但由于父元素旋转了90°所以正好能横向观看) -->
<!-- <image :src="previewImage" mode="aspectFit" class="small-preview" /> -->
<text class="desc-text">{{ description }}</text>
</view>
<!-- 实际保持直立正向 canvas 容器 -->
<view class="canvas-wrapper">
<!-- 只展示限制数量文字的米字格,超过配置数量文字则不展示 -->
<view class="char-group flex-col flex-center" v-if="signText && signText.length <= riceGridLimit">
<view class="char-box" v-for="(item, index) in signText" :key="index">
{{ item }}
</view>
</view>
<canvas
id="signatureCanvas"
type="2d"
class="signature-canvas"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
disable-scroll
></canvas>
</view>
<!-- footer 定位后以右下角顺时针旋转 90° -->
<view class="footer-btn flex">
<view class="action-btn" @click="resetCanvas">重签</view>
<view class="action-btn submit-btn" @click="handleSubmit">{{ submitText }}</view>
</view>
<!--用于绘制并生成旋转为正向签名图片的 canvas 容器-->
<canvas id="previewCanvas" type="2d" class="preview-canvas"></canvas>
</view>
</template>
4.2 业务 Js
<script>
export default {
props: {
description: {
type: String,
default: '请使用正楷字体,逐字签写', // 文字描述
},
submitText: {
type: String,
default: '提交', // 提交按钮文字
},
dotSize: {
type: Number,
default: 4, // 签名笔大小
},
penColor: {
type: String,
default: '#000000', // 签名笔颜色
},
signText: {
type: String,
default: '', // 签名文字
},
riceGridLimit: {
type: Number,
default: 3, // 米字格展示字数最大限制
},
},
data() {
return {
mainCtx: null,
mainCanvas: null,
isDrawing: false,
touchPoints: [],
signIsMove: false,
previewImage: '',
canvasRatio: 1,
};
},
mounted() {
this.canvasRatio = uni.getWindowInfo().pixelRatio ?? 1;
this.initCanvas();
},
methods: {
initCanvas() {
const domItem = uni.createSelectorQuery().in(this).select('#signatureCanvas');
domItem.fields({ node: true, size: true }).exec((res) => {
// Canvas 对象
this.mainCanvas = res[0]?.node;
// 渲染上下文
this.mainCtx = this.mainCanvas.getContext('2d');
// Canvas 画布的实际绘制宽高
const width = res[0].width;
const height = res[0].height;
// 初始化画布大小
this.mainCanvas.width = width * this.canvasRatio;
this.mainCanvas.height = height * this.canvasRatio;
this.mainCtx.scale(this.canvasRatio, this.canvasRatio);
this.setPen();
});
},
setPen() {
this.mainCtx.strokeStyle = this.penColor;
this.mainCtx.lineWidth = this.dotSize;
this.mainCtx.lineCap = 'round';
this.mainCtx.lineJoin = 'round';
},
handleTouchStart(e) {
const point = {
x: e.changedTouches[0].x,
y: e.changedTouches[0].y,
};
this.touchPoints.push(point);
this.isDrawing = true;
},
handleTouchMove(e) {
if (!this.isDrawing) return;
const point = {
x: e.touches[0].x,
y: e.touches[0].y,
};
this.touchPoints.push(point);
const len = this.touchPoints.length;
if (len >= 2) {
const prevPoint = this.touchPoints[len - 2];
const currentPoint = this.touchPoints[len - 1];
this.mainCtx.beginPath();
this.mainCtx.moveTo(prevPoint.x, prevPoint.y);
this.mainCtx.lineTo(currentPoint.x, currentPoint.y);
this.mainCtx.stroke();
this.signIsMove = true;
}
},
handleTouchEnd() {
this.isDrawing = false;
this.touchPoints = [];
},
resetCanvas() {
if (!this.signIsMove) {
return;
}
this.mainCtx.clearRect(0, 0, 1000, 1000);
this.setPen();
this.touchPoints = [];
this.previewImage = '';
this.signIsMove = false;
},
async handleSubmit() {
if (!this.signIsMove) {
uni.showToast({ title: '请先完成签名', icon: 'none' });
return;
}
try {
const _this = this;
uni.canvasToTempFilePath({
canvas: this.mainCanvas,
quality: 1,
fileType: 'png',
success: (res) => {
let path = res.tempFilePath;
_this.handlePreviewImage(path);
},
fail: (res) => {
uni.showToast({ title: '提交失败,请重新尝试', icon: 'none' });
},
});
} catch (err) {
uni.showToast({ title: '签名失败,请重试', icon: 'none' });
} finally {
uni.hideLoading();
}
},
handlePreviewImage(imagePath) {
const _this = this;
const previewDom = uni.createSelectorQuery().in(_this).select('#previewCanvas');
previewDom.fields({ node: true, size: true }).exec((res) => {
// Canvas 对象
const canvas = res[0]?.node;
// 渲染上下文
const previewCtx = canvas.getContext('2d');
const image = canvas.createImage();
image.src = imagePath;
image.onload = () => {
let { width, height } = image;
// 获取图片的宽高初始画布,canvas交换宽高
canvas.width = height;
canvas.height = width;
// 设置白色背景
previewCtx.fillStyle = '#FFFFFF';
previewCtx.fillRect(0, 0, height, width);
// 图片逆时针旋转90度,且换为弧度
previewCtx.rotate((-90 * Math.PI) / 180);
// 旋转后调整绘制的位置下移一个宽度的距离
previewCtx.drawImage(image, -width, 0);
};
// 最终导出
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvas,
fileType: 'png', // 指定文件类型
quality: 1, // 最高质量
success: (res) => {
_this.previewImage = res.tempFilePath;
uni.previewImage({ urls: [res.tempFilePath], current: 0 });
_this.$emit('submit', res.tempFilePath);
},
fail: (err) => {
uni.showToast({ title: '合成失败,请重试', icon: 'none' });
},
},
_this
);
}, 300); // 增加最终导出前的延迟
});
},
},
};
</script>
4.3 样式 Css
<style scoped>
.wrapping {
position: relative;
padding: 20rpx;
margin: 20rpx;
background-color: #fff;
box-sizing: border-box;
}
.header-title {
position: absolute;
right: 20rpx;
top: 20rpx;
height: 50rpx;
z-index: 1000;
transform-origin: top left;
transform: translateX(100%) rotate(90deg);
font-size: 32rpx;
color: #333;
}
.desc-text {
color: #969799;
}
.small-preview {
width: 100rpx;
height: 50rpx;
border-bottom: 1px solid #333;
}
.canvas-wrapper {
position: relative;
margin: auto;
width: 60%;
height: 80vh;
background: #f7f8fa;
}
.char-group {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
z-index: 1;
gap: 20rpx;
}
.char-box {
padding: 36rpx;
width: 30vw;
height: 30vw;
transform: rotate(90deg);
font-size: 30vw;
line-height: 30vw;
text-align: center;
color: #eeeeee;
/* 使用虚线边框框住字体 */
/* border: 1px dashed #ccc; */
/* 使用米字格照片当背景图 */
background: url('https://img1.baidu.com/it/u=2622499137,3527900847&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500') no-repeat;
background-size: 100%;
text-shadow: 1px 1px black, -1px -1px black, 1px -1px black, -1px 1px black;
}
.signature-canvas {
position: relative;
width: 100%;
height: 100%;
z-index: 2;
}
.footer-btn {
position: absolute;
left: 20rpx;
bottom: 20rpx;
transform-origin: bottom right;
transform: translateX(-100%) rotate(90deg);
z-index: 1000;
gap: 32rpx;
}
.action-btn {
text-align: center;
width: 200rpx;
height: 96rpx;
border-radius: 100rpx;
font-size: 32rpx;
line-height: 96rpx;
color: #3874f6;
border: 2rpx solid #3874f6;
background: #fff;
}
.submit-btn {
color: #fff;
border: 2rpx solid #3874f6;
background: #3874f6;
}
.preview-canvas {
visibility: hidden;
position: fixed;
/* 将画布移出展示区域 */
top: 100vh;
left: 100vw;
opacity: 0;
z-index: 0;
}
</style>
05 竖向签名组件源码
5.1 布局 Html
<template>
<view class="signature-container">
<view class="desc-text">{{ description }}</view>
<view class="signature-area">
<view class="canvas-wrapper">
<!-- 逐字展示文字 -->
<view class="char-box" v-if="signText && currentCharIndex < signText.length">
{{ signText[currentCharIndex] }}
</view>
<canvas
id="signatureCanvas"
class="signature-canvas"
type="2d"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
disable-scroll
></canvas>
</view>
<view class="action-box">
<view class="action-btn" v-if="currentCharIndex > 0" @click="prevChar">上一字</view>
<view class="action-btn" @click="resetCanvas">清空画板</view>
<view class="action-btn" v-if="currentCharIndex < signText.length" @click="nextChar">
{{ currentCharIndex < signText.length - 1 ? '下一字' : '确认' }}
</view>
</view>
</view>
<view class="preview-title">逐字预览</view>
<view class="preview-content">
<image v-for="(img, index) in previewImages" :key="index" :src="img" mode="aspectFit" class="preview-char" />
</view>
<view class="action-box">
<view class="action-btn submit-btn" @click="resetAllRecord">全部重签</view>
<view class="action-btn submit-btn" @click="handleSubmit">{{ submitText }}</view>
</view>
<!--用于拼接合并为完整签名图片的 canvas 容器-->
<canvas id="previewCanvas" type="2d" class="preview-canvas"></canvas>
</view>
</template>
5.2 业务 Js
<script>
export default {
props: {
description: {
type: String,
default: '请使用正楷字体,逐字签写', // 文字描述
},
submitText: {
type: String,
default: '提交', // 提交按钮文字
},
dotSize: {
type: Number,
default: 4, // 签名笔大小
},
penColor: {
type: String,
default: '#000000', // 签名笔颜色
},
signText: {
type: String,
default: '', // 签名文字
},
},
data() {
return {
mainCtx: null,
mainCanvas: null,
isDrawing: false,
touchPoints: [],
allTouchPoints: [],
signIsMove: false,
currentCharIndex: 0,
canvasRatio: 1,
previewImages: [],
};
},
mounted() {
this.canvasRatio = uni.getWindowInfo().pixelRatio ?? 1;
this.initCanvas();
},
methods: {
initCanvas() {
const domItem = uni.createSelectorQuery().in(this).select('#signatureCanvas');
domItem.fields({ node: true, size: true }).exec((res) => {
// Canvas 对象
this.mainCanvas = res[0]?.node;
// 渲染上下文
this.mainCtx = this.mainCanvas.getContext('2d');
// Canvas 画布的实际绘制宽高
const width = res[0].width;
const height = res[0].height;
// 初始化画布大小
this.mainCanvas.width = width * this.canvasRatio;
this.mainCanvas.height = height * this.canvasRatio;
this.mainCtx.scale(this.canvasRatio, this.canvasRatio);
this.setPen();
});
},
setPen() {
this.mainCtx.strokeStyle = this.penColor;
this.mainCtx.lineWidth = this.dotSize;
this.mainCtx.lineCap = 'round';
this.mainCtx.lineJoin = 'round';
},
handleTouchStart(e) {
const point = {
x: e.changedTouches[0].x,
y: e.changedTouches[0].y,
};
this.touchPoints.push(point);
this.allTouchPoints.push(point);
this.isDrawing = true;
},
handleTouchMove(e) {
if (!this.isDrawing) return;
const point = {
x: e.touches[0].x,
y: e.touches[0].y,
};
this.touchPoints.push(point);
this.allTouchPoints.push(point);
const len = this.touchPoints.length;
if (len >= 2) {
const prevPoint = this.touchPoints[len - 2];
const currentPoint = this.touchPoints[len - 1];
this.mainCtx.beginPath();
this.mainCtx.moveTo(prevPoint.x, prevPoint.y);
this.mainCtx.lineTo(currentPoint.x, currentPoint.y);
this.mainCtx.stroke();
this.signIsMove = true;
}
},
handleTouchEnd() {
this.isDrawing = false;
this.touchPoints = [];
},
getRectangle(points) {
// 计算每个字符的实际大小
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let point of points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
},
prevChar() {
if (this.previewImages.length > 0) {
this.previewImages.pop();
this.currentCharIndex--;
this.resetCanvas();
}
},
nextChar() {
if (!this.signIsMove) {
uni.showToast({ title: '请先完成签名', icon: 'none' });
return;
}
try {
const { x, y, width, height } = this.getRectangle(this.allTouchPoints);
const offset = 10;
const _this = this;
uni.canvasToTempFilePath(
{
canvas: this.mainCanvas,
x: x - offset,
y: y - offset,
width: width + offset * 2,
height: height + offset * 2,
success: (res) => {
_this.previewImages.push(res.tempFilePath);
_this.currentCharIndex++;
_this.resetCanvas();
},
fail: () => {
uni.showToast({ title: '提交失败,请重新尝试', icon: 'none' });
},
},
_this
);
} catch (err) {
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
}
},
resetCanvas() {
this.mainCtx.clearRect(0, 0, 1000, 1000);
this.setPen();
this.touchPoints = [];
this.allTouchPoints = [];
this.signIsMove = false;
},
resetAllRecord() {
this.previewImages = [];
this.currentCharIndex = 0;
this.resetCanvas();
},
async handleSubmit() {
if (this.previewImages.length <= 0) {
uni.showToast({ title: '请至少签写一个字', icon: 'none' });
return;
}
try {
this.handlePreviewImage();
} catch (err) {
uni.showToast({ title: '合成失败,请重试', icon: 'none' });
}
},
handlePreviewImage() {
const _this = this;
const previewDom = uni.createSelectorQuery().in(_this).select('#previewCanvas');
previewDom.fields({ node: true, size: true }).exec((res) => {
// Canvas 对象
const canvas = res[0]?.node;
// 渲染上下文
const previewCtx = canvas.getContext('2d');
// 计算总宽度和单个字的尺寸
const charWidth = 300 / this.previewImages.length;
const charHeight = 300 / this.previewImages.length;
const totalWidth = charWidth * this.previewImages.length;
// 设置白色背景
previewCtx.fillStyle = '#FFFFFF';
previewCtx.fillRect(0, 0, totalWidth, charHeight);
// 按顺序绘制每个图片
for (let i = 0; i < this.previewImages.length; i++) {
const image = canvas.createImage();
image.src = this.previewImages[i];
image.onload = () => {
const x = i * charWidth;
// 绘制当前图片
previewCtx.drawImage(image, x, 0, charWidth, charHeight);
};
}
// 最终导出
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvas,
x: 0,
y: 0,
width: totalWidth,
height: charHeight,
fileType: 'png', // 指定文件类型
quality: 1, // 最高质量
success: (res) => {
uni.previewImage({ urls: [res.tempFilePath], current: 0 });
_this.$emit('submit', res.tempFilePath);
},
fail: (err) => {
uni.showToast({ title: '合成失败,请重试', icon: 'none' });
},
},
_this
);
}, 300); // 增加最终导出前的延迟
});
},
},
};
</script>
5.3 样式 Css
<style scoped>
.signature-container {
padding: 0 20rpx 40rpx 20rpx;
background-color: #f5f5f5;
box-sizing: border-box;
}
.signature-area {
padding: 50rpx;
background-color: #fff;
box-sizing: border-box;
}
.desc-text {
padding: 20rpx 0;
font-size: 32rpx;
color: #333;
text-align: center;
box-sizing: border-box;
}
.canvas-wrapper {
position: relative;
width: 100%;
/* 保持宽高比 */
aspect-ratio: 1;
/* height: 600rpx; */
background: #fff;
/* 使用虚线边框框住字体 */
/* border: 1px dashed #ccc; */
/* 使用米字格照片当背景图 */
background: url('https://img1.baidu.com/it/u=2622499137,3527900847&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500') no-repeat;
background-size: 100%;
}
.char-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 400rpx;
text-shadow: 1px 1px black, -1px -1px black, 1px -1px black, -1px 1px black;
color: #eeeeee;
pointer-events: none;
user-select: none;
z-index: 1;
}
.signature-canvas {
position: relative;
width: 100%;
height: 100%;
z-index: 2;
}
.action-box {
display: flex;
margin-top: 32rpx;
gap: 20rpx;
}
.action-btn {
flex: 1;
text-align: center;
padding: 16rpx 30rpx;
font-size: 28rpx;
color: #3874f6;
border: 2rpx solid #3874f6;
border-radius: 80rpx;
box-sizing: border-box;
}
.submit-btn {
background: #3874f6;
color: #fff;
}
.preview-title {
margin-top: 32rpx;
width: 100%;
text-align: center;
font-size: 28rpx;
color: #666;
}
.preview-content {
display: flex;
flex-wrap: wrap;
margin-top: 20rpx;
background-color: #fff;
padding: 20rpx 20rpx 0 20rpx;
min-height: 190rpx;
box-sizing: border-box;
}
.preview-char {
width: 150rpx;
height: 150rpx;
margin-right: 19rpx;
margin-bottom: 20rpx;
}
.preview-canvas {
position: fixed;
left: -2000px;
width: 300px;
height: 300px;
}
</style>