2026-02-27 14:25:53 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
|
2026-03-03 10:23:07 +09:00
|
|
|
import { X, RotateCw, ChevronDown } from "lucide-react";
|
2026-02-27 14:25:53 +09:00
|
|
|
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;
|
2026-03-03 16:43:56 +09:00
|
|
|
const SETTLE_MS = 70;
|
|
|
|
|
const DROP_SETTLE_MS = 180;
|
2026-02-27 14:25:53 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
interface DropGhost {
|
|
|
|
|
title: string;
|
|
|
|
|
startX: number;
|
|
|
|
|
startY: number;
|
|
|
|
|
targetIdx: number;
|
|
|
|
|
tabCountAtCreation: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
export function TabBar() {
|
|
|
|
|
const tabs = useTabStore(selectTabs);
|
|
|
|
|
const activeTabId = useTabStore(selectActiveTabId);
|
|
|
|
|
const {
|
2026-03-03 16:43:56 +09:00
|
|
|
switchTab,
|
|
|
|
|
closeTab,
|
|
|
|
|
refreshTab,
|
|
|
|
|
closeOtherTabs,
|
|
|
|
|
closeTabsToLeft,
|
|
|
|
|
closeTabsToRight,
|
|
|
|
|
closeAllTabs,
|
|
|
|
|
updateTabOrder,
|
|
|
|
|
openTab,
|
2026-02-27 14:25:53 +09:00
|
|
|
} = useTabStore();
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
// --- Refs ---
|
2026-02-27 14:25:53 +09:00
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const dragActiveRef = useRef(false);
|
2026-03-03 16:43:56 +09:00
|
|
|
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const dropGhostRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const prevTabCountRef = useRef(tabs.length);
|
2026-02-27 14:25:53 +09:00
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
// --- State ---
|
2026-02-27 14:25:53 +09:00
|
|
|
const [visibleCount, setVisibleCount] = useState(tabs.length);
|
2026-03-03 16:43:56 +09:00
|
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
tabId: string;
|
|
|
|
|
} | null>(null);
|
2026-02-27 14:25:53 +09:00
|
|
|
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
|
|
|
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
|
2026-03-03 16:43:56 +09:00
|
|
|
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
|
2026-02-27 14:25:53 +09:00
|
|
|
|
|
|
|
|
dragActiveRef.current = !!dragState;
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
// --- 타이머 정리 ---
|
2026-02-27 14:25:53 +09:00
|
|
|
useEffect(() => {
|
2026-03-03 16:43:56 +09:00
|
|
|
return () => {
|
|
|
|
|
if (settleTimer.current) clearTimeout(settleTimer.current);
|
|
|
|
|
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
|
|
|
|
|
};
|
2026-02-27 14:25:53 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
// --- 드롭 고스트: 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]);
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
// --- 오버플로우 계산 (드래그 중 재계산 방지) ---
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
recalcVisible();
|
|
|
|
|
}, [tabs.length, recalcVisible]);
|
2026-02-27 14:25:53 +09:00
|
|
|
|
|
|
|
|
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 + 삽입 위치 애니메이션)
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) {
|
|
|
|
|
setExternalDragIdx(null);
|
|
|
|
|
}
|
|
|
|
|
prevTabCountRef.current = tabs.length;
|
|
|
|
|
}, [tabs.length, externalDragIdx]);
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
const resolveMenuAndOpenTab = async (
|
2026-03-03 16:43:56 +09:00
|
|
|
menuName: string,
|
|
|
|
|
menuObjid: string | number,
|
|
|
|
|
url: string,
|
|
|
|
|
insertIndex?: number,
|
2026-02-27 14:25:53 +09:00
|
|
|
) => {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-03 16:43:56 +09:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-02-27 14:25:53 +09:00
|
|
|
if (url && url !== "#") {
|
|
|
|
|
openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex);
|
2026-03-03 16:43:56 +09:00
|
|
|
} else {
|
|
|
|
|
setExternalDragIdx(null);
|
2026-02-27 14:25:53 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBarDragOver = (e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.dataTransfer.dropEffect = "copy";
|
2026-03-03 16:43:56 +09:00
|
|
|
if (dragLeaveTimerRef.current) {
|
|
|
|
|
clearTimeout(dragLeaveTimerRef.current);
|
|
|
|
|
dragLeaveTimerRef.current = null;
|
|
|
|
|
}
|
2026-02-27 14:25:53 +09:00
|
|
|
const bar = containerRef.current?.getBoundingClientRect();
|
|
|
|
|
if (bar) {
|
2026-03-03 16:43:56 +09:00
|
|
|
const idx = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length),
|
|
|
|
|
);
|
2026-02-27 14:25:53 +09:00
|
|
|
setExternalDragIdx(idx);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBarDragLeave = (e: React.DragEvent) => {
|
|
|
|
|
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
2026-03-03 16:43:56 +09:00
|
|
|
dragLeaveTimerRef.current = setTimeout(() => {
|
|
|
|
|
setExternalDragIdx(null);
|
|
|
|
|
dragLeaveTimerRef.current = null;
|
|
|
|
|
}, 50);
|
2026-02-27 14:25:53 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
const handleBarDrop = (e: React.DragEvent) => {
|
|
|
|
|
e.preventDefault();
|
2026-03-03 16:43:56 +09:00
|
|
|
if (dragLeaveTimerRef.current) {
|
|
|
|
|
clearTimeout(dragLeaveTimerRef.current);
|
|
|
|
|
dragLeaveTimerRef.current = null;
|
|
|
|
|
}
|
2026-02-27 14:25:53 +09:00
|
|
|
const insertIdx = externalDragIdx ?? undefined;
|
2026-03-03 16:43:56 +09:00
|
|
|
const ghostIdx = insertIdx ?? displayVisible.length;
|
2026-02-27 14:25:53 +09:00
|
|
|
|
|
|
|
|
const pending = e.dataTransfer.getData("application/tab-menu-pending");
|
|
|
|
|
if (pending) {
|
|
|
|
|
try {
|
|
|
|
|
const { menuName, menuObjid, url } = JSON.parse(pending);
|
2026-03-03 16:43:56 +09:00
|
|
|
createDropGhost(e, menuName, ghostIdx);
|
2026-02-27 14:25:53 +09:00
|
|
|
resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx);
|
2026-03-03 16:43:56 +09:00
|
|
|
} catch {
|
|
|
|
|
setExternalDragIdx(null);
|
|
|
|
|
}
|
2026-02-27 14:25:53 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const menuData = e.dataTransfer.getData("application/tab-menu");
|
|
|
|
|
if (menuData && menuData.length > 2) {
|
2026-03-03 16:43:56 +09:00
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(menuData);
|
|
|
|
|
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
|
|
|
|
|
setExternalDragIdx(null);
|
|
|
|
|
openTab(parsed, insertIdx);
|
|
|
|
|
} catch {
|
|
|
|
|
setExternalDragIdx(null);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setExternalDragIdx(null);
|
2026-02-27 14:25:53 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
const calcTarget = useCallback(
|
2026-03-03 16:43:56 +09:00
|
|
|
(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));
|
2026-02-27 14:25:53 +09:00
|
|
|
},
|
|
|
|
|
[displayVisible.length],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => {
|
|
|
|
|
if ((e.target as HTMLElement).closest("button")) return;
|
|
|
|
|
if (dragState?.settling) return;
|
|
|
|
|
|
2026-03-03 17:07:04 +09:00
|
|
|
if (settleTimer.current) {
|
|
|
|
|
clearTimeout(settleTimer.current);
|
|
|
|
|
settleTimer.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
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;
|
2026-03-03 17:07:04 +09:00
|
|
|
if (e.pointerId !== dragState.pointerId) return;
|
2026-02-27 14:25:53 +09:00
|
|
|
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) =>
|
2026-03-03 16:43:56 +09:00
|
|
|
p
|
|
|
|
|
? {
|
|
|
|
|
...p,
|
|
|
|
|
activated: true,
|
|
|
|
|
currentX: clampedX,
|
|
|
|
|
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
|
|
|
|
|
}
|
|
|
|
|
: null,
|
2026-02-27 14:25:53 +09:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDragState((p) =>
|
2026-03-03 16:43:56 +09:00
|
|
|
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
|
2026-02-27 14:25:53 +09:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[dragState, calcTarget],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handlePointerUp = useCallback(
|
|
|
|
|
(e: React.PointerEvent) => {
|
|
|
|
|
if (!dragState || dragState.settling) return;
|
2026-03-03 17:07:04 +09:00
|
|
|
if (e.pointerId !== dragState.pointerId) return;
|
2026-02-27 14:25:53 +09:00
|
|
|
(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) {
|
2026-03-03 16:43:56 +09:00
|
|
|
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
|
2026-02-27 14:25:53 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2026-03-03 16:43:56 +09:00
|
|
|
}, SETTLE_MS + 10);
|
2026-02-27 14:25:53 +09:00
|
|
|
},
|
|
|
|
|
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-03 17:07:04 +09:00
|
|
|
const handleLostPointerCapture = useCallback(() => {
|
|
|
|
|
if (dragState && !dragState.settling) {
|
|
|
|
|
setDragState(null);
|
|
|
|
|
if (settleTimer.current) {
|
|
|
|
|
clearTimeout(settleTimer.current);
|
|
|
|
|
settleTimer.current = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [dragState]);
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
// ============================================================
|
|
|
|
|
// 스타일 계산
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
|
|
|
|
|
if (externalDragIdx !== null && !dragState) {
|
|
|
|
|
return {
|
|
|
|
|
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
|
2026-03-03 16:43:56 +09:00
|
|
|
transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
|
2026-02-27 14:25:53 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-03 16:43:56 +09:00
|
|
|
opacity: 1,
|
|
|
|
|
boxShadow: "none",
|
|
|
|
|
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
|
2026-02-27 14:25:53 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-03 16:43:56 +09:00
|
|
|
const hiddenByGhost =
|
|
|
|
|
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
|
2026-02-27 14:25:53 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={tab.id}
|
|
|
|
|
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
|
|
|
|
|
onPointerMove={handlePointerMove}
|
|
|
|
|
onPointerUp={handlePointerUp}
|
2026-03-03 17:07:04 +09:00
|
|
|
onLostPointerCapture={handleLostPointerCapture}
|
2026-02-27 14:25:53 +09:00
|
|
|
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
|
|
|
|
className={cn(
|
2026-03-03 10:23:07 +09:00
|
|
|
"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",
|
2026-02-27 14:25:53 +09:00
|
|
|
isActive
|
2026-03-03 16:43:56 +09:00
|
|
|
? "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",
|
2026-02-27 14:25:53 +09:00
|
|
|
)}
|
2026-03-03 16:43:56 +09:00
|
|
|
style={{
|
|
|
|
|
width: TAB_WIDTH,
|
|
|
|
|
touchAction: "none",
|
|
|
|
|
...animStyle,
|
|
|
|
|
...(hiddenByGhost ? { opacity: 0 } : {}),
|
|
|
|
|
}}
|
2026-02-27 14:25:53 +09:00
|
|
|
title={tab.title}
|
|
|
|
|
>
|
2026-03-03 10:23:07 +09:00
|
|
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
|
2026-02-27 14:25:53 +09:00
|
|
|
|
2026-03-03 10:23:07 +09:00
|
|
|
<div className="flex shrink-0 items-center">
|
2026-02-27 14:25:53 +09:00
|
|
|
{isActive && (
|
|
|
|
|
<button
|
2026-03-03 16:43:56 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
refreshTab(tab.id);
|
|
|
|
|
}}
|
2026-03-03 10:23:07 +09:00
|
|
|
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
|
2026-02-27 14:25:53 +09:00
|
|
|
>
|
2026-03-03 10:23:07 +09:00
|
|
|
<RotateCw className="h-2.5 w-2.5" />
|
2026-02-27 14:25:53 +09:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
2026-03-03 16:43:56 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
closeTab(tab.id);
|
|
|
|
|
}}
|
2026-02-27 14:25:53 +09:00
|
|
|
className={cn(
|
2026-03-03 10:23:07 +09:00
|
|
|
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
|
2026-02-27 14:25:53 +09:00
|
|
|
!isActive && "opacity-0 group-hover:opacity-100",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-03-03 10:23:07 +09:00
|
|
|
<X className="h-2.5 w-2.5" />
|
2026-02-27 14:25:53 +09:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (tabs.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
2026-03-03 10:23:07 +09:00
|
|
|
className="border-border bg-muted/30 relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
2026-02-27 14:25:53 +09:00
|
|
|
onDragOver={handleBarDragOver}
|
|
|
|
|
onDragLeave={handleBarDragLeave}
|
|
|
|
|
onDrop={handleBarDrop}
|
|
|
|
|
>
|
2026-03-03 10:23:07 +09:00
|
|
|
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
2026-02-27 14:25:53 +09:00
|
|
|
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
|
|
|
|
|
|
|
|
|
{hasOverflow && (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
2026-03-03 10:23:07 +09:00
|
|
|
<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">
|
2026-02-27 14:25:53 +09:00
|
|
|
+{displayOverflow.length}
|
2026-03-03 10:23:07 +09:00
|
|
|
<ChevronDown className="h-2.5 w-2.5" />
|
2026-02-27 14:25:53 +09:00
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
|
|
|
|
{displayOverflow.map((tab) => (
|
2026-03-03 16:43:56 +09:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={tab.id}
|
|
|
|
|
onClick={() => switchTab(tab.id)}
|
|
|
|
|
className="flex items-center justify-between gap-2"
|
|
|
|
|
>
|
2026-02-27 14:25:53 +09:00
|
|
|
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
|
|
|
|
|
<button
|
2026-03-03 16:43:56 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
closeTab(tab.id);
|
|
|
|
|
}}
|
2026-02-27 14:25:53 +09:00
|
|
|
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>
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
{/* 탭 드래그 고스트 (내부 재정렬) */}
|
2026-02-27 14:25:53 +09:00
|
|
|
{ghostStyle && draggedTab && (
|
|
|
|
|
<div
|
|
|
|
|
style={ghostStyle}
|
2026-03-03 10:23:07 +09:00
|
|
|
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3 shadow-lg"
|
2026-02-27 14:25:53 +09:00
|
|
|
>
|
|
|
|
|
<div className="flex h-full items-center">
|
2026-03-03 10:23:07 +09:00
|
|
|
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
|
2026-02-27 14:25:53 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
2026-02-27 14:25:53 +09:00
|
|
|
{/* 우클릭 컨텍스트 메뉴 */}
|
|
|
|
|
{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 }}
|
|
|
|
|
>
|
2026-03-03 16:43:56 +09:00
|
|
|
<ContextMenuItem
|
|
|
|
|
label="새로고침"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
refreshTab(contextMenu.tabId);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-27 14:25:53 +09:00
|
|
|
<div className="bg-border my-1 h-px" />
|
2026-03-03 16:43:56 +09:00
|
|
|
<ContextMenuItem
|
|
|
|
|
label="왼쪽 탭 닫기"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
closeTabsToLeft(contextMenu.tabId);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<ContextMenuItem
|
|
|
|
|
label="오른쪽 탭 닫기"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
closeTabsToRight(contextMenu.tabId);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<ContextMenuItem
|
|
|
|
|
label="다른 탭 모두 닫기"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
closeOtherTabs(contextMenu.tabId);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-27 14:25:53 +09:00
|
|
|
<div className="bg-border my-1 h-px" />
|
2026-03-03 16:43:56 +09:00
|
|
|
<ContextMenuItem
|
|
|
|
|
label="모든 탭 닫기"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
closeAllTabs();
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
destructive
|
|
|
|
|
/>
|
2026-02-27 14:25:53 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 16:43:56 +09:00
|
|
|
function ContextMenuItem({
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
|
|
|
|
destructive,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
destructive?: boolean;
|
|
|
|
|
}) {
|
2026-02-27 14:25:53 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|