2023-04-03 · 5 min read · 개발

Gatsby - TOC 기능 구현하기 (Gastby 프레임워크에 기여한 경험)

TOC 기능 구현하기

긴 글을 읽다보면 자신이 글의 어느 부분을 읽고 있는지 알고 싶을 때가 있습니다. 또한, 클릭한 목차에 따라 화면을 이동시키거나 현재 보고 있는 화면의 목차가 스타일이 적용되면 사용자에게 더 좋은 경험을 제공해줄 수 있습니다. 이를 위해 저의 블로그에도 TOC (Table of Contents) 기능을 구현하고자 했습니다.

해당 플러그인을 설치해줍니다.

Terminal window
yarn add gatsby-remark-autolink-headers

gatsby.config.ts에 플러그인을 추가해줍니다.

gatsby.config.ts

{
resolve: 'gatsby-plugin-mdx',
options: {
gatsbyRemarkPlugins: [
{
resolve: 'gatsby-remark-autolink-headers',
},
// gatsby-remark-autolink-headers: gatsby-remark-prismjs 앞에 위치
// https://github.com/gatsbyjs/gatsby/issues/5764
{
resolve: 'gatsby-remark-prismjs',
},
],
},
},

저는 gatsby-plugin-mdx와 같이 사용하고 있어서 다음과 같이 설정해줍니다. 관련된 설명을 참고해주세요.

또한, gatsby-remark-autolink-headers 문서에서는 gatsby-remark-prismjs을 사용하고 있으면 플러그인을 추가한 순서에 유의해야 합니다. 관련 이슈 확인해보시고 gatsby-remark-autolink-headersgatsby-remark-prismjs 앞에 위치시킵니다.

설정이 끝나면 GraphQL에서 tableOfContents에서 데이터가 생성되었음을 확인할 수 있습니다.

graphql-tableofcontents

데이터를 보면 알 수 있듯이 items 내부에 urltitle, 재귀적으로 items과 호출되는 것을 확인할 수 있습니다.

이에 맞추어 관련된 타입 작업을 진행합니다.

Toc.tsx

export interface TableItem {
url: string
title: string
items?: TableItem[]
}
interface Props {
toc: {
items?: TableItem[]
}
}
const Toc = ({ toc }: Props) => {
return (
<Aside>
<TocElement toc={toc} />
</Aside>
)
}
const TocElement = ({ toc }: Props) => {
return (
<TocList>
{toc.items &&
toc.items.map((item) => (
<TocItem key={item.title}>{item.items && <TocElement toc={item} />}</TocItem>
))}
</TocList>
)
}

스크롤에 따른 TOC 스타일 적용하기

현재 사용자가 보고 있는 위치에 따라 TOC에 스타일을 주면 더 좋을 것 같습니다. 이를 위해 IntersectionObserver를 활용하고자 합니다. Heading 태그가 TOC를 나누는 기준이기 때문에 해당 태그들을 감지하면 될 것 같습니다.

이를 위해 TocElement 컴포넌트에 관련 작업을 수행합니다. 추가된 코드는 다음과 같습니다.

const Toc = ({ toc }: Props) => {
return (
<Aside>
<TocElement toc={toc} />
</Aside>
)
}
const TocElement = ({ toc }: Props) => {
const [scrollTocId, setScrollTocId] = useState('')
const contentRef = useRef<{ [index: string]: IntersectionObserverEntry }>({})
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach((entry) => {
contentRef.current[entry.target.id] = entry
})
const visibleContent = Object.values(contentRef.current).filter(
(content) => content.isIntersecting,
)
if (visibleContent[0]?.target.id !== undefined) {
setScrollTocId(visibleContent[0].target.id)
}
},
[],
)
useEffect(() => {
const obeserver = new IntersectionObserver(callback)
const contents = document.querySelectorAll('h1, h2, h3')
contents.forEach((content) => obeserver.observe(content))
return () => obeserver.disconnect()
}, [callback])
return (
<TocList>
{toc.items &&
toc.items.map((item) => (
<TocItem key={item.title}>
<StyledLink
to={item.url}
className={scrollTocId === item.url.slice(1) ? 'active' : ''}
>
{item.title}
</StyledLink>
{item.items && <TocElement toc={item} />}
</TocItem>
))}
</TocList>
)
}
export default Toc

마무리

toc

Gastby 프레임워크에 기여하기

TOC 작업을 하는 과정에서 공식 문서를 읽다가 gatsby-remark-autolink-headers에서 오타를 발견했습니다.🧐

redudant-typo

redundantredudant로 오타가 있네요. Gatsby라는 거대한 프레임워크에 기여할 수 있는 좋은 기회라고 생각하여 빠르게 수정하고 PR을 남겨봤습니다.

단순히 오타를 수정하는 작업으로 코드 한 줄만 바꾸는 것이지만, 혹시나 하는 마음에 계속 확인하며 기다렸네요.😂

PR Merge

pr-merge

다행히도 머지되었습니다.👍 (대단한 개발자분의 Thanks까지… 영광입니다..)

TOC 작업을 하면서 제가 사용한 기술에 작게나마 기여해서 뿌듯했습니다. Gastby를 학습하는 과정에서 오픈 소스에 기여하는 경험까지 할 수 있어서 Gastby가 저한테 더 특별하게 느껴지네요.😄

이번 글을 통해 Gatsby에서 TOC 기능을 구현하려는 분들에게 도움이 되었으면 합니다.🙇‍♂️