드래그 앤 드롭 기능 개선 및 Unified 컴포넌트 매핑 추가: ScreenDesigner, TabsWidget, DynamicComponentRenderer에서 드래그 앤 드롭 시 컴포넌트의 위치와 크기를 최적화하고, Unified 컴포넌트에 대한 매핑 로직을 추가하여 사용자 경험을 향상시켰습니다. 또한, ButtonConfigPanel에서 컴포넌트가 없는 경우 방어 처리 로직을 추가하여 안정성을 높였습니다.

This commit is contained in:
kjs
2026-01-20 14:01:35 +09:00
parent 58d658e638
commit 8cdb8a3047
7 changed files with 335 additions and 128 deletions

View File

@@ -187,7 +187,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
...props
}) => {
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
const componentType = (component as any).componentType || component.type;
const rawComponentType = (component as any).componentType || component.type;
// 🆕 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
const mapToV2ComponentType = (type: string | undefined): string | undefined => {
if (!type) return type;
// 이미 v2- 또는 unified- 접두사가 있으면 그대로 반환
if (type.startsWith("v2-") || type.startsWith("unified-")) return type;
// 레거시 타입을 v2로 매핑 시도
const v2Type = `v2-${type}`;
// v2 버전이 등록되어 있는지 확인
if (ComponentRegistry.hasComponent(v2Type)) {
return v2Type;
}
// v2 버전이 없으면 원본 유지
return type;
};
const componentType = mapToV2ComponentType(rawComponentType);
// 디버그: 컴포넌트 타입 확인
if (rawComponentType && rawComponentType.includes("table")) {
console.log("🔍 DynamicComponentRenderer 타입 변환:", {
raw: rawComponentType,
mapped: componentType,
hasComponent: ComponentRegistry.hasComponent(componentType || ""),
componentConfig: (component as any).componentConfig,
});
}
// 🆕 Unified 폼 시스템 연동 (최상위에서 한 번만 호출)
// eslint-disable-next-line react-hooks/rules-of-hooks

View File

@@ -28,11 +28,14 @@ export interface TableListConfigPanelProps {
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
*/
export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
config,
config: configProp,
onChange,
screenTableName,
tableColumns,
}) => {
// config가 undefined인 경우 빈 객체로 초기화
const config = configProp || {};
// console.log("🔍 TableListConfigPanel props:", {
// config,
// configType: typeof config,

View File

@@ -18,8 +18,9 @@ const TabsDesignEditor: React.FC<{
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const activeTab = tabs.find((t) => t.id === activeTabId);
@@ -65,64 +66,54 @@ const TabsDesignEditor: React.FC<{
e.stopPropagation();
e.preventDefault();
if (!containerRef.current) return;
const targetElement = (e.currentTarget as HTMLElement);
const targetRect = targetElement.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
// 스크롤 위치 고려
const scrollLeft = containerRef.current.scrollLeft;
const scrollTop = containerRef.current.scrollTop;
// 마우스 클릭 위치에서 컴포넌트의 좌상단까지의 오프셋
const offsetX = e.clientX - targetRect.left;
const offsetY = e.clientY - targetRect.top;
// 초기 컨테이너 위치 저장
const initialContainerX = containerRect.left;
const initialContainerY = containerRect.top;
// 드래그 시작 시 마우스 위치와 컴포넌트의 현재 위치 저장
const startMouseX = e.clientX;
const startMouseY = e.clientY;
const startLeft = comp.position?.x || 0;
const startTop = comp.position?.y || 0;
setDraggingCompId(comp.id);
setDragOffset({ x: offsetX, y: offsetY });
setDragPosition({ x: startLeft, y: startTop });
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!containerRef.current) return;
// 현재 컨테이너의 위치 가져오기 (스크롤/리사이즈 고려)
const currentContainerRect = containerRef.current.getBoundingClientRect();
const currentScrollLeft = containerRef.current.scrollLeft;
const currentScrollTop = containerRef.current.scrollTop;
// 컨테이너 내에서의 위치 계산 (스크롤 포함)
const newX = moveEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft;
const newY = moveEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop;
// 실시간 위치 업데이트 (시각적 피드백)
const draggedElement = document.querySelector(
`[data-tab-comp-id="${comp.id}"]`
) as HTMLElement;
if (draggedElement) {
draggedElement.style.left = `${Math.max(0, newX)}px`;
draggedElement.style.top = `${Math.max(0, newY)}px`;
// 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);
setDraggingCompId(null);
if (!containerRef.current) {
return;
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
const currentContainerRect = containerRef.current.getBoundingClientRect();
const currentScrollLeft = containerRef.current.scrollLeft;
const currentScrollTop = containerRef.current.scrollTop;
const newX = upEvent.clientX - currentContainerRect.left - offsetX + currentScrollLeft;
const newY = upEvent.clientY - currentContainerRect.top - offsetY + currentScrollTop;
// 마우스 이동량 계산
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) {
@@ -198,7 +189,6 @@ const TabsDesignEditor: React.FC<{
{/* 탭 컨텐츠 영역 - 드롭 영역 */}
<div
ref={containerRef}
className="relative flex-1 overflow-hidden"
data-tabs-container="true"
data-component-id={component.id}
@@ -206,9 +196,12 @@ const TabsDesignEditor: React.FC<{
onClick={() => onSelectTabComponent?.(activeTabId, "", {} as TabInlineComponent)}
>
{activeTab ? (
<div className="absolute inset-0 overflow-auto p-2">
<div
ref={containerRef}
className="absolute inset-0 overflow-auto p-2"
>
{activeTab.components && activeTab.components.length > 0 ? (
<div className="relative h-full w-full">
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
{activeTab.components.map((comp: TabInlineComponent) => {
const isSelected = selectedTabComponentId === comp.id;
const isDragging = draggingCompId === comp.id;
@@ -225,22 +218,18 @@ const TabsDesignEditor: React.FC<{
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={cn(
"absolute rounded border bg-white shadow-sm transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-primary/50",
isDragging && "opacity-80 shadow-lg"
)}
className="absolute"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || 200,
height: comp.size?.height || 100,
left: displayX,
top: displayY,
zIndex: isDragging ? 100 : isSelected ? 10 : 1,
}}
onClick={(e) => {
@@ -248,18 +237,22 @@ const TabsDesignEditor: React.FC<{
onSelectTabComponent?.(activeTabId, comp.id, comp);
}}
>
{/* 드래그 핸들 - 상단 */}
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
<div
className="absolute left-0 right-0 top-0 z-10 flex h-5 cursor-move items-center justify-between bg-gray-100/80 px-1"
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-1">
<Move className="h-3 w-3 text-gray-400" />
<span className="text-[10px] text-gray-500 truncate max-w-[120px]">
<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 gap-1">
<div className="flex items-center">
<button
className="rounded p-0.5 hover:bg-gray-200"
onClick={(e) => {
@@ -268,7 +261,7 @@ const TabsDesignEditor: React.FC<{
}}
title="설정"
>
<Settings className="h-3 w-3 text-gray-500" />
<Settings className="h-2.5 w-2.5 text-gray-500" />
</button>
<button
className="rounded p-0.5 hover:bg-red-100"
@@ -278,13 +271,26 @@ const TabsDesignEditor: React.FC<{
}}
title="삭제"
>
<Trash2 className="h-3 w-3 text-red-500" />
<Trash2 className="h-2.5 w-2.5 text-red-500" />
</button>
</div>
</div>
{/* 실제 컴포넌트 렌더링 */}
<div className="h-full w-full pt-5 overflow-hidden pointer-events-none">
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
<div
className={cn(
"rounded-b border bg-white shadow-sm overflow-hidden pointer-events-none",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDragging && "opacity-80 shadow-lg",
!isDragging && "transition-all"
)}
style={{
width: comp.size?.width || 200,
height: comp.size?.height || 100,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}

View File

@@ -7,6 +7,18 @@ import React from "react";
// 컴포넌트별 ConfigPanel 동적 import 맵
// 모든 ConfigPanel이 있는 컴포넌트를 여기에 등록해야 슬롯/중첩 컴포넌트에서 전용 설정 패널이 표시됨
const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// ========== Unified 컴포넌트 ==========
"unified-input": () => import("@/components/unified/config-panels/UnifiedInputConfigPanel"),
"unified-select": () => import("@/components/unified/config-panels/UnifiedSelectConfigPanel"),
"unified-date": () => import("@/components/unified/config-panels/UnifiedDateConfigPanel"),
"unified-list": () => import("@/components/unified/config-panels/UnifiedListConfigPanel"),
"unified-media": () => import("@/components/unified/config-panels/UnifiedMediaConfigPanel"),
"unified-biz": () => import("@/components/unified/config-panels/UnifiedBizConfigPanel"),
"unified-group": () => import("@/components/unified/config-panels/UnifiedGroupConfigPanel"),
"unified-hierarchy": () => import("@/components/unified/config-panels/UnifiedHierarchyConfigPanel"),
"unified-layout": () => import("@/components/unified/config-panels/UnifiedLayoutConfigPanel"),
"unified-repeater": () => import("@/components/unified/config-panels/UnifiedRepeaterConfigPanel"),
// ========== 기본 입력 컴포넌트 ==========
"text-input": () => import("@/lib/registry/components/text-input/TextInputConfigPanel"),
"number-input": () => import("@/lib/registry/components/number-input/NumberInputConfigPanel"),
@@ -116,21 +128,37 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
// 모듈에서 ConfigPanel 컴포넌트 추출
// 1차: PascalCase 변환된 이름으로 찾기 (예: text-input -> TextInputConfigPanel)
// 2차: 특수 export명들 fallback
// 3차: default export
// 2차: v2- 접두사 제거 후 PascalCase 이름으로 찾기 (예: v2-table-list -> TableListConfigPanel)
// 3차: 특수 export명들 fallback
// 4차: default export
const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`;
// v2- 접두사가 있는 경우 접두사를 제거한 이름도 시도
const baseComponentId = componentId.startsWith("v2-") ? componentId.slice(3) : componentId;
const basePascalCaseName = `${toPascalCase(baseComponentId)}ConfigPanel`;
const ConfigPanelComponent =
module[pascalCaseName] ||
module[basePascalCaseName] ||
// 특수 export명들
module.RepeaterConfigPanel ||
module.FlowWidgetConfigPanel ||
module.CustomerItemMappingConfigPanel ||
module.SelectedItemsDetailInputConfigPanel ||
module.ButtonConfigPanel ||
module.TableListConfigPanel ||
module.SectionCardConfigPanel ||
module.SectionPaperConfigPanel ||
module.TabsConfigPanel ||
module.UnifiedRepeaterConfigPanel ||
module.UnifiedInputConfigPanel ||
module.UnifiedSelectConfigPanel ||
module.UnifiedDateConfigPanel ||
module.UnifiedListConfigPanel ||
module.UnifiedMediaConfigPanel ||
module.UnifiedBizConfigPanel ||
module.UnifiedGroupConfigPanel ||
module.UnifiedHierarchyConfigPanel ||
module.UnifiedLayoutConfigPanel ||
module.RepeatContainerConfigPanel ||
module.ScreenSplitPanelConfigPanel ||
module.SimpleRepeaterTableConfigPanel ||
@@ -491,6 +519,20 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
}
// 🆕 Unified 컴포넌트들은 전용 props 사용
if (componentId.startsWith("unified-")) {
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
menuObjid={menuObjid}
inputType={currentComponent?.inputType || config?.inputType}
screenTableName={screenTableName}
tableColumns={selectedTableColumns}
/>
);
}
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
if (componentId === "entity-search-input") {
@@ -520,6 +562,50 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
);
}
// 🆕 ButtonConfigPanel은 component와 onUpdateProperty를 사용
if (componentId === "button-primary" || componentId === "v2-button-primary") {
// currentComponent가 있으면 그것을 사용, 없으면 config에서 component 구조 생성
const componentForButton = currentComponent || {
id: "temp",
type: "component",
componentType: componentId,
componentConfig: config,
};
return (
<ConfigPanelComponent
component={componentForButton}
onUpdateProperty={(path: string, value: any) => {
// path가 componentConfig로 시작하면 내부 경로 추출
if (path.startsWith("componentConfig.")) {
const configPath = path.replace("componentConfig.", "");
const pathParts = configPath.split(".");
// 중첩된 경로 처리 - 현재 config를 기반으로 새 config 생성
const currentConfig = componentForButton.componentConfig || {};
const newConfig = JSON.parse(JSON.stringify(currentConfig)); // deep clone
let current: any = newConfig;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
onChange(newConfig);
} else {
// 직접 config 속성 변경
const currentConfig = componentForButton.componentConfig || {};
onChange({ ...currentConfig, [path]: value });
}
}}
allComponents={allComponents}
currentTableName={screenTableName}
/>
);
}
return (
<ConfigPanelComponent
config={config}