- Removed unnecessary box shadow from active tab in TabBar for a cleaner look. - Updated TabBar background to use the main background color for better consistency. - Enhanced SaveModal to include validation for required fields, providing user feedback for missing inputs. - Removed unused master data loading function in EditModal to streamline the component. These changes improve the overall user interface and ensure that required fields are validated before submission, enhancing user experience.
699 lines
22 KiB
TypeScript
699 lines
22 KiB
TypeScript
"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<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);
|
|
|
|
// --- 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);
|
|
|
|
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 (
|
|
<div
|
|
key={tab.id}
|
|
onPointerDown={(e) => 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
|
|
? "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 } : {}),
|
|
...(isActive ? {} : {}),
|
|
}}
|
|
title={tab.title}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{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();
|
|
closeTab(tab.id);
|
|
}}
|
|
className={cn(
|
|
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
|
|
!isActive && "opacity-0 group-hover:opacity-100",
|
|
)}
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (tabs.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={containerRef}
|
|
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
|
onDragOver={handleBarDragOver}
|
|
onDragLeave={handleBarDragLeave}
|
|
onDrop={handleBarDrop}
|
|
>
|
|
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
|
<div className="pointer-events-none absolute inset-0 z-5" />
|
|
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
|
|
|
{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();
|
|
closeTab(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={() => {
|
|
closeTabsToLeft(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<ContextMenuItem
|
|
label="오른쪽 탭 닫기"
|
|
onClick={() => {
|
|
closeTabsToRight(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<ContextMenuItem
|
|
label="다른 탭 모두 닫기"
|
|
onClick={() => {
|
|
closeOtherTabs(contextMenu.tabId);
|
|
setContextMenu(null);
|
|
}}
|
|
/>
|
|
<div className="bg-border my-1 h-px" />
|
|
<ContextMenuItem
|
|
label="모든 탭 닫기"
|
|
onClick={() => {
|
|
closeAllTabs();
|
|
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>
|
|
);
|
|
}
|