diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 599eebec..251f10ca 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -56,6 +56,11 @@ import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; // 레이아웃 초기화 import "@/lib/registry/layouts"; +// 컴포넌트 초기화 (새 시스템) +import "@/lib/registry/components"; +// 성능 최적화 도구 초기화 (필요시 사용) +import "@/lib/registry/utils/performanceOptimizer"; + interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; @@ -1423,8 +1428,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; - const dropX = e.clientX - rect.left; - const dropY = e.clientY - rect.top; + // 컴포넌트 크기 정보 + const componentWidth = component.defaultSize?.width || 120; + const componentHeight = component.defaultSize?.height || 36; + + // 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식) + const dropX_centered = e.clientX - rect.left - componentWidth / 2; + const dropY_centered = e.clientY - rect.top - componentHeight / 2; + + // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식) + const dropX_topleft = e.clientX - rect.left; + const dropY_topleft = e.clientY - rect.top; + + // 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록 + const dropX = dropX_topleft; + const dropY = dropY_topleft; + + console.log("🎯 위치 계산 디버깅:", { + "1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY }, + "2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + "3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top }, + "4. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, + "5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered }, + "5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, + "6. 선택된 방식": { dropX, dropY }, + "7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 }, + "8. 마우스와 중심 일치 확인": { + match: + Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 && + Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1, + }, + }); // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = layout.gridSettings @@ -1436,36 +1470,45 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }) : null; + // 캔버스 경계 내로 위치 제한 + const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth)); + const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight)); + // 격자 스냅 적용 const snappedPosition = layout.gridSettings?.snapToGrid && currentGridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) - : { x: dropX, y: dropY, z: 1 }; + ? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) + : { x: boundedX, y: boundedY, z: 1 }; console.log("🧩 컴포넌트 드롭:", { componentName: component.name, webType: component.webType, - dropPosition: { x: dropX, y: dropY }, + rawPosition: { x: dropX, y: dropY }, + boundedPosition: { x: boundedX, y: boundedY }, snappedPosition, }); - // 새 컴포넌트 생성 + // 새 컴포넌트 생성 (새 컴포넌트 시스템 지원) console.log("🔍 ScreenDesigner handleComponentDrop:", { componentName: component.name, - componentType: component.componentType, + componentId: component.id, webType: component.webType, - componentConfig: component.componentConfig, - finalType: component.componentType || "widget", + category: component.category, + defaultConfig: component.defaultConfig, }); const newComponent: ComponentData = { id: generateComponentId(), - type: component.componentType || "widget", // 데이터베이스의 componentType 사용 + type: "widget", // 새 컴포넌트는 모두 widget 타입 label: component.name, widgetType: component.webType, + componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) position: snappedPosition, size: component.defaultSize, - componentConfig: component.componentConfig || {}, // 데이터베이스의 componentConfig 사용 + componentConfig: { + type: component.id, // 새 컴포넌트 시스템의 ID 사용 + ...component.defaultConfig, + }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { labelDisplay: true, @@ -3043,8 +3086,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD startSelectionDrag(e); } }} - onDrop={handleDrop} - onDragOver={handleDragOver} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={(e) => { + e.preventDefault(); + console.log("🎯 캔버스 드롭 이벤트 발생"); + handleComponentDrop(e); + }} > {/* 격자 라인 */} {gridLines.map((line, index) => ( @@ -3348,25 +3398,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD height={700} autoHeight={false} > - { - const dragData = { - type: "component", - component: { - id: component.id, - name: component.name, - description: component.description, - category: component.category, - webType: component.webType, - componentType: component.componentType, // 추가! - componentConfig: component.componentConfig, // 추가! - defaultSize: component.defaultSize, - }, - }; - console.log("🚀 드래그 데이터 설정:", dragData); - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - /> + void; + className?: string; } -interface ComponentItem { - id: string; - name: string; - description: string; - category: string; - componentType: string; - componentConfig: any; - webType: string; // webType 추가 - icon: React.ReactNode; - defaultSize: { width: number; height: number }; -} +export function ComponentsPanel({ className }: ComponentsPanelProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); -// 컴포넌트 카테고리 정의 (실제 생성된 컴포넌트에 맞게) -const COMPONENT_CATEGORIES = [ - { id: "액션", name: "액션", description: "사용자 동작을 처리하는 컴포넌트" }, - { id: "레이아웃", name: "레이아웃", description: "화면 구조를 제공하는 컴포넌트" }, - { id: "데이터", name: "데이터", description: "데이터를 표시하는 컴포넌트" }, - { id: "네비게이션", name: "네비게이션", description: "화면 이동을 도와주는 컴포넌트" }, - { id: "피드백", name: "피드백", description: "사용자 피드백을 제공하는 컴포넌트" }, - { id: "입력", name: "입력", description: "사용자 입력을 받는 컴포넌트" }, - { id: "표시", name: "표시", description: "정보를 표시하고 알리는 컴포넌트" }, - { id: "컨테이너", name: "컨테이너", description: "다른 컴포넌트를 담는 컨테이너" }, - { id: "위젯", name: "위젯", description: "범용 위젯 컴포넌트" }, - { id: "템플릿", name: "템플릿", description: "미리 정의된 템플릿" }, - { id: "차트", name: "차트", description: "데이터 시각화 컴포넌트" }, - { id: "폼", name: "폼", description: "폼 관련 컴포넌트" }, - { id: "미디어", name: "미디어", description: "이미지, 비디오 등 미디어 컴포넌트" }, - { id: "유틸리티", name: "유틸리티", description: "보조 기능 컴포넌트" }, - { id: "관리", name: "관리", description: "관리자 전용 컴포넌트" }, - { id: "시스템", name: "시스템", description: "시스템 관련 컴포넌트" }, - { id: "UI", name: "UI", description: "일반 UI 컴포넌트" }, - { id: "컴포넌트", name: "컴포넌트", description: "일반 컴포넌트" }, - { id: "기타", name: "기타", description: "기타 컴포넌트" }, -]; + // 레지스트리에서 모든 컴포넌트 조회 + const allComponents = useMemo(() => { + return ComponentRegistry.getAllComponents(); + }, []); -export const ComponentsPanel: React.FC = ({ onDragStart }) => { - const [searchTerm, setSearchTerm] = useState(""); - const [selectedCategory, setSelectedCategory] = useState("all"); + // 카테고리별 분류 + const componentsByCategory = useMemo(() => { + const categories: Record = { + all: allComponents, + input: [], + display: [], + action: [], + layout: [], + utility: [], + }; - // 데이터베이스에서 컴포넌트 가져오기 - const { - data: componentsData, - isLoading: loading, - error, - } = useComponents({ - active: "Y", - }); + allComponents.forEach((component) => { + if (categories[component.category]) { + categories[component.category].push(component); + } + }); - // 컴포넌트를 ComponentItem으로 변환 - const componentItems = useMemo(() => { - if (!componentsData?.components) { - console.log("🔍 ComponentsPanel: 컴포넌트 데이터 없음"); - return []; + return categories; + }, [allComponents]); + + // 검색 및 필터링된 컴포넌트 + const filteredComponents = useMemo(() => { + let components = componentsByCategory[selectedCategory] || []; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + components = components.filter( + (component) => + component.name.toLowerCase().includes(query) || + component.description.toLowerCase().includes(query) || + component.tags?.some((tag) => tag.toLowerCase().includes(query)), + ); } - console.log("🔍 ComponentsPanel 전체 컴포넌트 데이터:", { - totalComponents: componentsData.components.length, - components: componentsData.components.map((c) => ({ - code: c.component_code, - name: c.component_name, - category: c.category, - config: c.component_config, - })), - }); + return components; + }, [componentsByCategory, selectedCategory, searchQuery]); - return componentsData.components.map((component) => { - console.log("🔍 ComponentsPanel 컴포넌트 매핑:", { - component_code: component.component_code, - component_name: component.component_name, - component_config: component.component_config, - componentType: component.component_config?.type || component.component_code, - webType: component.component_config?.type || component.component_code, - category: component.category, - }); + // 드래그 시작 핸들러 + const handleDragStart = (e: React.DragEvent, component: ComponentDefinition) => { + const dragData = { + type: "component", + component: component, + }; + console.log("🚀 컴포넌트 드래그 시작:", component.name, dragData); + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + e.dataTransfer.effectAllowed = "copy"; + }; - // 카테고리 매핑 (영어 -> 한국어) - const categoryMapping: Record = { - display: "표시", - action: "액션", - layout: "레이아웃", - data: "데이터", - navigation: "네비게이션", - feedback: "피드백", - input: "입력", - container: "컨테이너", - widget: "위젯", - template: "템플릿", - chart: "차트", - form: "폼", - media: "미디어", - utility: "유틸리티", - admin: "관리", - system: "시스템", - ui: "UI", - component: "컴포넌트", - 기타: "기타", - other: "기타", - // 한국어도 처리 - 표시: "표시", - 액션: "액션", - 레이아웃: "레이아웃", - 데이터: "데이터", - 네비게이션: "네비게이션", - 피드백: "피드백", - 입력: "입력", - }; + // 카테고리별 아이콘 + const getCategoryIcon = (category: ComponentCategory | "all") => { + switch (category) { + case "input": + return ; + case "display": + return ; + case "action": + return ; + case "layout": + return ; + case "utility": + return ; + default: + return ; + } + }; - const mappedCategory = categoryMapping[component.category] || component.category || "other"; - - return { - id: component.component_code, - name: component.component_name, - description: component.description || `${component.component_name} 컴포넌트`, - category: mappedCategory, - componentType: component.component_config?.type || component.component_code, - componentConfig: component.component_config, - webType: component.component_config?.type || component.component_code, // webType 추가 - icon: getComponentIcon(component.icon_name || component.component_config?.type), - defaultSize: component.default_size || getDefaultSize(component.component_config?.type), - }; - }); - }, [componentsData]); - - // 필터링된 컴포넌트 - const filteredComponents = useMemo(() => { - return componentItems.filter((component) => { - const matchesSearch = - component.name.toLowerCase().includes(searchTerm.toLowerCase()) || - component.description.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesCategory = selectedCategory === "all" || component.category === selectedCategory; - - return matchesSearch && matchesCategory; - }); - }, [componentItems, searchTerm, selectedCategory]); - - // 카테고리별 그룹화 - const groupedComponents = useMemo(() => { - const groups: Record = {}; - - COMPONENT_CATEGORIES.forEach((category) => { - groups[category.id] = filteredComponents.filter((component) => component.category === category.id); - }); - - console.log("🔍 카테고리별 그룹화 결과:", { - 총컴포넌트: filteredComponents.length, - 카테고리별개수: Object.entries(groups).map(([cat, comps]) => ({ 카테고리: cat, 개수: comps.length })), - }); - - return groups; - }, [filteredComponents]); - - console.log("🔍 ComponentsPanel 상태:", { - loading, - error: error?.message, - componentsData, - componentItemsLength: componentItems.length, - }); - - if (loading) { - return ( -
-
- -

