포스트 목차 추가하기

2024년 2월 12일

intro

이번에는 포스트 페이지에 목차를 추가하려 한다. 구현하려는 기능으로는

  1. h2 태그(메인 섹션)와 h3 태그(서브 섹션)의 텍스트를 섹션으로 구분 지어 목차에서 항목을 알린다.
  2. 목차의 항목 중 h3 태그의 텍스트는 왼쪽에 여백을 넣어 서브 섹션임을 알린다.
  3. 목차에 있는 항목(주제, 제목)을 클릭하면 해당 섹션으로 이동한다.
  4. 현재 화면의 위치에 따라 목차의 항목을 스타일링한다.

위 기능들을 하나의 컴포저블 함수(useSectionsObserve.js)와 하나의 컴포넌트(ContentToc.vue)로 구현하겠다.

구현하려는 기능 1번, 2번 구현

구현하려는 기능 1. h2 태그(메인 섹션)와 h3 태그(서브 섹션)의 텍스트를 섹션으로 구분 지어 목차에서 항목을 알린다와 **2. 목차의 항목 중 h3 태그의 텍스트는 왼쪽에 여백을 넣어 서브 섹션임을 알린다.**를 한 번에 구현하겠다.

현재 작성한 포스트들은 h2 태그를 메인 섹션으로, h3 태그를 서브 섹션으로 구분하여 작성했다.
구현하려는 기능 1번, 2번을 구현하기 위해서는 h2, h3 태그의 hash id, 텍스트, h2 섹션안의 h3 섹션 존재 여부에 대한 값이 필요하다. 다행히 nuxt-content를 이용하면 간편하게 원하는 값을 얻을 수 있다.

작성한 포스트의 섹션 정보는 nuxt-content의 queryContent 함수의 반환 값 중 body.toc.links에 담겨 있다.

toc

이전 글의 queryContent 반환 값의 body 객체

배열 body.toc.links에 담긴 객체는 { id, depth, text, children } 형태로

  • id: h2, h3 태그의 hash id
  • depth: h2 태그면 2, h3 태그면 3으로 섹션의 깊이
  • text: h2, h3 태그의 텍스트
  • children: h2 섹션안에 h3 섹션이 있는 경우 생기는 속성, 현재 섹션의 depth보다 깊은 depth의 섹션 정보를 갖는 같은 형태의 { id, depth, text, children } 객체들의 배열

정보를 담고 있다.
hash id, text, 서브 섹션 여부와 섹션 정보까지 필요한 데이터들이 모두 담겨 있기에 이를 이용한다.

목차 컴포넌트 ContentToc.vue

components/ContentToc.vue
<script setup>
const props = defineProps({
  links: {
    type: Array,
    default() {
      return [
        {
          id: 'id',
          depth: 2,
          text: 'intro',
          children: [],
        },
      ];
    },
  },
});
</script>

<template>
  <div class="px-px flex flex-col gap-y-2">
    <div class="font-semibold text-slate-800/90">목차</div>
    <nav class="text-slate-500/90">
      <ul class="space-y-2.5 px-px">
        <li v-for="{ id, text, children } in props.links" :key="id">
          <NuxtLink
            :to="`#${id}`"
            class="font-medium text-sm block truncate"
          >
            {{ text }}
          </NuxtLink>
          <ul v-if="children" class="pt-2 pl-2.5 space-y-2">
            <li v-for="{ id: childId, text: childText } in children" :key="childId">
              <NuxtLink
                :to="`#${childId}`"
                class="font-medium text-sm block truncate"
              >
                {{ childText }}
              </NuxtLink>
            </li>
          </ul>
        </li>
      </ul>
    </nav>
  </div>
</template>

body.toc.links 값을 props 값으로 받아 화면에 렌더링하는 목차 컴포넌트
현재 구현하려는 기능 1번과 2번이 구현되었으며 3번도 동작하긴 하지만 추가해야 하는 부분이 있기에 아래에서 다루겠다.

