feat: Enhance TabBar component with drag-and-drop functionality and drop ghost animation
- Added support for drag-and-drop functionality in the TabBar component, allowing users to reorder tabs seamlessly. - Introduced a drop ghost feature that visually represents the target position of a dragged tab, enhancing user experience during tab reordering. - Updated the timing for settling animations to improve responsiveness and visual feedback. - Refactored state management to accommodate new drag-and-drop logic, ensuring smooth interactions and animations. Made-with: Cursor
This commit is contained in:
parent
7989305963
commit
2647031ef7
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
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";
|
||||
|
|
@ -17,7 +17,8 @@ const TAB_GAP = 2;
|
|||
const TAB_UNIT = TAB_WIDTH + TAB_GAP;
|
||||
const OVERFLOW_BTN_WIDTH = 48;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const SETTLE_MS = 200;
|
||||
const SETTLE_MS = 70;
|
||||
const DROP_SETTLE_MS = 180;
|
||||
const BAR_PAD_X = 8;
|
||||
|
||||
interface DragState {
|
||||
|
|
@ -32,30 +33,98 @@ interface DragState {
|
|||
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,
|
||||
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 [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); };
|
||||
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;
|
||||
|
|
@ -71,7 +140,9 @@ export function TabBar() {
|
|||
return () => obs.disconnect();
|
||||
}, [recalcVisible]);
|
||||
|
||||
useEffect(() => { recalcVisible(); }, [tabs.length, recalcVisible]);
|
||||
useLayoutEffect(() => {
|
||||
recalcVisible();
|
||||
}, [tabs.length, recalcVisible]);
|
||||
|
||||
const visibleTabs = tabs.slice(0, visibleCount);
|
||||
const overflowTabs = tabs.slice(visibleCount);
|
||||
|
|
@ -94,8 +165,18 @@ export function TabBar() {
|
|||
// 사이드바 -> 탭 바 드롭 (네이티브 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,
|
||||
menuName: string,
|
||||
menuObjid: string | number,
|
||||
url: string,
|
||||
insertIndex?: number,
|
||||
) => {
|
||||
const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid;
|
||||
try {
|
||||
|
|
@ -107,45 +188,84 @@ export function TabBar() {
|
|||
);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} 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) {
|
||||
let idx = Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT);
|
||||
idx = Math.max(0, Math.min(idx, displayVisible.length));
|
||||
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)) {
|
||||
setExternalDragIdx(null);
|
||||
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;
|
||||
setExternalDragIdx(null);
|
||||
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 { /* ignore */ }
|
||||
} catch {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const menuData = e.dataTransfer.getData("application/tab-menu");
|
||||
if (menuData && menuData.length > 2) {
|
||||
try { openTab(JSON.parse(menuData), insertIdx); } catch { /* ignore */ }
|
||||
try {
|
||||
const parsed = JSON.parse(menuData);
|
||||
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
|
||||
setExternalDragIdx(null);
|
||||
openTab(parsed, insertIdx);
|
||||
} catch {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
} else {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -154,11 +274,9 @@ export function TabBar() {
|
|||
// ============================================================
|
||||
|
||||
const calcTarget = useCallback(
|
||||
(clientX: number): number => {
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (!bar) return 0;
|
||||
let idx = Math.round((clientX - bar.left - BAR_PAD_X) / TAB_UNIT);
|
||||
return Math.max(0, Math.min(idx, displayVisible.length - 1));
|
||||
(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],
|
||||
);
|
||||
|
|
@ -194,13 +312,20 @@ export function TabBar() {
|
|||
if (!dragState.activated) {
|
||||
if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return;
|
||||
setDragState((p) =>
|
||||
p ? { ...p, activated: true, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null,
|
||||
p
|
||||
? {
|
||||
...p,
|
||||
activated: true,
|
||||
currentX: clampedX,
|
||||
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState((p) =>
|
||||
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null,
|
||||
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
|
||||
);
|
||||
},
|
||||
[dragState, calcTarget],
|
||||
|
|
@ -211,7 +336,6 @@ export function TabBar() {
|
|||
if (!dragState || dragState.settling) return;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
// 임계값 미달 → 클릭으로 처리
|
||||
if (!dragState.activated) {
|
||||
switchTab(dragState.tabId);
|
||||
setDragState(null);
|
||||
|
|
@ -220,16 +344,13 @@ export function TabBar() {
|
|||
|
||||
const { fromIndex, targetIndex, tabId } = dragState;
|
||||
|
||||
// settling 시작: 고스트가 목표(또는 원래) 슬롯으로 부드럽게 복귀
|
||||
setDragState((p) => (p ? { ...p, settling: true } : null));
|
||||
|
||||
if (targetIndex === fromIndex) {
|
||||
// 이동 없음: 고스트가 원래 위치로 애니메이션 후 정리
|
||||
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 30);
|
||||
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 배열 인덱스 계산 (setTimeout 전에 캡처)
|
||||
const actualFrom = tabs.findIndex((t) => t.id === tabId);
|
||||
const tgtTab = displayVisible[targetIndex];
|
||||
const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom;
|
||||
|
|
@ -239,7 +360,7 @@ export function TabBar() {
|
|||
if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) {
|
||||
updateTabOrder(actualFrom, actualTo);
|
||||
}
|
||||
}, SETTLE_MS + 30);
|
||||
}, SETTLE_MS + 10);
|
||||
},
|
||||
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
|
||||
);
|
||||
|
|
@ -249,15 +370,13 @@ export function TabBar() {
|
|||
// ============================================================
|
||||
|
||||
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
|
||||
// 사이드바 드래그 호버: 삽입 지점 이후 탭이 오른쪽으로 shift
|
||||
if (externalDragIdx !== null && !dragState) {
|
||||
return {
|
||||
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
|
||||
transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
|
||||
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;
|
||||
|
|
@ -298,7 +417,9 @@ export function TabBar() {
|
|||
return {
|
||||
...base,
|
||||
left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT,
|
||||
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
|
||||
opacity: 1,
|
||||
boxShadow: "none",
|
||||
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +462,8 @@ export function TabBar() {
|
|||
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
|
||||
|
|
@ -352,10 +475,15 @@ export function TabBar() {
|
|||
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 bg-white text-foreground z-10 mb-[-1px] h-[30px]"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
? "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 }}
|
||||
style={{
|
||||
width: TAB_WIDTH,
|
||||
touchAction: "none",
|
||||
...animStyle,
|
||||
...(hiddenByGhost ? { opacity: 0 } : {}),
|
||||
}}
|
||||
title={tab.title}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
|
||||
|
|
@ -363,14 +491,20 @@ export function TabBar() {
|
|||
<div className="flex shrink-0 items-center">
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); refreshTab(tab.id); }}
|
||||
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); }}
|
||||
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",
|
||||
|
|
@ -407,10 +541,17 @@ export function TabBar() {
|
|||
</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">
|
||||
<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); }}
|
||||
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" />
|
||||
|
|
@ -422,7 +563,7 @@ export function TabBar() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 드래그 고스트 */}
|
||||
{/* 탭 드래그 고스트 (내부 재정렬) */}
|
||||
{ghostStyle && draggedTab && (
|
||||
<div
|
||||
style={ghostStyle}
|
||||
|
|
@ -434,26 +575,95 @@ export function TabBar() {
|
|||
</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); }} />
|
||||
<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); }} />
|
||||
<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 />
|
||||
<ContextMenuItem
|
||||
label="모든 탭 닫기"
|
||||
onClick={() => {
|
||||
closeAllTabs();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) {
|
||||
function ContextMenuItem({
|
||||
label,
|
||||
onClick,
|
||||
destructive,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
|
|
|
|||
Loading…
Reference in New Issue