From a55d00fcdfe5405779f0b906484a2f70b4ce71cd Mon Sep 17 00:00:00 2001 From: Yunus Emre Alpu Date: Fri, 29 May 2026 12:22:53 +0300 Subject: [PATCH] feat: enhance table of contents with active section highlighting and debounced scroll handling --- client/themes/default/components/page.vue | 66 +++++++++++++++-------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue index 8b5bcc77..0aece686 100644 --- a/client/themes/default/components/page.vue +++ b/client/themes/default/components/page.vue @@ -96,12 +96,22 @@ .overline.pa-5.pb-0(:class='$vuetify.theme.dark ? `blue--text text--lighten-2` : `primary--text`') {{$t('common:page.toc')}} v-list.pb-3(dense, nav, :class='$vuetify.theme.dark ? `darken-3-d3` : ``') template(v-for='(tocItem, tocIdx) in tocDecoded') - v-list-item(@click='$vuetify.goTo(tocItem.anchor, scrollOpts)', :id='tocItem.anchor') + v-list-item( + :key='`toc-${tocIdx}`' + @click='$vuetify.goTo(tocItem.anchor, scrollOpts)' + :id='tocItem.anchor' + :class='{ titleactive: activeTocId === tocItem.anchor }' + ) v-icon(color='grey', small) {{ $vuetify.rtl ? `mdi-chevron-left` : `mdi-chevron-right` }} v-list-item-title.px-3 {{tocItem.title}} //- v-divider(v-if='tocIdx < toc.length - 1 || tocItem.children.length') - template(v-for='tocSubItem in tocItem.children') - v-list-item(@click='$vuetify.goTo(tocSubItem.anchor, scrollOpts)', :id='tocSubItem.anchor') + template(v-for='(tocSubItem, tocSubIdx) in tocItem.children') + v-list-item( + :key='`toc-${tocIdx}-${tocSubIdx}`' + @click='$vuetify.goTo(tocSubItem.anchor, scrollOpts)' + :id='tocSubItem.anchor' + :class='{ titleactive: activeTocId === tocSubItem.anchor }' + ) v-icon.px-3(color='grey lighten-1', small) {{ $vuetify.rtl ? `mdi-chevron-left` : `mdi-chevron-right` }} v-list-item-title.px-3.caption.grey--text(:class='$vuetify.theme.dark ? `text--lighten-1` : `text--darken-1`') {{tocSubItem.title}} //- v-divider(inset, v-if='tocIdx < toc.length - 1') @@ -520,7 +530,9 @@ export default { } } }, - winWidth: 0 + winWidth: 0, + activeTocId: null, + debouncedScrollHandler: null } }, computed: { @@ -600,7 +612,8 @@ export default { } this.$store.set('page/mode', 'view') - window.addEventListener('scroll', this.handleScroll) + this.debouncedScrollHandler = _.debounce(this.handleScroll, 100) + window.addEventListener('scroll', this.debouncedScrollHandler, { passive: true }) }, mounted () { if (this.$vuetify.theme.dark) { @@ -645,11 +658,19 @@ export default { } }) + if (this.tocDecoded.length) { + this.handleScroll() + } + window.boot.notify('page-ready') }) }, - destroyed () { - window.removeEventListener('scroll', this.handleScroll) + beforeDestroy () { + // Properly remove the debounced scroll handler + if (this.debouncedScrollHandler) { + window.removeEventListener('scroll', this.debouncedScrollHandler) + this.debouncedScrollHandler = null + } }, methods: { goHome () { @@ -674,23 +695,24 @@ export default { }, // Highlight the current section items in a sticky table of contents as you scroll down the page. handleScroll () { + if (!this.$refs.container) return + const scrollPosition = window.scrollY - const sections = document.querySelectorAll('h1, h2') - const links = document.querySelectorAll('.v-list-item--link') // .v-list-item--link .v-list-item__title - const current = [] - sections.forEach((el) => { - if (el.offsetTop <= scrollPosition + 5) { - current.push(el) + const offset = 100 + const sections = this.$refs.container.querySelectorAll('h1, h2') + + let activeSection = null + + for (let i = sections.length - 1; i >= 0; i--) { + const section = sections[i] + if (section.offsetTop <= scrollPosition + offset) { + activeSection = section + break } - }) - const currentSection = current[current.length - 1] - const id = currentSection && currentSection.id - links.forEach((el) => { - el.classList.remove('titleactive') - if (el.getAttribute('id') === `#${id}`) { - el.classList.add('titleactive') - } - }) + } + + const id = activeSection?.id + this.activeTocId = id ? `#${id}` : null }, pageEdit () { this.$root.$emit('pageEdit')