배열 props.linksv-for로 순회해 h2 태그의 텍스트를 화면에 렌더링하며 순회하는 h2 섹션에 children(h3)이 존재하는 경우 h3 섹션들을 순회해 렌더링한다.

<ul v-if="children" class="pt-2 pl-2.5 space-y-2"> 태그로 현재 순회하고 있는 섹션(h2 섹션)의 자식(children: h3 섹션)이 있는 경우 왼쪽에 padding 추가해 오른쪽에 위치시켜 서브 섹션임을 알린다.

pages/[category]/[detail]/[post].vue 코드 추가

pages/[category]/[detail]/[post].vue
<template>
  <!--  -->
    <div class="hidden lg:block lg:col-span-2">
      <aside class="py-10 sticky overflow-y-auto max-h-[calc(100vh-4rem)] top-[4rem]">
        <ContentToc :link="postData.body?.toc?.links" />
      </aside>
    </div>
  <!--  -->
</template>

화면 넓이가 lg:1024px이상일 때만 목차를 위치 시키며, 목차 컴포넌트 ContentTocpostData.body.toc.links를 전달한다.

구현하려는 기능 3번 구현

구현하려는 기능 중 3번 목차에 있는 항목(주제, 제목)을 클릭하면 해당 섹션으로 이동한다는 사실 위의 코드로 작동한다.
하지만 현재 목차의 항목을 클릭해 이동하면, 클릭한 섹션의 h2, h3 텍스트가 헤더에 가려져 보이지 않는다. 이를 해결하기 위해 scroll-margin-top을 이용한다.

scroll-margin-top은 CSS 속성 중 하나로, 페이지 내에서 이동 할 때 특정 요소의 상단에 마진을 추가하는 데 사용된다.
현재 post의 스타일은 tailwind typography를 이용하고 있다. 이를 수정하면 된다.

app.config.js
export default defineAppConfig({
  // 
  ui: {
    prose: {
      h2: 'prose-h2:scroll-mt-[6.3rem]',
      h3: 'prose-h3:scroll-mt-[6.4rem]',
    },
    // 
  },
});

app.config.jsprose-h2:scroll-mt-[6.3rem]prose-h3:scroll-mt-[6.4rem]을 추가한다. 이 값들은 h2 태그와 h3 태그 섹션으로 이동할 때 상단의 margin을 주는 클래스다.
위 클래스 값들을 [category]/[detail]/[post].vue의 post에 적용하면 된다.

pages/[category]/[detail]/[post].vue
<template>
  <!--  -->
    <section>
      <div
        class="prose min-w-full md:px-2 min-h-screen"
        :class="Object.values(appConfig.ui.prose).join(' ')"
      >
        <ContentRenderer v-if="postData" :value="postData" />
      </div>
    </section>
  <!--  -->
</template>

app.config.jsui.prose values를 하나의 문자열로 변환해 동적으로 클래스를 추가한 코드

이로써 목차의 항목들을 클릭해 해당 섹션으로 이동하면 헤더에 가려지지 않고 클릭한 섹션의 h2, h3 텍스트가 화면에 보이도록 이동한다.

추가로, 부드러운 scroll

현재 목차를 클릭하여 섹션으로 이동 시 한 번에 이동한다. 이보다는 애니메이션처럼 부드럽게 이동하는 것이 자연스럽다고 생각해 추가하려 한다.
nuxt.config의 router options에 원하는 스크롤 타입을 추가하면 된다.

nuxt.config.ts
export default defineNuxtConfig({
  // 
  router: {
    options: {
      scrollBehaviorType: 'smooth'
    }
  },
  // 
});

해당 option을 추가하면 목차를 클릭해 섹션으로 이동할 때, 포스트의 h2, h3 태그를 클릭해 해당 섹션으로 이동할 때, 현재 페이지 주소 마지막에 #id가 있어 새로 고침으로 #id 섹션으로 이동할 때 모두 부드럽게 이동한다.

