feat: mobile TOC active highlight

pull/5091/head
huamurui 4 months ago
parent 923aa90252
commit eaab408954

@ -4,7 +4,7 @@ import { onContentUpdated } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { nextTick, ref, watch } from 'vue'
import { useData } from '../composables/data'
import { resolveTitle } from '../composables/outline'
import { resolveTitle, useFloatActiveAnchor } from '../composables/outline'
import VPDocOutlineItem from './VPDocOutlineItem.vue'
const props = defineProps<{
@ -17,6 +17,7 @@ const open = ref(false)
const vh = ref(0)
const main = ref<HTMLDivElement>()
const items = ref<HTMLDivElement>()
const marker = ref<HTMLDivElement>()
function closeOnClickOutside(e: Event) {
if (!main.value?.contains(e.target as Node)) {
@ -61,6 +62,8 @@ function scrollToTop() {
open.value = false
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}
useFloatActiveAnchor(items, marker, open)
</script>
<template>
@ -83,6 +86,7 @@ function scrollToTop() {
{{ theme.returnToTopLabel || 'Return to top' }}
</a>
</div>
<div class="outline-marker" ref="marker" />
<div class="outline">
<VPDocOutlineItem :headers />
</div>
@ -171,11 +175,31 @@ function scrollToTop() {
color: var(--vp-c-brand-1);
}
.outline-marker {
position: absolute;
left: 1px;
z-index: 1;
opacity: 0;
width: 2px;
border-radius: 2px;
height: 18px;
background-color: var(--vp-c-brand-1);
transition:
top 0.25s cubic-bezier(0, 1, 0.5, 1),
background-color 0.5s,
opacity 0.25s;
}
.outline {
padding: 8px 0;
background-color: var(--vp-c-bg-soft);
}
.outline-link.active {
color: var(--vp-c-brand-1);
font-weight: 600;
}
.flyout-enter-active {
transition: all 0.2s ease-out;
}

@ -1,6 +1,6 @@
import { getScrollOffset } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { onMounted, onUnmounted, onUpdated, type Ref } from 'vue'
import { onMounted, onUnmounted, onUpdated, type Ref, watch } from 'vue'
import { throttleAndDebounce } from '../support/utils'
import { useAside } from './aside'
@ -77,32 +77,24 @@ export function resolveHeaders(
return buildTree(headers, high, low)
}
export function useActiveAnchor(
container: Ref<HTMLElement>,
marker: Ref<HTMLElement>
): void {
const { isAsideEnabled } = useAside()
const onScroll = throttleAndDebounce(setActiveLink, 100)
function useBaseActiveAnchor(
container: Ref<HTMLElement | undefined>,
marker: Ref<HTMLElement | undefined>,
isEnabled: Ref<boolean>,
options: {
topOffset: number;
defaultTop: string;
onEnable?: () => void;
},
prevActiveLinkRef?: { current: HTMLAnchorElement | null }
): {
setActiveLink: () => void;
activateLink: (hash: string | null) => void;
} {
let prevActiveLink: HTMLAnchorElement | null = null
onMounted(() => {
requestAnimationFrame(setActiveLink)
window.addEventListener('scroll', onScroll)
})
onUpdated(() => {
// sidebar update means a route change
activateLink(location.hash)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
function setActiveLink() {
if (!isAsideEnabled.value) {
if (!isEnabled.value || !container.value || !marker.value) {
return
}
@ -150,29 +142,118 @@ export function useActiveAnchor(
}
function activateLink(hash: string | null) {
if (!container.value || !marker.value) {
return
}
if (prevActiveLink) {
prevActiveLink.classList.remove('active')
}
if (hash == null) {
prevActiveLink = null
marker.value.style.top = options.defaultTop
marker.value.style.opacity = '0'
} else {
prevActiveLink = container.value.querySelector(
`a[href="${decodeURIComponent(hash)}"]`
)
}
const activeLink = prevActiveLink
if (prevActiveLink) {
prevActiveLink.classList.add('active')
marker.value.style.top = prevActiveLink.offsetTop + options.topOffset + 'px'
marker.value.style.opacity = '1'
} else {
marker.value.style.opacity = '0'
}
}
if (activeLink) {
activeLink.classList.add('active')
marker.value.style.top = activeLink.offsetTop + 39 + 'px'
marker.value.style.opacity = '1'
} else {
marker.value.style.top = '33px'
marker.value.style.opacity = '0'
// Update external ref if provided
if (prevActiveLinkRef) {
prevActiveLinkRef.current = prevActiveLink
}
}
return {
setActiveLink,
activateLink
}
}
export function useActiveAnchor(
container: Ref<HTMLElement>,
marker: Ref<HTMLElement>
): void {
const { isAsideEnabled } = useAside()
const { setActiveLink, activateLink } = useBaseActiveAnchor(
container,
marker,
isAsideEnabled,
{
topOffset: 39,
defaultTop: '33px'
}
)
const onScroll = throttleAndDebounce(setActiveLink, 100)
onMounted(() => {
requestAnimationFrame(setActiveLink)
window.addEventListener('scroll', onScroll)
})
onUpdated(() => {
// sidebar update means a route change
activateLink(location.hash)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
}
export function useFloatActiveAnchor(
container: Ref<HTMLElement | undefined>,
marker: Ref<HTMLElement | undefined>,
isEnabled: Ref<boolean>
): void {
// Use a ref to track prevActiveLink so it can be shared between scopes
const prevActiveLinkRef = { current: null as HTMLAnchorElement | null }
const { setActiveLink, activateLink } = useBaseActiveAnchor(
container,
marker,
isEnabled,
{
topOffset: 6,
defaultTop: '57px'
},
prevActiveLinkRef
)
const onScroll = throttleAndDebounce(setActiveLink, 100)
watch(isEnabled, (newValue, oldValue) => {
if (newValue && !oldValue) {
requestAnimationFrame(() => {
setActiveLink()
if (prevActiveLinkRef.current && container.value) {
container.value.scrollTop = prevActiveLinkRef.current.offsetTop - 8
}
})
window.addEventListener('scroll', onScroll)
} else if (!newValue && oldValue) {
window.removeEventListener('scroll', onScroll)
}
})
onUpdated(() => {
// Update active link on content update
if (isEnabled.value) {
activateLink(location.hash)
}
})
}
function getAbsoluteTop(element: HTMLElement): number {

Loading…
Cancel
Save