图片预览-列表图片联动

206 阅读4分钟

基于vue3实现的图片预览功能,小图列表展示所有图片,切换当前预览列表滚动到可视区,滚轮滑动查看列表图片,键盘切换当前预览; 相关技术:vue3, element-plus, @element-plus/icons-vue, vue-lazyload 效果如下:

image.png

代码如下:

<script lang="ts" setup>
import { ref, onMounted } from "vue"
import defaultThumbnail from "@/assets/images/gray64.png"

const currentPreivewPic = ref(0)

const imageErrorList = ref([
 "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR_ENy58lE94vL4sKJxi22863djqbz4nMrf1Q&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQmwtS8uahm2WQs8CnEXE3ZpQZtsM2HFeuvQg&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTPSMsOf0WN_PQttn2O-8kMif2L4pv7aGkcZg&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTzGm9bLZ11p8cKDbHBopJcsMbV0Gx6-mvhSQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTzGm9bLZ11p8cKDbHBopJcsMbV0Gx6-mvhSQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQI8j8H1yJZUx7Y2p9d-nj8o7A2i29bj6pjbA&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR_ENy58lE94vL4sKJxi22863djqbz4nMrf1Q&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQmwtS8uahm2WQs8CnEXE3ZpQZtsM2HFeuvQg&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR-2VpmZhsrAgOHxVl1vDzWMZzXR3Sqt06tLQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTPSMsOf0WN_PQttn2O-8kMif2L4pv7aGkcZg&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTzGm9bLZ11p8cKDbHBopJcsMbV0Gx6-mvhSQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTzGm9bLZ11p8cKDbHBopJcsMbV0Gx6-mvhSQ&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQI8j8H1yJZUx7Y2p9d-nj8o7A2i29bj6pjbA&usqp=CAU",
  "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR9eZkD6dfj6ISTOnby9qpn_YGSqimpFW0l4A&usqp=CAU"
])

const scrollWrapRef = ref<any>(null)
const scrollListRef = ref<any>(null)
const preIndexRef = ref<any>(null)
const nexIndexRef = ref<any>(null)
const itemWidth = ref(222) // 每张图片的固定宽度

onMounted(() => {
  // 监听键盘事件
  document.addEventListener("keydown", handleKeyDown)
  handleElementToView(currentPreivewPic.value, true)
  return () => {
    document.removeEventListener("keydown", handleKeyDown)
  }
})

/*处理鼠标滚动事件*/
const handleScroll = (e: any) => {
  if (e.currentTarget.className === "img-list-wrap") {
    const maxScrollLeft = scrollListRef.value.offsetWidth - scrollWrapRef.value.offsetWidth
    const delta = e.deltaY
    const scrollDistance =
      delta < 0 ? scrollWrapRef.value.scrollLeft - itemWidth.value : scrollWrapRef.value.scrollLeft + itemWidth.value
    const targetDistance = scrollDistance > maxScrollLeft ? maxScrollLeft : scrollDistance < 0 ? 0 : scrollDistance
    scrollWrapRef.value.scrollLeft = targetDistance
  }
  return false
}

/*键盘事件*/
const handleKeyDown = (e: any) => {
  //键盘按键判断: 左箭头-37; 上箭头-38;右箭头-39;下箭头-40 PageUp-33; PageDown-34; Esc-27;
  const keyCode = e.keyCode
  if (keyCode === 37 || keyCode === 38 || keyCode === 33) {
    // 切换上一页
    handlePreIndex()
  } else if (keyCode === 39 || keyCode === 40 || keyCode === 34) {
    // 切换下一页
    handleNextIndex()
  } else if (keyCode === 27) {
    // Esc退出
  }
}

/*处理上一页逻辑*/
const handlePreIndex = () => {
  if (currentPreivewPic.value > 0) {
    const index = currentPreivewPic.value - 1
    currentPreivewPic.value = index
    handleElementToView(index)
  }
}

/*处理上一页逻辑*/
const handleNextIndex = () => {
  if (currentPreivewPic.value < imageErrorList.value.length - 1) {
    const index = currentPreivewPic.value + 1
    currentPreivewPic.value = index
    handleElementToView(index)
  }
}

/*更新当前预览图片*/
const changePreview = (index: number) => {
  if (index === currentPreivewPic.value) return
  currentPreivewPic.value = index
  handleElementToView(index)
}