순간이동처럼 한 번에 타켓 섹션으로 이동하는 것보다 부드럽게 이동하는 것이 더 자연스럽고 시각적으로 안정성이 있다고 생각한다.

구현하려는 기능 4번 구현

대망의 4번이다. 이 기능을 구현하는 데 가장 오랜 시간을 소요했으며, 생각과 방식을 여러 번 변경했다.

4. 현재 화면의 위치에 따라 목차의 항목을 스타일링한다는 현재 화면에 보이는 섹션의 목차 항목을 스타일링해 지금 어떤 섹션을 보고 있는지 알리는 기능이다.

절대 좌표를 이용해 가장 가까운 섹션을 스타일링

처음에 떠오른 방식으로, 렌더링 된 h2, h3 태그들을 가지고 와 태그들의 절대 좌표를 구한 뒤, 스크롤 이벤트가 발생하면 현재 스크롤 위치가 어떤 태그와 가장 가까운지 구해 가장 가까운 태그의 목차 항목을 스타일링 하는 것이었다. 하지만 문제가 있었다.

목차의 위쪽에 위치한 항목들은 정상적으로 동작했으나, 아래쪽에 위치한 항목들이 정상적으로 동작하지 않는 경우가 있었다.
어떤 게 문제인지 콘솔을 계속 찍어 보니 h2, h3 태그들의 좌표가 한두 번씩 다르게 계산되었다. 좌푯값을을 구하는 방식이 잘못되었는가 해서 검색 및 gpt 등을 이용해도 문제가 없었다. 원인은 사진 및 영상이였다.
포스트의 사진이나 영상이 있을 때 h2, h3 태그들의 좌표를 구한 뒤에 사진이나 영상이 뒤늦게 load 돼 구한 좌표와 달라졌던 것이다.

이를 해결하기 위해 setTimeout을 통해 1초 뒤에 태그들의 좌표를 구하고 스크롤 이벤트를 추가했다.
setTimeout을 이용해 문제를 해결했지만 사진, 영상의 load가 1초보다 늦으면 같은 문제가 발생할 것이다. 사진, 영상의 load가 완료된 시점을 정확하게 구하면 해결되지만 아쉽게도 좋은 방법이 떠오르지 않았다.

IntersectionObserver를 이용해 보이는 섹션을 스타일링

구현하려는 기능 4번을 구현하기 위해 선택한 API
IntersectionObserver API는 브라우저에서 제공되는 웹 API로, 요소의 가시성에 대한 변화를 감지하는 데 사용되는 API다. 특정 요소가 화면에 진입하거나 빠져나갈 때의 이벤트를 처리하는 데 유용하다.
이 API를 이용하면 h2, h3 태그의 좌표를 구하지 않고, 어떤 태그와 현재 가장 가까운지 구할 필요 없이 어떤 섹션을 보고 있는지 간단하게 확인할 수 있다.

IntersectionObserver를 이용해 화면에 h2, h3 태그가 보이는 경우 해당 태그의 id를 저장해 동적으로 스타일링한다.
태그가 화면에서 빠져나가는 경우 저장한 id를 지워, 빠져나갔음을 확인한다.

composables/useSectionsObserve.js
function useSectionsObserve() {
  const visibleSections = ref([]);
  const activeSections = ref([]);

  const observer = ref(null);

  const callback = (entries) => {
    entries.forEach((entry) => {
      const { id } = entry.target;

      if (entry.isIntersecting) {
        visibleSections.value = [...visibleSections.value, id];
      } else {
        visibleSections.value = visibleSections.value.filter((target) => target !== id);
      }
    });
  };

  onMounted(() => {
    observer.value = new IntersectionObserver(callback);

    document.querySelectorAll('.prose h2, .prose h3').forEach((section) => {
      observer.value.observe(section);
    });
  });

  onUnmounted(() => {
    observer.value.disconnect();
  });

  watch(visibleSections, (newValue, oldValue) => {
    if (newValue.length === 0) {
      activeSections.value = oldValue;
    } else {
      activeSections.value = newValue;
    }
  });

  return {
    activeSections,
  };
}

