feat: F5 새로고침 시 다중 스크롤 영역 위치 저장/복원 지원
split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시 우측 패널 스크롤 위치가 복원되지 않던 문제 해결. - DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot) - 실시간 스크롤 추적을 요소별 Map으로 전환 - 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리) Made-with: Cursor
This commit is contained in:
parent
3db8a8a276
commit
7acdd852a5
|
|
@ -0,0 +1,247 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useCallback } from "react";
|
||||||
|
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
|
||||||
|
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||||
|
import { AdminPageRenderer } from "./AdminPageRenderer";
|
||||||
|
import { EmptyDashboard } from "./EmptyDashboard";
|
||||||
|
import { TabIdProvider } from "@/contexts/TabIdContext";
|
||||||
|
import { registerModalPortal } from "@/lib/modalPortalRef";
|
||||||
|
import ScreenModal from "@/components/common/ScreenModal";
|
||||||
|
import {
|
||||||
|
saveTabCacheImmediate,
|
||||||
|
loadTabCache,
|
||||||
|
captureAllScrollPositions,
|
||||||
|
restoreAllScrollPositions,
|
||||||
|
getElementPath,
|
||||||
|
captureFormState,
|
||||||
|
restoreFormState,
|
||||||
|
clearTabCache,
|
||||||
|
} from "@/lib/tabStateCache";
|
||||||
|
|
||||||
|
export function TabContent() {
|
||||||
|
const tabs = useTabStore(selectTabs);
|
||||||
|
const activeTabId = useTabStore(selectActiveTabId);
|
||||||
|
const refreshKeys = useTabStore((s) => s.refreshKeys);
|
||||||
|
|
||||||
|
// 한 번이라도 활성화된 탭만 마운트 (지연 마운트)
|
||||||
|
const mountedTabIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 각 탭의 스크롤 컨테이너 ref
|
||||||
|
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
|
|
||||||
|
// 이전 활성 탭 ID 추적
|
||||||
|
const prevActiveTabIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
|
||||||
|
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
|
||||||
|
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
|
||||||
|
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
|
||||||
|
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
|
||||||
|
|
||||||
|
if (activeTabId) {
|
||||||
|
mountedTabIdsRef.current.add(activeTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTabId) return;
|
||||||
|
|
||||||
|
const container = scrollRefsMap.current.get(activeTabId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
let path = pathCacheRef.current.get(target);
|
||||||
|
if (path === undefined) {
|
||||||
|
path = getElementPath(target, container);
|
||||||
|
pathCacheRef.current.set(target, path);
|
||||||
|
}
|
||||||
|
if (path === null) return;
|
||||||
|
|
||||||
|
let tabMap = lastScrollMapRef.current.get(activeTabId);
|
||||||
|
if (!tabMap) {
|
||||||
|
tabMap = new Map();
|
||||||
|
lastScrollMapRef.current.set(activeTabId, tabMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.scrollTop > 0 || target.scrollLeft > 0) {
|
||||||
|
tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft });
|
||||||
|
} else {
|
||||||
|
tabMap.delete(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll, true);
|
||||||
|
return () => container.removeEventListener("scroll", handleScroll, true);
|
||||||
|
}, [activeTabId]);
|
||||||
|
|
||||||
|
// 복원 관련 cleanup ref
|
||||||
|
const scrollRestoreCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const formRestoreCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원
|
||||||
|
useEffect(() => {
|
||||||
|
// 이전 복원 작업 취소
|
||||||
|
if (scrollRestoreCleanupRef.current) {
|
||||||
|
scrollRestoreCleanupRef.current();
|
||||||
|
scrollRestoreCleanupRef.current = null;
|
||||||
|
}
|
||||||
|
if (formRestoreCleanupRef.current) {
|
||||||
|
formRestoreCleanupRef.current();
|
||||||
|
formRestoreCleanupRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevId = prevActiveTabIdRef.current;
|
||||||
|
|
||||||
|
// 이전 활성 탭의 스크롤 + 폼 상태 저장
|
||||||
|
if (prevId && prevId !== activeTabId) {
|
||||||
|
const tabMap = lastScrollMapRef.current.get(prevId);
|
||||||
|
const scrollPositions =
|
||||||
|
tabMap && tabMap.size > 0
|
||||||
|
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
|
||||||
|
: undefined;
|
||||||
|
const prevEl = scrollRefsMap.current.get(prevId);
|
||||||
|
const formFields = captureFormState(prevEl ?? null);
|
||||||
|
saveTabCacheImmediate(prevId, {
|
||||||
|
...(scrollPositions && { scrollPositions }),
|
||||||
|
...(formFields && { domFormFields: formFields }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 활성 탭의 스크롤 + 폼 상태 복원
|
||||||
|
if (activeTabId) {
|
||||||
|
const cache = loadTabCache(activeTabId);
|
||||||
|
if (cache) {
|
||||||
|
const el = scrollRefsMap.current.get(activeTabId);
|
||||||
|
if (cache.scrollPositions) {
|
||||||
|
const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions);
|
||||||
|
if (cleanup) scrollRestoreCleanupRef.current = cleanup;
|
||||||
|
}
|
||||||
|
if (cache.domFormFields) {
|
||||||
|
const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null);
|
||||||
|
if (cleanup) formRestoreCleanupRef.current = cleanup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevActiveTabIdRef.current = activeTabId;
|
||||||
|
}, [activeTabId]);
|
||||||
|
|
||||||
|
// F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
const currentActiveId = prevActiveTabIdRef.current;
|
||||||
|
if (!currentActiveId) return;
|
||||||
|
|
||||||
|
const el = scrollRefsMap.current.get(currentActiveId);
|
||||||
|
// 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확)
|
||||||
|
const scrollPositions = captureAllScrollPositions(el ?? null);
|
||||||
|
// DOM 캡처 실패 시 실시간 추적 데이터 fallback
|
||||||
|
const tabMap = lastScrollMapRef.current.get(currentActiveId);
|
||||||
|
const trackedPositions =
|
||||||
|
!scrollPositions && tabMap && tabMap.size > 0
|
||||||
|
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const finalPositions = scrollPositions || trackedPositions;
|
||||||
|
const formFields = captureFormState(el ?? null);
|
||||||
|
saveTabCacheImmediate(currentActiveId, {
|
||||||
|
...(finalPositions && { scrollPositions: finalPositions }),
|
||||||
|
...(formFields && { domFormFields: formFields }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current();
|
||||||
|
if (formRestoreCleanupRef.current) formRestoreCleanupRef.current();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지)
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTabIds = new Set(tabs.map((t) => t.id));
|
||||||
|
const mountedIds = mountedTabIdsRef.current;
|
||||||
|
|
||||||
|
mountedIds.forEach((id) => {
|
||||||
|
if (!currentTabIds.has(id)) {
|
||||||
|
clearTabCache(id);
|
||||||
|
scrollRefsMap.current.delete(id);
|
||||||
|
mountedIds.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
|
||||||
|
scrollRefsMap.current.set(tabId, el);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록
|
||||||
|
const portalRefCallback = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
registerModalPortal(el);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return <EmptyDashboard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
|
||||||
|
const stableIds = Array.from(mountedTabIdsRef.current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
{stableIds.map((tabId) => {
|
||||||
|
const tab = tabLookup.get(tabId);
|
||||||
|
if (!tab) return null;
|
||||||
|
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
const refreshKey = refreshKeys[tab.id] || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => setScrollRef(tab.id, el)}
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={{ display: isActive ? "block" : "none" }}
|
||||||
|
>
|
||||||
|
<TabIdProvider value={tab.id}>
|
||||||
|
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
|
||||||
|
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
|
||||||
|
</TabIdProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPageRenderer({
|
||||||
|
tab,
|
||||||
|
refreshKey,
|
||||||
|
}: {
|
||||||
|
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
||||||
|
refreshKey: number;
|
||||||
|
}) {
|
||||||
|
if (tab.type === "screen" && tab.screenId != null) {
|
||||||
|
return (
|
||||||
|
<ScreenViewPageWrapper
|
||||||
|
key={`${tab.id}-${refreshKey}`}
|
||||||
|
screenIdProp={tab.screenId}
|
||||||
|
menuObjidProp={tab.menuObjid}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.type === "admin" && tab.adminUrl) {
|
||||||
|
return (
|
||||||
|
<div key={`${tab.id}-${refreshKey}`} className="h-full">
|
||||||
|
<AdminPageRenderer url={tab.adminUrl} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
/**
|
||||||
|
* 탭별 상태를 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { devtools, persist } from "zustand/middleware";
|
||||||
|
import { clearTabCache } from "@/lib/tabStateCache";
|
||||||
|
|
||||||
|
// --- 타입 정의 ---
|
||||||
|
|
||||||
|
export type AppMode = "user" | "admin";
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
type: "screen" | "admin";
|
||||||
|
title: string;
|
||||||
|
screenId?: number;
|
||||||
|
menuObjid?: number;
|
||||||
|
adminUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeTabData {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabState {
|
||||||
|
mode: AppMode;
|
||||||
|
user: ModeTabData;
|
||||||
|
admin: ModeTabData;
|
||||||
|
refreshKeys: Record<string, number>;
|
||||||
|
|
||||||
|
setMode: (mode: AppMode) => void;
|
||||||
|
|
||||||
|
openTab: (tab: Omit<Tab, "id">, insertIndex?: number) => void;
|
||||||
|
closeTab: (tabId: string) => void;
|
||||||
|
switchTab: (tabId: string) => void;
|
||||||
|
refreshTab: (tabId: string) => void;
|
||||||
|
|
||||||
|
closeOtherTabs: (tabId: string) => void;
|
||||||
|
closeTabsToLeft: (tabId: string) => void;
|
||||||
|
closeTabsToRight: (tabId: string) => void;
|
||||||
|
closeAllTabs: () => void;
|
||||||
|
|
||||||
|
updateTabOrder: (fromIndex: number, toIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 헬퍼 함수 ---
|
||||||
|
|
||||||
|
function generateTabId(tab: Omit<Tab, "id">): string {
|
||||||
|
if (tab.type === "screen" && tab.screenId != null) {
|
||||||
|
return `tab-screen-${tab.screenId}-${tab.menuObjid ?? 0}`;
|
||||||
|
}
|
||||||
|
if (tab.type === "admin" && tab.adminUrl) {
|
||||||
|
return `tab-admin-${tab.adminUrl.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||||
|
}
|
||||||
|
return `tab-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDuplicateTab(tabs: Tab[], newTab: Omit<Tab, "id">): Tab | undefined {
|
||||||
|
if (newTab.type === "screen" && newTab.screenId != null) {
|
||||||
|
return tabs.find(
|
||||||
|
(t) => t.type === "screen" && t.screenId === newTab.screenId && t.menuObjid === newTab.menuObjid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (newTab.type === "admin" && newTab.adminUrl) {
|
||||||
|
return tabs.find((t) => t.type === "admin" && t.adminUrl === newTab.adminUrl);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextActiveTabId(tabs: Tab[], closedTabId: string, currentActiveId: string | null): string | null {
|
||||||
|
if (currentActiveId !== closedTabId) return currentActiveId;
|
||||||
|
const idx = tabs.findIndex((t) => t.id === closedTabId);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const remaining = tabs.filter((t) => t.id !== closedTabId);
|
||||||
|
if (remaining.length === 0) return null;
|
||||||
|
if (idx > 0) return remaining[Math.min(idx - 1, remaining.length - 1)].id;
|
||||||
|
return remaining[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 모드의 데이터 키 반환
|
||||||
|
function modeKey(state: TabState): AppMode {
|
||||||
|
return state.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 셀렉터 (컴포넌트에서 사용) ---
|
||||||
|
|
||||||
|
export function selectTabs(state: TabState): Tab[] {
|
||||||
|
return state[state.mode].tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveTabId(state: TabState): string | null {
|
||||||
|
return state[state.mode].activeTabId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Store ---
|
||||||
|
|
||||||
|
const EMPTY_MODE: ModeTabData = { tabs: [], activeTabId: null };
|
||||||
|
|
||||||
|
export const useTabStore = create<TabState>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
mode: "user" as AppMode,
|
||||||
|
user: { ...EMPTY_MODE },
|
||||||
|
admin: { ...EMPTY_MODE },
|
||||||
|
refreshKeys: {},
|
||||||
|
|
||||||
|
setMode: (mode) => {
|
||||||
|
set({ mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
openTab: (tabData, insertIndex) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
const existing = findDuplicateTab(modeData.tabs, tabData);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
set({ [mk]: { ...modeData, activeTabId: existing.id } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateTabId(tabData);
|
||||||
|
const newTab: Tab = { ...tabData, id };
|
||||||
|
const newTabs = [...modeData.tabs];
|
||||||
|
|
||||||
|
if (insertIndex != null && insertIndex >= 0 && insertIndex <= newTabs.length) {
|
||||||
|
newTabs.splice(insertIndex, 0, newTab);
|
||||||
|
} else {
|
||||||
|
newTabs.push(newTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ [mk]: { tabs: newTabs, activeTabId: id } });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTab: (tabId) => {
|
||||||
|
clearTabCache(tabId);
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
const nextActive = getNextActiveTabId(modeData.tabs, tabId, modeData.activeTabId);
|
||||||
|
const newTabs = modeData.tabs.filter((t) => t.id !== tabId);
|
||||||
|
const { [tabId]: _, ...restKeys } = get().refreshKeys;
|
||||||
|
set({ [mk]: { tabs: newTabs, activeTabId: nextActive }, refreshKeys: restKeys });
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab: (tabId) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
set({ [mk]: { ...modeData, activeTabId: tabId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshTab: (tabId) => {
|
||||||
|
set((state) => ({
|
||||||
|
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
closeOtherTabs: (tabId) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
modeData.tabs.filter((t) => t.id !== tabId).forEach((t) => clearTabCache(t.id));
|
||||||
|
set({ [mk]: { tabs: modeData.tabs.filter((t) => t.id === tabId), activeTabId: tabId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTabsToLeft: (tabId) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
modeData.tabs.slice(0, idx).forEach((t) => clearTabCache(t.id));
|
||||||
|
set({ [mk]: { tabs: modeData.tabs.slice(idx), activeTabId: tabId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTabsToRight: (tabId) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
modeData.tabs.slice(idx + 1).forEach((t) => clearTabCache(t.id));
|
||||||
|
set({ [mk]: { tabs: modeData.tabs.slice(0, idx + 1), activeTabId: tabId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAllTabs: () => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
modeData.tabs.forEach((t) => clearTabCache(t.id));
|
||||||
|
set({ [mk]: { tabs: [], activeTabId: null } });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTabOrder: (fromIndex, toIndex) => {
|
||||||
|
const mk = modeKey(get());
|
||||||
|
const modeData = get()[mk];
|
||||||
|
const newTabs = [...modeData.tabs];
|
||||||
|
const [moved] = newTabs.splice(fromIndex, 1);
|
||||||
|
newTabs.splice(toIndex, 0, moved);
|
||||||
|
set({ [mk]: { ...modeData, tabs: newTabs } });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "erp-tab-store",
|
||||||
|
storage: {
|
||||||
|
getItem: (name) => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const raw = sessionStorage.getItem(name);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
},
|
||||||
|
setItem: (name, value) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
sessionStorage.setItem(name, JSON.stringify(value));
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
sessionStorage.removeItem(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
partialize: (state) => ({
|
||||||
|
mode: state.mode,
|
||||||
|
user: state.user,
|
||||||
|
admin: state.admin,
|
||||||
|
}) as unknown as TabState,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ name: "TabStore" },
|
||||||
|
),
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue