본문 바로가기

프론트엔드

TUI Editor로 작성한 포스팅에 TOC 만들기

🐰 구현방법

본 블로그 게시글은 ToastUI의 에디터로 작성을 하고 html로 변환하여 DB에 저장하고 있다.

TOC를 구현하려면 heading요소를 파싱해서 목록을 만들고, 각 요소의 id값을 넣어줘서 해당 heading영역으로 이동하도록 해야하는데, ToastUI 에디터로 html을 변환할 때는 각 head에 id값을 넣어주기가 힘들어서 detail화면에서 html을 뿌리기 전에 헤딩요소만 파싱해서 적절한 id값을 넣어 주는 방식으로 구현하였다.

1. heading Element에 id값 넣어주기

  • jsdom으로 document를 받아와서 heading요소(나의 경우는 어차피 h3까지만 쓸 것 같아서 h1, h2, h3만 가져옴)를 파싱하여 순서대로 id값을 넣어주었다.
// detail/page.tsx
const { JSDOM } = jsdom;

const dom = new JSDOM(result!.content);
const { document } = dom.window;
const headings = document.querySelectorAll("h1, h2, h3");

headings.forEach((heading: Element, index: number) => {
    heading.setAttribute("id", `heading-${index}`);
});

const modifiedHtmlString = dom.serialize(); // 

...

return (
    ...
    <div className="prose prose-sm sm:prose-base dark:prose-invert dangerouslySetInnerHTML={{__html: modifiedHtmlString}} />
)

2. TOC 컴포넌트, TOC 데이터구조 만들기

  • props로 전달받은 htmlString을 DOMParser로 파싱해서 heading태그들로 tocList를 생성하고 toc데이터 구조를 만든다.
  • tocList 구조는 아래와 같다.
    • id: 목차에서 요소 클릭 시 해당 heading요소로 스크롤 이동하기 위함
    • text: 목차에 보여줄 제목 text
    • level: 들여쓰기를 위한 레벨( h1 > h2 > h3 순으로 안으로 들어감)
// detail/Toc.tsx

'use client';
interface Toc {
    id: string
    text: string | null
    level: number
}
export default function Toc({ htmlString }: {htmlString: string}) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(htmlString, "text/html");
    const headings = doc.querySelectorAll("h1, h2, h3");

    const tocList:Toc[] = [];
    headings.forEach((heading) => {
        const text = heading.textContent;
        const level = parseInt(heading.tagName.substring(1), 10);
        const id = heading.id
        tocList.push({ id, text, level });
    });

    return (
        <div className="sticky top-28 p-10">
        {
            tocList.map(toc => 
                <div key={toc.id} 
                    className={`cursor-pointer px-3 border-l-4 py-1 text-sm 
                        ${toc.level === 2 ? 'pl-10' : toc.level === 3 ? 'pl-16' : 'pl-5'}`}
                    }
                >
                    { toc.text }
                </div>
            )
        }
        </div>
    )
}

3. scroll관련 hook 생성

  • useScrollPosition hook을 만들어서 스크롤위치를 업데이트하고, 목차의 요소 클릭 시 해당 heading태그로 이동시켜주는 함수를 만든다.
// useScrollPosition.ts
import { useState, useEffect } from 'react';

// blog detail 페이지에서 toc컴포넌트에 필요한 scroll관련 hook
export default function useScrollPosition() {
  const [ scrollPosition, setScrollPosition ] = useState<number>(0);
  
  useEffect(() => {
    // scroll 이벤트를 등록하여 현재 스크롤 위치를 업데이트한다. 
    
    const handleScroll = () => {
        const position = window.scrollY;
        setScrollPosition(position); 
    };
    
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const scrollToEl = (id:string) => {
    // 전달받은 id값으로 스크롤을 이동시킨다. 
    const element = document.getElementById(id);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth' });
    }
  };

  return { scrollPosition, scrollToEl };
}

4. Scroll 위치 값에 따라 화면에 위치한 heading id값 구하기

  • scoll 위치와 id값으로 검색한 header의 위치값을 비교하여 만든다.
// detail/Toc.tsx
const {scrollPosition, scrollToEl} = useScrollPosition();

const activeItemId = useMemo(() => {
    // 전달받은 hearItem의 id값으로 각 헤더의 offsetTop 값을 배열로 저장한다.
    const targetOffsets = tocList.map((item) => {
        const target = document.getElementById(item.id);
        return target?.offsetTop ?? Infinity;
    });
    
    // offset배열에서 현재 스크롤 위치보다 offset이 큰 index를 찾는다. 👉🏻 스크롤 위치보다 아래에 있는 div찾기
    const lastIndex = targetOffsets.findIndex((offset) => offset >= scrollPosition);
  
    // 스크롤위치보다 아래에 있는 div가 없을 경우 마지막 목차를 active로 설정한다.
    if (lastIndex === -1) {
        return items[items.length - 1]?.id ?? null;
    }

    // lastIndex가 있다면, 해당 목차를 active로 설정
    return items[lastIndex - 1]?.id ?? items[0]?.id;

}, [scrollPosition, items]);

5. 스크롤이 위치한 헤딩 하이라이트 및 목차에서 선택한 값으로 스크롤 이동

  • 스크롤이 위치한 곳의 헤딩을 하이라이트 해주기 위해 위에서 구한 activeItemId에 다른 컬러를 입혀주었다.
  • 목차에서 선택한 값으로 스크롤 이동 구현은 그냥 toc 요소에 아까 만들어준 scrollToEl를 넣어주면 끝난다.
{
    tocList.map(toc => 
        <div key={toc.id} 
            className={`cursor-pointer px-3 border-l-4 py-1 text-sm 
                ${ activeItemId === toc.id ? 'bg-pink-100 border-pink-300 text-gray-500' : 'bg-transparent border-pink-200 dark:text-gray-200' }
                ${toc.level === 2 ? 'pl-10' : toc.level === 3 ? 'pl-16' : 'pl-5'}`}
            onClick={() => scrollToEl(toc.id) }
        >
            { toc.text }
        </div>
    )
}

🐰 결과

귀엽게 잘 만들어진 것 같다!! 🤭