Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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)) {
Expand Down Expand Up @@ -61,6 +62,8 @@ function scrollToTop() {
open.value = false
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}

useFloatActiveAnchor(items, marker, open)
</script>

<template>
Expand All @@ -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>
Expand Down Expand Up @@ -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;
}
Expand Down
147 changes: 114 additions & 33 deletions src/client/theme-default/composables/outline.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down