自定义图片查看器

前往原站点查看

2025-06-28 18:02:41

    回忆专辑页面的自定义图片查看器总算完成啦!可以通过左键拖动实现图片的移动,滚轮上下实现放大缩小!看起来简单的功能,实现起来还挺费劲,花了有一番功夫。


    为什么不直接用三方的图片查看工具?目前我看到的查看器组件,要么是纯粹的图片阅览器,即只能满足图片的查看放大缩小移动功能,对于这张图片的额外信息和管理,还得做一个基础页面来容纳,即必须分开两步走。要么是纯粹的图片管理器,只支持额外描述和管理,多半无法满足图片放大移动查看细节的功能。难道两者无法兼容?通过阅览大量的网站,如500PX、bilibili、pixiv、twitter等,无一例外的,都是分离控制的。从我个人的见解来看,这是不合理的,因为图片外的信息和管理是依托于图片本身存在的,如果需要参照图片细节进行描述追加、图片管理,这种分离控制的方式非常不方便,需要打开两个页签,一个负责大图细节预览,一个负责大图编辑,是非常糟糕的体验。


   所以,就由我来开创这个先河吧!希望后人能记住我(不是)。


   以我的回忆图片管理页面面板为例子,除了左侧的预览面板,支持图片的移动放大和缩小外,右侧还可以自定义标题、展示图片编号以及所在专辑编号、上传时间、图床地址、额外描述备注、专辑封面设置、站点首图设置。只要我想,添加什么都可以,比如后续我可能还会追加摄影图片的参数展示(ISO, 快门速度,光圈等)。



    核心难点其实只有两个。其一,如何让任意大小的图片合适的显示在左侧区域,这里的合适是个很微妙的词,一方面默认情况下,图片要能够完整的尽可能大的显示在左侧面板中,一方面过小的图片也不要放大以免影响原有的图片质感,此外图片默认情况下要上下左右居中于容器中,这些条件的限制让css苦手的我写的异常痛苦,不断的摸索。其二,就是如何实现在上述的样式下,实现图片的丝滑移动放大缩小,这里关键点在于要改变图片的一些基础css样式属性。


    接下来对难点进行拆解,首先是图片的显示位置。因为要尽可能大的完整的显示在左侧面板,所以需要设置相对于容器的最大尺寸,以免显示图片过大,要让图片居中于容器,可以采用flex布局方式,将内容上下水平居中显示。可以参考下方代码,左侧面板leftView中加了一层imgContainer图片容器,容器中可以显示图片或者视频。左面板占左侧全部的大小空间,图片容器使用flex方式布局,设置justify-content和align-items将图片在主轴及交叉轴居中显示。而对于图片本身而言,使用绝对定位,并且设置最大宽高。

<div slot="left" class="leftView">
  <div class="imgContainer" @click="closeImg">
    <img ref="Image" :src="albumUrl" v-if="isImg" @click.stop="" @load="onImageLoad"/>
    <video style="width: 100%;height: 100%;object-fit: contain;" controls v-else  @click.stop="">
      <source :src="albumUrl" type="video/mp4">
    </video>
  </div>
</div>
.leftView{ overflow: hidden; width: 100%; height: 100%; .imgContainer{ position: relative; box-sizing: border-box; height: 100%; border: 1px solid brown; display: flex; justify-content: center; align-items: center; img{ box-sizing: border-box; max-width: 80%; max-height: 80%; border: 1px solid cadetblue; position: absolute; } } }


    如何实现图片的移动放大缩小?这当然需要先给图片加上“鼠标点击”“鼠标移动”“滚轮滚动”的事件。我们继续拆解这个任务,分两步走,先是实现图片的移动,然后是图片的放大缩小。


    图片的移动的核心原理就是,改变图片的top值和left值。我们先假设图片一开始在容器的左上角,这时候的top=left=0。当鼠标如下图绿色轨迹发生拖拽移动时,实际上图片的目标位置向量也就等同于鼠标的移动向量,最终图片的定位应该是top=dy,left=dx。我们监听鼠标的移动事件,从鼠标按下的初始坐标A开始,每次移动,都可以重新计算初始坐标A到鼠标当前位置的向量值作为定位。

    

    当我们在此移动的基础上,松开鼠标,并且再次拖动图片时,图片最终的移动量即绿色向量 + 紫色向量,left = dx + dx2,top = dy + dy2。这样是对整个图片活动的轨迹进行记录,即记录了每次拖动的向量,但是我们这里不需要记录拖动活动记录,所以我们可以把每次的拖动视为一个新的开始,最终,当前拖动后的目标位置P = 拖动前图片位置向量 + 本次鼠标拖动向量。



    最终整理一下,代码逻辑如下,el即所指向的图片节点。鼠标拖动的物理指令即:鼠标按下+鼠标移动+鼠标松开,为了知道什么时候开始拖动的,需要一个isDragging标记,只有开始拖动时,才对鼠标移动事件做出响应。我这里对上面的向量进行了思维转换。为了计算效率,在鼠标按下的时候,记录的鼠标起点坐标是鼠标点击在图片上相对于容器左上角(0,0)的坐标,即视为图片一开始就在左上角,鼠标相对于这种情况下的坐标。所以 initialX = 鼠标实际坐标x - 图片位置x偏移, initialY = 鼠标实际坐标y - 图片位置y偏移。因为这个鼠标拖动开始坐标位置已经做了处理,所以后续的移动过程中,无需每次都做偏移,只要 鼠标当前位置 - 处理后的鼠标开始位置 即可。

