VUE 的弹出框实现图片预览和视频预览

这是一个基于Vue3封装的媒体预览组件,主要功能包括:

  1. 多格式支持:可同时预览图片和视频
  2. 图片操作功能
    • 缩放(支持滚轮缩放和按钮控制)
    • 旋转(90度增量旋转)
    • 拖拽(仅在放大状态下可用)
  3. 自适应显示:图片自动适应容器大小
  4. 响应式设计:使用Element UI的Dialog作为容器

组件特点:

  • 通过计算属性动态计算图片样式
  • 使用requestAnimationFrame优化拖拽性能
  • 支持图片加载后自动调整方向
  • 提供视频播放控制功能

该组件封装了完整的交互逻辑,可方便地集成到项目中实现媒体预览功能。

下面是实现代码:

javascript 复制代码
<template>
  <el-dialog v-model="visible" width="1184px" class="preview-dialog" close align-center>
    <template v-if="!isVideoPreview" #footer>
      <div class="preview-dialog-footer">
        <el-button type="text" @click="zoomOut" class="zoom-button">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="20"
            height="20"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          >
            <circle cx="11" cy="11" r="8"></circle>
            <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
            <line x1="8" y1="11" x2="14" y2="11"></line>
          </svg>
        </el-button>
        <el-button type="text" @click="zoomIn" class="zoom-button">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="20"
            height="20"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
          >
            <circle cx="11" cy="11" r="8"></circle>
            <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
            <line x1="11" y1="8" x2="11" y2="14"></line>
            <line x1="8" y1="11" x2="14" y2="11"></line>
          </svg>
        </el-button>
        <el-button type="text" @click="rotateImage(90)" class="rotate-button">
          <img :src="Rotate" />
        </el-button>
      </div>
    </template>

    <div class="preview-content" @wheel="handleWheel">
      <img
        v-if="!isVideoPreview"
        :src="previewUrl"
        @load="onImageLoad"
        :style="imageStyle"
        ref="previewImage"
        @mousedown="startDrag"
        @mousemove="onDrag"
        @mouseup="endDrag"
        @mouseleave="endDrag"
      />
      <video v-if="isVideoPreview" :src="previewUrl" class="media-video" controls autoplay></video>
    </div>
  </el-dialog>
</template>