컴포넌트 로딩 중...

-

- API: {process.env.NODE_ENV === "development" ? "http://localhost:8080" : "39.117.244.52:8080"} -

-
-
- ); - } - - if (error) { - return ( -
-
- -

컴포넌트 로드 실패

-

{error.message}

-
- 상세 오류 -
{JSON.stringify(error, null, 2)}
-
-
-
- ); - } + // 컴포넌트 새로고침 + const handleRefresh = () => { + // Hot Reload 트리거 (개발 모드에서만) + if (process.env.NODE_ENV === "development") { + ComponentRegistry.refreshComponents?.(); + } + window.location.reload(); + }; return ( -
- {/* 헤더 */} -
-
- -

컴포넌트

- - {filteredComponents.length}개 - -
-

드래그하여 화면에 추가하세요

-
+ + + +
+ + 컴포넌트 ({allComponents.length}) +
+ +
- {/* 검색 및 필터 */} -
- {/* 검색 */} + {/* 검색창 */}
- + setSearchTerm(e.target.value)} - className="h-8 pl-9 text-xs" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" />
+ - {/* 카테고리 필터 */} -
- - -
-
+ + setSelectedCategory(value as ComponentCategory | "all")} + > + {/* 카테고리 탭 */} + + + + 전체 + + + + 입력 + + + + 표시 + + + + 액션 + + + + 레이아웃 + + + + 유틸 + + - {/* 컴포넌트 목록 */} -
- {selectedCategory === "all" ? ( - // 카테고리별 그룹 표시 -
- {COMPONENT_CATEGORIES.map((category) => { - const categoryComponents = groupedComponents[category.id]; - if (categoryComponents.length === 0) return null; + {/* 컴포넌트 목록 */} +
+ + {filteredComponents.length > 0 ? ( +
+ {filteredComponents.map((component) => ( +
handleDragStart(e, component)} + className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing" + title={component.description} + > +
+
+

{component.name}

+
+ {/* 카테고리 뱃지 */} + + {getCategoryIcon(component.category)} + {component.category} + - return ( -
-
-

{category.name}

- - {categoryComponents.length}개 - -
-

{category.description}

-
- {categoryComponents.map((component) => ( - - ))} -
+ {/* 새 컴포넌트 뱃지 */} + + 신규 + +
+
+ +

{component.description}

+ + {/* 웹타입 및 크기 정보 */} +
+ 웹타입: {component.webType} + + {component.defaultSize.width}×{component.defaultSize.height} + +
+ + {/* 태그 */} + {component.tags && component.tags.length > 0 && ( +
+ {component.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {component.tags.length > 3 && ( + + +{component.tags.length - 3} + + )} +
+ )} +
+
+ ))}
- ); - })} + ) : ( +
+ +

+ {searchQuery + ? `"${searchQuery}"에 대한 검색 결과가 없습니다.` + : "이 카테고리에 컴포넌트가 없습니다."} +

+
+ )} +
- ) : ( - // 선택된 카테고리만 표시 -
-
- {filteredComponents.map((component) => ( - - ))} + + + {/* 통계 정보 */} +
+
+
+
{filteredComponents.length}
+
표시된 컴포넌트
+
+
+
{allComponents.length}
+
전체 컴포넌트
+
+
+
+ + {/* 개발 정보 (개발 모드에서만) */} + {process.env.NODE_ENV === "development" && ( +
+
+
🔧 레지스트리 기반 시스템
+
⚡ Hot Reload 지원
+
🛡️ 완전한 타입 안전성
)} - - {filteredComponents.length === 0 && ( -
-
- -

검색 결과가 없습니다

-

다른 검색어를 시도해보세요

-
-
- )} -
-
+ + ); -}; - -// 컴포넌트 카드 컴포넌트 -const ComponentCard: React.FC<{ - component: ComponentItem; - onDragStart: (e: React.DragEvent, component: ComponentItem) => void; -}> = ({ component, onDragStart }) => { - return ( -
onDragStart(e, component)} - className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md" - > -
-
- {component.icon} -
-
-

