/** * 탭별 상태를 sessionStorage에 캐싱/복원하는 엔진. * F5 새로고침 시 비활성 탭의 데이터를 보존한다. * * 캐싱 키 구조: `tab-cache-{tabId}` * 값: JSON 직렬화된 TabCacheData */ const CACHE_PREFIX = "tab-cache-"; // --- 캐싱할 상태 구조 --- export interface FormFieldSnapshot { idx: number; tag: string; type: string; name: string; id: string; value?: string; checked?: boolean; } /** 개별 스크롤 요소의 위치 스냅샷 (DOM 경로 기반) */ export interface ScrollSnapshot { /** 탭 컨테이너 기준 자식 인덱스 경로 (예: "0/2/1/3") */ path: string; top: number; left: number; } export interface TabCacheData { /** DOM 폼 필드 스냅샷 (F5 복원용) */ domFormFields?: FormFieldSnapshot[]; /** 다중 스크롤 위치 (split panel 등 여러 스크롤 영역 지원) */ scrollPositions?: ScrollSnapshot[]; /** 캐싱 시각 */ cachedAt: number; } // --- 공개 API --- /** * 탭 상태를 sessionStorage에 즉시 저장 */ export function saveTabCacheImmediate(tabId: string, data: Partial>): void { if (typeof window === "undefined") return; try { const key = CACHE_PREFIX + tabId; const current = loadTabCache(tabId); const merged: TabCacheData = { ...current, ...data, cachedAt: Date.now(), }; sessionStorage.setItem(key, JSON.stringify(merged)); } catch (e) { console.warn("[TabCache] 저장 실패:", tabId, e); } } /** * 탭 상태를 sessionStorage에서 로드 */ export function loadTabCache(tabId: string): TabCacheData | null { if (typeof window === "undefined") return null; try { const key = CACHE_PREFIX + tabId; const raw = sessionStorage.getItem(key); if (!raw) return null; return JSON.parse(raw) as TabCacheData; } catch { return null; } } /** * 특정 탭의 캐시 삭제 */ export function clearTabCache(tabId: string): void { if (typeof window === "undefined") return; try { sessionStorage.removeItem(CACHE_PREFIX + tabId); } catch { // ignore } } /** * 모든 탭 캐시 삭제 */ export function clearAllTabCaches(): void { if (typeof window === "undefined") return; try { const keysToRemove: string[] = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (key?.startsWith(CACHE_PREFIX)) { keysToRemove.push(key); } } keysToRemove.forEach((key) => sessionStorage.removeItem(key)); } catch { // ignore } } // ============================================================ // DOM 폼 상태 캡처/복원 // ============================================================ /** * 컨테이너 내의 모든 폼 요소 상태를 스냅샷으로 캡처 */ export function captureFormState(container: HTMLElement | null): FormFieldSnapshot[] | null { if (!container) return null; const fields: FormFieldSnapshot[] = []; const elements = container.querySelectorAll( "input, textarea, select", ); elements.forEach((el, idx) => { const field: FormFieldSnapshot = { idx, tag: el.tagName.toLowerCase(), type: (el as HTMLInputElement).type || "", name: el.name || "", id: el.id || "", }; if (el instanceof HTMLInputElement) { if (el.type === "checkbox" || el.type === "radio") { field.checked = el.checked; } else if (el.type !== "file" && el.type !== "password") { field.value = el.value; } } else if (el instanceof HTMLTextAreaElement) { field.value = el.value; } else if (el instanceof HTMLSelectElement) { field.value = el.value; } fields.push(field); }); return fields.length > 0 ? fields : null; } /** * 단일 폼 필드에 값을 복원하고 React onChange를 트리거 */ function applyFieldValue(el: Element, field: FormFieldSnapshot): void { if (el instanceof HTMLInputElement) { if (field.type === "checkbox" || field.type === "radio") { if (el.checked !== field.checked) { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set; setter?.call(el, field.checked); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } } else if (field.value !== undefined && el.value !== field.value) { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setter?.call(el, field.value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } } else if (el instanceof HTMLTextAreaElement) { if (field.value !== undefined && el.value !== field.value) { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; setter?.call(el, field.value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } } else if (el instanceof HTMLSelectElement) { if (field.value !== undefined && el.value !== field.value) { el.value = field.value; el.dispatchEvent(new Event("change", { bubbles: true })); } } } /** * 컨테이너에서 스냅샷 필드에 해당하는 DOM 요소를 찾는다 */ function findFieldElement( container: HTMLElement, field: FormFieldSnapshot, allElements: NodeListOf, ): Element | null { // 1순위: id로 검색 if (field.id) { try { const el = container.querySelector(`#${CSS.escape(field.id)}`); if (el) return el; } catch { /* ignore */ } } // 2순위: name으로 검색 (유일한 경우) if (field.name) { try { const candidates = container.querySelectorAll(`[name="${CSS.escape(field.name)}"]`); if (candidates.length === 1) return candidates[0]; } catch { /* ignore */ } } // 3순위: 인덱스 + tag/type 일치 검증 if (field.idx < allElements.length) { const candidate = allElements[field.idx]; if (candidate.tagName.toLowerCase() === field.tag && ((candidate as HTMLInputElement).type || "") === field.type) { return candidate; } } return null; } /** * 캡처한 폼 스냅샷을 DOM에 복원하고 React onChange를 트리거. * 폼 필드가 아직 DOM에 없으면 폴링으로 대기한다. * 반환된 cleanup 함수를 호출하면 대기를 취소할 수 있다. */ export function restoreFormState( container: HTMLElement | null, fields: FormFieldSnapshot[] | null, ): (() => void) | undefined { if (!container || !fields || fields.length === 0) return undefined; let cleaned = false; const cleanup = () => { if (cleaned) return; cleaned = true; clearInterval(pollId); clearTimeout(timeoutId); }; const tryRestore = (): boolean => { const elements = container.querySelectorAll( "input, textarea, select", ); if (elements.length === 0) return false; let restoredCount = 0; for (const field of fields) { const el = findFieldElement(container, field, elements); if (el) { applyFieldValue(el, field); restoredCount++; } } return restoredCount > 0; }; // 즉시 시도 if (tryRestore()) return undefined; // 다음 프레임에서 재시도 requestAnimationFrame(() => { if (cleaned) return; if (tryRestore()) { cleanup(); return; } }); // 폼 필드가 DOM에 나타날 때까지 폴링 (API 데이터 로드 대기) const pollId = setInterval(() => { if (tryRestore()) cleanup(); }, 100); // 최대 5초 대기 후 포기 const timeoutId = setTimeout(() => { tryRestore(); cleanup(); }, 5000); return cleanup; } // ============================================================ // DOM 경로 기반 스크롤 위치 캡처/복원 (다중 스크롤 영역 지원) // ============================================================ /** * 컨테이너 기준 자식 인덱스 경로를 생성한다. * 예: container > div(2번째) > div(1번째) > div(3번째) → "2/1/3" */ export function getElementPath(element: HTMLElement, container: HTMLElement): string | null { const indices: number[] = []; let current: HTMLElement | null = element; while (current && current !== container) { const parent: HTMLElement | null = current.parentElement; if (!parent) return null; const children = parent.children; let idx = -1; for (let i = 0; i < children.length; i++) { if (children[i] === current) { idx = i; break; } } if (idx === -1) return null; indices.unshift(idx); current = parent; } if (current !== container) return null; return indices.join("/"); } /** * 경로 문자열로 컨테이너 내의 요소를 찾는다. */ function findElementByPath(container: HTMLElement, path: string): HTMLElement | null { if (!path) return container; const indices = path.split("/").map(Number); let current: HTMLElement = container; for (const idx of indices) { if (!current.children || idx >= current.children.length) return null; const child = current.children[idx]; if (!(child instanceof HTMLElement)) return null; current = child; } return current; } /** * 컨테이너 하위의 모든 스크롤된 요소를 찾아 경로와 함께 캡처한다. * F5 직전 (beforeunload)에 호출 - 활성 탭은 display:block이므로 DOM 값이 정확하다. */ export function captureAllScrollPositions(container: HTMLElement | null): ScrollSnapshot[] | undefined { if (!container) return undefined; const snapshots: ScrollSnapshot[] = []; const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT); let node: Node | null; while ((node = walker.nextNode())) { const el = node as HTMLElement; if (el.scrollTop > 0 || el.scrollLeft > 0) { const path = getElementPath(el, container); if (path) { snapshots.push({ path, top: el.scrollTop, left: el.scrollLeft }); } } } return snapshots.length > 0 ? snapshots : undefined; } /** * 다중 스크롤 위치를 DOM 경로 기반으로 복원한다. * 컨텐츠가 아직 로드되지 않았을 수 있으므로 폴링으로 대기한다. */ export function restoreAllScrollPositions( container: HTMLElement | null, positions?: ScrollSnapshot[], ): (() => void) | undefined { if (!container || !positions || positions.length === 0) return undefined; let cleaned = false; const cleanup = () => { if (cleaned) return; cleaned = true; clearInterval(pollId); clearTimeout(timeoutId); }; const tryRestore = (): boolean => { let restoredCount = 0; for (const pos of positions) { const el = findElementByPath(container, pos.path); if (!el) continue; if (el.scrollHeight >= pos.top + el.clientHeight) { el.scrollTop = pos.top; el.scrollLeft = pos.left; restoredCount++; } } return restoredCount === positions.length; }; if (tryRestore()) return undefined; requestAnimationFrame(() => { if (cleaned) return; if (tryRestore()) { cleanup(); return; } }); const pollId = setInterval(() => { if (tryRestore()) cleanup(); }, 50); // 최대 5초 대기 후 강제 복원 const timeoutId = setTimeout(() => { for (const pos of positions) { const el = findElementByPath(container, pos.path); if (el) { el.scrollTop = pos.top; el.scrollLeft = pos.left; } } cleanup(); }, 5000); return cleanup; }