<script setup>
  import { ref, computed, watch } from 'vue';
  import Rotate from '@/assets/home/icon/rotate.svg';

  const props = defineProps({
    modelValue: Boolean,
    previewUrl: {
      type: String,
      default: ''
    },
    isVideoPreview: Boolean
  });

  const emit = defineEmits(['update:modelValue']);

  const visible = ref(props.modelValue);
  watch(
    () => props.modelValue,
    (newVal) => {
      visible.value = newVal;
    }
  );

  watch(visible, (val) => {
    emit('update:modelValue', val);
  });

  const imageRotation = ref(0);
  const previewImage = ref(null);
  const dialogWidth = 1184;
  const dialogHeight = 648;
  const zoomLevel = ref(1);

  // 缩放限制
  const minZoom = 0.1;
  const maxZoom = 5;

  // 拖拽相关变量
  const isDragging = ref(false);
  const dragStartX = ref(0);
  const dragStartY = ref(0);
  const imageStartLeft = ref(0);
  const imageStartTop = ref(0);
  const imageLeft = ref(0);
  const imageTop = ref(0);
  const rafId = ref(0);

  const zoomIn = () => {
    if (zoomLevel.value < maxZoom) {
      zoomLevel.value = Math.min(zoomLevel.value + 0.1, maxZoom);
    }
  };

  const zoomOut = () => {
    if (zoomLevel.value > minZoom) {
      zoomLevel.value = Math.max(zoomLevel.value - 0.1, minZoom);
    }
  };

  const handleWheel = (event) => {
    event.preventDefault();
    if (event.deltaY < 0) {
      zoomIn();
    } else {
      zoomOut();
    }
  };

  const startDrag = (event) => {
    if (zoomLevel.value <= 1) return; // 只有在放大时才能拖拽

    isDragging.value = true;
    dragStartX.value = event.clientX;
    dragStartY.value = event.clientY;
    imageStartLeft.value = imageLeft.value;
    imageStartTop.value = imageTop.value;
    if (previewImage.value) {
      previewImage.value.style.cursor = 'grabbing';
    }
    // 阻止默认行为,防止图片被选中
    event.preventDefault();
  };

  const onDrag = (event) => {
    if (!isDragging.value || zoomLevel.value <= 1) return;

    // 使用 requestAnimationFrame 优化性能
    if (rafId.value) {
      cancelAnimationFrame(rafId.value);
    }

    rafId.value = requestAnimationFrame(() => {
      const deltaX = event.clientX - dragStartX.value;
      const deltaY = event.clientY - dragStartY.value;

      imageLeft.value = imageStartLeft.value + deltaX;
      imageTop.value = imageStartTop.value + deltaY;
      rafId.value = 0;
    });

    // 阻止默认行为
    event.preventDefault();
  };

  const endDrag = () => {
    isDragging.value = false;
    if (rafId.value) {
      cancelAnimationFrame(rafId.value);
      rafId.value = 0;
    }
    if (previewImage.value) {
      previewImage.value.style.cursor = 'grab';
    }
  };

  const rotateImage = (degree) => {
    console.log('翻转', degree, (imageRotation.value + degree) % 360);
    imageRotation.value += degree;
    // zoomIn();
    // 旋转时重置缩放级别以避免布局问题
    zoomLevel.value = 1;
    // 重置拖拽位置
    imageLeft.value = 0;
    imageTop.value = 0;
  };

  const imageDimensions = computed(() => {
    if (!previewImage.value) return { width: 0, height: 0 };

    const img = previewImage.value;
    const naturalWidth = img.naturalWidth;
    const naturalHeight = img.naturalHeight;

    const isRotated = imageRotation.value % 180 !== 0;
    const displayWidth = isRotated ? naturalHeight : naturalWidth;
    const displayHeight = isRotated ? naturalWidth : naturalHeight;

    return { width: displayWidth, height: displayHeight };
  });

  const imageStyle = computed(() => {
    if (!previewImage.value) return {};

    const { width: displayWidth, height: displayHeight } = imageDimensions.value;

    // 计算基础缩放比例,确保图片适应容器
    const baseScale = Math.min(dialogWidth / displayWidth, dialogHeight / displayHeight);

    // 应用用户缩放级别
    const finalScale = baseScale * zoomLevel.value;

    // 计算缩放后的尺寸
    const scaledWidth = displayWidth * finalScale;
    const scaledHeight = displayHeight * finalScale;

    // 居中定位
    const left = (dialogWidth - scaledWidth) / 2 + imageLeft.value;
    const top = (dialogHeight - scaledHeight) / 2 + imageTop.value;

    return {
      position: 'absolute',
      left: `${left}px`,
      top: `${top}px`,
      width: `${scaledWidth}px`,
      height: `${scaledHeight}px`,
      transform: `rotate(${imageRotation.value}deg)`,
      transformOrigin: 'center center',
      cursor: zoomLevel.value > 1 ? 'grab' : 'default'
    };
  });

  const onImageLoad = () => {
    // 重置旋转和缩放
    imageRotation.value = 0;
    zoomLevel.value = 1;
    imageLeft.value = 0;
    imageTop.value = 0;

    for (let index = 0; index < 4; index++) {
      console.log('执行第几次', index + 1);
      rotateImage(90); //执行四次 可以让图片以合适的宽度呈现
    }
    // 可选:调试用
    // console.log('Image loaded:', previewImage.value.naturalWidth, previewImage.value.naturalHeight);
  };
</script>

<style lang="scss" scoped>
  .preview-dialog {
    :deep(.el-dialog) {
      height: 648px;
      display: flex;
      flex-direction: column;
    }

    :deep(.el-dialog__body) {
      flex: 1;
      overflow: hidden !important;
      text-align: center;
      padding: 0;
      position: relative;
    }

    .preview-dialog-footer {
      display: flex;
      justify-content: center;
      align-items: center;
    }

    .rotate-button {
      font-size: 20px;
      padding: 10px;
    }

    .preview-content {
      width: 1184px;
      height: 648px;
      display: flex;
      justify-content: center;
      align-items: center;
      overflow: hidden;
      position: relative;

      img {
        max-width: none;
        max-height: none;
        object-fit: contain;
        user-select: none;
        // 添加硬件加速
        transform: translateZ(0);
        backface-visibility: hidden;
        perspective: 1000px;
      }

      .media-video {
        max-width: 100%;
        max-height: 100%;
        object-fit: contain;
      }
    }
  }
</style>