PPT预览左右联动与动态视窗高亮实现

250 阅读2分钟

需求场景与实现效果

需求背景: 实现PPT文件的双栏预览功能,左侧为缩略图导航栏,右侧为主内容区域,要求实现以下功能:

  1. 右侧滚动时左侧同步高亮对应缩略图
  2. 点击左侧缩略图精准定位右侧内容
  3. 动态视窗提示框展示可视区域比例

最终效果99999.jpg

布局大致如下

<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) 这句不好理解的话 可以看这张图 无标题-2025-02-25-1639.png

通过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

image.png

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 
    }
    ......
}

20250226100451.gif

缩略图动态视窗

如下图所示,红框内缩略图高亮的部分展示的内容,正是PPT页面当前看到的区域,

image.png 所以要先计算出PPT页面当前能看的区域高度,算出占比,再把比例作用到缩略图上设置高亮框的高度;
PPT可视比例 =可视高度/ PPT原始高度
缩略图高亮框的高度 =PPT可视比例*缩略图高度
计算可视比例时的四种情况:

  • PPT单页的仅上边出现在可视区域内
  • PPT单页的仅下边出现在可视区域内
  • PPT单页的上下边均出现在可视区域内
  • PPT单页的上下边均未出现在可视区域内,但部分内容可视,
    前两种情况,当PPT未完全出现在可视区域内,还要有灰色的遮罩,如图: image.pngimage.png
    随着右侧内容的滚动,蓝色展示框高度会随着减小/变大,灰色遮罩高度与之相反;
    第四种情况,当PPT中间部分出现在可视区域内,应当有上下两块遮罩,如图:
    image.png

遮罩高度比例计算 (pptMainRect.top - rect.top) / slideHeight

image.png

  <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如下

image.png

结语

整体思路就是这样, 哪里写的有误或者有疑问的朋友可以评论交流;