본문 바로가기

[Portfolio 스킨] 23. 자동 목차로 블로그 가독성 200% 업! (1탄: HTML/JavaScript 편)

728x90

 

🤔 내용의 흐름을 파악하기 쉽게 자동 목차를 만들자.

 

원래는 목차를 사용하지 않다가 이 블로그를 가장 많이 사용하는 내가 너무 불편한 거다.

디자인은둉이님이 올려주신 코드를 기반으로 내가 필요한 부분을 수정해서 만들었다.

기본적인 설명도 둉이님 블로그에 자세히 올라와 있다.

 

✨ 자동 목차 커스텀 목표 3가지

첫 번째, 본문 목차의 디폴트는 펼쳐진 상태이고 접으면 목차 내용 영역이 완전히 숨겨질 것.

두 번째, 현재 본문에 보이는 소제목이 오른쪽 목차에 표시되고 목차가 길면 목록 가운데를 유지할 것.

세 번째, 오른쪽 본문 목차 버튼은 오른쪽 목차와 별도로 생성해서 위치를 고정해 둘 것.

 

 

💬 목차 설정 삽질기

자동 목차 설정, 쉽지 않았다…!

레터 스킨을 사용할 때도 자동 목차를 만드는 게 너무 어려웠다.

다른 블로그에서 공유한 코드를 그대로 적용하면 간단하지만, 색상이나 위치 같은 세세한 부분이 내 블로그 스타일과 맞지 않았다.

어디를 수정해야 하는지 몰라 한참 헤매다가 겨우 색상과 위치를 조정하는 데 성공했는데...

스킨을 바꿔버렸지 뭐야. 😂

그래도 다시 도전!

자세한 설명과 적용 방법을 정리해 둔 분이 계셨기에 무사히 성공할 것 같은 느낌이 들었다.

하지만 디자인을 수정하는 과정에서 또다시 며칠을 고생했다. 😭

특히 제목이 보일 때는 오른쪽 목차가 숨겨지고, 본문이 내려가면 목차가 나타나는 구조로 만들고 싶었는데, 이게 문제였다.

영상을 보면 제목이 맨 위에 있을 때 목차가 보였다가, 스크롤을 내려야만 사라진다.

이 부분을 해결하려고 코드를 몇 번이나 뜯어고쳤는지 모른다.

자동 목차를 포기할까 싶었지만, 이미 쏟아부은 시간이 아까워서 쉽게 포기할 수도 없었다.

그러다가 드디어 해결했다!

하지만 허무했다...

원인은 어처구니없는 실수였다.

제목 부분에서 목차를 숨기는 코드를 함수 바깥에 넣어버린 것이 문제였던 것! 😡

if (filteredHeadings.length) { } 안에 넣어야 제대로 작동하는 코드였는데, 이걸 몰라서 계속 삽질했다.

이렇게 간단한 걸 해결 못 하고 난리 친 게 어이없다.

제대로 멍청 시간을 쏟아부었다.

머리가 나쁘면 몸이 고생한다더니...

진짜 틀린 말이 아니었다. 😭

나 자신, 고생 많았다. 👏

 

 

 

🔧 포트폴리오 스킨 수정하기

✔ 티스토리 스킨을 편집하기 위해선 꾸미기 → 스킨 변경 → 변경 → 스킨 편집 → HTML 편집 → HTML 화면으로 들어가면 된다.

 

코드 수정이 끝났다면 적용을 누른다.

 

 

📝 자동목차 JavaScript 설명

1️⃣ 제목 태그(H2, H3, H4) 찾기 & ID 부여

(1) 기본적인 변수 설정과 요소 선택

let idCount = 0; // ID 중복 방지용 카운터

const tags = { // 제목 태그별 숫자 지정
  'H2': 0,
  'H3': 1,
  'H4': 2,
};

const postContent = document.querySelector('.article_view'); // 본문 영역 가져오기
const headings = postContent.querySelectorAll('h2, h3, h4[data-ke-size]'); // 제목 태그 찾기
const filteredHeadings = [...headings].filter(heading => heading.textContent.trim()); // 빈 제목 제거
const minHeadingNumber = Math.min.apply(null, filteredHeadings.map(heading => tags[heading.tagName])); // 가장 작은 태그 숫자 찾기

