添加 cl-sign 签名组件

This commit is contained in:
icssoa
2025-07-29 10:44:18 +08:00
parent d6da3d768c
commit 28a6627224
16 changed files with 470 additions and 9 deletions

View File

@@ -6,7 +6,7 @@
'is-show': visible
}"
>
<cl-icon name="skip-up-line" color="white" :size="50"></cl-icon>
<cl-icon name="skip-up-line" color="white" size="25px"></cl-icon>
</view>
</view>
</template>

View File

@@ -3,7 +3,7 @@
<view class="theme-set" @tap="toggleTheme()">
<view class="theme-set__inner" :class="{ 'is-dark': isDark }">
<view class="theme-set__icon" v-for="item in list" :key="item">
<cl-icon :name="item" color="white" :size="36"></cl-icon>
<cl-icon :name="item" color="white" size="18px"></cl-icon>
</view>
</view>
</view>

View File

@@ -1,4 +1,4 @@
import type { ClSelectOption } from "../../types";
import type { ClSelectDateShortcut, ClSelectOption } from "../../types";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
@@ -11,6 +11,7 @@ export type ClSelectDateProps = {
className?: string;
pt?: ClSelectDatePassThrough;
modelValue?: string;
values?: string[];
headers?: string[];
title?: string;
placeholder?: string;
@@ -25,4 +26,10 @@ export type ClSelectDateProps = {
start?: string;
end?: string;
type?: "year" | "month" | "date" | "hour" | "minute" | "second";
rangeable?: boolean;
startPlaceholder?: string;
endPlaceholder?: string;
rangeSeparator?: string;
showShortcuts?: boolean;
shortcuts?: ClSelectDateShortcut[];
};

View File

@@ -0,0 +1,367 @@
<template>
<view class="cl-sign" :class="[pt.className]">
<canvas
class="cl-sign__canvas"
:id="canvasId"
:style="{
height: `${size.height}px`,
width: `${size.width}px`
}"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
></canvas>
</view>
</template>
<script lang="ts" setup>
import { parsePt, uuid } from "@/cool";
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, 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
},
// 是否支持横屏自适应
autoRotate: {
type: Boolean,
default: true
},
// 是否全屏
fullscreen: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["change"]);
const { proxy } = getCurrentInstance()!;
const { windowWidth, windowHeight } = uni.getWindowInfo();
// 触摸点类型
type Point = { x: number; y: number; time: number };
// 透传样式类型定义
type PassThrough = {
className?: string;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// canvas组件上下文
let canvasCtx: CanvasContext | 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[] = [];
// 当前是否为横屏
const isLandscape = ref(false);
// 动态计算的画布尺寸
const size = computed(() => {
if (props.fullscreen) {
const { windowWidth, windowHeight } = uni.getWindowInfo();
return {
width: windowWidth,
height: windowHeight
};
}
if (isLandscape.value) {
const { windowWidth } = uni.getWindowInfo();
return {
width: windowWidth,
height: props.height
};
}
return {
width: props.width,
height: props.height
};
});
// 获取触摸点在canvas中的坐标
function getTouchPos(e: TouchEvent): Point {
const touch = e.touches[0];
const rect = (e.target as any).getBoundingClientRect();
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
time: Date.now()
};
}
// 计算速度并返回动态线条宽度
function calculateStrokeWidth(currentPoint: Point): number {
if (!lastPoint || !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;
}
// 触摸开始
function onTouchStart(e: TouchEvent) {
e.preventDefault();
isDrawing.value = true;
lastPoint = getTouchPos(e);
// 初始化线条宽度和清空速度缓冲
currentStrokeWidth.value = props.enableBrush ? props.maxStrokeWidth : props.strokeWidth;
velocityBuffer.length = 0;
}
// 触摸移动
function onTouchMove(e: TouchEvent) {
e.preventDefault();
if (!isDrawing.value || !lastPoint || !drawCtx) return;
const currentPoint = getTouchPos(e);
// 计算动态线条宽度
const strokeWidth = calculateStrokeWidth(currentPoint);
currentStrokeWidth.value = strokeWidth;
// 绘制线条
drawCtx!.beginPath();
drawCtx!.moveTo(lastPoint.x, lastPoint.y);
drawCtx!.lineTo(currentPoint.x, currentPoint.y);
drawCtx!.strokeStyle = props.strokeColor;
drawCtx!.lineWidth = strokeWidth;
drawCtx!.lineCap = "round";
drawCtx!.lineJoin = "round";
drawCtx!.stroke();
lastPoint = currentPoint;
emit("change");
}
// 触摸结束
function onTouchEnd(e: TouchEvent) {
e.preventDefault();
isDrawing.value = false;
lastPoint = null;
}
// 判断横竖屏
function getOrientation() {
const { windowHeight, windowWidth } = uni.getWindowInfo();
// 判断是否为横屏(宽度大于高度)
isLandscape.value = windowWidth > windowHeight;
}
// 屏幕方向变化监听
function onOrientationChange() {
setTimeout(() => {
getOrientation();
// 重新初始化画布
if (props.autoRotate) {
initCanvas();
}
}, 300); // 延迟确保屏幕方向变化完成
}
// 清除画布
function clear() {
if (!drawCtx) return;
const { width, height } = size.value;
// #ifdef APP
drawCtx!.reset();
// #endif
// #ifndef APP
drawCtx!.clearRect(0, 0, width, height);
// #endif
// 填充背景色
drawCtx!.fillStyle = props.backgroundColor;
drawCtx!.fillRect(0, 0, width, height);
emit("change");
}
// 获取签名图片
function toPng(): Promise<string> {
return new Promise((resolve, reject) => {
if (!canvasCtx) {
reject(new Error("Canvas context not initialized"));
return;
}
uni.canvasToTempFilePath(
{
canvasId: canvasId,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
reject(err);
}
},
proxy
);
});
}
// 初始化画布
function initCanvas() {
const { width, height } = size.value;
uni.createCanvasContextAsync({
id: canvasId,
component: proxy,
success: (context: CanvasContext) => {
// 设置canvas上下文
canvasCtx = context;
// 获取绘图上下文
drawCtx = context.getContext("2d")!;
// 设置宽高
drawCtx!.canvas.width = width;
drawCtx!.canvas.height = height;
// 优化渲染质量
drawCtx!.textBaseline = "middle";
drawCtx!.textAlign = "center";
drawCtx!.miterLimit = 10;
// 初始化背景
clear();
}
});
}
onMounted(() => {
// 判断横屏竖屏
getOrientation();
// 初始化画布
initCanvas();
// 监听屏幕方向变化
if (props.autoRotate) {
uni.onWindowResize(onOrientationChange);
}
// 监听全屏状态变化
watch(
() => props.fullscreen,
() => {
initCanvas();
}
);
});
onUnmounted(() => {
// 移除屏幕方向监听
if (props.autoRotate) {
uni.offWindowResize(onOrientationChange);
}
});
defineExpose({
clear,
toPng
});
</script>

