336 lines
7.1 KiB
Plaintext
336 lines
7.1 KiB
Plaintext
<template>
|
||
<view class="cl-sign" :class="[pt.className]">
|
||
<canvas
|
||
class="cl-sign__canvas"
|
||
ref="canvasRef"
|
||
:id="canvasId"
|
||
:style="{
|
||
height: `${height}px`,
|
||
width: `${width}px`
|
||
}"
|
||
@touchstart="onTouchStart"
|
||
@touchmove.stop.prevent="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
></canvas>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { canvasToPng, getDevicePixelRatio, parsePt, uuid } from "@/cool";
|
||
import { computed, getCurrentInstance, nextTick, onMounted, ref, shallowRef, watch } from "vue";
|
||
|
||
defineOptions({
|
||
name: "cl-sign"
|
||
});
|
||
|
||
// 定义组件属性
|
||
const props = defineProps({
|
||
pt: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
// 画布宽度
|
||
width: {
|
||
type: Number,
|
||
default: 300
|
||
},
|
||
// 画布高度
|
||
height: {
|
||
type: Number,
|
||
default: 200
|
||
},
|
||
// 线条颜色
|
||
strokeColor: {
|
||
type: String,
|
||
default: "#000000"
|
||
},
|
||
// 线条宽度
|
||
strokeWidth: {
|
||
type: Number,
|
||
default: 3
|
||
},
|
||
// 背景颜色
|
||
backgroundColor: {
|
||
type: String,
|
||
default: "#ffffff"
|
||
},
|
||
// 是否启用毛笔效果
|
||
enableBrush: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 最小线条宽度
|
||
minStrokeWidth: {
|
||
type: Number,
|
||
default: 1
|
||
},
|
||
// 最大线条宽度
|
||
maxStrokeWidth: {
|
||
type: Number,
|
||
default: 6
|
||
},
|
||
// 速度敏感度
|
||
velocitySensitivity: {
|
||
type: Number,
|
||
default: 0.7
|
||
}
|
||
});
|
||
|
||
// 定义事件发射器
|
||
const emit = defineEmits(["change", "clear"]);
|
||
|
||
// 获取当前实例
|
||
const { proxy } = getCurrentInstance()!;
|
||
|
||
// 获取设备像素比
|
||
const dpr = getDevicePixelRatio();
|
||
|
||
// 触摸点类型
|
||
type Point = { x: number; y: number; time: number };
|
||
|
||
// 矩形类型
|
||
type Rect = { left: number; top: number };
|
||
|
||
// 透传样式类型定义
|
||
type PassThrough = {
|
||
className?: string;
|
||
};
|
||
|
||
// 解析透传样式配置
|
||
const pt = computed(() => parsePt<PassThrough>(props.pt));
|
||
|
||
// 签名组件画布
|
||
const canvasRef = shallowRef<UniElement | null>(null);
|
||
|
||
// 绘图上下文
|
||
let drawCtx: CanvasRenderingContext2D | null = null;
|
||
|
||
// 生成唯一的canvas ID
|
||
const canvasId = `cl-sign__${uuid()}`;
|
||
|
||
// 触摸状态
|
||
const isDrawing = ref(false);
|
||
|
||
// 上一个触摸点
|
||
let lastPoint: Point | null = null;
|
||
|
||
// 当前线条宽度
|
||
let currentStrokeWidth = ref(3);
|
||
|
||
// 速度缓冲数组(用于平滑速度变化)
|
||
const velocityBuffer: number[] = [];
|
||
|
||
// canvas位置信息缓存
|
||
let canvasRect: Rect | null = null;
|
||
|
||
// 获取canvas位置信息
|
||
function getCanvasRect(): Promise<Rect> {
|
||
return new Promise((resolve) => {
|
||
// #ifdef MP
|
||
uni.createSelectorQuery()
|
||
.in(proxy)
|
||
.select(`#${canvasId}`)
|
||
.boundingClientRect((rect: any) => {
|
||
if (rect) {
|
||
canvasRect = { left: rect.left, top: rect.top };
|
||
resolve(canvasRect!);
|
||
} else {
|
||
resolve({ left: 0, top: 0 });
|
||
}
|
||
})
|
||
.exec();
|
||
// #endif
|
||
|
||
// #ifndef MP
|
||
// 非小程序平台,在需要时通过DOM获取位置信息
|
||
canvasRect = { left: 0, top: 0 };
|
||
resolve(canvasRect!);
|
||
// #endif
|
||
});
|
||
}
|
||
|
||
// 获取触摸点在canvas中的坐标
|
||
function getTouchPos(e: TouchEvent): Point {
|
||
const touch = e.touches[0];
|
||
|
||
// #ifdef H5
|
||
const rect = (e.target as any).getBoundingClientRect();
|
||
|
||
return {
|
||
x: touch.clientX - rect.left,
|
||
y: touch.clientY - rect.top,
|
||
time: Date.now()
|
||
};
|
||
// #endif
|
||
|
||
// #ifndef H5
|
||
// 小程序中使用缓存的位置信息或直接使用触摸坐标
|
||
const left = canvasRect?.left ?? 0;
|
||
const top = canvasRect?.top ?? 0;
|
||
|
||
return {
|
||
x: touch.clientX - left,
|
||
y: touch.clientY - top,
|
||
time: Date.now()
|
||
};
|
||
// #endif
|
||
}
|
||
|
||
// 计算速度并返回动态线条宽度
|
||
function calculateStrokeWidth(currentPoint: Point): number {
|
||
if (lastPoint == null || !props.enableBrush) {
|
||
return props.strokeWidth;
|
||
}
|
||
|
||
// 计算距离和时间差
|
||
const distance = Math.sqrt(
|
||
Math.pow(currentPoint.x - lastPoint!.x, 2) + Math.pow(currentPoint.y - lastPoint!.y, 2)
|
||
);
|
||
const timeDelta = currentPoint.time - lastPoint!.time;
|
||
|
||
if (timeDelta <= 0) return currentStrokeWidth.value;
|
||
|
||
// 计算速度 (像素/毫秒)
|
||
const velocity = distance / timeDelta;
|
||
|
||
// 添加到速度缓冲区(用于平滑)
|
||
velocityBuffer.push(velocity);
|
||
if (velocityBuffer.length > 5) {
|
||
velocityBuffer.shift();
|
||
}
|
||
|
||
// 计算平均速度
|
||
const avgVelocity = velocityBuffer.reduce((sum, v) => sum + v, 0) / velocityBuffer.length;
|
||
|
||
// 根据速度计算线条宽度(速度越快越细)
|
||
const normalizedVelocity = Math.min(avgVelocity * props.velocitySensitivity, 1);
|
||
const widthRange = props.maxStrokeWidth - props.minStrokeWidth;
|
||
const targetWidth = props.maxStrokeWidth - normalizedVelocity * widthRange;
|
||
|
||
// 平滑过渡到目标宽度
|
||
const smoothFactor = 0.3;
|
||
return currentStrokeWidth.value + (targetWidth - currentStrokeWidth.value) * smoothFactor;
|
||
}
|
||
|
||
// 触摸开始
|
||
async function onTouchStart(e: TouchEvent) {
|
||
e.preventDefault();
|
||
isDrawing.value = true;
|
||
|
||
// #ifdef MP
|
||
// 小程序中,如果没有缓存位置信息,先获取
|
||
if (canvasRect == null) {
|
||
await getCanvasRect();
|
||
}
|
||
// #endif
|
||
|
||
lastPoint = getTouchPos(e);
|
||
|
||
// 初始化线条宽度和清空速度缓冲
|
||
currentStrokeWidth.value = props.enableBrush ? props.maxStrokeWidth : props.strokeWidth;
|
||
velocityBuffer.length = 0;
|
||
}
|
||
|
||
// 触摸移动
|
||
function onTouchMove(e: TouchEvent) {
|
||
e.preventDefault();
|
||
if (!isDrawing.value || lastPoint == null || drawCtx == null) return;
|
||
|
||
const currentPoint = getTouchPos(e);
|
||
|
||
// 计算动态线条宽度
|
||
const strokeWidth = calculateStrokeWidth(currentPoint);
|
||
currentStrokeWidth.value = strokeWidth;
|
||
|
||
// 绘制线条
|
||
drawCtx!.beginPath();
|
||
drawCtx!.moveTo(lastPoint!.x * dpr, lastPoint!.y * dpr);
|
||
drawCtx!.lineTo(currentPoint.x * dpr, currentPoint.y * dpr);
|
||
drawCtx!.strokeStyle = props.strokeColor;
|
||
drawCtx!.lineWidth = strokeWidth * dpr;
|
||
drawCtx!.lineCap = "round";
|
||
drawCtx!.lineJoin = "round";
|
||
drawCtx!.stroke();
|
||
|
||
lastPoint = currentPoint;
|
||
emit("change");
|
||
}
|
||
|
||
// 触摸结束
|
||
function onTouchEnd(e: TouchEvent) {
|
||
e.preventDefault();
|
||
isDrawing.value = false;
|
||
lastPoint = null;
|
||
}
|
||
|
||
// 清除画布
|
||
function clear() {
|
||
if (drawCtx == null) return;
|
||
|
||
// #ifdef APP
|
||
drawCtx!.reset();
|
||
// #endif
|
||
|
||
// #ifndef APP
|
||
drawCtx!.clearRect(0, 0, props.width * dpr, props.height * dpr);
|
||
// #endif
|
||
|
||
// 填充背景色
|
||
drawCtx!.fillStyle = props.backgroundColor;
|
||
drawCtx!.fillRect(0, 0, props.width * dpr, props.height * dpr);
|
||
|
||
emit("clear");
|
||
}
|
||
|
||
// 获取签名图片
|
||
function toPng(): Promise<string> {
|
||
return canvasToPng(canvasRef.value!);
|
||
}
|
||
|
||
// 初始化画布
|
||
function initCanvas() {
|
||
uni.createCanvasContextAsync({
|
||
id: canvasId,
|
||
component: proxy,
|
||
success: (context: CanvasContext) => {
|
||
// 获取绘图上下文
|
||
drawCtx = context.getContext("2d")!;
|
||
// 设置宽高
|
||
drawCtx!.canvas.width = props.width * dpr;
|
||
drawCtx!.canvas.height = props.height * dpr;
|
||
|
||
// 优化渲染质量
|
||
drawCtx!.textBaseline = "middle";
|
||
drawCtx!.textAlign = "center";
|
||
drawCtx!.miterLimit = 10;
|
||
|
||
// 初始化背景
|
||
clear();
|
||
|
||
// #ifdef MP
|
||
// 小程序中初始化时获取canvas位置信息
|
||
getCanvasRect();
|
||
// #endif
|
||
}
|
||
});
|
||
}
|
||
|
||
onMounted(() => {
|
||
initCanvas();
|
||
|
||
watch(
|
||
computed(() => [props.width, props.height]),
|
||
() => {
|
||
nextTick(() => {
|
||
initCanvas();
|
||
});
|
||
}
|
||
);
|
||
});
|
||
|
||
defineExpose({
|
||
clear,
|
||
toPng
|
||
});
|
||
</script>
|