{component.name}

-

{component.description}

-
- - {component.webType} - -
-
-
-
- ); -}; - -// 웹타입별 아이콘 매핑 -function getComponentIcon(webType: string): React.ReactNode { - const iconMap: Record = { - text: Aa, - number: 123, - date: 📅, - select: , - checkbox: , - radio: , - textarea: 📝, - file: 📎, - button: 🔘, - email: 📧, - tel: 📞, - password: 🔒, - code: <>, - entity: 🔗, - }; - - return iconMap[webType] || ; -} - -// 웹타입별 기본 크기 -function getDefaultSize(webType: string): { width: number; height: number } { - const sizeMap: Record = { - text: { width: 200, height: 36 }, - number: { width: 150, height: 36 }, - date: { width: 180, height: 36 }, - select: { width: 200, height: 36 }, - checkbox: { width: 150, height: 36 }, - radio: { width: 200, height: 80 }, - textarea: { width: 300, height: 100 }, - file: { width: 300, height: 120 }, - button: { width: 120, height: 36 }, - email: { width: 250, height: 36 }, - tel: { width: 180, height: 36 }, - password: { width: 200, height: 36 }, - code: { width: 200, height: 36 }, - entity: { width: 200, height: 36 }, - }; - - return sizeMap[webType] || { width: 200, height: 36 }; } export default ComponentsPanel; diff --git a/frontend/docs/레이아웃_추가_가이드.md b/frontend/docs/레이아웃_추가_가이드.md index 03dded6e..86008b76 100644 --- a/frontend/docs/레이아웃_추가_가이드.md +++ b/frontend/docs/레이아웃_추가_가이드.md @@ -875,12 +875,12 @@ const getColumnLabel = (columnName: string) => { export const YourLayoutLayout: React.FC = ({ layout, isDesignMode, ...props }) => { // 🚫 존별 드롭 이벤트 구현 금지 // onDrop, onDragOver 등 드롭 관련 이벤트 추가하지 않음 - + return (
{layout.zones.map((zone) => ( -
= ({ layout, isDesignMo {/* 존 내용 */}
))} - +