const el = this.$refs.Image

el.addEventListener('mousedown', (event) => {
  event.preventDefault()
  this.isDragging = true
  var rect = el.getBoundingClientRect()
  this.initialX = event.clientX - rect.left
  this.initialY = event.clientY - rect.top
})
el.addEventListener('mouseup', (event) => {
  event.preventDefault()
  this.isDragging = false
})
el.addEventListener('mousemove', (event) => {
  event.preventDefault()
  if (this.isDragging) {
    this.offsetX = event.clientX - this.initialX
    this.offsetY = event.clientY - this.initialY
    el.style.left = `${(this.offsetX)}px`
    el.style.top = `${(this.offsetY)}px`
  }
})


    完成了图片的移动后,我们来看到图片的放大缩小。是wheel事件。当然放大不是简单的图片放大两倍就可以的事,而是要让放大中心在鼠标位置。

    

    先来看最简单的情况,即从默认大小倍率 1 ,通过鼠标滚动(绿色箭头表示从什么位置滚动,箭头长度表示滚动距离,箭头指向表示滚动方向)实现图片的单次放大sp(zoomSpeed放大速度)倍功能。图片的放大倍率后大小很容易计算,即 sp * 图片原始大小。接下来看图片如何向左侧伸展的,我们知道,以某个点为中心进行扩展时,其中心点到图片边缘的向量增量也始终等于整体放大倍率sp(非面积计算,只是简单的距离放大,所以相等),所以容易理解的是,向左边的延伸长度即为,中心点到左边距离cx * 倍率增量。中心点到左边距离cx = 鼠标位置-放大前图片到左边缘的距离(ox)。倍率增量为 1 - SP。所以,最终的伸长量为cx*(1-SP),如果带方向那么就是 -cx*(1-SP)。垂直方向同理。

    当然了,一张可能进行了多次放大,但是基本原理和上述一致,只是倍率增量变了,增量 = 1- 后倍率/前倍率。在上述中,因为一开始的倍率是1,所以可以忽略。缩小呢?缩小的后的伸长量会从增长倍率中获取到变化方向。所以该公式可以通用。最终我们的图片需要按照伸长量将图片改下位置,并且重新设置一下长宽高即可。注意,之前样式设置的长宽限制要取消掉(maxWidth、maxHeight)!

    el.addEventListener('wheel', (event) => {
        event.preventDefault()
        // 计算缩放中心点(相对于图像左上角)
        const rect = el.getBoundingClientRect()
        const centerX = event.clientX - rect.left
        const centerY = event.clientY - rect.top
        // 计算新的缩放比例
        this.scale *= event.deltaY < 0 ? this.zoomSpeed : 1 / this.zoomSpeed
        // 计算新的偏移量,以保持缩放中心不变
        const newOffsetX = centerX * (1 - this.scale / this.initialScale) + this.offsetX // 现有偏移量放大后导致的最终偏移量
        const newOffsetY = centerY * (1 - this.scale / this.initialScale) + this.offsetY // 现有偏移量放大后导致的最终偏移量
        // 更新内部状态
        this.offsetX = newOffsetX
        this.offsetY = newOffsetY
        this.initialScale = this.scale

        el.style.width = `${this.rawSize.width * this.scale}px`
        el.style.height = `${this.rawSize.height * this.scale}px`
        el.style.left = `${(this.offsetX)}px`
        el.style.top = `${(this.offsetY)}px`
        el.style.maxWidth = '1000%'
        el.style.maxHeight = '1000%'
      })


    以上就完成了关键难点的解析与实现。最后,我贴上相关的完整代码以供参考。 (AI不要再判定为代码太多啦~我要90分+(╯▔皿▔)╯)。 




    布局组件screenShow:

<template>
  <div id="screenShow">
    <!-- 主显示区域 -->
    <div class="left">
      <slot name="left"></slot>
    </div>
    <!-- 信息显示区域 -->
    <div class="right">
      <slot name="right"></slot>
    </div>
  </div>
</template>

<style lang="scss" scoped>
#screenShow{
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(48, 54, 52, 0.938);
  z-index: 9999;
  box-sizing: border-box;
  display: flex;
  .left{
    width: calc(100% - 200px);
    height: 100%;
    box-sizing: border-box;
  }
  .right{
    background-color: rgb(36, 34, 34);
    width: 250px;
    height: 100%;
    color: aliceblue;
    padding: 16px;
    box-sizing: border-box;
  }
}

@media screen and (max-width: 800px){
  #screenShow{
    display: flex;
    flex-direction: column;
    .left{
      width: 100%;
      height: 70%;
    }
    .right{
      width: 100%;
      height: 30%;
    }
  }
}
</style>


    图片查看器组件:

