锚点跳转及鼠标滚动与锚点高亮联动

demo

html 复制代码
<template>
  <div class="demo-page">
    <div class="anchor-nav">
      <div v-for="item in anchors" :key="item.id" class="anchor-item" :class="{ active: activeAnchor === item.id }"
        @click="scrollToAnchor(item.id)">
        <i class="el-icon">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
            <path fill="currentColor"
              d="M128 192v640h768V320H485.76L357.504 192zm-32-64h287.872l128.384 128H928a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H96a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32">
            </path>
          </svg>
        </i>
        <a>{{ item.name }}</a>
      </div>
    </div>

    <div ref="contentRef" class="content scrollbar">
      <div id="eventBackground">
        <div class="module-title">事件背景</div>
      </div>

      <div id="eventDetail">
        <div class="module-title">事件详情</div>
      </div>

      <div id="relatePerson">
        <div class="module-title">关联人员</div>
      </div>

      <div id="attachment">
        <div class="module-title">附件</div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
// 锚点配置
const anchors = [
  {
    id: 'eventBackground',
    name: '事件背景'
  },
  {
    id: 'eventDetail',
    name: '事件详情'
  },
  {
    id: 'relatePerson',
    name: '关联人员'
  },
  {
    id: 'attachment',
    name: '附件'
  }
]
const activeAnchor = ref('eventBackground')

const contentRef = ref(null)
const isScrollingByClick = ref(false) // 标记是否由点击触发的滚动

// 滚动到指定锚点
function scrollToAnchor(anchorId) {
  activeAnchor.value = anchorId
  isScrollingByClick.value = true // 标记为点击触发的滚动

  const element = document.getElementById(anchorId)
  if (element) {
    element.scrollIntoView({
      // 滚动行为:平滑滚动
      behavior: 'smooth',
      // 垂直对齐方式: 1. 'start':元素顶部与滚动容器顶部对齐; 2. 'center':元素在滚动容器中垂直居中; 3. 'end':元素底部与滚动容器底部对齐; 4. 'nearest':元素最近的对齐位置
      block: 'start',
      // 水平对齐方式: 'start':元素左端与滚动容器左端对齐; 2. 'center':元素在滚动容器中水平居中; 3. 'end':元素右端与滚动容器右端对齐; 4. 'nearest':元素最近的对齐位置
      // inline: 'start'
    })

    // 平滑滚动结束后重置标记(根据滚动时间设置合适的延迟)
    setTimeout(() => {
      isScrollingByClick.value = false
    }, 500) // 500ms是平滑滚动的典型持续时间
  }
}

// 检测当前激活的锚点
function updateActiveAnchor() {
  // 如果是点击触发的滚动,不进行激活状态更新
  if (isScrollingByClick.value || !contentRef.value) return

  const scrollTop = contentRef.value.scrollTop
  const scrollHeight = contentRef.value.scrollHeight
  const clientHeight = contentRef.value.clientHeight

  // 计算每个锚点元素的位置
  const anchorPositions = anchors.map(anchor => {
    const element = document.getElementById(anchor.id)
    if (!element) return { id: anchor.id, top: -1 }

    // 获取元素相对于滚动容器的位置
    const rect = element.getBoundingClientRect()
    const containerRect = contentRef.value.getBoundingClientRect()
    const top = rect.top - containerRect.top + contentRef.value.scrollTop

    return { id: anchor.id, top }
  })

  // 添加底部边界(最后一个锚点)
  anchorPositions.push({ id: 'end', top: scrollHeight })

  // 查找当前应该激活的锚点
  let currentAnchor = activeAnchor.value

  for (let i = 0; i < anchors.length; i++) {
    const currentTop = anchorPositions[i].top
    const nextTop = anchorPositions[i + 1].top

    // 判断当前滚动位置是否在这个锚点区域内
    // 添加偏移量(例如50px)来提前切换激活状态,提升用户体验
    if (scrollTop + 50 >= currentTop && scrollTop + 50 < nextTop) {
      currentAnchor = anchors[i].id
      break
    }
  }

  // 如果滚动到底部,激活最后一个锚点
  if (scrollTop + clientHeight >= scrollHeight - 10) {
    currentAnchor = anchors[anchors.length - 1].id
  }

  activeAnchor.value = currentAnchor
}

