배경

Quartz의 Explorer는 파일 트리를 사이드바에 렌더링하는 컴포넌트다. 폴더 구조를 그대로 반영하여 블로그의 전체 콘텐츠를 탐색할 수 있게 해주는데, 기본 구현만으로는 아쉬운 부분이 있었다.

세 가지를 추가하기로 했다.

  1. 폴더 옆 파일 개수 표시 - 폴더 안에 글이 몇 개인지 한눈에 파악
  2. 폴더 노트 직접 네비게이션 - 폴더를 클릭했을 때 서브폴더가 펼쳐지지 않고 폴더 노트로 바로 이동
  3. 모바일 블러 오버레이 - 모바일에서 Explorer가 열릴 때 배경을 흐림 처리

각각의 개선이 어떤 문제를 풀고, 코드 수준에서 어떻게 구현되었는지를 정리한다.

폴더별 파일 카운트

폴더명만 보고는 그 안에 콘텐츠가 얼마나 있는지 알 수 없다. 빈 폴더인지, 수십 개의 글이 들어있는지 클릭해보기 전까지 모른다. 폴더명 옆에 파일 개수 배지를 붙이면 이 문제가 해결된다.

countFiles 메서드

Quartz는 파일 구조를 FileTrieNode라는 트리 자료구조로 관리한다. 여기에 countFiles() 메서드를 추가했다.

quartz/util/fileTrie.ts
countFiles(): number {
  let count = this.isFolder ? 0 : 1;
  for (let child of this.children) {
    count += child.countFiles();
  }
  return count;
}

로직은 단순하다. 현재 노드가 폴더면 0, 파일이면 1로 시작하고, 자식 노드를 재귀적으로 순회하면서 합산한다. 트리 자료구조의 자연스러운 확장이다.

Explorer 렌더링

Explorer의 inline 스크립트에서 폴더 노드를 생성할 때, countFiles()의 결과를 <span> 엘리먼트로 추가한다.

quartz/components/scripts/explorer.inline.ts
// In createFolderNode function:
a.className = "folder-title"
a.textContent = node.displayName
const span = document.createElement("span")
span.className = "folder-title-length"
span.textContent = ` (${node.countFiles()})`
a.appendChild(span)

하이라이트된 4줄이 추가된 부분이다. 폴더 제목 텍스트 뒤에 (N) 형식의 span을 붙인다.

스타일링

배지는 본문보다 작고 옅게 표시되어야 한다. 폴더명 자체의 가독성을 해치지 않으면서 보조 정보를 전달하는 것이 목적이다.

quartz/styles/component/plugins/explorer_override.scss
.folder-title-length {
  font-weight: 500;
  font-size: 0.65rem;
  opacity: 0.7;
}

font-size를 0.65rem으로 줄이고 opacity를 낮춰서, 시선을 끌지 않으면서도 필요할 때 확인할 수 있는 수준으로 조정했다.

폴더 노트 네비게이션

Quartz에서 폴더를 클릭하면 두 가지 동작이 동시에 발생한다. 폴더 노트(index)로 이동하면서, 해당 폴더의 하위 트리를 펼치는 것이다. 언뜻 합리적으로 보이지만, 사용자가 폴더 노트를 읽으러 갔을 때 의도하지 않은 트리 확장이 일어나는 문제가 있다.

문제 상황

예를 들어 개발 노트 폴더를 클릭하면 개발 노트/index 페이지로 이동한다. 이때 Explorer에서 개발 노트 하위의 모든 서브폴더가 펼쳐진다. 사용자는 폴더 노트 자체를 보러 온 것인데, 사이드바가 갑자기 확장되어 산만해진다.

해결

핵심은 “현재 보고 있는 페이지가 폴더 노트 자체인지” 구분하는 것이다. 기존 코드는 폴더 경로가 현재 slug의 접두사인지만 확인했다. 여기에 폴더 노트 정확 매칭 조건을 추가했다.

quartz/components/scripts/explorer.inline.ts
const folderIsPrefixOfCurrentSlug =
  simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
const isFolderNoteOfCurrentSlug = currentSlug === node.slug
 
if (!isCollapsed || (folderIsPrefixOfCurrentSlug && !isFolderNoteOfCurrentSlug)) {
  folderOuter.classList.add("open")
}

기존 조건은 folderIsPrefixOfCurrentSlug뿐이었다. 개발 노트/blog/some-post를 보고 있을 때 개발 노트 폴더가 열리는 것은 자연스럽다 - 현재 글이 해당 폴더 아래에 있으니까.