/*判断元素是否在可视区内, 并让元素滚动到可视区*/
const handleElementToView = (index: number, isFirstRender = false) => {
  // 默认当前视图滚动到正中间
  setTimeout(() => {
    const wrapWidth = scrollWrapRef.value.offsetWidth
    const wrapScrollLeft = scrollWrapRef.value.scrollLeft
    // 只有 index >= 3 && index <= (store.imageErrorList.length - 3) 才可以移动到最中间的位置
    const centerNumber = Math.floor((wrapWidth / itemWidth.value) / 2)
    let centerIndex = index
    if (index >= centerNumber && index <= (imgCloudStore.imageErrorList.length - centerNumber)) centerIndex = index + centerNumber
    if (((wrapScrollLeft + wrapWidth - itemWidth.value) <= (itemWidth.value * centerIndex)) || 
      (wrapScrollLeft * (itemWidth.value * centerNumber) > (itemWidth.value * centerIndex))
    ) { // 图片需要移动
      const leftDis = itemWidth.value * (centerIndex + 1) - wrapWidth
      scrollWrapRef.value.scrollTo({
        left: leftDis >= 0 ? leftDis : 0,
        behavior: !isFirstRender ? "smooth" : 'auto'
      })
    }
  }, 0)
  
  // 默认只滚动到可视区,不居中展示
  // setTimeout(() => {
  //   const wrapWidth = scrollWrapRef.value.offsetWidth
  //   const wrapScrollLeft = scrollWrapRef.value.scrollLeft
  //   if (wrapScrollLeft > itemWidth.value * index) {
  //     // 图片隐藏在左边
  //     scrollWrapRef.value.scrollTo({
  //       left: itemWidth.value * index,
  //       behavior: !isFirstRender ? "smooth" : "auto"
  //     })
  //   } else if (wrapScrollLeft + wrapWidth - itemWidth.value < itemWidth.value * index) {
  //     // 图片隐藏在右边
  //     scrollWrapRef.value.scrollTo({
  //       left: itemWidth.value * (index + 1) - wrapWidth,
  //       behavior: !isFirstRender ? "smooth" : "auto"
  //     })
  //   }
  // }, 0)
}

/*处理图片错误*/
const handleImgError = (e: any) => {
  e.target.src = null
  e.target.src = defaultThumbnail
}
</script>

<template>
  <div class="image-preview-wrap">
    <div class="preview-main-content">
      <div class="preview-header">
        <div class="title">图片预览</div>
        <div class="operator-btn">
          <el-icon
            :style="{
              fontSize: '25px',
              color: 'eaeaea',
              width: '50px',
              height: '50px',
              display: 'flex',
              justifyContent: 'center',
              alignItems: 'center',
              cursor: 'pointer'
            }"
            ><Close
          /></el-icon>
        </div>
      </div>
      <div class="preview-content">
        <div class="img-main">
          <!-- 预览大图 -->
          <div class="large-wrap">
            <!-- <div v-if="isFetching" class="loading-mask-large" v-loading="isFetching" /> -->
            <div class="page-info">{{ currentPreivewPic + 1 + " / " + imageErrorList.length }}</div>
            <el-icon ref="preIndexRef" @click="handlePreIndex" class="pre-index-btn index-btn"
              ><ArrowLeftBold
            /></el-icon>
            <el-icon ref="nexIndexRef" @click="handleNextIndex" class="next-index-btn index-btn"
              ><ArrowRightBold
            /></el-icon>
            <img :src="imageErrorList[currentPreivewPic]" @error="(e: any) => handleImgError(e)" alt="" />
          </div>
          <!-- 小图列表 -->
          <div class="img-list-wrap" ref="scrollWrapRef" @wheel="(e: any) => handleScroll(e)">
            <!-- <div v-if="isFetching" class="loading-mask-large" v-loading="isFetching" /> -->
            <ul
              class="img-list"
              ref="scrollListRef"
              draggable="false"
              :style="{ width: itemWidth * imageErrorList.length + 'px' }"
            >
              <li 
                v-for="(item, index) in imageErrorList"
                :key="index"
                @click="changePreview(index)"
                :class="['img-item', index === currentPreivewPic ? 'active-item ' : '']"
              >
                <img v-lazy="item" src="@/assets/images/gray64.png" alt="" />
              </li>
            </ul>
          </div>
        </div>

        <div class="img-detail">
          <div class="detail-title">基本信息, 展示当前预览的详细信息</div>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