export default useSectionsObserve;

현재 화면에서 어떤 섹션을 보고 있는지 감시하는 컴포저블 함수

반응형 변수 visibleSections는 현재 어떤 섹션을 보고 있는지 저장하는 변수다. 화면에 감시하고 있는 태그가 진입하는 경우 추가하고, 빠져나가는 경우 제외한다.

반응형 변수 activeSections는 어떤 섹션들을 스타일링하는 지 알려주는 변수다.
activeSections가 필요한 이유는 섹션과 섹션과의 거리가 먼 경우 visibleSections가 비는 타이밍이 있다. 이때 visibleSections가 비더라도 activeSections에서는 가장 최근의 태그를 제외하지 않고 유지하여 현재 어떤 섹션인지를 알 수 있게 한다.

onMounted 훅에서 새로운 IntersectionObserver(new IntersectionObserver(callback))를 만들고, document.querySelectorAll('.prose h2, .prose h3')로 출력된 포스트의 h2, h3 태그를 가지고 와 감시를 시작한다.

IntersectionObserver의 콜백 함수 callback은 감시할 타겟들이(entries) 화면에 진입하면(if(entry.isIntersecting)) visibleSections에 추가하고 제외되면 visibleSections에서도 제외한다.

이때 vue의 watch를 이용해 visibleSections가 변경될 때 변경된 visibleSections의 길이가 0인 경우는 activeSections를 변경하지 않고 그 외에는 activeSectionsvisibleSections와 같게 해 목차의 스타일링을 빈 타이밍이 없게 한다.

onUnMounted 훅에서 observer.value.disconnect()로 감시를 종료하고,
activeSections를 반환해 스타일링할 Section의 항목들을 알린다.

목차 컴포넌트 ContentToc.vueuseSectionsObserve.js의 반환 값 activeSections를 통해 항목들을 동적으로 스타일링하면 끝난다.

components/ContentToc.vue
<script setup>
// 
const { activeSections } = useSectionsObserve();

function isActive(id) {
  return activeSections.value.includes(id);
}
</script>

<template>
  <!--  -->
    <NuxtLink
      :to="`#${id}`"
      class="font-medium text-sm block truncate"
      :class="{ 'text-emerald-500': isActive(id) }"
    >
      {{ text }}
    </NuxtLink>
    <!--  -->
        <NuxtLink
          :to="`#${childId}`"
          class="font-medium text-sm block truncate"
          :class="{ 'text-emerald-500': isActive(childId) }"
        >
          {{ childText }}
        </NuxtLink>
    <!--  -->
</template>

함수 isActive를 통해 순회하는 id가 activeSections에 있는지 확인해 있는 경우 동적으로 스타일링한다.

마무리

4번 기능 구현에 있어 불만스러운 점이 계속 생겨 상당히 오래 걸렸으며, 오류 또한 다양하게 발생했다.
중간에 nuxt 버전을 업그레이드했는데 TypeError: Cannot read properties of null (reading 'parentNode') when using keepAlive 오류가 발생해 동작이 안 돼 당황했다. vue upstream issue라 하여 버전을 다운시키기도 했었다. (nuxt github issue 주소 link)

아쉬운 점은 IntersectionObserver를 이용해 4번 기능을 구현하면 상당히 간단하지만, 현재 어떤 섹션인지 정확하지 않을 때가 있다.
위에서 아래로 스크롤 할 때는 괜찮지만 아래에서 위로 스크롤 할 때는 위 섹션의 내용이어도 감시하는 태그가 화면에 진입하기 전까지는 아래 섹션의 항목 활성화되어 있다.
정확하게 어떤 섹션인지 알기 위해서는 태그의 절대 좌표를 이용하는 것이 더 적합한 거 같다. 나중에 사진, 영상의 로딩 완료 시점을 구하는 방법을 알게 되면 변경하는 것이 좋을 거 같다.


댓글