feat: F5 새로고침 시 다중 스크롤 영역 위치 저장/복원 지원
split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시 우측 패널 스크롤 위치가 복원되지 않던 문제 해결. - DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot) - 실시간 스크롤 추적을 요소별 Map으로 전환 - 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리) Made-with: Cursor
This commit is contained in:
247
frontend/components/layout/TabContent.tsx
Normal file
247
frontend/components/layout/TabContent.tsx
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user