添加 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

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -263,7 +263,6 @@ export const remixicon = {
"list-check": "eeba", "list-check": "eeba",
"list-ordered": "eebb", "list-ordered": "eebb",
"list-radio": "f39b", "list-radio": "f39b",
"translate-2": "f226",
"sort-asc": "f15f", "sort-asc": "f15f",
"sort-desc": "f160", "sort-desc": "f160",
"send-backward": "f0d6", "send-backward": "f0d6",
@@ -695,5 +694,6 @@ export const remixicon = {
"shield-user-line": "f10c", "shield-user-line": "f10c",
"shield-user-fill": "f10b", "shield-user-fill": "f10b",
"circle-line": "f3c2", "circle-line": "f3c2",
"circle-fill": "f3c1" "circle-fill": "f3c1",
"sketching": "f35f"
}; };

View File

@@ -1,6 +1,6 @@
{ {
"name": "cool-unix", "name": "cool-unix",
"version": "8.0.3", "version": "8.0.4",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js", "build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

View File

@@ -369,6 +369,12 @@
"navigationBarTitleText": "QRCode 二维码" "navigationBarTitleText": "QRCode 二维码"
} }
}, },
{
"path": "other/sign",
"style": {
"navigationBarTitleText": "Sign 签名"
}
},
{ {
"path": "other/day-uts", "path": "other/day-uts",
"style": { "style": {

View File

@@ -0,0 +1,48 @@
<template>
<cl-page>
<cl-sign
ref="signRef"
:width="windowWidth"
:fullscreen="isFullscreen"
:enable-brush="isBrush"
></cl-sign>
<view class="p-3">
<cl-list>
<cl-list-item label="操作">
<cl-button type="info" @click="clear">清空</cl-button>
<cl-button @click="preview">预览</cl-button>
</cl-list-item>
<cl-list-item label="全屏">
<cl-switch v-model="isFullscreen"></cl-switch>
</cl-list-item>
<cl-list-item label="毛笔效果">
<cl-switch v-model="isBrush"></cl-switch>
</cl-list-item>
</cl-list>
</view>
</cl-page>
</template>
<script setup lang="ts">
import { ref } from "vue";
import DemoItem from "../components/item.uvue";
const { windowWidth } = uni.getWindowInfo();
const isFullscreen = ref(false);
const isBrush = ref(true);
const signRef = ref<ClSignComponentPublicInstance | null>(null);
function clear() {
signRef.value?.clear();
}
function preview() {
signRef.value?.toPng().then((res) => {
console.log(res);
});
}
</script>

View File

@@ -357,6 +357,11 @@ const data = computed<Item[]>(() => {
icon: "qr-code-line", icon: "qr-code-line",
path: "/pages/demo/other/qrcode" path: "/pages/demo/other/qrcode"
}, },
{
label: "签名",
icon: "sketching",
path: "/pages/demo/other/sign"
},
{ {
label: "DayUts", label: "DayUts",
icon: "timer-2-line", icon: "timer-2-line",

View File

@@ -6,7 +6,7 @@
'is-show': visible '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>
</view> </view>
</template> </template>

View File

@@ -3,7 +3,7 @@
<view class="theme-set" @tap="toggleTheme()"> <view class="theme-set" @tap="toggleTheme()">
<view class="theme-set__inner" :class="{ 'is-dark': isDark }"> <view class="theme-set__inner" :class="{ 'is-dark': isDark }">
<view class="theme-set__icon" v-for="item in list" :key="item"> <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>
</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 { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props"; import type { ClPopupPassThrough } from "../cl-popup/props";
@@ -11,6 +11,7 @@ export type ClSelectDateProps = {
className?: string; className?: string;
pt?: ClSelectDatePassThrough; pt?: ClSelectDatePassThrough;
modelValue?: string; modelValue?: string;
values?: string[];
headers?: string[]; headers?: string[];
title?: string; title?: string;
placeholder?: string; placeholder?: string;
@@ -25,4 +26,10 @@ export type ClSelectDateProps = {
start?: string; start?: string;
end?: string; end?: string;
type?: "year" | "month" | "date" | "hour" | "minute" | "second"; 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 UiInstance } from "./hooks";
import { type QrcodeOptions } from "./draw"; 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 { ClSelectDateProps, ClSelectDatePassThrough } from "./components/cl-select-date/props";
import type { ClSelectTimeProps, ClSelectTimePassThrough } from "./components/cl-select-time/props"; import type { ClSelectTimeProps, ClSelectTimePassThrough } from "./components/cl-select-time/props";
import type { ClSelectTriggerProps, ClSelectTriggerPassThrough } from "./components/cl-select-trigger/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 { ClSkeletonProps, ClSkeletonPassThrough } from "./components/cl-skeleton/props";
import type { ClSliderProps, ClSliderPassThrough } from "./components/cl-slider/props"; import type { ClSliderProps, ClSliderPassThrough } from "./components/cl-slider/props";
import type { ClStickyProps } from "./components/cl-sticky/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-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-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-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-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-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>; "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 = { declare type ClProgressCircleComponentPublicInstance = {
animate: (value: number) => void; animate: (value: number) => void;
}; };
declare type ClSignComponentPublicInstance = {
clear: () => void;
toPng: () => Promise<string>;
};