"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(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(-1); const pendingDropRef = useRef<{ timerId: ReturnType; finalize: () => void } | null>(null); const tabsRef = useRef(tabs); tabsRef.current = tabs; const newDropTabIdRef = useRef(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 (
); } 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 (
{/* 탭 + 오버플로우 버튼이 나란히 배치 */}
{displayVisibleTabs.map((tab) => { const isActive = tab.id === activeTabId; const displayName = getTabDisplayName(tab); return (
handleTabMouseDown(e, tab.id)} onDragStart={(e) => e.preventDefault()} onContextMenu={(e) => handleContextMenu(e, tab.id)} title={displayName} > {displayName} {isActive && ( )}
); })} {/* 오버플로우 드롭다운 - 마지막 탭 바로 옆 */} {hasOverflow && ( {displayOverflowTabs.map((tab) => { const isActive = tab.id === activeTabId; return ( switchTab(tab.id)} >
{tab.parentMenuName && ( {tab.parentMenuName} )} {tab.screenName || `화면 ${tab.screenId}`}
); })} 모든 탭 닫기
)}
{/* 우클릭 컨텍스트 메뉴 */} {contextMenu && (
)}
); }