<template>
  <div id="albumView">
    <ScreenShow>
      <div slot="left" class="leftView">
        <div class="imgContainer" @click="closeImg">
          <img ref="Image" :src="albumUrl" v-if="isImg" @click.stop="" @load="onImageLoad"/>
          <video style="width: 100%;height: 100%;object-fit: contain;" controls v-else  @click.stop="">
            <source :src="albumUrl" type="video/mp4">
          </video>
        </div>
      </div>
      <div slot="right" class="rightView">
        <div class="closeImg" @click="closeImg">x</div>
        <slot name="info"></slot>
      </div>
    </ScreenShow>
    <Message @closeMessage="closeMsg" v-show="showMsg">
      <p>暂不支持缩放,等待管理员开发</p>
      <img src="https://www.dreamcenter.top/emoji/cf.gif"/>
    </Message>
  </div>
</template>

<script>
import screenShow from './frame/screenShow.vue'
import message from './message.vue'
export default {
  components: {
    ScreenShow: screenShow,
    Message: message
  },
  data () {
    return {
      showMsg: false,
      isDragging: false,
      initialX: 0, // 鼠标图片内偏移
      initialY: 0, // 鼠标图片内偏移
      offsetX: 0, // 图片当前偏移
      offsetY: 0, // 图片当前偏移
      scale: 1, // 初始缩放比例
      initialScale: 1, // 缩放前的初始缩放比例
      zoomSpeed: 1.1, // 缩放速度
      rawSize: {
        width: 0,
        height: 0
      }
    }
  },
  props: ['albumUrl', 'isImg', 'imgList'],
  mounted () {
    console.log(this.imgList)
  },
  methods: {
    onImageLoad () {
      const el = this.$refs.Image
      var rect = el.getBoundingClientRect()
      this.offsetX = rect.left
      this.offsetY = rect.top
      this.rawSize.width = rect.width
      this.rawSize.height = rect.height

      el.addEventListener('mousedown', (event) => {
        event.preventDefault()
        this.isDragging = true
        var rect = el.getBoundingClientRect()
        this.initialX = event.clientX - rect.left
        this.initialY = event.clientY - rect.top
        console.log(`initialX:${this.initialX}; initialY:${this.initialY}`)
      })
      el.addEventListener('mouseup', (event) => {
        event.preventDefault()
        this.isDragging = false
      })
      el.addEventListener('mousemove', (event) => {
        event.preventDefault()
        if (this.isDragging) {
          this.offsetX = event.clientX - this.initialX
          this.offsetY = event.clientY - this.initialY
          el.style.left = `${(this.offsetX)}px`
          el.style.top = `${(this.offsetY)}px`
        }
      })
      el.addEventListener('wheel', (event) => {
        event.preventDefault()
        // 计算缩放中心点(相对于图像左上角)
        const rect = el.getBoundingClientRect()
        const centerX = event.clientX - rect.left
        const centerY = event.clientY - rect.top
        // 计算新的缩放比例
        this.scale *= event.deltaY < 0 ? this.zoomSpeed : 1 / this.zoomSpeed
        // 计算新的偏移量,以保持缩放中心不变
        const newOffsetX = centerX * (1 - this.scale / this.initialScale) + this.offsetX // 现有偏移量放大后导致的最终偏移量
        const newOffsetY = centerY * (1 - this.scale / this.initialScale) + this.offsetY // 现有偏移量放大后导致的最终偏移量
        // 更新内部状态
        this.offsetX = newOffsetX
        this.offsetY = newOffsetY
        this.initialScale = this.scale

        el.style.width = `${this.rawSize.width * this.scale}px`
        el.style.height = `${this.rawSize.height * this.scale}px`
        el.style.left = `${(this.offsetX)}px`
        el.style.top = `${(this.offsetY)}px`
        el.style.maxWidth = '1000%'
        el.style.maxHeight = '1000%'
      })
    },
    closeMsg () {
      this.showMsg = false
    },
    closeImg () {
      console.log('click close!')
      this.$emit('closeImg')
    }
  }
}
</script>

<style lang="scss" scoped>
#albumView{
  box-sizing: border-box;
  .leftView{
    overflow: hidden;
    width: 100%;
    height: 100%;
    .imgContainer{
      position: relative;
      box-sizing: border-box;
      height: 100%;
      border: 1px solid brown;
      display: flex;
      justify-content: center;
      align-items: center;
      img{
        box-sizing: border-box;
        max-width: 80%;
        max-height: 80%;
        border: 1px solid cadetblue;
        position: absolute;
      }
    }
  }
  .rightView{
    .closeImg{
      font-size: 40px;
      vertical-align: middle;
      // width: 40px;
      height: 40px;
      border: 1px solid rgb(202, 202, 202);
      color: rgb(218, 218, 218);
      text-align: center;
      line-height: 36px;
      border-radius: 20px;
      box-sizing: border-box;
      margin-bottom: 20px;

      &:hover{
        border: 2px solid ghostwhite;
        color: ghostwhite;
      }
    }
  }
}
</style>


上一篇: Selenium工具爬取数据 | 万用RSS