🐰 구현방법
본 블로그 게시글은 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>
)
}
🐰 결과
귀엽게 잘 만들어진 것 같다!! 🤭