194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
import { useTabStore } from "@/store/tab-store";
|
|
import { Tabs, TabsList, TabsTrigger } from "@common/components";
|
|
|
|
type TabContextMenuState = {
|
|
x: number;
|
|
y: number;
|
|
tabId: string;
|
|
};
|
|
|
|
const TabBar = () => {
|
|
const {
|
|
tabs,
|
|
activeTabId,
|
|
setActiveTab,
|
|
removeTab,
|
|
closeAllTabs,
|
|
closeOtherTabs,
|
|
closeTabsToLeft,
|
|
closeTabsToRight,
|
|
} = useTabStore();
|
|
const [contextMenu, setContextMenu] = useState<TabContextMenuState | null>(null);
|
|
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenu(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (contextMenuRef.current?.contains(event.target as Node)) return;
|
|
closeContextMenu();
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") closeContextMenu();
|
|
};
|
|
const onScroll = () => closeContextMenu();
|
|
|
|
window.addEventListener("pointerdown", onPointerDown);
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("scroll", onScroll, true);
|
|
|
|
return () => {
|
|
window.removeEventListener("pointerdown", onPointerDown);
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
};
|
|
}, [closeContextMenu, contextMenu]);
|
|
|
|
if (tabs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const handleCloseTab = (
|
|
e: React.MouseEvent | React.KeyboardEvent,
|
|
tabId: string,
|
|
) => {
|
|
e.stopPropagation();
|
|
removeTab(tabId);
|
|
};
|
|
|
|
const handleTabContextMenu = (
|
|
e: React.MouseEvent<HTMLButtonElement>,
|
|
tabId: string,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
|
};
|
|
|
|
const handleCloseOtherTabs = () => {
|
|
if (!contextMenu || tabs.length <= 1) return;
|
|
closeOtherTabs(contextMenu.tabId);
|
|
closeContextMenu();
|
|
};
|
|
|
|
const handleCloseAllTabs = () => {
|
|
closeAllTabs();
|
|
closeContextMenu();
|
|
};
|
|
|
|
const contextTabIndex = contextMenu
|
|
? tabs.findIndex((tab) => tab.id === contextMenu.tabId)
|
|
: -1;
|
|
const canCloseTabsToLeft = contextTabIndex > 0;
|
|
const canCloseTabsToRight =
|
|
contextTabIndex >= 0 && contextTabIndex < tabs.length - 1;
|
|
const canCloseOtherTabs = tabs.length > 1;
|
|
|
|
const handleCloseTabsToLeft = () => {
|
|
if (!contextMenu || !canCloseTabsToLeft) return;
|
|
closeTabsToLeft(contextMenu.tabId);
|
|
closeContextMenu();
|
|
};
|
|
|
|
const handleCloseTabsToRight = () => {
|
|
if (!contextMenu || !canCloseTabsToRight) return;
|
|
closeTabsToRight(contextMenu.tabId);
|
|
closeContextMenu();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Tabs
|
|
value={activeTabId || undefined}
|
|
onValueChange={setActiveTab}
|
|
className="w-full border-b border-border"
|
|
variant="outline"
|
|
>
|
|
<TabsList className="w-full !justify-start !p-4 !pb-0">
|
|
{tabs.map((tab) => (
|
|
<TabsTrigger
|
|
key={tab.id}
|
|
value={tab.id}
|
|
className="!flex !w-[140px] !items-center !justify-start !gap-2 !px-4 [&::after]:hidden"
|
|
title={tab.label}
|
|
onContextMenu={(e) => handleTabContextMenu(e, tab.id)}
|
|
>
|
|
<span className="flex-1 truncate text-xl leading-none">
|
|
{tab.label}
|
|
</span>
|
|
<span
|
|
role="button"
|
|
onClick={(e) => handleCloseTab(e, tab.id)}
|
|
aria-label={`${tab.label} 탭 닫기`}
|
|
className="flex flex-shrink-0 items-center justify-center leading-none hover:text-[#2094fa]"
|
|
>
|
|
<i className="ri-close-line" />
|
|
</span>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{contextMenu &&
|
|
typeof document !== "undefined" &&
|
|
createPortal(
|
|
<div
|
|
ref={contextMenuRef}
|
|
role="menu"
|
|
className="fixed z-[100] min-w-[20rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={!canCloseTabsToLeft}
|
|
className="flex w-full cursor-default select-none items-center rounded-sm px-4 py-3 text-left text-[1.2rem] outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={handleCloseTabsToLeft}
|
|
>
|
|
왼쪽 탭 모두 닫기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={!canCloseTabsToRight}
|
|
className="flex w-full cursor-default select-none items-center rounded-sm px-4 py-3 text-left text-[1.2rem] outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={handleCloseTabsToRight}
|
|
>
|
|
오른쪽 탭 모두 닫기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={!canCloseOtherTabs}
|
|
className="flex w-full cursor-default select-none items-center rounded-sm px-4 py-3 text-left text-[1.2rem] outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
onClick={handleCloseOtherTabs}
|
|
>
|
|
해당 탭 제외 모두 닫기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="flex w-full cursor-default select-none items-center rounded-sm px-4 py-3 text-left text-[1.2rem] outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
onClick={handleCloseAllTabs}
|
|
>
|
|
모든 탭 닫기
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export { TabBar };
|