Skip to content

VitePress 预览组件

前言

Vitepress 的使用过程中,我发现当图片本身较大或者较为模糊时,显示不够清楚。

Vitepress 文档中并没有相关的解决办法,于是我决定写一个预览组件来满足这个简单需求。

环境

node: v21.7.1

实现

需求解析

  • 弹出显示
  • 可缩放
  • 可拖拽

弹出显示

这个很简单,就是写一个蒙层放大图片即可,使用 <Teleport> 组件,传递蒙层节点挂载到 <body> 下,避免层级和定位问题。

当蒙层展示以后,蒙层下的网页依然可以滑动,所以需要对网页做设置,阻止其滑动,我这里选择的是固定网页高度,并禁止滚动。

html
<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 实现的居中,所以我的缩放中心点需要修改为左上角,默认缩放中心点为图片中心。

js
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 函数。

js
// 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 属性。

js
// 图片拖拽
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 端不一样,所以需要进行一些的适配,但是区别不大,主要是要区分触控点数量,来进行不同的操作。

因为返回值的数据结构大致一样,所以可以复用之前写的拖拽相关方法。

js
// 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 来实现了。

代码

html
<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>

Released under the MIT License.