게시물 목차 구현하기 ( TOC )

2021-08-21 오후 4:25:58
Next.js
markdown
TOC
게시물 목차 구현하기 ( TOC )

MarkDown으로 작성된 게시물 목차(TOC) 구현하기


여러 개발 블로그 플랫폼에서 제공하는 기능인 목차(TOC) 를 구현하려고 한다.
TOC는 게시글 내용의 전체적인 구성을 한눈에 볼 수 있고,
클릭을 통해 해당 내용으로 바로 이동 하는 기능과 현재 사용자가
읽고 있는 내용의 위치를 알려주는 편리함을 제공한다.

Develogger 또한 글 작성시 MarkDown 형식을 사용하므로 제목을 통해
TOC를 구현 할 수 있고 사용자 또한 목차를 생각하며 글을 작성하면
정리가 더 깔끔해지기에 많은 이점을 취할 수 있다.
(요즘은 대부분의 개발 블로그가 TOC를 만들어 둔 것 같다...ㅋㅋ)

1. 동작 원리

IntersectionObserver와 a tag -> href 속성에 #id를 이용

구현 방법은 여러가지 방식이 있겠지만 게시글 내용에서 제목 태그(h1, h2, h3)를
추출해서 목차 컴포넌트를 그리는 것은 그리 어렵지 않은 작업이다. (#을 기준으로 문자열 가공)

특정 제목을 클릭 했을때 해당 게시글 내용으로 스크롤을 이동시켜 주려면 제목 태그와 같은 #id를 할당
해주어야 하고, TOC에도 각 제목별로 a태그와 링크에 #id를 할당해 주면 된다.

화면 위치에 따라 TOC에 표시해주는 기능은 intersectionObserver를 사용했다.
게시글 내용에서 제목 태그(h1, h2, h3)가 화면 상단 위치 해 있을때 해당 태그를
가져와서 TOC에 같은 value가 있는 경우 style을 변경해 주어 목차에 표시해 주었다.

자, 그럼 실제 구현 과정을 살펴보자.


2. 구현 과정

2-1. Toc component

먼저 Toc컴포넌트와 목차 내용물을 채워넣는 과정이다.

// TOC component import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core'; import Divider from '@material-ui/core/Divider'; import List from '@material-ui/core/List'; import ListItem, { ListItemProps } from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import clsx from 'clsx'; import React, { useState } from 'react'; import { Scrollbars } from 'react-custom-scrollbars'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; const useStyles = makeStyles((theme: Theme) => createStyles({ root: { [theme.breakpoints.down(1290)]: { // ['@media (max-width:1365px)']: { display: 'none', }, }, tocFont: { '& > span': { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', fontSize: '0.8rem', }, }, btnStyle: { '&:hover': { '& div span': { color: 'black', fontSize: '0.83rem', transition: '0.2s', }, }, }, currentHeading: { '&': { backgroundColor: 'rgba(102, 128, 153, 0.07)', borderLeft: '3px solid #959ed8', }, '& div span': { color: 'black', fontSize: '0.83rem', transition: '0.2s', }, }, }) ); interface Props { content: string; } const Toc = ({ content }: Props) => { // activeId는 화면 상단에 위치한 제목 element 다룰 state const [activeId, setActiveId] = useState(''); // intersectionObserver를 이용해 만든 커스텀 훅으로 setState를 전달 하여 // 화면 상단에 위치한 제목 element가 뭔지 알아낸다. useIntersectionObserver(setActiveId, content); const classes = useStyles(); // 게시물 본문을 줄바꿈 기준으로 나누고, 제목 요소인 것만 저장 const titles = content.split(`\n`).filter((t) => t.includes('# ')); // 예외처리 - 제목은 문자열 시작부터 #을 써야함 const result = titles .filter((str) => str[0] === '#') .map((item) => { // #의 갯수에 따라 제목의 크기가 달라지므로 갯수를 센다. let count = item.match(/#/g)?.length; if (count) { // 갯수에 따라 목차에 그릴때 들여쓰기 하기위해 *10을 함. count = count * 10; } // 제목의 내용물만 꺼내기 위해 '# '을 기준으로 나누고, 백틱과 공백을 없애주고 count와 묶어서 리턴 return { title: item.split('# ')[1].replace(/`/g, '').trim(), count }; }); return ( <div style={{ width: '200px' }} className={classes.root}> <List component="nav" aria-label="secondary mailbox folders"> <Typography variant="h6" component="h6" style={{ marginBottom: '3px', fontSize: '0.9rem', marginLeft: '10px', color: '#909090', }}> 목차 </Typography> <Divider /> {/* 목차에 item이 너무 많은경우 화면 아래로 넘어갈수 있기때문에 ScrollBars를 이용하여 스크롤을 만들어준다. */} <Scrollbars universal={true} autoHide autoHeight autoHeightMax="calc(100vh - 250px)"> {result.map((item, idx) => { // count는 샾개수에 따른 들여쓰기용 변수 if (item?.count && item.count <= 30 && item?.title) { return ( <ListItemLink // href에 #title을 주어서 클릭시 해당 위치로 스크롤 이동하도록 구현 href={`#${item.title}`} key={item.title + idx} style={{ padding: '0px' }} className={clsx( classes.btnStyle, // activeId와 같은 list item만 스타일을 다르게 주어서 사용자에게 표시해준다. activeId === item.title && classes.currentHeading )}> <ListItemText // 목차에 해당 하는 title을 넣는다. primary={`${item.title}`} style={{ marginLeft: `${item.count}px`, overflow: 'hidden', color: '#909090', }} className={classes.tocFont} /> </ListItemLink> ); } })} </Scrollbars> </List> </div> ); }; export default Toc; function ListItemLink(props: ListItemProps<'a', { button?: true }>) { return <ListItem button component="a" {...props} />; }

스타일 적용하는 코드들이 정리되지 않은 상태이다.
Toc 컴포넌트는 content(게시물 내용)를 부모로부터 markdown형태 그대로 받아와서
#을 기준으로 제목만 잘라내서 목차를 그려 주도록 작업 하였다.

그리고 useIntersectionObserver로 setActivateId를 넘겨서
activateId값에 따라 현재 사용자가 읽고있는 위치를 목차에 표시해 주도록 되어있다.

다음으로 useIntersectionObserver의 내부 코드를 살펴보자.


2-2. useIntersectionObserver

// useIntersectionObserver custom hook import { useEffect, useRef } from 'react'; export const useIntersectionObserver = ( // 넘겨받은 setActiveId 를 통해 화면 상단의 제목 element를 set해준다. setActiveId: React.Dispatch<React.SetStateAction<string>>, // 게시글 내용이 바뀔때를 알기 위해 content를 넘겨받는다. content: string ) => { // heading element를 담아서 사용하기 위한 ref const headingElementsRef = useRef<any>({}); useEffect(() => { // 새로고침 없이 다른 게시물로 이동할 경우를 대비한 초기화 headingElementsRef.current = {}; // callback은 intersectionObserver로 관찰할 대상 비교 로직 const callback: IntersectionObserverCallback = (headings) => { // 모든 제목을 reduce로 순회해서 headingElementsRef.current에 키 밸류 형태로 할당. headingElementsRef.current = headings.reduce((map: any, headingElement) => { map[headingElement.target.id] = headingElement; return map; }, headingElementsRef.current); // 화면 상단에 보이고 있는 제목을 찾아내기 위한 로직 const visibleHeadings: IntersectionObserverEntry[] = []; Object.keys(headingElementsRef.current).forEach((key) => { const headingElement = headingElementsRef.current[key]; // isIntersecting이 true라면 visibleHeadings에 push한다. if (headingElement.isIntersecting) visibleHeadings.push(headingElement); }); // observer가 관찰하는 영역에 여러개의 제목이 있을때 가장 상단의 제목을 알아내기 위한 함수 const getIndexFromId = (id: string) => headingElements.findIndex((heading) => heading.id === id); if (visibleHeadings.length === 1) { // 화면에 보이고 있는 제목이 1개라면 해당 element의 target.id를 setActiveId로 set해준다. setActiveId(visibleHeadings[0].target.id); } else if (visibleHeadings.length > 1) { // 2개 이상이라면 sort로 더 상단에 있는 제목을 set해준다. const sortedVisibleHeadings = visibleHeadings.sort( (a, b) => getIndexFromId(a.target.id) - getIndexFromId(b.target.id) ); setActiveId(sortedVisibleHeadings[0].target.id); } }; // IntersectionObserver에 callback과 옵션을 생성자로 넘겨 주고 새로 생성한다. const observer = new IntersectionObserver(callback, { // rootMargin 옵션을 통해 화면 상단에서 네비바 영역(-64px)을 빼고, 위에서부터 -40%정도 영역만 관찰한다. rootMargin: '-64px 0px -40% 0px', }); // 제목 태그들을 다 찾아낸다. const headingElements = Array.from(document.querySelectorAll('h1, h2, h3')); // 이 요소들을 observer로 관찰한다. headingElements.forEach((element) => observer.observe(element)); // 컴포넌트 언마운트시 observer의 관찰을 멈춘다. return () => observer.disconnect(); // content 내용이 바뀔때를 대비하여 deps로 content를 넣어준다. }, [content]); };

마찬가지로 코드 내에 주석을 통해 설명을 작성해 두었다.
이런 형태의 커스텀 훅을 사용하여 화면 상단에 위치한 제목태그를 알아낼 수 있다.

마지막으로 Toc컴포넌트를 삽입할 게시물 컴포넌트 내에

<Toc content={postData ? postData.content : ''} />

Toc만 삽입하면 게시물 목록을 보여주게 된다. Toc의 위치나 스타일링 작업은 따로 필요하지만 게시물 페이지를 담당하는 Post 컴포넌트 코드가 길어서 생략하도록 하겠다.



3. 결과물

image

위와같이 화면 상단 40%영역 내에 h1, h2, h3태그 중 가장 상단에 있는 태그와
목차 리스트 중 일치하는 item에 표시 해주고 있으며, 클릭시 스크롤 이동까지 잘 동작한다.



후기

이렇게 MarkDown 형식의 게시물의 목차(Toc) 를 만드는 방법에 대해 정리해 보았다.
MarkDown형식으로 글을 작성 한 후, html 형태로 파싱하는 과정도 필요하기 때문에
생각보다 예외처리 해야 할 것들이 많았다.

( 검색을 통해 새로고침 없이 다른 게시글로 바로 이동하는 경우에 대한 처리( custom hook에서 ) 라던지, 글 내용 중간에 #을 쓴다던지, 빈 제목을 작성 하는 등 )

그리고, IntersectionObserver를 react에서 custom hook으로 사용하는 경험도 새로웠던 것 같다.
아직은 사용자가 적은 편이기 때문에 모든 예외처리를 했다고는 할 수 없지만,
실용적인 기능을 예외처리 한다는 점에서 재미있게 작업 했던 것 같다.



User Profile Icon
LKHcoding
Front-End Developer