772 lines
25 KiB
Vue
772 lines
25 KiB
Vue
<template>
|
||
<view
|
||
class="u-signature"
|
||
:class="[landscape ? 'u-signature-landscape' : '']"
|
||
:style="[containerStyle, $u.addStyle(customStyle)]"
|
||
@touchmove.prevent.stop
|
||
@wheel.prevent.stop
|
||
>
|
||
<view v-if="showTitle" class="u-signature__title" :class="[{'u-signature__title-fixed': fixed}]">
|
||
<slot name="title">
|
||
<text class="u-signature__title-text">{{ title }}</text>
|
||
</slot>
|
||
</view>
|
||
|
||
<view class="u-signature__canvas">
|
||
<canvas
|
||
:type="type"
|
||
:canvas-id="canvasId"
|
||
:id="canvasId"
|
||
:style="[canvasStyle]"
|
||
:class="['u-signature__canvas']"
|
||
:disable-scroll="disableScroll"
|
||
@touchstart="onCanvasTouchStart"
|
||
@touchmove="onCanvasTouchMove"
|
||
@touchend="onCanvasTouchEnd"
|
||
@touchcancel="onCanvasTouchCancel" >
|
||
</canvas>
|
||
</view>
|
||
|
||
<view v-if="showToolbar"
|
||
class="u-signature__toolbar"
|
||
:class="[{'u-signature__toolbar-fixed': fixed}]"
|
||
:style="[$u.addStyle(toolbarStyle)]"
|
||
>
|
||
<slot name="toolbar">
|
||
<view class="u-signature__toolbar-left">
|
||
<view v-if="showColorList" class="u-signature__toolbar-item">
|
||
<view class="u-signature__toolbar-color-list">
|
||
<view
|
||
v-for="(item, index) in penColorList"
|
||
:key="index"
|
||
class="u-signature__toolbar-color"
|
||
:style="[
|
||
{
|
||
backgroundColor: penColorInner === item ? 'transparent' : item,
|
||
borderColor: item,
|
||
},
|
||
]"
|
||
@click="handlePenColor(item)"
|
||
></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="u-signature__toolbar-right">
|
||
<view class="u-signature__toolbar-item">
|
||
<u-button
|
||
type="info"
|
||
icon="trash"
|
||
@click="handleClear"
|
||
>
|
||
{{ clearText }}
|
||
</u-button>
|
||
</view>
|
||
<view class="u-signature__toolbar-item">
|
||
<u-button
|
||
type="info"
|
||
icon="back"
|
||
@click="handleUndo"
|
||
>
|
||
{{ undoText }}
|
||
</u-button>
|
||
</view>
|
||
<view class="u-signature__toolbar-item">
|
||
<u-button
|
||
type="primary"
|
||
@click="handleConfirm"
|
||
>
|
||
{{ confirmText }}
|
||
</u-button>
|
||
</view>
|
||
</view>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import props from './props.js';
|
||
import mixin from '../../libs/mixin/mixin';
|
||
import mpMixin from '../../libs/mixin/mpMixin';
|
||
|
||
/**
|
||
* Signature 签名组件
|
||
* @description 可用于业务签名等场景
|
||
* @tutorial https://uview.d3u.cn/components/signature.html
|
||
*
|
||
* @property {Number} penSize 画笔大小 (默认 2 )
|
||
* @property {Number} minLineWidth 线条最小宽度 (默认 2 )
|
||
* @property {Number} maxLineWidth 线条最大宽度 (默认 6 )
|
||
* @property {String} penColor 画笔颜色 (默认 'black' )
|
||
* @property {String} backgroundColor 背景颜色 (默认 '' )
|
||
* @property {String} type canvas类型 (默认 '2d' )
|
||
* @property {Boolean} openSmooth 是否开启压感 (默认 false )
|
||
* @property {Number} maxHistoryLength 最大历史记录数 (默认 20 )
|
||
* @property {Boolean} landscape 是否横屏 (默认 false )
|
||
* @property {Boolean} disableScroll 是否禁用滚动 (默认 true )
|
||
* @property {Boolean} disabled 是否禁用 (默认 false )
|
||
* @property {Boolean} boundingBox 只生成内容区域 (默认 false )
|
||
* @property {Object} customStyle 自定义样式
|
||
* @event {Function} undo 撤销方法
|
||
* @event {Function} clear 清空方法
|
||
* @event {Function} getImage 保存方法
|
||
* @example <u-signature :penColor="penColor" :penSize="penSize" ref="signatureRef"></u-signature>
|
||
*/
|
||
|
||
let canvasObj = {};
|
||
|
||
export default {
|
||
name: 'u-signature',
|
||
mixins: [mpMixin, mixin, props],
|
||
data() {
|
||
return {
|
||
canvasId: 'signatureId_' + uni.$u.guid(),
|
||
ctx: null,
|
||
canvas: null,
|
||
canvasWidth: 0,
|
||
canvasHeight: 0,
|
||
isDrawing: false,
|
||
lastPoint: null,
|
||
currentStroke: [],
|
||
history: [],
|
||
isEmpty: true,
|
||
velocityFilterWeight: 0.7,
|
||
minVelocity: 0.25,
|
||
currentVelocity: 0,
|
||
lastTimestamp: 0,
|
||
penColorInner: ''
|
||
};
|
||
},
|
||
computed: {
|
||
is2d() {
|
||
// #ifdef MP-WEIXIN
|
||
return this.type == '2d';
|
||
// #endif
|
||
// #ifndef MP-WEIXIN
|
||
return false;
|
||
// #endif
|
||
},
|
||
containerStyle() {
|
||
const style = {
|
||
width: '100%',
|
||
height: '100%',
|
||
};
|
||
return style;
|
||
},
|
||
canvasStyle() {
|
||
const style = {
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'block',
|
||
};
|
||
|
||
if (
|
||
this.backgroundColor &&
|
||
this.backgroundColor !== 'transparent'
|
||
) {
|
||
style.backgroundColor = this.backgroundColor;
|
||
}
|
||
|
||
return style;
|
||
},
|
||
},
|
||
watch: {
|
||
penColor: {
|
||
immediate: true,
|
||
handler(newVal) {
|
||
this.penColorInner = newVal;
|
||
},
|
||
}
|
||
},
|
||
mounted() {
|
||
this.init();
|
||
},
|
||
// #ifdef VUE3
|
||
emits: ['clear', 'undo', 'confirm'],
|
||
// #endif
|
||
methods: {
|
||
async init() {
|
||
await this.$nextTick();
|
||
|
||
const query = uni
|
||
.createSelectorQuery()
|
||
.in(this)
|
||
.select(`#${this.canvasId}`);
|
||
|
||
if (this.is2d) {
|
||
let canvas = await new Promise((resolve) => {
|
||
query
|
||
.fields({
|
||
node: true,
|
||
size: true,
|
||
})
|
||
.exec((res) => {
|
||
|
||
this.canvasWidth = parseInt(res[0].width);
|
||
this.canvasHeight = parseInt(res[0].height);
|
||
resolve(res[0].node);
|
||
});
|
||
});
|
||
|
||
canvas.width = this.canvasWidth;
|
||
canvas.height = this.canvasHeight;
|
||
this.ctx = canvas.getContext('2d');
|
||
canvasObj[this.canvasId] = canvas;
|
||
} else {
|
||
// 传统 canvas 模式
|
||
// #ifdef MP-ALIPAY
|
||
this.ctx = uni.createCanvasContext(this.canvasId);
|
||
// #endif
|
||
// #ifndef MP-ALIPAY
|
||
this.ctx = uni.createCanvasContext(this.canvasId, this);
|
||
// #endif
|
||
|
||
await new Promise((resolve) => {
|
||
query
|
||
.boundingClientRect((data) => {
|
||
this.canvasWidth = parseInt(data.width);
|
||
this.canvasHeight = parseInt(data.height);
|
||
resolve();
|
||
})
|
||
.exec();
|
||
});
|
||
}
|
||
|
||
// 设置画笔样式
|
||
this.ctx.lineCap = 'round';
|
||
this.ctx.lineJoin = 'round';
|
||
this.ctx.strokeStyle = this.penColorInner;
|
||
this.ctx.lineWidth = this.penSize;
|
||
|
||
// 绘制背景和水印
|
||
this.drawBackgroundAndWatermark();
|
||
},
|
||
|
||
// 开始绘制
|
||
onCanvasTouchStart(e) {
|
||
if (this.disabled) return;
|
||
const touch = e.touches[0];
|
||
const point = this.getTouchPoint(touch);
|
||
|
||
this.isDrawing = true;
|
||
this.lastPoint = point;
|
||
this.currentStroke = [point];
|
||
this.lastTimestamp = Date.now();
|
||
this.currentVelocity = 0;
|
||
},
|
||
|
||
// 绘制中
|
||
onCanvasTouchMove(e) {
|
||
if (this.disabled || !this.isDrawing) return;
|
||
|
||
const touch = e.touches[0];
|
||
const point = this.getTouchPoint(touch);
|
||
const now = Date.now();
|
||
|
||
// 计算速度(用于压感)
|
||
if (this.openSmooth && this.lastPoint) {
|
||
const distance = this.getDistance(this.lastPoint, point);
|
||
const timeDelta = now - this.lastTimestamp;
|
||
const velocity = distance / timeDelta;
|
||
|
||
this.currentVelocity =
|
||
this.velocityFilterWeight * velocity +
|
||
(1 - this.velocityFilterWeight) * this.currentVelocity;
|
||
}
|
||
|
||
this.drawLine(this.lastPoint, point);
|
||
this.lastPoint = point;
|
||
this.currentStroke.push(point);
|
||
this.lastTimestamp = now;
|
||
this.isEmpty = false;
|
||
},
|
||
|
||
// 结束绘制
|
||
onCanvasTouchEnd(e) {
|
||
if (this.disabled || !this.isDrawing) return;
|
||
|
||
this.isDrawing = false;
|
||
|
||
// 保存到历史记录
|
||
if (this.currentStroke.length > 0) {
|
||
this.saveToHistory();
|
||
}
|
||
},
|
||
|
||
// 取消绘制
|
||
onCanvasTouchCancel(e) {
|
||
this.onCanvasTouchEnd(e);
|
||
},
|
||
|
||
// 获取触摸点坐标
|
||
getTouchPoint(touch) {
|
||
return {
|
||
x: touch.x,
|
||
y: touch.y,
|
||
timestamp: Date.now(),
|
||
};
|
||
},
|
||
|
||
// 绘制线条
|
||
drawLine(from, to) {
|
||
if (!this.ctx || !from || !to) return;
|
||
|
||
let lineWidth = this.penSize;
|
||
|
||
// 压感效果
|
||
if (this.openSmooth) {
|
||
const velocity = Math.max(
|
||
this.currentVelocity,
|
||
this.minVelocity,
|
||
);
|
||
lineWidth = Math.max(
|
||
this.minLineWidth,
|
||
Math.min(this.maxLineWidth, this.penSize / velocity),
|
||
);
|
||
}
|
||
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(from.x, from.y);
|
||
this.ctx.lineTo(to.x, to.y);
|
||
this.ctx.strokeStyle = this.penColorInner;
|
||
this.ctx.lineWidth = lineWidth;
|
||
this.ctx.stroke();
|
||
|
||
if (!this.is2d) {
|
||
this.ctx.draw(true);
|
||
}
|
||
},
|
||
|
||
// 计算两点距离
|
||
getDistance(p1, p2) {
|
||
return Math.sqrt(
|
||
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
|
||
);
|
||
},
|
||
|
||
// 绘制水印
|
||
drawWatermark() {
|
||
if (!this.ctx || !this.watermark.text) return;
|
||
|
||
const ctx = this.ctx;
|
||
const text = this.watermark.text;
|
||
const fontSize = parseInt(this.watermark.fontSize);
|
||
const fontFamily = this.watermark.fontFamily;
|
||
const color = this.watermark.color;
|
||
const rotate = this.watermark.rotate;
|
||
const spacing = this.watermark.spacing;
|
||
|
||
// 设置水印样式
|
||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||
ctx.fillStyle = color;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
|
||
// 计算文本尺寸
|
||
const textMetrics = ctx.measureText(text);
|
||
const textWidth = textMetrics.width;
|
||
const textHeight = fontSize;
|
||
|
||
if (this.watermark.single) {
|
||
// 绘制单个居中水印
|
||
const centerX = this.canvasWidth / 2;
|
||
const centerY = this.canvasHeight / 2;
|
||
|
||
ctx.save();
|
||
ctx.translate(centerX, centerY);
|
||
ctx.rotate((rotate * Math.PI) / 180);
|
||
ctx.fillText(text, 0, 0);
|
||
ctx.restore();
|
||
} else {
|
||
// 绘制网格水印
|
||
const cols = Math.ceil(this.canvasWidth / spacing) + 1;
|
||
const rows = Math.ceil(this.canvasHeight / spacing) + 1;
|
||
|
||
// 绘制水印网格
|
||
for (let row = 0; row < rows; row++) {
|
||
for (let col = 0; col < cols; col++) {
|
||
const x = col * spacing;
|
||
const y = row * spacing;
|
||
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate((rotate * Math.PI) / 180);
|
||
ctx.fillText(text, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 对于非2d canvas,需要调用draw方法
|
||
if (!this.is2d) {
|
||
ctx.draw(true);
|
||
}
|
||
},
|
||
|
||
// 重绘历史记录
|
||
redrawHistory() {
|
||
if (!this.ctx || !this.history.length) return;
|
||
|
||
this.history.forEach((item) => {
|
||
if (item.stroke && item.stroke.length > 1) {
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(item.stroke[0].x, item.stroke[0].y);
|
||
|
||
for (let i = 1; i < item.stroke.length; i++) {
|
||
this.ctx.lineTo(item.stroke[i].x, item.stroke[i].y);
|
||
}
|
||
|
||
this.ctx.strokeStyle = this.penColorInner;
|
||
this.ctx.lineWidth = this.penSize;
|
||
this.ctx.stroke();
|
||
}
|
||
});
|
||
},
|
||
|
||
// 清空画布并设置背景
|
||
clearCanvas() {
|
||
if (!this.ctx) return;
|
||
|
||
// 清空画布
|
||
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
|
||
|
||
// 重新设置背景色
|
||
if (
|
||
this.backgroundColor &&
|
||
this.backgroundColor !== 'transparent'
|
||
) {
|
||
this.ctx.fillStyle = this.backgroundColor;
|
||
this.ctx.fillRect(
|
||
0,
|
||
0,
|
||
this.canvasWidth,
|
||
this.canvasHeight,
|
||
);
|
||
}
|
||
},
|
||
|
||
// 绘制背景和水印
|
||
drawBackgroundAndWatermark() {
|
||
this.clearCanvas();
|
||
|
||
// 重新绘制水印
|
||
if (this.showWatermark && this.watermark.text) {
|
||
this.drawWatermark();
|
||
}
|
||
},
|
||
|
||
// 恢复水印
|
||
restoreWatermark() {
|
||
if (!this.ctx) return;
|
||
|
||
// 绘制背景和水印
|
||
this.drawBackgroundAndWatermark();
|
||
|
||
// 重绘所有笔画
|
||
this.redrawHistory();
|
||
|
||
// 对于非2d canvas,需要调用draw方法
|
||
if (!this.is2d) {
|
||
this.ctx.draw(true);
|
||
}
|
||
},
|
||
|
||
// 保存到历史记录
|
||
saveToHistory() {
|
||
if (this.maxHistoryLength <= 0) return;
|
||
|
||
const imageData = {
|
||
stroke: [...this.currentStroke],
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
this.history.push(imageData);
|
||
|
||
// 限制历史记录数量
|
||
if (this.history.length > this.maxHistoryLength) {
|
||
this.history.shift();
|
||
}
|
||
|
||
this.currentStroke = [];
|
||
},
|
||
|
||
handlePenColor(color){
|
||
this.penColorInner = color;
|
||
this.ctx.strokeStyle = color;
|
||
},
|
||
handleClear(){
|
||
this.clear();
|
||
this.$emit('clear');
|
||
},
|
||
handleUndo(){
|
||
this.undo();
|
||
this.$emit('undo');
|
||
},
|
||
handleConfirm(){
|
||
this.getImage().then(res => {
|
||
this.$emit('confirm', res);
|
||
});
|
||
},
|
||
// 撤销
|
||
undo() {
|
||
if (this.history.length === 0) return;
|
||
this.history.pop();
|
||
this.redrawFromHistory();
|
||
},
|
||
|
||
// 重做(从历史记录重绘)
|
||
redrawFromHistory() {
|
||
if (!this.ctx) return;
|
||
|
||
// 绘制背景和水印
|
||
this.drawBackgroundAndWatermark();
|
||
|
||
// 重绘所有笔画
|
||
this.redrawHistory();
|
||
|
||
// 对于非2d canvas,需要调用draw方法
|
||
if (!this.is2d) {
|
||
this.ctx.draw(true);
|
||
}
|
||
|
||
this.isEmpty = this.history.length === 0;
|
||
},
|
||
|
||
// 清空
|
||
clear() {
|
||
if (!this.ctx) return;
|
||
|
||
// 绘制背景和水印
|
||
this.drawBackgroundAndWatermark();
|
||
|
||
//对于非2d canvas,需要调用draw方法
|
||
if (!this.is2d) {
|
||
this.ctx.draw(true);
|
||
}
|
||
|
||
this.history = [];
|
||
this.currentStroke = [];
|
||
this.isEmpty = true;
|
||
|
||
// 触发清空事件
|
||
this.$emit('clear');
|
||
},
|
||
|
||
|
||
// 导出图片
|
||
getImage() {
|
||
return new Promise((resolve, reject) => {
|
||
if (this.isEmpty) {
|
||
uni.showToast({
|
||
title: '签名板为空',
|
||
icon: 'none',
|
||
});
|
||
reject('签名板为空');
|
||
return;
|
||
}
|
||
|
||
// 如果保存时不显示水印,需要临时隐藏水印
|
||
let needRestoreWatermark = false;
|
||
if (this.showWatermark && !this.watermark.showOnSave) {
|
||
needRestoreWatermark = true;
|
||
// 临时隐藏水印,只绘制背景和笔画
|
||
this.clearCanvas();
|
||
this.redrawHistory();
|
||
|
||
// 对于非2d canvas,需要调用draw方法
|
||
if (!this.is2d) {
|
||
this.ctx.draw(true);
|
||
}
|
||
}
|
||
|
||
let params = {
|
||
canvas: canvasObj[this.canvasId],
|
||
canvasId: this.canvasId,
|
||
width: this.canvasWidth,
|
||
height: this.canvasHeight,
|
||
fileType: this.fileType,
|
||
quality: this.quality,
|
||
success: (res) => {
|
||
if (needRestoreWatermark) {
|
||
this.restoreWatermark();
|
||
}
|
||
resolve(res.tempFilePath);
|
||
},
|
||
fail: (err) => {
|
||
if (needRestoreWatermark) {
|
||
this.restoreWatermark();
|
||
}
|
||
reject(err);
|
||
},
|
||
};
|
||
|
||
// 处理boundingBox
|
||
if (this.boundingBox) {
|
||
params.x = 0;
|
||
params.y = 0;
|
||
}
|
||
|
||
// #ifdef MP-ALIPAY
|
||
uni.canvasToTempFilePath(params);
|
||
// #endif
|
||
// #ifndef MP-ALIPAY
|
||
uni.canvasToTempFilePath(params, this);
|
||
// #endif
|
||
});
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '../../libs/css/components.scss';
|
||
|
||
.u-signature {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
// 横屏模式
|
||
&-landscape {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
&__title{
|
||
text-align: center;
|
||
font-size: 15px;
|
||
color: #333;
|
||
padding: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&-fixed{
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 9999;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
// 横屏模式标题样式
|
||
.u-signature-landscape & {
|
||
width: 26px;
|
||
height: 100%;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
|
||
&-text {
|
||
width: 100vh;
|
||
transform: rotate(90deg);
|
||
transform-origin: center center;
|
||
}
|
||
}
|
||
}
|
||
|
||
&__canvas {
|
||
width: 100%;
|
||
display: block;
|
||
touch-action: none;
|
||
flex: 1;
|
||
}
|
||
|
||
&__toolbar {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 10px 20px 10px;
|
||
|
||
&-fixed{
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 9999;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
// 横屏模式工具栏样式
|
||
.u-signature-landscape & {
|
||
padding: 10px;
|
||
width: 34px;
|
||
height: 100%;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
&-color-list{
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
|
||
// 横屏模式颜色列表样式
|
||
.u-signature-landscape & {
|
||
flex-direction: column;
|
||
margin-bottom: 10px;
|
||
}
|
||
}
|
||
|
||
&-color{
|
||
width: 15px;
|
||
height: 15px;
|
||
border-radius: 100px;
|
||
margin: 0 3px;
|
||
border: 6px solid #fff;
|
||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
|
||
// 横屏模式下的颜色选择器样式
|
||
.u-signature-landscape & {
|
||
margin: 5px 0;
|
||
}
|
||
}
|
||
|
||
&-left {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
|
||
// 横屏模式左侧工具栏样式
|
||
.u-signature-landscape & {
|
||
flex-direction: column;
|
||
margin-top: 50px;
|
||
}
|
||
}
|
||
|
||
&-right {
|
||
display: flex;
|
||
flex-direction: row;
|
||
//align-items: center;
|
||
|
||
// 横屏模式右侧工具栏样式
|
||
.u-signature-landscape & {
|
||
flex-direction: column;
|
||
margin-bottom: 30px;
|
||
|
||
.u-signature__toolbar-item {
|
||
margin-right: 0;
|
||
margin-left: -40px;
|
||
height: 100px;
|
||
transform: rotate(90deg);
|
||
transform-origin: center center;
|
||
}
|
||
}
|
||
}
|
||
|
||
&-item {
|
||
margin-right: 10px;
|
||
|
||
}
|
||
|
||
&-item:last-child {
|
||
margin-right: 0;
|
||
}
|
||
}
|
||
}
|
||
</style>
|