Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/features/post-toc/use-active-heading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@
import { useEffect, useState } from "react"
import type { TableOfContentsItem } from "@/lib/markdown"

const ACTIVE_LINE_VIEWPORT_RATIO = 0.35

export const ACTIVE_LINE_VIEWPORT_RATIO = 0.35

/**
* 목차(TOC)에서 현재 스크롤 위치에 해당하는 활성 헤딩 ID를 추적합니다.
* 뷰포트 높이의 35% 지점(ACTIVE_LINE_VIEWPORT_RATIO)을 지나는 가장 최근 헤딩을 식별합니다.
*
* [성능 최적화]:
* 1. requestAnimationFrame(rAF) 스로틀링을 적용하여 스크롤 중 프레임당 최대 1회만 계산.
* 2. 역순(Bottom-up) 탐색 및 조기 종료(Early-break)를 통해 활성선 이전 헤딩들의 불필요한 getBoundingClientRect() 호출 방지.
*/
export function useActiveHeading(items: readonly TableOfContentsItem[]): string | undefined {
const [activeHeadingId, setActiveHeadingId] = useState<string>()

Expand All @@ -30,13 +38,19 @@ export function useActiveHeading(items: readonly TableOfContentsItem[]): string
const activeLine = window.innerHeight * ACTIVE_LINE_VIEWPORT_RATIO
let nextActiveHeadingId: string | undefined

for (const heading of headings) {
if (heading.getBoundingClientRect().top <= activeLine) {
// 아래쪽(최신) 헤딩부터 역순으로 검사하여 활성선(activeLine) 이하에 도달한 첫 번째 요소를 찾으면 즉시 루프 종료
for (let i = headings.length - 1; i >= 0; i -= 1) {
const heading = headings[i]

if (heading !== undefined && heading.getBoundingClientRect().top <= activeLine) {
nextActiveHeadingId = heading.id
break
}
}

setActiveHeadingId(nextActiveHeadingId)
setActiveHeadingId((current) =>
current === nextActiveHeadingId ? current : nextActiveHeadingId,
)
}

const requestActiveHeadingUpdate = () => {
Expand Down
26 changes: 26 additions & 0 deletions tests/use-active-heading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { useActiveHeading } from "../src/features/post-toc/use-active-heading"
import type { TableOfContentsItem } from "../src/lib/markdown"

declare global {
interface Window {
happyDOM: {
setViewport: (options: { width?: number; height?: number }) => void
}
}
}

// 활성 판정선은 뷰포트 높이의 35% 지점이다. 800px 뷰포트에서는 280px.
const VIEWPORT_HEIGHT = 800
const ACTIVE_LINE = 280
Expand Down Expand Up @@ -41,6 +49,24 @@ describe("active heading tracking", () => {
expect(result.current).toBe("body")
})

it("Given multiple headings When bottom-up search finds active heading Then it breaks early without measuring earlier headings", () => {
const first = placeHeading("first", -300)
const second = placeHeading("second", 150)
const third = placeHeading("third", 500)

const firstMeasure = vi.spyOn(first, "getBoundingClientRect")
const secondMeasure = vi.spyOn(second, "getBoundingClientRect")
const thirdMeasure = vi.spyOn(third, "getBoundingClientRect")

const { result } = renderHook(() => useActiveHeading(tocItems("first", "second", "third")))

expect(result.current).toBe("second")
expect(thirdMeasure).toHaveBeenCalled()
expect(secondMeasure).toHaveBeenCalled()
// second가 activeLine 이하이므로 루프가 조기 종료되어 first는 측정되지 않아야 한다.
expect(firstMeasure).not.toHaveBeenCalled()
})

it("Given every heading below the active line When tracking Then reports no active heading", () => {
placeHeading("intro", ACTIVE_LINE + 1)

Expand Down