View File

@@ -0,0 +1,21 @@
export type ClSignPassThrough = {
className?: string;
};
export type ClSignProps = {
className?: string;
pt?: ClSignPassThrough;
width?: number;
height?: number;
strokeColor?: string;
strokeWidth?: number;
backgroundColor?: string;
enableBrush?: boolean;
minStrokeWidth?: number;
maxStrokeWidth?: number;
velocitySensitivity?: number;
autoRotate?: boolean;
landscapeWidthRatio?: number;
landscapeHeightRatio?: number;
fullscreen?: boolean;
};

View File

@@ -1,4 +1,4 @@
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps, Type, ClButtonType, Size, ClListViewItem, ClInputType, ClListItem, Justify, ClConfirmAction, ClConfirmOptions, ClToastOptions, ClSelectOption, ClPopupDirection, ClQrcodeMode, ClTabsItem, ClTextType, ClUploadItem } from "./types";
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps, Type, ClButtonType, Size, ClListViewItem, ClInputType, ClListItem, Justify, ClConfirmAction, ClConfirmOptions, ClToastOptions, ClSelectOption, ClPopupDirection, ClQrcodeMode, ClSelectDateShortcut, ClTabsItem, ClTextType, ClUploadItem } from "./types";
import { type UiInstance } from "./hooks";
import { type QrcodeOptions } from "./draw";
@@ -48,6 +48,7 @@ import type { ClSelectProps, ClSelectPassThrough } from "./components/cl-select/
import type { ClSelectDateProps, ClSelectDatePassThrough } from "./components/cl-select-date/props";
import type { ClSelectTimeProps, ClSelectTimePassThrough } from "./components/cl-select-time/props";
import type { ClSelectTriggerProps, ClSelectTriggerPassThrough } from "./components/cl-select-trigger/props";
import type { ClSignProps, ClSignPassThrough } from "./components/cl-sign/props";
import type { ClSkeletonProps, ClSkeletonPassThrough } from "./components/cl-skeleton/props";
import type { ClSliderProps, ClSliderPassThrough } from "./components/cl-slider/props";
import type { ClStickyProps } from "./components/cl-sticky/props";
@@ -114,6 +115,7 @@ declare module "vue" {
"cl-select-date": (typeof import('./components/cl-select-date/cl-select-date.uvue')['default']) & import('vue').DefineComponent<ClSelectDateProps>;
"cl-select-time": (typeof import('./components/cl-select-time/cl-select-time.uvue')['default']) & import('vue').DefineComponent<ClSelectTimeProps>;
"cl-select-trigger": (typeof import('./components/cl-select-trigger/cl-select-trigger.uvue')['default']) & import('vue').DefineComponent<ClSelectTriggerProps>;
"cl-sign": (typeof import('./components/cl-sign/cl-sign.uvue')['default']) & import('vue').DefineComponent<ClSignProps>;
"cl-skeleton": (typeof import('./components/cl-skeleton/cl-skeleton.uvue')['default']) & import('vue').DefineComponent<ClSkeletonProps>;
"cl-slider": (typeof import('./components/cl-slider/cl-slider.uvue')['default']) & import('vue').DefineComponent<ClSliderProps>;
"cl-sticky": (typeof import('./components/cl-sticky/cl-sticky.uvue')['default']) & import('vue').DefineComponent<ClStickyProps>;

View File

@@ -147,3 +147,8 @@ declare type ClQrcodeComponentPublicInstance = {
declare type ClProgressCircleComponentPublicInstance = {
animate: (value: number) => void;
};
declare type ClSignComponentPublicInstance = {
clear: () => void;
toPng: () => Promise<string>;
};