From 2647031ef75b43532fc0def07970038f71da498f Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 3 Mar 2026 16:43:56 +0900 Subject: [PATCH] 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 --- frontend/components/layout/TabBar.tsx | 304 ++++++++++++++++++++++---- 1 file changed, 257 insertions(+), 47 deletions(-) diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx index f2616173..8ef3d0b1 100644 --- a/frontend/components/layout/TabBar.tsx +++ b/frontend/components/layout/TabBar.tsx @@ -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(null); const settleTimer = useRef | null>(null); const dragActiveRef = useRef(false); + const dragLeaveTimerRef = useRef | null>(null); + const dropGhostRef = useRef(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(null); const [externalDragIdx, setExternalDragIdx] = useState(null); + const [dropGhost, setDropGhost] = useState(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 (
{tab.title} @@ -363,14 +491,20 @@ export function TabBar() {
{isActive && ( )}
- {/* 드래그 고스트 */} + {/* 탭 드래그 고스트 (내부 재정렬) */} {ghostStyle && draggedTab && (
)} + {/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */} + {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 ( +
+
+ {dropGhost.title} +
+
+ ); + })()} + {/* 우클릭 컨텍스트 메뉴 */} {contextMenu && (
- { refreshTab(contextMenu.tabId); setContextMenu(null); }} /> + { + refreshTab(contextMenu.tabId); + setContextMenu(null); + }} + />
- { closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} /> - { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} /> - { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} /> + { + closeTabsToLeft(contextMenu.tabId); + setContextMenu(null); + }} + /> + { + closeTabsToRight(contextMenu.tabId); + setContextMenu(null); + }} + /> + { + closeOtherTabs(contextMenu.tabId); + setContextMenu(null); + }} + />
- { closeAllTabs(); setContextMenu(null); }} destructive /> + { + closeAllTabs(); + setContextMenu(null); + }} + destructive + />
)} ); } -function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) { +function ContextMenuItem({ + label, + onClick, + destructive, +}: { + label: string; + onClick: () => void; + destructive?: boolean; +}) { return (