추가된 isFolderNoteOfCurrentSlug는 현재 slug가 폴더 노트의 slug와 정확히 일치하는지 확인한다. 일치한다면 !isFolderNoteOfCurrentSlugfalse가 되어, 폴더를 펼치지 않는다. 즉, 폴더 노트 자체를 보고 있을 때는 하위 트리가 접힌 상태를 유지한다.

모바일 블러 오버레이

모바일에서 Explorer는 화면 좌측에서 슬라이드 형태로 열린다. 이때 Explorer 뒤의 본문이 그대로 보이면 시각적으로 산만하다. 블러 오버레이를 추가하여 Explorer에 집중할 수 있도록 했다.

동적 엘리먼트 생성

explorer.inline.ts에서 Explorer가 초기화될 때 블러 오버레이 div를 동적으로 생성한다.

quartz/components/scripts/explorer.inline.ts
const explorerBlur = document.createElement("div")
explorerBlur.className = "explorer-blur"
explorer.appendChild(explorerBlur)
explorerBlur.addEventListener("click", toggleExplorer)

오버레이를 클릭하면 toggleExplorer가 호출되어 Explorer가 닫힌다. 모바일 UX에서 흔히 사용되는 “배경 클릭으로 닫기” 패턴이다.

CSS

오버레이는 모바일에서만 활성화된다. backdrop-filter로 배경에 블러 효과를 적용하고, 트랜지션으로 부드러운 전환을 준다.

quartz/styles/component/plugins/explorer_override.scss
.explorer {
  @media all and ($mobile) {
    .explorer-blur {
      box-sizing: border-box;
      z-index: 99;
      position: absolute;
      top: 0;
      right: 0;
      width: 100%;
      height: 100dvh;
      max-height: 100dvh;
      background: rgba(255, 255, 255, 0.01);
      backdrop-filter: blur(5px) !important;
      -webkit-backdrop-filter: blur(5px);
      transition: opacity 200ms ease;
    }
  }
}

.explorer 안에 중첩하여 .explorer .explorer-blur로 스코프를 제한한다. background를 거의 투명한 흰색(rgba(255, 255, 255, 0.01))으로 설정한 이유는, 완전히 투명하면 일부 브라우저에서 backdrop-filter가 동작하지 않기 때문이다. 100dvh는 모바일 브라우저의 주소창 크기 변화에 대응하는 동적 뷰포트 높이 단위다. -webkit-backdrop-filter는 Safari 호환을 위한 접두사다.

데스크톱 Explorer 항상 펼침

데스크톱에서는 사이드바 공간이 충분하므로 Explorer를 접을 필요가 없다. title-button의 클릭 이벤트를 차단하고, 접힌 상태에서도 레이아웃이 유지되도록 했다.

quartz/styles/component/plugins/explorer_override.scss
.explorer {
  &.collapsed {
    flex: 0 1 auto;
 
    & .fold {
      transform: none;
    }
  }
 
  button.title-button {
    pointer-events: none;
 
    & > svg {
      display: none;
    }
  }
}

button.title-buttonpointer-events: none을 적용하여 클릭 자체를 비활성화하고, 접기 화살표 SVG를 숨긴다. .collapsed 상태에서도 flex: 0 1 auto로 레이아웃을 유지하고, .fold 엘리먼트의 transform을 초기화하여 접힘 애니메이션을 무효화한다. 미디어 쿼리 없이 전역으로 적용되는 규칙이며, JavaScript 수정 없이 CSS만으로 동작을 제어한 것이 포인트다.

마무리

세 가지 개선 모두 Quartz의 기존 구조를 크게 변경하지 않는 범위에서 이루어졌다. FileTrieNodecountFiles()는 트리 자료구조에 자연스럽게 녹아드는 메서드이고, 폴더 노트 네비게이션은 기존 조건문에 한 줄을 추가한 것이며, 블러 오버레이와 데스크톱 펼침 고정은 오버라이드 SCSS로 처리했다.

공통된 접근 방식은 최소 침습이다. Explorer의 핵심 로직을 재작성하지 않고, 필요한 지점에만 코드를 끼워 넣어 동작을 확장했다. 이렇게 하면 Quartz upstream이 업데이트되었을 때 충돌 가능성이 낮아지고, 변경 사항을 추적하기도 쉽다.