Project/블로그 테마 만들기

[티스토리 블로그 테마] - 2. 티스토리 본문에 목차 추가

둉이 2022. 9. 6. 03:25

오늘은 티스토리 블로그 글 상세 페이지에 목차를 추가해보자.

 

기본 제공 티스토리 테마는 보다시피 본문 목차가 없다.

양쪽 영역이 탈모처럼 허한 상세 페이지

 

구글링을 해보니 jQuery의 toc 라이브러리로 간단하게 목차를 생성하는 방법도 있었지만, 직접 자바스크립트로 페이지를 파싱해서 만들어 보기로 했다.

 

 

목차 레이아웃 구성

코드를 작성하기에 앞서, 목차 컨텐츠 및 레이아웃을 구성해 보자.

 

목차는 본문시작 지점 가운데에 위치하며, 사용자가 스크롤을 내려서 목차가 보이지 않는 경우에는 사용자 화면의 오른쪽 영역에 미니 목차가 표출되도록 구현할 것이다.

 

모바일 기기로 접속한 사용자의 경우에는 목차를 표출할 충분한 공간이 없으므로, 목차 열고 닫기 버튼을 우측에 대신 표출하기로 했다.

구상한 목차의 위치

 

또한 티스토리 에디터에서는 제목 1, 2, 3과 본문 1, 2, 3 문단을 지원한다.

 

따라서, 글 내용 중 제목 1, 2, 3 요소만 추출하여 목차에 추가해 주면 된다.

 

제목에 따른 넘버링, 탭 구분, 목차 아코디언 등의 추가 기능과 전체적인 디테일은 jQuery toc를 참고했으며, 와이어프레임은 다음과 같다.

목차 와이어프레임

 

제목 1의 경우에는 대분류로 간주하며, 1 -> 2 -> 3 -> ... 형태로 넘버링을 할 것이다.

 

다음에 위치할 제목 2 문단의 경우에는 중분류로 간주하며, 상위에 위치한 제목 1의 넘버링에 따라 [대분류].1 -> 2 -> 3 -> ... 형태를 갖도록 넘버링을 할 것이다.

 

제목 3의 경우에는 따로 별도의 넘버링을 하지 않고 - 기호로만 처리할 예정인데, 이 이유는 예외 처리의 편리성을 위해서이다.

(자세한 내용은 아래 예외처리 항목 참고)

 

 

HTML 파싱 선택자 정리 및 파싱 구현

내 티스토리 블로그 테마의 본문 상세 페이지의 마크업 구조는 다음과 같다. (테마 별로 클래스명은 차이가 있을 수 있음)

.area-view
  .article-header
    .category  // 글의 카테고리
    .title-article  // 글의 제목
    .box-info  // 글쓴이의 닉네임 및 글 작성 시간
  .article-view
    h2  // 제목 1
    h3  // 제목 2
    h4  // 제목 3
    p  // 본문

 

여기서 목차를 만들 때 고려해야 하는 부분은 제목 1, 2, 3 문단에 해당하는 .article-view의 h2, h3, h4이다.

 

먼저, 본문에서 제목 요소들을 파싱하고 해당 요소 기준으로 목차를 생성해야 한다.

 

각각의 제목 텍스트로 목차 요소를 생성하고, a 태그로 감싸서 목차에서 특정 제목을 클릭하면 자동으로 해당 영역으로 스크롤이 이동하도록 구현하면 좋을 것 같다.

 

또한, 목차 구성에서의 중요한 점으로는 제목 1 -> 2 -> 3 순서에 맞게 nesting한 레이아웃이 되어야 한다는 점이다.

 

제목을 2 -> 1 혹은 3 -> 1 순서로 작성할 경우에는 다음 목차로 간주하여 다음 넘버링으로 넘어가야 한다.

 

그리고 이런 경우는 적겠지만, 본문 내 제목이 1 -> 3 -> 2 처럼 일반적이지 않은 순서로 작성되는 경우도 있을 것이다.

 

목차의 완성도를 위해 발생 가능한 예외 케이스를 고안해 보자.

 

 

제목 순서가 맞지 않을 경우 예외처리

글을 작성할 때 제목1 -> 제목2 -> 제목2 -> 제목3 -> 제목2 -> 제목1 -> ... 처럼 정상적인 순서로 제목을 사용한다면 목차 생성에는 문제가 없을 것이다.

 

