VitePress 预览组件
前言
在 Vitepress
的使用过程中,我发现当图片本身较大或者较为模糊时,显示不够清楚。
而 Vitepress
文档中并没有相关的解决办法,于是我决定写一个预览组件来满足这个简单需求。
环境
node: v21.7.1
实现
需求解析
- 弹出显示
- 可缩放
- 可拖拽
弹出显示
这个很简单,就是写一个蒙层放大图片即可,使用 <Teleport>
组件,传递蒙层节点挂载到 <body>
下,避免层级和定位问题。
当蒙层展示以后,蒙层下的网页依然可以滑动,所以需要对网页做设置,阻止其滑动,我这里选择的是固定网页高度,并禁止滚动。
<template>
<img
class="frame-img"
:src="imageUrl"
@click="() => handleImageClick(true)"
/>
<Teleport to="body">
<div
v-show="showMask"
:id="imageUrl"
class="preview-img-mask"
@click="() => handleImageClick(false)"
>
<img
class="preview-img"
:style="imgStyle"
:src="imageUrl"
@dragover="(e) => e.preventDefault()"
/>
</div>
</Teleport>
</template>
<script setup>
import { ref, onUpdated } from "vue";
// 处理蒙层状态
const showMask = ref(false);
const handleImageClick = (state) => {
const bodyDOM = document.getElementsByTagName("body")[0];
// 蒙层显示时,禁止页面滚动
if (state) {
bodyDOM.style.height = "100vh";
bodyDOM.style.overflow = "hidden";
init();
} else {
bodyDOM.style.height = "unset";
bodyDOM.style.overflow = "auto";
}
showMask.value = state;
};
</script>
<style scoped>
.frame-img {
width: 100%;
margin-bottom: 20px;
}
.preview-img-mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
width: 100vw;
min-height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
}
.preview-img {
opacity: 1;
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
transform-origin: top left;
max-width: 80vw;
object-fit: contain;
}
</style>
缩放
这个也很简单,隆重介绍 scale
属性,可以放大或者缩小元素。
需要注意的是,因为我是使用 position: absolute
+ transform
实现的居中,所以我的缩放中心点需要修改为左上角,默认缩放中心点为图片中心。
class CreateImgStyle {
scale = 1;
left = "50%";
top = "50%";
translate = "";
}
// 放大缩放
const imgStyle = ref(new CreateImgStyle());
const handleWheel = (data) => {
const { deltaY } = data;
let { scale } = imgStyle.value;
if (deltaY > 0) {
scale -= 0.2;
if (scale < 0) scale = 0;
} else if (deltaY < 0) {
scale += 0.2;
}
imgStyle.value.scale = scale;
};
移动端适配
移动端因为没有滚轮事件,所以需要通过 touchmove
事件的两个触摸点距离来计算 scale
的值,两个触摸点的起始位置用 touchstart
事件来确定。
还有一点要注意,就是 touchend
事件的触发是每个触摸点独立触发的,所以需要规避这个事件。
这里我是通过存储两点间距离的变量,initDistance
,来判断的,该变量有值则不执行 handleDragEnd
函数。
// H5 点触
const initDistance = ref(0);
const handleTouchStart = (e) => {
// console.log("touchstart", e);
if (e.touches.length === 2) {
// 两个触点,图片缩放
initDistance.value = getDistance(e.touches[0], e.touches[1]);
}
};
const handleTouchMove = (e) => {
e.preventDefault();
// console.log("touchmove", e);
if (e.touches.length === 2) {
// 两个触点,图片缩放
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const scaleFactor = currentDistance / initDistance.value;
imgStyle.value.scale *= scaleFactor;
initDistance.value = currentDistance;
}
};
const handleTouchEnd = (e) => {
// console.log("touchend", e);
if (e.changedTouches.length === 1 && !initDistance.value) {
handleDragEnd(e.changedTouches[0]);
}
};
const getDistance = (touch1, touch2) => {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
};
拖拽
tranform
方案在放开以后,重新拖拽时,鼠标会回到中心点,体验不好;
position: absolute
方案在拖拽过程中,没有 tranform
丝滑;
所以我做了一个结合,拖拽使用 tranform
,放开以后使用 position: absolute
固定图片。
endPoint
的定义为终止拖拽以后的点位,所以需要给一个初始值,一般为图片中心点坐标。
别忘了更改定位以后,需要清空 transform
属性。
// 图片拖拽
class CreateStartPoint {
x = 0;
y = 0;
}
class CreateEndPoint {
x = document.body.clientWidth / 2;
y = document.body.clientHeight / 2;
}
const startPoint = ref(new CreateStartPoint());
const endPoint = ref(new CreateEndPoint());
const handleDragStart = (data) => {
const { clientX, clientY } = data;
startPoint.value.x = clientX;
startPoint.value.y = clientY;
};
const handleDragEnd = (data) => {
const { clientX, clientY } = data;
setPoint(clientX, clientY);
imgStyle.value.translate = "";
};
const handleDrag = (data) => {
const { clientX, clientY } = data;
const { x, y } = startPoint.value;
imgStyle.value.translate = `${clientX - x}px ${clientY - y}px`;
};
const setPoint = (clientX, clientY) => {
endPoint.value.x = endPoint.value.x - startPoint.value.x + clientX;
endPoint.value.y = endPoint.value.y - startPoint.value.y + clientY;
imgStyle.value.left = `${endPoint.value.x}px`;
imgStyle.value.top = `${endPoint.value.y}px`;
};
移动端适配
因为移动端的触发事件和 Web
端不一样,所以需要进行一些的适配,但是区别不大,主要是要区分触控点数量,来进行不同的操作。
因为返回值的数据结构大致一样,所以可以复用之前写的拖拽相关方法。
// H5 点触
const handleTouchStart = (e) => {
console.log("touchstart", e);
if (e.touches.length === 1) {
handleDragStart(e.touches[0]);
}
};
const handleTouchMove = (e) => {
console.log("touchmove", e);
if (e.touches.length === 1) {
handleDrag(e.touches[0]);
}
};
const handleTouchEnd = (e) => {
console.log("touchend", e);
if (e.changedTouches.length === 1) {
handleDragEnd(e.changedTouches[0]);
}
};
// 判断运行环境
const isMobile = ref(false);
const detectDeviceType = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
// 检查是否为常见的移动设备用户代理
isMobile.value =
/Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
userAgent
);
if (isMobile.value) {
console.log("这是一个移动设备");
} else {
console.log("这是一个PC或桌面设备");
}
};
detectDeviceType();
onUpdated(() => {
const previewImgDOM = document.getElementById(props.imageUrl);
if (isMobile.value) {
// 监听点触事件
previewImgDOM.addEventListener("touchstart", handleTouchStart);
previewImgDOM.addEventListener("touchmove", handleTouchMove);
previewImgDOM.addEventListener("touchend", handleTouchEnd);
} else {
// 监听滚轮事件
previewImgDOM.addEventListener("wheel", handleWheel);
// 监听图片拖拽
previewImgDOM.addEventListener("dragstart", handleDragStart);
previewImgDOM.addEventListener("drag", handleDrag);
previewImgDOM.addEventListener("dragend", handleDragEnd);
previewImgDOM.addEventListener("dragover", (e) => e.preventDefault());
}
});
总结
我这是一个简单的预览组件,实现了缩放和拖拽功能,下一步可以添加旋转和操作条等优化功能,甚至是编辑功能,而这个就需要 canvas
来实现了。
代码
<template>
<img
class="frame-img"
:src="imageUrl"
@click.stop="() => handleImageClick(true)"
/>
<Teleport to="body">
<div
v-show="showMask"
:id="imageUrl"
class="preview-img-mask"
@click.stop="() => handleImageClick(false)"
>
<img class="preview-img" :style="imgStyle" :src="imageUrl" />
</div>
</Teleport>
</template>
<script setup>
import { ref, onUpdated } from "vue";
import detectDeviceType from "../hooks/isMobile.js";
const props = defineProps({
imageUrl: String,
});
// 处理蒙层状态
const showMask = ref(false);
const handleImageClick = (state) => {
const bodyDOM = document.getElementsByTagName("body")[0];
// 蒙层显示时,禁止页面滚动
if (state) {
bodyDOM.style.height = "100vh";
bodyDOM.style.overflow = "hidden";
init();
} else {
bodyDOM.style.height = "unset";
bodyDOM.style.overflow = "auto";
}
showMask.value = state;
};
class CreateImgStyle {
scale = 1;
left = "50%";
top = "50%";
translate = "";
}
// 放大缩放
const imgStyle = ref(new CreateImgStyle());
const handleWheel = (data) => {
const { deltaY } = data;
let { scale } = imgStyle.value;
if (deltaY > 0) {
scale -= 0.2;
if (scale < 0.2) scale = 0.2;
} else if (deltaY < 0) {
scale += 0.2;
}
imgStyle.value.scale = scale;
};
// 图片拖拽
class CreateStartPoint {
x = 0;
y = 0;
}
class CreateEndPoint {
x = document.body.clientWidth / 2;
y = document.body.clientHeight / 2;
}
const startPoint = ref(new CreateStartPoint());
const endPoint = ref(new CreateEndPoint());
const handleDragStart = (data) => {
const { clientX, clientY } = data;
startPoint.value.x = clientX;
startPoint.value.y = clientY;
};
const handleDragEnd = (data) => {
const { clientX, clientY } = data;
setPoint(clientX, clientY);
imgStyle.value.translate = "";
};
const handleDrag = (data) => {
const { clientX, clientY } = data;
const { x, y } = startPoint.value;
imgStyle.value.translate = `${clientX - x}px ${clientY - y}px`;
};
const setPoint = (clientX, clientY) => {
endPoint.value.x = endPoint.value.x - startPoint.value.x + clientX;
endPoint.value.y = endPoint.value.y - startPoint.value.y + clientY;
imgStyle.value.left = `${endPoint.value.x}px`;
imgStyle.value.top = `${endPoint.value.y}px`;
};
// 蒙层关闭,则初始化
const init = () => {
startPoint.value = new CreateStartPoint();
endPoint.value = new CreateEndPoint();
imgStyle.value = new CreateImgStyle();
};
// H5 点触
const initDistance = ref(0);
const handleTouchStart = (e) => {
// console.log("touchstart", e);
if (e.touches.length === 1) {
handleDragStart(e.touches[0]);
initDistance.value = 0;
}
if (e.touches.length === 2) {
// 两个触点,图片缩放
initDistance.value = getDistance(e.touches[0], e.touches[1]);
}
};
const handleTouchMove = (e) => {
e.preventDefault();
// console.log("touchmove", e);
if (e.touches.length === 1) {
handleDrag(e.touches[0]);
}
if (e.touches.length === 2) {
// 两个触点,图片缩放
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const scaleFactor = currentDistance / initDistance.value;
imgStyle.value.scale *= scaleFactor;
initDistance.value = currentDistance;
}
};
const handleTouchEnd = (e) => {
// console.log("touchend", e);
if (e.changedTouches.length === 1 && !initDistance.value) {
handleDragEnd(e.changedTouches[0]);
}
};
const getDistance = (touch1, touch2) => {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
};
// 判断运行环境
const isMobile = detectDeviceType();
onUpdated(() => {
const previewImgDOM = document.getElementById(props.imageUrl);
if (isMobile.value) {
// 监听点触事件
previewImgDOM.addEventListener("touchstart", handleTouchStart);
previewImgDOM.addEventListener("touchmove", handleTouchMove);
previewImgDOM.addEventListener("touchend", handleTouchEnd);
} else {
// 监听滚轮事件
previewImgDOM.addEventListener("wheel", handleWheel);
// 监听图片拖拽
previewImgDOM.addEventListener("dragstart", handleDragStart);
previewImgDOM.addEventListener("drag", handleDrag);
previewImgDOM.addEventListener("dragend", handleDragEnd);
previewImgDOM.addEventListener("dragover", (e) => e.preventDefault());
}
});
</script>
<style scoped>
.frame-img {
width: 100%;
margin-bottom: 20px;
}
.preview-img-mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
width: 100vw;
min-height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
}
.preview-img {
opacity: 1;
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
transform-origin: top left;
max-width: 80vw;
object-fit: contain;
}
.preview-img:hover {
cursor: grab;
}
.preview-img:active {
cursor: grabbing;
}
/* 使用媒体查询,去设置样式更加方便 */
@media screen and (max-width: 480px) {
.preview-img {
max-width: 95vw;
}
}
</style>