diff --git a/components/comments/PostComments.vue b/components/comments/PostComments.vue
index 32e3dce..92b1dea 100644
--- a/components/comments/PostComments.vue
+++ b/components/comments/PostComments.vue
@@ -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 () => {
-
+
@@ -339,8 +341,8 @@ onMounted(async () => {
-
-
diff --git a/components/site/RightSidebar.vue b/components/site/RightSidebar.vue
index afd8968..efa06ae 100644
--- a/components/site/RightSidebar.vue
+++ b/components/site/RightSidebar.vue
@@ -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()
+})
@@ -240,17 +382,22 @@ const showAboutSection = false
TOC
-