목차 기능 구현

7일 전

블로그에서 사용하는 목차 기능을 구현했다.


내 블로그의 포스트 렌더링 방식

포스트 에디터를 위해 TipTap이라는 라이브러리를 사용했다. 이 에디터를 사용하면 다음과 같은 JSON이 생성된다.

이미지
{
  "type": "doc",
  "content": [
      {
        "type": "heading",
        "attrs": {
            "level": 2
        },
        "content": [
            {
              "type": "text",
              "text": "h2 태그입니다."
            }
        ]
      },
      {
        "type": "paragraph",
        "content": [
            {
              "type": "text",
              "text": "p태그 입니다."
            }
        ]
      },
      {
        "type": "paragraph"
      }
  ]
}

이 JSON을 Postgres(Serverless Postgres platform인 Neon)에 저장하고, /post 페이지에서 이 JSON을 Viewer로 파싱하는 식으로 사용하고 있다.

자세한 내용은 https://github.com/yewonJin/blog-app/issues/3 참고하면 된다.


Post Viewer가 렌더링한 요소들 가져오기

목차를 구현하기 위해서는 heading 요소들이 필요하다. 이 heading 요소들은 Viewer의 요소들이다. 이 Viewer의 요소들을 얻기 위해 useRef를 이용했다.

export default function PostDetail({ post, indexedNode }: Props) {
  const ref = useRef<HTMLDivElement>(null);

  return (
    <div className="mx-auto flex w-full justify-center gap-12">
      <div className="flex w-[800px] flex-col gap-4">
        ...
        <div ref={ref}>
          <Suspense>
            <TiptapViewer node={indexedNode} />
          </Suspense>
        </div>
      </div>
      <div className="hidden w-[250px] xl:block">
        <TableOfContents ref={ref} node={indexedNode} />
      </div>
    </div>
  );
}

Viewer의 상위 요소를 ref를 이용하여 얻은 후, TableOfContents 컴포넌트로 전달했다.

TableOfContents 컴포넌트는 목차 기능과 관련된 함수들을 실행하고, heading 요소들을 하이라이팅한다

리액트 19에서 ref

리액트 19 이전에는 forwardRef를 이용하여 ref를 컴포넌트로 전달했는데, 리액트 19부터는 간편하게 prop로 ref 를 전달할 수 있게 되었다.

