Repository: varletjs/varlet
Pull request #1863: add component signature
Author: login: william-xue
Created: 2025-03-04T15:46:04Z | Merged: 2025-03-20T14:56:04Z

## Checklist

List of tasks you have already done and plan to do.

- [x] Fix linting errors
- [x] Tests have been added / updated (or snapshots)

## Change information

Describe your modifications here.

## Issues

The issues you want to close, formatted as close #1.

## Related Links

Links related to this pr.



Diff:
diff --git a/packages/varlet-ui/src/signature/Signature.vue b/packages/varlet-ui/src/signature/Signature.vue
new file mode 100644
index 00000000000..3087c24b949
--- /dev/null
+++ b/packages/varlet-ui/src/signature/Signature.vue
@@ -0,0 +1,148 @@
+<template>
+  <div ref="root" :class="n()">
+    <canvas ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref } from 'vue'
+import { call, getRect, getStyle } from '@varlet/shared'
+import { onWindowResize, useEventListener } from '@varlet/use'
+import { createNamespace } from '../utils/components'
+import { props } from './props'
+
+const { name, n } = createNamespace('signature')
+
+export default defineComponent({
+  name,
+  props,
+  setup(props) {
+    const root = ref<HTMLElement>()
+    const canvas = ref<HTMLCanvasElement>()
+    const canvasWidth = ref(0)
+    const canvasHeight = ref(0)
+    let isSigning = false
+    let ctx: CanvasRenderingContext2D | null = null
+
+    useEventListener(canvas, 'touchstart', handleTouchstart)
+    useEventListener(canvas, 'touchmove', handleTouchmove)
+    useEventListener(canvas, 'touchend', handleTouchend)
+
+    onWindowResize(resize)
+    onMounted(resize)
+
+    function resize() {
+      if (!canvas.value || !root.value) {
+        return
+      }
+
+      ctx = canvas.value.getContext('2d')
+      canvasWidth.value = root.value.offsetWidth
+      canvasHeight.value = root.value.offsetHeight
+    }
+
+    function handleTouchstart(event: TouchEvent) {
+      if (!ctx || !root.value) {
+        return
+      }
+
+      event.preventDefault()
+      isSigning = true
+
+      ctx.beginPath()
+      ctx.lineWidth = props.lineWidth
+      ctx.strokeStyle = props.strokeStyle === 'currentColor' ? getStyle(root.value).color : props.strokeStyle
+
+      call(props.onStart)
+    }
+
+    function handleTouchmove(event: TouchEvent) {
+      event.preventDefault()
+      if (!canvas.value || !ctx || !isSigning) {
+        return
+      }
+
+      const clientX = event.touches[0].clientX
+      const clientY = event.touches[0].clientY
+      const rect = getRect(canvas.value)
+      const x = clientX - rect.left
+      const y = clientY - rect.top
+
+      ctx.lineTo(x, y)
+      ctx.stroke()
+
+      call(props.onSigning)
+    }
+
+    function handleTouchend(event: Event) {
+      if (!isSigning) {
+        return
+      }
+
+      event.preventDefault()
+      isSigning = false
+      call(props.onEnd)
+    }
+
+    function getDataUrl(canvas: HTMLCanvasElement) {
+      switch (props.dataUrlType) {
+        case 'jpg':
+          return canvas.toDataURL('image/jpeg')
+        default:
+          return canvas.toDataURL('image/png')
+      }
+    }
+
+    function isCanvasEmpty(canvas: HTMLCanvasElement) {
+      if (!canvas) {
+        return true
+      }
+
+      const emptyCanvas = document.createElement('canvas')
+      emptyCanvas.width = canvas.width
+      emptyCanvas.height = canvas.height
+
+      return getDataUrl(canvas) === getDataUrl(emptyCanvas)
+    }
+
+    // expose
+    function reset() {
+      if (!ctx) {
+        return
+      }
+
+      isSigning = false
+      ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
+      ctx.closePath()
+    }
+
+    // expose
+    function confirm() {
+      if (!canvas.value) {
+        return
+      }
+
+      if (isCanvasEmpty(canvas.value)) {
+        return ''
+      }
+
+      return getDataUrl(canvas.value)
+    }
+
+    return {
+      root,
+      canvas,
+      canvasWidth,
+      canvasHeight,
+      n,
+      confirm,
+      reset,
+    }
+  },
+})
+</script>
+
+<style lang="less">
+@import '../styles/common';
+@import './signature';
+</style>
diff --git a/packages/varlet-ui/src/signature/__tests__/index.spec.js b/packages/varlet-ui/src/signature/__tests__/index.spec.js
new file mode 100644
index 00000000000..0abd5af3609
--- /dev/null
+++ b/packages/varlet-ui/src/signature/__tests__/index.spec.js
@@ -0,0 +1,83 @@
+import { createApp } from 'vue'
+import { mount } from '@vue/test-utils'
+import { beforeAll, expect, test, vi } from 'vitest'
+import Signature from '..'
+import { trigger } from '../../utils/test'
+import VarSignature from '../Signature.vue'
+
+beforeAll(() => {
+  HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
+    clearRect: vi.fn(),
+    closePath: vi.fn(),
+    beginPath: vi.fn(),
+    lineTo: vi.fn(),
+    stroke: vi.fn(),
+  }))
+
+  HTMLCanvasElement.prototype.toDataURL = vi.fn((type) => {
+    return type === 'image/jpeg' ? 'data:image/jpeg;base64,test' : 'data:image/png;base64,test'
+  })
+})
+
+test('signature plugin', () => {
+  const app = createApp({}).use(Signature)
+  expect(app.component(Signature.name)).toBeTruthy()
+})
+
+test('signature events', async () => {
+  const onStart = vi.fn()
+  const onEnd = vi.fn()
+  const onSigning = vi.fn()
+
+  const wrapper = mount(VarSignature, {
+    props: {
+      onStart,
+      onEnd,
+      onSigning,
+    },
+  })
+
+  const canvas = wrapper.find('canvas')
+
+  await trigger(canvas, 'touchmove')
+  expect(onSigning).not.toHaveBeenCalled()
+
+  await trigger(canvas, 'touchend')
+  expect(onEnd).not.toHaveBeenCalled()
+
+  await trigger(canvas, 'touchstart')
+  expect(onStart).toHaveBeenCalled()
+
+  await trigger(canvas, 'touchmove')
+  expect(onSigning).toHaveBeenCalled()
+
+  await trigger(canvas, 'touchend')
+  expect(onEnd).toHaveBeenCalled()
+
+  wrapper.unmount()
+})
+
+test('signature methods', async () => {
+  const wrapper = mount(VarSignature, {
+    props: {
+      dataUrlType: 'png',
+    },
+  })
+
+  HTMLCanvasElement.prototype.toDataURL = vi.fn((type) => {
+    expect(type).toBe('image/png')
+    return type === 'image/jpeg' ? 'data:image/jpeg;base64,test' : 'data:image/png;base64,test'
+  })
+
+  expect(wrapper.vm.confirm()).toBe('')
+  wrapper.vm.reset()
+
+  await wrapper.setProps({ dataUrlType: 'jpg' })
+
+  HTMLCanvasElement.prototype.toDataURL = vi.fn((type) => {
+    expect(type).toBe('image/jpeg')
+    return type === 'image/jpeg' ? 'data:image/jpeg;base64,test' : 'data:image/png;base64,test'
+  })
+
+  expect(wrapper.vm.confirm()).toBe('')
+})
diff --git a/packages/varlet-ui/src/signature/docs/en-US.md b/packages/varlet-ui/src/signature/docs/en-US.md
new file mode 100644
index 00000000000..bc20d1ebba0
--- /dev/null
+++ b/packages/varlet-ui/src/signature/docs/en-US.md
@@ -0,0 +1,103 @@
+# Signature
+
+### Intro
+
+Signature component based on `Canvas`.
+
+### Basic Usage
+
+Get the data url of the canvas through the component method `confirm`. When the canvas is empty, you will get the `empty string`.
+
+```html
+<script setup>
+import { ref } from 'vue'
+
+const signature = ref('')
+const signatureRef = ref()
+
+function confirmSignature() {
+  signature.value = signatureRef.value.confirm()
+}
+
+function resetSignature() {
+  signature.value = ''
+  signatureRef.value.reset()
+}
+</script>
+
+<template>
+  <var-signature ref="signatureRef" />
+  <var-space style="margin-top: 10px">
+    <var-button type="primary" @click="confirmSignature">确认</var-button>
+    <var-button type="primary" @click="resetSignature">重置</var-button>
+  </var-space>
+  <img v-if="signature" :src="signature" style="margin-top: 10px" />
+</template>
+```
+
+### Custom Style
+
+You can customize the signature style through `stroke-style` and `line-width`. The default `stroke-style` is `currentColor`, which will inherit the text color, so you can also set the color through `css color`.
+
+```html
+<script setup>
+import { ref } from 'vue'
+
+const signature = ref('')
+const signatureRef = ref()
+
+function confirmSignature() {
+  signature.value = signatureRef.value.confirm()
+}
+
+function resetSignature() {
+  signature.value = ''
+  signatureRef.value.reset()
+}
+</script>
+
+<template>
+  <var-signature ref="signatureRef" style="color: var(--color-primary)" :line-width="4" />
+  <var-space style="margin-top: 10px">
+    <var-button type="primary" @click="confirmSignature">确认</var-button>
+    <var-button type="primary" @click="resetSignature">重置</var-button>
+  </var-space>
+  <img v-if="signature" :src="signature" style="margin-top: 10px" />
+</template>
+```
+
+## API
+
+### Props
+
+| Prop | Description  | Type | Default |
+| --- | --- | --- | --- |
+| `stroke-style` | Canvas stroke style | _string_ | `currentColor` |
+| `line-width` | Canvas line width | _string \| number_ | `2` |
+| `data-url-type` | Data url type for generating image | _'png' \| 'jpg'_ | `'png'` |
+
+### Events
+
+| Event | Description | Arguments |
+| --- | --- | --- |
+| `start` | Triggered when signing begins | `-` |
+| `signing` | Triggered during signature | `-` |
+| `end` | Triggered when ending signature | `-` |
+
+### Methods
+
+| Method | Description | Arguments | Return |
+| --- | --- | --- | --- |
+| `reset` | Reset canvas | `-` | `-` |
+| `confirm` | Confirm the signature and return image data in data url format.Returns an empty string when the canvas is empty | `-` | `string` |
+
+### Style Variables
+
+Here are the CSS variables used by the component. Styles can be customized using [StyleProvider](#/en-US/style-provider).
+
+| Variable | Default |
+| --- | --- |
+| `--signature-height` | `200px` |
+| `--signature-background-color` | `var(--color-surface-container-highest)` |
+| `--signature-stroke-color` | `#333` |
+| `--signature-border-radius` | `4px` |
\ No newline at end of file
diff --git a/packages/varlet-ui/src/signature/docs/zh-CN.md b/packages/varlet-ui/src/signature/docs/zh-CN.md
new file mode 100644
index 00000000000..8a88fcfa9fc
--- /dev/null
+++ b/packages/varlet-ui/src/signature/docs/zh-CN.md
@@ -0,0 +1,103 @@
+# 签名
+
+### 介绍
+
+基于 `Canvas` 的签名组件。
+
+### 基本使用
+
+通过组件方法 `confirm` 获取画布的 `data url`，画布为空时会获取到 `空字符串`。
+
+```html
+<script setup>
+import { ref } from 'vue'
+
+const signature = ref('')
+const signatureRef = ref()
+
+function confirmSignature() {
+  signature.value = signatureRef.value.confirm()
+}
+
+function resetSignature() {
+  signature.value = ''
+  signatureRef.value.reset()
+}
+</script>
+
+<template>
+  <var-signature ref="signatureRef" />
+  <var-space style="margin-top: 10px">
+    <var-button type="primary" @click="confirmSignature">确认</var-button>
+    <var-button type="primary" @click="resetSignature">重置</var-button>
+  </var-space>
+  <img v-if="signature" :src="signature" style="margin-top: 10px" />
+</template>
+```
+
+### 自定义样式
+
+可以通过 `stroke-style`、`line-width` 自定义签名的样式，`stroke-style` 默认是 `currentColor`，会继承文字颜色，所以也可以通过 `css color` 来设置颜色。 
+
+```html
+<script setup>
+import { ref } from 'vue'
+
+const signature = ref('')
+const signatureRef = ref()
+
+function confirmSignature() {
+  signature.value = signatureRef.value.confirm()
+}
+
+function resetSignature() {
+  signature.value = ''
+  signatureRef.value.reset()
+}
+</script>
+
+<template>
+  <var-signature ref="signatureRef" style="color: var(--color-primary)" :line-width="4" />
+  <var-space style="margin-top: 10px">
+    <var-button type="primary" @click="confirmSignature">确认</var-button>
+    <var-button type="primary" @click="resetSignature">重置</var-button>
+  </var-space>
+  <img v-if="signature" :src="signature" style="margin-top: 10px" />
+</template>
+```
+
+## API
+
+### 属性
+
+| 参数 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| `stroke-style` | 画笔颜色 | _string_ | `currentColor` |
+| `line-width` | 画笔粗细 | _string \| number_ | `2` |
+| `data-url-type` | 生成图片的格式 | _'png' \| 'jpg'_ | `'png'` |
+
+### 事件
+
+| 事件名 | 说明 | 回调参数 |
+| --- | --- | --- |
+| `start` | 开始签名时触发 | `-` |
+| `signing` | 签名过程中触发 | `-` |
+| `end` | 结束签名时触发 | `-` |
+
+### 方法
+
+| 方法名 | 说明 | 参数 | 返回值 |
+| --- | --- | --- | --- |
+| `reset` | 重置签名 | `-` | `-` |
+| `confirm` | 确认签名，返回 data url 格式的图片数据。画布为空时返回空字符串 | `-` | `string` |
+
+### 样式变量
+
+以下为组件使用的 css 变量，可以使用 [StyleProvider 组件](#/zh-CN/style-provider) 进行样式定制。
+
+| 变量名 | 默认值 |
+| --- | --- |
+| `--signature-height` | `200px` |
+| `--signature-background-color` | `var(--color-surface-container-highest)` |
+| `--signature-stroke-color` | `#333` |
+| `--signature-border-radius` | `4px` |
\ No newline at end of file
diff --git a/packages/varlet-ui/src/signature/example/index.vue b/packages/varlet-ui/src/signature/example/index.vue
new file mode 100644
index 00000000000..5b211d613d6
--- /dev/null
+++ b/packages/varlet-ui/src/signature/example/index.vue
@@ -0,0 +1,61 @@
+<script setup>
+import { ref } from 'vue'
+import { AppType, onThemeChange, watchLang } from '@varlet/cli/client'
+import { t, use } from './locale'
+
+const signature = ref('')
+const signatureRef = ref()
+
+const customStyleSignature = ref('')
+const customStyleSignatureRef = ref()
+
+watchLang(use)
+onThemeChange(() => {
+  signatureRef.value?.reset()
+  customStyleSignatureRef.value?.reset()
+  signature.value = ''
+  customStyleSignature.value = ''
+})
+
+function confirmSignature() {
+  signature.value = signatureRef.value.confirm()
+}
+
+function resetSignature() {
+  signature.value = ''
+  signatureRef.value.reset()
+}
+
+function confirmCustomStyleSignature() {
+  customStyleSignature.value = customStyleSignatureRef.value.confirm()
+}
+
+function clearCustomStyleSignature() {
+  customStyleSignature.value = ''
+  customStyleSignatureRef.value.reset()
+}
+</script>
+
+<template>
+  <app-type>{{ t('basicUsage') }}</app-type>
+  <var-signature ref="signatureRef" />
+  <var-space class="gap">
+    <var-button type="primary" @click="confirmSignature">{{ t('confirm') }}</var-button>
+    <var-button type="primary" @click="resetSignature">{{ t('reset') }}</var-button>
+  </var-space>
+  <img v-if="signature" class="gap" :src="signature" />
+
+  <app-type>{{ t('customStyle') }}</app-type>
+  <var-signature ref="customStyleSignatureRef" style="color: var(--color-primary)" :line-width="4" />
+  <var-space class="gap">
+    <var-button type="primary" @click="confirmCustomStyleSignature">{{ t('confirm') }}</var-button>
+    <var-button type="primary" @click="clearCustomStyleSignature">{{ t('reset') }}</var-button>
+  </var-space>
+  <img v-if="customStyleSignature" :src="customStyleSignature" class="gap" />
+</template>
+
+<style scoped>
+.gap {
+  margin-top: 10px !important;
+}
+</style>
diff --git a/packages/varlet-ui/src/signature/example/locale/en-US.ts b/packages/varlet-ui/src/signature/example/locale/en-US.ts
new file mode 100644
index 00000000000..bb8bf63962a
--- /dev/null
+++ b/packages/varlet-ui/src/signature/example/locale/en-US.ts
@@ -0,0 +1,6 @@
+export default {
+  basicUsage: 'Basic Usage',
+  customStyle: 'Custom Style',
+  confirm: 'Confirm',
+  reset: 'Reset',
+}
diff --git a/packages/varlet-ui/src/signature/example/locale/index.ts b/packages/varlet-ui/src/signature/example/locale/index.ts
new file mode 100644
index 00000000000..e031be8c1a4
--- /dev/null
+++ b/packages/varlet-ui/src/signature/example/locale/index.ts
@@ -0,0 +1,17 @@
+import { Locale } from '@varlet/ui'
+import enUS from './en-US'
+import zhCN from './zh-CN'
+
+const { add, use: exampleUse, t, merge } = Locale.useLocale()
+
+const use = (lang: string) => {
+  Locale.use(lang)
+  exampleUse(lang)
+}
+
+Locale.add('zh-CN', Locale.zhCN)
+Locale.add('en-US', Locale.enUS)
+add('zh-CN', zhCN)
+add('en-US', enUS)
+
+export { add, t, merge, use }
diff --git a/packages/varlet-ui/src/signature/example/locale/zh-CN.ts b/packages/varlet-ui/src/signature/example/locale/zh-CN.ts
new file mode 100644
index 00000000000..6c49e847037
--- /dev/null
+++ b/packages/varlet-ui/src/signature/example/locale/zh-CN.ts
@@ -0,0 +1,6 @@
+export default {
+  basicUsage: '基本使用',
+  customStyle: '自定义样式',
+  confirm: '确认',
+  reset: '重置',
+}
diff --git a/packages/varlet-ui/src/signature/index.ts b/packages/varlet-ui/src/signature/index.ts
new file mode 100644
index 00000000000..8c0eb706deb
--- /dev/null
+++ b/packages/varlet-ui/src/signature/index.ts
@@ -0,0 +1,12 @@
+import { withInstall, withPropsDefaultsSetter } from '../utils/components'
+import { props as signatureProps } from './props'
+import Signature from './Signature.vue'
+
+withInstall(Signature)
+withPropsDefaultsSetter(Signature, signatureProps)
+
+export { signatureProps }
+
+export const _SignatureComponent = Signature
+
+export default Signature
diff --git a/packages/varlet-ui/src/signature/props.ts b/packages/varlet-ui/src/signature/props.ts
new file mode 100644
index 00000000000..03396818873
--- /dev/null
+++ b/packages/varlet-ui/src/signature/props.ts
@@ -0,0 +1,22 @@
+import { type PropType } from 'vue'
+import { defineListenerProp } from '../utils/components'
+
+export type SignatureDataUrlType = 'png' | 'jpg'
+
+export const props = {
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: 'currentColor',
+  },
+  dataUrlType: {
+    type: String as PropType<SignatureDataUrlType>,
+    default: 'png',
+  },
+  onStart: defineListenerProp<() => void>(),
+  onEnd: defineListenerProp<() => void>(),
+  onSigning: defineListenerProp<() => void>(),
+}
diff --git a/packages/varlet-ui/src/signature/signature.less b/packages/varlet-ui/src/signature/signature.less
new file mode 100644
index 00000000000..caf186dc84c
--- /dev/null
+++ b/packages/varlet-ui/src/signature/signature.less
@@ -0,0 +1,14 @@
+:root {
+  --signature-background-color: var(--color-surface-container-highest);
+  --signature-stroke-color: #333;
+  --signature-height: 200px;
+  --signature-border-radius: 4px;
+}
+
+.var-signature {
+  width: 100%;
+  color: var(--signature-stroke-color);
+  background: var(--signature-background-color);
+  height: var(--signature-height);
+  border-radius: var(--signature-border-radius);
+}
diff --git a/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap b/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap
index 241b7b71f22..dabef0e15e1 100644
--- a/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap
+++ b/packages/varlet-ui/src/themes/__tests__/__snapshots__/index.spec.js.snap
@@ -640,6 +640,9 @@ exports[`dark theme 1`] = `
   "--shadow-key-ambient-opacity": "rgba(0, 0, 0, 0.12)",
   "--shadow-key-penumbra-opacity": "rgba(0, 0, 0, 0.14)",
   "--shadow-key-umbra-opacity": "rgba(0, 0, 0, 0.2)",
+  "--signature-background-color": "var(--color-surface-container-highest)",
+  "--signature-height": "200px",
+  "--signature-stroke-color": "#fff",
   "--skeleton-animation-background": "linear-gradient(
         90deg,hsla(0,0%,100%,0),hsla(0,0%,100%,.05),hsla(0,0%,100%,0))
       ",
@@ -1534,6 +1537,9 @@ exports[`md3Dark theme 1`] = `
   "--shadow-key-ambient-opacity": "rgba(0, 0, 0, 0.12)",
   "--shadow-key-penumbra-opacity": "rgba(0, 0, 0, 0.14)",
   "--shadow-key-umbra-opacity": "rgba(0, 0, 0, 0.2)",
+  "--signature-background-color": "var(--color-surface-container-highest)",
+  "--signature-height": "200px",
+  "--signature-stroke-color": "var(--color-inverse-surface)",
   "--skeleton-animation-background": "linear-gradient(
         90deg,hsla(0,0%,100%,0),hsla(0,0%,100%,.05),hsla(0,0%,100%,0))
       ",
