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

View File

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

View File

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