✔ 각 제목에 고유한 ID를 부여하기 위해 idCount 변수를 0으로 초기화한다.

이후 heading0, heading1, heading2 같은 ID를 자동으로 생성하는 데 사용된다.

✔ HTML에서 <h2>, <h3>, <h4> 태그를 구별하기 위해 tags를 만든다.

✔ .article_view 클래스를 가진 게시글 내용 영역에서 제목(h2, h3, h4)들을 가져온다.

<h4>는 특정 속성(data-ke-size)을 가진 태그만 선택한다.

공백만 있는 제목은 제거하고 실제 내용이 있는 것만 남기고 이 중 가장 중요한 제목(H2, H3, H4 중 가장 작은 숫자)을 찾는다.

→ Math.min.apply(null, [...]) : 배열에서 가장 작은 값을 찾는 방법

예를 들어, H2(0), H3(1), H4(2)가 있다면 0이 가장 작은 값이 된다.

 

(2) ID 생성 및 태그 번호 계산 함수

const generateID = () => `heading${idCount++}`; // ID 자동 생성 함수
const calcTagNumber = (tagName) => tags[tagName] - minHeadingNumber; // 태그 숫자 계산

✔ heading0, heading1, heading2처럼 자동으로 고유한 ID를 만든다.

✔ 제목의 레벨(H2, H3, H4)을 계산할 때 가장 중요한 제목을 0으로 맞추기 위해 조정하는 함수다.

예를 들어 minHeadingNumber가 H2(0)이면 H2는 0, H3는 1, H4는 2로 조정된다.

 

(3) 목차(TOC) HTML 생성

const tocContents = filteredHeadings.reduce((prev, cur, idx) => {
  const headingTagName = cur.tagName;
  const headingText = cur.textContent;
  const headingID = generateID();
  
  cur.id = headingID; // 제목 태그에 ID 부여
  return prev + `<li data-tag="${calcTagNumber(headingTagName)}"><a href="#${headingID}">${headingText}</a></li>`;
}, '');

✔ reduce를 사용해 filteredHeadings에서 목차 항목(li 태그)을 만든다.

✔ generateID()를 사용해서 각 제목에 고유한 ID를 부여하고, 그 ID를 기반으로 <a href="#ID">제목</a> 링크를 생성한다.

data-tag 속성은 제목의 계층을 구별하는 데 사용된다.

 

📂 여기까지 정리

본문 안에서 h2, h3, h4 태그를 찾는다.

각각의 제목에 고유한 ID를 부여한다.

목차에서 제목을 클릭하면 이동 가능하게 만든다.

 

 

2️⃣ 목차(TOC) HTML 추가하기