// 图片预览组件单独封装
.image-preview-wrap {
  position: fixed;
  top: 0px;
  left: 0px;
  bottom: 0px;
  right: 0px;
  background-color: rgba(0, 0, 0, 0.8);
  z-index: 2000;
  padding: 10px;
  .preview-main-content {
    width: 100%;
    height: 100%;
    border-radius: 4px;
    box-shadow: 1px 2px 1px #eaeaea;
    overflow: hidden;
    background-color: #ffffff;
    .preview-header {
      width: 100%;
      height: 70px;
      padding: 10px 15px 10px 20px;
      display: flex;
      justify-content: space-between;
      align-items: center;
      box-shadow: 1px 1px 1px #eaeaea;
      .title {
        font-size: 20px;
      }
      .operator-btn {
        display: flex;
        justify-content: flex-end;
        align-items: center;
      }
    }
    .preview-content {
      height: calc(100% - 70px);
      width: 100%;
      display: flex;
      justify-content: space-between;
      padding: 2px 10px 20px 10px;
      .img-main {
        width: 75%;
        height: 100%;
        .large-wrap {
          width: 100%;
          height: calc(100% - 150px);
          background-color: rgb(0, 0, 0, 0.8);
          position: relative;
          user-select: none;
          .page-info {
            position: absolute;
            top: 10px;
            left: 50%;
            transform: translateX(-50%);
            color: #ffffff;
            font-size: 20px;
            width: 150px;
            height: 40px;
            border-radius: 20px;
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: rgba(0, 0, 0, 0.8);
          }
          .index-btn {
            position: absolute;
            z-index: 10;
            color: #eaeaea;
            width: 50px;
            height: 50px;
            display: flex;
            justify-content: center;
            align-items: center;
            border-radius: 50%;
            cursor: pointer;
            background: rgba(0, 0, 0, 0.2);
            svg {
              font-size: 30px;
            }
            &.pre-index-btn {
              top: 50%;
              left: 10px;
              transform: translateY(-50%);
            }
            &.next-index-btn {
              top: 50%;
              right: 10px;
              transform: translateY(-50%);
            }
          }
          img {
            width: 100%;
            height: 100%;
            object-fit: contain;
            opacity: 1;
          }
        }

        .img-list-wrap {
          width: 100%;
          height: 130px;
          overflow-x: auto;
          white-space: nowrap;
          overflow-y: hidden;
          position: relative;
          margin-top: 15px;
          &::-webkit-scrollbar {
            display: none;
          }
          .img-list {
            height: 130px;
            padding: 0;
            margin: 0;
            cursor: pointer;
            .img-item {
              width: 222px;
              height: 130px;
              display: inline-block;
              line-height: 130px;
              padding: 0px;
              cursor: pointer;
              &.active-item {
                margin-top: -5px;
                img {
                  box-sizing: border-box;
                  opacity: 1;
                  width: 222px;
                  height: 130px;
                  padding: 0px;
                  border: 3px solid #ffc353;
                  transform-origin: center;
                  transform: scale(0.98);
                  box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);
                }
              }
              img {
                display: block;
                width: 222px;
                height: 130px;
                padding: 2px;
                opacity: 0.8;
                &:hover {
                  box-sizing: border-box;
                  opacity: 1;
                  width: 222px;
                  height: 130px;
                  padding: 0px;
                  border: 3px solid #fc8746;
                  transform-origin: center;
                  transform: scale(0.98);
                  box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);
                }
              }
            }
          }
        }
        .loading-mask-large {
          position: absolute;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          display: flex;
          justify-content: center;
          align-items: center;
        }
      }
      .img-detail {
        width: 25%;
        height: 100%;
        padding: 10px 20px;
        .detail-title {
          font-size: 20px;
        }
        .detail-item {
          width: 100%;
          font-size: 18px;
          margin: 10px 0px;
          span {
            margin-right: 5px;
          }
        }
      }
    }
  }
}
</style>

在main中需要引入vue-lazyload

import VueLazyload from "vue-lazyload"

app.use(VueLazyload, {
  loading: defaultThumbnail,
  error: defaultThumbnail,
  dispatchEvent: true,
  attempt: 1
})

app.use(VueLazyload)