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

200 lines
5.2 KiB
TypeScript

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<HTMLElement>()
const sectionObservers = new Map<SectionElement, IntersectionObserver>()
const sectionItems = new Map<SectionElement, Set<HTMLElement>>()
/**
* 섹션의 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<SectionElement>(
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)
}
}
})
}
})