webapp/src/components/TableOfContents/TableOfContents.tsx

173 lines
5.6 KiB
TypeScript
Raw Normal View History

import { clsx } from 'clsx'
2024-02-04 11:25:21 +00:00
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { debounce, throttle } from 'throttle-debounce'
import { useLocalize } from '../../context/localize'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { isDesktop } from '../../utils/media-query'
import { Icon } from '../_shared/Icon'
import styles from './TableOfContents.module.scss'
interface Props {
variant: 'article' | 'editor'
parentSelector: string
body: string
}
const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
2023-10-17 13:37:54 +00:00
return rect.top <= DEFAULT_HEADER_OFFSET
}
const scrollToHeader = (element) => {
window.scrollTo({
behavior: 'smooth',
top:
element.getBoundingClientRect().top -
document.body.getBoundingClientRect().top -
DEFAULT_HEADER_OFFSET,
})
}
export const TableOfContents = (props: Props) => {
const { t } = useLocalize()
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)
}
2023-09-21 20:04:23 +00:00
setIsVisible(isDesktop())
const updateHeadings = () => {
setHeadings(
// eslint-disable-next-line unicorn/prefer-spread
2023-11-15 21:51:32 +00:00
Array.from(
document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h1, h2, h3, h4'),
),
)
setAreHeadingsLoaded(true)
}
const debouncedUpdateHeadings = debounce(500, updateHeadings)
const updateActiveHeader = throttle(50, () => {
2023-10-17 13:37:54 +00:00
const newActiveIndex = headings().findLastIndex((heading) => isInViewport(heading))
setActiveHeaderIndex(newActiveIndex)
})
createEffect(
on(
() => props.body,
() => debouncedUpdateHeadings(),
),
)
onMount(() => {
window.addEventListener('scroll', updateActiveHeader)
onCleanup(() => window.removeEventListener('scroll', updateActiveHeader))
})
return (
<Show
when={
areHeadingsLoaded() && (props.variant === 'article' ? headings().length > 2 : headings().length > 1)
}
>
<div
class={clsx(styles.TableOfContentsFixedWrapper, {
[styles.TableOfContentsFixedWrapperLefted]: props.variant === 'editor',
})}
>
<div class={styles.TableOfContentsContainer}>
<Show when={isVisible()}>
2023-09-20 20:57:44 +00:00
<div class={styles.TableOfContentsContainerInner}>
<div class={styles.TableOfContentsHeader}>
<p class={styles.TableOfContentsHeading}>{t('contents')}</p>
</div>
<ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}>
{(h, index) => (
2023-09-20 20:57:44 +00:00
<li>
<button
class={clsx(styles.TableOfContentsHeadingsItem, {
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
[styles.TableOfContentsHeadingsItemH4]: h.nodeName === 'H4',
[styles.active]: index() === activeHeaderIndex(),
2023-09-20 20:57:44 +00:00
})}
innerHTML={h.textContent}
onClick={(e) => {
e.preventDefault()
scrollToHeader(h)
}}
/>
</li>
)}
</For>
</ul>
</div>
</Show>
<button
2023-09-20 20:57:44 +00:00
class={clsx(
styles.TableOfContentsPrimaryButton,
{
[styles.TableOfContentsPrimaryButtonLefted]: props.variant === 'editor' && !isVisible(),
2023-09-20 20:57:44 +00:00
},
'd-none d-xl-block',
2023-09-20 20:57:44 +00:00
)}
onClick={(e) => {
e.preventDefault()
toggleIsVisible()
}}
2023-08-27 16:48:28 +00:00
title={isVisible() ? t('Hide table of contents') : t('Show table of contents')}
>
2023-09-20 20:57:44 +00:00
<Show when={isVisible()} fallback={<Icon name="show-table-of-contents" class="icon" />}>
2023-09-04 21:50:11 +00:00
{props.variant === 'editor' ? (
<Icon name="hide-table-of-contents" class="icon" />
) : (
<Icon name="hide-table-of-contents-2" class="icon" />
)}
</Show>
</button>
2023-09-20 20:57:44 +00:00
<Show when={isVisible()}>
<button
class={clsx(styles.TableOfContentsCloseButton, 'd-xl-none')}
onClick={(e) => {
e.preventDefault()
toggleIsVisible()
}}
title={isVisible() ? t('Hide table of contents') : t('Show table of contents')}
>
<Icon name="close-white" class="icon" />
</button>
</Show>
</div>
2023-09-20 20:57:44 +00:00
<Show when={!isVisible()}>
<button
class={clsx(
styles.TableOfContentsPrimaryButton,
{
[styles.TableOfContentsPrimaryButtonLefted]: props.variant === 'editor' && !isVisible(),
2023-09-20 20:57:44 +00:00
},
'd-xl-none',
2023-09-20 20:57:44 +00:00
)}
onClick={(e) => {
e.preventDefault()
toggleIsVisible()
}}
title={isVisible() ? t('Hide table of contents') : t('Show table of contents')}
>
<Icon name="hide-table-of-contents-2" class="icon" />
</button>
</Show>
</div>
</Show>
)
}