6b17c1fadf
Build & Deploy to K8s / build-and-deploy (push) Failing after 8m6s
Include the outstanding numbering-rule admin page changes, TabBar interaction updates, V5 layout theme accent styling, and auto-generation option compatibility fix. Add the local web-prototype skill assets, numbering-rule design variants, control IDE refactor note, and the table canonical cleanup plan/prompts used across phases B through F. This commit captures the remaining workspace files after the canonical table cleanup commit so the branch can be pushed without leaving local dirty work behind.
783 lines
25 KiB
TypeScript
783 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
|
|
import { X, RotateCw, ChevronDown, ChevronUp } 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;
|
|
}
|
|
|
|
interface TabBarProps {
|
|
collapsed?: boolean;
|
|
onToggleCollapse?: () => void;
|
|
modeTransition?: "idle" | "out" | "in";
|
|
}
|
|
|
|
export function TabBar({ collapsed = false, onToggleCollapse, modeTransition = "idle" }: TabBarProps) {
|
|
const tabs = useTabStore(selectTabs);
|
|
const activeTabId = useTabStore(selectActiveTabId);
|
|
const {
|
|
switchTab,
|
|
closeTab,
|
|
refreshTab,
|
|
closeOtherTabs,
|
|
closeTabsToLeft,
|
|
closeTabsToRight,
|
|
closeAllTabs,
|
|
updateTabOrder,
|
|
openTab,
|
|
} = useTabStore();
|
|
|
|
// --- Refs ---
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const dragActiveRef = useRef(false);
|
|
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const dropGhostRef = useRef<HTMLDivElement>(null);
|
|
const prevTabCountRef = useRef(tabs.length);
|
|
const activeTabElRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// --- State ---
|
|
const [visibleCount, setVisibleCount] = useState(tabs.length);
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
tabId: string;
|
|
} | null>(null);
|
|
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
|
|
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
|
|
const [closingIds, setClosingIds] = useState<Set<string>>(() => new Set());
|
|
const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number; opacity: number }>({
|
|
left: 0,
|
|
width: 0,
|
|
opacity: 0,
|
|
});
|
|
|
|
dragActiveRef.current = !!dragState;
|
|
|
|
// --- 타이머 정리 ---
|
|
useEffect(() => {
|
|
return () => {
|
|
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// --- 탭 leave 애니메이션: closing 표시 → 180ms 후 store splice ---
|
|
const CLOSE_ANIM_MS = 180;
|
|
const markClosing = useCallback((ids: string[]) => {
|
|
if (ids.length === 0) return;
|
|
setClosingIds((prev) => {
|
|
const next = new Set(prev);
|
|
ids.forEach((id) => next.add(id));
|
|
return next;
|
|
});
|
|
}, []);
|
|
const handleCloseTab = useCallback(
|
|
(tabId: string) => {
|
|
markClosing([tabId]);
|
|
setTimeout(() => closeTab(tabId), CLOSE_ANIM_MS);
|
|
},
|
|
[closeTab, markClosing],
|
|
);
|
|
const handleCloseOtherTabs = useCallback(
|
|
(tabId: string) => {
|
|
markClosing(tabs.filter((t) => t.id !== tabId).map((t) => t.id));
|
|
setTimeout(() => closeOtherTabs(tabId), CLOSE_ANIM_MS);
|
|
},
|
|
[tabs, closeOtherTabs, markClosing],
|
|
);
|
|
const handleCloseTabsToLeft = useCallback(
|
|
(tabId: string) => {
|
|
const idx = tabs.findIndex((t) => t.id === tabId);
|
|
if (idx <= 0) return;
|
|
markClosing(tabs.slice(0, idx).map((t) => t.id));
|
|
setTimeout(() => closeTabsToLeft(tabId), CLOSE_ANIM_MS);
|
|
},
|
|
[tabs, closeTabsToLeft, markClosing],
|
|
);
|
|
const handleCloseTabsToRight = useCallback(
|
|
(tabId: string) => {
|
|
const idx = tabs.findIndex((t) => t.id === tabId);
|
|
if (idx === -1 || idx >= tabs.length - 1) return;
|
|
markClosing(tabs.slice(idx + 1).map((t) => t.id));
|
|
setTimeout(() => closeTabsToRight(tabId), CLOSE_ANIM_MS);
|
|
},
|
|
[tabs, closeTabsToRight, markClosing],
|
|
);
|
|
const handleCloseAllTabs = useCallback(() => {
|
|
markClosing(tabs.map((t) => t.id));
|
|
setTimeout(() => closeAllTabs(), CLOSE_ANIM_MS);
|
|
}, [tabs, closeAllTabs, markClosing]);
|
|
|
|
// --- 드롭 고스트: 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]);
|
|
|
|
// --- Active 탭 underline indicator 위치 측정 (Chrome devtools 식 슬라이드) ---
|
|
useLayoutEffect(() => {
|
|
if (collapsed || !activeTabElRef.current || !containerRef.current) {
|
|
setIndicatorStyle((s) => (s.opacity === 0 ? s : { ...s, opacity: 0 }));
|
|
return;
|
|
}
|
|
const tabEl = activeTabElRef.current;
|
|
const left = tabEl.offsetLeft;
|
|
const width = tabEl.offsetWidth;
|
|
setIndicatorStyle((s) =>
|
|
s.left === left && s.width === width && s.opacity === 1 ? s : { left, width, opacity: 1 },
|
|
);
|
|
}, [activeTabId, displayVisible, collapsed, tabs.length]);
|
|
|
|
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, screen_id: screens[0].screen_id, menu_objid: numericObjid },
|
|
insertIndex,
|
|
);
|
|
return;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (url && url !== "#") {
|
|
openTab({ type: "admin", title: menuName, admin_url: 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 isClosing = closingIds.has(tab.id);
|
|
const animStyle = getTabAnimStyle(tab.id, displayIndex);
|
|
const hiddenByGhost =
|
|
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
|
|
|
|
return (
|
|
<div
|
|
key={tab.id}
|
|
ref={isActive ? (el) => { activeTabElRef.current = el; } : undefined}
|
|
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onLostPointerCapture={handleLostPointerCapture}
|
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
|
className={cn(
|
|
"v5-tab group relative flex shrink-0 cursor-pointer items-center gap-1 px-3 select-none",
|
|
isActive && "on",
|
|
isClosing && "closing",
|
|
)}
|
|
style={{
|
|
width: TAB_WIDTH,
|
|
touchAction: "none",
|
|
...animStyle,
|
|
...(hiddenByGhost ? { opacity: 0 } : {}),
|
|
...(isActive ? {} : {}),
|
|
}}
|
|
title={tab.title}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{tab.title}</span>
|
|
|
|
<div className="flex shrink-0 items-center">
|
|
{isActive && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
refreshTab(tab.id);
|
|
}}
|
|
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
|
|
>
|
|
<RotateCw className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCloseTab(tab.id);
|
|
}}
|
|
className="v5-tab-x"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (tabs.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={containerRef}
|
|
className={`v5-tabs relative flex shrink-0 items-stretch gap-[1px] overflow-hidden ${collapsed ? "collapsed" : ""} ${modeTransition === "out" ? "fade-out" : modeTransition === "in" ? "fade-in" : ""}`}
|
|
onDragOver={handleBarDragOver}
|
|
onDragLeave={handleBarDragLeave}
|
|
onDrop={handleBarDrop}
|
|
>
|
|
{onToggleCollapse && (
|
|
<button className="v5-tab-toggle" onClick={onToggleCollapse} title={collapsed ? "탭 펼치기" : "탭 접기"}>
|
|
<ChevronUp className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
|
|
|
<div
|
|
className="v5-tab-indicator"
|
|
style={{
|
|
transform: `translateX(${indicatorStyle.left}px)`,
|
|
width: indicatorStyle.width,
|
|
opacity: indicatorStyle.opacity,
|
|
}}
|
|
/>
|
|
|
|
{hasOverflow && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
|
|
+{displayOverflow.length}
|
|
<ChevronDown className="h-2.5 w-2.5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
|
{displayOverflow.map((tab) => (
|
|
<DropdownMenuItem
|
|
key={tab.id}
|
|
onClick={() => switchTab(tab.id)}
|
|
className="flex items-center justify-between gap-2"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCloseTab(tab.id);
|
|
}}
|
|
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
|
|
{/* 탭 드래그 고스트 (내부 재정렬) */}
|
|
{ghostStyle && draggedTab && (
|
|
<div
|
|
style={ghostStyle}
|
|
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3"
|
|
>
|
|
<div className="flex h-full items-center">
|
|
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
|
|
{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 (
|
|
<div
|
|
ref={dropGhostRef}
|
|
style={{
|
|
position: "fixed",
|
|
left: targetX,
|
|
top: targetY,
|
|
width: TAB_WIDTH,
|
|
height: 28,
|
|
zIndex: 100,
|
|
pointerEvents: "none",
|
|
}}
|
|
className="border-border bg-background rounded-t-md border border-b-0 px-3"
|
|
>
|
|
<div className="flex h-full items-center">
|
|
<span className="truncate text-[11px] font-medium">{dropGhost.title}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 우클릭 컨텍스트 메뉴 */}
|
|
{contextMenu && (
|
|
<div
|
|
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
>
|
|
<ContextMenuItem
|
|
label="새로고침"
|
|
onClick={() => {
|
|
refreshTab(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<div className="bg-border my-1 h-px" />
|
|
<ContextMenuItem
|
|
label="왼쪽 탭 닫기"
|
|
onClick={() => {
|
|
handleCloseTabsToLeft(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<ContextMenuItem
|
|
label="오른쪽 탭 닫기"
|
|
onClick={() => {
|
|
handleCloseTabsToRight(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<ContextMenuItem
|
|
label="다른 탭 모두 닫기"
|
|
onClick={() => {
|
|
handleCloseOtherTabs(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<div className="bg-border my-1 h-px" />
|
|
<ContextMenuItem
|
|
label="모든 탭 닫기"
|
|
onClick={() => {
|
|
handleCloseAllTabs();
|
|
setContextMenu(null);
|
|
}}
|
|
destructive
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContextMenuItem({
|
|
label,
|
|
onClick,
|
|
destructive,
|
|
}: {
|
|
label: string;
|
|
onClick: () => void;
|
|
destructive?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
|
|
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|