ERP-node/frontend/components/layout/TabBar.tsx

587 lines
21 KiB
TypeScript

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { X, ChevronDown, RotateCw } from "lucide-react";
import { useTab, TabItem } from "@/contexts/TabContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { menuScreenApi } from "@/lib/api/screen";
import { useRouter } from "next/navigation";
const TAB_MIN_WIDTH = 190;
const TAB_MAX_WIDTH = 190;
const TAB_GAP = 2;
const OVERFLOW_BUTTON_WIDTH = 48;
function getTabDisplayName(tab: TabItem): string {
if (tab.parentMenuName && tab.screenName) {
return `${tab.parentMenuName} - ${tab.screenName}`;
}
return tab.screenName || `화면 ${tab.screenId}`;
}
export function TabBar() {
const { tabs, activeTabId, switchTab, closeTab, closeOtherTabs, closeAllTabs, closeTabsToTheLeft, closeTabsToTheRight, updateTabOrder, openTab, refreshTab } = useTab();
const router = useRouter();
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [dragOver, setDragOver] = useState(false);
const [visibleCount, setVisibleCount] = useState(tabs.length);
const [tabWidth, setTabWidth] = useState(TAB_MAX_WIDTH);
const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null);
const dragStateRef = useRef<{
tabId: string;
startX: number;
startIndex: number;
currentIndex: number;
order: string[]; // 드래그 중 논리적 순서 (tabId 배열)
isDragging: boolean;
} | null>(null);
const menuDragInsertRef = useRef<number>(-1);
const pendingDropRef = useRef<{ timerId: ReturnType<typeof setTimeout>; finalize: () => void } | null>(null);
const tabsRef = useRef(tabs);
tabsRef.current = tabs;
const newDropTabIdRef = useRef<string | null>(null);
const calculateVisibleTabs = useCallback(() => {
const container = tabsContainerRef.current;
if (!container) {
setVisibleCount(tabs.length);
return;
}
const containerWidth = container.offsetWidth;
const tabSlotWidth = TAB_MIN_WIDTH + TAB_GAP;
// 오버플로우 없이 전부 들어가는지 확인
const totalIfAll = tabs.length * tabSlotWidth;
if (totalIfAll <= containerWidth) {
// 전부 표시, 남는 공간을 균등 분배
setVisibleCount(tabs.length);
const w = Math.floor(containerWidth / tabs.length) - TAB_GAP;
setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w)));
return;
}
// 오버플로우 필요: +N 버튼 공간 확보 후 몇 개 들어가는지 계산
const availableForTabs = containerWidth - OVERFLOW_BUTTON_WIDTH;
const count = Math.max(1, Math.floor(availableForTabs / tabSlotWidth));
setVisibleCount(count);
const w = Math.floor(availableForTabs / count) - TAB_GAP;
setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w)));
}, [tabs.length]);
useEffect(() => {
calculateVisibleTabs();
const resizeObserver = new ResizeObserver(calculateVisibleTabs);
if (tabsContainerRef.current) {
resizeObserver.observe(tabsContainerRef.current);
}
return () => resizeObserver.disconnect();
}, [calculateVisibleTabs]);
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) {
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}
}, [contextMenu]);
if (tabs.length === 0) {
return (
<div className="relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]" />
);
}
const visibleTabs = tabs.slice(0, visibleCount);
const overflowTabs = tabs.slice(visibleCount);
const hasOverflow = overflowTabs.length > 0;
// 활성 탭이 오버플로우에 있는 경우, 보이는 탭의 마지막을 교체
const activeInOverflow = overflowTabs.find(t => t.id === activeTabId);
let displayVisibleTabs = [...visibleTabs];
let displayOverflowTabs = [...overflowTabs];
if (activeInOverflow && visibleTabs.length > 0) {
const lastVisible = displayVisibleTabs[displayVisibleTabs.length - 1];
displayVisibleTabs[displayVisibleTabs.length - 1] = activeInOverflow;
displayOverflowTabs = displayOverflowTabs.filter(t => t.id !== activeInOverflow.id);
displayOverflowTabs.unshift(lastVisible);
}
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
};
const applyDragTransforms = (order: string[], dragId: string, dragDx: number) => {
const container = tabsContainerRef.current;
if (!container) return;
const slotWidth = tabWidth + TAB_GAP;
const originalOrder = tabsRef.current.map(t => t.id);
order.forEach((id, newIdx) => {
const el = container.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null;
if (!el) return;
const origIdx = originalOrder.indexOf(id);
if (origIdx === -1) return;
if (id === dragId) {
el.style.transform = `translateX(${dragDx}px)`;
el.style.zIndex = "50";
el.style.opacity = "0.85";
} else {
const offset = (newIdx - origIdx) * slotWidth;
el.style.transform = offset !== 0 ? `translateX(${offset}px)` : "";
el.style.zIndex = "";
el.style.opacity = "";
}
el.style.transition = "none";
});
};
const clearAllTransforms = () => {
const container = tabsContainerRef.current;
if (!container) return;
container.querySelectorAll("[data-tab-id]").forEach((el) => {
const h = el as HTMLElement;
h.style.transform = "";
h.style.zIndex = "";
h.style.opacity = "";
h.style.transition = "";
});
};
const handleTabMouseDown = (e: React.MouseEvent, tabId: string) => {
if (e.button === 1) {
e.preventDefault();
closeTab(tabId);
return;
}
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
// 이전 드롭 애니메이션이 진행 중이면 즉시 완료
if (pendingDropRef.current) {
clearTimeout(pendingDropRef.current.timerId);
pendingDropRef.current.finalize();
pendingDropRef.current = null;
}
const container = tabsContainerRef.current;
if (!container) return;
// finalize() 후 flushSync로 상태가 갱신되었으므로 최신 tabs 사용
const currentTabs = tabsRef.current;
const startIndex = currentTabs.findIndex(t => t.id === tabId);
const startX = e.clientX;
const slotWidth = tabWidth + TAB_GAP;
const order = currentTabs.map(t => t.id);
dragStateRef.current = {
tabId,
startX,
startIndex,
currentIndex: startIndex,
order,
isDragging: false,
};
const handleMouseMove = (moveEvent: MouseEvent) => {
const state = dragStateRef.current;
if (!state) return;
const dx = moveEvent.clientX - state.startX;
if (!state.isDragging && Math.abs(dx) > 4) {
state.isDragging = true;
}
if (!state.isDragging) return;
// 마우스 위치로 목표 슬롯 계산
const containerLeft = container.getBoundingClientRect().left + 4;
const mouseRelX = moveEvent.clientX - containerLeft;
const targetSlot = Math.max(0, Math.min(visibleCount - 1, Math.floor(mouseRelX / slotWidth)));
// 순서 배열에서 직접 이동 (React 상태 안 건드림)
if (targetSlot !== state.currentIndex) {
const [moved] = state.order.splice(state.currentIndex, 1);
state.order.splice(targetSlot, 0, moved);
state.currentIndex = targetSlot;
}
// 드래그 탭의 시각적 위치: 원래 슬롯 기준 마우스 이동량
const visualDx = dx;
applyDragTransforms(state.order, state.tabId, visualDx);
};
const handleMouseUp = () => {
const state = dragStateRef.current;
dragStateRef.current = null;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (state && state.isDragging && state.currentIndex !== state.startIndex) {
const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null;
const targetOffset = (state.currentIndex - state.startIndex) * slotWidth;
const newOrder = [...state.order];
if (dragEl) {
dragEl.style.transition = "transform 150ms ease, opacity 150ms ease";
dragEl.style.transform = `translateX(${targetOffset}px)`;
dragEl.style.opacity = "1";
dragEl.style.zIndex = "50";
}
const finalize = () => {
clearAllTransforms();
flushSync(() => updateTabOrder(newOrder));
pendingDropRef.current = null;
};
const timerId = setTimeout(finalize, 150);
pendingDropRef.current = { timerId, finalize };
} else if (state && state.isDragging) {
const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null;
if (dragEl) {
dragEl.style.transition = "transform 150ms ease, opacity 150ms ease";
dragEl.style.transform = "translateX(0)";
dragEl.style.opacity = "1";
}
const finalize = () => {
clearAllTransforms();
pendingDropRef.current = null;
};
const timerId = setTimeout(finalize, 150);
pendingDropRef.current = { timerId, finalize };
} else {
clearAllTransforms();
if (state) switchTab(tabId);
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
const applyMenuDragGap = (insertIdx: number) => {
const container = tabsContainerRef.current;
if (!container) return;
const slotWidth = tabWidth + TAB_GAP;
displayVisibleTabs.forEach((tab, i) => {
const el = container.querySelector(`[data-tab-id="${tab.id}"]`) as HTMLElement | null;
if (!el) return;
if (i >= insertIdx) {
el.style.transform = `translateX(${slotWidth}px)`;
el.style.transition = "transform 150ms ease";
} else {
el.style.transform = "";
el.style.transition = "transform 150ms ease";
}
});
};
const clearMenuDragGap = () => {
const container = tabsContainerRef.current;
if (!container) return;
container.querySelectorAll("[data-tab-id]").forEach((el) => {
const h = el as HTMLElement;
h.style.transform = "";
h.style.transition = "";
});
menuDragInsertRef.current = -1;
};
const handleMenuDragOver = (e: React.DragEvent) => {
if (e.dataTransfer.types.includes("application/tab-menu")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (!dragOver) setDragOver(true);
const container = tabsContainerRef.current;
if (container) {
const containerLeft = container.getBoundingClientRect().left + 4;
const mouseRelX = e.clientX - containerLeft;
const slotWidth = tabWidth + TAB_GAP;
const insertIdx = Math.max(0, Math.min(displayVisibleTabs.length, Math.round(mouseRelX / slotWidth)));
if (insertIdx !== menuDragInsertRef.current) {
menuDragInsertRef.current = insertIdx;
applyMenuDragGap(insertIdx);
}
}
}
};
const handleMenuDragLeave = (e: React.DragEvent) => {
const container = tabsContainerRef.current;
if (container && !container.contains(e.relatedTarget as Node)) {
setDragOver(false);
clearMenuDragGap();
}
};
const handleMenuDrop = async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const insertIndex = menuDragInsertRef.current >= 0
? menuDragInsertRef.current
: undefined;
clearMenuDragGap();
const raw = e.dataTransfer.getData("application/tab-menu");
if (!raw) return;
try {
const data = JSON.parse(raw);
const { menuObjid, menuName, parentMenuName } = data;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
const firstScreen = assignedScreens[0];
const expectedTabId = `tab-${firstScreen.screenId}-${menuObjid || "default"}`;
const alreadyOpen = tabs.some(t => t.id === expectedTabId);
if (!alreadyOpen) {
newDropTabIdRef.current = expectedTabId;
}
openTab({
screenId: firstScreen.screenId,
menuObjid,
screenName: menuName,
menuName: menuName,
parentMenuName: parentMenuName,
}, insertIndex);
const urlParams = new URLSearchParams();
urlParams.set("menuObjid", menuObjid.toString());
router.replace(`/screens/${firstScreen.screenId}?${urlParams.toString()}`);
if (!alreadyOpen) {
requestAnimationFrame(() => {
const container = tabsContainerRef.current;
if (!container) return;
const newEl = container.querySelector(`[data-tab-id="${expectedTabId}"]`) as HTMLElement | null;
if (newEl) {
newEl.style.transition = "none";
newEl.style.transform = "scale(0.85)";
newEl.style.opacity = "0";
requestAnimationFrame(() => {
newEl.style.transition = "transform 150ms ease, opacity 150ms ease";
newEl.style.transform = "scale(1)";
newEl.style.opacity = "1";
setTimeout(() => {
newEl.style.transition = "";
newEl.style.transform = "";
newEl.style.opacity = "";
newDropTabIdRef.current = null;
}, 160);
});
}
});
}
}
} catch (error) {
console.warn("드래그 드롭 탭 생성 실패:", error);
}
};
return (
<div
className={cn(
"relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]",
dragOver && "shadow-[inset_0_-2px_0_0_rgb(59,130,246)]",
)}
ref={tabsContainerRef}
onDragOver={handleMenuDragOver}
onDragLeave={handleMenuDragLeave}
onDrop={handleMenuDrop}
>
{/* 탭 + 오버플로우 버튼이 나란히 배치 */}
<div className="flex min-w-0 items-end">
{displayVisibleTabs.map((tab) => {
const isActive = tab.id === activeTabId;
const displayName = getTabDisplayName(tab);
return (
<div
key={tab.id}
data-tab-id={tab.id}
style={{ width: `${tabWidth}px` }}
className={cn(
"group relative mr-0.5 flex shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border px-3 text-xs font-medium select-none transition-colors",
isActive
? "z-10 h-[33px] border-slate-200 border-b-0 bg-white text-slate-900"
: "h-8 border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)}
onMouseDown={(e) => handleTabMouseDown(e, tab.id)}
onDragStart={(e) => e.preventDefault()}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
title={displayName}
>
<span className="min-w-0 flex-1 truncate">{displayName}</span>
{isActive && (
<button
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700"
onClick={(e) => {
e.stopPropagation();
refreshTab(tab.id);
}}
title="새로고침"
>
<RotateCw className="h-2.5 w-2.5" />
</button>
)}
<button
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors",
isActive
? "text-slate-400 hover:bg-slate-200 hover:text-slate-700"
: "text-transparent group-hover:text-slate-400 group-hover:hover:bg-slate-200 group-hover:hover:text-slate-700",
)}
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* 오버플로우 드롭다운 - 마지막 탭 바로 옆 */}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="mb-0.5 flex h-7 shrink-0 items-center justify-center gap-0.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-700"
title={`${displayOverflowTabs.length}개 탭`}
>
<span className="text-[10px]">+{displayOverflowTabs.length}</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[400px] w-[280px] overflow-y-auto">
{displayOverflowTabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<DropdownMenuItem
key={tab.id}
className={cn(
"flex cursor-pointer items-center justify-between gap-2",
isActive && "bg-slate-100 font-semibold",
)}
onClick={() => switchTab(tab.id)}
>
<div className="flex min-w-0 flex-1 flex-col">
{tab.parentMenuName && (
<span className="text-[10px] text-slate-400">{tab.parentMenuName}</span>
)}
<span className="truncate text-xs">{tab.screenName || `화면 ${tab.screenId}`}</span>
</div>
<button
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 hover:bg-slate-200 hover:text-slate-700"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-xs text-slate-500"
onClick={closeAllTabs}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<div
className="fixed z-50 min-w-[160px] rounded-md border border-slate-200 bg-white py-1 shadow-lg"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
refreshTab(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<div className="my-1 border-t border-slate-200" />
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTab(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeOtherTabs(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTabsToTheLeft(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTabsToTheRight(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<div className="my-1 border-t border-slate-200" />
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-red-600 hover:bg-red-50"
onClick={() => {
closeAllTabs();
setContextMenu(null);
}}
>
</button>
</div>
)}
</div>
);
}