// 이전
const TableOfContents = React.forwardedRef((props, ref) => {
	...
}

// 이후
type Props = {
  node: TipTapNode;
  ref: React.RefObject<HTMLDivElement | null>;
};

export function TableOfContents({ node, ref }: Props) {
	...
}


heading 요소들 추출하기

위에서 얻은 Viewer의 요소들로부터 heading 요소들을 추출해야 한다.

일단 debugger를 이용해, ref를 살펴보겠다.

useEffect(() => {
	const contentContainer = ref.current;
	
	// debugger;
  ...
}, [ref])
이미지

Viewer의 요소들이 담긴 객체는 contentContainer.children[0].children 이다.

useEffect(() => {
	const contentContainer = ref.current;
	if(!contentContainer) return;
	
  const HEADING_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
  const headingElements = Array.from(
    contentContainer.children[0].children,
  ).filter((node): node is HTMLElement =>
    HEADING_TAGS.includes(node.nodeName),
  );
  
  ...
}, [ref])

이 중에서 heading 태그들만 필터링해야 한다. 그래서 HEADING_TAGS 라는 배열에 얻어야하는 tagName(nodeName)들을 기입했다.


Viewer의 요소들이 담긴 객체를 배열로 만들고(HTMLCollection은 유사 배열 객체라 배열 메서드를 사용할 수 없기 때문에), filter 메서드를 이용해서 heading 태그들만 필터링했다.

이미지

이렇게 목차에 필요한 heading 요소들을 모두 구했다.


(기존) 목차 기능 구현 방법

목차 하이라이팅을 구현하기 위해 HTMLElementoffsetTop 값인, heading 태그들의 HTML 요소의 상대적인 위치를 사용했었다. 그러나 이미지와 비디오 같이 나중에 로딩되는 요소가 있으면 정확하지 않다는 문제가 있었다.


이미지

계산된 offsetTops 에서 index가 1인 요소의 offsetTop 값은 983 이다.


이미지

하지만 모든 이미지나 비디오가 로딩된 후에 index가 1인 요소의 offsetTop 값은 2055 인 것을 볼 수 있다.


개선된 목차 기능 구현 방법

현재 보고 있는 컨텐츠가 어떤 heading 요소의 컨텐츠인지 판별하기 위해 브라우저 스크롤과 관련된 offsetTop 를 이용하지 않고 IntersectionObserver 를 이용했다.


우선 debugger 를 이용하여, IntersectionObserver이 heading 요소들을 감지하는 케이스들을 살펴보자.

useEffect(() => {
	...
	const observer = new IntersectionObserver(
	  ([entry]) => {
	    debugger;
	  },
	  { rootMargin: "-70px 0px" }
	);
	
	headingElements.forEach((heading) => {
	  observer.observe(heading);
	});
	
	return () => {
	  headingElements.forEach((heading) => {
	    observer.unobserve(heading);
	  });
	};
, [ref]}

아래의 4가지 경우에 이벤트가 발생한다.

케이스 분석

아래로 스크롤하다가 heading 태그가 위에서 사라질 때

이미지

아래에 있는 content를 보기 위해, 아래로 스크롤을 하다가 heading 요소가 위에서 사라질 때 isIntersecting: false, target : index = 0인 heading 요소 이벤트가 발생한다.


아래로 스크롤하다가 heading 태그가 아래에서 보여질 때

이미지

아래에 있는 content를 보기 위해, 아래로 스크롤을 하다가 heading 태그가 아래에서 보여질 때 isIntersecting : false , target : index = 1인 heading 요소 이벤트가 발생한다.


위로 스크롤하다가 heading 태그가 아래에서 사라질 때

이미지

위에 있는 content를 보기 위해, 위로 스크롤을 하다가 heading 태그가 아래에서 사라질 때, isIntersecting : false , target : index = 2인 heading 요소 이벤트가 발생한다.


위로 스크롤하다가 heading 태그가 위에서 보여질 때

이미지

위에 있는 content를 보기 위해, 위로 스크롤을 하다가 heading 태그가 위에서 보여질 때,

isIntersecting : true , target : index = 1인 heading 요소 이벤트가 발생한다.


필요한 케이스

위 4가지의 케이스 중 목차 하이라이팅에 필요한 케이스는 첫 번째와 마지막 케이스이다.

이미지

아래로 스크롤 중 heading 요소가 기준선보다 위에 위치할 때 → heading 요소 하이라이팅


이미지

위로 스크롤 중 heading 요소가 기준선보다 아래에 위치할 때 → 인덱스가 [heading 요소-1]인 요소 하이라이팅


이를 코드로 하면 아래와 같이 된다.

const [highlightedIndex, setHighlightedIndex] = useState(-1);

...

const observer = new IntersectionObserver(
  ([entry]) => {
    const currentHeading = entry.target as HTMLElement;
    const currentIndex = headingElements.indexOf(currentHeading);

    if (!entry.isIntersecting && entry.boundingClientRect.y < 100) {
      setHighlightedIndex(currentIndex);
    }

    if (entry.isIntersecting && entry.boundingClientRect.y < 100) {
      setHighlightedIndex(currentIndex - 1);
    }
  },
  { rootMargin: '-150px 0px' },
);


rootMargin을 사용한 기준선 변경

위의 코드에서 rootMargin 을 “-150px 0px”로 설정한 것을 볼 수 있는데, 왜 이렇게 했을까?


rootMargin은 관찰 대상 요소와 루트 요소 간의 관찰 영역을 조정하는 속성이다. 만약 rootMargin: "50px" 라면 요소가 화면에 50px 전에 도달하면 보여진 걸로 감지되고 rootMargin: "-50px" 이면, 요소가 화면에 50px 더 들어와야 보여진 걸로 감지되는 것이다.


이와는 반대로 나는 사라질 때(뷰포트에서 벗어날 때)를 기준으로 사용하고 있다. 만약 rootMargin: "50px" 이면, 요소가 화면에 50px 더 들어가야 사라진 걸로 감지되고, rootMargin: "-50px" 이면, 요소가 화면에 50px 덜 들어가도 사라진 걸로 감지된다.


이미지

rootMargin를 사용하지 않았을 때는 scrollY395일 때, 하이라이팅 된다.


이미지

rootMargin: -150px를 사용하면 scrollY245일 때, 하이라이팅 된다.

150px 덜 들어가도 사라진 걸로 감지된다.


이와 같이 rootMargin을 이용하여 감지의 기준선 위치를 변경할 수 있다.

완성된 코드

  useEffect(() => {
    const contentContainer = ref.current;

    if (!contentContainer) return;

    const HEADING_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
    const headingElements = Array.from(
      contentContainer.children[0].children,
    ).filter((node): node is HTMLElement =>
      HEADING_TAGS.includes(node.nodeName),
    );

    const observer = new IntersectionObserver(
      ([entry]) => {
        const currentHeading = entry.target as HTMLElement;
        const currentIndex = headingElements.indexOf(currentHeading);

        if (!entry.isIntersecting && entry.boundingClientRect.y < 160) {
          setHighlightedIndex(currentIndex);
        }

        if (entry.isIntersecting && entry.boundingClientRect.y < 160) {
          setHighlightedIndex(currentIndex - 1);
        }
      },
      { rootMargin: '-150px 0px' },
    );

    headingElements.forEach((heading) => {
      observer.observe(heading);
    });

    return () => {
      headingElements.forEach((heading) => {
        observer.unobserve(heading);
      });
    };
  }, [ref]);


목차 hash navigation 기능

목차의 요소들을 클릭하면, 그 heading 요소로 이동하는 기능인 hash navigation 을 구현해야 한다.

hash navigation 는 a태그의 href에서 #를 이용하면 간단하게 구현할 수 있지만, 다만 heading 요소의 id값은 “유일”해야 하고, 띄어쓰기가 있는 경우 이를 처리해야 한다.

// props.node.attrs.id 값은 TipTap 에디터에서 사용되는 형식이다.
// 이 형식은 무시하고 아래와 같이 하면 hash navigation을 사용할 수 있다.
<h1 id={props.node.attrs.id} key={uuidv4()}>
  <a href={`#${props.node.attrs.id}`}>&#8203;</a>
  {props.children}
</h1>


id 값 만들기

예를 들어, 아래와 같은 태그들이 있다고 해보자.

<h1 id="첫 문제">첫 문제</h1>
<h2 id="해결 방법">해결 방법</h2>
<p>안녕하세요</p>
<h1 id="두 번째 문제">두 번째 문제</h1>
<h2 id="해결 방법">해결 방법</h2>

“해결 방법”이라는 id는 유일 값이 아니므로 navigation을 할 때, 문제가 생긴다.

→ heading 태그의 id 값들을 유일한 id 값으로 처리해야한다.


띄어쓰기 처리

const headingId = replaceSpaceWithDash(
  normalizeSpaces(item.content[0].text),
);

const normalizeSpaces = (str: string) => {
  return str.replace(/\s{2,}/g, ' ');
};

const replaceSpaceWithDash = (str: string) => {
  return str.replace(/\s/g, '-');
};

일단 태그의 id 값은 공백을 포함할 수 없기 때문에, heading 요소의 text 값의 띄어쓰기 값을 처리해야 한다.

  1. 띄어쓰기가 2개 이상이면 1개로 줄이기

  2. 띄어쓰기를 ‘-”로 변환


유일한 id 값 처리

중복된 heading id 값이 없게 하기 위해 배열을 이용할 것이다.

// elements는 위의 예시에서 h1, h2, p, h1, h2이다.
elements?.map((item) => {
  if (item.type === "heading") {
    if (!item.content?.[0]?.text) return item;

    const headingId = replaceSpaceWithDash(
      normalizeSpaces(item.content[0].text)
    );
    headingIds.push(headingId);

    const duplicateCount = headingIds.filter((id) => id === headingId).length;

    return {
      ...item,
      attrs: {
        ...item.attrs,
        id:
          duplicateCount > 1 ? `${headingId}-${duplicateCount - 1}` : headingId,
      },
    };
  }

  return item;
});

Tiptap 에디터의 Node를 기반으로 Post를 구현했기 때문에 변수나 key 값은 가볍게 보고 넘기면 된다.

  1. 띄어쓰기 처리한 headingId 값을 배열에 push한다.

  2. 배열에서 중복되는 값이 몇 개 있는지 구한다.

  3. 중복되는 값이 있으면 이를 id 값에 추가한다.

예시를 이용해 설명하겠다.

이미지


이미지


이미지


이미지


목차를 클릭했을 때

목차를 클릭했을 때, 해당 heading 요소로 이동하기 위해 a태그의 href를 이용했다.

return (
  <div className="flex flex-col gap-3 text-sm">
    {node.content
      .filter((node): node is HeadingNode => node.type === 'heading')
      .map((heading, index) => (
        <a
          className={cn(
            'text-neutral-tertiary',
            headingPaddings[heading.attrs.level],
            index === highlightedIndex
              ? 'text-neutral-primary'
              : 'hover:text-neutral-secondary',
          )}
          key={heading.attrs.id}
          href={`#${heading.attrs.id}`}
          onClick={() => handleHeadingClick(index)}
        >
          {heading.content?.[0]?.text}
        </a>
      ))}
  </div>
);

const handleHeadingClick = (index: number) => {
  // 이동하는 중간에 Observe가 되어 콜백큐에 넣어 처리
  setTimeout(() => {
    setHighlightedIndex(index);
  }, 10);
};

이벤트 핸들러가 실행될 때, 이동하는 중간에 heading 요소가 감지되는 문제가 있어서 setTimeout을 이용하여 콜백 큐에 넣어 콜 스택이 비었을 때 highlightIndex를 index로 설정하게 했다.