하지만 만약 제목1 -> 제목3 -> 제목3 -> 제목2 -> 제목2 -> ... 처럼 일반적인 제목 사용 순서와 다르게 문단을 사용한다면 목차 생성 과정에서 문제가 발생할 것이다.

 

몇 가지 예외 상황에 대해 생각한 후, 해당 케이스에 대한 예외 처리를 진행했다.

 

 

1. 제목 1 -> 3 -> 2 순서로 사용

순서에 맞지 않게 제목을 사용하는 경우에는 목차 생성 과정에서 각 항목에 대한 넘버링을 하기가 상당히 애매해진다.

 

특히 만약 제목 1 -> 3 -> 2 처럼 사용하는 경우에는 제목 3 자체에 대한 넘버링이 상당히 애매하다.

애매한 제목 3의 넘버링

 

위 케이스에서는 제목 3의 중분류 넘버를 0으로 봐야 할까 아니면 1로 봐야 할까?

 

만약 1로 본다고 하면, 그 다음 위치한 제목 2의 넘버링이 2.2가 되기 때문에 이 부분도 순서에 맞지 않는다. (2.1에 해당하는 제목 2 요소가 없기 때문)

 

따라서, 제목 3의 경우에는 별도의 넘버링 처리를 하지 않기로 결정했다.

 

그리고 위 사진 같은 케이스에는 제목 3 이후에 오는 제목 2에 대한 넘버링을 1부터 시작하도록 함으로써 제목 2의 순서에도 문제가 없도록 했다.

 

2. 제목 1을 사용하지 않고 제목 2 -> 제목 3 형태로 사용

제목 1을 사용하지 않고 제목 2 -> 제목 3 형태로 사용

 

이러한 케이스는 간단하게 제목 2를 대제목(제목 1)처럼, 제목 3을 중제목(제목 2)처럼 간주하여 작업하면 된다.

 

3. 제목 2만(혹은 제목 3만) 사용

특정 제목만 사용하는 경우인데, 내 블로그에서는 아마 이 케이스가 가장 많을 것이다. (제목 2만 사용)

 

이 케이스도 2번 케이스와 유사하게, 제목 2 혹은 제목 3을 대제목으로 간주하여 작업하면 된다.

 

 

 

이제 위에서 정리한 것들을 바탕으로 목차를 화면에 렌더링하는 작업을 진행해 보자.

 

 

제목 요소 커스텀 및 목차 렌더링

이제 본격적으로 제목을 파싱하여 마크업을 만드는 작업을 시작해 보자.

 

querySelectorAll()로 본문에 있는 제목 요소를 모두 가져온 후, reduce 함수를 사용하여 각 제목 요소에 있는 태그 이름과 텍스트를 추출하여 문자열 형태의 목차 요소 마크업을 생성했다.

 

이제 목차 요소를 클릭하면 a 태그를 이용하여 본문의 해당 제목 위치로 이동하게끔 구현해야 한다.

 

이를 위해 ID 생성 함수인 generateID()를 선언한 후, reduce 반복문 내에서 본문 제목 요소에 만들어 준 ID를 추가한 후, 해당 ID를 목차 요소의 href 속성에도 전달했다.

const generateID = () => `heading${idCount++}`;

const tocContents = filteredHeadings.reduce((prev, cur) => {
  const headingTagName = cur.tagName;
  const headingText = cur.textContent;
  const headingID = generateID();
  
  const newHeadingHTML = `
  <li data-tag="${calcTagNumber(headingTagName)}">
    <a href="#${headingID}">${headingText}</a>
  </li>`;
  
  cur.id = headingID;
  return prev + newHeadingHTML;
}, '');

 

목차 요소를 생성하면서 넘버링을 위한 값(data-tag)도 li 태그에 추가했다.

 

data-tag 값은 대분류, 중분류, 소분류 넘버링을 위한 값으로, 제목 1은 0, 제목 2는 1, 제목 3은 2 값을 갖는다.

 

또한 위에서 작성한 예외 처리를 위해 본문에서 쓰인 가장 작은 제목 넘버(minHeadingNumber)를 계산한 후, 원래 태그 넘버 값에서 빼줌으로서 제목 1을 사용하지 않았을 때는 제목 2가 제목 1을 대체하도록 했다.