@@ -2412,6 +2418,9 @@ exports[`md3Light theme 1`] = `
   "--shadow-key-ambient-opacity": "rgba(0, 0, 0, 0.12)",
   "--shadow-key-penumbra-opacity": "rgba(0, 0, 0, 0.14)",
   "--shadow-key-umbra-opacity": "rgba(0, 0, 0, 0.2)",
+  "--signature-background-color": "var(--color-surface-container-low)",
+  "--signature-height": "200px",
+  "--signature-stroke-color": "#1D1B20",
   "--skeleton-animation-background": "linear-gradient(90deg, hsla(0, 0%, 100%, 0), hsla(0, 0%, 100%, 0.3), hsla(0, 0%, 100%, 0))",
   "--skeleton-avatar-background-color": "rgba(0, 0, 0, 0.12)",
   "--skeleton-avatar-border-radius": "50%",
diff --git a/packages/varlet-ui/src/themes/dark/index.ts b/packages/varlet-ui/src/themes/dark/index.ts
index 6b8df27a28e..8ee8bbe074d 100644
--- a/packages/varlet-ui/src/themes/dark/index.ts
+++ b/packages/varlet-ui/src/themes/dark/index.ts
@@ -50,6 +50,7 @@ import rate from './rate'
 import result from './result'
 import ripple from './ripple'
 import select from './select'
+import signature from './signature'
 import skeleton from './skeleton'
 import slider from './slider'
 import snackbar from './snackbar'
@@ -209,4 +210,5 @@ export default {
   ...tooltip,
   ...select,
   ...code,
+  ...signature,
 } as StyleVars
diff --git a/packages/varlet-ui/src/themes/dark/signature.ts b/packages/varlet-ui/src/themes/dark/signature.ts
new file mode 100644
index 00000000000..9d5f5117836
--- /dev/null
+++ b/packages/varlet-ui/src/themes/dark/signature.ts
@@ -0,0 +1,6 @@
+export default {
+  '--signature-background-color': 'var(--color-surface-container-highest)',
+  '--signature-stroke-color': '#fff',
+  '--signature-height': '200px',
+  '--signature-border-radius': '4px',
+}
diff --git a/packages/varlet-ui/src/themes/md3-dark/index.ts b/packages/varlet-ui/src/themes/md3-dark/index.ts
index 5505cdccb52..afdcc3d67e5 100644
--- a/packages/varlet-ui/src/themes/md3-dark/index.ts
+++ b/packages/varlet-ui/src/themes/md3-dark/index.ts
@@ -50,6 +50,7 @@ import rate from './rate'
 import result from './result'
 import ripple from './ripple'
 import select from './select'
+import signature from './signature'
 import skeleton from './skeleton'
 import slider from './slider'
 import snackbar from './snackbar'
@@ -209,4 +210,5 @@ export default {
   ...space,
   ...swipe,
   ...code,
+  ...signature,
 } as StyleVars
diff --git a/packages/varlet-ui/src/themes/md3-dark/signature.ts b/packages/varlet-ui/src/themes/md3-dark/signature.ts
new file mode 100644
index 00000000000..9044b0ee9c7
--- /dev/null
+++ b/packages/varlet-ui/src/themes/md3-dark/signature.ts
@@ -0,0 +1,6 @@
+export default {
+  '--signature-background-color': 'var(--color-surface-container-highest)',
+  '--signature-stroke-color': 'var(--color-inverse-surface)',
+  '--signature-height': '200px',
+  '--signature-border-radius': '12px',
+}
diff --git a/packages/varlet-ui/src/themes/md3-light/index.ts b/packages/varlet-ui/src/themes/md3-light/index.ts
index f62b74a1853..9641c039b20 100644
--- a/packages/varlet-ui/src/themes/md3-light/index.ts
+++ b/packages/varlet-ui/src/themes/md3-light/index.ts
@@ -50,6 +50,7 @@ import rate from './rate'
 import result from './result'
 import ripple from './ripple'
 import select from './select'
+import signature from './signature'
 import skeleton from './skeleton'
 import slider from './slider'
 import snackbar from './snackbar'
@@ -209,4 +210,5 @@ export default {
   ...watermark,
   ...appBar,
   ...code,
+  ...signature,
 } as StyleVars
diff --git a/packages/varlet-ui/src/themes/md3-light/signature.ts b/packages/varlet-ui/src/themes/md3-light/signature.ts
new file mode 100644
index 00000000000..a387592b7e9
--- /dev/null
+++ b/packages/varlet-ui/src/themes/md3-light/signature.ts
@@ -0,0 +1,6 @@
+export default {
+  '--signature-background-color': 'var(--color-surface-container-low)',
+  '--signature-stroke-color': '#1D1B20',
+  '--signature-height': '200px',
+  '--signature-border-radius': '12px',
+}
diff --git a/packages/varlet-ui/types/index.d.ts b/packages/varlet-ui/types/index.d.ts
index 4baa019bfb0..79414fd30d2 100644
--- a/packages/varlet-ui/types/index.d.ts
+++ b/packages/varlet-ui/types/index.d.ts
@@ -74,6 +74,7 @@ export * from './result'
 export * from './ripple'
 export * from './row'
 export * from './select'
+export * from './signature'
 export * from './skeleton'
 export * from './slider'
 export * from './snackbar'
@@ -169,6 +170,7 @@ declare module 'vue' {
     VarResult: typeof import('@varlet/ui')['_ResultComponent']
     VarRow: typeof import('@varlet/ui')['_RowComponent']
     VarSelect: typeof import('@varlet/ui')['_SelectComponent']
+    VarSignature: typeof import('@varlet/ui')['_SignatureComponent']
     VarSkeleton: typeof import('@varlet/ui')['_SkeletonComponent']
     VarSlider: typeof import('@varlet/ui')['_SliderComponent']
     VarSnackbar: typeof import('@varlet/ui')['_SnackbarComponent']
diff --git a/packages/varlet-ui/types/signature.d.ts b/packages/varlet-ui/types/signature.d.ts
new file mode 100644
index 00000000000..2022eeee40f
--- /dev/null
+++ b/packages/varlet-ui/types/signature.d.ts
@@ -0,0 +1,26 @@
+import { BasicAttributes, ListenerProp, SetPropsDefaults, VarComponent } from './varComponent'
+
+export declare const signatureProps: Record<keyof SignatureProps, any>
+
+export type SignatureDataUrlType = 'png' | 'jpg'
+
+export interface SignatureProps extends BasicAttributes {
+  lineWidth?: number
+  strokeStyle?: string
+  dataUrlType?: SignatureDataUrlType
+  onStart?: ListenerProp<() => void>
+  onEnd?: ListenerProp<() => void>
+  onSigning?: ListenerProp<() => void>
+}
+
+export class Signature extends VarComponent {
+  static setPropsDefaults: SetPropsDefaults<SignatureProps>
+
+  $props: SignatureProps
+
+  confirm(): string
+
+  reset(): void
+}
+
+export class _SignatureComponent extends Signature {}
diff --git a/packages/varlet-ui/types/skeleton.d.ts b/packages/varlet-ui/types/skeleton.d.ts
index ba426f7ed5b..bd113a3ecf3 100644
--- a/packages/varlet-ui/types/skeleton.d.ts
+++ b/packages/varlet-ui/types/skeleton.d.ts
@@ -1,30 +1,33 @@
 import { VNode } from 'vue'
 import { BasicAttributes, SetPropsDefaults, VarComponent } from './varComponent'
 
-export declare const skeletonProps: Record<keyof SkeletonProps, any>
-
-export interface SkeletonProps extends BasicAttributes {
-  loading?: boolean
-  title?: boolean
-  avatar?: boolean
-  card?: boolean
-  fullscreen?: boolean
-  fullscreenZIndex?: string | number
-  titleWidth?: string | number
-  avatarSize?: string | number
-  cardHeight?: string | number
-  rows?: string | number
-  rowsWidth?: (string | number)[]
+export type SignatureType = 'png' | 'jpg'
+
+export declare const signatureProps: Record<keyof SignatureProps, any>
+
+export interface SignatureProps extends BasicAttributes {
+  modelValue?: string
+  lineWidth?: number
+  strokeStyle?: string
+  type?: SignatureType
+  unsupportText?: string
+  customClass?: string
+  height?: number | string
+  validateTrigger?: string[]
+  rules?: Array<(v: string) => any>
 }
 
-export class Skeleton extends VarComponent {
-  static setPropsDefaults: SetPropsDefaults<SkeletonProps>
+export class Signature extends VarComponent {
+  static setPropsDefaults: SetPropsDefaults<SignatureProps>
 
-  $props: SkeletonProps
+  $props: SignatureProps
 
   $slots: {
     default(): VNode[]
   }
+
+  clear(): void
+  confirm(): void
 }
 
-export class _SkeletonComponent extends Skeleton {}
+export class _SignatureComponent extends Signature {}
diff --git a/packages/varlet-ui/types/styleVars.d.ts b/packages/varlet-ui/types/styleVars.d.ts
index a68e9ae407c..4d13dbad5b9 100644
--- a/packages/varlet-ui/types/styleVars.d.ts
+++ b/packages/varlet-ui/types/styleVars.d.ts
@@ -575,6 +575,10 @@ interface BaseStyleVars {
   '--option-font-size'?: string
   '--option-selected-background'?: string
   '--option-text-color'?: string
+  '--signature-height'?: string
+  '--signature-background-color'?: string
+  '--signature-stroke-color'?: string
+  '--signature-border-radius'?: string
   '--skeleton-content-padding'?: string
   '--skeleton-card-height'?: string
   '--skeleton-card-border-radius'?: string
diff --git a/packages/varlet-ui/varlet.config.mjs b/packages/varlet-ui/varlet.config.mjs
index 97196f1bbc1..5114f034e58 100644
--- a/packages/varlet-ui/varlet.config.mjs
+++ b/packages/varlet-ui/varlet.config.mjs
@@ -637,6 +637,14 @@ export default defineConfig({
         doc: 'time-picker',
         type: 2,
       },
+      {
+        text: {
+          'zh-CN': 'Signature 签名',
+          'en-US': 'Signature',
+        },
+        doc: 'signature',
+        type: 2,
+      },
       {
         text: {
           'zh-CN': '表单组件',


Reviews:
user: login: chouchouji
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T02:36:55Z
user: login: rzzf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T05:30:31Z
user: login: haoziqaq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T07:27:46Z
user: login: haoziqaq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-09T12:47:32Z
user: login: william-xue
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-09T15:30:32Z
user: login: haoziqaq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:47:06Z
user: login: haoziqaq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:49:21Z

Review comments:
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
path: packages/varlet-ui/src/signature/Signature.vue
user: login: chouchouji
body: 这里可以用 classes 和 n 处理
created_at: 2025-03-07T02:30:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit }) {
+    const { validateWithTrigger } = useValidation()
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+    const isCanvasSupported = computed(() => {
path: packages/varlet-ui/src/signature/Signature.vue
user: login: chouchouji
body: 这个最好不要用computed，没有依赖需要追踪
created_at: 2025-03-07T02:32:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: '#000',
+  },
+  type: {
+    type: String as PropType<SignatureType>,
+    default: 'png',
+  },
+  unsupportText: {
+    type: String,
+    default: '',
+  },
+  customClass: {
+    type: String,
+    default: '',
+  },
+  validateTrigger: {
+    type: Array as PropType<Array<'onChange'>>,
+    default: () => ['onChange'],
+  },
+  rules: {
+    type: [Array, Object] as PropType<Array<(v: any) => any> | object>,
+    default: () => [],
+  },
+  height: {
+    type: [Number, String],
+    default: 200,
+  },
+  disabled: {
path: packages/varlet-ui/src/signature/props.ts
user: login: chouchouji
body: disabled: Boolean
created_at: 2025-03-07T02:34:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,45 @@
+@import '../styles/common';
+@import '../styles/elevation';
path: packages/varlet-ui/src/signature/signature.less
user: login: chouchouji
body: 这个最好移动到 该组件的vue文件的style中
created_at: 2025-03-07T02:35:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit }) {
+    const { validateWithTrigger } = useValidation()
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+    const isCanvasSupported = computed(() => {
+      const elem = document.createElement('canvas')
+      return !!(elem.getContext && elem.getContext('2d'))
+    })
+
+    const unsupportText = computed(() => props.unsupportText || translate('unsupportText'))
+
+    const state = reactive({
+      canvasHeight: 0,
+      canvasWidth: 0,
+      ctx: null as CanvasRenderingContext2D | null,
+      isSupportTouch: 'ontouchstart' in window,
+      events:
+        'ontouchstart' in window
+          ? ['touchstart', 'touchmove', 'touchend', 'touchleave']
+          : ['mousedown', 'mousemove', 'mouseup', 'mouseleave'],
+      isEmpty: true,
+    })
+
+    const addEvent = () => {
+      if (!canvas.value) {
+        return
+      }
+      canvas.value.addEventListener(state.events[0], startEventHandler as EventListener, false)
+    }
+
+    const startEventHandler = (event: MouseEvent | TouchEvent) => {
+      event.preventDefault()
+      if (!state.ctx || props.disabled) {
+        return
+      }
+
+      state.ctx.beginPath()
+      state.ctx.lineWidth = props.lineWidth
+      state.ctx.strokeStyle = props.strokeStyle
+      state.isEmpty = false
+      emit('start')
+
+      if (!canvas.value) {
+        return
+      }
+      canvas.value.addEventListener(state.events[1], moveEventHandler as EventListener, false)
+      canvas.value.addEventListener(state.events[2], endEventHandler as EventListener, false)
+      canvas.value.addEventListener(state.events[3], leaveEventHandler as EventListener, false)
+    }
+
+    const moveEventHandler = (event: MouseEvent | TouchEvent) => {
+      event.preventDefault()
+      if (!canvas.value || !state.ctx) {
+        return
+      }
+
+      const evt = state.isSupportTouch ? (event as TouchEvent).touches[0] : (event as MouseEvent)
+
+      emit('signing', evt)
+      const coverPos = canvas.value.getBoundingClientRect()
+      const mouseX = evt.clientX - coverPos.left
+      const mouseY = evt.clientY - coverPos.top
+
+      state.ctx.lineTo(mouseX, mouseY)
+      state.ctx.stroke()
+    }
+
+    const endEventHandler = (event: MouseEvent | TouchEvent) => {
+      event.preventDefault()
+      emit('end')
+      if (!canvas.value) {
+        return
+      }
+
+      canvas.value.removeEventListener(state.events[1], moveEventHandler as EventListener, false)
+      canvas.value.removeEventListener(state.events[2], endEventHandler as EventListener, false)
+
+      validateWithTrigger(props.validateTrigger, 'onChange', props.rules, props.modelValue)
+    }
+
+    const leaveEventHandler = (event: MouseEvent | TouchEvent) => {
+      event.preventDefault()
+      if (!canvas.value) {
+        return
+      }
+
+      canvas.value.removeEventListener(state.events[1], moveEventHandler as EventListener, false)
+      canvas.value.removeEventListener(state.events[2], endEventHandler as EventListener, false)
+    }
+
+    const clear = () => {
+      if (!canvas.value || !state.ctx) {
+        return
+      }
+
+      canvas.value.addEventListener(state.events[2], endEventHandler as EventListener, false)
+      state.ctx.clearRect(0, 0, state.canvasWidth, state.canvasHeight)
+      state.ctx.closePath()
+      state.isEmpty = true
+
+      emit('clear')
+      emit('update:modelValue', '')
+      validateWithTrigger(props.validateTrigger, 'onChange', props.rules, '')
+    }
+
+    const confirm = () => {
+      if (!canvas.value) {
+        return
+      }
+
+      const dataUrl = getDataUrl(canvas.value)
+      emit('confirm', canvas.value, dataUrl)
+      emit('update:modelValue', dataUrl)
+      validateWithTrigger(props.validateTrigger, 'onChange', props.rules, dataUrl)
+    }
+
+    const isCanvasBlank = (canvas: HTMLCanvasElement) => {
+      if (!canvas) {
+        return true
+      }
+
+      const blank = document.createElement('canvas')
+      blank.width = canvas.width
+      blank.height = canvas.height
+
+      return canvas.toDataURL() === blank.toDataURL()
+    }
+
+    const getDataUrl = (canvas: HTMLCanvasElement) => {
+      if (isCanvasBlank(canvas)) {
+        return ''
+      }
+
+      switch (props.type) {
+        case 'png':
+          return canvas.toDataURL('image/png')
+        case 'jpg':
+          return canvas.toDataURL('image/jpeg', 0.8)
+        default:
+          return canvas.toDataURL('image/png')
+      }
+    }
+
+    onMounted(() => {
+      if (isCanvasSupported.value && canvas.value && wrap.value) {
+        state.ctx = canvas.value.getContext('2d')
+        state.canvasWidth = wrap.value.offsetWidth
+        state.canvasHeight = wrap.value.offsetHeight
+        addEvent()
+      }
+    })
+
+    return {
+      ...toRefs(state),
+      canvas,
+      wrap,
+      isCanvasSupported,
+      confirm,
+      clear,
+      classes: computed(() => classes(n(), props.customClass ? props.customClass : null)),
+      translate,
+      unsupportText,
+    }
+  },
+})
+</script>
+
+<style lang="less">
+@import './signature';
+
+.var-signature {
+  &--disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+    pointer-events: none;
+  }
+}
path: packages/varlet-ui/src/signature/Signature.vue
user: login: rzzf
body: 移到signature.less中可能会更好
created_at: 2025-03-07T05:27:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,180 @@
+import { createApp } from 'vue'
+import { mount } from '@vue/test-utils'
+import { beforeAll, describe, expect, test, vi } from 'vitest'
+import Signature from '..'
+import VarSignature from '../Signature.vue'
+
+// 添加 canvas mock
+beforeAll(() => {
+  const mockContext = {
+    beginPath: vi.fn(),
+    moveTo: vi.fn(),
+    lineTo: vi.fn(),
+    stroke: vi.fn(),
+    clearRect: vi.fn(),
+    closePath: vi.fn(),
+    canvas: {
+      width: 300,
+      height: 150,
+    },
+    lineWidth: 2,
+    strokeStyle: '#000',
+  }
+
+  HTMLCanvasElement.prototype.getContext = () => mockContext
+})
+
+test('signature use', () => {
+  const app = createApp({}).use(Signature)
+  expect(app.component(Signature.name)).toBeTruthy()
+})
+
+describe('test signature component props', () => {
+  test('signature lineWidth', async () => {
+    const wrapper = mount(VarSignature, {
+      props: {
+        lineWidth: 2,
+      },
+    })
+
+    expect(wrapper.find('.var-signature__inner').exists()).toBe(true)
+    await wrapper.setProps({ lineWidth: 4 })
+    wrapper.unmount()
+  })
+
+  test('signature strokeStyle', async () => {
+    const wrapper = mount(VarSignature, {
+      props: {
+        strokeStyle: '#000',
+      },
+    })
+
+    expect(wrapper.find('.var-signature__inner').exists()).toBe(true)
+    await wrapper.setProps({ strokeStyle: '#f00' })
+    wrapper.unmount()
+  })
+
+  test('signature type', async () => {
+    const wrapper = mount(VarSignature, {
+      props: {
+        type: 'png',
+      },
+    })
+
+    expect(wrapper.find('.var-signature__inner').exists()).toBe(true)
+    await wrapper.setProps({ type: 'jpg' })
+    wrapper.unmount()
+  })
+
+  test('signature unsupportText', () => {
+    // 模拟不支持 canvas 的环境
path: packages/varlet-ui/src/signature/__tests__/index.spec.js
user: login: haoziqaq
body: 理论上在 vue3 的使用环境下，不存在不支持 canvas 的环境，感觉这个功能可以去掉。
created_at: 2025-03-07T07:27:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 这里直接用 createNamespace 返回的 name
created_at: 2025-03-09T12:43:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit }) {
+    const { validateWithTrigger } = useValidation()
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+    const isCanvasSupported = computed(() => {
+      const elem = document.createElement('canvas')
+      return !!(elem.getContext && elem.getContext('2d'))
+    })
+
+    const unsupportText = computed(() => props.unsupportText || translate('unsupportText'))
+
+    const state = reactive({
+      canvasHeight: 0,
+      canvasWidth: 0,
+      ctx: null as CanvasRenderingContext2D | null,
+      isSupportTouch: 'ontouchstart' in window,
+      events:
+        'ontouchstart' in window
+          ? ['touchstart', 'touchmove', 'touchend', 'touchleave']
+          : ['mousedown', 'mousemove', 'mouseup', 'mouseleave'],
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 这里应该直接使用 touch 事件即可，因为我们有独立的事件适配器。
created_at: 2025-03-09T12:44:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit }) {
+    const { validateWithTrigger } = useValidation()
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+    const isCanvasSupported = computed(() => {
+      const elem = document.createElement('canvas')
+      return !!(elem.getContext && elem.getContext('2d'))
+    })
+
+    const unsupportText = computed(() => props.unsupportText || translate('unsupportText'))
+
+    const state = reactive({
+      canvasHeight: 0,
+      canvasWidth: 0,
+      ctx: null as CanvasRenderingContext2D | null,
+      isSupportTouch: 'ontouchstart' in window,
+      events:
+        'ontouchstart' in window
+          ? ['touchstart', 'touchmove', 'touchend', 'touchleave']
+          : ['mousedown', 'mousemove', 'mouseup', 'mouseleave'],
+      isEmpty: true,
+    })
+
+    const addEvent = () => {
+      if (!canvas.value) {
+        return
+      }
+      canvas.value.addEventListener(state.events[0], startEventHandler as EventListener, false)
+    }
+
+    const startEventHandler = (event: MouseEvent | TouchEvent) => {
+      event.preventDefault()
+      if (!state.ctx || props.disabled) {
+        return
+      }
+
+      state.ctx.beginPath()
+      state.ctx.lineWidth = props.lineWidth
+      state.ctx.strokeStyle = props.strokeStyle
+      state.isEmpty = false
+      emit('start')
+
+      if (!canvas.value) {
+        return
+      }
+      canvas.value.addEventListener(state.events[1], moveEventHandler as EventListener, false)
+      canvas.value.addEventListener(state.events[2], endEventHandler as EventListener, false)
+      canvas.value.addEventListener(state.events[3], leaveEventHandler as EventListener, false)
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 注册事件使用  @varlet/use 里的 useEventListener
created_at: 2025-03-09T12:44:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
path: packages/varlet-ui/src/signature/props.ts
user: login: haoziqaq
body: 不需要这个属性
created_at: 2025-03-09T12:45:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: '#000',
+  },
+  type: {
+    type: String as PropType<SignatureType>,
+    default: 'png',
+  },
+  unsupportText: {
+    type: String,
+    default: '',
+  },
+  customClass: {
+    type: String,
+    default: '',
+  },
+  validateTrigger: {
+    type: Array as PropType<Array<'onChange'>>,
+    default: () => ['onChange'],
+  },
+  rules: {
+    type: [Array, Object] as PropType<Array<(v: any) => any> | object>,
+    default: () => [],
+  },
+  height: {
+    type: [Number, String],
+    default: 200,
+  },
path: packages/varlet-ui/src/signature/props.ts
user: login: haoziqaq
body: 这几个属性都不需要，暂时不把它当成表单组件看。因为这个组件不是高频组件，我们先设计一个最简单的版本，后续真的需要可以再加。但是现在设计的过多，为了兼容性就很难再删了。
created_at: 2025-03-09T12:46:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,46 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: '#000',
+  },
+  type: {
+    type: String as PropType<SignatureType>,
+    default: 'png',
+  },
+  unsupportText: {
+    type: String,
+    default: '',
+  },
+  customClass: {
+    type: String,
+    default: '',
+  },
+  validateTrigger: {
+    type: Array as PropType<Array<'onChange'>>,
+    default: () => ['onChange'],
+  },
+  rules: {
+    type: [Array, Object] as PropType<Array<(v: any) => any> | object>,
+    default: () => [],
+  },
+  height: {
+    type: [Number, String],
+    default: 200,
+  },
path: packages/varlet-ui/src/signature/props.ts
user: login: haoziqaq
body: height 可以跟随父容器的高度
created_at: 2025-03-09T12:47:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,203 @@
+<template>
+  <div :class="classes">
+    <div ref="wrap" class="var-signature__inner" :class="{ 'var-signature--disabled': disabled }">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" class="var-signature__unsupport">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useLocale } from '../locale'
+import { createNamespace, useValidation } from '../utils/components'
+import { props } from './props'
+
+const { n, classes } = createNamespace('signature')
+
+export default defineComponent({
+  name: 'VarSignature',
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit }) {
+    const { validateWithTrigger } = useValidation()
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+    const isCanvasSupported = computed(() => {
path: packages/varlet-ui/src/signature/Signature.vue
user: login: william-xue
body: 对的
created_at: 2025-03-09T15:30:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: '#000',
+  },
+  type: {
+    type: String as PropType<SignatureType>,
+    default: 'png',
+  },
+  unsupportText: {
+    type: String,
+    default: '',
+  },
path: packages/varlet-ui/src/signature/props.ts
user: login: haoziqaq
body: 这个可以删掉
created_at: 2025-03-12T13:39:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,30 @@
+import { type PropType } from 'vue'
+
+export type SignatureType = 'png' | 'jpg'
+
+export const props = {
+  modelValue: {
+    type: String,
+    default: '',
+  },
+  lineWidth: {
+    type: Number,
+    default: 2,
+  },
+  strokeStyle: {
+    type: String,
+    default: '#000',
+  },
+  type: {
+    type: String as PropType<SignatureType>,
+    default: 'png',
+  },
path: packages/varlet-ui/src/signature/props.ts
user: login: haoziqaq
body: 重命名为 dataUrlType
created_at: 2025-03-12T13:43:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,51 @@
+@import '../styles/common';
+@import '../styles/elevation';
path: packages/varlet-ui/src/signature/signature.less
user: login: haoziqaq
body: 删除
created_at: 2025-03-12T13:43:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 这一层 div 是不是可以去掉
created_at: 2025-03-12T13:44:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" :class="n('unsupport')">{{ unsupportText }}</p>
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 删除
created_at: 2025-03-12T13:44:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" :class="n('unsupport')">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useEventListener } from '@varlet/use'
+import { useLocale } from '../locale'
+import { createNamespace } from '../utils/components'
+import { props } from './props'
+
+const { name, n } = createNamespace('signature')
+
+export default defineComponent({
+  name,
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 事件定义修改成 onXXX 的定义方式，参考其他组件
created_at: 2025-03-12T13:44:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" :class="n('unsupport')">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useEventListener } from '@varlet/use'
+import { useLocale } from '../locale'
+import { createNamespace } from '../utils/components'
+import { props } from './props'
+
+const { name, n } = createNamespace('signature')
+
+export default defineComponent({
+  name,
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit, expose }) {
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+
+    const elem = document.createElement('canvas')
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 不要在 setup 里操作 dom，因为要支持服务端渲染。
created_at: 2025-03-12T13:45:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" :class="n('unsupport')">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useEventListener } from '@varlet/use'
+import { useLocale } from '../locale'
+import { createNamespace } from '../utils/components'
+import { props } from './props'
+
+const { name, n } = createNamespace('signature')
+
+export default defineComponent({
+  name,
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit, expose }) {
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+
+    const elem = document.createElement('canvas')
+    const isCanvasSupported = !!(elem.getContext && elem.getContext('2d'))
+
+    const unsupportText = computed(() => props.unsupportText || translate('unsupportText'))
+
+    const state = reactive({
+      canvasHeight: 0,
+      canvasWidth: 0,
+      ctx: null as CanvasRenderingContext2D | null,
+      isSupportTouch: 'ontouchstart' in window,
+      events:
+        'ontouchstart' in window
+          ? ['touchstart', 'touchmove', 'touchend', 'touchleave']
+          : ['mousedown', 'mousemove', 'mouseup', 'mouseleave'],
+      isEmpty: true,
+      isDrawing: false, // 添加绘制状态控制
+    })
+
+    // 修改事件处理函数的类型声明
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 注释全部删除
created_at: 2025-03-12T13:46:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,183 @@
+<template>
+  <div :class="n()">
+    <div ref="wrap" :class="[n('inner'), { [n('--disabled')]: disabled }]">
+      <canvas v-show="isCanvasSupported" ref="canvas" :height="canvasHeight" :width="canvasWidth"></canvas>
+      <p v-if="!isCanvasSupported" :class="n('unsupport')">{{ unsupportText }}</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, reactive, ref, toRefs } from 'vue'
+import { useEventListener } from '@varlet/use'
+import { useLocale } from '../locale'
+import { createNamespace } from '../utils/components'
+import { props } from './props'
+
+const { name, n } = createNamespace('signature')
+
+export default defineComponent({
+  name,
+  props,
+  emits: ['start', 'end', 'signing', 'confirm', 'clear', 'update:modelValue'],
+
+  setup(props, { emit, expose }) {
+    const { t } = useLocale()
+    const translate = (key: string) => t(`signature.${key}`)
+
+    const canvas = ref<HTMLCanvasElement | null>(null)
+    const wrap = ref<HTMLElement | null>(null)
+
+    const elem = document.createElement('canvas')
+    const isCanvasSupported = !!(elem.getContext && elem.getContext('2d'))
+
+    const unsupportText = computed(() => props.unsupportText || translate('unsupportText'))
+
+    const state = reactive({
+      canvasHeight: 0,
+      canvasWidth: 0,
+      ctx: null as CanvasRenderingContext2D | null,
+      isSupportTouch: 'ontouchstart' in window,
+      events:
+        'ontouchstart' in window
+          ? ['touchstart', 'touchmove', 'touchend', 'touchleave']
+          : ['mousedown', 'mousemove', 'mouseup', 'mouseleave'],
path: packages/varlet-ui/src/signature/Signature.vue
user: login: haoziqaq
body: 直接使用 touch 即可
created_at: 2025-03-12T13:49:10Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-03-04T15:46:09Z
body: [vc]: #qFTg6iadC7lZGWEojA7NXyu9LRPUA8IWiKpuWp+jtcU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ2YXJsZXQiLCJyb290RGlyZWN0b3J5IjpudWxsLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdmFybGV0anMvdmFybGV0L0Z1ZlNYV2o2YmRGM3pvdkp2cnRjaDRwdmg5YnUiLCJwcmV2aWV3VXJsIjoidmFybGV0LWdpdC1mb3JrLXdpbGxpYW0teHVlLWRldi12YXJsZXRqcy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InZhcmxldC1naXQtZm9yay13aWxsaWFtLXh1ZS1kZXYtdmFybGV0anMudmVyY2VsLmFwcCJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **varlet** | ✅ Ready ([Inspect](https://vercel.com/varletjs/varlet/FufSXWj6bdF3zovJvrtch4pvh9bu)) | [Visit Preview](https://varlet-git-fork-william-xue-dev-varletjs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/varlet-git-fork-william-xue-dev-varletjs.vercel.app?via=pr-comment-feedback-link) | Mar 20, 2025 2:56pm |


author_association: NONE
user: login: vercel[bot]
created_at: 2025-03-04T15:46:10Z
body: @william-xue is attempting to deploy a commit to the **varletjs** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=varletjs&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22f4cf2a7110f5f72872bc6560eabc2d1aeeb216f8%22%7D%2C%22id%22%3A%22QmThFAMFmRqjXKRmNAXumzZSfPA8NyMQpas6FfJQUtskit%22%2C%22org%22%3A%22varletjs%22%2C%22prId%22%3A1863%2C%22repo%22%3A%22varlet%22%7D).


author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-03-04T15:47:09Z
body: 
[Open in Stackblitz](https://pkg.pr.new/template/a53bec19-c468-469c-9d36-0457b80de9e5)


<details><summary><b>@varlet/cli</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/cli@1863
```

</p></details>
      
    

<details><summary><b>@varlet/icons</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/icons@1863
```

</p></details>
      
    

<details><summary><b>@varlet/import-resolver</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/import-resolver@1863
```

</p></details>
      
    

<details><summary><b>@varlet/preset-tailwindcss</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/preset-tailwindcss@1863
```

</p></details>
      
    

<details><summary><b>@varlet/preset-unocss</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/preset-unocss@1863
```

</p></details>
      
    

<details><summary><b>@varlet/shared</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/shared@1863
```

</p></details>
      
    

<details><summary><b>@varlet/touch-emulator</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/touch-emulator@1863
```

</p></details>
      
    

<details><summary><b>@varlet/ui</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/ui@1863
```

</p></details>
      
    

<details><summary><b>@varlet/use</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/use@1863
```

</p></details>
      
    

<details><summary><b>@varlet/vite-plugins</b></summary><p>

```
npm i https://pkg.pr.new/@varlet/vite-plugins@1863
```

</p></details>
      
    

_commit: <a href="https://github.com/varletjs/varlet/runs/39117481429"><code>8a6c042</code></a>_

author_association: NONE