페이지 전환 깜빡임 완화 및 레이아웃 통합 (v1.10.50)

- 게시물 상세를 default 레이아웃으로 통합해 홈↔게시물 레이아웃 교체 제거
- CSS 전환 베일·스크롤 정리 플러그인으로 이동 중 깜빡임 완화

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 14:28:24 +09:00
parent 021c1ee526
commit 4c8f6e44f6
9 changed files with 293 additions and 84 deletions

92
app.vue
View File

@@ -18,68 +18,7 @@ const siteAccentStyle = computed(() => ({
'--site-accent': siteAccentColor.value '--site-accent': siteAccentColor.value
})) }))
const isSitePageNavigating = ref(false) const isSitePageNavigating = useState('site-page-veil', () => false)
if (import.meta.client) {
const nuxtApp = useNuxtApp()
const router = useRouter()
/** @type {ReturnType<typeof setTimeout> | null} */
let hideOverlayTimer = null
let navigationOverlayToken = 0
/**
* 공개 페이지 이동 로딩 오버레이를 표시한다.
* @returns {void}
*/
const showSitePageNavigationOverlay = () => {
navigationOverlayToken += 1
clearTimeout(hideOverlayTimer)
hideOverlayTimer = null
isSitePageNavigating.value = true
}
/**
* 새 화면이 실제로 그려진 뒤 공개 페이지 이동 로딩 오버레이를 숨긴다.
* @returns {void}
*/
const scheduleHideSitePageNavigationOverlay = () => {
const token = navigationOverlayToken
requestAnimationFrame(() => {
requestAnimationFrame(() => {
hideOverlayTimer = setTimeout(() => {
if (token !== navigationOverlayToken) {
return
}
isSitePageNavigating.value = false
hideOverlayTimer = null
}, 120)
})
})
}
/**
* 오류·취소 상황에서 공개 페이지 이동 로딩 오버레이를 즉시 숨긴다.
* @returns {void}
*/
const hideSitePageNavigationOverlayImmediately = () => {
clearTimeout(hideOverlayTimer)
hideOverlayTimer = null
isSitePageNavigating.value = false
}
router.beforeEach((to, from) => {
if (to.fullPath !== from.fullPath) {
showSitePageNavigationOverlay()
}
})
router.onError(hideSitePageNavigationOverlayImmediately)
nuxtApp.hook('page:loading:start', showSitePageNavigationOverlay)
nuxtApp.hook('page:finish', scheduleHideSitePageNavigationOverlay)
nuxtApp.hook('app:error', hideSitePageNavigationOverlayImmediately)
}
useHead(() => ({ useHead(() => ({
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title, titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
@@ -97,27 +36,18 @@ useHead(() => ({
<template> <template>
<div class="site-app" :style="siteAccentStyle"> <div class="site-app" :style="siteAccentStyle">
<NuxtLoadingIndicator :color="siteAccentColor" :height="3" :throttle="80" :duration="5000" /> <div
<Transition v-show="isSitePageNavigating"
enter-active-class="transition duration-100 ease-out" class="site-page-loading pointer-events-none fixed inset-0 z-[2147483647] grid place-items-center text-[var(--site-text)]"
enter-from-class="opacity-0" role="status"
enter-to-class="opacity-100" aria-live="polite"
leave-active-class="transition duration-150 ease-in" aria-busy="true"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
> >
<div <div class="site-page-loading__panel grid place-items-center gap-3 text-center">
v-if="isSitePageNavigating" <span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
class="site-page-loading fixed inset-0 z-[90] grid place-items-center bg-[var(--site-bg)] text-[var(--site-text)]" <span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
role="status"
aria-live="polite"
>
<div class="site-page-loading__panel grid place-items-center gap-3 text-center">
<span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
<span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
</div>
</div> </div>
</Transition> </div>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>

25
app/router.options.js Normal file
View File

@@ -0,0 +1,25 @@
import { setPendingScrollRestore } from '../lib/site-route-transition.js'
/**
* 라우터 스크롤 동작
* 클라이언트에서는 scrollBehavior가 직접 스크롤하지 않고,
* 전환 베일 뒤에서 `site-page-transition.client.js`가 스크롤을 적용한다.
* @type {import('vue-router').RouterScrollBehavior}
*/
export default (to, from, savedPosition) => {
if (import.meta.server) {
return { top: 0, left: 0 }
}
if (to.hash) {
return {
el: to.hash
}
}
if (savedPosition) {
setPendingScrollRestore(savedPosition)
}
return false
}

View File

@@ -104,6 +104,24 @@
pointer-events: none; pointer-events: none;
} }
html.site-page-veil {
overflow: hidden;
}
html.site-page-veil::before {
content: '';
position: fixed;
inset: 0;
z-index: 2147483646;
background: var(--site-bg);
pointer-events: all;
}
html.site-page-veil .site-shell,
html.site-page-veil .site-shell * {
transition: none !important;
}
body { body {
min-width: 320px; min-width: 320px;
margin: 0; margin: 0;

View File

@@ -144,7 +144,7 @@
### 공개 URL 구조 ### 공개 URL 구조
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다. - `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
- `/post/:slug` - 개별 게시물 상세. 직접 접속·SSR과 클라이언트 내부 이동 모두 본문을 먼저 조회한 뒤 완성된 상세 화면으로 진입한다. 이동 중에는 `NuxtLoadingIndicator` 상단 진행바, 중복 클릭 잠금, 전역 페이지 이동 로딩 오버레이로 피드백을 제공한다. 오버레이는 불투명 배경으로 이전 화면을 완전히 가리고, 새 페이지 렌더 완료 후 다음 렌더 프레임까지 기다린 뒤 해제해 이전 홈 화면이 상단으로 튀어 보이지 않게 한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다. - `/post/:slug` - 개별 게시물 상세. 직접 접속·SSR과 클라이언트 내부 이동 모두 본문을 먼저 조회한 뒤 완성된 상세 화면으로 진입한다. 이동 시작 시 불투명 전환 베일을 즉시 표시하고, `page:finish` 이후 베일 뒤에서 스크롤을 최상단으로 맞춘 뒤 베일을 해제한다. 뒤로가기는 저장 스크롤 위치를 베일 뒤에서 복원한다. `history.scrollRestoration`은 `manual`로 두어 브라우저 자동 복원과 충돌하지 않게 한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
- `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다. - `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다.
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다. - `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다. - `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.

View File

@@ -1,5 +1,19 @@
# 업데이트 이력 # 업데이트 이력
## v1.10.50
- 게시물 상세를 `layouts/post` 대신 `layouts/default`로 통합해 홈↔게시물 이동 시 레이아웃 전체 교체(깜빡임 원인) 제거.
- 전환 베일을 `html::before` CSS로 즉시 표시(Vue 렌더 대기 없음), 내부 링크 클릭 캡처 단계에서 선제 적용.
- 베일 유지 중 `.site-shell` CSS transition 비활성화, 해제 전 48ms 추가 대기로 페인트 완료 후 공개.
- `NuxtLoadingIndicator` 제거(전환 베일과 중복), 스피너만 최상위 z-index로 표시.
## v1.10.49
- 페이지 전환 깜빡임·스크롤 튐을 줄이기 위해 전환 베일 방식을 재정리.
- 오버레이 페이드 제거(`v-show` 즉시 표시/해제), 베일 뒤에서 스크롤 정리 후 화면 공개.
- `history.scrollRestoration = 'manual'`과 `app/router.options.js`로 브라우저 자동 스크롤 복원과 충돌 방지.
- 앞으로 이동은 최상단, 뒤로가기는 저장 위치 복원. `site-page-transition.client.js` 추가.
## v1.10.48 ## v1.10.48
- 페이지 이동 로딩 오버레이 배경을 불투명하게 변경해 이전 홈 헤더 이미지가 비쳐 보이지 않도록 수정. - 페이지 이동 로딩 오버레이 배경을 불투명하게 변경해 이전 홈 헤더 이미지가 비쳐 보이지 않도록 수정.

View File

@@ -0,0 +1,65 @@
/** @type {{ top: number, left: number } | null} */
let pendingScrollRestore = null
/**
* 뒤로가기 스크롤 복원 좌표를 저장한다.
* @param {{ top?: number, left?: number }} position - 복원 좌표
* @returns {void}
*/
export const setPendingScrollRestore = (position) => {
pendingScrollRestore = {
top: Number(position?.top || 0),
left: Number(position?.left || 0)
}
}
/**
* 저장된 스크롤 복원 좌표를 꺼낸다.
* @returns {{ top: number, left: number } | null}
*/
export const consumePendingScrollRestore = () => {
const position = pendingScrollRestore
pendingScrollRestore = null
return position
}
/**
* 공개 사이트 경로에서 전환 베일을 사용할지 판별한다.
* @param {string} path - 라우트 경로
* @returns {boolean}
*/
export const shouldUseSiteRouteVeil = (path) => {
const clean = String(path || '').split('?')[0].split('#')[0]
return !clean.startsWith('/admin')
}
/**
* 문서 스크롤을 최상단으로 이동한다.
* @returns {void}
*/
export const scrollDocumentToTop = () => {
if (!import.meta.client) {
return
}
window.scrollTo(0, 0)
document.documentElement.scrollTop = 0
document.body.scrollTop = 0
}
/**
* 문서 스크롤을 지정 좌표로 이동한다.
* @param {{ top?: number, left?: number }} position - 복원 좌표
* @returns {void}
*/
export const scrollDocumentTo = (position) => {
if (!import.meta.client) {
return
}
const top = Number(position?.top || 0)
const left = Number(position?.left || 0)
window.scrollTo(left, top)
document.documentElement.scrollTop = top
document.body.scrollTop = top
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.10.48", "version": "1.10.50",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -2,7 +2,7 @@
import { extractMarkdownToc } from '~/lib/markdown-toc.js' import { extractMarkdownToc } from '~/lib/markdown-toc.js'
definePageMeta({ definePageMeta({
layout: 'post' layout: 'default'
}) })
const route = useRoute() const route = useRoute()

View File

@@ -0,0 +1,157 @@
import {
consumePendingScrollRestore,
scrollDocumentTo,
scrollDocumentToTop,
shouldUseSiteRouteVeil
} from '../lib/site-route-transition.js'
const SITE_ROUTE_VEIL_CLASS = 'site-page-veil'
/**
* 공개 페이지 이동 시 전환 베일을 표시하고, 베일 뒤에서 스크롤을 정리한 뒤 새 화면을 드러낸다.
*/
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter()
const isSitePageVeiled = useState('site-page-veil', () => false)
/** @type {boolean} */
let activeVeilNavigation = false
/**
* 전환 베일을 즉시 표시한다.
* @returns {void}
*/
const showRouteVeil = () => {
if (document.documentElement.classList.contains(SITE_ROUTE_VEIL_CLASS)) {
return
}
activeVeilNavigation = true
isSitePageVeiled.value = true
document.documentElement.classList.add(SITE_ROUTE_VEIL_CLASS)
}
/**
* 전환 베일을 즉시 숨긴다.
* @returns {void}
*/
const hideRouteVeil = () => {
activeVeilNavigation = false
isSitePageVeiled.value = false
document.documentElement.classList.remove(SITE_ROUTE_VEIL_CLASS)
}
/**
* 내부 링크 클릭이 라우터보다 먼저 베일을 덮도록 한다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const handleInternalLinkCapture = (event) => {
if (event.defaultPrevented || event.button !== 0) {
return
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return
}
const target = event.target
if (!(target instanceof Element)) {
return
}
const anchor = target.closest('a')
if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download')) {
return
}
const href = anchor.getAttribute('href')
if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
return
}
let url = null
try {
url = new URL(anchor.href, window.location.origin)
} catch {
return
}
if (url.origin !== window.location.origin) {
return
}
const currentPath = router.currentRoute.value.path
if (!shouldUseSiteRouteVeil(url.pathname) && !shouldUseSiteRouteVeil(currentPath)) {
return
}
if (url.pathname === currentPath && !url.hash) {
return
}
showRouteVeil()
}
/**
* 페이지 렌더 완료 후 스크롤을 맞추고 베일을 해제한다.
* @returns {Promise<void>}
*/
const finishRouteTransition = async () => {
if (!activeVeilNavigation) {
return
}
const route = router.currentRoute.value
const restore = consumePendingScrollRestore()
if (restore) {
scrollDocumentTo(restore)
} else if (!route.hash) {
scrollDocumentToTop()
}
await new Promise((resolve) => requestAnimationFrame(resolve))
await new Promise((resolve) => requestAnimationFrame(resolve))
if (restore) {
scrollDocumentTo(restore)
} else if (!route.hash) {
scrollDocumentToTop()
}
await new Promise((resolve) => setTimeout(resolve, 48))
hideRouteVeil()
}
if (import.meta.client && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual'
}
document.addEventListener('click', handleInternalLinkCapture, true)
router.beforeEach((to, from) => {
if (to.fullPath === from.fullPath) {
return
}
if (!shouldUseSiteRouteVeil(to.path) && !shouldUseSiteRouteVeil(from.path)) {
return
}
showRouteVeil()
})
router.onError(() => {
hideRouteVeil()
})
nuxtApp.hook('page:finish', () => {
finishRouteTransition()
})
nuxtApp.hook('app:error', () => {
hideRouteVeil()
})
})