2025년 01월 15일

ToC (Table of Contents) 구현하기

프론트엔드

Prologue

ToC 예시

ToC(Table of Contents) 란?
게시글의 목차이다. 마크다운으로 구성된 내용을 HTML로 파싱하여, 헤딩 태그(h1, h2)들을 목차로 만드는 것이다.
이미지에서 왼쪽에 있는 영역이 ToC이다.

개인 블로그를 개발하면서 뭔가 이것저것 집어넣다 보니, 다른 분들의 블로그나 velog에 ToC가 구현되어 있는 것을 보았다.
그래서 공부하는 겸, 구현해 보기로 결정했다.

1. 헤딩 태그에 ID 부여하기

(마크다운 -> HTML로 파싱이 되었다는 전제)
먼저, 헤딩을 구별하고 <a> 태그로 특정 헤딩으로 이동하는 기능을 구현하기 위해 헤딩 태그들에 id값을 부여한다.
복잡할 수 있는 위 과정을 rehype-slug라는 라이브러리로 해결이 가능하다.
rehype-slug는 마크다운으로 작성된 문서를 HTML 트리로 파싱해주는 unified 라이브러리와 함께 쓰이는 플러그인이다.

npm install rehype-slug // or
yarn add rehype-slug

그리고 unified.use() 메서드를 통해 rehype-slug 플러그인을 등록한다.

const processedContent = await unified()
    .use(rehypeSlug)
    ...
    .process(markdown)

아래와 같이 헤딩 태그에 id가 삽입되어 HTML 문서로 파싱되어 나온다.
rehype-slug를 통해 헤딩 태그에 삽입된 id

2. ToC 컴포넌트

ToC 컴포넌트는 다음과 같은 기능을 가진다.

  1. 현재 문서의 탐색 위치에 해당되는 목차 하이라이팅
  2. 목차 클릭 시 해당 위치로 이동

위 기능을 구현하기 전에, 마크다운 문서에서 헤딩 태그를 파싱하는 과정을 거쳐야 한다.

export function getHeadingsFromMdx(mdx: string) {
  const _getHeadingLevel = (s: string) => s.match(/^(#{1,3})(?=\s)/)![0].length;
  const _getSanitizedId = (s: string) => {
    const slugger = new GithubSlugger();
    const slug = slugger.slug(s.match(/^#{1,3}\s+(\S.*)$/)![1]);

    return slug;
  };

  const _getHeadings = (mdx: string) => {
    const lines = mdx.split("\n");
    return lines
      .filter((line) => line.startsWith("#"))
      .map((heading) => {
        const level = _getHeadingLevel(heading);
        const id = _getSanitizedId(heading);
        return {
          id: id,
          level: level,
          content: heading.replaceAll("#", "").trim(),
        };
      });
  };

  return _getHeadings(mdx);
}

과정은 다음과 같다.

  1. 헤딩 레벨 구하기(_getHeadingLevel): 각 헤딩의 인덱싱 레벨을 구한다. 예를 들어, "# Heading 1" 이면 레벨이 1인 헤딩이고, "## Heading 2" 이면 레벨이 2인 헤딩이다. 목차 내 들여쓰기를 구현하기 위해 사용된다.
  2. 헤딩 ID 파싱(_getSanitizedId): 헤딩 태그에 삽입된 id를 파싱한다. 이때, html 문서에서 직접적으로 id를 파싱하는 게 아닌 rehype-slug에서 slug를 만들기 위해 사용되는 라이브러리인 GitHubSlugger를 사용한다.
  3. 헤딩 태그를 담은 배열 객체 반환(_getHeadings): 위 정보와 헤딩 content를 담은 객체 배열을 반환한다.

3번 과정까지 거치면 다음 형식을 가진 객체 배열이 반환된다.

[
    {
        "id": "heading1",
        "level": 1,
        "content": "heading1"
    },
    {
        "id": "heading2",
        "level": 1,
        "content": "heading2"
    },
    {
        "id": "heading3",
        "level": 1,
        "content": "heading3"
    }
]

위 객체를 ToC 컴포넌트에 prop으로 넘겨주어 다음 작업을 진행한다.

2-1. 각 헤딩 태그의 위치(top)을 담은 객체 생성

const [headingCoords, setHeadingCoords] = useState<
    { id: string; top: number }[]
  >([]);

const calcHeadingsTop = useCallback(() => {
  const _scrollTop = document.documentElement.scrollTop;
  setHeadingCoords(
    tocs.map((toc) => {
      const element = document.getElementById(toc.id);
      const top = element
      ? element.getBoundingClientRect().top + _scrollTop
        : -1;
      return { id: toc.id, top: top };
    }),
  );
}, [tocs]);

헤딩 태그의 위치(top)와 id를 담은 State(headingCoords) 를 생성한다. 앞서 prop으로 전달 받은 헤딩 객체 배열을 바탕으로 헤딩 객체의 top 좌표와 id 객체를 담은 배열을 headingCoords에 저장한다.

2-2. 스크롤 이벤트로 문서 탐색 위치 관측

현재 문서 탐색 위치, 즉 현재 스크롤 위치를 관측하고 목차를 하이라이팅 해주는 함수를 스크롤 이벤트로 등록한다.

const [activateId, setActivateId] = useState<string | null>(null);

useEffect(() => {
    if (tocs.length >= 1) {
      const onScroll = () => {
        const _scrollTop = document.documentElement.scrollTop;
        const currentToc = headingCoords
          .slice()
          .reverse()
          .find((coord) => coord.top - 40 <= _scrollTop);

        if (currentToc?.id !== activateId)
          setActivateId(currentToc?.id || null);
      };

      onScroll();
      window.addEventListener("scroll", onScroll);

      return () => {
        window.removeEventListener("scroll", onScroll);
      };
    }
  }, [headingCoords]);

이때, 현재 위치에 해당되는 목차를 탐색하기 위해 headingCoords를 역방향으로 순회하여 처음으로 해당하는 헤딩 태그를 찾는다. (currentToc)
해당하는 헤딩 태그를 발견했다면 이를 활성화된 헤딩(activateId) 으로 지정한다.

주의
useEffect 내에서 window.addEventListener를 통해 이벤트 리스너를 등록했다면, return을 통해 이벤트 리스너를 삭제 해줘야 한다.

2-3. ToC 컴포넌트 설계

return (
    <ul className="sticky top-[100px] flex flex-col items-start justify-start gap-1">
      {tocs.map((toc) => (
        <li
          key={toc.id}
          className={clsx(`transition-all`, {
            ["text-sm text-gray-300 hover:text-gray-700"]:
              activateId !== toc.id,
            ["text-gray-700"]: activateId === toc.id,
            ["pl-2"]: toc.level === 2,
            ["pl-4"]: toc.level === 3,
          })}
        >
          <a href={`#${toc.id}`}>{toc.content}</a>
        </li>
      ))}
    </ul>
  );

tocs 배열 내에 있는 목차 객체들을 렌더링한다.
조건문을 통해 activateId가 헤딩 객체 id와 같다면 하이라이팅 해준다.

Epilogue

Intersection Observer를 통해 로직을 구현할 수도 있었지만, 감지 능력이 둔하다는 얘기를 듣고 스크롤 이벤트로 구현하였다.
별거 없어보이는 기능이지만, 어느정도 복잡한 로직을 가지고 있는 것 같아 구현하는 데 애를 먹었다.
그래도 완성도 있는 블로그를 위해서라면 투자할 가치가 있다고 생각한다.