在界面中使用 van-signature 组件在移动端界面下过小,这就需要把组件调整为全屏横屏。为了不干扰原有布局,所以这里结合 van-popup 组件,以弹窗的形式实现横屏签名框。
思路概要
起初,为了让布局能够全屏,我使用 CSS 进行调整,将弹窗扩展到全屏,然后再使用 transform 属性将 van-signature 组件旋转 90 度到横屏。
但是,这样做会导致 canvas 绘制错位,如果采用这种方法,就需要再对 canvas 进行一次转换,反倒更复杂了。
所以,我们可以换个思路,让画布保持竖屏,直接把 canvas 铺满布局,在绘制好签名后,获取到 base64 图片,然后逆时针旋转 90 度得到横屏图片。这样看起来,就和横屏绘制的效果是一样的了。
关键方法
那么如何逆时针旋转绘制好的图片呢?同样使用 canvas 来完成,我们只需要创建一个画布,将图片放进去,使用 ctx.rotate(-Math.PI / 2);
旋转一下再导出为 base64 图片就可以了。
javascript
async function rotateToBase64File(
base64: string,
mimeType: string = "image/png"
): Promise<string> {
const img = new Image();
img.src = base64;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(new Error("图片加载失败"));
});
const canvas = document.createElement("canvas");
canvas.width = img.height;
canvas.height = img.width;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法获取画布上下文");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return canvas.toDataURL(mimeType);
}
签名组件封装和使用
这里简单封装一个签名组件,需要传入一个 signaturePic 作为 v-model 参数,用于和父组件共享。
javascript
<template>
<div>
<!-- 签名空白区域 -->
<div class="signature-empty" v-if="!signaturePic" @click="toSign">
点击此处进行签名
</div>
<!-- 签名预览区域 -->
<div v-else class="signature-preview">
<div class="preview-header">
<p>签名预览:</p>
<button @click="reSign">重签</button>
</div>
<van-image class="preview-image" :src="signaturePic" />
</div>
<!-- 签名弹窗 -->
<van-popup v-model:show="showSignature" class="signature-popup">
<div class="popup-wrapper">
<div class="popup-rotated">
<van-signature
ref="signatureRef"
class="signature-content"
@submit="submitSign"
@cancel="clearSign"
/>
</div>
</div>
</van-popup>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const signatureRef = ref();
const showSignature = ref<boolean>(false);
const signaturePic = defineModel<string>("signaturePic", { required: true });
// 显示签名
function toSign() {
showSignature.value = true;
}
// 取消签名
function clearSign() {
signaturePic.value = "";
showSignature.value = false;
}
// 重置签名
function reSign() {
signaturePic.value = "";
signatureRef.value.clear();
showSignature.value = true;
}
// 完成签名
async function submitSign(data: { image: string }) {
if (!data.image) {
return;
}
const base64 = await rotateToBase64File(data.image);
signaturePic.value = base64;
showSignature.value = false;
}
// 将 Base64 图片逆时针旋转90度
async function rotateToBase64File(
base64: string,
mimeType: string = "image/png"
): Promise<string> {
const img = new Image();
img.src = base64;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(new Error("图片加载失败"));
});
const canvas = document.createElement("canvas");
canvas.width = img.height;
canvas.height = img.width;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法获取画布上下文");
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(-Math.PI / 2); // 逆时针旋转90度
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return canvas.toDataURL(mimeType);
}
</script>
<style lang="scss" scoped>
.signature-empty {
width: 100%;
height: 200px;
margin: 10px 0;
border: 1px dashed #ebebebff;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
color: #afafaf;
font-size: 14px;
cursor: pointer;
background-color: #ffffff;
}
.preview-header {
display: flex;
justify-content: space-between;
font-size: 14px;
align-items: center;
color: #000000;
margin-bottom: 10px;
button {
border: none;
border-radius: 50px;
padding: 5px 15px;
color: #ffffff;
background-color: #0073ff;
font-size: 12px;
cursor: pointer;
}
}
.preview-image {
background-color: #ffffff;
padding: 10px;
border-radius: 10px;
box-sizing: border-box;
}
.signature-popup {
width: 100%;
height: 100%;
margin: 0;
}
:deep(.van-popup--center) {
max-width: 100%;
}
.popup-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
background-color: #f0f0f0;
}
.van-signature.signature-content {
--van-signature-content-border: 1px dashed #ebebebff;
--van-signature-padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: row-reverse;
align-items: center;
width: 100%;
height: 100%;
:deep(.van-signature__content) {
min-height: 95vh;
width: 80vw;
flex: 1;
}
:deep(.van-signature__footer) {
height: 50px;
width: 60px;
transform: rotate(90deg);
transform-origin: center;
justify-content: center;
.van-button__content {
width: 100px;
}
}
}
</style>
在父组件中使用,并且传入 v-model 参数
javascript
<template>
<main>
<Signature v-model:signature-pic="signaturePic" />
</main>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import Signature from "./components/Signature.vue";
const signaturePic = ref<string>("");
</script>
<style lang="scss" scoped></style>
效果预览

结语
需要注意的是 Vant4 中的 van-signature 只能在移动端下绘制,如果是在网页端就会无法绘制。也许,后续应该寻找一个同时支持网页和移动端的,更好替代方案,或者直接使用纯 canvas 实现保证更好的兼容性。
此外 Vant4 弹窗组件的可塑性不强,似乎没有像其他流行组件库中的设计那样,保留常见的 slot 用来自定义弹窗布局,因此在这个案例中只能强行覆盖原有组件的样式实现全屏弹窗,可能在实际项目中样式会出现一些问题,需要自行调整。