429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 탭별 상태를 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<Omit<TabCacheData, "cachedAt">>): 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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||
|
|
"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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||
|
|
): 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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||
|
|
"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;
|
||
|
|
}
|
||
|
|
|