766 lines
27 KiB
TypeScript
766 lines
27 KiB
TypeScript
"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<string>(tabs[0]?.id || "");
|
|
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
|
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
|
|
// 리사이즈 상태
|
|
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
|
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
|
const [lastResizedCompId, setLastResizedCompId] = useState<string | null>(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]
|
|
);
|
|
|
|
// 10px 단위 스냅 함수
|
|
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
|
|
|
|
// 컴포넌트 드래그 시작
|
|
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;
|
|
|
|
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
|
|
const newX = snapTo10(Math.max(0, startLeft + deltaX));
|
|
const newY = snapTo10(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;
|
|
|
|
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
|
|
const newX = snapTo10(Math.max(0, startLeft + deltaX));
|
|
const newY = snapTo10(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: newX,
|
|
y: 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, snapTo10]
|
|
);
|
|
|
|
// 리사이즈 시작 핸들러
|
|
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 (
|
|
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
|
|
{/* 탭 헤더 */}
|
|
<div className="flex items-center border-b bg-muted/30">
|
|
{tabs.length > 0 ? (
|
|
tabs.map((tab) => (
|
|
<div
|
|
key={tab.id}
|
|
className={getTabStyle(tab)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setActiveTabId(tab.id);
|
|
onSelectTabComponent?.(null);
|
|
}}
|
|
>
|
|
{tab.label || "탭"}
|
|
{tab.components && tab.components.length > 0 && (
|
|
<span className="ml-1 text-xs text-muted-foreground">
|
|
({tab.components.length})
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
|
탭이 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
|
|
<div
|
|
className="relative flex-1 overflow-hidden"
|
|
data-tabs-container="true"
|
|
data-component-id={component.id}
|
|
data-active-tab-id={activeTabId}
|
|
onClick={() => onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)}
|
|
>
|
|
{activeTab ? (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute inset-0 overflow-auto p-2"
|
|
>
|
|
{activeTab.components && activeTab.components.length > 0 ? (
|
|
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
|
|
{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 (
|
|
<div
|
|
key={comp.id}
|
|
data-tab-comp-id={comp.id}
|
|
className="absolute"
|
|
style={{
|
|
left: displayX,
|
|
top: displayY,
|
|
zIndex: isDragging ? 100 : isSelected ? 10 : 1,
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
console.log("🔍 [탭 컴포넌트] 클릭:", { activeTabId, compId: comp.id, hasOnSelectTabComponent: !!onSelectTabComponent });
|
|
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
|
}}
|
|
>
|
|
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
|
|
<div
|
|
className={cn(
|
|
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
|
|
isSelected ? "border-primary" : "border-gray-200"
|
|
)}
|
|
style={{ width: comp.size?.width || 200 }}
|
|
onMouseDown={(e) => handleDragStart(e, comp)}
|
|
>
|
|
<div className="flex items-center gap-0.5">
|
|
<Move className="h-2.5 w-2.5 text-gray-400" />
|
|
<span className="text-[9px] text-gray-500 truncate max-w-[100px]">
|
|
{comp.label || comp.componentType}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<button
|
|
className="rounded p-0.5 hover:bg-gray-200"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
|
}}
|
|
title="설정"
|
|
>
|
|
<Settings className="h-2.5 w-2.5 text-gray-500" />
|
|
</button>
|
|
<button
|
|
className="rounded p-0.5 hover:bg-red-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteComponent(comp.id);
|
|
}}
|
|
title="삭제"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5 text-red-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
|
|
<div
|
|
className={cn(
|
|
"relative rounded-b border bg-white shadow-sm overflow-hidden",
|
|
isSelected
|
|
? "border-primary ring-2 ring-primary/30"
|
|
: "border-gray-200",
|
|
(isDragging || isResizing) && "opacity-80 shadow-lg",
|
|
!(isDragging || isResizing) && "transition-all"
|
|
)}
|
|
style={{
|
|
width: displayWidth,
|
|
height: displayHeight,
|
|
}}
|
|
>
|
|
<div className="h-full w-full pointer-events-none">
|
|
<DynamicComponentRenderer
|
|
component={componentData as any}
|
|
isDesignMode={true}
|
|
formData={{}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
|
|
{isSelected && (
|
|
<>
|
|
{/* 오른쪽 가장자리 (너비 조절) */}
|
|
<div
|
|
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize pointer-events-auto z-10 hover:bg-primary/10"
|
|
onMouseDown={(e) => handleResizeStart(e, comp, "e")}
|
|
/>
|
|
{/* 아래 가장자리 (높이 조절) */}
|
|
<div
|
|
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize pointer-events-auto z-10 hover:bg-primary/10"
|
|
onMouseDown={(e) => handleResizeStart(e, comp, "s")}
|
|
/>
|
|
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
|
|
<div
|
|
className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize pointer-events-auto z-20 hover:bg-primary/20"
|
|
onMouseDown={(e) => handleResizeStart(e, comp, "se")}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
|
|
<Plus className="mb-2 h-8 w-8 text-gray-400" />
|
|
<p className="text-sm font-medium text-gray-500">
|
|
컴포넌트를 드래그하여 추가
|
|
</p>
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
좌측 패널에서 컴포넌트를 이 영역에 드롭하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
설정 패널에서 탭을 추가하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// TabsWidget 래퍼 컴포넌트
|
|
const TabsWidgetWrapper: React.FC<any> = (props) => {
|
|
const {
|
|
component,
|
|
isDesignMode,
|
|
onUpdateComponent,
|
|
onSelectTabComponent,
|
|
selectedTabComponentId,
|
|
...restProps
|
|
} = props;
|
|
|
|
// componentConfig에서 탭 정보 추출
|
|
const tabsConfig = component.componentConfig || {};
|
|
const tabs: TabItem[] = tabsConfig.tabs || [];
|
|
|
|
// 🎯 디자인 모드에서는 드롭 가능한 에디터 UI 렌더링
|
|
if (isDesignMode) {
|
|
return (
|
|
<TabsDesignEditor
|
|
component={component}
|
|
tabs={tabs}
|
|
onUpdateComponent={onUpdateComponent}
|
|
onSelectTabComponent={onSelectTabComponent}
|
|
selectedTabComponentId={selectedTabComponentId}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 실행 모드에서는 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 (
|
|
<div className="h-full w-full">
|
|
<TabsWidget component={tabsComponent} {...restProps} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 탭 컴포넌트 정의
|
|
*
|
|
* 탭별로 컴포넌트를 자유롭게 배치할 수 있는 레이아웃 컴포넌트
|
|
*/
|
|
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<string>(
|
|
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 (
|
|
<div
|
|
className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background"
|
|
onClick={onClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
{/* 탭 헤더 */}
|
|
<div className="flex items-center border-b bg-muted/30">
|
|
{tabs.length > 0 ? (
|
|
tabs.map((tab) => (
|
|
<div
|
|
key={tab.id}
|
|
className={getTabStyle(tab)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setActiveTabId(tab.id);
|
|
}}
|
|
>
|
|
{tab.label || "탭"}
|
|
{tab.components && tab.components.length > 0 && (
|
|
<span className="ml-1 text-xs text-muted-foreground">
|
|
({tab.components.length})
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
|
탭이 없습니다
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
|
|
<div
|
|
className="relative flex-1 overflow-hidden"
|
|
data-tabs-container="true"
|
|
data-component-id={component.id}
|
|
data-active-tab-id={activeTabId}
|
|
>
|
|
{activeTab ? (
|
|
<div className="absolute inset-0 overflow-auto p-2">
|
|
{activeTab.components && activeTab.components.length > 0 ? (
|
|
<div className="relative h-full w-full">
|
|
{activeTab.components.map((comp: TabInlineComponent) => (
|
|
<div
|
|
key={comp.id}
|
|
className="absolute rounded border border-dashed border-gray-300 bg-white/80 p-2 shadow-sm"
|
|
style={{
|
|
left: comp.position?.x || 0,
|
|
top: comp.position?.y || 0,
|
|
width: comp.size?.width || 200,
|
|
height: comp.size?.height || 100,
|
|
}}
|
|
>
|
|
<div className="flex h-full flex-col items-center justify-center">
|
|
<span className="text-xs font-medium text-gray-600">
|
|
{comp.label || comp.componentType}
|
|
</span>
|
|
<span className="text-[10px] text-gray-400">
|
|
{comp.componentType}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
|
|
<Plus className="mb-2 h-8 w-8 text-gray-400" />
|
|
<p className="text-sm font-medium text-gray-500">
|
|
컴포넌트를 드래그하여 추가
|
|
</p>
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
좌측 패널에서 컴포넌트를 이 영역에 드롭하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
설정 패널에서 탭을 추가하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 선택 표시 */}
|
|
{isSelected && (
|
|
<div className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-primary ring-offset-2" />
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
|
|
// 인터랙티브 모드에서의 렌더링
|
|
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,
|
|
};
|
|
},
|
|
});
|