요즘 너무 바쁘지만, 주말을 맞아 NextJS로 만들고 있던 Blog에 ToC 컴포넌트 만들어 봤습니다.
이번 포스트에서는 어떤 부분에 포커스를 두고 개발을 했고, 구현하며 있었던 문제점과 해결방법에 대해 정리해보고자 합니다.
구현한 최종 모습은 아래와 같을 것입니다. (영상을 찍으면서 box-shadow효과가 뭉개져서 나오는데 이 부분은 무시해주세요..ㅎ)
주요 기능사항
- 포스트의 HTag를 기반으로 목차를 만든다(범위 : h1 ~ h3)
- 목차를 열고 닫을 수 있어야 한다.
- ToC 컴포넌트가 메인컴포넌트가 겹치지 않는다면 열려 있는 것이 default이어야 한다.
- 겹친다면, 닫혀 있는 것이 default이어야 한다.
- 현재 읽고 있는 부분이 ToC 컴포넌트에 색상으로 표시되어야 한다.
- ToC컴포넌트에서 클릭 시, 해당 컨텐츠로 이동해야 한다.
구현 로직
제가 생각했던 기본 로직은 아래와 같습니다.
- post 본문을 감싸는 태그에 유니크한 아이디를 부여한다.(e.g. #content__entry__point)
(물론, 본문을 string으로 넘겨 처리하는 방법도 존재하지만, tistory와 같은 곳에서도 사용할 수 있도록 이러한 방법을 선택했습니다.) - 본문에 있는 h1~h3 태그들을 모두 찾는다.
- 찾은 hTag들을 기반으로 목차에 넣을 li 요소를 만들어준다.
(이때 만들면서 클릭하면, 해당요소의 위치로 이동하는 eventListner도 같이 설정 해 줍니다.) - 현재 focused Element를 찾아 표시한다.
- 스크롤 이벤트가 일어날 때 마다 focused Element를 찾아 표시해준다.
가장 하단에 전체 구현코드를 첨부하도록 하고, 바로 TroubleShooting으로 넘어가도록 하겠습니다.
TroubleShootings
[메인 컨텐츠 위치 찾기 - Problem] 잘못된 방법(IntersectionObserver)
사실 로직을 구현하는 부분 자체는 크게 어렵지 않았습니다. 다만, 현재 읽고 있는 부분을 어떻게 정의하고 이를 찾는 방법을 구현하는 것에 가장 많은 시간이 소모되었습니다.
가장 처음 생각 했던 방법은 IntersectionObeserver 였습니다.
현재 화면에 보이고 있는 hTag를 찾고, 상단 20%정도안에 위치한 hTag중 가장 처음에 있는 태그를 메인 컨텐츠로 설정하면 어떨까 했습니다. 하지만, 이 방법은 아래 사진에서 볼 수 있듯이 컨텐츠양이 커질 경우 문제가 있었습니다.
위 사진을 보면 현재 메인 컨텐츠는 올드타운하우스 부분이지만, ToC에서는 그 하단에 있는 도르트문트 U-타워를 보여주고 있습니다.
이 문제는 메인 컨텐츠가 길어져서 해당 제목(HTag)이 화면에 더 이상 보이지 않는 경우 발생합니다.
[메인 컨텐츠 위치 찾기 - Solve] 직접 메인 컨텐츠 판단하는 함수 개발
제가 원하는 방식으로 메인컨텐츠를 찾아내기 위해 아래처럼 직접 함수를 만들기로 했습니다.
최대한 자세히 주석을 달도록 하였지만, 궁금하시거나 잘못된 부분이 있다면 댓글로 알려주세요.
/**
* 진짜 내가 보고 있는 컨텐츠의 헤드를 찾아주는 함수
* 단순히 ObserverIntersection을 이용하면, 컨텐츠가 길어지는 경우, HTag가 보이지 않으면
* 내가 보고 있는 컨텐츠가 아니라, 하단에 아직 읽지 않고 있는 hTag에 포커스가 가는 것을 방지하기 위함
* @param mainContentHeight
* @returns
*/
function getMainElementAtMainContentHeight(mainContentHeight: number) {
// 메인 컨텐츠의 높이(mainContentHeight)
// 이전 태그의 TOP값(PT)
// 이후 태그의 TOP값(NT)
// MainTag(MT)
// 조건: 항상 PT < NT
// 경우의 수
// 1. PT <= mainContentHeight < NT ==> MT = PT
// 2. mainContentHeight < PT ==> MT = PT
// 3. PT < mainContentHeight && NT <= mainContentHeight ==> MT = NT
let MT = hTags[0];
for (let i = 1; i < hTags.length; i++) {
const PT = hTags[i];
if (
mainContentHeight < MT.offsetTop &&
mainContentHeight < PT.offsetTop
) {
return MT;
} else if (
MT.offsetTop < mainContentHeight &&
mainContentHeight <= PT.offsetTop
) {
return MT;
} else {
MT = PT;
}
}
return MT;
}
이 함수를 이용하여 아래처럼 컨텐츠가 길어져서 현재 보이는 HTag는 This is the Second H1 Tag 밖에 없더라도
메인 컨텐츠는 상단의 andSomeLongPart 태그라고 판단 할 수 있었습니다.
[현재 보고 있는 위치 찾기 - Problem] 잘못된 방법: window.innerHeight
사실 위에서 설명한 함수를 통해 메인 컨텐츠를 찾을 수는 있지만, 메인 컨텐츠의 높이를 구하는 것 자체에서 막혔습니다.
window.innerHeight로 판단하면 될 것 같았는데, 이 방식으로는 스크롤에 따라 변하는 내가 현재 보고 있는 위치를 알 수 없었습니다.
[현재 보고 있는 위치 찾기 - Solve] innerHeight + scrollY + Offset
사실 실시간으로 내가 보고 있는 위치를 찾는 방법은 어렵지 않았습니다. innerHeight와 scrollY를 통해 현재 위치를 찾을 수 있었고, 따로 offset을 추가 하면서 정확히 화면상단 n%의 높이를 찾을 수 있었습니다.
하단 코드에서 각각의 변수들이 의미하는 바는 아래와 같습니다.
- window.innerHeight / 2 : 화면의 전체 뷰포트 높이를 픽셀로 나타낸 수의 절반
- window.scrollY : 원점으로부터 문서를 수직방향으로 스크롤한 픽셀 수
- mainContentOffset : 기준점을 화면 상단으로부터 30%인 지점으로 잡기 위한 offset
(e.g. 화면 상단으로부터 20%를 기준점으로 잡으려면 mainContentOffset을 0.3으로 조정)
function setFocusedElement() {
const mainContentOffset = 0.2;
const mainContentHeight =
window.innerHeight * (0.5 - mainContentOffset) + window.scrollY;
const mainTag =
getMainElementAtMainContentHeight(mainContentHeight);
const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);
focusedTag.current?.classList.remove(classes.focused);
focusedTocTag?.classList.add(classes.focused);
focusedTag.current = focusedTocTag ?? null;
}
[ToC요소 클래스명 부여 - Problem] 잘못된 방법: focuse변할 때마다 리렌더링(feat. useState)
위에서 개발한 함수를 통해 메인 컨텐츠를 찾는 부분은 해결했지만, 여기에서 찾은 HTag는 본문의 태그이기에 ToC컴포넌트의 Element에 제가 원하는 클래스(".focused")를 부여해주는 작업이 필요했습니다. 처음에는 그냥 ToC순회 하면서 li Element안의 innerHTML이 내가 찾은 HTag의 innerHTML과 같은 경우에 ".focused"클래스를 부여해주려고 하였습니다.
하지만, 이 경우에 focused가 바뀌었을 때, 제거 하는 작업을 하기위해 또 순회해야 하는 문제가 생겼습니다.
그래서 focusedTag를 state로 관리를 하려고 하였지만, 이렇게 되면 focusedTag가 변할 때 마다 ToC컴포넌트가 리렌더링되는 문제가 발생하였습니다.
[ToC요소 클래스명 부여 - Solve] Map으로 관리(feat. useRef)
이러한 해결하기 위해 ToC의 li Element를 Map으로 관리하고, focused Element는 useRef로 관리하기로 했습니다.
hTag를 순회하며 ToC의 li Element를 생성해주는 과정에서 실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가지는 Map을 생성해주었습니다.
//...
const hTagToTocElMapper = new Map<String, HTMLElement>();
//...
//HTag를 돌면서, Toc에 넣을 Element를 생성해서 넣어준다.
hTags?.forEach((hTag) => {
const liEl = document.createElement('li');
const linkEl = document.createElement('a');
//...
//liElement를 생성하면서 바로 Map에 넣어준다.
//실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가진다.
hTagToTocElMapper.set(hTag.innerHTML, liEl);
listContainer?.appendChild(liEl);
});
그리고 하단처럼 focusedElement를 세팅해주는 함수에서 아래처럼 기존에 있는 focusedTag의 클래스를 삭제하고, 현재 focusedTag에 클래스를 부여하고 useRef에 세팅해주는 방식으로 해결하였습니다. 이를 통해 리렌더 되지 않고도 focusedTag를 적절히 관리 할 수 있었습니다.
//...
const focusedTag = useRef<HTMLElement | null>(null);
//...
function setFocusedElement() {
//...
const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);
focusedTag.current?.classList.remove(classes.focused);
focusedTocTag?.classList.add(classes.focused);
focusedTag.current = focusedTocTag ?? null;
}
이를 통해 앞서 보여드린 결과물 처럼 메인 컨텐츠에 따라 focused 된 부분을 표현 할 수 있었습니다.
사용방법
사용방법은 아래와 같습니다.
여기에서 주의할 점은 본문이 있는 CustomMarkdown 태그를 content__entry__point id를 가진 div로 감싸줬다는 점입니다.
그리고, 하단에 ToC컴포넌트를 호출해주면 됩니다.
<div className={classes.content__wrapper}>
<FadeIn from="left">
<div id="content__entry__point">
<CustomMarkdown components={post} />
</div>
</article>
</FadeIn>
<Toc title={post.title}></Toc>
</div>
구현코드
전체 구현코드는 아래와 같습니다. (Next.js를 사용하고 있기에 use clinet로 client 컴포넌트임을 명시했습니다.)
내부 구현 로직에 대해서는 최대한 자세히 주석을 달았습니다.
잘못된 점이 있거나 궁금한 점이 있다면 댓글로 남겨주시면 감사하겠습니다!
'use client';
import classes from './toc.module.scss';
import ListIcon from './icons/list-icon';
import CloseIcon from './icons/close-icon';
import { useEffect, useRef, useState } from 'react';
type TocProps = {
title?: string;
};
const levelMap: { [key: string]: string } = {
H1: 'lv1',
H2: 'lv2',
H3: 'lv3',
};
export default function Toc({ title }: TocProps) {
const [tocOpened, setTocOpened] = useState(false);
const [hTags, setHTags] = useState<[] | NodeListOf<HTMLElement>>([]);
const focusedTag = useRef<HTMLElement | null>(null);
console.log('hmm');
//처음 렌더 후, hTag 모두 찾기.
useEffect(() => {
const entryPoint = document.querySelector('#content__entry__point');
const hTagEls = entryPoint?.querySelectorAll(
'h1, h2, h3'
) as NodeListOf<HTMLElement>;
setHTags(hTagEls);
//현재 창 넓이가 1350 이하면 ToC와 메인 컴포넌트가 겹친다.
//그 이상이라면, ToC를 처음에 열어준다.
const width = window.innerWidth;
if (width >= 1350) {
setTocOpened(true);
}
}, []);
//hTag를 다 찾으면, 아래 로직 실행
useEffect(() => {
//실제 HTag에 해당하는 TocElement를 맵핑해주는 Map
//실제 컨텐츠의 hTag의 innerHTML을 key로, TocElement를 value로 가진다.
const hTagToTocElMapper = new Map<String, HTMLElement>();
const listContainer = document?.querySelector('#toc__list');
//HTag를 돌면서, Toc에 넣을 Element를 생성해서 넣어준다.
hTags?.forEach((hTag) => {
const liEl = document.createElement('li');
const linkEl = document.createElement('a');
linkEl.innerHTML = hTag.innerHTML;
//linkEl를 클릭하면, 해당 Tag를 가진 곳으로 스크롤 해 준다.
linkEl.addEventListener('click', () => {
window.scrollTo({
top: hTag.offsetTop - 20,
behavior: 'smooth',
});
});
liEl.appendChild(linkEl);
const levelClass = levelMap[hTag.tagName];
liEl.classList.add(classes[levelClass]);
hTagToTocElMapper.set(hTag.innerHTML, liEl);
listContainer?.appendChild(liEl);
});
//현재 focused Element를 찾아 세팅해준다.
setFocusedElement();
//scroll Event가 발생할 때마다 focused Element를 찾아 세팅해준다.
const findCurTagEvent = (window.onscroll = () => setFocusedElement());
function setFocusedElement() {
// window.innerHeight / 2 : 화면의 전체 뷰포트 높이를 픽셀로 나타낸 수의 절반
// window.scrollY : 원점으로부터 문서를 수직방향으로 스크롤한 픽셀 수
// mainContentOffset : 기준점을 화면 상단으로부터 30%인 지점으로 잡기 위한 offset
// e.g. 화면 상단으로부터 20%를 기준점으로 잡으려면 mainContentOffset을 0.3으로 조정
const mainContentOffset = 0.2;
const mainContentHeight =
window.innerHeight * (0.5 - mainContentOffset) + window.scrollY;
const mainTag =
getMainElementAtMainContentHeight(mainContentHeight);
const focusedTocTag = hTagToTocElMapper.get(mainTag?.innerHTML);
focusedTag.current?.classList.remove(classes.focused);
focusedTocTag?.classList.add(classes.focused);
focusedTag.current = focusedTocTag ?? null;
}
/**
* 진짜 내가 보고 있는 컨텐츠의 헤드를 찾아주는 함수
* 단순히 ObserverIntersection을 이용하면, 컨텐츠가 길어지는 경우, HTag가 보이지 않으면
* 내가 보고 있는 컨텐츠가 아니라, 하단에 아직 읽지 않고 있는 hTag에 포커스가 가는 것을 방지하기 위함
* @param mainContentHeight
* @returns
*/
function getMainElementAtMainContentHeight(mainContentHeight: number) {
// 메인 컨텐츠의 높이(mainContentHeight)
// 이전 태그의 TOP값(PT)
// 이후 태그의 TOP값(NT)
// MainTag(MT)
// 조건: 항상 PT < NT
// 경우의 수
// 1. PT <= mainContentHeight < NT ==> MT = PT
// 2. mainContentHeight < PT ==> MT = PT
// 3. PT < mainContentHeight && NT <= mainContentHeight ==> MT = NT
let MT = hTags[0];
for (let i = 1; i < hTags.length; i++) {
const PT = hTags[i];
if (
mainContentHeight < MT.offsetTop &&
mainContentHeight < PT.offsetTop
) {
return MT;
} else if (
MT.offsetTop < mainContentHeight &&
mainContentHeight <= PT.offsetTop
) {
return MT;
} else {
MT = PT;
}
}
return MT;
}
//걸어줬던 스크롤 이벤트를 clean-up 해준다.
return findCurTagEvent;
}, [hTags]);
return (
<>
<div
className={`${classes.icon} ${tocOpened && classes.opened}`}
onClick={() => setTocOpened((prev) => !prev)}
>
<ListIcon size="25px" />
</div>
{hTags.length > 0 && (
<div
className={`${classes.toc} ${tocOpened && classes.opened}`}
>
<nav>
<header className={`${classes.title}`}>
<div
className={classes.close__icon}
onClick={() => setTocOpened(false)}
>
<CloseIcon size="12.5px" />
</div>
<p>{title ? title : 'Table Of Contents'}</p>
</header>
<div className={`${classes.toc__body}`}>
<ul
className={classes.toc__list}
id="toc__list"
></ul>
</div>
</nav>
</div>
)}
</>
);
}
'React' 카테고리의 다른 글
Preview 있는 File Input 만들기(Next.js, TS) (1) | 2024.01.29 |
---|---|
[React] Custom useForm 직접 만들기 (feat. zod) (1) | 2024.01.08 |