feat: F5 새로고침 시 다중 스크롤 영역 위치 저장/복원 지원

split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시
우측 패널 스크롤 위치가 복원되지 않던 문제 해결.

- DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot)
- 실시간 스크롤 추적을 요소별 Map으로 전환
- 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리)

Made-with: Cursor
This commit is contained in:
syc0123 2026-02-27 14:21:15 +09:00
parent 3db8a8a276
commit 7acdd852a5
3 changed files with 899 additions and 0 deletions

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

View File

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

224
frontend/stores/tabStore.ts Normal file
View File

@ -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" },
),
);