// 防抖函数,避免滚动时频繁触发
function debounce(func, wait) {
  let timeout
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout)
      func(...args)
    }
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
  }
}

// 防抖后的更新函数
const debouncedUpdate = debounce(updateActiveAnchor, 10)

// 监听滚动事件
function setupScrollListener() {
  if (contentRef.value) {
    contentRef.value.addEventListener('scroll', debouncedUpdate)
  }
}

// 移除滚动监听
function removeScrollListener() {
  if (contentRef.value) {
    contentRef.value.removeEventListener('scroll', debouncedUpdate)
  }
}


// 组件挂载时设置监听
onMounted(() => {
  setupScrollListener()
  scrollToAnchor(activeAnchor.value)
})

// 组件卸载时移除监听
onUnmounted(() => {
  removeScrollListener()
})
</script>

<style lang="scss" scoped>
.demo-page {
  width: 100%;
  height: 100%;
  background: rgba(247, 247, 247, 1);
  font-size: 16px;
  padding: 10px 20px;

  .anchor-nav {
    height: 40px;
    display: flex;
    align-items: center;
    margin: 16px 0 8px 0;

    .anchor-item {
      width: 124px;
      height: 40px;
      display: flex;
      justify-content: center;
      align-items: center;
      position: relative;
      cursor: pointer;
      color: rgba(0, 0, 0, 0.7);
      transition: color 0.3s ease;

      &:hover {
        color: rgba(32, 128, 247, 0.8);
      }

      &.active {
        color: rgba(32, 128, 247, 1);

        &::after {
          content: '';
          position: absolute;
          left: 0;
          bottom: 0;
          width: 100%;
          height: 2px;
          background-color: rgba(32, 128, 247, 1);
          animation: slideIn 0.3s ease;
        }
      }

      a {
        text-decoration: none;
        color: inherit;
      }

      .el-icon {
        font-size: 20px;
        color: inherit;
        height: 1em;
        width: 1em;
        line-height: 1em;
        display: inline-flex;
        justify-content: center;
        align-items: center;
        position: relative;
        fill: currentColor;
        margin-right: 4px;
      }
    }
  }

  .content {
    width: 100%;
    height: calc(100% - 48px);
    overflow-x: hidden;
    overflow-y: auto;
  }

  #eventBackground,
  #eventDetail,
  #relatePerson,
  #attachment {
    width: 100%;
    height: 500px;
    background-color: #fff;

    &:not(:last-child) {
      margin-bottom: 24px;
    }
  }

  .module-title {
    width: 100%;
    height: 56px;
    border-radius: 4px 4px 0px 0px;
    background: rgba(32, 128, 247, 0.02);
    display: flex;
    align-items: center;
    padding: 0 12px;
    color: rgba(4, 44, 119, 1);
    font-family: Microsoft YaHei UI;
    font-size: 16px;
    font-weight: 600;
    line-height: 24px;
  }
}

// 添加动画效果
@keyframes slideIn {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}
</style>

<style lang="scss">
.scrollbar {

  /* 滚动条 */
  &::-webkit-scrollbar {
    width: 12px;
    height: 12px;
  }

  /* 滚动条边角 */
  &::-webkit-scrollbar-corner {
    width: 0;
  }

  /* 滚动条轨道 */
  &::-webkit-scrollbar-track {
    padding: 0px 2px;
    background: transparent;
  }

  &::-webkit-scrollbar-track:hover {
    background: rgba(0, 0, 0, 0.04);
  }

  /* 滚动条滑块 */
  &::-webkit-scrollbar-thumb {
    border-radius: 6px;
    background-color: transparent;
    cursor: pointer;
    border: 4px solid transparent;
    background-clip: padding-box;
  }

  &:hover::-webkit-scrollbar-thumb {
    background-color: rgba(0, 0, 0, 0.3);
  }

  &:hover::-webkit-scrollbar-thumb:hover {
    border: 2px solid transparent;
  }

  &:hover::-webkit-scrollbar-thumb:active {
    background-color: rgba(0, 0, 0, 0.7);
  }
}
</style>
相关推荐
weixin_382395237 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__8 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.8 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~9 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华10 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子12 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅12 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni13 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学14 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端