2023-10-17 10:29:30 +00:00
|
|
|
import { For, Show, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'
|
2023-07-31 21:43:41 +00:00
|
|
|
import { clsx } from 'clsx'
|
|
|
|
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
|
|
|
|
import { useLocalize } from '../../context/localize'
|
|
|
|
import { Icon } from '../_shared/Icon'
|
|
|
|
import styles from './TableOfContents.module.scss'
|
2023-09-21 20:04:23 +00:00
|
|
|
import { isDesktop } from '../../utils/media-query'
|
2023-11-13 15:55:36 +00:00
|
|
|
import { throttle, debounce } from 'throttle-debounce'
|
2023-07-31 21:43:41 +00:00
|
|
|
|
|
|
|
interface Props {
|
|
|
|
variant: 'article' | 'editor'
|
|
|
|
parentSelector: string
|
2023-08-17 18:03:09 +00:00
|
|
|
body: string
|
2023-07-31 21:43:41 +00:00
|
|
|
}
|
|
|
|
|
2023-10-17 10:29:30 +00:00
|
|
|
const isInViewport = (el: Element): boolean => {
|
|
|
|
const rect = el.getBoundingClientRect()
|
2023-10-17 13:37:54 +00:00
|
|
|
return rect.top <= DEFAULT_HEADER_OFFSET
|
2023-10-17 10:29:30 +00:00
|
|
|
}
|
2023-07-31 21:43:41 +00:00
|
|
|
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()
|
|
|
|
|
2023-10-17 10:29:30 +00:00
|
|
|
const [headings, setHeadings] = createSignal<HTMLElement[]>([])
|
2023-07-31 21:43:41 +00:00
|
|
|
const [areHeadingsLoaded, setAreHeadingsLoaded] = createSignal<boolean>(false)
|
2023-10-17 10:29:30 +00:00
|
|
|
const [activeHeaderIndex, setActiveHeaderIndex] = createSignal<number>(-1)
|
2023-08-17 18:03:09 +00:00
|
|
|
const [isVisible, setIsVisible] = createSignal<boolean>(props.variant === 'article')
|
2023-07-31 21:43:41 +00:00
|
|
|
const toggleIsVisible = () => {
|
|
|
|
setIsVisible((visible) => !visible)
|
|
|
|
}
|
|
|
|
|
2023-09-21 20:04:23 +00:00
|
|
|
setIsVisible(isDesktop())
|
|
|
|
|
2023-08-17 18:03:09 +00:00
|
|
|
const updateHeadings = () => {
|
2023-10-17 10:29:30 +00:00
|
|
|
setHeadings(
|
|
|
|
// eslint-disable-next-line unicorn/prefer-spread
|
|
|
|
Array.from(document.querySelector(props.parentSelector).querySelectorAll<HTMLElement>('h2, h3, h4'))
|
|
|
|
)
|
2023-08-14 13:42:12 +00:00
|
|
|
setAreHeadingsLoaded(true)
|
2023-08-17 18:03:09 +00:00
|
|
|
}
|
|
|
|
|
2023-11-13 15:55:36 +00:00
|
|
|
const debouncedUpdateHeadings = debounce(500, updateHeadings)
|
2023-08-17 18:03:09 +00:00
|
|
|
|
2023-11-13 15:55:36 +00:00
|
|
|
const updateActiveHeader = throttle(50, () => {
|
2023-10-17 13:37:54 +00:00
|
|
|
const newActiveIndex = headings().findLastIndex((heading) => isInViewport(heading))
|
2023-10-17 10:29:30 +00:00
|
|
|
setActiveHeaderIndex(newActiveIndex)
|
2023-11-13 15:55:36 +00:00
|
|
|
})
|
2023-10-17 10:29:30 +00:00
|
|
|
|
2023-08-17 18:03:09 +00:00
|
|
|
createEffect(
|
|
|
|
on(
|
|
|
|
() => props.body,
|
|
|
|
() => debouncedUpdateHeadings()
|
|
|
|
)
|
|
|
|
)
|
2023-07-31 21:43:41 +00:00
|
|
|
|
2023-10-17 10:29:30 +00:00
|
|
|
onMount(() => {
|
|
|
|
window.addEventListener('scroll', updateActiveHeader)
|
|
|
|
onCleanup(() => window.removeEventListener('scroll', updateActiveHeader))
|
|
|
|
})
|
|
|
|
|
2023-07-31 21:43:41 +00:00
|
|
|
return (
|
2023-08-17 18:03:09 +00:00
|
|
|
<Show
|
|
|
|
when={
|
|
|
|
areHeadingsLoaded() && (props.variant === 'article' ? headings().length > 2 : headings().length > 1)
|
|
|
|
}
|
|
|
|
>
|
2023-07-31 21:43:41 +00:00
|
|
|
<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()}>
|
2023-10-17 10:29:30 +00:00
|
|
|
{(h, index) => (
|
2023-09-20 20:57:44 +00:00
|
|
|
<li>
|
|
|
|
<button
|
|
|
|
class={clsx(styles.TableOfContentsHeadingsItem, {
|
|
|
|
[styles.TableOfContentsHeadingsItemH3]: h.nodeName === 'H3',
|
2023-10-17 10:29:30 +00:00
|
|
|
[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>
|
2023-07-31 21:43:41 +00:00
|
|
|
</div>
|
|
|
|
</Show>
|
|
|
|
|
|
|
|
<button
|
2023-09-20 20:57:44 +00:00
|
|
|
class={clsx(
|
|
|
|
styles.TableOfContentsPrimaryButton,
|
|
|
|
{
|
|
|
|
[styles.TableOfContentsPrimaryButtonLefted]: props.variant === 'editor' && !isVisible()
|
|
|
|
},
|
|
|
|
'd-none d-xl-block'
|
|
|
|
)}
|
2023-07-31 21:43:41 +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-07-31 21:43:41 +00:00
|
|
|
>
|
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" />
|
|
|
|
)}
|
2023-07-31 21:43:41 +00:00
|
|
|
</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>
|
2023-07-31 21:43:41 +00:00
|
|
|
</div>
|
2023-09-20 20:57:44 +00:00
|
|
|
|
|
|
|
<Show when={!isVisible()}>
|
|
|
|
<button
|
|
|
|
class={clsx(
|
|
|
|
styles.TableOfContentsPrimaryButton,
|
|
|
|
{
|
|
|
|
[styles.TableOfContentsPrimaryButtonLefted]: props.variant === 'editor' && !isVisible()
|
|
|
|
},
|
|
|
|
'd-xl-none'
|
|
|
|
)}
|
|
|
|
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>
|
2023-07-31 21:43:41 +00:00
|
|
|
</div>
|
|
|
|
</Show>
|
|
|
|
)
|
|
|
|
}
|