Files
web-temp/layers/plugins/motion-stagger.client.ts
2026-01-13 13:56:31 +09:00

169 lines
4.9 KiB
TypeScript

import { animate, stagger } from 'motion-v'
import type { DOMKeyframesDefinition, AnimationOptions } from 'motion-v'
export default defineNuxtPlugin(nuxtApp => {
// 전역 상태 관리
const animatedItems = new Set<Element>()
const sectionObservers = new Map<Element, IntersectionObserver>()
const sectionItems = new Map<Element, Set<Element>>()
// 섹션의 motion-item들을 애니메이션
const animateSectionItems = (section: Element) => {
const items = sectionItems.get(section)
if (!items || items.size === 0) return
const newItems = Array.from(items).filter(item => !animatedItems.has(item))
if (newItems.length === 0) return
// 애니메이션 실행
newItems.forEach(item => animatedItems.add(item))
animate(
newItems,
{ opacity: 1, y: 0 } as DOMKeyframesDefinition,
{
delay: stagger(0.2),
duration: 0.5,
easing: [0.22, 1, 0.36, 1],
} as AnimationOptions
)
}
// 섹션에 IntersectionObserver 등록
const observeSection = (section: Element) => {
if (sectionObservers.has(section)) return
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateSectionItems(section)
}
})
},
{ threshold: 0.2 }
)
observer.observe(section)
sectionObservers.set(section, observer)
}
// 섹션이 viewport에 있는지 확인
const isSectionInViewport = (section: Element): boolean => {
const rect = section.getBoundingClientRect()
const windowHeight =
window.innerHeight || document.documentElement.clientHeight
// threshold 0.2를 고려한 체크
const visibleHeight =
Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
const sectionHeight = rect.height
return visibleHeight / sectionHeight >= 0.2
}
// v-motion-stagger 디렉티브
nuxtApp.vueApp.directive('motion-stagger', {
mounted(el: HTMLElement) {
// 초기 스타일 설정 (애니메이션 전)
el.style.opacity = '0'
el.style.transform = 'translateY(30px)'
// 디버깅: 요소 등록 확인
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] Element mounted:', el.tagName)
}
// 가장 가까운 section 찾기
const section = el.closest('section')
if (!section) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn('[motion-stagger] No section found for element:', el)
}
return
}
// 섹션의 아이템 목록에 추가
if (!sectionItems.has(section)) {
sectionItems.set(section, new Set())
observeSection(section)
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] New section registered')
}
}
sectionItems.get(section)!.add(el)
// 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크)
const inViewport = isSectionInViewport(section)
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.log('[motion-stagger] Section in viewport:', inViewport)
}
if (inViewport) {
// 즉시 실행
requestAnimationFrame(() => {
animateSectionItems(section)
})
// 추가로 100ms 후 재확인 (비동기 렌더링 대응)
setTimeout(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
}, 100)
// 추가로 300ms 후 재확인 (느린 비동기 데이터 대응)
setTimeout(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
}, 300)
}
},
unmounted(el: Element) {
const section = el.closest('section')
if (!section) return
// 아이템 제거
const items = sectionItems.get(section)
if (items) {
items.delete(el)
// 섹션에 아이템이 없으면 observer 정리
if (items.size === 0) {
const observer = sectionObservers.get(section)
if (observer) {
observer.disconnect()
sectionObservers.delete(section)
}
sectionItems.delete(section)
}
}
animatedItems.delete(el)
},
})
// 페이지 이동 시 정리
if (import.meta.client) {
nuxtApp.hook('page:finish', () => {
// 현재 페이지에 없는 섹션들 정리
const currentSections = new Set<Element>(
Array.from(document.querySelectorAll('section'))
)
sectionObservers.forEach((observer, section) => {
if (!currentSections.has(section)) {
observer.disconnect()
sectionObservers.delete(section)
sectionItems.delete(section)
}
})
})
}
})