feat: 重构 Signature 签字板历史记录模式并添加压感模式和横屏示例 (#967)

This commit is contained in:
不如摸鱼去 2025-03-23 21:34:29 +08:00 committed by GitHub
parent 8d9e5c6658
commit aad3e8332b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1083 additions and 262 deletions

View File

@ -1,83 +1,113 @@
# Signature 签名 <el-tag text style="vertical-align: middle;margin-left:8px;" effect="plain">1.6.0</el-tag> # Signature 签名 <el-tag text style="vertical-align: middle;margin-left:8px;" effect="plain">1.6.0</el-tag>
用于签名场景,基于 Canvas 实现的签名组件。它提供了多种自定义选项,包括签名笔的颜色、宽度以及自定义操作按钮 用于签名场景,基于 Canvas 实现的签名组件。提供了基础签名、历史记录、笔锋效果等功能
:::tip 提醒 :::tip 提醒
如果遇到导出图片不清晰,可以将`exportScale`设置为`2`以上。 如果遇到导出图片不清晰,可以将 `exportScale` 设置为 `2` 以上。
::: :::
## 基础用法 ## 基础用法
基础的电子签名功能。签名完成后会使用预览组件显示签名图片。
```html ```html
<wd-signature @submit="confirm" @clear="clear" /> <wd-signature @confirm="confirm" @clear="clear" :export-scale="2" background-color="#ffffff" />
<wd-img :height="img.height" :width="img.width" :src="img.src" v-if="img.src" />
``` ```
```typescript ```typescript
const img = ref({ const img = ref<Partial<SignatureResult>>({})
width: 0,
height: 0,
src: ''
})
function confirm(result: SignatureResult) { function confirm(result: SignatureResult) {
img.value.src = result.tempFilePath if (result.success) {
img.value.height = result.height uni.previewImage({
img.value.width = result.width urls: [result.tempFilePath]
})
}
}
function clear() {
img.value = {}
} }
``` ```
## 开启历史记录 ## 历史记录
通过 `enable-history` 开启历史记录功能,可以进行撤销和恢复操作。
```html ```html
<wd-signature :history="true" background-color="lightgray" /> <wd-signature enable-history background-color="#f5f5f5" />
``` ```
## 自定义画笔颜色 ## 笔锋模式
`pen-color`设置签名笔的颜色,默认为`黑色` 通过 `pressure` 开启笔锋模式,模拟真实书写效果。笔锋模式下笔画粗细会随书写速度变化。
### 基础笔锋效果
```html ```html
<wd-signature pen-color="red" /> <wd-signature pressure :height="300" />
``` ```
## 自定义画笔宽度 :::tip 使用建议
1. 笔锋模式推荐参数范围:
- min-width: 1-2
- max-width: 4-6
- min-speed: 1-2
2. max-width 和 min-width 的差值建议保持在 3-5 之间
3. min-speed 值越小,压感越灵敏,建议根据实际书写习惯调整
4. 对于签名场景,建议将画布高度设置在 300-400 之间
:::
`line-width`设置签名笔的宽度,默认为`2` ### 自定义笔锋参数
可以通过以下属性精确控制笔锋效果:
- `min-width`: 最小笔画宽度,快速书写时的线条粗细
- `max-width`: 最大笔画宽度,慢速书写时的线条粗细
- `min-speed`: 速度阈值,用于调整压感灵敏度
```html ```html
<wd-signature :line-width="6" /> <wd-signature
pressure
:height="300"
:min-width="1"
:max-width="6"
:min-speed="1.5"
background-color="#f5f5f5"
/>
<view class="tip-text">快速书写产生细线条,慢速书写产生粗线条</view>
``` ```
## 自定义背景色 ### 笔锋模式 + 历史记录
`background-color`设置画板的背景色,无默认值。 笔锋模式可以与历史记录功能结合使用,支持对带有笔锋效果的线条进行撤销和恢复操作
```html ```html
<wd-signature background-color="lightgray" /> <wd-signature
pressure
enable-history
:height="300"
:min-width="1"
:max-width="6"
background-color="#f5f5f5"
/>
<view class="tip-text">结合历史记录,支持笔锋效果的撤销与恢复</view>
``` ```
## 禁用滚动 ## 自定义功能
`disable-scroll`设置是否禁用画布滚动,默认为`true` ### 自定义按钮
通过 `footer` 插槽自定义底部按钮。
```html ```html
<wd-signature :disable-scroll="false" /> <wd-signature :disabled="disabled" enable-history :step="3">
``` <template #footer="{ clear, confirm, currentStep, restore, revoke, historyList }">
## 自定义按钮
通过`footer`插槽可以自定义按钮。
```html
<wd-signature :disabled="disabled" :step="3">
<template #footer="{ clear, confirm, currentStep, restore, revoke,historyList }">
<wd-button block @click="changeDisabled" v-if="disabled">开始签名</wd-button> <wd-button block @click="changeDisabled" v-if="disabled">开始签名</wd-button>
<block v-if="!disabled"> <block v-if="!disabled">
<wd-button size="small" plain @click="revoke()" :disabled="currentStep <= 0">撤回三步</wd-button> <wd-button size="small" plain @click="revoke" :disabled="currentStep <= 0">撤回</wd-button>
<wd-button size="small" plain @click="restore()" :disabled="!(currentStep < historyList.length)">恢复三步</wd-button> <wd-button size="small" plain @click="restore" :disabled="currentStep >= historyList.length">恢复</wd-button>
<wd-button size="small" plain @click="clear">清除</wd-button> <wd-button size="small" plain @click="clear">清除</wd-button>
<wd-button size="small" style="margin-left: 4px" @click="confirm"></wd-button> <wd-button size="small" @click="confirm">确定</wd-button>
</block> </block>
</template> </template>
</wd-signature> </wd-signature>
@ -91,48 +121,372 @@ function changeDisabled() {
} }
``` ```
### 自定义画笔
可以自定义画笔的颜色和宽度。
```html
<wd-signature pen-color="#0083ff" :line-width="4" />
```
### 弹窗中使用
结合 `wd-popup` 组件在弹窗中使用签名板。建议使用 `after-enter` 事件调用签名板的 `init` 方法以确保正确初始化。
```html
<wd-button type="primary" @click="show = true">打开签名板</wd-button>
<wd-popup
v-model="show"
closable
safe-area-inset-bottom
position="bottom"
custom-style="padding: 48px 20px 20px 20px; border-radius: 16px 16px 0 0;"
@after-enter="signatureRef?.init()"
>
<wd-signature
ref="signatureRef"
:height="300"
enable-history
pressure
background-color="#f5f5f5"
@confirm="handleConfirm"
/>
</wd-popup>
<wd-img v-if="img.tempFilePath" mode="widthFix" width="100%" :src="img.tempFilePath" />
```
```typescript
import { ref } from 'vue'
import type { SignatureInstance, SignatureResult } from '@/uni_modules/wot-design-uni/components/wd-signature/types'
const show = ref(false)
const img = ref<Partial<SignatureResult>>({})
const signatureRef = ref<SignatureInstance>()
function handleConfirm(result: SignatureResult) {
show.value = false
if (result.success) {
uni.previewImage({
urls: [result.tempFilePath]
})
}
}
```
```scss
.popup-footer {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
```
:::tip 提示
弹窗中使用签名板时,建议:
1. 开启 `closable` 显示关闭按钮
2. 设置 `safe-area-inset-bottom` 以适配底部安全区
3. 使用 `custom-style` 调整弹窗内边距,为关闭按钮留出空间
4. 在弹窗的 `after-enter` 事件中调用签名板的 `init` 方法,确保正确初始化
:::
### 横屏签名页面
可以通过配置页面的 `pageOrientation` 来实现横屏签名页面。
```json
// pages.json
{
"pages": [
{
"path": "pages/signature-landscape/Index",
"style": {
"navigationBarTitleText": "横屏签名",
"pageOrientation": "landscape"
}
}
]
}
```
```html
<template>
<view class="landscape-signature">
<wd-signature
ref="signatureRef"
:height="height"
:width="width"
pressure
enable-history
background-color="#f5f5f5"
@confirm="handleConfirm"
/>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
const height = ref(0)
const width = ref(0)
onMounted(() => {
const { windowWidth, windowHeight } = uni.getSystemInfoSync()
// 减去页面边距
height.value = windowWidth - 40
width.value = windowHeight - 40
})
</script>
<style>
.landscape-signature {
padding: 20px;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
</style>
```
:::tip 提示
横屏签名页面的建议:
1. 使用 `pageOrientation: "landscape"` 强制横屏显示
2. 动态计算画布尺寸以适配不同设备
3. 注意横屏时 windowWidth 和 windowHeight 的对调
4. 建议开启笔锋模式提供更好的签名体验
:::
### 横屏签名
支持以下两种横屏签名实现方案:
#### 1. 通用横屏方案 (推荐)
通过自定义布局和按钮旋转实现横屏效果,适用于所有平台。
```html
<template>
<view class="landscape-signature">
<wd-signature
v-if="inited"
:height="height"
:width="width"
enable-history
pressure
background-color="#f5f5f5"
@confirm="handleConfirm"
>
<template #footer="{ clear, confirm, restore, revoke, canUndo, canRedo }">
<view class="custom-actions">
<view class="button-group">
<wd-button size="small" plain @click="revoke" :disabled="!canUndo">撤回</wd-button>
<wd-button size="small" plain @click="restore" :disabled="!canRedo">恢复</wd-button>
<wd-button size="small" plain @click="clear">清除</wd-button>
<wd-button size="small" type="primary" @click="confirm">完成</wd-button>
</view>
</view>
</template>
</wd-signature>
</view>
</template>
```
```ts
import { pause } from '@/uni_modules/wot-design-uni/components/common/util'
const height = ref(0)
const width = ref(0)
const inited = ref(false)
onMounted(() => {
const { windowWidth, windowHeight } = uni.getSystemInfoSync()
width.value = windowWidth - 48
height.value = windowHeight - 48
pause(100).then(() => {
inited.value = true
})
})
```
```scss
.landscape-signature {
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
background: #fff;
position: relative;
padding: 24px 0;
padding-left: 48px;
box-sizing: border-box;
.custom-actions {
position: fixed;
left: 0;
top: 50%;
width: 48px;
transform: translateY(-50%) rotate(90deg);
transform-origin: center;
z-index: 10;
.button-group {
display: flex;
flex-direction: row;
gap: 12px;
white-space: nowrap;
width: max-content;
transform: translateX(-50%);
}
}
}
```
:::tip 实现说明
通用横屏方案特点:
1. 使用 fixed 布局配合旋转实现左侧垂直按钮栏
2. 通过 footer 插槽自定义操作按钮
3. 使用 transform 实现按钮的旋转效果
4. 适用于所有平台,实现方式一致
5. 建议使用 inited 变量配合延迟加载避免画布初始化问题
:::
#### 2. 原生横屏方案 (仅微信小程序)
微信小程序提供了原生的横屏支持,使用时需要注意区分平台:
```json
{
"path": "pages/signature/landscape",
"style": {
"navigationBarTitleText": "横屏签名",
// #ifdef MP-WEIXIN
"pageOrientation": "landscape"
// #endif
}
}
```
```html
<template>
<view class="landscape-signature">
<wd-signature
v-if="inited"
ref="signatureRef"
:height="height"
:width="width"
enable-history
pressure
background-color="#f5f5f5"
@confirm="handleConfirm"
>
</wd-signature>
</view>
</template>
```
```ts
import { pause } from '@/uni_modules/wot-design-uni/components/common/util'
const height = ref(0)
const width = ref(0)
const inited = ref(false)
onMounted(() => {
const { windowWidth, windowHeight } = uni.getSystemInfoSync()
width.value = windowWidth
height.value = windowHeight - 60 // 预留底部按钮空间
pause(100).then(() => {
inited.value = true
})
})
```
```scss
.landscape-signature {
height: 100vh;
background: #fff;
position: relative;
box-sizing: border-box;
// #ifdef MP-WEIXIN
padding: 0;
display: flex;
flex-direction: column;
.weixin-actions {
padding: 12px;
background-color: #f8f8f8;
.button-group {
display: flex;
justify-content: center;
gap: 12px;
}
}
// #endif
}
```
:::warning 注意事项
1. `pageOrientation` 配置仅在微信小程序端生效
2. 使用条件编译区分不同平台的布局结构
3. 微信小程序页面会自动旋转,按钮布局不需要特殊处理
4. 预留底部按钮空间时需要考虑横屏布局
5. 其他平台请使用通用横屏方案
:::
## Attributes ## Attributes
| 参数 | 说明 | 类型 | 可选值 | 默认值 | 最低版本 | | 参数 | 说明 | 类型 | 默认值 | 最低版本 |
|-----------------|----------------------------------------------------------------------|---------|--------|----------|----------| |------|------|------|--------|----------|
| penColor | 签名笔颜色 | String | -- | #000000 | -- | | pen-color | 签名笔颜色 | string | #000000 | - |
| lineWidth | 签名笔宽度 | Number | -- | 2 | -- | | line-width | 签名笔宽度 | number | 3 | - |
| height | 画布的高度 | Number | -- | 200 | -- | | height | 画布的高度 | number | 200 | - |
| width | 画布的宽度 | Number | -- | 300 | -- | | width | 画布的宽度 | number | 300 | - |
| clearText | 清空按钮的文本 | String | -- | 清空 | -- | | clear-text | 清空按钮的文本 | string | - | - |
| confirmText | 确认按钮的文本 | String | -- | 确认 | -- | | confirm-text | 确认按钮的文本 | string | - | - |
| fileType | 目标文件的类型,[uni.canvasToTempFilePath属性介绍](https://uniapp.dcloud.net.cn/api/canvas/canvasToTempFilePath.html) | String | -- | png | -- | | file-type | 导出图片类型 | string | png | - |
| quality | 图片的质量,取值范围为 `(0, 1]`不在范围内时当作1.0处理,[uni.canvasToTempFilePath属性介绍](https://uniapp.dcloud.net.cn/api/canvas/canvasToTempFilePath.html) | Number | -- | 1 | -- | | quality | 导出图片质量(0-1) | number | 1 | - |
| exportScale | 导出图片的缩放比例 | Number | -- | 1 | -- | | export-scale | 导出图片的缩放比例 | number | 1 | - |
| disabled | 是否禁用签名板 | Boolean | -- | false | -- | | disabled | 是否禁用签名板 | boolean | false | - |
| backgroundColor | 画板的背景色 | String | -- | -- | -- | | background-color | 画板的背景色 | string | - | - |
| disableScroll | 是否禁用画布滚动 | Boolean | -- | true | -- | | disable-scroll | 是否禁用画布滚动 | boolean | true | - |
| history | 是否开启历史记录 | Boolean | -- | false | -- | | enable-history | 是否开启历史记录 | boolean | false | - |
| step | 开启历史记录之后的步长(撤回step步) | Number | -- | 1 | -- | | step | 历史记录步长 | number | 1 | - |
| pressure | 是否启用笔锋模式 | boolean | false | - |
## Slot | min-width | 笔锋模式最小宽度 | number | 2 | - |
| max-width | 笔锋模式最大宽度 | number | 6 | - |
| name | 说明 | 参数 | 最低版本 | | min-speed | 笔锋模式速度阈值 | number | 1.5 | - |
|--------|----------------|-----------------------|----------|
| footer | 自定义footer | `{ clear, confirm, restore, revoke, currentStep ,historyList }` | - |
## Events ## Events
| 事件名称 | 说明 | 参数 | 最低版本 | | 事件名称 | 说明 | 参数 | 最低版本 |
|-----------|--------------------|-------------------------------------------|----------| |---------|------|------|----------|
| start | 开始签名时触发 | - | - | | start | 开始签名时触发 | event: TouchEvent | - |
| end | 结束签名时触发 | - | - | | end | 结束签名时触发 | event: TouchEvent | - |
| signing | 签名过程中触发 | `event` | - | | signing | 签名过程中触发 | event: TouchEvent | - |
| confirm | 点击确定按钮时触发 | `{tempFilePath, width, height, success}` 分别为生成文件的临时路径 (本地路径)、生成图片宽、生成图片高、是否成功 | - | | confirm | 确认签名时触发 | result: SignatureResult | - |
| clear | 点击清空按钮时触发 | - | - | | clear | 清空签名时触发 | - | - |
## Methods ## Methods
对外暴露函数 | 方法名 | 说明 | 参数 | 最低版本 |
|--------|------|------|----------|
| init | 初始化签名板 | forceUpdate?: boolean | - |
| confirm | 确认签名 | - | - |
| clear | 清空签名 | - | - |
| restore | 恢复上一步 | - | - |
| revoke | 撤销上一步 | - | - |
| 事件名称 | 说明 | 参数 | 最低版本 | ## Slots
|-----------|--------------------|-------------------------------------------|----------|
| confirm | 点击确认按钮时触发 | `{tempFilePath, width, height, success}` 分别为生成文件的临时路径 (本地路径)、生成图片宽、生成图片高、是否成功 | - | | 名称 | 说明 | 参数 | 最低版本 |
| clear | 点击清空按钮时触发 | - | - | |------|------|------|----------|
| restore | 暴露恢复方法 | - | - | | footer | 自定义底部按钮 | `{ clear, confirm, restore, revoke, currentStep, historyList }` | - |
| revoke | 暴露撤回方法 | - | - |

View File

@ -811,6 +811,17 @@
"navigationBarTitleText": "Signature 签名" "navigationBarTitleText": "Signature 签名"
} }
}, },
{
"path": "pages/signature/Landscape",
"name": "signatureLandscape",
"style": {
"mp-alipay": {
"allowsBounceVertical": "NO"
},
"navigationBarTitleText": "横屏签名",
"pageOrientation": "landscape"
}
},
{ {
"path": "pages/backtop/Index", "path": "pages/backtop/Index",
"name": "backtop", "name": "backtop",

View File

@ -1,51 +1,76 @@
<!-- <!--
* @Author: 810505339 * @Author: 810505339
* @Date: 2025-02-11 21:17:21 * @Date: 2025-02-11 21:17:21
* @LastEditors: 810505339 * @LastEditors: weisheng
<<<<<<< HEAD * @LastEditTime: 2025-03-23 14:12:32
* @LastEditTime: 2025-02-14 12:22:03 * @FilePath: /wot-design-uni/src/pages/signature/Index.vue
=======
* @LastEditTime: 2025-02-15 21:38:52
>>>>>>> 4c29deae524fe910351cc9a131067fb6124891b7
* @FilePath: \wot-design-uni\src\pages\signature\Index.vue
* 记得注释 * 记得注释
--> -->
<template> <template>
<page-wraper> <page-wraper>
<demo-block title="基础用法"> <demo-block title="基础用法">
<wd-signature @confirm="confirm" @clear="clear" :export-scale="2" /> <wd-signature @confirm="confirm" @clear="clear" :export-scale="2" background-color="#ffffff" />
<wd-img v-if="img.tempFilePath" mode="widthFix" width="100%" :src="img.tempFilePath" />
</demo-block> </demo-block>
<demo-block title="开启历史记录">
<wd-signature :history="true" background-color="lightgray" /> <demo-block title="历史记录">
<wd-signature enable-history background-color="#f5f5f5" />
</demo-block> </demo-block>
<demo-block title="自定义画笔颜色">
<wd-signature pen-color="red" /> <demo-block title="笔锋模式-基础">
<wd-signature pressure :height="300" />
</demo-block> </demo-block>
<demo-block title="自定义画笔宽度">
<wd-signature :line-width="6" /> <demo-block title="笔锋模式-自定义">
<wd-signature pressure :height="300" :min-width="1" :max-width="6" :min-speed="1.5" background-color="#f5f5f5" />
<view class="tip-text">快速书写产生细线条慢速书写产生粗线条</view>
</demo-block> </demo-block>
<demo-block title="自定义背景颜色">
<wd-signature background-color="lightgray" /> <demo-block title="笔锋模式 + 历史记录">
<wd-signature pressure enable-history :height="300" :min-width="1" :max-width="6" background-color="#f5f5f5" />
<view class="tip-text">结合历史记录支持笔锋效果的撤销与恢复</view>
</demo-block> </demo-block>
<demo-block title="自定义插槽">
<wd-signature :disabled="disabled" :history="true" :step="3"> <demo-block title="自定义按钮">
<wd-signature :disabled="disabled" enable-history :step="3">
<template #footer="{ clear, confirm, currentStep, restore, revoke, historyList }"> <template #footer="{ clear, confirm, currentStep, restore, revoke, historyList }">
<wd-button block @click="changeDisabled" v-if="disabled">开始签名</wd-button> <wd-button block @click="changeDisabled" v-if="disabled">开始签名</wd-button>
<block v-if="!disabled"> <block v-if="!disabled">
<wd-button size="small" plain @click="revoke()" :disabled="currentStep <= 0">撤回三步</wd-button> <wd-button size="small" plain @click="revoke" :disabled="currentStep <= 0">撤回</wd-button>
<wd-button size="small" plain @click="restore()" :disabled="!(currentStep < historyList.length)">恢复三步</wd-button> <wd-button size="small" plain @click="restore" :disabled="currentStep >= historyList.length">恢复</wd-button>
<wd-button size="small" plain @click="clear">清除</wd-button> <wd-button size="small" plain @click="clear">清除</wd-button>
<wd-button size="small" style="margin-left: 4px" @click="confirm">确定</wd-button> <wd-button size="small" @click="confirm">确定</wd-button>
</block> </block>
</template> </template>
</wd-signature> </wd-signature>
</demo-block> </demo-block>
<demo-block title="自定义画笔">
<wd-signature pen-color="#0083ff" :line-width="4" />
</demo-block>
<demo-block title="弹窗中使用">
<wd-button type="primary" @click="showPopup = true">打开签名板</wd-button>
<wd-popup
v-model="showPopup"
closable
safe-area-inset-bottom
position="bottom"
custom-style="padding: 48px 20px 20px 20px; border-radius: 16px 16px 0 0;"
@after-enter="signatureRef?.init()"
>
<wd-signature ref="signatureRef" :height="300" enable-history pressure background-color="#f5f5f5" @confirm="handlePopupConfirm" />
</wd-popup>
</demo-block>
<demo-block title="横屏签名">
<wd-button type="primary" @click="toSignatureLandscape">使用横屏签名</wd-button>
</demo-block>
</page-wraper> </page-wraper>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { SignatureResult } from '@/uni_modules/wot-design-uni/components/wd-signature/types' import type { SignatureInstance, SignatureResult } from '@/uni_modules/wot-design-uni/components/wd-signature/types'
import { ref } from 'vue' import { ref } from 'vue'
const img = ref<Partial<SignatureResult>>({}) const img = ref<Partial<SignatureResult>>({})
@ -53,7 +78,12 @@ const img = ref<Partial<SignatureResult>>({})
const disabled = ref(true) const disabled = ref(true)
function confirm(result: SignatureResult) { function confirm(result: SignatureResult) {
img.value = result debugger
if (result.success) {
uni.previewImage({
urls: [result.tempFilePath]
})
}
} }
function clear() { function clear() {
@ -63,4 +93,53 @@ function clear() {
function changeDisabled() { function changeDisabled() {
disabled.value = false disabled.value = false
} }
const showPopup = ref(false)
const signatureRef = ref<SignatureInstance>()
function handlePopupConfirm(result: SignatureResult) {
showPopup.value = false
if (result.success) {
uni.previewImage({
urls: [result.tempFilePath]
})
}
}
function toSignatureLandscape() {
console.log(232)
uni.navigateTo({
url: '/pages/signature/Landscape'
})
}
</script> </script>
<style lang="scss" scoped>
.tip-text {
font-size: 12px;
color: #999;
margin-top: 8px;
text-align: center;
}
.button-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
.button-row {
display: flex;
justify-content: center;
gap: 8px;
}
}
.popup-footer {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<view class="landscape-signature">
<wd-signature
v-if="inited"
ref="signatureRef"
:height="height"
:width="width"
enable-history
pressure
background-color="#f5f5f5"
@confirm="handleConfirm"
>
<!-- #ifndef MP-WEIXIN -->
<template #footer="{ clear, confirm, restore, revoke, canUndo, canRedo }">
<view class="custom-actions">
<view class="button-group">
<wd-button size="small" plain @click="revoke" :disabled="!canUndo">撤回</wd-button>
<wd-button size="small" plain @click="restore" :disabled="!canRedo">恢复</wd-button>
<wd-button size="small" plain @click="clear">清除</wd-button>
<wd-button size="small" type="primary" @click="confirm">完成</wd-button>
</view>
</view>
</template>
<!-- #endif -->
</wd-signature>
</view>
</template>
<script lang="ts" setup>
import { pause } from '@/uni_modules/wot-design-uni/components/common/util'
import type { SignatureInstance, SignatureResult } from '@/uni_modules/wot-design-uni/components/wd-signature/types'
import { ref, onMounted } from 'vue'
const signatureRef = ref<SignatureInstance>()
const height = ref(0)
const width = ref(0)
const inited = ref(false)
onMounted(() => {
const { windowWidth, windowHeight } = uni.getSystemInfoSync()
// #ifdef MP-WEIXIN
// 使
width.value = windowWidth
height.value = windowHeight - 60 //
// #endif
// #ifndef MP-WEIXIN
width.value = windowWidth - 48
height.value = windowHeight - 48
inited.value = true
// #endif
pause(100).then(() => {
inited.value = true
})
})
function handleConfirm(result: SignatureResult) {
if (result.success) {
uni.previewImage({
urls: [result.tempFilePath]
})
}
}
</script>
<style lang="scss">
.landscape-signature {
height: 100vh;
// #ifdef H5
height: calc(100vh - 44px);
// #endif
background: #fff;
position: relative;
box-sizing: border-box;
// #ifdef MP-WEIXIN
//
padding: 0;
display: flex;
flex-direction: column;
// #endif
// #ifndef MP-WEIXIN
padding: 24px 0;
padding-left: 48px;
.custom-actions {
position: fixed;
left: 0;
top: 50%;
width: 48px;
transform: translateY(-50%) rotate(90deg);
transform-origin: center;
z-index: 10;
.button-group {
display: flex;
flex-direction: row;
gap: 12px;
white-space: nowrap;
width: max-content;
transform: translateX(-50%);
}
}
// #endif
}
</style>

View File

@ -1,11 +1,12 @@
/* /*
* @Author: 810505339 * @Author: 810505339
* @Date: 2025-01-10 20:03:57 * @Date: 2025-01-10 20:03:57
* @LastEditors: 810505339 * @LastEditors: weisheng
* @LastEditTime: 2025-02-18 13:04:45 * @LastEditTime: 2025-03-23 16:35:14
* @FilePath: \wot-design-uni\src\uni_modules\wot-design-uni\components\wd-signature\types.ts * @FilePath: /wot-design-uni/src/uni_modules/wot-design-uni/components/wd-signature/types.ts
* *
*/ */
import type { ComponentPublicInstance, ExtractPropTypes } from 'vue'
import { baseProps, numericProp } from '../common/props' import { baseProps, numericProp } from '../common/props'
export const signatureProps = { export const signatureProps = {
@ -108,31 +109,155 @@ export const signatureProps = {
type: Boolean, type: Boolean,
default: true default: true
}, },
/* 是否开始历史记录 */ /**
history: { *
* boolean
* false
*/
enableHistory: {
type: Boolean, type: Boolean,
default: false default: false
}, },
/**
*
* number
* 1
*/
step: { step: {
type: Number, type: Number,
default: 1 default: 1
}, },
/**
*
* string
*
*/
undoText: String, undoText: String,
redoText: String /**
*
* string
*
*/
redoText: String,
/**
* ()
* boolean
* false
*/
pressure: {
type: Boolean,
default: false
},
/**
*
* number
* 2
*/
minWidth: {
type: Number,
default: 2
},
/**
*
* number
* 6
*/
maxWidth: {
type: Number,
default: 6
},
/**
*
* number
* 1.5
*/
minSpeed: {
type: Number,
default: 1.5
}
} }
/**
*
* @property tempFilePath -
* @property success -
* @property width -
* @property height -
*/
export type SignatureResult = { export type SignatureResult = {
tempFilePath: string tempFilePath: string
success: boolean success: boolean
width: number width: number
height: number height: number
} }
/**
* 线
* @property points - 线
* @property color - 线
* @property width - 线
* @property backgroundColor - 线 ()
* @property isPressure - 线 ()
*/
export interface Line {
points: Point[]
color: string
width: number
backgroundColor?: string
isPressure?: boolean
}
/**
*
* @property x -
* @property y -
* @property t -
* @property speed - ()
* @property distance - ()
* @property lineWidth - 线 ()
* @property lastX1 - 线x坐标 ()
* @property lastY1 - 线y坐标 ()
* @property lastX2 - 线x坐标 ()
* @property lastY2 - 线y坐标 ()
* @property isFirstPoint - 线 ()
*/
export interface Point {
x: number
y: number
t: number
speed?: number
distance?: number
lineWidth?: number
lastX1?: number
lastY1?: number
lastX2?: number
lastY2?: number
isFirstPoint?: boolean
}
/**
*
* @property init -
* @property clear -
* @property confirm -
* @property restore -
* @property revoke -
*/
export type SignatureExpose = { export type SignatureExpose = {
/**
* @param forceUpdate -
*/
init: (forceUpdate?: boolean) => void
/** 点击清除按钮清除签名 */ /** 点击清除按钮清除签名 */
clear: () => void clear: () => void
/** 点击确定按钮 */ /** 点击确定按钮 */
confirm: (result: SignatureResult) => void confirm: () => void
/* 点击恢复 */ /* 点击恢复 */
restore: () => void restore: () => void
/* 点击撤回 */ /* 点击撤回 */
revoke: () => void revoke: () => void
} }
export type SignatureProps = ExtractPropTypes<typeof signatureProps>
export type SignatureInstance = ComponentPublicInstance<SignatureExpose, SignatureProps>

View File

@ -1,9 +1,10 @@
<template> <template>
<view class="wd-signature"> <view class="wd-signature">
<view class="wd-signature__content" :style="canvasStyle"> <view class="wd-signature__content">
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<canvas <canvas
class="wd-signature__content-canvas" class="wd-signature__content-canvas"
:style="canvasStyle"
:width="canvasState.canvasWidth" :width="canvasState.canvasWidth"
:height="canvasState.canvasHeight" :height="canvasState.canvasHeight"
:canvas-id="canvasId" :canvas-id="canvasId"
@ -19,6 +20,7 @@
<canvas <canvas
class="wd-signature__content-canvas" class="wd-signature__content-canvas"
:canvas-id="canvasId" :canvas-id="canvasId"
:style="canvasStyle"
:width="canvasState.canvasWidth" :width="canvasState.canvasWidth"
:height="canvasState.canvasHeight" :height="canvasState.canvasHeight"
:id="canvasId" :id="canvasId"
@ -34,14 +36,18 @@
name="footer" name="footer"
:clear="clear" :clear="clear"
:confirm="confirmSignature" :confirm="confirmSignature"
:currentStep="currentStep" :current-step="currentStep"
:revoke="revoke" :revoke="revoke"
:restore="restore" :restore="restore"
:historyList="historyList" :can-undo="lines.length > 0"
:can-redo="redoLines.length > 0"
:history-list="lines"
> >
<block v-if="history"> <block v-if="enableHistory">
<wd-button size="small" plain @click="revoke" :disabled="currentStep <= 0">{{ revokeText || translate('revokeText') }}</wd-button> <wd-button size="small" plain @click="revoke" :disabled="lines.length <= 0">
<wd-button size="small" plain @click="restore" :disabled="!(currentStep < historyList.length)"> {{ revokeText || translate('revokeText') }}
</wd-button>
<wd-button size="small" plain @click="restore" :disabled="redoLines.length <= 0">
{{ restoreText || translate('restoreText') }} {{ restoreText || translate('restoreText') }}
</wd-button> </wd-button>
</block> </block>
@ -64,7 +70,7 @@ export default {
<script lang="ts" setup> <script lang="ts" setup>
import { computed, getCurrentInstance, onBeforeMount, onMounted, reactive, ref, watch, type CSSProperties } from 'vue' import { computed, getCurrentInstance, onBeforeMount, onMounted, reactive, ref, watch, type CSSProperties } from 'vue'
import { addUnit, getRect, isDef, objToStyle, uuid } from '../common/util' import { addUnit, getRect, isDef, objToStyle, uuid } from '../common/util'
import { signatureProps, type SignatureExpose, type SignatureResult } from './types' import { signatureProps, type SignatureExpose, type SignatureResult, type Point, type Line } from './types'
import { useTranslate } from '../composables/useTranslate' import { useTranslate } from '../composables/useTranslate'
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
import { canvas2dAdapter } from '../common/canvasHelper' import { canvas2dAdapter } from '../common/canvasHelper'
@ -78,9 +84,6 @@ const canvasId = ref<string>(`signature${uuid()}`) // canvas 组件的唯一标
let canvas: null = null //canvas let canvas: null = null //canvas
const drawing = ref<boolean>(false) // const drawing = ref<boolean>(false) //
const pixelRatio = ref<number>(1) // const pixelRatio = ref<number>(1) //
const historyList = ref<Array<ImageData>>([]) //
const currentStep = ref(0) //
const maxHistoryLength = ref<number>(100) //
const canvasState = reactive({ const canvasState = reactive({
canvasWidth: 0, canvasWidth: 0,
@ -116,37 +119,99 @@ const canvasStyle = computed(() => {
}) })
const disableScroll = computed(() => props.disableScroll) const disableScroll = computed(() => props.disableScroll)
const history = computed(() => props.history) const enableHistory = computed(() => props.enableHistory)
const lines = ref<Line[]>([]) // 线
const redoLines = ref<Line[]>([]) // 线
const currentLine = ref<Line>() // 线
const currentStep = ref(0) //
//
function calculateLineWidth(speed: number): number {
if (!props.pressure) return props.lineWidth
const minSpeed = props.minSpeed || 1.5
const limitedSpeed = Math.min(minSpeed * 10, Math.max(minSpeed, speed))
const addWidth = ((props.maxWidth - props.minWidth) * (limitedSpeed - minSpeed)) / minSpeed
const lineWidth = Math.max(props.maxWidth - addWidth, props.minWidth)
return Math.min(lineWidth, props.maxWidth)
}
/* 获取默认笔画宽度 */
const getDefaultLineWidth = () => {
if (props.pressure) {
// 使
return (props.maxWidth + props.minWidth) / 2
}
return props.lineWidth
}
/* 开始画线 */ /* 开始画线 */
const startDrawing = (e: TouchEvent) => { const startDrawing = (e: any) => {
e.preventDefault() e.preventDefault()
drawing.value = true drawing.value = true
setLine() setLine()
emit('start', e) emit('start', e)
draw(e)
// // 线
if (history.value) { const { x, y } = e.touches[0]
if (currentStep.value < historyList.value.length) { currentLine.value = {
historyList.value = historyList.value.slice(0, currentStep.value) points: [
} {
x,
y,
t: Date.now() // 使 t width
}
],
color: props.penColor,
width: getDefaultLineWidth(),
backgroundColor: props.backgroundColor,
isPressure: props.pressure //
} }
//
currentStep.value = historyList.value.length //
redoLines.value = []
draw(e)
} }
/* 结束画线 */ /* 结束画线 */
const stopDrawing = (e: TouchEvent) => { const stopDrawing = (e: TouchEvent) => {
e.preventDefault() e.preventDefault()
drawing.value = false drawing.value = false
if (currentLine.value) {
// 线
lines.value.push({
...currentLine.value,
points: currentLine.value.points.map((point) => ({
...point,
t: point.t,
speed: point.speed,
distance: point.distance,
lineWidth: point.lineWidth,
lastX1: point.lastX1,
lastY1: point.lastY1,
lastX2: point.lastX2,
lastY2: point.lastY2,
isFirstPoint: point.isFirstPoint
}))
})
currentStep.value = lines.value.length
}
currentLine.value = undefined
const { ctx } = canvasState const { ctx } = canvasState
if (ctx) ctx.beginPath() if (ctx) ctx.beginPath()
pushHistoryList()
emit('end', e) emit('end', e)
} }
// canvas /**
const initCanvas = () => { * 初始化 canvas
* @param forceUpdate 是否强制更新
*/
const initCanvas = (forceUpdate: boolean = false) => {
// canvas
if (!forceUpdate && canvasState.canvasHeight && canvasState.canvasWidth) {
return
}
getContext().then(() => { getContext().then(() => {
const { ctx } = canvasState const { ctx } = canvasState
if (ctx && isDef(props.backgroundColor)) { if (ctx && isDef(props.backgroundColor)) {
@ -159,7 +224,9 @@ const initCanvas = () => {
// canvas // canvas
const clear = () => { const clear = () => {
clearHistoryList() lines.value = []
redoLines.value = []
currentStep.value = 0
clearCanvas() clearCanvas()
emit('clear') emit('clear')
} }
@ -176,39 +243,217 @@ const draw = (e: any) => {
if (!drawing.value || props.disabled || !ctx) return if (!drawing.value || props.disabled || !ctx) return
const { x, y } = e.touches[0] const { x, y } = e.touches[0]
ctx.lineTo(x, y)
ctx.stroke() const point: Point = {
ctx.draw(true) //线 x,
ctx.moveTo(x, y) y,
emit('signing', e) t: Date.now()
} }
/* 点击上一步 */
const revoke = () => { if (currentLine.value) {
if (history.value) { const points = currentLine.value.points
if (isDef(props.step)) { const prePoint = points[points.length - 1]
currentStep.value = Math.max(currentStep.value - props.step, 0)
if (currentStep.value > 0) { if (prePoint.t === point.t || (prePoint.x === x && prePoint.y === y)) {
clearCanvas() return
putCanvasImageData(props.step) }
} else {
clearCanvas() //
currentStep.value = 0 point.distance = Math.sqrt(Math.pow(point.x - prePoint.x, 2) + Math.pow(point.y - prePoint.y, 2))
point.speed = point.distance / (point.t - prePoint.t || 0.1)
if (props.pressure) {
point.lineWidth = calculateLineWidth(point.speed)
// 线
if (points.length >= 2) {
const prePoint2 = points[points.length - 2]
if (prePoint2.lineWidth && prePoint.lineWidth) {
const rate = (point.lineWidth - prePoint.lineWidth) / prePoint.lineWidth
const maxRate = 0.2 // 20%
if (Math.abs(rate) > maxRate) {
const per = rate > 0 ? maxRate : -maxRate
point.lineWidth = prePoint.lineWidth * (1 + per)
}
}
} }
} }
points.push(point)
// 使线
if (!props.pressure) {
ctx.beginPath()
ctx.moveTo(prePoint.x, prePoint.y)
ctx.lineTo(point.x, point.y)
ctx.stroke()
ctx.draw(true)
} else if (points.length >= 2) {
// 使线
drawSmoothLine(prePoint, point)
}
} }
}
/* 点击下一步 */
const restore = () => {
if (history.value) {
if (isDef(props.step)) {
/* 是否可以点击下一步 */
if (currentStep.value <= historyList.value.length - props.step) { emit('signing', e)
currentStep.value = currentStep.value + props.step }
clearCanvas()
putCanvasImageData(props.step) /* 重绘整个画布 */
} const redrawCanvas = () => {
const { ctx } = canvasState
if (!ctx) return
//
if (isDef(props.backgroundColor)) {
ctx.setFillStyle(props.backgroundColor)
ctx.fillRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
} else {
ctx.clearRect(0, 0, canvasState.canvasWidth, canvasState.canvasHeight)
}
// 线
if (lines.value.length === 0) {
ctx.draw()
return
}
// draw
lines.value.forEach((line) => {
if (!line.points.length) return
ctx.setStrokeStyle(line.color)
ctx.setLineJoin('round')
ctx.setLineCap('round')
if (line.isPressure && props.pressure) {
//
line.points.forEach((point, index) => {
if (index === 0) return
const prePoint = line.points[index - 1]
const dis_x = point.x - prePoint.x
const dis_y = point.y - prePoint.y
const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
if (distance <= 2) {
point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
} else {
const speed = point.speed || 0
const minSpeed = props.minSpeed || 1.5
const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
}
const lineWidth = point.lineWidth || line.width
if (typeof prePoint.lastX1 === 'number') {
ctx.setLineWidth(lineWidth)
ctx.beginPath()
ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
ctx.stroke()
if (!prePoint.isFirstPoint) {
ctx.beginPath()
ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
ctx.stroke()
}
} else {
point.isFirstPoint = true
}
})
} else {
//
ctx.setLineWidth(line.width)
line.points.forEach((point, index) => {
if (index === 0) return
const prePoint = line.points[index - 1]
ctx.beginPath()
ctx.moveTo(prePoint.x, prePoint.y)
ctx.lineTo(point.x, point.y)
ctx.stroke()
})
} }
})
// 线
ctx.draw()
}
//
const revoke = () => {
if (!lines.value.length) return
const step = Math.min(props.step, lines.value.length)
const removedLines = lines.value.splice(lines.value.length - step)
redoLines.value.push(...removedLines)
currentStep.value = Math.max(0, currentStep.value - step)
redrawCanvas()
}
//
const restore = () => {
if (!redoLines.value.length) return
const step = Math.min(props.step, redoLines.value.length)
const restoredLines = redoLines.value.splice(redoLines.value.length - step)
lines.value.push(...restoredLines)
currentStep.value = Math.min(lines.value.length, currentStep.value + step)
redrawCanvas()
}
// 线
function drawSmoothLine(prePoint: Point, point: Point) {
const { ctx } = canvasState
if (!ctx) return
//
const dis_x = point.x - prePoint.x
const dis_y = point.y - prePoint.y
const distance = Math.sqrt(dis_x * dis_x + dis_y * dis_y)
if (distance <= 2) {
// 使
point.lastX1 = point.lastX2 = prePoint.x + dis_x * 0.5
point.lastY1 = point.lastY2 = prePoint.y + dis_y * 0.5
} else {
//
const speed = point.speed || 0
const minSpeed = props.minSpeed || 1.5
const speedFactor = Math.max(0.1, Math.min(0.9, speed / (minSpeed * 10)))
//
point.lastX1 = prePoint.x + dis_x * (0.2 + speedFactor * 0.3)
point.lastY1 = prePoint.y + dis_y * (0.2 + speedFactor * 0.3)
point.lastX2 = prePoint.x + dis_x * (0.8 - speedFactor * 0.3)
point.lastY2 = prePoint.y + dis_y * (0.8 - speedFactor * 0.3)
}
// 线
const lineWidth = point.lineWidth || props.lineWidth
// 线
if (typeof prePoint.lastX1 === 'number') {
// 线
ctx.setLineWidth(lineWidth)
// 线
ctx.beginPath()
ctx.moveTo(prePoint.lastX2!, prePoint.lastY2!)
ctx.quadraticCurveTo(prePoint.x, prePoint.y, point.lastX1, point.lastY1)
ctx.stroke()
if (!prePoint.isFirstPoint) {
// 线
ctx.beginPath()
ctx.moveTo(prePoint.lastX1!, prePoint.lastY1!)
ctx.quadraticCurveTo(prePoint.x, prePoint.y, prePoint.lastX2!, prePoint.lastY2!)
ctx.stroke()
}
//
ctx.draw(true)
} else {
point.isFirstPoint = true
} }
} }
@ -245,7 +490,7 @@ function getContext() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
getRect(`#${canvasId.value}`, false, proxy, true).then((canvasRect: any) => { getRect(`#${canvasId.value}`, false, proxy, true).then((canvasRect: any) => {
if (canvasRect && canvasRect.node) { if (canvasRect && canvasRect.node && canvasRect.width && canvasRect.height) {
const canvasInstance = canvasRect.node const canvasInstance = canvasRect.node
canvasState.ctx = canvas2dAdapter(canvasInstance.getContext('2d') as CanvasRenderingContext2D) canvasState.ctx = canvas2dAdapter(canvasInstance.getContext('2d') as CanvasRenderingContext2D)
canvasInstance.width = canvasRect.width * pixelRatio.value canvasInstance.width = canvasRect.width * pixelRatio.value
@ -272,7 +517,7 @@ function setcanvasState(width: number, height: number) {
function setLine() { function setLine() {
const { ctx } = canvasState const { ctx } = canvasState
if (ctx) { if (ctx) {
ctx.setLineWidth(props.lineWidth) ctx.setLineWidth(getDefaultLineWidth()) // 使
ctx.setStrokeStyle(props.penColor) ctx.setStrokeStyle(props.penColor)
ctx.setLineJoin('round') ctx.setLineJoin('round')
ctx.setLineCap('round') ctx.setLineCap('round')
@ -320,80 +565,6 @@ function canvasToImage() {
proxy proxy
) )
} }
/* canvas获取每一步的图片 */
function getCanvasImageData(): Promise<ImageData> {
const { canvasWidth, canvasHeight, ctx } = canvasState
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
try {
//
if (ctx) {
const imageData = (ctx as unknown as CanvasRenderingContext2D).getImageData(0, 0, canvasWidth, canvasHeight)
resolve(imageData)
}
} catch (error) {
console.error('获取 canvas 像素数据失败:', error)
reject(error)
}
// #endif
// #ifndef MP-WEIXIN
uni.canvasGetImageData({
canvasId: canvasId.value,
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
success: (res: any) => {
resolve(res)
},
fail: (err) => {
console.error('获取 canvas 像素数据失败:')
reject(err)
}
})
// #endif
})
}
function putCanvasImageData(step: number = 1) {
const { canvasWidth, canvasHeight, ctx } = canvasState
const imagedata = historyList.value[currentStep.value - step]
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
try {
if (ctx) {
;(ctx as unknown as CanvasRenderingContext2D).putImageData(imagedata, 0, 0)
resolve(true)
}
} catch (error) {
console.error('获取 canvas 像素数据失败:', error)
reject(error)
}
// #endif
// #ifndef MP-WEIXIN
uni.canvasPutImageData({
canvasId: canvasId.value,
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
data: imagedata.data,
success: (res) => {
resolve(res)
},
fail: (err) => {
console.error('获取 canvas 像素数据失败:', err)
reject(err)
}
})
// #endif
})
}
function clearCanvas() { function clearCanvas() {
const { canvasWidth, canvasHeight, ctx } = canvasState const { canvasWidth, canvasHeight, ctx } = canvasState
@ -406,37 +577,9 @@ function clearCanvas() {
ctx.draw() ctx.draw()
} }
} }
/* pushHistoryList */
function pushHistoryList() {
return new Promise((resolve, reject) => {
console.log(history.value)
if (history.value) {
getCanvasImageData()
.then((imageData) => {
historyList.value.push(imageData)
currentStep.value++
//
if (historyList.value.length > maxHistoryLength.value) {
historyList.value.shift()
currentStep.value--
}
resolve(true)
})
.catch((err) => {
reject(err)
})
}
})
}
function clearHistoryList() {
if (history.value) {
historyList.value = []
currentStep.value = 0
}
}
defineExpose<SignatureExpose>({ defineExpose<SignatureExpose>({
init: initCanvas,
clear, clear,
confirm: confirmSignature, confirm: confirmSignature,
restore, restore,