๐Ÿ“‘ ๋ชฉ์ฐจ ์Šคํฌ๋กค ( TOC )

๋ฐ•์ƒ์€ยท2022๋…„ 6์›” 28์ผ
1

๋ชฉ์ฐจ ์Šคํฌ๋กค gif

Next.js + tailwind css๋ฅผ ์ด์šฉํ•œ TOC(Table Of Contents) ๊ตฌํ˜„ ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ์ •๋ฆฌ ํฌ์ŠคํŠธ์ž…๋‹ˆ๋‹ค.

Next.js๋ฅผ ์ด์šฉํ•˜๊ธด ํ–ˆ์ง€๋งŒ, ๊ตณ์ด Next.js๊ฐ€ ์•„๋‹ˆ๋”๋ผ๋„ ์ƒ๊ด€์—†์Šต๋‹ˆ๋‹ค.

  • ์ฐธ๊ณ  ์‚ฌํ•ญ
    1. ํŠน์ • ํƒœ๊ทธ์˜ id๊ฐ€ ์กด์žฌํ•œ๋‹ค๋ฉด <a href="#id"> ํ˜•ํƒœ๋กœ ๋งŒ๋“  ์•ต์ปค๋ฅผ ํด๋ฆญํ•˜๋ฉด ํ•ด๋‹น ์•„์ด๋””๋ฅผ ๊ฐ€์ง„ ํƒœ๊ทธ๊ฐ€ ํ™”๋ฉด์— ๋ณด์ด๋„๋ก ์Šคํฌ๋กค์ด ์ด๋™๋ฉ๋‹ˆ๋‹ค.
    2. IntersectionObserver๋Š” ์›ํ•˜๋Š” ํƒœ๊ทธ๊ฐ€ ํ™”๋ฉด์— ๋ Œ๋”๋ง๋  ๋•Œ ํŠน์ • ์ด๋ฒคํŠธ๋ฅผ ์‹คํ–‰ํ•˜๋„๋ก ๋„์™€์ฃผ๋Š” API์ž…๋‹ˆ๋‹ค. ( ์ฐธ๊ณ  )
    3. ํŠน์ • ํƒœ๊ทธ ๋‚ด๋ถ€์˜ ํ…์ŠคํŠธ ๊ฐ’์„ textContent๋ผ๊ณ  ๋ช…์นญ ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

