페이지 전환 깜빡임 완화 및 레이아웃 통합 (v1.10.50)
- 게시물 상세를 default 레이아웃으로 통합해 홈↔게시물 레이아웃 교체 제거 - CSS 전환 베일·스크롤 정리 플러그인으로 이동 중 깜빡임 완화 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
app.vue
92
app.vue
@@ -18,68 +18,7 @@ const siteAccentStyle = computed(() => ({
|
||||
'--site-accent': siteAccentColor.value
|
||||
}))
|
||||
|
||||
const isSitePageNavigating = ref(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)
|
||||
}
|
||||
const isSitePageNavigating = useState('site-page-veil', () => false)
|
||||
|
||||
useHead(() => ({
|
||||
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
|
||||
@@ -97,27 +36,18 @@ useHead(() => ({
|
||||
|
||||
<template>
|
||||
<div class="site-app" :style="siteAccentStyle">
|
||||
<NuxtLoadingIndicator :color="siteAccentColor" :height="3" :throttle="80" :duration="5000" />
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
<div
|
||||
v-show="isSitePageNavigating"
|
||||
class="site-page-loading pointer-events-none fixed inset-0 z-[2147483647] grid place-items-center text-[var(--site-text)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-if="isSitePageNavigating"
|
||||
class="site-page-loading fixed inset-0 z-[90] grid place-items-center bg-[var(--site-bg)] text-[var(--site-text)]"
|
||||
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 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>
|
||||
</Transition>
|
||||
</div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
25
app/router.options.js
Normal file
25
app/router.options.js
Normal 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
|
||||
}
|
||||
@@ -104,6 +104,24 @@
|
||||
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 {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/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` 글은 포함하지 않는다.
|
||||
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
- 페이지 이동 로딩 오버레이 배경을 불투명하게 변경해 이전 홈 헤더 이미지가 비쳐 보이지 않도록 수정.
|
||||
|
||||
65
lib/site-route-transition.js
Normal file
65
lib/site-route-transition.js
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.48",
|
||||
"version": "1.10.50",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { extractMarkdownToc } from '~/lib/markdown-toc.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'post'
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
157
plugins/site-page-transition.client.js
Normal file
157
plugins/site-page-transition.client.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user