Table of contents - detect active item (#267)

Table of contents - detect active item
This commit is contained in:
Ilya Y 2023-10-17 13:29:30 +03:00 committed by GitHub
parent 3d8011a4e3
commit ac069b2776
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 46 additions and 15 deletions

11
package-lock.json generated
View File

@ -13,6 +13,7 @@
"i18next": "22.4.15",
"i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1",
"node-fetch": "3.3.1"
},
@ -13168,6 +13169,11 @@
"node": ">=4.0"
}
},
"node_modules/just-throttle": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
"integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
},
"node_modules/kebab-case": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",
@ -28106,6 +28112,11 @@
"object.values": "^1.1.6"
}
},
"just-throttle": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
"integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
},
"kebab-case": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",

View File

@ -33,6 +33,7 @@
"i18next": "22.4.15",
"i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3",
"just-throttle": "4.2.0",
"mailgun.js": "8.2.1",
"node-fetch": "3.3.1"
},

View File

@ -164,6 +164,10 @@
&:hover {
color: rgb(0 0 0 / 50%);
}
&.active {
font-weight: 700 !important;
}
}
.TableOfContentsHeadingsItemH3,

View File

@ -1,16 +1,12 @@
import { For, Show, createSignal, createEffect, on } from 'solid-js'
import { For, Show, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'
import { clsx } from 'clsx'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { useLocalize } from '../../context/localize'
import debounce from 'debounce'
import { Icon } from '../_shared/Icon'
import styles from './TableOfContents.module.scss'
import { isDesktop } from '../../utils/media-query'
import throttle from 'just-throttle'
interface Props {
variant: 'article' | 'editor'
@ -18,6 +14,15 @@ interface Props {
body: string
}
const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
)
}
const scrollToHeader = (element) => {
window.scrollTo({
behavior: 'smooth',
@ -31,9 +36,9 @@ const scrollToHeader = (element) => {
export const TableOfContents = (props: Props) => {
const { t } = useLocalize()
const [headings, setHeadings] = createSignal<Element[]>([])
const [headings, setHeadings] = createSignal<HTMLElement[]>([])
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false)
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal<number>(-1)
const [isVisible, setIsVisible] = createSignal<boolean>(props.variant === 'article')
const toggleIsVisible = () => {
setIsVisible((visible) => !visible)
@ -42,15 +47,20 @@ export const TableOfContents = (props: Props) => {
setIsVisible(isDesktop())
const updateHeadings = () => {
const { parentSelector } = props
// eslint-disable-next-line unicorn/prefer-spread
setHeadings(Array.from(document.querySelector(parentSelector).querySelectorAll('h2, h3, h4')))
setHeadings(
// eslint-disable-next-line unicorn/prefer-spread
Array.from(document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h2, h3, h4'))
)
setAreHeadingsLoaded(true)
}
const debouncedUpdateHeadings = debounce(updateHeadings, 500)
const updateActiveHeader = throttle(() => {
const newActiveIndex = headings().findIndex((heading) => isInViewport(heading))
setActiveHeaderIndex(newActiveIndex)
}, 50)
createEffect(
on(
() => props.body,
@ -58,6 +68,11 @@ export const TableOfContents = (props: Props) => {
)
)
onMount(() => {
window.addEventListener('scroll', updateActiveHeader)
onCleanup(() => window.removeEventListener('scroll', updateActiveHeader))
})
return (
<Show
when={
@ -77,17 +92,17 @@ export const TableOfContents = (props: Props) => {
</div>
<ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}>
{(h) => (
{(h, index) => (
<li>
<button
class={clsx(styles.TableOfContentsHeadingsItem, {
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4'
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
[styles.active]: index() === activeHeaderIndex()
})}
innerHTML={h.textContent}
onClick={(e) => {
e.preventDefault()
scrollToHeader(h)
}}
/>