import { animate, stagger } from 'motion-v' import type { DOMKeyframesDefinition, AnimationOptions } from 'motion-v' // 상수 정의 const ANIMATION_CONFIG = { initial: { opacity: 0, translateY: '40px', }, target: { opacity: 1, y: 0, }, stagger: 0.1, duration: 0.6, easing: 'ease', } as const const INTERSECTION_CONFIG = { threshold: 0.2, } as const const RETRY_DELAYS = [0, 100, 300] as const // 타입 정의 type SectionElement = HTMLElement & { tagName: 'SECTION' } export default defineNuxtPlugin(nuxtApp => { // 전역 상태 관리 const animatedItems = new Set() const sectionObservers = new Map() const sectionItems = new Map>() /** * 섹션의 motion-item들을 애니메이션 */ const animateSectionItems = (section: SectionElement): void => { const items = sectionItems.get(section) if (!items?.size) return const newItems = Array.from(items).filter(item => !animatedItems.has(item)) if (!newItems.length) return // 애니메이션 실행 newItems.forEach(item => animatedItems.add(item)) animate( newItems, ANIMATION_CONFIG.target as DOMKeyframesDefinition, { delay: stagger(ANIMATION_CONFIG.stagger), duration: ANIMATION_CONFIG.duration, easing: ANIMATION_CONFIG.easing, } as AnimationOptions ) } /** * 섹션이 viewport에 있는지 확인 */ const isSectionInViewport = (section: SectionElement): boolean => { const rect = section.getBoundingClientRect() const windowHeight = window.innerHeight || document.documentElement.clientHeight const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0) const sectionHeight = rect.height return visibleHeight / sectionHeight >= INTERSECTION_CONFIG.threshold } /** * 섹션에 IntersectionObserver 등록 */ const observeSection = (section: SectionElement): void => { if (sectionObservers.has(section)) return const observer = new IntersectionObserver(entries => { for (const entry of entries) { if (entry.isIntersecting) { animateSectionItems(section) } } }, INTERSECTION_CONFIG) observer.observe(section) sectionObservers.set(section, observer) } /** * 섹션에 아이템 등록 및 초기 애니메이션 체크 */ const registerItem = (el: HTMLElement, section: SectionElement): void => { // 섹션의 아이템 목록에 추가 if (!sectionItems.has(section)) { sectionItems.set(section, new Set()) observeSection(section) } sectionItems.get(section)!.add(el) // 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크) if (!isSectionInViewport(section)) return // 즉시 실행 및 재확인 for (const delay of RETRY_DELAYS) { if (delay === 0) { requestAnimationFrame(() => { if (isSectionInViewport(section)) { animateSectionItems(section) } }) } else { setTimeout(() => { if (isSectionInViewport(section)) { animateSectionItems(section) } }, delay) } } } /** * 섹션에서 아이템 제거 및 정리 */ const unregisterItem = (el: HTMLElement, section: SectionElement): void => { const items = sectionItems.get(section) if (!items) return items.delete(el) animatedItems.delete(el) // 섹션에 아이템이 없으면 observer 정리 if (items.size === 0) { const observer = sectionObservers.get(section) observer?.disconnect() sectionObservers.delete(section) sectionItems.delete(section) } } /** * 초기 스타일 설정 */ const setInitialStyles = (el: HTMLElement): void => { el.style.opacity = ANIMATION_CONFIG.initial.opacity.toString() el.style.transform = `translateY(${ANIMATION_CONFIG.initial.translateY})` } /** * 가장 가까운 section 찾기 */ const findSection = (el: HTMLElement): SectionElement | null => { 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 null } return section as SectionElement } // v-motion-stagger 디렉티브 nuxtApp.vueApp.directive('motion-stagger', { mounted(el: HTMLElement) { setInitialStyles(el) const section = findSection(el) if (!section) return registerItem(el, section) }, unmounted(el: HTMLElement) { const section = el.closest('section') as SectionElement | null if (!section) return unregisterItem(el, section) }, }) // 페이지 이동 시 정리 if (import.meta.client) { nuxtApp.hook('page:finish', () => { const currentSections = new Set( Array.from(document.querySelectorAll('section')) as SectionElement[] ) for (const [section, observer] of sectionObservers.entries()) { if (!currentSections.has(section)) { observer.disconnect() sectionObservers.delete(section) sectionItems.delete(section) } } }) } })