/** * 공통 스크립트 (common.js) * ======================================== * 모든 페이지에서 공통으로 사용하는 기능을 모아둔 진입점입니다. * * [주요 역할] * - ScrollManager: 모바일 뷰포트 높이 동기화, 스크롤 잠금(모달 열 때) * - ModalManager: 모달 열기/닫기 (애니메이션, 비디오 정지) * - DeviceUtils: 모바일/태블릿/데스크톱 감지 * * [기존 코드 호환 함수] - 레거시 코드에서 그대로 사용 가능 * - popOpen(id): 모달 열기 (예: popOpen('modal-video')) * - popClose(element): 모달 닫기 (닫기 버튼에 사용) * - syncHeight(), bodyLock(), bodyUnlock(), isMobile() * * @module CommonUtils */ /** * 스크롤 및 레이아웃 관리 클래스 * - syncHeight(): CSS 변수 --window-inner-height 설정 (모바일 주소창 대응) * - lock()/unlock(): 모달 열 때 body 스크롤 잠금/해제 */ class ScrollManager { constructor() { this.scrollY = 0; this.wrap = null; this.isLocked = false; } /** * 스크린 높이 계산 및 CSS 변수 설정 */ syncHeight() { try { document.documentElement.style.setProperty( "--window-inner-height", `${window.innerHeight}px` ); } catch (error) { if (typeof ErrorHandler !== 'undefined') { ErrorHandler.handle(error, { context: 'ScrollManager.syncHeight' }); } else { console.error('[ScrollManager] syncHeight error:', error); } } } /** * body 스크롤 잠금 */ lock() { if (this.isLocked) return; this.scrollY = window.scrollY; document.documentElement.classList.add("is-locked"); document.documentElement.style.scrollBehavior = "auto"; if (this.wrap) { this.wrap.style.top = `-${this.scrollY}px`; } this.isLocked = true; } /** * body 스크롤 잠금 해제 */ unlock() { if (!this.isLocked) return; document.documentElement.classList.remove("is-locked"); window.scrollTo(0, this.scrollY); if (this.wrap) { this.wrap.style.top = ""; } document.documentElement.style.scrollBehavior = ""; this.isLocked = false; } /** * 초기화 */ init() { this.wrap = typeof DOMUtils !== 'undefined' ? DOMUtils.$(".wrap") : document.querySelector(".wrap"); // 즉시 높이 설정 this.syncHeight(); // 리사이즈 이벤트 (쓰로틀 적용) const throttledSyncHeight = typeof Utils !== 'undefined' && Utils.throttle ? Utils.throttle(() => this.syncHeight(), 100) : (() => { let resizeTimer; return () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => this.syncHeight(), 100); }; })(); if (typeof eventManager !== 'undefined') { eventManager.on(window, "resize", throttledSyncHeight); eventManager.on(window, "orientationchange", () => { setTimeout(() => this.syncHeight(), 100); }); } else { window.addEventListener("resize", throttledSyncHeight); window.addEventListener("orientationchange", () => { setTimeout(() => this.syncHeight(), 100); }); } } } /** * 모바일 감지 유틸리티 */ class DeviceUtils { /** * 모바일 기기 여부 확인 * @param {number} breakpoint - 브레이크포인트 (기본값: 1025) * @returns {boolean} */ static isMobile(breakpoint = 1025) { return window.innerWidth < breakpoint; } /** * 태블릿 기기 여부 확인 * @param {number} minWidth - 최소 너비 * @param {number} maxWidth - 최대 너비 * @returns {boolean} */ static isTablet(minWidth = 768, maxWidth = 1024) { const width = window.innerWidth; return width >= minWidth && width <= maxWidth; } /** * 데스크톱 기기 여부 확인 * @param {number} breakpoint - 브레이크포인트 * @returns {boolean} */ static isDesktop(breakpoint = 1025) { return window.innerWidth >= breakpoint; } } /** * 모달/팝업 관리 클래스 */ class ModalManager { constructor(scrollManager) { this.scrollManager = scrollManager; this.openModals = new Set(); } /** * 모달 열기 * @param {string|Element} target - 모달 ID 또는 요소 * @param {Object} options - 옵션 * @returns {Promise} */ async open(target, options = {}) { const { duration = 300, lockScroll = true, stopVideo = true, } = options; try { const element = typeof target === 'string' ? (typeof DOMUtils !== 'undefined' ? DOMUtils.$(`#${target}`) : document.getElementById(target)) : target; if (!element) { console.warn('[ModalManager] Element not found:', target); return; } if (typeof DOMUtils !== 'undefined') { await DOMUtils.fadeIn(element, duration); } else if (typeof AnimationUtils !== 'undefined') { await AnimationUtils.fade(element, 'in', duration); } else { element.style.display = 'block'; element.style.opacity = '1'; } if (lockScroll && this.scrollManager) { this.scrollManager.lock(); } this.openModals.add(element); return element; } catch (error) { if (typeof ErrorHandler !== 'undefined') { ErrorHandler.handle(error, { context: 'ModalManager.open', target }); } else { console.error('[ModalManager] open error:', error); } } } /** * 모달 닫기 * @param {string|Element} target - 모달 ID 또는 요소 * @param {Object} options - 옵션 * @returns {Promise} */ async close(target, options = {}) { const { duration = 300, unlockScroll = true, stopVideo = true, } = options; try { const element = typeof target === 'string' ? (typeof DOMUtils !== 'undefined' ? DOMUtils.$(`#${target}`) : document.getElementById(target)) : target; if (!element) return; if (typeof DOMUtils !== 'undefined') { await DOMUtils.fadeOut(element, duration); } else if (typeof AnimationUtils !== 'undefined') { await AnimationUtils.fade(element, 'out', duration); } else { element.style.display = 'none'; element.style.opacity = ''; } // 비디오 정지 if (stopVideo) { const video = element.querySelector("video"); if (video) video.pause(); } if (unlockScroll && this.scrollManager && this.openModals.size <= 1) { this.scrollManager.unlock(); } this.openModals.delete(element); return element; } catch (error) { if (typeof ErrorHandler !== 'undefined') { ErrorHandler.handle(error, { context: 'ModalManager.close', target }); } else { console.error('[ModalManager] close error:', error); } } } /** * 모든 모달 닫기 */ async closeAll() { const promises = Array.from(this.openModals).map(modal => this.close(modal)); await Promise.all(promises); this.openModals.clear(); } } // 전역 인스턴스 생성 const scrollManager = new ScrollManager(); const modalManager = new ModalManager(scrollManager); // ---------------------------------------- // 기존 함수 호환성 래퍼 (레거시 코드용) // ---------------------------------------- // 아래 함수들은 기존 코드에서 호출하는 이름입니다. // 새로 작성 시에는 scrollManager, modalManager를 직접 사용하는 것을 권장합니다. let scrollY = 0; let wrap = null; /** 뷰포트 높이 동기화 (리사이즈 시 호출) */ function syncHeight() { scrollManager.syncHeight(); } /** 모바일 기기 여부 (breakpoint 1025px) */ function isMobile() { return DeviceUtils.isMobile(); } /** body 스크롤 잠금 (모달 열 때) */ function bodyLock() { scrollManager.lock(); } /** body 스크롤 해제 (모달 닫을 때) */ function bodyUnlock() { scrollManager.unlock(); } /** * 모달 열기 - id가 "open-modal-video" 인 버튼 클릭 시 모달 "modal-video" 가 열림 * @param {string} id - 모달 요소의 id (앞에 # 없이) */ async function popOpen(id) { return await modalManager.open(id); } /** * 모달 닫기 - 닫기 버튼(.close) 또는 백드롭 클릭 시 호출 * @param {Element} obj - 클릭된 요소 (보통 this 또는 event.target) */ async function popClose(obj) { const popup = obj.closest ? obj.closest(".popup") : null; if (popup) { return await modalManager.close(popup); } } /** * 공통 이벤트 초기화 * - ScrollManager: 뷰포트 높이, 리사이즈 대응 * - 모달: id가 "open-modal-XXX"인 요소 클릭 시 #modal-XXX 열기 * - 모달 닫기: .close 클릭 또는 모달 바깥(.modal) 클릭 */ function initCommonEvents() { const baseHref = window.location.href.split("#")[0]; // ScrollManager 초기화 scrollManager.init(); wrap = scrollManager.wrap; // 모달 열기 이벤트 (이벤트 위임) const openModalHandler = function(e) { const modalId = this.id.replace("open-", ""); modalManager.open(modalId); }; // 모달 닫기 이벤트 const closeModalHandler = async function(e) { const modal = this.closest(".modal"); if (modal) { await modalManager.close(modal); } }; // 모달 바깥 클릭 시 닫기 const modalBackdropHandler = async function(e) { const modalContent = e.target.closest(".modal-content"); if (!modalContent && e.target === this) { await modalManager.close(this); } }; // EventManager 사용 (있는 경우) if (typeof eventManager !== 'undefined') { eventManager.delegate(document, "click", "[id^=open-modal]", openModalHandler); eventManager.delegate(document, "click", ".close", closeModalHandler); eventManager.delegate(document, "click", ".modal", modalBackdropHandler); } else if (typeof DOMUtils !== 'undefined' && DOMUtils.delegate) { DOMUtils.delegate(document, "click", "[id^=open-modal]", openModalHandler); DOMUtils.delegate(document, "click", ".close", closeModalHandler); DOMUtils.delegate(document, "click", ".modal", modalBackdropHandler); } else { // 폴백: 직접 이벤트 리스너 등록 document.addEventListener("click", (e) => { const target = e.target.closest("[id^=open-modal]"); if (target) { openModalHandler.call(target, e); } const closeBtn = e.target.closest(".close"); if (closeBtn) { closeModalHandler.call(closeBtn, e); } const modal = e.target.closest(".modal"); if (modal && e.target === modal) { modalBackdropHandler.call(modal, e); } }); } // ---------------------------------------- // 모바일 검색 오버레이 // ---------------------------------------- const searchOpenBtn = document.querySelector(".btn-search"); const searchLayer = document.querySelector(".mo-search-layer"); const searchCloseBtn = searchLayer ? searchLayer.querySelector(".btn-close-search") : null; const searchInput = searchLayer ? searchLayer.querySelector('input[type="text"]') : null; const openSearch = () => { if (!searchLayer) return; searchLayer.classList.add("is-open"); searchLayer.setAttribute("aria-hidden", "false"); scrollManager.lock(); if (searchInput) { setTimeout(() => { searchInput.focus(); }, 50); } }; const closeSearch = () => { if (!searchLayer) return; searchLayer.classList.remove("is-open"); searchLayer.setAttribute("aria-hidden", "true"); scrollManager.unlock(); }; if (searchOpenBtn && searchLayer) { searchOpenBtn.addEventListener("click", openSearch); } if (searchCloseBtn) { searchCloseBtn.addEventListener("click", closeSearch); } if (searchLayer) { const searchBackdrop = searchLayer.querySelector(".mo-search-backdrop"); searchLayer.addEventListener("click", (e) => { if (e.target === searchLayer || e.target === searchBackdrop) { closeSearch(); } }); } } // DOMContentLoaded 시 초기화 if (document.readyState === 'loading') { document.addEventListener("DOMContentLoaded", initCommonEvents); } else { initCommonEvents(); } // 리사이즈 이벤트는 ScrollManager.init()에서 처리됨 /** * 컨테이너 스크롤 효과 클래스 */ class ContainerScrollEffect { constructor(container, options = {}) { this.container = container; this.options = { borderRadius: 30, scrollThreshold: 100, excludeClass: 'search-result', ...options, }; this.isActive = false; } /** * 효과 초기화 */ init() { if (!this.container) return; // 검색 결과 페이지에서는 이 효과를 적용하지 않음 const wrap = this.container.closest(".wrap"); if (wrap && wrap.classList.contains(this.options.excludeClass)) { return; } const throttledScroll = typeof Utils !== 'undefined' && Utils.throttle ? Utils.throttle(() => this._handleScroll(), 16) : (() => { let lastTime = 0; return () => { const now = performance.now(); if (now - lastTime >= 16) { this._handleScroll(); lastTime = now; } }; })(); if (typeof eventManager !== 'undefined') { eventManager.on(this.container, "scroll", throttledScroll); } else { this.container.addEventListener("scroll", throttledScroll); } this.isActive = true; } /** * 스크롤 핸들러 * @private */ _handleScroll() { const scrollTop = this.container.scrollTop; const progress = Math.min(scrollTop / this.options.scrollThreshold, 1); const currentRadius = this.options.borderRadius * (1 - progress); this.container.style.clipPath = `inset(0 0 0 0 round ${currentRadius}px ${currentRadius}px 0 0)`; } /** * 효과 제거 */ destroy() { if (this.container && this.isActive) { this.container.style.clipPath = ''; this.isActive = false; } } } // 기존 함수 호환성 function initContainerScrollEffect() { const container = typeof DOMUtils !== 'undefined' ? DOMUtils.$(".container") : document.querySelector(".container"); if (container) { const effect = new ContainerScrollEffect(container); effect.init(); } } /** * 컨테이너 상단 라운드 모서리 유지 * - .container에 clip-path를 적용해 상단 30px 라운드를 고정 유지 * - 스크롤, 스타일 변경 등으로 덮어써져도 복원 */ function initContainerRoundCorners() { // 인트로 페이지에서는 clip-path 적용 안 함 if (document.querySelector(".wrap.intro")) return; const container = document.querySelector(".container"); if (!container) return; const targetClipPath = "inset(0 0 0 0 round 30px 30px 0 0)"; container.style.clipPath = targetClipPath; function maintainRoundCorners() { const currentClipPath = container.style.clipPath || ""; if (currentClipPath !== targetClipPath && !currentClipPath.includes("30px")) { container.style.clipPath = targetClipPath; } requestAnimationFrame(maintainRoundCorners); } container.addEventListener( "scroll", function () { this.style.clipPath = targetClipPath; }, { passive: true, capture: true } ); const observer = new MutationObserver(function () { if (container.style.clipPath !== targetClipPath) { container.style.clipPath = targetClipPath; } }); observer.observe(container, { attributes: true, attributeFilter: ["style"], attributeOldValue: true, }); maintainRoundCorners(); } // DOMContentLoaded 시 초기화 document.addEventListener("DOMContentLoaded", () => { initContainerRoundCorners(); }); /** * HTML Include 관리 클래스 */ class HTMLIncludeManager { constructor(options = {}) { this.options = { selector: "[data-include-path]", attribute: "data-include-path", ...options, }; } /** * HTML include 실행 * @returns {Promise} */ async include() { const allElements = typeof DOMUtils !== 'undefined' ? DOMUtils.$$(this.options.selector) : document.querySelectorAll(this.options.selector); const promises = Array.from(allElements).map(async (el) => { const includePath = el.dataset.includePath || el.getAttribute(this.options.attribute); if (!includePath) return; try { const response = await fetch(includePath); if (!response.ok) { throw new Error(`Failed to load: ${includePath} (${response.status})`); } const html = await response.text(); el.innerHTML = html; el.removeAttribute(this.options.attribute); // 포함된 HTML에 대한 이벤트 재초기화 (필요한 경우) this._reinitializeEvents(el); } catch (error) { if (typeof ErrorHandler !== 'undefined') { ErrorHandler.handle(error, { context: 'HTMLIncludeManager.include', includePath, }); } else { console.error(`[HTMLIncludeManager] Error loading ${includePath}:`, error); } } }); await Promise.all(promises); } /** * 포함된 HTML의 이벤트 재초기화 * @private */ _reinitializeEvents(element) { // 포함된 스크립트 실행 (보안 주의) const scripts = element.querySelectorAll('script'); scripts.forEach((script) => { const newScript = document.createElement('script'); if (script.src) { newScript.src = script.src; } else { newScript.textContent = script.textContent; } script.parentNode.replaceChild(newScript, script); }); } } // 전역 인스턴스 const htmlIncludeManager = new HTMLIncludeManager(); // 기존 함수 호환성 async function includehtml() { return await htmlIncludeManager.include(); } /** * 사이트맵 패널 관리 클래스 (1400px 이하에서 사용) * - 햄버거 버튼 클릭 시 .nav-wrap에 is-sitemap-open 토글 * - 아코디언: .menu-section 클릭 시 is-open 토글 */ class SiteMapManager { constructor() { this.navWrap = null; this.btnSitemap = null; this.siteMap = null; this.isOpen = false; this._onKeydown = this._handleKeydown.bind(this); } init() { this.navWrap = document.querySelector('.nav-wrap'); this.btnSitemap = document.querySelector('.btn-sitemap'); this.siteMap = document.getElementById('siteMap'); if (!this.btnSitemap || !this.siteMap || !this.navWrap) return; // 햄버거 버튼 클릭 this.btnSitemap.addEventListener('click', () => this.toggle()); // 사이트맵 내부 닫기 버튼 const btnCloseSitemap = this.siteMap.querySelector('.site-map-header .btn-close'); if (btnCloseSitemap) { btnCloseSitemap.addEventListener('click', () => this.close()); } // 백드롭 클릭 시 닫기 const backdrop = this.siteMap.querySelector('.site-map-backdrop'); if (backdrop) { backdrop.addEventListener('click', () => this.close()); } // 아코디언 메뉴 초기화 this._initAccordion(); // 현재 페이지에 해당하는 하위 메뉴 링크 active 처리 this._setActiveLinks(); // 리사이즈 시 1400px 초과이면 자동으로 닫기 window.addEventListener('resize', () => { if (window.innerWidth > 1400 && this.isOpen) { this.close(); } }); } toggle() { this.isOpen ? this.close() : this.open(); } open() { this.isOpen = true; this.navWrap.classList.add('is-sitemap-open'); this.btnSitemap.setAttribute('aria-expanded', 'true'); this.siteMap.setAttribute('aria-hidden', 'false'); scrollManager.lock(); document.addEventListener('keydown', this._onKeydown); } close() { this.isOpen = false; this.navWrap.classList.remove('is-sitemap-open'); this.btnSitemap.setAttribute('aria-expanded', 'false'); this.siteMap.setAttribute('aria-hidden', 'true'); scrollManager.unlock(); document.removeEventListener('keydown', this._onKeydown); } _handleKeydown(e) { if (e.key === 'Escape') this.close(); } _setActiveLinks() { const currentPath = window.location.pathname; const currentFile = currentPath.split('/').pop(); // 하위 메뉴 링크 + 섹션 타이틀 직링크() 모두 체크 const links = this.siteMap.querySelectorAll('.menu-section-list a, a.menu-section-title'); links.forEach((link) => { const href = link.getAttribute('href'); if (!href || href === '#') return; const hrefFile = href.replace('./', ''); const isActive = hrefFile === currentFile || currentPath.endsWith(hrefFile) || currentPath.includes(hrefFile.replace('.html', '')); if (isActive) { link.classList.add('active'); // 하위 메뉴 링크인 경우 부모 섹션 자동 열기 const section = link.closest('.menu-section'); if (section && link.closest('.menu-section-list')) { section.classList.add('is-open'); } } }); } _initAccordion() { const sections = this.siteMap.querySelectorAll('.menu-section'); sections.forEach((section) => { const btn = section.querySelector('.menu-section-title'); const list = section.querySelector('.menu-section-list'); // 하위 메뉴가 없는 섹션은 아코디언 동작 생략 if (!btn || !list) return; btn.addEventListener('click', () => { const isOpen = section.classList.contains('is-open'); // 다른 섹션 닫기 sections.forEach((s) => s.classList.remove('is-open')); // 현재 섹션 토글 if (!isOpen) { section.classList.add('is-open'); } }); }); } } const siteMapManager = new SiteMapManager(); document.addEventListener('DOMContentLoaded', () => { siteMapManager.init(); if (typeof LearningGuideModal !== 'undefined') { LearningGuideModal.init(); } // 알림 더보기/접기 토글 document.querySelectorAll('.btn-alert-toggle').forEach((btn) => { btn.addEventListener('click', function () { const item = this.closest('.alert-item'); if (!item) return; const isOpen = item.classList.toggle('is-open'); this.innerHTML = isOpen ? '접기 ' : '더보기 '; }); }); }); // 전역으로 내보내기 (선택사항) if (typeof window !== 'undefined') { window.CommonUtils = { ScrollManager, DeviceUtils, ModalManager, ContainerScrollEffect, HTMLIncludeManager, SiteMapManager, scrollManager, modalManager, htmlIncludeManager, siteMapManager, // 기존 함수들 syncHeight, isMobile, bodyLock, bodyUnlock, popOpen, popClose, initContainerScrollEffect, initContainerRoundCorners, includehtml, }; }