게시글 목차와 댓글 등록 상태 정리 v1.5.16
This commit is contained in:
@@ -19,6 +19,8 @@ const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
const sortOption = ref('best')
|
||||
const brokenAvatarCommentIds = ref([])
|
||||
const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value)
|
||||
const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value)
|
||||
|
||||
/**
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
@@ -313,15 +315,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||
<label for="comment-sort">Sort by:</label>
|
||||
<label for="comment-sort">정렬:</label>
|
||||
<select
|
||||
id="comment-sort"
|
||||
v-model="sortOption"
|
||||
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
|
||||
>
|
||||
<option value="best">Best</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="best">인기순</option>
|
||||
<option value="latest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -339,8 +341,8 @@ onMounted(async () => {
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submitting"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="!canSubmitComment"
|
||||
@click="submitComment"
|
||||
>
|
||||
{{ submitting ? '등록 중...' : '댓글 등록' }}
|
||||
@@ -448,7 +450,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
|
||||
<textarea
|
||||
v-model="replyBody"
|
||||
rows="3"
|
||||
@@ -464,8 +466,8 @@ onMounted(async () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submittingReplyId === comment.id"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="!canSubmitReply"
|
||||
@click="submitReply(comment.id)"
|
||||
>
|
||||
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
|
||||
@@ -543,4 +545,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user