if (filteredHeadings.length) {
  const tocWrapper = `
    <section class="toc">
      <div class="toc-header">
        <svg class="toc-icon" width="15px" height="15px" viewBox="0 0 24 24">
          <path d="..."></path>
        </svg>
        <span>목차</span>
        <button class="center-toggle-btn">
          <svg width="20px" height="20px" viewBox="0 0 24 24">
            <path d="..."></path>
          </svg>
        </button>
      </div>
      <div class="toc-body">
        <ul class="toc-list">${tocContents}</ul>
      </div>
    </section>
    <div class="toc-right">
      <div class="toc-body">
        <ul class="toc-list">${tocContents}</ul>
      </div>
    </div>
    <button class="right-toggle-btn">
      <svg width="20px" height="20px" viewBox="0 0 24 24">
        <path d="..."></path>
      </svg>
    </button>
  `;

  postContent.insertAdjacentHTML('afterbegin', tocWrapper);

✔ 목차 UI를 HTML로 생성하고 .article_view 안에 추가한다.

toc : 본문 맨 앞에 표시되는 목차

toc-right : 화면 오른쪽에 표시되는 목차

center-toggle-btn, right-toggle-btn : 목차를 펼치고 닫는 버튼

 

 

3️⃣ 목차 열고 닫기 (TOC Toggle)

const toc = postContent.querySelector('.toc');
const rightToc = document.querySelector('.toc-right');

const centerToggleBtn = postContent.querySelector('.center-toggle-btn');
const rightToggleBtn = document.querySelector('.right-toggle-btn');

centerToggleBtn.addEventListener('click', () => {
  toc.classList.toggle('on'); // 'on' 클래스 토글 -> 목차 숨기기/보이기
});

rightToggleBtn.addEventListener('click', () => {
  rightToc.classList.toggle('on'); // 'on' 클래스 토글 -> 오른쪽 목차 숨기기/보이기
  rightToggleBtn.classList.toggle('rotated'); // 'rotated' 클래스 토글 -> 오른쪽 목차 버튼 회전하기
});

✔ 버튼 클릭하면 목차(.toc)와 오른쪽 목차(.toc-right)가 숨겨졌다가 다시 나온다.

centerToggleBtn : 중앙 목차를 열고 닫는 버튼.

rightToggleBtn : 오른쪽 목차를 열고 닫는 버튼.

 

 

4️⃣ 목차 클릭하면 해당 위치로 스크롤 이동

const handleTocClick = (event) => {
  event.preventDefault(); // 기본 클릭 이벤트 막기
  const targetHeadingID = event.target.getAttribute('href'); // 클릭한 링크의 ID 가져오기
  const target = document.querySelector(targetHeadingID);
  target?.scrollIntoView({ behavior: 'smooth' }); // 부드럽게 스크롤 이동
};

toc.addEventListener('click', handleTocClick);
rightToc.addEventListener('click', handleTocClick);

✔ 목차 항목을 클릭하면 해당 제목으로 이동한다. 이때 스크롤 효과는 부드럽게 적용.

 

 

반응형

5️⃣ 화면 너비에 따라 오른쪽 목차 숨기기/보이기

const handleTocScroll = ([entries]) => {
  const isTocVisible = entries.isIntersecting;
  const rootWidth = document.body.offsetWidth;

  if (rootWidth > 1300) {
    isTocVisible ? rightToc.classList.remove('on') : rightToc.classList.add('on');
  }
};

const tocObserver = new IntersectionObserver(handleTocScroll);
tocObserver.observe(toc);

✔ 화면 너비가 1300px 이상일 때 오른쪽 목차가 펼쳐지고 1300px 이하일 때 오른쪽 목차가 접힌다.

목차가 화면에서 사라지면 → rightToc에 on 클래스를 추가해서 오른쪽 목차를 표시한다.

목차가 화면에 보이면 → on 클래스를 제거해서 오른쪽 목차 숨긴다.

 

 

6️⃣ 현재 위치에 따라 오른쪽 목차 숨기기/보이기

const postTitle = document.querySelector('.inner_header');

const titleObserver = new IntersectionObserver(([entry]) => {
  const isVisible = entry.isIntersecting;

  rightToc.style.opacity = isVisible ? '0' : '1';
  rightToc.style.pointerEvents = isVisible ? 'none' : 'auto';
  rightToggleBtn.style.opacity = isVisible ? '0' : '1';
  rightToggleBtn.style.pointerEvents = isVisible ? 'none' : 'auto';

}, { threshold: 0 });

titleObserver.observe(postTitle);

✔ .inner_header(게시글 제목 부분)이 화면에 보이는지 감지한다.

게시글 제목이 보이면 → 오른쪽 목차와 오른쪽 목차 버튼을 opacity: 0으로 설정해서 숨긴다.

게시글 제목이 안 보이면 → opacity: 1로 변경해서 보이게 한다.

opacity: 0일 땐 클릭할 수 없게 pointerEvents: none을 설정한다.

 

 

7️⃣ 스크롤할 때 오른쪽 목차 항목 강조하기 & 자동 위치 조정

현재 화면에 보이는 제목을 기준으로 오른쪽 목차에서 해당 항목을 강조 표시! (active 클래스 추가)

 

(1) 오른쪽 목차의 리스트 요소 선택

const rightTocElements = rightToc.querySelectorAll('li a');
const rightTocBody = rightToc.querySelector('.toc-right .toc-body');

rightTocElements : 오른쪽 목차 내부의 <li> 안에 있는 <a> 태그(각 목차 항목)를 모두 선택해서 가져온다.

rightTocBody : .toc-right 안에 있는 .toc-body 요소를 가져온다. (오른쪽 목차의 스크롤 영역)

 

(2) 오른쪽 목차 활성화(active) 함수

const handleRightTocScroll = (entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {  
      const targetID = entry.target.id;

entries : IntersectionObserver가 감지한 요소 리스트

entry.isIntersecting : 요소가 화면에 보이는 것인지 아닌지를 확인.

targetID : 현재 화면에 보이는 제목(h2, h3, h4)의 id를 가져온다.

 

(3) 해당 제목과 연결된 목차 항목 활성화

rightTocElements.forEach(link => {
  if (link.getAttribute('href') === `#${targetID}`) {
    link.classList.add('active');
  } else {
    link.classList.remove('active');
  }
});

rightTocElements(오른쪽 목차의 <a> 태그들)를 모두 순회하면서, 해당 <a> 태그의 href 속성이 현재 보이는 제목의 id와 같은지 확인한다.

만약 같은 id를 가리키고 있다면 classList.add('active')를 해서 해당 목차 항목을 활성화(하이라이트)한다.

 

(4) 오른쪽 목차의 스크롤 위치 조정

const parentOffset = rightTocBody.offsetTop;
const linkOffset = link.offsetTop;
rightTocBody.scrollTop = linkOffset - parentOffset - 200;

✔ 현재 활성화된 목차 항목 <a>가 설정한 위치에 보이도록 오른쪽 목차의 스크롤을 자동으로 조정한다.

rightTocBody.scrollTop : 원하는 위치에 되도록 값을 조절한다.

 

🗒 나는 오른쪽 목차의 최대 높이를 400으로 설정했기 때문에 200으로 넣어서 중앙 근처에 오도록 했다.

 

(5) IntersectionObserver 설정

const observer = new IntersectionObserver(handleRightTocScroll, {
  rootMargin: '0px 0px -70% 0px',
  threshold: 0.1,
});

filteredHeadings.forEach(heading => observer.observe(heading));

✔ IntersectionObserver를 사용해서 filteredHeadings 요소들이 화면에 보이는지를 감시한다.

threshold: 0.1 : 요소의 10%만 화면에 보여도 감지하도록 설정

rootMargin: '0px 0px -70% 0px' : 화면의 아래쪽 70%를 마진으로 설정해서, 좀 더 일찍 감지되도록 설정

 

📂 여기까지 정리

스크롤할 때 현재 보고 있는 제목(h2, h3, h4)에 맞춰,

→ 해당 항목을 오른쪽 목차에서 강조한다.

→ 해당 항목이 오른쪽 목차에서 중앙 근처에 오도록 자동 조정한다.

 

 

💡 자동 목차가 있으면 좋은 점

✔ 한눈에 보기 편하다. 긴 글을 읽을 때 어디에 무슨 내용이 있는지 쉽게 파악할 수 있다.

✔ 찾고 싶은 내용을 빠르게 찾을 수 있다. 원하는 정보가 어디 있는지 스크롤을 내리지 않고 바로 이동 가능하다.

✔ 가독성이 좋아진다. 글이 체계적으로 정리되어 보인다.

✔ SEO(검색 최적화)에도 도움 된다. 검색 엔진이 글의 구조를 더 잘 파악할 수 있어서 검색 결과에 유리할 수 있다.

✔ 디자인적으로 깔끔하다. 단락별로 정리된 느낌이라 더 전문적으로 보일 수도 있다.

✔ 내용이 길어질수록 더 유용하다. 짧은 글보다는 긴 글에서 효과가 극대화된다.

✔ PC & 모바일에서도 유용하다. 모바일에서 글을 읽을 때도 원하는 부분을 빠르게 찾아갈 수 있다.

 

728x90

 


 

이제 HTML과 JavaScript로 자동 목차의 기본 기능은 다 만들었다.

css까지 한 번에 정리하려고 했는데 내용이 너무 길어져버렸다.

그래서 글씨 크기, 색상, 여백 조정까지 자동목차를 꾸미는 방법은 CSS에 담아보겠다.

 

"[Info🔍/HTML&CSS] - [Portfolio 스킨] 29. 자동 목차를 더 예쁘게! (2탄: CSS 디자인 편)"에서 계속 🚀

 

전체 코드가 필요하신 분은 둉이님 블로그를 참고해주세요.

728x90