Initial commit: 교육 프로젝트 배포
This commit is contained in:
+827
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* 공통 스크립트 (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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user