๐Ÿง ๊ตฌํ˜„ ๋ฐฉ๋ฒ•

  1. TOC๋ฅผ ์›ํ•˜๋Š” ์˜์—ญ์„ ๋ชจ๋‘ ํฌํ•จํ•˜๋Š” ์ตœ์ƒ์œ„ ํƒœ๊ทธ๋ฅผ ์ฐพ๋Š”๋‹ค.
  2. ์ตœ์ƒ์œ„ ํƒœ๊ทธ์˜ ํ•˜์œ„ ํƒœ๊ทธ์ค‘์— <h*>ํƒœ๊ทธ๋“ค์„ ๋ชจ๋‘ ์ˆœ์„œ๋Œ€๋กœ ์ฐพ๋Š”๋‹ค.
  3. <h*>ํƒœ๊ทธ๋“ค์— ๊ฐ๊ฐ id๋กœ textContent๋ฅผ ๋„ฃ๋Š”๋‹ค.
  4. <a href="#textContent">ํ˜•ํƒœ๋กœ ๋งŒ๋“ค์–ด์„œ ์šฐ์ธก ์ƒ๋‹จ์— ๋ Œ๋”๋งํ•œ๋‹ค.
  5. ![](https://velog.velcdn.com/images/1-blue/post/ab3cecbd-1094-40f7-898f-02f3b43229d6/image.gif) ๋ฅผ ์ด์šฉํ•ด์„œ ํŠน์ • ๋ชฉ์ฐจ๊ฐ€ ํ™”๋ฉด์— ๋ Œ๋”๋ง๋˜๋ฉด ๊ฐ•์กฐ ํ‘œ์‹œํ•ด์ค€๋‹ค.

โœจ ์ฝ”๋“œ ์ ์šฉ ์˜ˆ์‹œ

import { useEffect, useState } from "react";

// ํด๋ž˜์Šค๋“ค์„ ํ•˜๋‚˜์˜ ๋ฌธ์ž์—ด๋กœ ํ•ฉ์ณ์ฃผ๋Š” ํ—ฌํผ ํ•จ์ˆ˜
import { combineClassNames } from "@src/libs/utils";

const TOC = () => {
  // ๋ชฉ์ฐจ ๋ฆฌ์ŠคํŠธ ( index: ๋ชฉ์ฐจ, size: ๋ชฉ์ฐจ์˜ ํฌ๊ธฐ ( h1~h6๋Š” ํฌ๊ธฐ๋ฅผ ๋‹ค๋ฅด๊ฒŒ ๋ Œ๋”๋งํ•ด์ฃผ๊ธฐ ์œ„ํ•จ ) )
  const [indexList, setIndexList] = useState<{ index: string; size: number }[]>([]);
  
  // ํ˜„์žฌ ๋ณด์ด๋Š” ๋ชฉ์ฐจ ( ๊ฐ•์กฐ ํ‘œ์‹œ ํ•ด์ฃผ๊ธฐ ์œ„ํ•จ )
  const [currentIndex, setCurrentIndex] = useState<string>("");

  useEffect(() => {
    // 1. <main> ๋‚ด๋ถ€์—์„œ๋งŒ ๋ชฉ์ฐจ๋ฅผ ๋งŒ๋“ค๊ฑฐ๋ผ์„œ <main> ์„ ํƒ
    // 2. <h1>, <h2>, <h3> ์ฐพ๊ธฐ ( h4~h6๋Š” ์—†๊ธฐ๋„ ํ•˜๊ณ  ์•ˆ์“ธ๊ฑฐ๋ผ์„œ ์ƒ๋žต )
    const hNodeList = document
      .querySelector("main")
      ?.querySelectorAll("h1, h2, h3") as NodeListOf<Element>;

    // IntersectionObserver๋“ค์ด ๋“ค์–ด๊ฐˆ ๋ฐฐ์—ด ( ์ด๋ฒคํŠธ ํ•ด์ œ๋ฅผ ์œ„ํ•ด )
    const IOList: IntersectionObserver[] = [];
    let IO: IntersectionObserver;

    // ๋งŒ์•ฝ ์—ฌ๊ธฐ์„œ ์˜ค๋ฅ˜๊ฐ€ ๋‚œ๋‹ค๋ฉด "spread opeartor"๋Š” es6๋ถ€ํ„ฐ ์ง€์›๋˜๋Š” ๋ฌธ๋ฒ•์ด๋ผ์„œ ๊ทธ ์ด์ „์— ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” "downlevelIteration"์— ๋Œ€ํ•ด์„œ ์ฐพ์•„๋ณด๋ฉด ๋œ๋‹ค.
    [...hNodeList].forEach((node) => {
      // ๋ชฉ์ฐจ ๋‚ด์šฉ์ด๋ž‘ ์‚ฌ์ด์ฆˆ ๊ตฌํ•ด์„œ ์ €์žฅ
      const index = node.textContent as string;
      const size = (+node.nodeName[1] - 1) * 20;
      setIndexList((prev) => {
        if (prev.map((v) => v.index).includes(index)) return prev;
        return [...prev, { index, size }];
      });

      // 3. ๊ฐ <h*>์— id๋กœ ํ˜„์žฌ ์ปจํ…์ธ  ๋‚ด์šฉ ์ถ”๊ฐ€
      node.id = index;

      // 5. ํ™”๋ฉด์— ๋ณด์ด๋ฉด ๊ฐ•์กฐ๋˜๋„๋ก "IntersectionObserver" ๋“ฑ๋ก
      IO = new IntersectionObserver(
        ([
          {
            isIntersecting,
            target: { textContent },
          },
        ]) => {
          if (!isIntersecting) return;
          setCurrentIndex(textContent!);
        },
        { threshold: 0.5 }
      );
      IO.observe(node);

      // ์ด๋ฒคํŠธ ํ•ด์ œ๋ฅผ ์œ„ํ•ด ๋“ฑ๋ก
      IOList.push(IO);
    });

    // ์ด๋ฒคํŠธ ํ•ด์ œ
    return () => IOList.forEach((IO) => IO.disconnect());
  }, []);

  return (
    <aside className="fixed top-10 right-10 border-l-4 border-indigo-400 px-4 py-2 bg-white z-10">
      // 4.
      <ul>
        {indexList.map(({ index, size }) => (
          <li
            key={index}
            style={{
              paddingLeft: size + "px",
              fontSize: 17 - size / 12 + "px",
            }}
            className={combineClassNames(
              "transition-all hover:text-blue-600",
              currentIndex === index ? "text-indigo-400 scale-105" : ""
            )}
          >
            <a href={`/#${index}`}>{index}</a>
          </li>
        ))}
      </ul>
    </aside>
  );
};

export default TOC;

๐Ÿ™‚ ๋ถ€๋“œ๋Ÿฌ์šด ์Šคํฌ๋กค ์ ์šฉ๋ฒ•

html {
  scroll-behavior: smooth;
}

0๊ฐœ์˜ ๋Œ“๊ธ€