feat(signature): add Signature component (#11733)

* feat(signature): add signature component

* feat(signature): fix md naming error

* feat(signature): refactor and adjust code
This commit is contained in:
李江辰 2023-05-01 11:30:52 +08:00 committed by GitHub
parent dc1531084b
commit f2b1b3156e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 639 additions and 0 deletions

View File

@ -0,0 +1,105 @@
# Signature
### Intro
Component for signature scene, based on Canvas.
### Install
Register component globally via `app.use`, refer to [Component Registration](#/en-US/advanced-usage#zu-jian-zhu-ce) for more registration ways.
```js
import { createApp } from 'vue';
import { Space } from 'vant';
const app = createApp();
app.use(Space);
```
## Usage
### Basic Usage
```html
<van-signature @submit="onSubmit" @clear="onClear" />
<van-image v-if="demoUrl" :src="demoUrl" />
```
```js
import { ref } from 'vue';
export default {
setup() {
const demoUrl = ref('');
const onSubmit = (data) => {
const { filePath, canvas } = data;
demoUrl.value = filePath;
console.log('submit', canvas, filePath);
};
const onClear = () => console.log('clear');
return {
onSubmit,
onClear,
demoUrl,
};
},
};
```
### Pen Color
```html
<van-signature pen-color="#ff0000" @submit="onSubmit" @clear="onClear" />
```
### LineWidth
```html
<van-signature :line-width="6" @submit="onSubmit" @clear="onClear" />
```
## API
### Props
| Parameter | Description | Type | Default |
| --- | --- | --- | --- |
| type | Export image type | _string_ | `png` |
| penColor | Color of the brush stroke, default is black | _string_ | `#000` |
| lineWidth | Width of the line | _number_ | `3` |
| tips | Text that appears when Canvas is not supported | _string_ | - |
### Events
| Event Name | Description | Callback Parameters |
| --- | --- | --- |
| start | Callback for start of signature | - |
| end | Callback for end of signature | - |
| signing | Callback for signature in progress | _event: TouchEvent_ |
| submit | submit button click | _data: {canvas: HTMLCanvasElement, filePath: string}_ |
| clear | clear button click | - |
### Types
The component exports the following type definitions:
```js
import type { SignatureProps } from 'vant';
```
## Theming
### CSS Variables
The component provides the following CSS variables, which can be used to customize styles. Please refer to [ConfigProvider component](#/en-US/config-provider).
| Name | Default Value | Description |
| --- | --- | --- |
| --van-signature-padding | _var(--van-padding-xs)_ | - |
| --van-signature-content-height | _160px_ | Height of the canvas |
| --van-signature-content-background | _var(--van-background-2)_ | Background color of the canvas |
| --van-signature-content-border | _1px dotted #dadada_ | Border style of the canvas |

View File

@ -0,0 +1,105 @@
# Signature 签名
### 介绍
用于签名场景的组件,基于 Canvas。
### 引入
通过以下方式来全局注册组件,更多注册方式请参考[组件注册](#/zh-CN/advanced-usage#zu-jian-zhu-ce)。
```js
import { createApp } from 'vue';
import { Signature } from 'vant';
const app = createApp();
app.use(Signature);
```
## 代码演示
### 基础用法
```html
<van-signature @submit="onSubmit" @clear="onClear" />
<van-image v-if="demoUrl" :src="demoUrl" />
```
```js
import { ref } from 'vue';
export default {
setup() {
const demoUrl = ref('');
const onSubmit = (data) => {
const { filePath, canvas } = data;
demoUrl.value = filePath;
console.log('submit', canvas, filePath);
};
const onClear = () => console.log('clear');
return {
onSubmit,
onClear,
demoUrl,
};
},
};
```
### 自定义颜色
```html
<van-signature pen-color="#ff0000" @submit="onSubmit" @clear="onClear" />
```
### 自定义线宽
```html
<van-signature :line-width="6" @submit="onSubmit" @clear="onClear" />
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| --------- | ------------------------------------ | -------- | ------ |
| type | 导出图片类型 | _string_ | `png` |
| penColor | 笔触颜色,默认黑色。 | _string_ | `#000` |
| lineWidth | 线条宽度 | _number_ | `3` |
| tips | 当不支持 Canvas 的时候出现的提示文案 | _string_ | - |
### Events
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| start | 签名开始事件回调 | - |
| end | 签名结束事件回调 | - |
| signing | 签名过程事件回调 | _event: TouchEvent_ |
| submit | 确定按钮事件回调 | _data: {canvas: HTMLCanvasElement, filePath: string}_ |
| clear | 取消按钮事件回调 | - |
### 类型定义
组件导出以下类型定义:
```js
import type { SignatureProps } from 'vant';
```
## 主题定制
### 样式变量
组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/config-provider)。
| 名称 | 默认值 | 描述 |
| --- | --- | --- |
| --van-signature-padding | _var(--van-padding-xs)_ | - |
| --van-signature-content-height | _160px_ | 画布高度 |
| --van-signature-content-background | _var(--van-background-2)_ | 画布背景色 |
| --van-signature-content-border | _1px dotted #dadada_ | 画布边框样式 |

View File

@ -0,0 +1,153 @@
import {
defineComponent,
reactive,
ref,
onMounted,
type ExtractPropTypes,
} from 'vue';
import { createNamespace, makeNumberProp, makeStringProp } from '../utils';
import { Button } from '../button';
const [name, bem, t] = createNamespace('signature');
export const signatureProps = {
type: makeStringProp('png'),
lineWidth: makeNumberProp(3),
penColor: makeStringProp('#000'),
tips: String,
};
export type SignatureProps = ExtractPropTypes<typeof signatureProps>;
export default defineComponent({
name,
props: signatureProps,
emits: ['submit', 'clear', 'start', 'end', 'signing'],
setup(props, { emit }) {
const canvasRef = ref<HTMLCanvasElement | null>(null);
const wrapRef = ref<HTMLElement | null>(null);
const state = reactive({
width: 0,
height: 0,
ctx: null as any,
isSupportTouch: 'ontouchstart' in window,
});
const hasCanvasSupport = () => {
const canvas = document.createElement('canvas');
return !!(canvas.getContext && canvas.getContext('2d'));
};
const touchMove = (event: TouchEvent) => {
if (!state.ctx) {
return false;
}
const evt = event.changedTouches
? event.changedTouches[0]
: event.targetTouches[0];
emit('signing', evt);
let mouseX = evt.clientX;
let mouseY = evt.clientY;
if (!state.isSupportTouch) {
const coverPos = canvasRef.value?.getBoundingClientRect();
mouseX = evt.clientX - (coverPos?.left || 0);
mouseY = evt.clientY - (coverPos?.top || 0);
}
state.ctx.lineCap = 'round';
state.ctx.lineJoin = 'round';
state.ctx?.lineTo(mouseX, mouseY);
state.ctx?.stroke();
};
const touchEnd = (event: { preventDefault: () => void }) => {
event.preventDefault();
emit('end');
};
const touchStart = () => {
if (!state.ctx) {
return false;
}
emit('start');
state.ctx.beginPath();
state.ctx.lineWidth = props.lineWidth;
state.ctx.strokeStyle = props.penColor;
};
const isCanvasEmpty = (canvas: HTMLCanvasElement) => {
const empty: HTMLCanvasElement = document.createElement('canvas');
empty.width = canvas.width;
empty.height = canvas.height;
return canvas.toDataURL() === empty.toDataURL();
};
const submit = () => {
const canvas = canvasRef.value;
if (!canvas) {
return;
}
const isEmpty = isCanvasEmpty(canvas);
const _canvas = isEmpty ? null : canvas;
const _filePath = isEmpty
? ''
: canvas.toDataURL(
`image/${props.type}`,
props.type === 'jpg' ? 0.9 : null
);
const data = {
canvas: _canvas,
filePath: _filePath,
};
emit('submit', data);
};
const clear = () => {
state.ctx.clearRect(0, 0, state.width, state.height);
state.ctx.closePath();
emit('clear');
};
onMounted(() => {
if (hasCanvasSupport()) {
state.ctx = canvasRef.value?.getContext('2d');
state.width = wrapRef.value?.offsetWidth || 0;
state.height = wrapRef.value?.offsetHeight || 0;
}
});
return () => (
<div class={bem()}>
<div class={bem('content')} ref={wrapRef}>
{(hasCanvasSupport() && (
<canvas
ref={canvasRef}
width={state.width}
height={state.height}
onTouchstartPassive={touchStart}
onTouchmovePassive={touchMove}
onTouchend={touchEnd}
/>
)) || <p>{props.tips}</p>}
</div>
<div class={bem('footer')}>
<p>{props.tips}</p>
<Button size="small" onClick={clear}>
{t('cancel')}
</Button>
<Button type="primary" size="small" onClick={submit}>
{t('confirm')}
</Button>
</div>
</div>
);
},
});

View File

@ -0,0 +1,67 @@
<script setup>
import { ref } from 'vue';
import VanSignature from '..';
import VanImage from '../../image';
import { useTranslate } from '../../../docs/site';
const t = useTranslate({
'zh-CN': {
basic: '基础用法',
penColor: '自定义颜色',
lineWidth: '自定义线宽',
},
'en-US': {
basic: 'basic',
penColor: 'penColor',
lineWidth: 'lineWidth',
},
});
const demoUrl = ref('');
const onSubmit = (data) => {
const { filePath, canvas } = data;
demoUrl.value = filePath;
console.log('submit', canvas, filePath);
};
const onStart = () => console.log('start');
const onClear = () => console.log('clear');
const onEnd = () => console.log('end');
const onSigning = (e) => console.log('signing', e);
</script>
<template>
<demo-block :title="t('basic')">
<van-signature
@submit="onSubmit"
@clear="onClear"
@start="onStart"
@end="onEnd"
@signing="onSigning"
/>
</demo-block>
<van-image v-if="demoUrl" :src="demoUrl" />
<demo-block :title="t('penColor')">
<van-signature
pen-color="#ff0000"
@clear="onClear"
@submit="onSubmit"
@start="onStart"
@end="onEnd"
@signing="onSigning"
/>
</demo-block>
<demo-block :title="t('lineWidth')">
<van-signature
:line-width="6"
@clear="onClear"
@submit="onSubmit"
@start="onStart"
@end="onEnd"
@signing="onSigning"
/>
</demo-block>
</template>

View File

@ -0,0 +1,27 @@
:root {
--van-signature-padding: var(--van-padding-xs);
--van-signature-content-height: 160px;
--van-signature-content-background: var(--van-background-2);
--van-signature-content-border: 1px dotted #dadada;
}
.van-signature {
padding: var(--van-signature-padding);
&__content {
display: flex;
justify-content: center;
align-items: center;
height: var(--van-signature-content-height);
background-color: var(--van-signature-content-background);
border: var(--van-signature-content-border);
}
&__footer {
display: flex;
justify-content: flex-end;
.van-button {
margin: 4px;
}
}
}

View File

@ -0,0 +1,12 @@
import { withInstall } from '../utils';
import _Signature from './Signature';
export const Signature = withInstall(_Signature);
export default Signature;
export type { SignatureProps } from './Signature';
declare module 'vue' {
export interface GlobalComponents {
Signature: typeof Signature;
}
}

View File

@ -0,0 +1,100 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render demo and match snapshot 1`] = `
<div>
<div class="van-signature">
<div class="van-signature__content">
<canvas width="100"
height="100"
>
</canvas>
</div>
<div class="van-signature__footer">
<p>
</p>
<button type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Cancel
</span>
</div>
</button>
<button type="button"
class="van-button van-button--primary van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Confirm
</span>
</div>
</button>
</div>
</div>
</div>
<div>
<div class="van-signature">
<div class="van-signature__content">
<canvas width="100"
height="100"
>
</canvas>
</div>
<div class="van-signature__footer">
<p>
</p>
<button type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Cancel
</span>
</div>
</button>
<button type="button"
class="van-button van-button--primary van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Confirm
</span>
</div>
</button>
</div>
</div>
</div>
<div>
<div class="van-signature">
<div class="van-signature__content">
<canvas width="100"
height="100"
>
</canvas>
</div>
<div class="van-signature__footer">
<p>
</p>
<button type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Cancel
</span>
</div>
</button>
<button type="button"
class="van-button van-button--primary van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Confirm
</span>
</div>
</button>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,4 @@
import Demo from '../demo/index.vue';
import { snapshotDemo } from '../../../test/demo';
snapshotDemo(Demo);

View File

@ -0,0 +1,58 @@
import { Signature } from '..';
import { mount } from '../../../test';
test('renders a canvas element when canvas is supported', async () => {
const wrapper = mount(Signature);
expect(wrapper.find('canvas').exists()).toBe(true);
});
it('should emit "start" event when touch starts', async () => {
const wrapper = mount(Signature);
const canvas = wrapper.find('canvas');
await canvas.trigger('touchstart');
expect(wrapper.emitted('start')).toBeTruthy();
});
test('should emit "signing" event when touch is moving', async () => {
const wrapper = mount(Signature);
const canvas = wrapper.find('canvas');
await canvas.trigger('touchstart');
await canvas.trigger('touchmove', {
changedTouches: [{ clientX: 10, clientY: 20 }],
});
expect(wrapper.emitted('signing')).toBeTruthy();
expect(wrapper.emitted('signing')![0][0]).toMatchObject({
clientX: 10,
clientY: 20,
});
});
test('should emit `end` event when touchend is triggered', async () => {
const wrapper = mount(Signature);
await wrapper.vm.$nextTick();
const canvas = wrapper.find('canvas');
await canvas.trigger('touchend');
expect(wrapper.emitted('end')).toBeTruthy();
});
test('submit() should output a valid canvas', async () => {
const wrapper = mount(Signature);
await wrapper.vm.$nextTick();
wrapper.vm.$emit('submit', { canvas: null, filePath: '' });
const emitted = wrapper.emitted();
expect(emitted.submit).toBeTruthy();
const [data] = emitted.submit[0] as [
{ canvas: HTMLCanvasElement | null; filePath: string }
];
expect(data.canvas).toBeNull();
expect(data.filePath).toBe('');
});

View File

@ -434,6 +434,10 @@ location.href = location.href.replace('youzan.github.io', 'vant-ui.github.io');
path: 'submit-bar',
title: 'SubmitBar 提交订单栏',
},
{
path: 'signature',
title: 'Signature 签名',
},
],
},
{
@ -878,6 +882,10 @@ location.href = location.href.replace('youzan.github.io', 'vant-ui.github.io');
path: 'submit-bar',
title: 'SubmitBar',
},
{
path: 'signature',
title: 'Signature',
},
],
},
{