"use client"; import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react"; import { X, RotateCw, ChevronDown } from "lucide-react"; import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore"; import { menuScreenApi } from "@/lib/api/screen"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; const TAB_WIDTH = 180; const TAB_GAP = 2; const TAB_UNIT = TAB_WIDTH + TAB_GAP; const OVERFLOW_BTN_WIDTH = 48; const DRAG_THRESHOLD = 5; const SETTLE_MS = 70; const DROP_SETTLE_MS = 180; const BAR_PAD_X = 8; interface DragState { tabId: string; pointerId: number; startX: number; currentX: number; tabRect: DOMRect; fromIndex: number; targetIndex: number; activated: boolean; settling: boolean; } interface DropGhost { title: string; startX: number; startY: number; targetIdx: number; tabCountAtCreation: number; } export function TabBar() { const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); const { switchTab, closeTab, refreshTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, updateTabOrder, openTab, } = useTabStore(); // --- Refs --- const containerRef = useRef(null); const settleTimer = useRef | null>(null); const dragActiveRef = useRef(false); const dragLeaveTimerRef = useRef | null>(null); const dropGhostRef = useRef(null); const prevTabCountRef = useRef(tabs.length); // --- State --- const [visibleCount, setVisibleCount] = useState(tabs.length); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string; } | null>(null); const [dragState, setDragState] = useState(null); const [externalDragIdx, setExternalDragIdx] = useState(null); const [dropGhost, setDropGhost] = useState(null); dragActiveRef.current = !!dragState; // --- 타이머 정리 --- useEffect(() => { return () => { if (settleTimer.current) clearTimeout(settleTimer.current); if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current); }; }, []); // --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 --- useEffect(() => { if (!dropGhost) return; const el = dropGhostRef.current; const bar = containerRef.current?.getBoundingClientRect(); if (!el || !bar) return; const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT; const targetY = bar.bottom - 28; const dx = dropGhost.startX - targetX; const dy = dropGhost.startY - targetY; const anim = el.animate( [ { transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 }, { transform: "translate(0, 0)", opacity: 1 }, ], { duration: DROP_SETTLE_MS, easing: "cubic-bezier(0.25, 1, 0.5, 1)", fill: "forwards", }, ); anim.onfinish = () => { setDropGhost(null); setExternalDragIdx(null); }; const safety = setTimeout(() => { setDropGhost(null); setExternalDragIdx(null); }, DROP_SETTLE_MS + 500); return () => { anim.cancel(); clearTimeout(safety); }; }, [dropGhost]); // --- 오버플로우 계산 (드래그 중 재계산 방지) --- const recalcVisible = useCallback(() => { if (dragActiveRef.current) return; if (!containerRef.current) return; const w = containerRef.current.clientWidth; setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT))); }, []); useEffect(() => { recalcVisible(); const obs = new ResizeObserver(recalcVisible); if (containerRef.current) obs.observe(containerRef.current); return () => obs.disconnect(); }, [recalcVisible]); useLayoutEffect(() => { recalcVisible(); }, [tabs.length, recalcVisible]); const visibleTabs = tabs.slice(0, visibleCount); const overflowTabs = tabs.slice(visibleCount); const hasOverflow = overflowTabs.length > 0; const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId); let displayVisible = visibleTabs; let displayOverflow = overflowTabs; if (activeInOverflow && activeTabId) { const activeTab = tabs.find((t) => t.id === activeTabId)!; displayVisible = [...visibleTabs.slice(0, -1), activeTab]; displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId); if (visibleTabs.length > 0) { displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow]; } } // ============================================================ // 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션) // ============================================================ useLayoutEffect(() => { if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) { setExternalDragIdx(null); } prevTabCountRef.current = tabs.length; }, [tabs.length, externalDragIdx]); const resolveMenuAndOpenTab = async ( menuName: string, menuObjid: string | number, url: string, insertIndex?: number, ) => { const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid; try { const screens = await menuScreenApi.getScreensByMenu(numericObjid); if (screens.length > 0) { openTab( { type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid }, insertIndex, ); return; } } catch { /* ignore */ } if (url && url !== "#") { openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex); } else { setExternalDragIdx(null); } }; const handleBarDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; if (dragLeaveTimerRef.current) { clearTimeout(dragLeaveTimerRef.current); dragLeaveTimerRef.current = null; } const bar = containerRef.current?.getBoundingClientRect(); if (bar) { const idx = Math.max( 0, Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length), ); setExternalDragIdx(idx); } }; const handleBarDragLeave = (e: React.DragEvent) => { if (!containerRef.current?.contains(e.relatedTarget as Node)) { dragLeaveTimerRef.current = setTimeout(() => { setExternalDragIdx(null); dragLeaveTimerRef.current = null; }, 50); } }; const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => { setDropGhost({ title, startX: e.clientX - TAB_WIDTH / 2, startY: e.clientY - 14, targetIdx, tabCountAtCreation: tabs.length, }); }; const handleBarDrop = (e: React.DragEvent) => { e.preventDefault(); if (dragLeaveTimerRef.current) { clearTimeout(dragLeaveTimerRef.current); dragLeaveTimerRef.current = null; } const insertIdx = externalDragIdx ?? undefined; const ghostIdx = insertIdx ?? displayVisible.length; const pending = e.dataTransfer.getData("application/tab-menu-pending"); if (pending) { try { const { menuName, menuObjid, url } = JSON.parse(pending); createDropGhost(e, menuName, ghostIdx); resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx); } catch { setExternalDragIdx(null); } return; } const menuData = e.dataTransfer.getData("application/tab-menu"); if (menuData && menuData.length > 2) { try { const parsed = JSON.parse(menuData); createDropGhost(e, parsed.title || "새 탭", ghostIdx); setExternalDragIdx(null); openTab(parsed, insertIdx); } catch { setExternalDragIdx(null); } } else { setExternalDragIdx(null); } }; // ============================================================ // 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션 // ============================================================ const calcTarget = useCallback( (clientX: number, startX: number, fromIndex: number): number => { const delta = Math.round((clientX - startX) / TAB_UNIT); return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1)); }, [displayVisible.length], ); const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => { if ((e.target as HTMLElement).closest("button")) return; if (dragState?.settling) return; if (settleTimer.current) { clearTimeout(settleTimer.current); settleTimer.current = null; } e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); setDragState({ tabId, pointerId: e.pointerId, startX: e.clientX, currentX: e.clientX, tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(), fromIndex: idx, targetIndex: idx, activated: false, settling: false, }); }; const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!dragState || dragState.settling) return; if (e.pointerId !== dragState.pointerId) return; const bar = containerRef.current?.getBoundingClientRect(); if (!bar) return; const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right)); if (!dragState.activated) { if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return; setDragState((p) => p ? { ...p, activated: true, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex), } : null, ); return; } setDragState((p) => p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null, ); }, [dragState, calcTarget], ); const handlePointerUp = useCallback( (e: React.PointerEvent) => { if (!dragState || dragState.settling) return; if (e.pointerId !== dragState.pointerId) return; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); if (!dragState.activated) { switchTab(dragState.tabId); setDragState(null); return; } const { fromIndex, targetIndex, tabId } = dragState; setDragState((p) => (p ? { ...p, settling: true } : null)); if (targetIndex === fromIndex) { settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10); return; } const actualFrom = tabs.findIndex((t) => t.id === tabId); const tgtTab = displayVisible[targetIndex]; const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom; settleTimer.current = setTimeout(() => { setDragState(null); if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) { updateTabOrder(actualFrom, actualTo); } }, SETTLE_MS + 10); }, [dragState, tabs, displayVisible, switchTab, updateTabOrder], ); const handleLostPointerCapture = useCallback(() => { if (dragState && !dragState.settling) { setDragState(null); if (settleTimer.current) { clearTimeout(settleTimer.current); settleTimer.current = null; } } }, [dragState]); // ============================================================ // 스타일 계산 // ============================================================ const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => { if (externalDragIdx !== null && !dragState) { return { transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none", transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, }; } if (!dragState || !dragState.activated) return {}; const { fromIndex, targetIndex, tabId: draggedId } = dragState; if (tabId === draggedId) { return { opacity: 0, transition: "none" }; } let shift = 0; if (fromIndex < targetIndex) { if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT; } else if (fromIndex > targetIndex) { if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT; } return { transform: shift !== 0 ? `translateX(${shift}px)` : "none", transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, }; }; const getGhostStyle = (): React.CSSProperties | null => { if (!dragState || !dragState.activated) return null; const bar = containerRef.current?.getBoundingClientRect(); if (!bar) return null; const base: React.CSSProperties = { position: "fixed", top: dragState.tabRect.top, width: TAB_WIDTH, height: dragState.tabRect.height, zIndex: 100, pointerEvents: "none", opacity: 0.9, }; if (dragState.settling) { return { ...base, left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT, opacity: 1, boxShadow: "none", transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`, }; } const offsetX = dragState.currentX - dragState.startX; const rawLeft = dragState.tabRect.left + offsetX; return { ...base, left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)), transition: "none", }; }; const ghostStyle = getGhostStyle(); const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null; // ============================================================ // 우클릭 컨텍스트 메뉴 // ============================================================ const handleContextMenu = (e: React.MouseEvent, tabId: string) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, tabId }); }; useEffect(() => { if (!contextMenu) return; const close = () => setContextMenu(null); window.addEventListener("click", close); window.addEventListener("scroll", close); return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close); }; }, [contextMenu]); // ============================================================ // 렌더링 // ============================================================ const renderTab = (tab: Tab, displayIndex: number) => { const isActive = tab.id === activeTabId; const animStyle = getTabAnimStyle(tab.id, displayIndex); const hiddenByGhost = !!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation; return (
handlePointerDown(e, tab.id, displayIndex)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onLostPointerCapture={handleLostPointerCapture} onContextMenu={(e) => handleContextMenu(e, tab.id)} className={cn( "group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none", isActive ? "border-border text-foreground z-10 -mb-px h-[30px] bg-white" : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent", )} style={{ width: TAB_WIDTH, touchAction: "none", ...animStyle, ...(hiddenByGhost ? { opacity: 0 } : {}), }} title={tab.title} > {tab.title}
{isActive && ( )}
); }; if (tabs.length === 0) return null; return ( <>
{displayVisible.map((tab, i) => renderTab(tab, i))} {hasOverflow && ( {displayOverflow.map((tab) => ( switchTab(tab.id)} className="flex items-center justify-between gap-2" > {tab.title} ))} )}
{/* 탭 드래그 고스트 (내부 재정렬) */} {ghostStyle && draggedTab && (
{draggedTab.title}
)} {/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */} {dropGhost && (() => { const bar = containerRef.current?.getBoundingClientRect(); if (!bar) return null; const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT; const targetY = bar.bottom - 28; return (
{dropGhost.title}
); })()} {/* 우클릭 컨텍스트 메뉴 */} {contextMenu && (
{ refreshTab(contextMenu.tabId); setContextMenu(null); }} />
{ closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} /> { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} /> { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} />
{ closeAllTabs(); setContextMenu(null); }} destructive />
)} ); } function ContextMenuItem({ label, onClick, destructive, }: { label: string; onClick: () => void; destructive?: boolean; }) { return ( ); }