This commit is contained in:
2025-08-12 15:04:01 +08:00
parent df3f5f5269
commit 6708fb8eeb
544 changed files with 75169 additions and 17 deletions
@@ -0,0 +1,145 @@
export default {
props: {
// 标题
title: {
type: String,
default: () => uni.$u.$t('uSignature.title'),
},
// 是否显示标题
showTitle: {
type: Boolean,
default: () => uni.$u.props.signature.showTitle,
},
// 是否显示工具栏
showToolbar: {
type: Boolean,
default: () => uni.$u.props.signature.showToolbar,
},
// 是否显示颜色列表
showColorList: {
type: Boolean,
default: () => uni.$u.props.signature.showColorList,
},
// 是否显示清空按钮
showClear: {
type: Boolean,
default: () => uni.$u.props.signature.showClear,
},
// 是否显示撤销按钮
showUndo: {
type: Boolean,
default: () => uni.$u.props.signature.showUndo,
},
// 清空按钮的文本
clearText: {
type: String,
default: () => uni.$u.$t('uSignature.clearText'),
},
// 撤销按钮的文本
undoText: {
type: String,
default: () => uni.$u.$t('uSignature.undoText'),
},
// 完成按钮的文本
confirmText: {
type: String,
default: () => uni.$u.$t('uSignature.confirmText'),
},
// 工具栏对齐方式
toolbarStyle: {
type: Object,
default: () => uni.$u.props.signature.toolbarStyle,
},
// 是否固定标题栏和工具栏
fixed: {
type: Boolean,
default: () => uni.$u.props.signature.fixed,
},
// 线条最小宽度
minLineWidth: {
type: [String, Number],
default: () => uni.$u.props.signature.minLineWidth,
},
// 线条最大宽度
maxLineWidth: {
type: [String, Number],
default: () => uni.$u.props.signature.maxLineWidth,
},
// 画笔颜色
penColor: {
type: String,
default: () => uni.$u.props.signature.penColor,
},
// 画笔颜色列表
penColorList: {
type: Array,
default: () => uni.$u.props.signature.penColorList,
},
// 画笔大小
penSize: {
type: [String, Number],
default: () => uni.$u.props.signature.penSize,
},
// 背景颜色
backgroundColor: {
type: String,
default: () => uni.$u.props.signature.backgroundColor,
},
// canvas类型
type: {
type: String,
default: () => uni.$u.props.signature.type,
},
// 是否开启压感
openSmooth: {
type: Boolean,
default: () => uni.$u.props.signature.openSmooth,
},
// 最大历史记录数
maxHistoryLength: {
type: [String, Number],
default: () => uni.$u.props.signature.maxHistoryLength,
},
// 是否横屏
landscape: {
type: Boolean,
default: () => uni.$u.props.signature.landscape,
},
// 是否禁用滚动
disableScroll: {
type: Boolean,
default: () => uni.$u.props.signature.disableScroll,
},
// 是否禁用
disabled: {
type: Boolean,
default: () => uni.$u.props.signature.disabled,
},
// 只生成内容区域
boundingBox: {
type: Boolean,
default: () => uni.$u.props.signature.boundingBox,
},
// 输出的图片类型,如果发现裁剪的图片很大,可能是因为设置为了"png",改成"jpg"即可
fileType: {
type: String,
default: () => uni.$u.props.signature.fileType,
},
// 图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
quality: {
type: Number,
default: () => uni.$u.props.signature.quality,
},
// 是否显示水印
showWatermark: {
type: Boolean,
default: () => uni.$u.props.signature.showWatermark,
},
// 水印配置对象
watermark: {
type: Object,
default: () => uni.$u.props.signature.watermark,
},
},
};
@@ -0,0 +1,771 @@
<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>