UNPKG

two-dimension-scroll

Version:

A smooth scroll library that detects both horizontal and vertical scroll and converts them to vertical smooth scrolling

1,763 lines (1,513 loc) 71.8 kB
/** * TwoDimensionScroll - lenis 스타일 스무스 스크롤 * 가로와 세로 스크롤을 모두 감지하여 부드러운 세로 스크롤로 변환하는 라이브러리 */ (function (global) { "use strict"; // Debug log removed for production // === 이징 함수들 === const Easing = { linear: function (t) { return t; }, easeInQuad: function (t) { return t * t; }, easeOutQuad: function (t) { return t * (2 - t); }, easeInOutQuad: function (t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; }, easeInCubic: function (t) { return t * t * t; }, easeOutCubic: function (t) { return --t * t * t + 1; }, easeInOutCubic: function (t) { return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; }, // lenis 스타일의 부드러운 이징 easeOutExpo: function (t) { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); }, easeOutCirc: function (t) { return Math.sqrt(1 - --t * t); }, }; // === 접근성 유틸리티 함수들 === // 사용자의 모션 감소 설정 확인 function prefersReducedMotion() { if (typeof window === "undefined") return false; return ( window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } // 고대비 모드 감지 function prefersHighContrast() { if (typeof window === "undefined") return false; return ( window.matchMedia && window.matchMedia("(prefers-contrast: high)").matches ); } // 키보드 네비게이션 사용자 감지 function detectKeyboardUser() { if (typeof document === "undefined") return false; var keyboardUser = false; document.addEventListener("keydown", function (e) { if ( e.key === "Tab" || e.key === "Enter" || e.key === " " || e.key.startsWith("Arrow") ) { keyboardUser = true; document.body.classList.add("keyboard-user"); } }); document.addEventListener("mousedown", function () { keyboardUser = false; document.body.classList.remove("keyboard-user"); }); return keyboardUser; } // 스크린 리더 감지 (휴리스틱 방법) function detectScreenReader() { if (typeof navigator === "undefined") return false; var isScreenReader = false; // 일반적인 스크린 리더 감지 var screenReaderIndicators = [ navigator.userAgent.includes("NVDA"), navigator.userAgent.includes("JAWS"), navigator.userAgent.includes("VoiceOver"), !!window.speechSynthesis, !!document.querySelector("[aria-live]"), document.body.classList.contains("screen-reader"), ]; return screenReaderIndicators.some(function (indicator) { return indicator; }); } // ARIA Live Region 생성 function createAriaLiveRegion() { if (typeof document === "undefined") return null; var liveRegion = document.getElementById("scroll-live-region"); if (!liveRegion) { liveRegion = document.createElement("div"); liveRegion.id = "scroll-live-region"; liveRegion.setAttribute("aria-live", "polite"); liveRegion.setAttribute("aria-atomic", "true"); liveRegion.style.position = "absolute"; liveRegion.style.left = "-10000px"; liveRegion.style.width = "1px"; liveRegion.style.height = "1px"; liveRegion.style.overflow = "hidden"; document.body.appendChild(liveRegion); } return liveRegion; } // 접근성 메시지 announce function announceToScreenReader(message, priority) { var liveRegion = createAriaLiveRegion(); if (!liveRegion) return; priority = priority || "polite"; // 'polite' 또는 'assertive' liveRegion.setAttribute("aria-live", priority); // 기존 내용 지우고 새 메시지 추가 liveRegion.textContent = ""; setTimeout(function () { liveRegion.textContent = message; }, 100); } // 포커스 관리 유틸리티 function manageFocus(element) { if (!element || typeof element.focus !== "function") return; element.focus(); element.setAttribute("tabindex", "-1"); // 프로그래밍적 포커스만 허용 // 포커스 아웃라인 스타일 적용 if (document.body.classList.contains("keyboard-user")) { element.style.outline = "2px solid #005fcc"; element.style.outlineOffset = "2px"; } } // 접근성 설정 체크 function getAccessibilitySettings() { return { reducedMotion: prefersReducedMotion(), highContrast: prefersHighContrast(), keyboardUser: detectKeyboardUser(), screenReader: detectScreenReader(), supportsAriaLive: typeof document !== "undefined" && "setAttribute" in document.createElement("div"), }; } // === 유틸리티 함수들 === function isMobile() { if (typeof window === "undefined") return false; return ( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ) || window.innerWidth <= 768 ); } function isTouchDevice() { if (typeof window === "undefined") return false; return "ontouchstart" in window || navigator.maxTouchPoints > 0; } // 더 정교한 환경 감지 function detectEnvironment() { if (typeof window === "undefined") return "desktop"; var isMobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); var isTouchCapable = "ontouchstart" in window || navigator.maxTouchPoints > 0; var isSmallScreen = window.innerWidth <= 768; var isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(navigator.userAgent) && window.innerWidth >= 768; if (isMobileUA && !isTablet) return "mobile"; if (isSmallScreen && isTouchCapable) return "mobile"; if (isTablet) return "tablet"; return "desktop"; } // 환경별 기본 옵션 정의 function getDefaultOptions() { // 접근성 설정 가져오기 var a11ySettings = getAccessibilitySettings(); return { // 공통 옵션 (모든 환경에서 동일) disabled: false, scrollableSelector: "body", debug: false, useNativeScrollOnMobile: false, // 라이브러리가 모든 환경을 처리 // 접근성 공통 옵션 accessibility: { respectReducedMotion: true, // prefers-reduced-motion 준수 announceScrollPosition: true, // 스크린 리더에 스크롤 위치 알림 keyboardNavigation: true, // 키보드 네비게이션 활성화 focusManagement: true, // 포커스 관리 활성화 highContrastMode: a11ySettings.highContrast, // 고대비 모드 자동 감지 screenReaderOptimizations: a11ySettings.screenReader, // 스크린 리더 최적화 announceFrequency: 1000, // 스크롤 위치 알림 빈도 (ms) skipAnimation: a11ySettings.reducedMotion, // 모션 감소 설정 시 애니메이션 건너뛰기 }, // UI/UX 옵션 ui: { hideScrollbar: true, // 스크롤바 숨김 (기본값: true) showScrollProgress: false, // 스크롤 진행률 표시 (기본값: false) customScrollbarStyle: false, // 커스텀 스크롤바 스타일 (기본값: false) }, // 환경별 옵션 desktop: { // PC 환경 최적화 옵션 duration: a11ySettings.reducedMotion ? 100 : 1000, // ms 단위 easing: Easing.easeOutCubic, horizontalSensitivity: 1, verticalSensitivity: 1, lerp: a11ySettings.reducedMotion ? 0.8 : 0.1, // 모션 감소 시 즉시 반응 wheelMultiplier: 1, touchMultiplier: 1.5, // PC에서도 터치 지원 smoothWheel: !a11ySettings.reducedMotion, // 모션 감소 시 부드러운 휠 비활성화 touchStopThreshold: 8, // PC 전용 옵션 keyboardScrollAmount: 0.8, // 키보드 스크롤 양 (화면 높이 대비) precisionMode: a11ySettings.keyboardUser, // 키보드 사용자에게 정밀 모드 // 접근성 전용 옵션 keyboardScrollSpeed: a11ySettings.keyboardUser ? 600 : 1000, // ms 단위 - 키보드 사용자를 위한 느린 스크롤 skipInertia: a11ySettings.reducedMotion, // 모션 감소 시 관성 비활성화 }, mobile: { // 모바일 환경 최적화 옵션 duration: a11ySettings.reducedMotion ? 50 : 800, // ms 단위 easing: Easing.easeOutCubic, horizontalSensitivity: 1.5, verticalSensitivity: 1.8, lerp: a11ySettings.reducedMotion ? 0.9 : 0.15, // 모바일에서도 모션 감소 시 즉시 반응 wheelMultiplier: 1.2, touchMultiplier: 2.5, smoothWheel: !a11ySettings.reducedMotion, touchStopThreshold: 5, // 모바일 전용 옵션 flingMultiplier: a11ySettings.reducedMotion ? 0.1 : 1.2, // 모션 감소 시 플링 최소화 bounceEffect: !a11ySettings.reducedMotion, // 모션 감소 시 바운스 비활성화 fastScrollThreshold: 50, // 빠른 스크롤 감지 임계값 // 접근성 전용 옵션 touchScrollSpeed: a11ySettings.screenReader ? 700 : 1000, // ms 단위 - 스크린 리더 사용자를 위한 느린 터치 스크롤 skipInertia: a11ySettings.reducedMotion, }, tablet: { // 태블릿 환경 (PC와 모바일의 중간) duration: a11ySettings.reducedMotion ? 80 : 900, // ms 단위 easing: Easing.easeOutCubic, horizontalSensitivity: 1.2, verticalSensitivity: 1.5, lerp: a11ySettings.reducedMotion ? 0.85 : 0.12, wheelMultiplier: 1.1, touchMultiplier: 2.0, smoothWheel: !a11ySettings.reducedMotion, touchStopThreshold: 6, // 태블릿 전용 옵션 hybridMode: true, // 터치와 마우스 모두 최적화 // 접근성 전용 옵션 adaptiveSpeed: a11ySettings.keyboardUser, // 키보드 사용자를 위한 적응형 속도 skipInertia: a11ySettings.reducedMotion, }, }; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function getMaxScrollTop() { if (typeof document === "undefined") return 0; return Math.max( document.body.scrollHeight - window.innerHeight, document.documentElement.scrollHeight - window.innerHeight, 0 ); } function getCurrentScrollTop() { if (typeof window === "undefined") return 0; return ( window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0 ); } var raf = (function () { if (typeof window === "undefined") return function (callback) { return setTimeout(callback, 16); }; return ( window.requestAnimationFrame || window.webkitRequestAnimationFrame || function (callback) { return setTimeout(callback, 16); } ); })(); var cancelRaf = (function () { if (typeof window === "undefined") return clearTimeout; return ( window.cancelAnimationFrame || window.webkitCancelAnimationFrame || clearTimeout ); })(); function supportsPassive() { if (typeof window === "undefined") return false; var supportsPassive = false; try { var opts = Object.defineProperty({}, "passive", { get: function () { supportsPassive = true; return true; }, }); window.addEventListener("testPassive", function () {}, opts); window.removeEventListener("testPassive", function () {}, opts); } catch (e) {} return supportsPassive; } function throttle(func, limit) { var inThrottle; return function () { var args = arguments; var context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(function () { inThrottle = false; }, limit); } }; } // lenis 스타일 lerp 함수 function lerp(start, end, factor) { return (1 - factor) * start + factor * end; } // === 메인 클래스 === function TwoDimensionScroll(options) { options = options || {}; // 현재 환경 감지 this.currentEnvironment = detectEnvironment(); this.isMobileDevice = this.currentEnvironment === "mobile"; this.isTabletDevice = this.currentEnvironment === "tablet"; this.isDesktopDevice = this.currentEnvironment === "desktop"; // 환경별 옵션 병합 this.options = this.mergeOptions(options); // React 호환성 시스템 초기화 this.isReactEnv = isReactEnvironment(); this.eventManager = createEventManager(); this.reactStateObserver = createReactStateObserver(); this.routerCompatibility = createRouterCompatibility(); this.isDestroyed = false; // 접근성 설정 초기화 this.accessibilitySettings = getAccessibilitySettings(); this.ariaLiveRegion = null; this.lastAnnounceTime = 0; this.keyboardNavigationActive = this.options.accessibility?.keyboardNavigation !== false; // lenis 스타일 상태 변수들 this.targetScroll = 0; this.animatedScroll = 0; this.isScrolling = false; this.isAnimating = false; this.rafId = null; this.scrollCallbacks = []; this.passive = false; // 터치 관련 변수들 this.touchStartY = 0; this.touchStartX = 0; this.touchStartTime = 0; this.lastTouchX = 0; this.lastTouchY = 0; this.lastTouchTime = 0; this.touchVelocityX = 0; this.touchVelocityY = 0; this.touchMoveCount = 0; this.touchStopTimer = null; // 초기화 this.passive = supportsPassive() ? { passive: false } : false; this.targetScroll = getCurrentScrollTop(); this.animatedScroll = this.targetScroll; this.isModalOpen = false; // 모달 상태 초기화 this.init(); } // === 프로토타입 메서드들 === // === 접근성 관련 메서드들 === // 접근성 초기화 TwoDimensionScroll.prototype.initAccessibility = function () { if (typeof document === "undefined") return; // ARIA Live Region 생성 if (this.options.accessibility?.announceScrollPosition) { this.ariaLiveRegion = createAriaLiveRegion(); } // 키보드 사용자 감지 초기화 this.initKeyboardUserDetection(); // prefers-reduced-motion 변경 감지 this.watchReducedMotionPreference(); // 접근성 CSS 클래스 추가 this.applyAccessibilityStyles(); // 스크롤 위치 초기 알림 if ( this.accessibilitySettings.screenReader && this.options.accessibility?.announceScrollPosition ) { var self = this; setTimeout(function () { announceToScreenReader( "페이지 스크롤이 준비되었습니다. 화살표 키나 Page Up/Down으로 탐색할 수 있습니다." ); }, 1000); } }; // 키보드 사용자 감지 초기화 TwoDimensionScroll.prototype.initKeyboardUserDetection = function () { var self = this; if (typeof document === "undefined") return; // 키보드 포커스 스타일 적용 document.addEventListener("keydown", function (e) { if ( e.key === "Tab" || e.key === "Enter" || e.key === " " || e.key.startsWith("Arrow") ) { document.body.classList.add("keyboard-user"); self.keyboardNavigationActive = true; } }); document.addEventListener("mousedown", function () { document.body.classList.remove("keyboard-user"); self.keyboardNavigationActive = false; }); }; // prefers-reduced-motion 변경 감지 TwoDimensionScroll.prototype.watchReducedMotionPreference = function () { var self = this; if (typeof window === "undefined" || !window.matchMedia) return; var mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); // 초기 설정 self.handleReducedMotionChange(mediaQuery.matches); // 변경 감지 if (mediaQuery.addEventListener) { mediaQuery.addEventListener("change", function (e) { self.handleReducedMotionChange(e.matches); }); } else if (mediaQuery.addListener) { // 구형 브라우저 지원 mediaQuery.addListener(function (e) { self.handleReducedMotionChange(e.matches); }); } }; // prefers-reduced-motion 변경 처리 TwoDimensionScroll.prototype.handleReducedMotionChange = function ( reducedMotion ) { this.accessibilitySettings.reducedMotion = reducedMotion; // 옵션 동적 업데이트 if (reducedMotion) { // 모션 감소 모드 this.options.lerp = Math.max(this.options.lerp, 0.8); // 즉시 반응 this.options.smoothWheel = false; this.options.bounceEffect = false; this.options.flingMultiplier = 0.1; this.options.skipInertia = true; if (this.options.debug) { console.log("🎯 모션 감소 모드 활성화:", { lerp: this.options.lerp, smoothWheel: this.options.smoothWheel, }); } } else { // 일반 모드로 복원 this.options = this.mergeOptions(this.originalUserOptions || {}); if (this.options.debug) { console.log("🎯 일반 모션 모드 복원"); } } }; // 접근성 CSS 스타일 적용 TwoDimensionScroll.prototype.applyAccessibilityStyles = function () { if (typeof document === "undefined") return; var styleId = "twodimension-scroll-a11y-styles"; var existingStyle = document.getElementById(styleId); if (existingStyle) return; // 이미 추가됨 var style = document.createElement("style"); style.id = styleId; style.textContent = "/* 키보드 포커스 스타일 */" + ".keyboard-user *:focus {" + "outline: 2px solid #005fcc !important;" + "outline-offset: 2px !important;" + "}" + "/* 고대비 모드 지원 */" + "@media (prefers-contrast: high) {" + ".keyboard-user *:focus {" + "outline: 3px solid currentColor !important;" + "outline-offset: 3px !important;" + "}" + "}" + "/* 모션 감소 지원 */" + "@media (prefers-reduced-motion: reduce) {" + "* {" + "animation-duration: 0.01ms !important;" + "animation-iteration-count: 1 !important;" + "transition-duration: 0.01ms !important;" + "}" + "}"; document.head.appendChild(style); }; // 스크롤 위치 알림 TwoDimensionScroll.prototype.announceScrollPosition = function (force) { if (!this.options.accessibility?.announceScrollPosition) return; if (!this.accessibilitySettings.screenReader && !force) return; var now = Date.now(); var frequency = this.options.accessibility.announceFrequency || 1000; if (now - this.lastAnnounceTime < frequency && !force) return; this.lastAnnounceTime = now; var maxScroll = getMaxScrollTop(); var currentScroll = this.animatedScroll; var percentage = maxScroll > 0 ? Math.round((currentScroll / maxScroll) * 100) : 0; var message; if (percentage <= 0) { message = "페이지 최상단입니다"; } else if (percentage >= 100) { message = "페이지 최하단입니다"; } else { message = "페이지 " + percentage + "% 지점입니다"; } announceToScreenReader(message, "polite"); }; // 키보드 네비게이션 개선된 스크롤 TwoDimensionScroll.prototype.accessibleScrollTo = function ( position, announcement ) { // 접근성을 고려한 스크롤 var options = {}; if (this.accessibilitySettings.reducedMotion) { options.immediate = true; // 즉시 이동 } else { options.duration = this.options.keyboardScrollSpeed || 1000; // ms 단위 } this.scrollTo(position, options); // 스크린 리더 알림 if (announcement && this.accessibilitySettings.screenReader) { setTimeout(function () { announceToScreenReader(announcement, "assertive"); }, 100); } // 접근성 포커스 관리 if ( this.keyboardNavigationActive && this.options.accessibility?.focusManagement ) { this.manageFocusAfterScroll(position); } }; // 스크롤 후 포커스 관리 TwoDimensionScroll.prototype.manageFocusAfterScroll = function (position) { if (typeof document === "undefined") return; setTimeout(function () { // 현재 위치에서 포커스 가능한 첫 번째 요소 찾기 var viewportTop = position; var viewportBottom = position + window.innerHeight; var focusableElements = document.querySelectorAll( 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])' ); for (var i = 0; i < focusableElements.length; i++) { var element = focusableElements[i]; var rect = element.getBoundingClientRect(); var elementTop = rect.top + position; if (elementTop >= viewportTop && elementTop <= viewportBottom) { manageFocus(element); break; } } }, 200); }; // 접근성 상태 조회 TwoDimensionScroll.prototype.getAccessibilityStatus = function () { return { settings: this.accessibilitySettings, options: this.options.accessibility, keyboardNavigationActive: this.keyboardNavigationActive, ariaLiveRegionExists: !!this.ariaLiveRegion, reducedMotionActive: this.accessibilitySettings.reducedMotion, }; }; // 실시간 접근성 설정 업데이트 TwoDimensionScroll.prototype.updateAccessibilitySettings = function ( newSettings ) { for (var key in newSettings) { if (newSettings.hasOwnProperty(key) && this.options.accessibility) { this.options.accessibility[key] = newSettings[key]; } } // 설정에 따라 재초기화 if (newSettings.announceScrollPosition && !this.ariaLiveRegion) { this.ariaLiveRegion = createAriaLiveRegion(); } if (this.options.debug) { console.log("♿ 접근성 설정 업데이트:", newSettings); } }; // === UI/UX 제어 메서드들 === // 스크롤바 표시/숨김 토글 TwoDimensionScroll.prototype.toggleScrollbar = function (show) { this.options.ui = this.options.ui || {}; if (show !== undefined) { this.options.ui.hideScrollbar = !show; } else { // 토글 this.options.ui.hideScrollbar = !this.options.ui.hideScrollbar; } // CSS 재적용 this.updateScrollbarStyles(); if (this.options.debug) { console.log("📏 스크롤바 토글:", { visible: !this.options.ui.hideScrollbar, hideScrollbar: this.options.ui.hideScrollbar, }); } return !this.options.ui.hideScrollbar; // 현재 표시 상태 반환 }; // 스크롤바 스타일만 업데이트 TwoDimensionScroll.prototype.updateScrollbarStyles = function () { if (!this.styleElement) return; // 기존 스타일 요소 제거 if (this.styleElement.parentNode) { this.styleElement.parentNode.removeChild(this.styleElement); } // 새로운 스타일 적용 this.disableDefaultScroll(); }; // 커스텀 스크롤바 스타일 설정 TwoDimensionScroll.prototype.setCustomScrollbarStyle = function ( enable, styles ) { this.options.ui = this.options.ui || {}; this.options.ui.customScrollbarStyle = enable; if (enable && styles) { // 커스텀 스타일 저장 this.customScrollbarStyles = styles; } this.updateScrollbarStyles(); if (this.options.debug) { console.log("🎨 커스텀 스크롤바 설정:", { enabled: enable, styles: styles, }); } }; // 스크롤바 상태 조회 TwoDimensionScroll.prototype.getScrollbarStatus = function () { return { hidden: this.options.ui?.hideScrollbar !== false, customStyle: this.options.ui?.customScrollbarStyle || false, visible: this.options.ui?.hideScrollbar === false, }; }; // === 환경별 옵션 병합 메서드 === // 환경별 옵션 병합 메서드 TwoDimensionScroll.prototype.mergeOptions = function (userOptions) { var defaults = getDefaultOptions(); var merged = {}; // 공통 옵션 병합 for (var key in defaults) { if (key !== "desktop" && key !== "mobile" && key !== "tablet") { merged[key] = userOptions[key] !== undefined ? userOptions[key] : defaults[key]; } } // 환경별 옵션 병합 var envDefaults = defaults[this.currentEnvironment] || defaults.desktop; var userEnvOptions = userOptions[this.currentEnvironment] || {}; // 기존 방식 호환성: 최상위 레벨 옵션이 있으면 현재 환경에 적용 for (var envKey in envDefaults) { if (userOptions[envKey] !== undefined) { // 기존 방식: 최상위 레벨에 정의된 옵션 merged[envKey] = userOptions[envKey]; } else if (userEnvOptions[envKey] !== undefined) { // 새로운 방식: 환경별로 정의된 옵션 merged[envKey] = userEnvOptions[envKey]; } else { // 기본값 적용 merged[envKey] = envDefaults[envKey]; } } if (merged.debug) { console.log("🔧 옵션 병합 완료:", { environment: this.currentEnvironment, userOptions: userOptions, mergedOptions: merged, }); } return merged; }; // 환경 변경 감지 및 옵션 업데이트 TwoDimensionScroll.prototype.updateEnvironment = function () { var newEnvironment = detectEnvironment(); if (newEnvironment !== this.currentEnvironment) { var oldEnvironment = this.currentEnvironment; this.currentEnvironment = newEnvironment; this.isMobileDevice = newEnvironment === "mobile"; this.isTabletDevice = newEnvironment === "tablet"; this.isDesktopDevice = newEnvironment === "desktop"; // 옵션 재병합 (사용자 옵션 보존) var userOptions = this.originalUserOptions || {}; this.options = this.mergeOptions(userOptions); if (this.options.debug) { console.log("🔄 환경 변경 감지:", { from: oldEnvironment, to: newEnvironment, newOptions: this.options, }); } // 환경 변경 콜백 실행 this.onEnvironmentChange(oldEnvironment, newEnvironment); } }; // 환경 변경 시 호출되는 메서드 (오버라이드 가능) TwoDimensionScroll.prototype.onEnvironmentChange = function (oldEnv, newEnv) { // 서브클래스에서 오버라이드하여 환경 변경 시 특별한 처리 가능 if (this.options.debug) { console.log("🌍 환경 변경 이벤트:", { from: oldEnv, to: newEnv }); } }; TwoDimensionScroll.prototype.init = function () { if (typeof window === "undefined") return; // 사용자 옵션 보존 (환경 변경 시 재사용) this.originalUserOptions = arguments[0] || {}; if (this.isMobileDevice && this.options.useNativeScrollOnMobile) { console.log( "📱 모바일 네이티브 스크롤 모드 (사용 안함 - 라이브러리가 처리)" ); // 더 이상 네이티브 스크롤로 돌아가지 않고 모든 환경을 지원 } this.disableDefaultScroll(); this.bindEvents(); this.startAnimationLoop(); // 접근성 초기화 this.initAccessibility(); // React Router 호환성 초기화 this.routerCompatibility.init(this); // 환경별 초기화 완료 로그 var envFeatures = []; if (this.isDesktopDevice && this.options.precisionMode) envFeatures.push("정밀모드"); if (this.isMobileDevice && this.options.bounceEffect) envFeatures.push("바운스효과"); if (this.isTabletDevice && this.options.hybridMode) envFeatures.push("하이브리드모드"); // 접근성 기능 로그 var a11yFeatures = []; if (this.accessibilitySettings.reducedMotion) a11yFeatures.push("모션감소"); if (this.accessibilitySettings.screenReader) a11yFeatures.push("스크린리더"); if (this.accessibilitySettings.keyboardUser) a11yFeatures.push("키보드네비게이션"); if (this.accessibilitySettings.highContrast) a11yFeatures.push("고대비"); }; TwoDimensionScroll.prototype.disableDefaultScroll = function () { // lenis 스타일 CSS로 수정 - 전체 콘텐츠가 보이도록 개선 var style = document.createElement("style"); // 기본 CSS var baseCSS = ` html { overflow-x: hidden; scroll-behavior: auto; } body { overflow-x: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch; } /* 모달 열림 상태에서 바디 스크롤 완전 차단 */ body.twodimension-modal-open, html:has(body.twodimension-modal-open) { overflow: hidden !important; position: fixed !important; width: 100% !important; height: 100% !important; touch-action: none !important; overscroll-behavior: none !important; } /* 모달 스크롤 완전 격리 (모바일 최적화) */ .modal, [role="dialog"], [aria-modal="true"], dialog { overscroll-behavior: contain; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; /* 세로 터치 스크롤만 허용 */ } /* 모달 내부 스크롤 영역 최적화 */ .modal *, [role="dialog"] *, [aria-modal="true"] * { overscroll-behavior: inherit; -webkit-overflow-scrolling: touch; } `; // 스크롤바 숨김 CSS (옵션에 따라) var scrollbarCSS = ""; if (this.options.ui?.hideScrollbar !== false) { scrollbarCSS = ` /* 스크롤바 숨김 */ html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; } html { -ms-overflow-style: none; scrollbar-width: none; } `; } else { // 커스텀 스크롤바 스타일 (옵션) if (this.options.ui?.customScrollbarStyle) { scrollbarCSS = ` /* 커스텀 스크롤바 */ html::-webkit-scrollbar { width: 8px; } html::-webkit-scrollbar-track { background: rgba(0,0,0,0.1); } html::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.3); border-radius: 4px; } html::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.5); } `; } } style.textContent = baseCSS + scrollbarCSS; document.head.appendChild(style); this.styleElement = style; // 모달 친화적인 스크롤 차단 시스템 var self = this; this.preventScroll = function (e) { if (self.options.disabled) return; // React 합성 이벤트와의 충돌 방지 if ( e.isPropagationStopped && typeof e.isPropagationStopped === "function" && e.isPropagationStopped() ) { return; } // 수동 모달 모드일 때 간단한 처리 if (self.isModalOpen) { var target = e.target; var element = target; // 모달 관련 요소인지 빠른 체크 var isInModal = false; var checkElement = element; // 최대 10단계까지만 부모 요소 탐색 (성능 최적화) for ( var i = 0; i < 10 && checkElement && checkElement !== document.body; i++ ) { if (checkElement.classList) { var classList = checkElement.classList; if ( classList.contains("modal") || classList.contains("modal-overlay") || classList.contains("modal-content") || classList.contains("modal-wrapper") || checkElement.getAttribute("role") === "dialog" || checkElement.getAttribute("aria-modal") === "true" ) { isInModal = true; break; } } checkElement = checkElement.parentElement; } if (isInModal) { return; // 모달 내부 스크롤 허용 } else { e.preventDefault(); // 모달 외부 스크롤 차단 return; } } // 일반 모드에서의 모달 내부 스크롤 감지 (React 환경 최적화) var target = e.target; var element = target; // 부모 요소들을 순회하면서 모달 관련 요소 확인 var modalElement = null; while (element && element !== document.body) { if (!element.tagName) { element = element.parentElement; continue; } var tagName = element.tagName.toLowerCase(); var classList = element.classList || []; var role = element.getAttribute("role") || ""; var ariaModal = element.getAttribute("aria-modal"); // 모달 관련 요소 감지 (React 환경 포함한 포괄적 조건들) var isModal = // HTML5 dialog 요소 tagName === "dialog" || // 일반적인 모달 클래스명들 classList.contains("modal") || classList.contains("modal-overlay") || classList.contains("modal-content") || classList.contains("modal-wrapper") || classList.contains("modal-container") || classList.contains("dialog") || classList.contains("popup") || classList.contains("overlay") || classList.contains("lightbox") || classList.contains("backdrop") || // React Portal 패턴 element.id === "modal-root" || element.id === "portal-root" || // ARIA 속성 role === "dialog" || role === "alertdialog" || role === "modal" || ariaModal === "true" || // React 모달 라이브러리 패턴 classList.contains("ReactModal__Overlay") || classList.contains("ReactModal__Content"); if (isModal) { modalElement = element; break; } element = element.parentElement; } // 모달 내부에서 발생한 스크롤인 경우 if (modalElement) { // 수동 모달 모드이고 모달 내부가 아닌 경우 차단 if (self.isModalOpen) { return; // 수동 모달 모드에서는 모달 내부 모든 스크롤 허용 } // 스크롤 가능한 요소 찾기 (모달 내부의 실제 스크롤 컨테이너) var scrollableElement = self.findScrollableElement( target, modalElement ); if (scrollableElement) { var scrollTop = scrollableElement.scrollTop; var scrollHeight = scrollableElement.scrollHeight; var clientHeight = scrollableElement.clientHeight; var maxScrollTop = scrollHeight - clientHeight; if (e.type === "wheel") { // 휠 스크롤의 경우 오버스크롤 체크 var deltaY = e.deltaY || e.detail || e.wheelDelta; var isScrollingDown = deltaY > 0; var isScrollingUp = deltaY < 0; // 스크롤 끝에서 더 스크롤하려고 할 때 body 스크롤 차단 var shouldBlockOverscroll = false; if (isScrollingUp && scrollTop <= 0) { // 맨 위에서 위로 더 스크롤하려고 할 때 shouldBlockOverscroll = true; } else if (isScrollingDown && scrollTop >= maxScrollTop) { // 맨 아래에서 아래로 더 스크롤하려고 할 때 shouldBlockOverscroll = true; } if (shouldBlockOverscroll) { e.preventDefault(); return; } } else if (e.type === "touchmove") { // 터치 스크롤의 경우 - CSS overscroll-behavior에 주로 의존 // 모바일에서는 자연스러운 터치 스크롤을 위해 오버스크롤 차단을 최소화 var isAtTop = scrollTop <= 0; var isAtBottom = scrollTop >= maxScrollTop; // 정확히 끝에 도달했을 때만 차단 (여유값 제거) if ((isAtTop || isAtBottom) && maxScrollTop > 0) { // 모바일에서는 더 관대하게 - preventDefault 하지 않고 CSS에 의존 // e.preventDefault(); // return; } } } return; // 모달 내부에서는 기본 스크롤 허용 (오버스크롤 제외) } e.preventDefault(); }; // 스크롤 가능한 요소를 찾는 헬퍼 함수 (React 환경 최적화) this.findScrollableElement = function (startElement, modalElement) { var element = startElement; // React 환경에서 자주 사용되는 스크롤 컨테이너 클래스명들 var reactScrollContainers = [ "modal-content", "modal-body", "dialog-content", "popup-content", "ReactModal__Content", "Modal__content", "scroll-container", ]; while (element && element !== modalElement.parentElement) { // React 스크롤 컨테이너 클래스명 우선 확인 var classList = element.classList || []; for (var i = 0; i < reactScrollContainers.length; i++) { if (classList.contains(reactScrollContainers[i])) { // 실제로 스크롤 가능한지 확인 if (element.scrollHeight > element.clientHeight) { var computedStyle = window.getComputedStyle(element); var overflowY = computedStyle.overflowY; if ( overflowY === "auto" || overflowY === "scroll" || overflowY === "visible" ) { return element; } } } } // 일반적인 스크롤 가능 요소 확인 if (element.scrollHeight > element.clientHeight) { var computedStyle = window.getComputedStyle(element); var overflowY = computedStyle.overflowY; if (overflowY === "auto" || overflowY === "scroll") { return element; } } element = element.parentElement; } // 모달 자체가 스크롤 가능한지 확인 if ( modalElement && modalElement.scrollHeight > modalElement.clientHeight ) { var modalStyle = window.getComputedStyle(modalElement); var modalOverflowY = modalStyle.overflowY; if (modalOverflowY === "auto" || modalOverflowY === "scroll") { return modalElement; } } return null; }; // 휠과 터치 이벤트를 직접 차단 document.addEventListener("wheel", this.preventScroll, { passive: false }); document.addEventListener("touchmove", this.preventScroll, { passive: false, }); if (this.options.debug) { console.log("✅ 모달 친화적 스크롤 시스템 적용"); } }; TwoDimensionScroll.prototype.bindEvents = function () { if (!isClient()) return; var self = this; // React 친화적 이벤트 바인딩 this.eventManager.add( document, "wheel", function (e) { if (self.isDestroyed) return; self.onWheel(e); }, this.passive ); if (isTouchDevice()) { this.eventManager.add( document, "touchstart", function (e) { if (self.isDestroyed) return; self.onTouchStart(e); }, this.passive ); this.eventManager.add( document, "touchmove", function (e) { if (self.isDestroyed) return; self.onTouchMove(e); }, this.passive ); this.eventManager.add( document, "touchend", function (e) { if (self.isDestroyed) return; self.onTouchEnd(e); }, this.passive ); } this.eventManager.add(document, "keydown", function (e) { if (self.isDestroyed) return; self.onKeyDown(e); }); this.eventManager.add( window, "resize", throttle(function () { if (self.isDestroyed) return; self.onResize(); }, 100) ); // React 상태 변경 감지 시작 if (this.isReactEnv) { this.reactStateObserver.add(function () { if (self.isDestroyed) return; // React 컴포넌트 re-render 시 환경 변경 체크 self.updateEnvironment(); }); } if (this.options.debug) { console.log("🔗 이벤트 바인딩 완료:", { 환경: this.isReactEnv ? "React" : "Vanilla", 이벤트_개수: this.eventManager.getCount(), }); } }; // lenis 스타일 애니메이션 루프 TwoDimensionScroll.prototype.startAnimationLoop = function () { var self = this; function animate() { // lerp를 사용한 부드러운 스크롤 var oldAnimatedScroll = self.animatedScroll; self.animatedScroll = lerp( self.animatedScroll, self.targetScroll, self.options.lerp ); // 경계값 처리 var maxScrollTop = getMaxScrollTop(); self.animatedScroll = clamp(self.animatedScroll, 0, maxScrollTop); self.targetScroll = clamp(self.targetScroll, 0, maxScrollTop); // 차이값 계산 var difference = Math.abs(self.targetScroll - self.animatedScroll); var positionChange = Math.abs(self.animatedScroll - oldAnimatedScroll); // 정지 조건: 목표와 현재 위치가 거의 같고, 위치 변화가 거의 없을 때 if (difference < 0.5 && positionChange < 0.1) { // 최종 위치로 정확히 설정 self.animatedScroll = self.targetScroll; window.scrollTo(0, self.animatedScroll); // 애니메이션 중지 self.isScrolling = false; self.rafId = null; // rafId 정리 // 애니메이션 루프 종료 return; } // 스크롤이 활성 상태일 때만 DOM 업데이트 if (difference > 0.1 || positionChange > 0.05) { // 실제 DOM 스크롤 적용 - window.scrollTo() 사용 window.scrollTo(0, self.animatedScroll); // 접근성: 스크롤 위치 알림 if (self.options.accessibility?.announceScrollPosition) { self.announceScrollPosition(); } // 스크롤 이벤트 콜백 실행 if (difference > 0.5) { self.isScrolling = true; var eventData = { deltaX: 0, deltaY: self.targetScroll - self.animatedScroll, scrollTop: self.animatedScroll, direction: self.targetScroll > self.animatedScroll ? 1 : -1, type: "smooth", }; for (var i = 0; i < self.scrollCallbacks.length; i++) { self.scrollCallbacks[i](eventData); } } else { self.isScrolling = false; } } // 다음 프레임 예약 self.rafId = raf(animate); } animate(); }; TwoDimensionScroll.prototype.onWheel = function (event) { if (this.options.disabled) return; // 모달이 열려있을 때는 라이브러리 스크롤 비활성화 및 기본 스크롤 차단 if (this.isModalOpen) { if (this.options.debug) { console.log("🎭 모달 모드: onWheel 비활성화 및 기본 스크롤 차단"); } this.preventScroll(event); // preventScroll을 통해 모달 내부/외부 구분 처리 return; } var deltaX = event.deltaX; var deltaY = event.deltaY; // deltaMode에 따른 값 정규화 var normalizedDeltaX = deltaX; var normalizedDeltaY = deltaY; if (event.deltaMode === 1) { // DOM_DELTA_LINE normalizedDeltaX *= 40; normalizedDeltaY *= 40; } else if (event.deltaMode === 2) { // DOM_DELTA_PAGE normalizedDeltaX *= window.innerHeight * 0.8; normalizedDeltaY *= window.innerHeight * 0.8; } // 민감도 적용 var adjustedDeltaX = normalizedDeltaX * this.options.horizontalSensitivity; var adjustedDeltaY = normalizedDeltaY * this.options.verticalSensitivity; if (this.options.debug) { console.log("🖱️ 휠 이벤트:", { 원시_deltaX: deltaX, 원시_deltaY: deltaY, 조정된_deltaX: adjustedDeltaX, 조정된_deltaY: adjustedDeltaY, 가로스크롤_감지: Math.abs(adjustedDeltaX) > Math.abs(adjustedDeltaY) ? "✅ YES" : "❌ NO", }); } var combinedDelta = this.calculateCombinedDelta( adjustedDeltaX, adjustedDeltaY ); this.addToScroll(combinedDelta * this.options.wheelMultiplier); }; // lenis 스타일 스크롤 추가 함수 TwoDimensionScroll.prototype.addToScroll = function (delta) { var maxScrollTop = getMaxScrollTop(); var oldTargetScroll = this.targetScroll; this.targetScroll = clamp(this.targetScroll + delta, 0, maxScrollTop); // 실제로 스크롤 위치가 변경되었고, 애니메이션이 정지되어 있다면 재시작 if (Math.abs(this.targetScroll - oldTargetScroll) > 0.1 && !this.rafId) { if (this.options.debug) { console.log("🔄 애니메이션 재시작:", { oldTarget: Math.round(oldTargetScroll), newTarget: Math.round(this.targetScroll), delta: Math.round(delta), }); } this.startAnimationLoop(); } }; TwoDimensionScroll.prototype.onTouchStart = function (event) { if (this.options.disabled) return; var touch = event.touches[0]; this.touchStartX = touch.clientX; this.touchStartY = touch.clientY; this.touchStartTime = Date.now(); this.lastTouchX = touch.clientX; this.lastTouchY = touch.clientY; this.lastTouchTime = this.touchStartTime; this.touchVelocityX = 0; this.touchVelocityY = 0; this.touchMoveCount = 0; if (this.touchStopTimer) { clearTimeout(this.touchStopTimer); this.touchStopTimer = null; } }; TwoDimensionScroll.prototype.onTouchMove = function (event) { if (this.options.disabled) return; var touch = event.touches[0]; var currentTime = Date.now(); var currentDeltaX = this.lastTouchX - touch.clientX; var currentDeltaY = this.lastTouchY - touch.clientY; var movementDistance = Math.sqrt( currentDeltaX * currentDeltaX + currentDeltaY * currentDeltaY ); if (movementDistance > this.options.touchStopThreshold) { if (this.touchStopTimer) { clearTimeout(this.touchStopTimer); this.touchStopTimer = null; } var timeDelta = currentTime - this.lastTouchTime; if (timeDelta > 0) { this.touchVelocityX = currentDeltaX / timeDelta; this.touchVelocityY = currentDeltaY / timeDelta; } var adjustedDeltaX = currentDeltaX * this.options.horizontalSensitivity * this.options.touchMultiplier; var adjustedDeltaY = currentDeltaY * this.options.verticalSensitivity * this.options.touchMultiplier; // 모달이 열려있을 때는 preventScroll을 통해 모달 내부/외부 구분 처리 if (this.isModalOpen) { this.preventScroll(event); // preventScroll을 통해 모달 내부/외부 구분 처리 if (this.options.debug) { console.log("🎭 모달 모드: onTouchMove - preventScroll 호출"); } } else if (Math.abs(adjustedDeltaX) > 3 || Math.abs(adjustedDeltaY) > 3) { var combinedDelta = this.calculateCombinedDelta( adjustedDeltaX, adjustedDeltaY ); this.addToScroll(combinedDelta); } this.lastTouchX = touch.clientX; this.lastTouchY = touch.clientY; this.lastTouchTime = currentTime; this.touchMoveCount++; } else { var self = this; if (!this.touchStopTimer) { this.touchStopTimer = setTimeout(function () { self.touchVelocityX *= 0.8; self.touchVelocityY *= 0.8; self.touchStopTimer = null; }, 100); } } }; TwoDimensionScroll.prototype.onTouchEnd = function (event) { if (this.options.disabled) return; if (this.touchStopTimer) { clearTimeout(this.touchStopTimer); this.touchStopTimer = null; } var touch = event.changedTouches[0]; var deltaTime = Date.now() - this.touchStartTime; var totalDeltaY = this.touchStartY - touch.clientY; // 플링 제스처 처리 if ( deltaTime < 300 && Math.abs(totalDeltaY) > 50 && this.touchMoveCount > 3 ) { var velocity = this.touchVelocityY; // 환경별 플링 배수 적용 var flingMultiplier = this.options.flingMultiplier !== undefined ? this.options.flingMultiplier : 1.0; var flingDistance = velocity * 400 * flingMultiplier; if (Math.abs(flingDistance) > 50) { // 모달이 열려있을 때는 body 스크롤 플링 제스처 차단 if (!this.isModalOpen) { this.addToScroll(flingDistance); } if (this.options.debug) { console.log("🚀 플링 제스처:", { velocity: velocity, flingDistance: flingDistance, modalMode: this.isModalOpen ? "차단됨" : "허용됨", }); } } } this.touchVelocityX = 0; this.touchVelocityY = 0; this.touchMoveCount = 0; }; TwoDimensionScroll.prototype.onKeyDown = function (event) { if (this.options.disabled) return; // 모달이 열려있을 때는 라이브러리 스크롤 비활성화 및 기본 스크롤 차단 if (this.isModalOpen) { if (this.options.debug) { console.log("🎭 모달 모드: onKeyDown 비활성화 및 기본 스크롤 차단"); } this.preventScroll(event); // preventScroll을 통해 모달 내부/외부 구분 처리 return; } var delta = 0; // 환경별 키보드 스크롤 양 적용 var scrollRatio = this.options.keyboardScrollAmount !== undefined ? this.options.keyboardScrollAmount : 0.8; var scrollAmount = window.innerHeight * scrollRatio; var announcement; switch (event.key) { case "ArrowUp": case "PageUp": delta = -scrollAmount; announcement = "위로 스크롤했습니다"; break; case "ArrowDown": case "PageDown": case " ": delta = scrollAmount; announcement = "아래로 스크롤했습니다"; break; case "Home": event.preventDefault(); this.accessibleScrollTo(0, "페이지 최상단으로 이동했습니다"); return; case "End": event.preventDefault(); this.accessibleScrollTo( getMaxScrollTop(), "페이지 최하단으로 이동했습니다" ); return; default: return; } event.preventDefault(); // 접근성 고려 스크롤 if ( this.keyboardNavigationActive && this.options.accessibility?.keyboardNavigation ) { // 키보드 사용자를 위한 부드러운 스크롤 var newPosition = clamp(this.targetScroll + delta, 0, getMaxScrollTop()); this.accessibleScrollTo(newPosition, announcement); } else { // 일반 스크롤 this.addToScroll(delta); } }; TwoDimensionScroll.prototype.onResize = function () { var maxScrollTop = getMaxScrollTop(); if (this.targetScroll > maxScrollTop) { this.targetScroll = maxScrollTop; } this.updateEnvironment(); // 리사이즈 시 환경 변경 감지 }; TwoDimensionScroll.prototype.calculateCombinedDelta = function ( deltaX, deltaY ) { var absX = Math.abs(deltaX); var absY = Math.abs(deltaY); if (absX > absY * 0.7) { return deltaX; } if (absY > absX * 0.7) { return deltaY; } var magnitude = Math.sqrt(deltaX * deltaX + deltaY * deltaY); var angle = Math.atan2(deltaY, deltaX); if (Math.abs(angle) < Math.PI / 3 || Math.abs(angle) > (2 * Math.PI) / 3) { return deltaX > 0 ? magnitude : -magnitude; } else { return deltaY > 0 ? magnitude : -magnitude; } }; // === 공개 메서드들 (lenis 호환) === TwoDimensionScroll.prototype.scrollTo = function (position, options) { var maxScrollTop = getMaxScrollTop(); this.targetScroll = clamp(position, 0, maxScrollTop); // 즉시 스크롤 옵션 if (options && options.immediate) { this.animatedScroll = this.targetScroll; window.scrollTo(0, this.animatedScroll); } // 애니메이션이 정지되어 있다면 재시작 if ( !this.rafId && Math.abs(this.targetScroll - this.animatedScroll) > 0.1 ) { this.startAnimationLoop(); } }; TwoDimensionScroll.prototype.on = function (callback) { this.scrollCallbacks.push(callback); }; TwoDimensionScroll.prototype.off = function (callback) { var index = this.scrollCallbacks.indexOf(callback); if (index > -1) { this.scrollCallbacks.splice(index, 1); } }; TwoDimensionScroll.prototype.disable = function () { this.options.disabled = true; this.log("스크롤 비활성화"); }; TwoDimensionScroll.prototype.enable = function () { this.options.disabled = false; this.log("스크롤 활성화"); }; TwoDimensionScroll.prototype.updateOptions = function (newOptions) { // 사용자 옵션 업데이트 후 재병합 this.originalUserOptions = this.originalUserOptions || {}; // 새로운 옵션을 기존 사용자 옵션에 병합 for (var key in newOptions) { if (newOptions.hasOwnProperty(key)) { this.originalUserOptions[key] = newOptions[key]; } } // 환경별 옵션 재병합 this.options = this.mergeOptions(this.originalUserOptions); if (this.options.debug) { console.log("🔧 옵션 업데이트 완료:", { environment: this.currentEnvironment, updatedOptions: newOptions, finalOptions: this.options, }); } }; TwoDimensionScroll.prototype.getCurrentPosition = function () { return this.animatedScroll; }; TwoDimensionScroll.prototype.getMaxPosition = function () { return getMaxScrollTop(); }; // === 환경별 최적화 API === // 현재 환경 정보 조회 TwoDimensionScroll.prototype.getEnvironmentInfo = function () { return { current: this.currentEnvironment, isMobile: this.isMobileDevice, isTablet: this.isTabletDevice, isDesktop: this.isDesktopDevice, screenWidth: window.innerWidth, screenHeight: window.innerHeight, isTouchCapable: isTouchDevice(), userAgent: navigator.userAgent, }; }; // 특정 환경의 옵션 업데이트 TwoDimensionScroll.prototype.updateEnvironmentOptions = function ( environment, options ) { this.originalUserOptions = this.originalUserOptions || {};