const tags = {
  'H2': 0,
  'H3': 1,
  'H4': 2,
};

const minHeadingNumber = Math.min.apply(null, filteredHeadings.map(heading => tags[heading.tagName]));

const calcTagNumber = (tagName) => tags[tagName] - minHeadingNumber;

 

그리고 제목 태그인데 공백(space)만 있는 태그도 파싱되어 함께 목차로 만들어지는 경우도 있었다.

 

이런 경우도 공백 textContent를 갖는 태그를 filter 함수를 이용하여 걸러주자.

const filteredHeadings = [...headings].filter(heading => heading.textContent.trim());

 

 

스크롤 클릭 이벤트 구현

목차의 제목을 클릭하면 본문의 해당 영역으로 부드럽게 스크롤이 이동하는 기능을 만들어 보자.

 

간단하게 a 태그와 제목 요소의 ID 값을 이용하여 이동이 가능하게끔 해줬지만, 이렇게 만들 경우에는 스무스하게 스크롤이 동작하지 않는다.

<!-- 목차 -->
<div class="view-index">
  ...
  <a href="#heading1">제목 2 영역입니다.</a>
</div>

<!-- 본문 영역 -->
<h3 id="heading1" data-ke-size="size23">제목 2 영역입니다.</h3>
<p data-ke-size="size16">어쩌고 저쩌고</p>

 

어떻게 하면 a 태그에서 스무스한 스크롤을 구현할 수 있을까?

 

나는 목차 요소(a 태그)의 클릭 이벤트를 커스텀하기로 했다.

 

목차를 화면에 렌더링한 후, 목차 내의 a 태그에 다음과 같은 이벤트 리스너를 추가하여 태그 기본 동작 대신 스무스한 스크롤로 해당 위치로 이동하도록 했다.

const toc = postContent.querySelector('.toc');

const handleTocScroll = (event) => {
  event.preventDefault();
  const targetHeadingID = event.target.getAttribute('href');
  const target = document.querySelector(targetHeadingID);
  
  target?.scrollIntoView({ behavior: 'smooth' });
}

toc.addEventListener('click', handleTocScroll);

 

위 코드처럼 scrollIntoView의 behavior를 smooth로 변경해 주면 다음과 같이 스무스한 스크롤을 적용할 수 있다.

스무스한 스크롤 적용

 

 

목차 요소 넘버링 추가

만들어 준 목차 요소 앞에 넘버링을 추가해 주자.

 

물론 자바스크립트로 만드는 방법도 있겠지만, 나는 저번 코드 블록 넘버링 때도 사용했었던 CSS의 카운터를 사용할 것이다.

/* 카운터를 선언하고 0으로 초기화 */
counter-reset: 카운터명;

/* 카운터 값을 1씩 증가(카운터가 선언되어 있지 않으면 먼저 선언 후 1 증가) */
counter-increment: 카운터명;

/* 현재 카운터 값을 content에 표시 */
content: counter(카운터명);

 

카운터를 사용하여 각각의 data-tag 값을 갖는 HTML 요소마다 counter-increment를 통해 넘버링 값을 증가시켰다.

 

대분류 넘버링(tag0)의 경우에는 새로운 번호로 시작할 때마다 중분류 넘버링(tag1)의 카운터 값이 초기화되어야 하기 때문에 counter-reset을 통해 카운터 값을 초기화하는 부분도 추가했다.

 

content 속성에는 태그 데이터 값이 0(제목1, 대분류)인 목차 요소에는 tag0의 값을 표출하고, 태그 데이터 값이 1(제목2, 중분류)인 목차 요소에는 tag0 + . + tag1 값이 표출되도록 했다.

.toc-list li[data-tag="0"] {
  counter-increment: tag0;
  counter-reset: tag1;
}

.toc-list li[data-tag="0"] a::before {
  content: counter(tag0);
}

.toc-list li[data-tag="1"] {
  counter-increment: tag1;
}

.toc-list li[data-tag="1"] a::before {
  content: counter(tag0)'.'counter(tag1);
}

 

 

스크롤 위치/화면 크기에 따른 목차 표출 여부

사용자 화면 내에 본문 영역 내에 있는 목차가 표출된다면 오른쪽 빈 공간에 플로팅 목차가 필요 없으므로 숨기고, 본문 영역 목차가 보이지 않는다면 오른쪽 목차를 표출하도록 구현해 보자.

 

