Files

828 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 공통 스크립트 (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();
// 하위 메뉴 링크 + 섹션 타이틀 직링크(<a>) 모두 체크
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 ? '접기 <span aria-hidden="true">∧</span>' : '더보기 <span aria-hidden="true"></span>';
});
});
});
// 전역으로 내보내기 (선택사항)
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,
};
}