需求场景与实现效果
需求背景: 实现PPT文件的双栏预览功能,左侧为缩略图导航栏,右侧为主内容区域,要求实现以下功能:
- 右侧滚动时左侧同步高亮对应缩略图
- 点击左侧缩略图精准定位右侧内容
- 动态视窗提示框展示可视区域比例
最终效果:
布局大致如下
<div class="ppt-viewer">
// 左侧缩略图
<div class="ppt-sidebar" ref="pptSidebar" >
<ul>
<li class="slide-img-wrap" v-for="(slide, index) in slides" :key="index">
<div class="img-wrap" @click.stop="scrollToSlide(slide)" ref="thumbnail">
<img :src="slide.picUrl" draggable="false" />
</div>
</li>
</ul>
</div>
// 右侧容器
<div class="ppt-main" ref="pptMain">
<div v-for="(slide, index) in slides"
:key="index"
:ref="`slide_${slide.id}`"
class="ppt-slide"
>
<img :src="slide.picUrl" ref="img_slides" draggable="false" />
</div>
</div>
</div>
样式就省略了,重点来看下左右联动以及动态视窗是如何实现的;
双向联动机制
右侧滚动 → 左侧高亮
- 给右侧容器绑定滚动事件,滚动的过程中去判断容器内当前展示的是第几张的PPT;得到index, 通过index获取左侧的目标缩略图 判断是否在窗口内;
生命周期绑定滚动事件
mounted() {
this.pptMain = this.$refs.pptMain
this.pptMain.addEventListener('scroll', this.onScroll)
},
beforeDestroy() {
this.pptMain.removeEventListener('scroll', this.onScroll)
}
onScroll() {
const slides = this.$refs.img_slides //PPT内容
slides.some((slide, index) => {
const rect = slide.getBoundingClientRect()
const pptMainRect = this.pptMain.getBoundingClientRect()
//PPT在容器内的可视区域计算
const visibleHeight = Math.min(rect.bottom, pptMainRect.bottom) - Math.max(rect.top, pptMainRect.top)
//获取 可见区域占比
let visibleBoxRatio = visibleHeight / pptMainRect.height
// 幻灯片超过50%可见时
if (visibleBoxRatio > 0.5) {
// 记住当前展示的的是哪一张PPT
this.activeSlideIndex = index
// 停止遍历
return true
}
})
}
Math.min(rect.bottom, pptMainRect.bottom) - Math.max(rect.top, pptMainRect.top)
这句不好理解的话 可以看这张图
通过watch监听activeSlideIndex得变化调用 highlightActiveThumbnail方法
highlightActiveThumbnail() {
// 缩略图容器
const sidebar = this.$refs.pptSidebar
// 缩略图
const thumbnails = this.$refs.thumbnail
const activeThumbnail = thumbnails[this.activeSlideIndex]
if (activeThumbnail) {
const rect = activeThumbnail.getBoundingClientRect()
const sidebarRect = sidebar.getBoundingClientRect()
// 检查缩略图是否在视口中
if (rect.top < sidebarRect.top || rect.bottom > sidebarRect.bottom) {
// 如果不在视口中,滚动左侧栏
sidebar.scrollTop += rect.top - sidebarRect.top - 20 // 少滚动20px 避免贴上边
}
}
}
点击缩略图,右侧容器滚动
- 给右侧图片绑定ref,左侧点击缩略图获取到id,然后通过ref获取到目标元素的offsetTop
scrollToSlide({ id }) {
const refKey = `slide_${id}`
const slide = this.$refs[refKey][0]
const targetPosition = slide.offsetTop - 20
this.isScrollingByClick = true // 设置标志位为 true
this.scrollToTop(this.pptMain, targetPosition, 500)
setTimeout(() => {
this.isScrollingByClick = false // 设置标志位为 false
}, 550)
}
需要注意的是,这段代码增加字段 isScrollingByClick用来记录右侧滚动是否由左侧触发;由左边点击触发的右侧内容滚动,应当阻止 onScroll() 方法调用;
onScroll() {
// 如果是点击触发的滚动,直接返回,不执行后续逻辑
if (this.isScrollingByClick) {
return
}
......
}
缩略图动态视窗
如下图所示,红框内缩略图高亮的部分展示的内容,正是PPT页面当前看到的区域,
所以要先计算出PPT页面当前能看的区域高度,算出占比,再把比例作用到缩略图上设置高亮框的高度;
PPT可视比例 =可视高度/ PPT原始高度
缩略图高亮框的高度 =PPT可视比例*缩略图高度
计算可视比例时的四种情况:
- PPT单页的
仅上边出现在可视区域内 - PPT单页的
仅下边出现在可视区域内 - PPT单页的
上下边均出现在可视区域内 - PPT单页的
上下边均未出现在可视区域内,但部分内容可视,
前两种情况,当PPT未完全出现在可视区域内,还要有灰色的遮罩,如图:
随着右侧内容的滚动,蓝色展示框高度会随着减小/变大,灰色遮罩高度与之相反;
第四种情况,当PPT中间部分出现在可视区域内,应当有上下两块遮罩,如图:
遮罩高度比例计算 (pptMainRect.top - rect.top) / slideHeight
<div class="ppt-sidebar" ref="pptSidebar" >
<ul>
<li class="slide-img-wrap" v-for="(slide, index) in slides" :key="index">
<div class="img-wrap" @click.stop="scrollToSlide(slide, $event)" ref="thumbnail" :data-oss-id="slide.id">
<img :src="slide.picUrl" draggable="false" />
<!-- 上遮罩 -->
<div class="slide-progress-overlay"></div>
<!-- 下遮罩 -->
<div class="slide-progress-overlay1"></div>
<!-- 高亮蓝色边框区域 -->
<div class="slide-progress-border"></div>
</div>
</li>
</ul>
</div>
onScroll() {
if (this.isScrollingByClick || this.hiddenSide) {
return // 如果是点击触发的滚动,直接返回,不执行后续逻辑
}
const slides = this.$refs.img_slides
const thumbnails = this.$refs.thumbnail
slides.some((slide, index) => {
const rect = slide.getBoundingClientRect()
const pptMainRect = this.pptMain.getBoundingClientRect()
// 幻灯片总高度
const slideHeight = rect.height
// 获取PPT在容器内的可见高度
const visibleHeight = Math.min(rect.bottom, pptMainRect.bottom) - Math.max(rect.top, pptMainRect.top)
let visibleBoxRatio = visibleHeight / pptMainRect.height
// 幻灯片超过50%可见时
if (visibleBoxRatio > 0.5) {
this.activeSlideIndex = index
// 动态设置遮罩和边框
const activeThumbnail = thumbnails[index]
const progressOverlay = activeThumbnail.querySelector('.slide-progress-overlay')
const progressBorder = activeThumbnail.querySelector('.slide-progress-border')
const progressOverlay1 = activeThumbnail.querySelector('.slide-progress-overlay1')
if (progressOverlay && progressBorder) {
const thumbnailHeight = activeThumbnail.clientHeight
//PPT的实际可视比例
let pptVisibleRatio = visibleHeight / slideHeight
// 计算蓝色边框高度
const borderHeight = thumbnailHeight * pptVisibleRatio
// 遮罩高度随可见比例变化
const overlayHeight = thumbnailHeight * (1 - pptVisibleRatio)
progressBorder.style.height = `${borderHeight}px`
// 计算幻灯片在可视区域的位置
let rectTopShow = rect.top - pptMainRect.top > 0
let rectBottomShow = pptMainRect.bottom - rect.bottom > 0
if (rectTopShow && !rectBottomShow) {
// 顶部出现,底部未出现
progressBorder.style.top = '0'
progressBorder.style.bottom = 'auto'
progressOverlay.style.bottom = '0'
progressOverlay.style.top = 'auto'
progressOverlay.style.height = `${overlayHeight}px`
progressOverlay1.style.display = 'none'
} else if (!rectTopShow && !rectBottomShow) {
// 上下边均未出现时
// 上遮罩的比例
let pptTopOverlayRatio = (pptMainRect.top - rect.top) / slideHeight
progressBorder.style.top = `${pptTopOverlayRatio * 100}%`
progressBorder.style.bottom = 'auto'
// 上遮罩的样式
progressOverlay.style.top = '0'
progressOverlay.style.bottom = 'auto'
progressOverlay.style.height = `${pptTopOverlayRatio * thumbnailHeight}px`
// 下遮罩的样式
progressOverlay1.style.display = 'block'
progressOverlay1.style.top = 'auto'
progressOverlay1.style.bottom = '0'
progressOverlay1.style.height = `${(1 - pptTopOverlayRatio - pptVisibleRatio) * thumbnailHeight}px`
} else {
// 其他情况(包括完全可视和上边未出现,下边出现):边框固定在底部
progressBorder.style.top = 'auto'
progressBorder.style.bottom = '0'
progressOverlay.style.top = '0'
progressOverlay.style.bottom = 'auto'
progressOverlay.style.height = `${overlayHeight}px`
progressOverlay1.style.display = 'none'
}
}
// 停止遍历
return true
}
})
}
highlightActiveThumbnail(index) {
const thumbnails = this.$refs.thumbnail
....
// 其余的移除
thumbnails.forEach((thumbnail) => {
thumbnail.classList.remove('active')
})
// 当前的加上
activeThumbnail.classList.add('active')
if (activeThumbnail) {
....
}
}
同时点击左侧缩略图时,也要改变蓝色提示框的状态
scrollToSlide({ id }, event) {
const refKey = `slide_${id}`
const slide = this.$refs[refKey][0]
// 点击的缩略图加上active类
const thumbnails = this.$refs.thumbnail
thumbnails.forEach((thumbnail) => {
thumbnail.classList.remove('active')
})
event.currentTarget.classList.add('active')
// 点击的时候就高亮整个缩略图部分, 这里没有再做进一步的判断可视比例
event.currentTarget.querySelector(
'.slide-progress-border'
).style.height = `${event.currentTarget.clientHeight}px`
// 遮罩高度置为0
event.currentTarget.querySelector('.slide-progress-overlay').style.height = `0`
const targetPosition = slide.offsetTop - 20
this.isScrollingByClick = true // 设置标志位为 true
this.scrollToTop(this.pptMain, targetPosition, 500)
setTimeout(() => {
this.isScrollingByClick = false // 设置标志位为 false
}, 550)
}
遮罩的css如下
结语
整体思路就是这样, 哪里写的有误或者有疑问的朋友可以评论交流;