From 886328bc72e88786078d47d148d790fb2d4a7fbd Mon Sep 17 00:00:00 2001 From: clkim Date: Tue, 20 Jan 2026 13:15:55 +0900 Subject: [PATCH] =?UTF-8?q?fix.=EB=AA=A8=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- layers/components/atoms/Button/Circle.vue | 9 +- layers/components/atoms/Button/Play.vue | 10 +- layers/components/atoms/Button/index.vue | 27 +- layers/components/blocks/Button/Home.vue | 3 + layers/components/blocks/Button/ScrollTop.vue | 3 + .../components/blocks/Button/SlideArrows.vue | 10 + layers/components/layouts/EventNavigation.vue | 4 + layers/plugins/motion-stagger.client.ts | 247 +++++++++++------- tailwind.config.ts | 4 +- 9 files changed, 207 insertions(+), 110 deletions(-) diff --git a/layers/components/atoms/Button/Circle.vue b/layers/components/atoms/Button/Circle.vue index 0c76db0..6b143a6 100644 --- a/layers/components/atoms/Button/Circle.vue +++ b/layers/components/atoms/Button/Circle.vue @@ -21,7 +21,6 @@ const componentTag = computed((): string => { return 'button' } }) - const componentProps = computed(() => { switch (props.type) { case 'link': @@ -34,7 +33,9 @@ const componentProps = computed(() => { @@ -51,4 +52,8 @@ const componentProps = computed(() => { .btn-circle:deep(svg) { @apply w-[20px] h-[20px] md:w-[24px] md:h-[24px]; } + +.icon { + @apply transition-transform duration-300 ease-spring; +} diff --git a/layers/components/atoms/Button/Play.vue b/layers/components/atoms/Button/Play.vue index eedc27d..946a705 100644 --- a/layers/components/atoms/Button/Play.vue +++ b/layers/components/atoms/Button/Play.vue @@ -22,7 +22,9 @@ const handlePlayClick = () => { :style="{ backgroundColor: props.bgColor }" @click="handlePlayClick" > - + + + Play @@ -34,4 +36,10 @@ const handlePlayClick = () => { after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-[50%] after:opacity-0 after:transition-opacity after:duration-300 after:ease-in-out hover:after:opacity-10; } +.btn-play:hover .icon { + @apply scale-[1.08]; +} +.icon { + @apply transition-transform duration-300 ease-spring; +} diff --git a/layers/components/atoms/Button/index.vue b/layers/components/atoms/Button/index.vue index 07b8b7c..146ead8 100644 --- a/layers/components/atoms/Button/index.vue +++ b/layers/components/atoms/Button/index.vue @@ -29,7 +29,7 @@ const componentTag = computed((): string => { case 'download': return props.href ? 'a' : 'button' case 'internal': - return 'AtomsLocaleLink' + return props.href ? 'AtomsLocaleLink' : 'button' default: return 'button' } @@ -49,9 +49,12 @@ const componentProps = computed(() => { } if (props.type === 'internal') { - return { - to: props.href, + if (props.href) { + return { + to: props.href, + } } + return {} } if (props.type === 'download') { @@ -87,17 +90,17 @@ const componentProps = computed(() => { @@ -121,6 +124,18 @@ const componentProps = computed(() => { @apply before:border-[rgba(0,0,0,0.1)] hover:before:border-[#999]; } +.icon { + @apply transition-transform duration-300 ease-spring; +} +.btn-base:hover .icon-internal { + @apply translate-x-[3px]; +} +.btn-base:hover .icon-external { + @apply scale-[1.08]; +} +.btn-base:hover .icon-download { + @apply translate-y-[3px]; +} .btn-base.disabled .btn-content { @apply opacity-50; diff --git a/layers/components/blocks/Button/Home.vue b/layers/components/blocks/Button/Home.vue index bad5983..cfd9293 100644 --- a/layers/components/blocks/Button/Home.vue +++ b/layers/components/blocks/Button/Home.vue @@ -31,4 +31,7 @@ const analytics = { @apply fixed top-3 right-3 mt-[calc(var(--scroll-position,48px)+48px)] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] z-[100] sm:top-5 md:top-6 md:right-8 md:mt-[calc(var(--scroll-position,64px)+64px)]; } +.btn-home:hover :deep(.icon) { + @apply scale-[1.08]; +} diff --git a/layers/components/blocks/Button/ScrollTop.vue b/layers/components/blocks/Button/ScrollTop.vue index 94fef90..7e73f0d 100644 --- a/layers/components/blocks/Button/ScrollTop.vue +++ b/layers/components/blocks/Button/ScrollTop.vue @@ -36,4 +36,7 @@ const handleScrollToTop = () => { .btn-top { @apply bg-[image:var(--button-top)] bg-center bg-cover bg-no-repeat; } +.btn-top:hover :deep(.icon) { + @apply -translate-y-[3px]; +} diff --git a/layers/components/blocks/Button/SlideArrows.vue b/layers/components/blocks/Button/SlideArrows.vue index b96e7a9..9acdba9 100644 --- a/layers/components/blocks/Button/SlideArrows.vue +++ b/layers/components/blocks/Button/SlideArrows.vue @@ -45,3 +45,13 @@ const handleArrowClick = (direction: 'prev' | 'next') => { + + diff --git a/layers/components/layouts/EventNavigation.vue b/layers/components/layouts/EventNavigation.vue index d6120a8..f023273 100644 --- a/layers/components/layouts/EventNavigation.vue +++ b/layers/components/layouts/EventNavigation.vue @@ -9,6 +9,7 @@ const { locale } = useI18n() const { sendLog } = useAnalytics() const gameDomain = getGameDomain() + const analytics = { action_type: 'click', click_sarea: 'EventNavigation', @@ -132,6 +133,9 @@ onMounted(async () => { @apply absolute top-3 right-[-40px] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] rotate-180 sm:top-5 md:top-6 md:right-[-48px]; } +.btn-control:hover :deep(.icon) { + @apply translate-x-[3px]; +} .event-navigation.is-closed { @apply translate-x-[calc(-100%+20px)] sm:translate-x-[calc(-100%+40px)]; diff --git a/layers/plugins/motion-stagger.client.ts b/layers/plugins/motion-stagger.client.ts index 07d0de6..55f95cb 100644 --- a/layers/plugins/motion-stagger.client.ts +++ b/layers/plugins/motion-stagger.client.ts @@ -1,152 +1,199 @@ 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>() + const animatedItems = new Set() + const sectionObservers = new Map() + const sectionItems = new Map>() - // 섹션의 motion-item들을 애니메이션 - const animateSectionItems = (section: Element) => { + /** + * 섹션의 motion-item들을 애니메이션 + */ + const animateSectionItems = (section: SectionElement): void => { const items = sectionItems.get(section) - if (!items || items.size === 0) return + if (!items?.size) return const newItems = Array.from(items).filter(item => !animatedItems.has(item)) - if (newItems.length === 0) return + if (!newItems.length) return // 애니메이션 실행 newItems.forEach(item => animatedItems.add(item)) animate( newItems, - { opacity: 1, y: 0 } as DOMKeyframesDefinition, + ANIMATION_CONFIG.target as DOMKeyframesDefinition, { - delay: stagger(0.2), - duration: 0.5, - easing: [0.22, 1, 0.36, 1], + delay: stagger(ANIMATION_CONFIG.stagger), + duration: ANIMATION_CONFIG.duration, + easing: ANIMATION_CONFIG.easing, } as AnimationOptions ) } - // 섹션에 IntersectionObserver 등록 - const observeSection = (section: Element) => { + /** + * 섹션이 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 => { - entries.forEach(entry => { - if (entry.isIntersecting) { - animateSectionItems(section) - } - }) - }, - { threshold: 0.2 } - ) + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + animateSectionItems(section) + } + } + }, INTERSECTION_CONFIG) 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 + /** + * 섹션에 아이템 등록 및 초기 애니메이션 체크 + */ + 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) { - // 초기 스타일 설정 (애니메이션 전) - el.style.opacity = '0' - el.style.transform = 'translateY(30px)' + setInitialStyles(el) - // 가장 가까운 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) - } - sectionItems.get(section)!.add(el) - - // 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크) - const inViewport = isSectionInViewport(section) - - 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') + const section = findSection(el) 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) - } - } + registerItem(el, section) + }, - animatedItems.delete(el) + 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')) + const currentSections = new Set( + Array.from(document.querySelectorAll('section')) as SectionElement[] ) - sectionObservers.forEach((observer, section) => { + for (const [section, observer] of sectionObservers.entries()) { if (!currentSections.has(section)) { observer.disconnect() sectionObservers.delete(section) sectionItems.delete(section) } - }) + } }) } }) diff --git a/tailwind.config.ts b/tailwind.config.ts index 17fe0c2..7dd19de 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -11,7 +11,6 @@ export default { md: '1024px', // PC: 1024px ~ 1439px lg: '1440px', // Large PC: 1440px+ }, - spacing: {}, colors: { 'theme-foreground': 'var(--foreground)', 'theme-foreground-10': 'var(--foreground-10)', @@ -26,6 +25,9 @@ export default { 'theme-foreground-gray-750': 'var(--foreground-gray-750)', 'theme-foreground-gray-500': 'var(--foreground-gray-500)', }, + transitionTimingFunction: { + spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + }, }, }, } satisfies Config