먼저, 화면에 본문 목차가 표출되는지에 대한 여부를 얻어와야 한다.

 

나는 Intersection Observer를 사용하여 목차가 화면에 표출되고 숨겨질 때마다 자동으로 오른쪽 목차를 대신 표출하도록 구현했다.

 

Intersection Observer는 브라우저의 뷰포트(viewport)와 감시 대상이 되는 요소의 교차점(intersection)을 감시하며, 해당 요소가 화면에 표출되는지에 대한 정보를 제공한다.

 

다음과 같이 본문 목차를 감시 대상으로 지정하고, inIntersecting(화면 표출 여부)가 true인 경우에만 오른쪽 목차를 표출하도록 구현했다.

const handleTocScroll = ([entries]) => {
  const isTocVisible = entries.isIntersecting;
  isTocVisible ? rightToc.classList.remove('on') : rightToc.classList.add('on');
};

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

 

또한 데스크탑으로 접속했을 때는 양 쪽 여백이 낭낭하기 때문에 목차가 위치할 공간이 충분하지만, 모바일이나 태블릿 등 화면 가로 길이가 짧은 디바이스로 접속했을 때는 양 쪽 여백이 없으므로 목차를 표출할 공간이 없다.

 

따라서, 데스크탑 외 기기로 접속했을 때는 오른쪽 목차 영역을 숨기고 대신 목차를 펼 수 있는 버튼을 추가하자.

 

내 블로그 스킨의 경우에는 1600px 기준으로 본문과 오른쪽 목차가 겹치므로, 1600px 기준으로 미디어 쿼리를 작성하여 목차를 열고 닫을 수 있는 버튼을 오른쪽에 대신 표출하도록 했다.

@media screen and (max-width: 1600px) {
  .toc-right {
    background-color: #fff8;
    backdrop-filter: blur(2px);
    border-left: none;
    right: -240px;
    z-index: 10;
  }

  .toc-right .toc-header {
    display: flex;
  }

  .toc-right,
  .toc-right .toc-header {
    box-shadow: 0 2px 10px 2px rgb(0 0 0 / 15%);
  }
}

 

 

스크롤 시 오른쪽 목차 요소 하이라이팅 처리

글의 가독성 향상을 위해 사용자가 본문을 스크롤하면 화면 영역에 해당하는 목차 요소에 하이라이팅 처리를 추가하자.

 

이 기능도 역시 위에서 사용한 Intersection Observer를 사용하면 쉽게 구현할 수 있다.

 

본문에서 파싱한 제목(h2, h3, h4)에 각각 옵저버를 추가하고 제목 요소가 화면에서 숨겨질 때(isIntersecting === false), 해당 제목에 해당하는 오른쪽 목차 요소가 하이라이팅 되도록 구현했다.

const rightTocElements = rightToc.querySelectorAll('li a');
const handleRightTocScroll = ([entry]) => {
  if (!entry.isIntersecting) {  
    const targetID = entry.target.id;
    rightTocElements.forEach(heading => {
      if (heading.href.endsWith(targetID)) {
        heading.classList.add('active');
        return;
      }
      heading.classList.remove('active');
    });
  }
};

const headingObserver = new IntersectionObserver(handleRightTocScroll);
for (const heading of filteredHeadings) {
  headingObserver.observe(heading);
}

 

하지만 이렇게 구현하니 스크롤 다운 시에는 정상적으로 기능이 동작하지만, 스크롤 업의 경우에는 하이라이팅이 튀는 버그가 발생했다.

 

 

트러블 슈팅: 스크롤 업 하이라이팅 오류 수정

제목 요소가 화면에서 숨겨질 때(isIntersecting === false) 기준으로 하이라이팅 되도록 기능을 구현하니 스크롤 업 시에는 화면 상 위에 위치한 본문 제목이 아닌 아래에 위치한 본문 제목 기준으로 하이라이팅이 되는 문제가 발생했다.

 

따라서 스크롤 방향에 상관 없이 하이라이팅이 될 수 있도록 조건을 변경해야 했다.

 

기본적으로 Intersection Observer은 별도의 root 요소를 지정하지 않는다면 브라우저의 viewport를 기준으로 타켓 요소를 감시한다.

 

