ERP-node/frontend/lib/tabStateCache.ts

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;
}