게시글 목차와 댓글 등록 상태 정리 v1.5.16

This commit is contained in:
2026-06-01 11:43:36 +09:00
parent edbbd3c83c
commit dc50780ff8
9 changed files with 191 additions and 22 deletions

View File

@@ -3,6 +3,9 @@ import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
const route = useRoute()
const postToc = useState('post-detail-toc', () => [])
const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
@@ -45,6 +48,113 @@ const recommendedSites = computed(() => {
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
/**
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
* @returns {number} 뷰포트 상단 기준 오프셋
*/
const getTocActivationOffset = () => {
if (!import.meta.client) {
return 96
}
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
}
/**
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
* @returns {string} 활성 제목 ID
*/
const findActiveTocId = () => {
if (!import.meta.client || !postTocItems.value.length) {
return ''
}
const offset = getTocActivationOffset()
const currentY = window.scrollY + offset
let activeId = postTocItems.value[0].id
for (const item of postTocItems.value) {
const target = document.getElementById(item.id)
if (!target) {
continue
}
const targetY = target.getBoundingClientRect().top + window.scrollY
if (targetY <= currentY) {
activeId = item.id
} else {
break
}
}
return activeId
}
/**
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
* @param {string} id - 활성 제목 ID
* @returns {void}
*/
const scrollActiveTocIntoView = (id) => {
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
return
}
const nav = tocNavRef.value
const link = nav.querySelector(`[data-toc-id="${id}"]`)
if (!(link instanceof HTMLElement)) {
return
}
const navTop = nav.scrollTop
const navBottom = navTop + nav.clientHeight
const linkTop = link.offsetTop
const linkBottom = linkTop + link.offsetHeight
const buffer = 24
if (linkTop < navTop + buffer) {
nav.scrollTo({
top: Math.max(0, linkTop - buffer),
behavior: 'smooth'
})
return
}
if (linkBottom > navBottom - buffer) {
nav.scrollTo({
top: linkBottom - nav.clientHeight + buffer,
behavior: 'smooth'
})
}
}
/**
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
* @returns {void}
*/
const updateActiveToc = () => {
if (!import.meta.client || tocScrollFrame) {
return
}
tocScrollFrame = window.requestAnimationFrame(() => {
tocScrollFrame = 0
const nextActiveId = findActiveTocId()
if (!nextActiveId || nextActiveId === activeTocId.value) {
return
}
activeTocId.value = nextActiveId
scrollActiveTocIntoView(nextActiveId)
})
}
/**
* 새 탭으로 열 외부 URL인지
* @param {string} url - 링크
@@ -89,6 +199,8 @@ const scrollToTocItem = (event, id) => {
}
event.preventDefault()
activeTocId.value = id
scrollActiveTocIntoView(id)
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
@@ -98,6 +210,36 @@ const scrollToTocItem = (event, id) => {
/** 소개 영역 공개 여부 */
const showAboutSection = false
onMounted(() => {
if (!import.meta.client) {
return
}
window.addEventListener('scroll', updateActiveToc, { passive: true })
window.addEventListener('resize', updateActiveToc)
nextTick(updateActiveToc)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
window.removeEventListener('scroll', updateActiveToc)
window.removeEventListener('resize', updateActiveToc)
if (tocScrollFrame) {
window.cancelAnimationFrame(tocScrollFrame)
tocScrollFrame = 0
}
})
watch([postTocItems, () => route.fullPath], async () => {
activeTocId.value = ''
await nextTick()
updateActiveToc()
})
</script>
<template>
@@ -240,17 +382,22 @@ const showAboutSection = false
TOC
</p>
</div>
<nav class="right-sidebar__toc-nav mt-4" aria-label="게시글 목차">
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 max-h-[min(28rem,calc(100vh-18rem))] overflow-y-auto pr-2" aria-label="게시글 목차">
<ul v-if="postTocItems.length" class="right-sidebar__toc-list list-none space-y-2 p-0">
<li v-for="item in postTocItems" :key="item.id">
<a
class="right-sidebar__toc-link site-interactive block rounded-md py-1.5 pr-3 text-sm leading-snug text-[var(--site-text)] hover:text-[var(--site-accent)]"
class="right-sidebar__toc-link site-interactive block rounded-md border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
:class="{
'pl-0 font-semibold': item.level === 1,
'pl-3': item.level === 2,
'pl-6 text-xs site-muted': item.level === 3
'border-[var(--site-accent)] bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
'border-transparent text-[var(--site-text)]': activeTocId !== item.id,
'pl-2 font-semibold': item.level === 1,
'pl-5': item.level === 2,
'pl-8 text-xs': item.level === 3,
'site-muted': item.level === 3 && activeTocId !== item.id
}"
:href="`#${item.id}`"
:aria-current="activeTocId === item.id ? 'location' : undefined"
:data-toc-id="item.id"
@click="scrollToTocItem($event, item.id)"
>
{{ item.text }}