rootMargin 옵션을 사용하여 margin-bottom에 -90% 값을 지정해 줘서 viewport 전체 영역이 아닌 화면 위 10% 영역만 감시하도록 옵션을 추가했고, 하이라이팅 조건을 제목 요소가 화면에서 숨겨질 때가 아닌 나타날 때(isIntersecting === true)를 기준으로 변경하여 문제를 해결했다.

인터섹션 옵저버의 감시 영역 변경

 

const handleRightTocScroll = ([entry]) => {
  const { target, isIntersecting, boundingClientRect, intersectionRatio } = entry;
  
    boundingClientRect.bottom < boundingClientRect.height)
  if (entry.isIntersecting) {  
    const targetID = entry.target.id;
    rightTocElements.forEach(heading => {
      if (heading.href.endsWith(targetID)) {
        heading.classList.add('active');
        return;
      }
      heading.classList.remove('active');
    });
  }
};

const headingObserver = new IntersectionObserver(handleRightTocScroll, {
  rootMargin: '0px 0px -90% 0px',
});
for (const heading of filteredHeadings) {
  headingObserver.observe(heading);
}

 

 

트러블 슈팅 2: 이 카테고리의 다른 글 제거

만든 목차를 테스트하기 위해 블로그에 적용하던 도중 이상한 항목이 목차 마지막 항목으로 추가되어 있는 것을 발견했다.

티스토리 테마에 숨겨져 있던 제목 요소

 

위 요소(~~ 카테고리의 다른 글)는 티스토리에서 글 작성 시 자동으로 생성되는 HTML 요소로, 아마 SEO를 위해 만들어지는 것 같았다.

<h4>
  '<a href="/category/Project">Project</a>
  &nbsp;&gt;&nbsp;
  <a href="/category/Project/블로그%20테마%20만들기">블로그 테마 만들기</a>
  ' 카테고리의 다른 글
</h4>

 

다행히도 이 요소는 티스토리 에디터에서 생성한 제목 요소와는 달리 data-ke-size 속성을 갖지 않는다.

 

따라서, h4 태그에 대해서 data-ke-size 속성을 가진 요소만 긁어올 수 있도록 선택자에 조건을 추가하여 해결할 수 있었다.

const headings = postContent.querySelectorAll('h2, h3, h4[data-ke-size]');

 

 

트러블 슈팅 3: 목차 요소가 존재하지 않는 경우에는 목차 생성 안하도록

본문 내에서 제목 문단을 아예 사용하지 않은 경우에는 빈 목차 껍데기만 생성되는 문제가 발생했다.

빈 목차 헤더만 보임

 

다음과 같이 목차에서 긁어온 제목 배열의 length가 1 이상인 경우에만 목차가 생성되도록 조건을 추가하여 해결했다.

if (filteredHeadings.length) {  // 길이가 1 이상인 경우에만 목차 생성
  const tocWrapper = `
  <section class="toc on">
  
  // ...
  
  for (const heading of filteredHeadings) {
    headingObserver.observe(heading);
  }
}

 

 

만든 목차 적용

이제 위에서 만든 목차를 블로그에 적용해 보자.

 

먼저 티스토리 로그인 후, 관리 - 꾸미기 - 스킨 편집 메뉴를 클릭하여 스킨 편집 페이지로 이동한다.

티스토리 - 설정 - 스킨 편집


페이지 우측 HTML 편집 버튼을 클릭하면 다음과 같이 HTML, CSS를 편집하고 js 파일을 업로드할 수 있는 화면으로 이동할 수 있다.

스킨 편집 - html 편집 버튼 클릭

 

건드려야 할 곳은 파일 업로드  HTML 메뉴 순이다.

 

아래 두 파일을 파일 업로드에서 업로드 한 후, HTML의 head 태그에 업로드한 파일의 import 구문을 추가해 주면 된다.

<link rel="stylesheet" href="./images/toc.css" />
<script src="./images/toc.js" defer></script>

toc.css
0.00MB
toc.js
0.00MB

 

(사용하는 블로그 스킨에 따라 코드 내 className이나 세부 스타일 margin, padding 값 변경이 필요할 수도 있으니 필요하신 분들은 값 확인 후 수정해서 사용 부탁드립니다. 🙏🏻)

 

 

마무리

적용 후 블로그의 아무 글이나 클릭하면 다음과 같이 자동으로 목차가 생성되는 것을 확인할 수 있다.

잘 적용된 모습