"use client"; import React, { useState, useRef, useCallback, useEffect } from "react"; import { ComponentRegistry } from "../../ComponentRegistry"; import { ComponentCategory } from "@/types/component"; import { Folder, Plus, Move, Settings, Trash2 } from "lucide-react"; import type { TabItem, TabInlineComponent } from "@/types/screen-management"; import { cn } from "@/lib/utils"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; // 디자인 모드용 탭 에디터 컴포넌트 const TabsDesignEditor: React.FC<{ component: any; tabs: TabItem[]; onUpdateComponent?: (updatedComponent: any) => void; onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void; selectedTabComponentId?: string; }> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => { const [activeTabId, setActiveTabId] = useState(tabs[0]?.id || ""); const [draggingCompId, setDraggingCompId] = useState(null); const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); const containerRef = useRef(null); const rafRef = useRef(null); // 리사이즈 상태 const [resizingCompId, setResizingCompId] = useState(null); const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); const [lastResizedCompId, setLastResizedCompId] = useState(null); const activeTab = tabs.find((t) => t.id === activeTabId); // 🆕 탭 컴포넌트 size가 업데이트되면 resizeSize 초기화 useEffect(() => { if (resizeSize && lastResizedCompId && !resizingCompId) { const targetComp = activeTab?.components?.find(c => c.id === lastResizedCompId); if (targetComp && targetComp.size?.width === resizeSize.width && targetComp.size?.height === resizeSize.height) { setResizeSize(null); setLastResizedCompId(null); } } }, [tabs, activeTab, resizeSize, lastResizedCompId, resizingCompId]); const getTabStyle = (tab: TabItem) => { const isActive = tab.id === activeTabId; return cn( "px-4 py-2 text-sm font-medium cursor-pointer transition-colors", isActive ? "bg-background border-b-2 border-primary text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" ); }; // 컴포넌트 삭제 const handleDeleteComponent = useCallback( (compId: string) => { if (!onUpdateComponent) return; const updatedTabs = tabs.map((tab) => { if (tab.id === activeTabId) { return { ...tab, components: (tab.components || []).filter((c) => c.id !== compId), }; } return tab; }); onUpdateComponent({ ...component, componentConfig: { ...component.componentConfig, tabs: updatedTabs, }, }); }, [activeTabId, component, onUpdateComponent, tabs] ); // 컴포넌트 드래그 시작 const handleDragStart = useCallback( (e: React.MouseEvent, comp: TabInlineComponent) => { e.stopPropagation(); e.preventDefault(); // 드래그 시작 시 마우스 위치와 컴포넌트의 현재 위치 저장 const startMouseX = e.clientX; const startMouseY = e.clientY; const startLeft = comp.position?.x || 0; const startTop = comp.position?.y || 0; setDraggingCompId(comp.id); setDragPosition({ x: startLeft, y: startTop }); const handleMouseMove = (moveEvent: MouseEvent) => { // requestAnimationFrame으로 성능 최적화 if (rafRef.current) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { // 마우스 이동량 계산 const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; // 새 위치 = 시작 위치 + 이동량 const newX = Math.max(0, startLeft + deltaX); const newY = Math.max(0, startTop + deltaY); // React 상태로 위치 업데이트 (리렌더링 트리거) setDragPosition({ x: newX, y: newY }); }); }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } // 마우스 이동량 계산 const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; // 새 위치 = 시작 위치 + 이동량 const newX = Math.max(0, startLeft + deltaX); const newY = Math.max(0, startTop + deltaY); setDraggingCompId(null); setDragPosition(null); // 탭 컴포넌트 위치 업데이트 if (onUpdateComponent) { const updatedTabs = tabs.map((tab) => { if (tab.id === activeTabId) { return { ...tab, components: (tab.components || []).map((c) => c.id === comp.id ? { ...c, position: { x: Math.max(0, Math.round(newX)), y: Math.max(0, Math.round(newY)), }, } : c ), }; } return tab; }); onUpdateComponent({ ...component, componentConfig: { ...component.componentConfig, tabs: updatedTabs, }, }); } document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); setDraggingCompId(null); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [activeTabId, component, onUpdateComponent, tabs] ); // 10px 단위 스냅 함수 const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []); // 리사이즈 시작 핸들러 const handleResizeStart = useCallback( (e: React.MouseEvent, comp: TabInlineComponent, direction: "e" | "s" | "se") => { e.stopPropagation(); e.preventDefault(); const startMouseX = e.clientX; const startMouseY = e.clientY; const startWidth = comp.size?.width || 200; const startHeight = comp.size?.height || 100; setResizingCompId(comp.id); setResizeSize({ width: startWidth, height: startHeight }); const handleMouseMove = (moveEvent: MouseEvent) => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); } rafRef.current = requestAnimationFrame(() => { const deltaX = moveEvent.clientX - startMouseX; const deltaY = moveEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(20, startHeight + deltaY)); } setResizeSize({ width: newWidth, height: newHeight }); }); }; const handleMouseUp = (upEvent: MouseEvent) => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } const deltaX = upEvent.clientX - startMouseX; const deltaY = upEvent.clientY - startMouseY; let newWidth = startWidth; let newHeight = startHeight; if (direction === "e" || direction === "se") { newWidth = snapTo10(Math.max(50, startWidth + deltaX)); } if (direction === "s" || direction === "se") { newHeight = snapTo10(Math.max(20, startHeight + deltaY)); } // 🆕 탭 컴포넌트 크기 업데이트 먼저 실행 if (onUpdateComponent) { const updatedTabs = tabs.map((tab) => { if (tab.id === activeTabId) { return { ...tab, components: (tab.components || []).map((c) => c.id === comp.id ? { ...c, size: { width: newWidth, height: newHeight, }, } : c ), }; } return tab; }); onUpdateComponent({ ...component, componentConfig: { ...component.componentConfig, tabs: updatedTabs, }, }); } // 🆕 리사이즈 상태 해제 (resizeSize는 마지막 크기 유지, lastResizedCompId 설정) setLastResizedCompId(comp.id); setResizingCompId(null); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, [activeTabId, component, onUpdateComponent, tabs] ); return (
{/* 탭 헤더 */}
{tabs.length > 0 ? ( tabs.map((tab) => (
{ e.stopPropagation(); setActiveTabId(tab.id); onSelectTabComponent?.(null); }} > {tab.label || "탭"} {tab.components && tab.components.length > 0 && ( ({tab.components.length}) )}
)) ) : (
탭이 없습니다
)}
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)} > {activeTab ? (
{activeTab.components && activeTab.components.length > 0 ? (
{activeTab.components.map((comp: TabInlineComponent) => { const isSelected = selectedTabComponentId === comp.id; const isDragging = draggingCompId === comp.id; const isResizing = resizingCompId === comp.id; // 드래그/리사이즈 중 표시할 크기 // resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지) const compWidth = comp.size?.width || 200; const compHeight = comp.size?.height || 100; const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize; const displayWidth = isResizingThis ? resizeSize!.width : compWidth; const displayHeight = isResizingThis ? resizeSize!.height : compHeight; // 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환 const componentData = { id: comp.id, type: "component" as const, componentType: comp.componentType, label: comp.label, position: comp.position || { x: 0, y: 0 }, size: { width: displayWidth, height: displayHeight }, componentConfig: comp.componentConfig || {}, style: comp.style || {}, }; // 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용 const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0); const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0); return (
{ e.stopPropagation(); console.log("🔍 [탭 컴포넌트] 클릭:", { activeTabId, compId: comp.id, hasOnSelectTabComponent: !!onSelectTabComponent }); onSelectTabComponent?.(activeTabId, comp.id, comp); }} > {/* 드래그 핸들 - 컴포넌트 외부 상단 */}
handleDragStart(e, comp)} >
{comp.label || comp.componentType}
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} {isSelected && ( <> {/* 오른쪽 가장자리 (너비 조절) */}
handleResizeStart(e, comp, "e")} /> {/* 아래 가장자리 (높이 조절) */}
handleResizeStart(e, comp, "s")} /> {/* 오른쪽 아래 모서리 (너비+높이 조절) */}
handleResizeStart(e, comp, "se")} /> )}
); })}
) : (

컴포넌트를 드래그하여 추가

좌측 패널에서 컴포넌트를 이 영역에 드롭하세요

)}
) : (

설정 패널에서 탭을 추가하세요

)}
); }; // TabsWidget 래퍼 컴포넌트 const TabsWidgetWrapper: React.FC = (props) => { const { component, isDesignMode, onUpdateComponent, onSelectTabComponent, selectedTabComponentId, ...restProps } = props; // componentConfig에서 탭 정보 추출 const tabsConfig = component.componentConfig || {}; const tabs: TabItem[] = tabsConfig.tabs || []; // 🎯 디자인 모드에서는 드롭 가능한 에디터 UI 렌더링 if (isDesignMode) { return ( ); } // 실행 모드에서는 TabsWidget 렌더링 const tabsComponent = { ...component, type: "tabs" as const, tabs: tabs, defaultTab: tabsConfig.defaultTab, orientation: tabsConfig.orientation || "horizontal", variant: tabsConfig.variant || "default", allowCloseable: tabsConfig.allowCloseable || false, persistSelection: tabsConfig.persistSelection || false, }; const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; return (
); }; /** * 탭 컴포넌트 정의 * * 탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트 */ ComponentRegistry.registerComponent({ id: "v2-tabs-widget", name: "탭 컴포넌트", description: "탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트입니다.", category: ComponentCategory.LAYOUT, webType: "text" as any, component: TabsWidgetWrapper, defaultConfig: { tabs: [ { id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [], }, { id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [], }, ], defaultTab: "tab-1", orientation: "horizontal", variant: "default", allowCloseable: false, persistSelection: false, }, tags: ["tabs", "navigation", "layout", "container"], icon: Folder, version: "2.0.0", defaultSize: { width: 800, height: 600, }, defaultProps: { type: "tabs" as const, tabs: [ { id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [], }, { id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [], }, ] as TabItem[], defaultTab: "tab-1", orientation: "horizontal" as const, variant: "default" as const, allowCloseable: false, persistSelection: false, }, // 에디터 모드에서의 렌더링 - 탭 선택 및 컴포넌트 드롭 지원 renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, }) => { const tabsConfig = (component as any).componentConfig || {}; const tabs: TabItem[] = tabsConfig.tabs || []; // 에디터 모드에서 선택된 탭 상태 관리 const [activeTabId, setActiveTabId] = useState( tabs[0]?.id || "" ); const activeTab = tabs.find((t) => t.id === activeTabId); // 탭 스타일 클래스 const getTabStyle = (tab: TabItem) => { const isActive = tab.id === activeTabId; return cn( "px-4 py-2 text-sm font-medium cursor-pointer transition-colors", isActive ? "bg-background border-b-2 border-primary text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50" ); }; return (
{/* 탭 헤더 */}
{tabs.length > 0 ? ( tabs.map((tab) => (
{ e.stopPropagation(); setActiveTabId(tab.id); }} > {tab.label || "탭"} {tab.components && tab.components.length > 0 && ( ({tab.components.length}) )}
)) ) : (
탭이 없습니다
)}
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
{activeTab ? (
{activeTab.components && activeTab.components.length > 0 ? (
{activeTab.components.map((comp: TabInlineComponent) => (
{comp.label || comp.componentType} {comp.componentType}
))}
) : (

컴포넌트를 드래그하여 추가

좌측 패널에서 컴포넌트를 이 영역에 드롭하세요

)}
) : (

설정 패널에서 탭을 추가하세요

)}
{/* 선택 표시 */} {isSelected && (
)}
); }, // 인터랙티브 모드에서의 렌더링 renderInteractive: ({ component }) => { return null; }, // 설정 패널 configPanel: React.lazy(() => import("@/components/screen/config-panels/TabsConfigPanel").then( (module) => ({ default: module.TabsConfigPanel, }) ) ), // 검증 함수 validate: (component) => { const tabsConfig = (component as any).componentConfig || {}; const tabs: TabItem[] = tabsConfig.tabs || []; const errors: string[] = []; if (!tabs || tabs.length === 0) { errors.push("최소 1개 이상의 탭이 필요합니다."); } if (tabs) { const tabIds = tabs.map((t) => t.id); const uniqueIds = new Set(tabIds); if (tabIds.length !== uniqueIds.size) { errors.push("탭 ID가 중복되었습니다."); } } return { isValid: errors.length === 0, errors, }; }, });