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>