feat(Signature): add undo functionality (#13775)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gavin
2026-03-26 22:53:30 +08:00
committed by GitHub
parent c0b3a0ad89
commit bd70834e28
47 changed files with 347 additions and 51 deletions
+12 -3
View File
@@ -3,6 +3,7 @@ export default {
tel: 'الهاتف',
save: 'حفظ',
clear: 'مسح',
undo: 'تراجع',
cancel: 'إلغاء',
confirm: 'تأكيد',
delete: 'حذف',
@@ -15,9 +16,17 @@ export default {
end: 'نهاية',
start: 'بداية',
title: 'التقويم',
weekdays: ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'],
weekdays: [
'الأحد',
'الاثنين',
'الثلاثاء',
'الأربعاء',
'الخميس',
'الجمعة',
'السبت',
],
monthTitle: (year: number, month: number) => `${year}/${month}`,
rangePrompt: (maxRange: number) => `اختر لا يزيد عن ${maxRange} أيام`
rangePrompt: (maxRange: number) => `اختر لا يزيد عن ${maxRange} أيام`,
},
vanCascader: {
select: 'اختر',
@@ -59,4 +68,4 @@ export default {
vanAddressList: {
add: 'إضافة عنوان جديد',
},
};
};
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Телефон',
save: 'Запазване',
clear: 'ясно',
undo: 'Отмени',
cancel: 'Отказ',
confirm: 'Потвърди',
delete: 'Изтриване',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'ফোন',
save: 'সংরক্ষণ করুন',
clear: 'পরিষ্কার',
undo: 'পূর্বাবস্থা',
cancel: 'বাতিল',
confirm: 'নিশ্চিত করুন',
delete: 'মুছুন',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Gem',
clear: 'Klar',
undo: 'Fortryd',
cancel: 'Annuller',
confirm: 'Bekræft',
delete: 'Slet',
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Speichern',
clear: 'Klar',
undo: 'Rückgängig',
cancel: 'Abbrechen',
confirm: 'Bestätigen',
delete: 'Löschen',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Speichern',
clear: 'Klar',
undo: 'Rückgängig',
cancel: 'Abbrechen',
confirm: 'Bestätigen',
delete: 'Löschen',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Τηλέφωνο',
save: 'Αποθήκευση',
clear: 'Σαφή',
undo: 'Αναίρεση',
cancel: 'Ακύρωση',
confirm: 'Επιβεβαίωση',
delete: 'Διαγραφή',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Phone',
save: 'Save',
clear: 'Clear',
undo: 'Undo',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefonnumero',
save: 'Konservi',
clear: 'Klara',
undo: 'Malfari',
cancel: 'Rezigni',
confirm: 'Konfirmi',
delete: 'Forigi',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Teléfono',
save: 'Guardar',
clear: 'Claro',
undo: 'Deshacer',
cancel: 'Cancelar',
confirm: 'Confirmar',
delete: 'Eliminar',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'تلفن',
save: 'ذخیره',
clear: 'پاک کردن',
undo: 'واگرد',
cancel: 'انصراف',
confirm: 'تایید',
delete: 'حذف',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telephone',
save: 'Sauvegarder',
clear: 'Clair',
undo: 'Défaire',
cancel: 'Annuler',
confirm: 'Confirmer',
delete: 'Suprimer',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'טלפון',
save: 'שמור',
clear: 'ברור',
undo: 'בטל',
cancel: 'ביטול',
confirm: 'אישור',
delete: 'מחיקה',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'फ़ोन',
save: 'सहेजें',
clear: 'साफ़',
undo: 'पूर्ववत',
cancel: 'रद्द करें',
confirm: 'पुष्टि करना',
delete: 'हटाएं',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telepon',
save: 'Simpan',
clear: 'Jernih',
undo: 'Batalkan',
cancel: 'Batal',
confirm: 'Konfirmasi',
delete: 'Hapus',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Sími',
save: 'Vista',
clear: 'Hreinsa',
undo: 'Afturkalla',
cancel: 'hætta við',
confirm: 'Staðfesta',
delete: 'Eyða',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefono',
save: 'Salva',
clear: 'Chiaro',
undo: 'Annulla',
cancel: 'Annulla',
confirm: 'Conferma',
delete: 'Elimina',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: '電話番号',
save: 'セーブ',
clear: 'クリア',
undo: '元に戻す',
cancel: 'キャンセル',
confirm: '確認',
delete: '削除',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Телефон',
save: 'Сақтау',
clear: 'Тазарту',
undo: 'Болдырмау',
cancel: 'Бастарту',
confirm: 'Растау',
delete: 'Жою',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'ទូរស័ព្ទ',
save: 'រក្សាទុក',
clear: 'ច្បាស់',
undo: 'មិនធ្វើវិញ',
cancel: 'បោះបង់',
confirm: 'យល់ព្រម',
delete: 'លុប',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: '핸드폰',
save: '구하다',
clear: '분명한',
undo: '실행 취소',
cancel: '취소',
confirm: '확인',
delete: '삭제',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'ໂທລະສັບ',
save: 'ບັນທຶກ',
clear: 'ຈະແຈ້ງ',
undo: 'ຍົກເລີກ',
cancel: 'ຍົກເລີກ',
confirm: 'ຢຶນຢັນ',
delete: 'ລືບ',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Утас',
save: 'Хадгалах',
clear: 'Тодорхой',
undo: 'Буцаах',
cancel: 'Цуцлах',
confirm: 'Баталгаажуулах',
delete: 'Устгах',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Lagre',
clear: 'Klar',
undo: 'Angre',
cancel: 'Avbryt',
confirm: 'Bekreft',
delete: 'Slett',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefoon',
save: 'Opslaan',
clear: 'Duidelijk',
undo: 'Ongedaan maken',
cancel: 'Annuleren',
confirm: 'Bevestigen',
delete: 'Verwijderen',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Zapisz',
clear: 'Wyczyść',
undo: 'Cofnij',
cancel: 'Anuluj',
confirm: 'Potwierdź',
delete: 'Usuń',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Fone',
save: 'Salvar',
clear: 'Claro',
undo: 'Desfazer',
cancel: 'Cancelar',
confirm: 'Confirmar',
delete: 'Excluir',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Salvează',
clear: 'Clar',
undo: 'Anulează',
cancel: 'Anulează',
confirm: 'Confirmă',
delete: 'Șterge',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Телефон',
save: 'Сохранить',
clear: 'Прозрачный',
undo: 'Отменить',
cancel: 'Отмена',
confirm: 'Подтвердить',
delete: 'Удалить',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Broj telefona',
save: 'Sačuvaj',
clear: 'Prazno',
undo: 'Поништи',
cancel: 'Otkaži',
confirm: 'Potvrdi',
delete: 'Brisanje',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Spara',
clear: 'Klar',
undo: 'Ångra',
cancel: 'Avbryt',
confirm: 'Bekräfta',
delete: 'Radera',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'โทรศัพท์',
save: 'บันทึก',
clear: 'ชัดเจน',
undo: 'เลิกทำ',
cancel: 'ยกเลิก',
confirm: 'ยืนยัน',
delete: 'ลบ',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Telefon',
save: 'Kaydet',
clear: 'Temizlemek',
undo: 'Geri Al',
cancel: 'İptal',
confirm: 'Onayla',
delete: 'Sil',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Телефон',
save: 'Зберегти',
clear: 'ясно',
undo: 'Скасувати',
cancel: 'Скасувати',
confirm: 'Підтвердити',
delete: 'Видалити',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: 'Điện thoại',
save: 'Cứu',
clear: 'Thông thoáng',
undo: 'Hoàn tác',
cancel: 'Hủy bỏ',
confirm: 'Xác nhận',
delete: 'Xóa',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: '电话',
save: '保存',
clear: '清空',
undo: '撤销',
cancel: '取消',
confirm: '确认',
delete: '删除',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: '電話',
save: '保存',
clear: '清空',
undo: '撤銷',
cancel: '取消',
confirm: '確認',
delete: '刪除',
+1
View File
@@ -3,6 +3,7 @@ export default {
tel: '電話',
save: '儲存',
clear: '清空',
undo: '復原',
cancel: '取消',
confirm: '確認',
delete: '刪除',
+3
View File
@@ -84,9 +84,11 @@ Use `background-color` prop to set the color of the background.
| type | Export image type | _string_ | `png` |
| pen-color | Color of the brush stroke, default is black | _string_ | `#000` |
| line-width | Width of the line | _number_ | `3` |
| history-size | Maximum undo history size | _number_ | `20` |
| background-color | Background color | _string_ | - |
| tips | Text that appears when Canvas is not supported | _string_ | - |
| clear-button-text | Clear button text | _string_ | `Clear` |
| undo-button-text | Undo button text | _string_ | `Undo` |
| confirm-button-text | Confirm button text | _string_ | `Confirm` |
### Events
@@ -114,6 +116,7 @@ Use [ref](https://vuejs.org/guide/essentials/template-refs.html) to get Signatur
| resize `v4.7.3` | Resize Signature when container element resized or visibility changed | - | - |
| clear `v4.8.6` | Can be called to clear the signature | - | - |
| submit `v4.8.6` | Trigger the `submit` event, which is equivalent to clicking the confirm button. | - | - |
| undo | Undo the last stroke | - | - |
### Types
@@ -84,9 +84,11 @@ export default {
| type | 导出图片类型 | _string_ | `png` |
| pen-color | 笔触颜色,默认黑色 | _string_ | `#000` |
| line-width | 线条宽度 | _number_ | `3` |
| history-size | 撤销历史记录最大数量 | _number_ | `20` |
| background-color | 背景颜色 | _string_ | - |
| tips | 当不支持 Canvas 的时候出现的提示文案 | _string_ | - |
| clear-button-text | 清除按钮文案 | _string_ | `清空` |
| undo-button-text | 撤销按钮文案 | _string_ | `撤销` |
| confirm-button-text | 确认按钮文案 | _string_ | `确认` |
### Events
@@ -114,6 +116,7 @@ export default {
| resize `v4.7.3` | 外层元素大小或组件显示状态变化时,可以调用此方法来触发重绘 | - | - |
| clear `v4.8.6` | 可调用此方法来清除签名 | - | - |
| submit `v4.8.6` | 触发 `submit` 事件,与点击确认按钮的效果等价 | - | - |
| undo | 撤销上一次笔画 | - | - |
### 类型定义
+38
View File
@@ -34,6 +34,8 @@ export const signatureProps = {
type: makeStringProp('png'),
penColor: makeStringProp('#000'),
lineWidth: makeNumberProp(3),
historySize: makeNumberProp(20),
undoButtonText: String,
clearButtonText: String,
backgroundColor: makeStringProp(''),
confirmButtonText: String,
@@ -66,6 +68,19 @@ export default defineComponent({
let canvasHeight = 0;
let canvasRect: DOMRect;
const history = ref<ImageData[]>([]);
const saveState = () => {
if (ctx.value && canvasWidth && canvasHeight) {
if (history.value.length >= props.historySize) {
history.value.shift();
}
history.value.push(
ctx.value.getImageData(0, 0, canvasWidth, canvasHeight),
);
}
};
const touchStart = () => {
if (!ctx.value) {
return false;
@@ -100,6 +115,7 @@ export default defineComponent({
const touchEnd = (event: TouchEvent) => {
preventDefault(event);
saveState();
emit('end');
};
@@ -152,9 +168,27 @@ export default defineComponent({
ctx.value.closePath();
setCanvasBgColor(ctx.value);
}
history.value = [];
emit('clear');
};
const undo = () => {
if (history.value.length) {
history.value.pop();
if (ctx.value) {
ctx.value.clearRect(0, 0, canvasWidth, canvasHeight);
setCanvasBgColor(ctx.value);
if (history.value.length) {
ctx.value.putImageData(
history.value[history.value.length - 1],
0,
0,
);
}
}
}
};
const initialize = () => {
if (isRenderCanvas && canvasRef.value) {
const canvas = canvasRef.value;
@@ -183,6 +217,7 @@ export default defineComponent({
resize,
clear,
submit,
undo,
});
return () => (
@@ -205,6 +240,9 @@ export default defineComponent({
<Button size="small" onClick={clear}>
{props.clearButtonText || t('clear')}
</Button>
<Button size="small" onClick={undo}>
{props.undoButtonText || t('undo')}
</Button>
<Button type="primary" size="small" onClick={submit}>
{props.confirmButtonText || t('confirm')}
</Button>
@@ -22,29 +22,6 @@ exports[`should render demo and match snapshot 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
style
>
<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>
</canvas>
</div>
<div class="van-signature__footer">
<button
type="button"
class="van-button van-button--default van-button--small"
@@ -53,7 +30,7 @@ exports[`should render demo and match snapshot 1`] = `
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Clear
Undo
</span>
</div>
</button>
@@ -92,6 +69,18 @@ exports[`should render demo and match snapshot 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
style
>
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -127,6 +116,65 @@ exports[`should render demo and match snapshot 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
style
>
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
style
>
<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>
</canvas>
</div>
<div class="van-signature__footer">
<button
type="button"
class="van-button van-button--default van-button--small"
style
>
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Clear
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
style
>
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -21,36 +21,13 @@ exports[`should render demo and match snapshot 1`] = `
</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">
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Clear
Undo
</span>
</div>
</button>
@@ -87,6 +64,16 @@ exports[`should render demo and match snapshot 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -120,6 +107,59 @@ exports[`should render demo and match snapshot 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Undo
</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">
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Clear
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -12,6 +12,16 @@ exports[`should allow to custom button text 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -43,6 +53,16 @@ exports[`should render tips correctly 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
>
<div class="van-button__content">
<span class="van-button__text">
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -19,6 +19,18 @@ exports[`should render correctly when SSR 1`] = `
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--default van-button--small"
style
>
<div class="van-button__content">
<span class="van-button__text">
<!--[-->
Undo
</span>
</div>
</button>
<button
type="button"
class="van-button van-button--primary van-button--small"
@@ -108,3 +108,88 @@ test('should call resize when window width changes', async () => {
await trigger(window, 'resize');
expect(spy).toBeCalled();
});
test('expose undo method', async () => {
const wrapper = mount(Signature);
expect(wrapper.vm.undo).toBeTypeOf('function');
});
test('should allow to custom undo button text', async () => {
const wrapper = mount(Signature, {
props: {
undoButtonText: 'Back',
},
});
expect(wrapper.find('.van-signature__footer').text()).toContain('Back');
});
test('undo should restore canvas to previous state', async () => {
const wrapper = mount(Signature);
const canvas = wrapper.find('canvas');
const ctx = canvas.element.getContext('2d')!;
const putImageDataSpy = vi.spyOn(ctx, 'putImageData');
const clearRectSpy = vi.spyOn(ctx, 'clearRect');
// First stroke
await canvas.trigger('touchstart');
await canvas.trigger('touchmove', {
touches: [{ clientX: 10, clientY: 10 }],
});
await canvas.trigger('touchend');
// Second stroke
await canvas.trigger('touchstart');
await canvas.trigger('touchmove', {
touches: [{ clientX: 50, clientY: 50 }],
});
await canvas.trigger('touchend');
// Undo should restore to state after first stroke
wrapper.vm.undo();
expect(clearRectSpy).toHaveBeenCalled();
expect(putImageDataSpy).toHaveBeenCalled();
// Undo again should clear canvas (no more history)
wrapper.vm.undo();
expect(clearRectSpy).toHaveBeenCalledTimes(2);
});
test('history should be limited by historySize prop', async () => {
const wrapper = mount(Signature, {
props: {
historySize: 3,
},
});
const canvas = wrapper.find('canvas');
const ctx = canvas.element.getContext('2d')!;
const getImageDataSpy = vi.spyOn(ctx, 'getImageData');
// Draw 5 strokes
for (let i = 0; i < 5; i++) {
await canvas.trigger('touchstart');
await canvas.trigger('touchmove', {
touches: [{ clientX: i * 10, clientY: i * 10 }],
});
await canvas.trigger('touchend');
}
// getImageData should be called 5 times
expect(getImageDataSpy).toHaveBeenCalledTimes(5);
// Undo 3 times (max history size)
const clearRectSpy = vi.spyOn(ctx, 'clearRect');
wrapper.vm.undo();
wrapper.vm.undo();
wrapper.vm.undo();
expect(clearRectSpy).toHaveBeenCalledTimes(3);
// Fourth undo should do nothing (history exhausted)
const putImageDataSpy = vi.spyOn(ctx, 'putImageData');
putImageDataSpy.mockClear();
wrapper.vm.undo();
expect(putImageDataSpy).not.toHaveBeenCalled();
});
+1
View File
@@ -5,6 +5,7 @@ export type SignatureExpose = {
resize: () => void;
clear: () => void;
submit: () => void;
undo: () => void;
};
export type SignatureInstance = ComponentPublicInstance<