From 9af3cdea01b613966ab2d634808ecc0321fbb3cd Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 2 Sep 2025 16:18:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EB=A9=94=EB=89=B4=EB=93=A4=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=ED=8C=85=20=ED=8C=A8=EB=84=90=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/screenMng/page.tsx | 60 +- .../components/screen/DesignerToolbar.tsx | 158 + frontend/components/screen/FloatingPanel.tsx | 236 ++ .../components/screen/RealtimePreview.tsx | 1 + frontend/components/screen/ScreenDesigner.tsx | 3307 +++++++---------- .../components/screen/ScreenDesigner_new.tsx | 667 ++++ .../components/screen/ScreenDesigner_old.tsx | 2157 +++++++++++ .../components/screen/panels/GridPanel.tsx | 223 ++ .../screen/panels/PropertiesPanel.tsx | 456 +++ .../components/screen/panels/TablesPanel.tsx | 224 ++ frontend/hooks/usePanelState.ts | 139 + frontend/lib/utils/gridUtils.ts | 18 +- frontend/types/screen.ts | 11 +- 13 files changed, 5704 insertions(+), 1953 deletions(-) create mode 100644 frontend/components/screen/DesignerToolbar.tsx create mode 100644 frontend/components/screen/FloatingPanel.tsx create mode 100644 frontend/components/screen/ScreenDesigner_new.tsx create mode 100644 frontend/components/screen/ScreenDesigner_old.tsx create mode 100644 frontend/components/screen/panels/GridPanel.tsx create mode 100644 frontend/components/screen/panels/PropertiesPanel.tsx create mode 100644 frontend/components/screen/panels/TablesPanel.tsx create mode 100644 frontend/hooks/usePanelState.ts diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 0b481e39..bf90f2d7 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react"; +import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; @@ -62,69 +62,11 @@ export default function ScreenManagementPage() { } }; - // 단계별 진행 상태 확인 - const isStepCompleted = (step: Step) => { - return stepHistory.includes(step); - }; - // 현재 단계가 마지막 단계인지 확인 const isLastStep = currentStep === "template"; return (
- {/* 페이지 헤더 */} -
-
-

화면관리 시스템

-

단계별로 화면을 관리하고 설계하세요

-
-
{stepConfig[currentStep].description}
-
- - {/* 단계별 진행 표시 */} -
-
- {Object.entries(stepConfig).map(([step, config], index) => ( -
-
- -
-
- {config.title} -
-
-
- {index < Object.keys(stepConfig).length - 1 && ( -
- )} -
- ))} -
-
- {/* 단계별 내용 */}
{/* 화면 목록 단계 */} diff --git a/frontend/components/screen/DesignerToolbar.tsx b/frontend/components/screen/DesignerToolbar.tsx new file mode 100644 index 00000000..cb31eb93 --- /dev/null +++ b/frontend/components/screen/DesignerToolbar.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface DesignerToolbarProps { + screenName?: string; + tableName?: string; + onBack: () => void; + onSave: () => void; + onUndo: () => void; + onRedo: () => void; + onPreview: () => void; + onTogglePanel: (panelId: string) => void; + panelStates: Record; + canUndo: boolean; + canRedo: boolean; + isSaving?: boolean; +} + +export const DesignerToolbar: React.FC = ({ + screenName, + tableName, + onBack, + onSave, + onUndo, + onRedo, + onPreview, + onTogglePanel, + panelStates, + canUndo, + canRedo, + isSaving = false, +}) => { + return ( +
+ {/* 좌측: 네비게이션 및 화면 정보 */} +
+ + +
+ +
+ +
+

{screenName || "화면 설계"}

+ {tableName && ( +
+ + {tableName} +
+ )} +
+
+
+ + {/* 중앙: 패널 토글 버튼들 */} +
+ + + + + + + +
+ + {/* 우측: 액션 버튼들 */} +
+ + + + +
+ + + + +
+
+ ); +}; + +export default DesignerToolbar; diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx new file mode 100644 index 00000000..de8a0416 --- /dev/null +++ b/frontend/components/screen/FloatingPanel.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { X, GripVertical } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface FloatingPanelProps { + id: string; + title: string; + children: React.ReactNode; + isOpen: boolean; + onClose: () => void; + position?: "left" | "right" | "top" | "bottom"; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + resizable?: boolean; + draggable?: boolean; + autoHeight?: boolean; // 자동 높이 조정 옵션 + className?: string; +} + +export const FloatingPanel: React.FC = ({ + id, + title, + children, + isOpen, + onClose, + position = "right", + width = 320, + height = 400, + minWidth = 280, + minHeight = 300, + maxWidth = 600, + maxHeight = 800, + resizable = true, + draggable = true, + autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원) + className, +}) => { + const [panelSize, setPanelSize] = useState({ width, height }); + const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const panelRef = useRef(null); + const dragHandleRef = useRef(null); + const contentRef = useRef(null); + + // 초기 위치 설정 (패널이 처음 열릴 때만) + const [hasInitialized, setHasInitialized] = useState(false); + + useEffect(() => { + if (isOpen && !hasInitialized && panelRef.current) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let initialX = 0; + let initialY = 0; + + switch (position) { + case "left": + initialX = 20; + initialY = 80; + break; + case "right": + initialX = viewportWidth - panelSize.width - 20; + initialY = 80; + break; + case "top": + initialX = (viewportWidth - panelSize.width) / 2; + initialY = 20; + break; + case "bottom": + initialX = (viewportWidth - panelSize.width) / 2; + initialY = viewportHeight - panelSize.height - 20; + break; + } + + setPanelPosition({ x: initialX, y: initialY }); + setHasInitialized(true); + } + + // 패널이 닫힐 때 초기화 상태 리셋 + if (!isOpen) { + setHasInitialized(false); + } + }, [isOpen, position, hasInitialized]); + + // 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원 + + // 드래그 시작 - 성능 최적화 + const handleDragStart = (e: React.MouseEvent) => { + if (!draggable) return; + + e.preventDefault(); // 기본 동작 방지로 딜레이 제거 + e.stopPropagation(); // 이벤트 버블링 방지 + + setIsDragging(true); + const rect = panelRef.current?.getBoundingClientRect(); + if (rect) { + setDragOffset({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }; + + // 리사이즈 시작 + const handleResizeStart = (e: React.MouseEvent) => { + if (!resizable) return; + + e.preventDefault(); + setIsResizing(true); + }; + + // 마우스 이동 처리 - 초고속 최적화 + useEffect(() => { + if (!isDragging && !isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + // 직접 DOM 조작으로 최고 성능 + if (panelRef.current) { + const newX = e.clientX - dragOffset.x; + const newY = e.clientY - dragOffset.y; + + panelRef.current.style.left = `${newX}px`; + panelRef.current.style.top = `${newY}px`; + + // 상태는 throttle로 업데이트 + setPanelPosition({ x: newX, y: newY }); + } + } else if (isResizing) { + const newWidth = Math.max(minWidth, Math.min(maxWidth, e.clientX - panelPosition.x)); + const newHeight = Math.max(minHeight, Math.min(maxHeight, e.clientY - panelPosition.y)); + + if (panelRef.current) { + panelRef.current.style.width = `${newWidth}px`; + panelRef.current.style.height = `${newHeight}px`; + } + + setPanelSize({ width: newWidth, height: newHeight }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + // 고성능 이벤트 리스너 + document.addEventListener("mousemove", handleMouseMove, { + passive: true, + capture: false, + }); + document.addEventListener("mouseup", handleMouseUp, { + passive: true, + capture: false, + }); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, isResizing, dragOffset.x, dragOffset.y, panelPosition.x, minWidth, maxWidth, minHeight, maxHeight]); + + if (!isOpen) return null; + + return ( +
+ {/* 헤더 */} +
+
+ +

{title}

+
+ +
+ + {/* 컨텐츠 */} +
+ {children} +
+ + {/* 리사이즈 핸들 */} + {resizable && ( +
+
+
+ )} +
+ ); +}; + +export default FloatingPanel; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 400de969..e23de4f5 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -252,6 +252,7 @@ export const RealtimePreview: React.FC = ({ top: `${component.position.y}px`, width: `${size.width}px`, // 격자 기반 계산 제거 height: `${size.height}px`, + zIndex: component.position.z || 1, // z-index 적용 ...selectionStyle, }} onClick={(e) => { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 724ab7c7..d8ea08bd 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,43 +1,16 @@ "use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; - -import { - Palette, - Type, - Calendar, - Hash, - CheckSquare, - Radio, - Save, - Undo, - Redo, - Group, - Database, - Trash2, - Settings, - ChevronDown, - Code, - Building, - File, - List, - AlignLeft, - ChevronRight, - Copy, - Clipboard, -} from "lucide-react"; +import { Database } from "lucide-react"; import { ScreenDefinition, ComponentData, LayoutData, GroupState, - WebType, TableInfo, - GroupComponent, Position, + ColumnInfo, + GridSettings, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { @@ -45,7 +18,6 @@ import { calculateBoundingBox, calculateRelativePositions, restoreAbsolutePositions, - getGroupChildren, } from "@/lib/utils/groupingUtils"; import { calculateGridInfo, @@ -55,58 +27,193 @@ import { GridSettings as GridUtilSettings, } from "@/lib/utils/gridUtils"; import { GroupingToolbar } from "./GroupingToolbar"; -import GridControls from "./GridControls"; -import { screenApi } from "@/lib/api/screen"; +import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { toast } from "sonner"; import StyleEditor from "./StyleEditor"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; import { RealtimePreview } from "./RealtimePreview"; +import FloatingPanel from "./FloatingPanel"; +import DesignerToolbar from "./DesignerToolbar"; +import TablesPanel from "./panels/TablesPanel"; +import PropertiesPanel from "./panels/PropertiesPanel"; +import GridPanel from "./panels/GridPanel"; +import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; } +// 패널 설정 +const panelConfigs: PanelConfig[] = [ + { + id: "tables", + title: "테이블 목록", + defaultPosition: "left", + defaultWidth: 320, + defaultHeight: 600, + shortcutKey: "t", + }, + { + id: "properties", + title: "속성 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 500, + shortcutKey: "p", + }, + { + id: "styles", + title: "스타일 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 400, + shortcutKey: "s", + }, + { + id: "grid", + title: "격자 설정", + defaultPosition: "right", + defaultWidth: 280, + defaultHeight: 450, + shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음) + }, +]; + export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { + // 패널 상태 관리 + const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs); + const [layout, setLayout] = useState({ components: [], - gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + snapToGrid: true, + showGrid: true, + gridColor: "#d1d5db", + gridOpacity: 0.5, + }, }); const [isSaving, setIsSaving] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [selectedComponent, setSelectedComponent] = useState(null); + // 클립보드 상태 + const [clipboard, setClipboard] = useState([]); + // 실행취소/다시실행을 위한 히스토리 상태 - const [history, setHistory] = useState([ - { - components: [], - gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, - }, - ]); - const [historyIndex, setHistoryIndex] = useState(0); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); - // 클립보드 상태 (복사/붙여넣기용) - const [clipboard, setClipboard] = useState<{ - type: "single" | "multiple" | "group"; - data: ComponentData[]; - offset: { x: number; y: number }; - boundingBox?: { x: number; y: number; width: number; height: number }; - } | null>(null); + // 그룹 상태 + const [groupState, setGroupState] = useState({ + selectedComponents: [], + isGrouping: false, + }); - // 히스토리에 상태 저장 + // 드래그 상태 + const [dragState, setDragState] = useState({ + isDragging: false, + draggedComponent: null as ComponentData | null, + draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열 + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 + }); + + // 드래그 선택 상태 + const [selectionDrag, setSelectionDrag] = useState({ + isSelecting: false, + startPoint: { x: 0, y: 0, z: 1 }, + currentPoint: { x: 0, y: 0, z: 1 }, + wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적 + }); + + // 테이블 데이터 + const [tables, setTables] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + + // 그룹 생성 다이얼로그 + const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + + const canvasRef = useRef(null); + + // 격자 정보 계산 + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + + const gridInfo = useMemo(() => { + if (!layout.gridSettings) return null; + + // 캔버스 크기 계산 + let width = canvasSize.width || window.innerWidth - 100; + let height = canvasSize.height || window.innerHeight - 200; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + width = rect.width || width; + height = rect.height || height; + } + + return calculateGridInfo(width, height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + }, [layout.gridSettings, canvasSize]); + + // 격자 라인 생성 + const gridLines = useMemo(() => { + if (!gridInfo || !layout.gridSettings?.showGrid) return []; + + // 캔버스 크기 계산 + let width = window.innerWidth - 100; + let height = window.innerHeight - 200; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + width = rect.width || width; + height = rect.height || height; + } + + const lines = generateGridLines(width, height, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }); + + // 수직선과 수평선을 하나의 배열로 합치기 + const allLines = [ + ...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })), + ...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })), + ]; + + return allLines; + }, [gridInfo, layout.gridSettings]); + + // 필터된 테이블 목록 + const filteredTables = useMemo(() => { + if (!searchTerm) return tables; + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), + ); + }, [tables, searchTerm]); + + // 히스토리에 저장 const saveToHistory = useCallback( (newLayout: LayoutData) => { - setHistory((prevHistory) => { - const newHistory = prevHistory.slice(0, historyIndex + 1); - newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 - return newHistory.slice(-50); // 최대 50개 히스토리 유지 + setHistory((prev) => { + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(newLayout); + return newHistory.slice(-50); // 최대 50개까지만 저장 }); - setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49)); - setHasUnsavedChanges(true); // 변경사항 표시 + setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [historyIndex], ); @@ -114,771 +221,710 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 실행취소 const undo = useCallback(() => { if (historyIndex > 0) { - const newIndex = historyIndex - 1; - setHistoryIndex(newIndex); - setLayout(JSON.parse(JSON.stringify(history[newIndex]))); - setSelectedComponent(null); // 선택 해제 + setHistoryIndex((prev) => prev - 1); + setLayout(history[historyIndex - 1]); } - }, [historyIndex, history]); + }, [history, historyIndex]); // 다시실행 const redo = useCallback(() => { if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - setHistoryIndex(newIndex); - setLayout(JSON.parse(JSON.stringify(history[newIndex]))); - setSelectedComponent(null); // 선택 해제 + setHistoryIndex((prev) => prev + 1); + setLayout(history[historyIndex + 1]); } - }, [historyIndex, history]); + }, [history, historyIndex]); - const [dragState, setDragState] = useState({ - isDragging: false, - draggedComponent: null as ComponentData | null, - draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들 - originalPosition: { x: 0, y: 0 }, - currentPosition: { x: 0, y: 0 }, - isMultiDrag: false, // 다중 드래그 여부 - initialMouse: { x: 0, y: 0 }, - grabOffset: { x: 0, y: 0 }, - }); - const [groupState, setGroupState] = useState({ - isGrouping: false, - selectedComponents: [], - groupTarget: null, - groupMode: "create", - }); + // 컴포넌트 속성 업데이트 + const updateComponentProperty = useCallback( + (componentId: string, path: string, value: any) => { + const pathParts = path.split("."); + const updatedComponents = layout.components.map((comp) => { + if (comp.id !== componentId) return comp; - // 그룹 생성 다이얼로그 상태 - const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + const newComp = { ...comp }; + let current: any = newComp; - // 캔버스 컨테이너 참조 - const canvasRef = useRef(null); + 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; - // 격자 정보 계산 - const gridInfo = useMemo(() => { - if (!layout.gridSettings) return null; - - // canvasRef가 없거나 크기가 0인 경우 기본값 사용 - let width = 800; - let height = 600; - - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - width = Math.max(rect.width || 800, 800); - height = Math.max(rect.height || 600, 600); - } - - return calculateGridInfo(width, height, layout.gridSettings as GridUtilSettings); - }, [layout.gridSettings]); - - // 격자 설정 변경 핸들러 - const handleGridSettingsChange = useCallback( - (newGridSettings: GridUtilSettings) => { - let updatedComponents = layout.components; - - // 격자 스냅이 활성화되어 있고 격자 정보가 있으며 컴포넌트가 있는 경우 기존 컴포넌트들을 새 격자에 맞춤 - if (newGridSettings.snapToGrid && gridInfo && layout.components.length > 0) { - // 현재 캔버스 크기 가져오기 - let canvasWidth = 800; - let canvasHeight = 600; - - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - canvasWidth = Math.max(rect.width || 800, 800); - canvasHeight = Math.max(rect.height || 600, 600); + // 크기 변경 시 격자 스냅 적용 + if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) { + const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); + newComp.size = snappedSize; } - const newGridInfo = calculateGridInfo(canvasWidth, canvasHeight, newGridSettings); + return newComp; + }); - updatedComponents = layout.components.map((comp) => { - // 그룹의 자식 컴포넌트는 건드리지 않음 (그룹에서 처리) - if (comp.parentId) return comp; - - // 기존 격자에서의 상대적 위치 계산 (격자 컬럼 단위) - const oldGridInfo = gridInfo; - const oldColumnWidth = oldGridInfo.columnWidth; - const oldGap = layout.gridSettings?.gap || 16; - const oldPadding = layout.gridSettings?.padding || 16; - - // 기존 위치를 격자 컬럼/행 단위로 변환 - const oldGridX = Math.round((comp.position.x - oldPadding) / (oldColumnWidth + oldGap)); - const oldGridY = Math.round((comp.position.y - oldPadding) / 20); // 20px 단위 - - // 기존 크기를 격자 컬럼 단위로 변환 - const oldGridColumns = Math.max(1, Math.round((comp.size.width + oldGap) / (oldColumnWidth + oldGap))); - const oldGridRows = Math.max(2, Math.round(comp.size.height / 20)); // 20px 단위 - - // 새 격자에서의 위치와 크기 계산 - const newColumnWidth = newGridInfo.columnWidth; - const newGap = newGridSettings.gap; - const newPadding = newGridSettings.padding; - - // 새 위치 계산 (격자 비율 유지) - const newX = newPadding + oldGridX * (newColumnWidth + newGap); - const newY = newPadding + oldGridY * 20; - - // 새 크기 계산 (격자 비율 유지) - const newWidth = oldGridColumns * newColumnWidth + (oldGridColumns - 1) * newGap; - const newHeight = oldGridRows * 20; - - return { - ...comp, - position: { x: newX, y: newY, z: comp.position.z || 1 }, - size: { width: newWidth, height: newHeight }, - }; - }); - } - - const newLayout = { - ...layout, - components: updatedComponents, - gridSettings: newGridSettings, - }; + const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); saveToHistory(newLayout); }, - [layout, saveToHistory, gridInfo], + [layout, gridInfo, saveToHistory], ); - const [tables, setTables] = useState([]); - const [expandedTables, setExpandedTables] = useState>(new Set()); + // 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회) + useEffect(() => { + if (selectedScreen?.tableName && selectedScreen.tableName.trim()) { + const loadTable = async () => { + try { + // 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화) + const columnsResponse = await tableTypeApi.getColumns(selectedScreen.tableName); + const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({ + tableName: col.tableName || selectedScreen.tableName, + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + webType: col.webType || col.web_type, + widgetType: col.widgetType || col.widget_type || col.webType || col.web_type, + isNullable: col.isNullable || col.is_nullable, + required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", + columnDefault: col.columnDefault || col.column_default, + characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, + })); - // 테이블 검색 및 페이징 상태 추가 - const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage] = useState(10); + const tableInfo: TableInfo = { + tableName: selectedScreen.tableName, + tableLabel: selectedScreen.tableName, // 필요시 별도 API로 displayName 조회 + columns: columns, + }; + setTables([tableInfo]); // 단일 테이블 정보만 설정 + } catch (error) { + console.error("테이블 정보 로드 실패:", error); + toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`); + } + }; - // 드래그 박스(마키) 다중선택 상태 - const [selectionState, setSelectionState] = useState({ - isSelecting: false, - start: { x: 0, y: 0 }, - current: { x: 0, y: 0 }, - }); + loadTable(); + } else { + // 테이블명이 없는 경우 테이블 목록 초기화 + setTables([]); + } + }, [selectedScreen?.tableName]); - // 선택된 컴포넌트를 항상 레이아웃 최신 값으로 참조 (좌표 실시간 반영용) - const selectedFromLayout = useMemo(() => { - if (!selectedComponent) return null; - return layout.components.find((c) => c.id === selectedComponent.id) || null; - }, [selectedComponent, layout.components]); + // 화면 레이아웃 로드 + useEffect(() => { + if (selectedScreen?.screenId) { + const loadLayout = async () => { + try { + const response = await screenApi.getLayout(selectedScreen.screenId); + if (response) { + // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) + const layoutWithDefaultGrid = { + ...response, + gridSettings: { + columns: 12, + gap: 16, + padding: 16, + snapToGrid: true, + showGrid: true, + gridColor: "#d1d5db", + gridOpacity: 0.5, + ...response.gridSettings, // 기존 설정이 있으면 덮어쓰기 + }, + }; + setLayout(layoutWithDefaultGrid); + setHistory([layoutWithDefaultGrid]); + setHistoryIndex(0); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } + }; + loadLayout(); + } + }, [selectedScreen?.screenId]); - // 드래그 중에는 라이브 좌표를 계산하여 속성 패널에 표시 - const liveSelectedPosition = useMemo(() => { - if (!selectedFromLayout) return { x: 0, y: 0 }; + // 격자 설정 업데이트 및 컴포넌트 자동 스냅 + const updateGridSettings = useCallback( + (newGridSettings: GridSettings) => { + const newLayout = { ...layout, gridSettings: newGridSettings }; - let x = selectedFromLayout.position.x; - let y = selectedFromLayout.position.y; + // 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 + if (newGridSettings.snapToGrid && canvasSize.width > 0) { + // 새로운 격자 설정으로 격자 정보 재계산 + const newGridInfo = calculateGridInfo(canvasSize.width, canvasSize.height, { + columns: newGridSettings.columns, + gap: newGridSettings.gap, + padding: newGridSettings.padding, + snapToGrid: newGridSettings.snapToGrid || false, + }); - if (dragState.isDragging) { - const isSelectedInMulti = groupState.selectedComponents.includes(selectedFromLayout.id); - if (dragState.isMultiDrag && isSelectedInMulti) { - const deltaX = dragState.currentPosition.x - dragState.initialMouse.x; - const deltaY = dragState.currentPosition.y - dragState.initialMouse.y; - x = selectedFromLayout.position.x + deltaX; - y = selectedFromLayout.position.y + deltaY; - } else if (dragState.draggedComponent?.id === selectedFromLayout.id) { - x = dragState.currentPosition.x - dragState.grabOffset.x; - y = dragState.currentPosition.y - dragState.grabOffset.y; + const gridUtilSettings = { + columns: newGridSettings.columns, + gap: newGridSettings.gap, + padding: newGridSettings.padding, + snapToGrid: newGridSettings.snapToGrid, + }; + + const adjustedComponents = layout.components.map((comp) => { + const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); + const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); + return { + ...comp, + position: snappedPosition, + size: snappedSize, + }; + }); + + newLayout.components = adjustedComponents; + console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개"); + console.log("새로운 격자 정보:", newGridInfo); } + + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, canvasSize, saveToHistory], + ); + + // 저장 + const handleSave = useCallback(async () => { + if (!selectedScreen?.screenId) return; + + try { + setIsSaving(true); + await screenApi.saveLayout(selectedScreen.screenId, layout); + toast.success("화면이 저장되었습니다."); + } catch (error) { + console.error("저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }, [selectedScreen?.screenId, layout]); + + // 드래그 앤 드롭 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + + const dragData = e.dataTransfer.getData("application/json"); + if (!dragData) return; + + try { + const { type, table, column } = JSON.parse(dragData); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + let newComponent: ComponentData; + + if (type === "table") { + // 테이블 컨테이너 생성 + newComponent = { + id: generateComponentId(), + type: "container", + label: table.tableName, + tableName: table.tableName, + position: { x, y, z: 1 } as Position, + size: { width: 300, height: 200 }, + }; + } else if (type === "column") { + // 격자 기반 컬럼 너비 계산 + const columnWidth = gridInfo ? gridInfo.columnWidth : 200; + + // 컬럼 위젯 생성 + newComponent = { + id: generateComponentId(), + type: "widget", + label: column.columnName, + tableName: table.tableName, + columnName: column.columnName, + widgetType: column.widgetType, + // dataType: column.dataType, // WidgetComponent에 dataType 속성이 없음 + required: column.required, + readonly: false, // 누락된 속성 추가 + position: { x, y, z: 1 } as Position, + size: { width: columnWidth, height: 40 }, + }; + } else { + return; + } + + // 격자 스냅 적용 (올바른 타입 변환) + if (layout.gridSettings?.snapToGrid && gridInfo) { + const gridUtilSettings = { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }; + newComponent.position = snapToGrid(newComponent.position, gridInfo, gridUtilSettings); + newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, gridUtilSettings); + } + + const newLayout = { + ...layout, + components: [...layout.components, newComponent], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(newComponent); + + // 속성 패널 자동 열기 + openPanel("properties"); + } catch (error) { + console.error("드롭 처리 실패:", error); + } + }, + [layout, gridInfo, saveToHistory, openPanel], + ); + + // 컴포넌트 클릭 처리 (다중선택 지원) + const handleComponentClick = useCallback( + (component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지) + if (dragState.justFinishedDrag) { + return; + } + + const isShiftPressed = event?.shiftKey || false; + const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; + const isGroupContainer = component.type === "group"; + + if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { + // 다중 선택 모드 + if (isGroupContainer) { + // 그룹 컨테이너는 단일 선택으로 처리 + setSelectedComponent(component); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [component.id], + isGrouping: false, + })); + return; + } + + const isSelected = groupState.selectedComponents.includes(component.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: isSelected + ? prev.selectedComponents.filter((id) => id !== component.id) + : [...prev.selectedComponents, component.id], + })); + + // 마지막 선택된 컴포넌트를 selectedComponent로 설정 + if (!isSelected) { + setSelectedComponent(component); + } + } else { + // 단일 선택 모드 + setSelectedComponent(component); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [component.id], + })); + } + + // 속성 패널 자동 열기 + openPanel("properties"); + }, + [openPanel, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag], + ); + + // 컴포넌트 드래그 시작 + const startComponentDrag = useCallback( + (component: ComponentData, event: React.MouseEvent) => { + event.preventDefault(); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + // 새로운 드래그 시작 시 justFinishedDrag 플래그 해제 + if (dragState.justFinishedDrag) { + setDragState((prev) => ({ + ...prev, + justFinishedDrag: false, + })); + } + + // 다중 선택된 컴포넌트들 확인 + const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); + const componentsToMove = isDraggedComponentSelected + ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) + : [component]; + + console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length); + + setDragState({ + isDragging: true, + draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준) + draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들 + originalPosition: { + x: component.position.x, + y: component.position.y, + z: (component.position as Position).z || 1, + }, + currentPosition: { + x: component.position.x, + y: component.position.y, + z: (component.position as Position).z || 1, + }, + grabOffset: { + x: event.clientX - rect.left - component.position.x, + y: event.clientY - rect.top - component.position.y, + }, + justFinishedDrag: false, + }); + }, + [groupState.selectedComponents, layout.components, dragState.justFinishedDrag], + ); + + // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) + const updateDragPosition = useCallback( + (event: MouseEvent) => { + if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const newPosition = { + x: event.clientX - rect.left - dragState.grabOffset.x, + y: event.clientY - rect.top - dragState.grabOffset.y, + z: (dragState.draggedComponent.position as Position).z || 1, + }; + + // 드래그 상태 업데이트 + setDragState((prev) => ({ + ...prev, + currentPosition: newPosition, + })); + + // 실시간 피드백은 렌더링에서 처리하므로 setLayout 호출 제거 + // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, 실제 레이아웃 업데이트는 endDrag에서 처리 + }, + [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset], + ); + + // 드래그 종료 + const endDrag = useCallback(() => { + if (dragState.isDragging && dragState.draggedComponent) { + // 주 드래그 컴포넌트의 최종 위치에 격자 스냅 적용 + const finalPosition = + layout.gridSettings?.snapToGrid && gridInfo + ? snapToGrid(dragState.currentPosition, gridInfo, { + columns: layout.gridSettings.columns, + gap: layout.gridSettings.gap, + padding: layout.gridSettings.padding, + snapToGrid: layout.gridSettings.snapToGrid || false, + }) + : dragState.currentPosition; + + // 스냅으로 인한 추가 이동 거리 계산 + const snapDeltaX = finalPosition.x - dragState.currentPosition.x; + const snapDeltaY = finalPosition.y - dragState.currentPosition.y; + + // 원래 이동 거리 + 스냅 조정 거리 + const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX; + const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY; + + // 다중 컴포넌트들의 최종 위치 업데이트 + const updatedComponents = layout.components.map((comp) => { + const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id); + if (isDraggedComponent) { + const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!; + return { + ...comp, + position: { + x: originalComponent.position.x + totalDeltaX, + y: originalComponent.position.y + totalDeltaY, + z: originalComponent.position.z || 1, + } as Position, + }; + } + return comp; + }); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + + // 히스토리에 저장 + saveToHistory(newLayout); } - return { x: Math.round(x), y: Math.round(y) }; - }, [ - selectedFromLayout, - dragState.isDragging, - dragState.isMultiDrag, - dragState.currentPosition.x, - dragState.currentPosition.y, - dragState.initialMouse.x, - dragState.initialMouse.y, - dragState.grabOffset.x, - dragState.grabOffset.y, - groupState.selectedComponents, - ]); + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: true, + }); - // 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적) - const getAbsolutePosition = useCallback( - (comp: ComponentData) => { - let x = comp.position.x; - let y = comp.position.y; - let cur: ComponentData | undefined = comp; - while (cur.parentId) { - const parent = layout.components.find((c) => c.id === cur!.parentId); - if (!parent) break; - x += parent.position.x; - y += parent.position.y; - cur = parent; - } - return { x, y }; - }, - [layout.components], - ); + // 짧은 시간 후 justFinishedDrag 플래그 해제 + setTimeout(() => { + setDragState((prev) => ({ + ...prev, + justFinishedDrag: false, + })); + }, 100); + }, [dragState, layout, gridInfo, saveToHistory]); + + // 드래그 선택 시작 + const startSelectionDrag = useCallback( + (event: React.MouseEvent) => { + if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시 - // 마키 선택 시작 (캔버스 빈 영역 마우스다운) - const handleMarqueeStart = useCallback( - (e: React.MouseEvent) => { - if (dragState.isDragging) return; // 드래그 중이면 무시 const rect = canvasRef.current?.getBoundingClientRect(); - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const x = rect ? e.clientX - rect.left + scrollLeft : 0; - const y = rect ? e.clientY - rect.top + scrollTop : 0; - setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } }); - // 기존 선택 초기화 (Shift 미사용 시) - if (!e.shiftKey) { - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } + if (!rect) return; + + const startPoint = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + z: 1, + }; + + setSelectionDrag({ + isSelecting: true, + startPoint, + currentPoint: startPoint, + wasSelecting: false, + }); }, [dragState.isDragging], ); - // 마키 이동 - const handleMarqueeMove = useCallback( - (e: React.MouseEvent) => { - if (!selectionState.isSelecting) return; - const rect = canvasRef.current?.getBoundingClientRect(); - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const x = rect ? e.clientX - rect.left + scrollLeft : 0; - const y = rect ? e.clientY - rect.top + scrollTop : 0; - setSelectionState((prev) => ({ ...prev, current: { x, y } })); + // 드래그 선택 업데이트 + const updateSelectionDrag = useCallback( + (event: MouseEvent) => { + if (!selectionDrag.isSelecting || !canvasRef.current) return; + + const rect = canvasRef.current.getBoundingClientRect(); + const currentPoint = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + z: 1, + }; + + setSelectionDrag((prev) => ({ + ...prev, + currentPoint, + })); + + // 선택 영역 내의 컴포넌트들 찾기 + const selectionRect = { + left: Math.min(selectionDrag.startPoint.x, currentPoint.x), + top: Math.min(selectionDrag.startPoint.y, currentPoint.y), + right: Math.max(selectionDrag.startPoint.x, currentPoint.x), + bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), + }; + + const selectedIds = layout.components + .filter((comp) => { + const compRect = { + left: comp.position.x, + top: comp.position.y, + right: comp.position.x + comp.size.width, + bottom: comp.position.y + comp.size.height, + }; + + return ( + compRect.left < selectionRect.right && + compRect.right > selectionRect.left && + compRect.top < selectionRect.bottom && + compRect.bottom > selectionRect.top + ); + }) + .map((comp) => comp.id); + + setGroupState((prev) => ({ + ...prev, + selectedComponents: selectedIds, + })); }, - [selectionState.isSelecting], + [selectionDrag.isSelecting, selectionDrag.startPoint, layout.components], ); - // 마키 종료 -> 영역 내 컴포넌트 선택 - const handleMarqueeEnd = useCallback(() => { - if (!selectionState.isSelecting) return; - const minX = Math.min(selectionState.start.x, selectionState.current.x); - const minY = Math.min(selectionState.start.y, selectionState.current.y); - const maxX = Math.max(selectionState.start.x, selectionState.current.x); - const maxY = Math.max(selectionState.start.y, selectionState.current.y); - - const selectedIds = layout.components - // 그룹 컨테이너는 제외 - .filter((c) => c.type !== "group") - .filter((c) => { - const abs = getAbsolutePosition(c); - const left = abs.x; - const top = abs.y; - const right = abs.x + c.size.width; - const bottom = abs.y + c.size.height; - // 영역과 교차 여부 판단 (일부라도 겹치면 선택) - return right >= minX && left <= maxX && bottom >= minY && top <= maxY; - }) - .map((c) => c.id); - - setGroupState((prev) => ({ - ...prev, - selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])), - })); - setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } }); - }, [selectionState, layout.components, getAbsolutePosition]); - - // 선택된 화면의 테이블만 로드 (최적화된 API 사용) - useEffect(() => { - const fetchScreenTable = async () => { - if (!selectedScreen?.tableName) { - setTables([]); - return; - } - - try { - console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`); - const startTime = performance.now(); - - // 최적화된 단일 테이블 조회 API 사용 - const response = await fetch(`http://localhost:8080/api/screen-management/tables/${selectedScreen.tableName}`, { - headers: { - Authorization: `Bearer ${localStorage.getItem("authToken")}`, - }, - }); - - const endTime = performance.now(); - console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`); - - if (response.ok) { - const data = await response.json(); - if (data.success && data.data) { - setTables([data.data]); - console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`); - } else { - console.error("테이블 조회 실패:", data.message); - // 선택된 화면의 테이블에 대한 임시 데이터 생성 - setTables([createMockTableForScreen(selectedScreen.tableName)]); - } - } else if (response.status === 404) { - console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`); - // 테이블이 존재하지 않는 경우 임시 데이터 생성 - setTables([createMockTableForScreen(selectedScreen.tableName)]); - } else { - console.error("테이블 조회 실패:", response.status); - // 선택된 화면의 테이블에 대한 임시 데이터 생성 - setTables([createMockTableForScreen(selectedScreen.tableName)]); - } - } catch (error) { - console.error("테이블 조회 중 오류:", error); - // 선택된 화면의 테이블에 대한 임시 데이터 생성 - setTables([createMockTableForScreen(selectedScreen.tableName)]); - } - }; - - fetchScreenTable(); - }, [selectedScreen?.tableName]); - - // 검색된 테이블 필터링 - const filteredTables = useMemo(() => { - if (!searchTerm.trim()) return tables; - - return tables.filter( - (table) => - table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) || - table.columns.some( - (column) => - column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) || - (column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()), - ), + // 드래그 선택 종료 + const endSelectionDrag = useCallback(() => { + // 최소 드래그 거리 확인 (5픽셀) + const minDragDistance = 5; + const dragDistance = Math.sqrt( + Math.pow(selectionDrag.currentPoint.x - selectionDrag.startPoint.x, 2) + + Math.pow(selectionDrag.currentPoint.y - selectionDrag.startPoint.y, 2), ); - }, [tables, searchTerm]); - // 페이징된 테이블 - const paginatedTables = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - return filteredTables.slice(startIndex, endIndex); - }, [filteredTables, currentPage, itemsPerPage]); + const wasActualDrag = dragDistance > minDragDistance; - // 총 페이지 수 계산 - const totalPages = Math.ceil(filteredTables.length / itemsPerPage); - - // 페이지 변경 핸들러 - const handlePageChange = (page: number) => { - setCurrentPage(page); - setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화 - }; - - // 검색어 변경 핸들러 - const handleSearchChange = (value: string) => { - setSearchTerm(value); - setCurrentPage(1); // 검색 시 첫 페이지로 이동 - setExpandedTables(new Set()); // 검색 시 확장 상태 초기화 - }; - - // 임시 테이블 데이터 (API 실패 시 사용) - // 사용하지 않는 getMockTables 함수 제거됨 - - // 특정 테이블에 대한 임시 데이터 생성 - const createMockTableForScreen = (tableName: string): TableInfo => { - // 기본 컬럼들 생성 - const baseColumns = [ - { - tableName, - columnName: "id", - columnLabel: "ID", - webType: "number" as WebType, - dataType: "BIGINT", - isNullable: "NO", - }, - { - tableName, - columnName: "name", - columnLabel: "이름", - webType: "text" as WebType, - dataType: "VARCHAR", - isNullable: "NO", - }, - { - tableName, - columnName: "description", - columnLabel: "설명", - webType: "textarea" as WebType, - dataType: "TEXT", - isNullable: "YES", - }, - { - tableName, - columnName: "created_date", - columnLabel: "생성일", - webType: "date" as WebType, - dataType: "TIMESTAMP", - isNullable: "NO", - }, - { - tableName, - columnName: "updated_date", - columnLabel: "수정일", - webType: "date" as WebType, - dataType: "TIMESTAMP", - isNullable: "YES", - }, - ]; - - return { - tableName, - tableLabel: `${tableName} (임시)`, - columns: baseColumns, - }; - }; - - // 테이블 확장/축소 토글 - const toggleTableExpansion = useCallback((tableName: string) => { - setExpandedTables((prev) => { - const newSet = new Set(prev); - if (newSet.has(tableName)) { - newSet.delete(tableName); - } else { - newSet.add(tableName); - } - return newSet; + setSelectionDrag({ + isSelecting: false, + startPoint: { x: 0, y: 0, z: 1 }, + currentPoint: { x: 0, y: 0, z: 1 }, + wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시 }); - }, []); - // 웹타입에 따른 위젯 타입 매핑 - const getWidgetTypeFromWebType = useCallback((webType: string): string => { - console.log("getWidgetTypeFromWebType - input webType:", webType); - switch (webType) { - case "text": - return "text"; - case "email": - return "email"; - case "tel": - return "tel"; - case "number": - return "number"; - case "decimal": - return "decimal"; - case "date": - return "date"; - case "datetime": - return "datetime"; - case "select": - return "select"; - case "dropdown": - return "dropdown"; - case "textarea": - return "textarea"; - case "text_area": - return "text_area"; - case "checkbox": - return "checkbox"; - case "boolean": - return "boolean"; - case "radio": - return "radio"; - case "code": - return "code"; - case "entity": - return "entity"; - case "file": - return "file"; - default: - console.log("getWidgetTypeFromWebType - default case, returning text for:", webType); - return "text"; + // 짧은 시간 후 wasSelecting을 false로 리셋 + setTimeout(() => { + setSelectionDrag((prev) => ({ + ...prev, + wasSelecting: false, + })); + }, 100); + }, [selectionDrag.currentPoint, selectionDrag.startPoint]); + + // 컴포넌트 삭제 (단일/다중 선택 지원) + const deleteComponent = useCallback(() => { + // 다중 선택된 컴포넌트가 있는 경우 + if (groupState.selectedComponents.length > 0) { + console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개"); + + let newComponents = [...layout.components]; + + // 각 선택된 컴포넌트를 삭제 처리 + groupState.selectedComponents.forEach((componentId) => { + const component = layout.components.find((comp) => comp.id === componentId); + if (!component) return; + + if (component.type === "group") { + // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 + const childComponents = newComponents.filter((comp) => comp.parentId === component.id); + const restoredChildren = restoreAbsolutePositions(childComponents, component.position); + + newComponents = newComponents + .map((comp) => { + if (comp.parentId === component.id) { + // 복원된 절대 위치로 업데이트 + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; + } + return comp; + }) + .filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거 + } else { + // 일반 컴포넌트 삭제 + newComponents = newComponents.filter((comp) => comp.id !== component.id); + } + }); + + const newLayout = { ...layout, components: newComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + + // 선택 상태 초기화 + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + + toast.success(`${groupState.selectedComponents.length}개 컴포넌트가 삭제되었습니다.`); + return; } - }, []); - // 범용 복사 함수 - const copyComponents = useCallback(() => { - if (!selectedComponent && groupState.selectedComponents.length === 0) return; + // 단일 선택된 컴포넌트 삭제 + if (!selectedComponent) return; - let componentsToCopy: ComponentData[] = []; - let copyType: "single" | "multiple" | "group" = "single"; + console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id); - if (selectedComponent?.type === "group") { - // 그룹 복사 - const children = getGroupChildren(layout.components, selectedComponent.id); - componentsToCopy = [selectedComponent, ...children]; - copyType = "group"; - } else if (groupState.selectedComponents.length > 1) { - // 다중 선택 복사 - componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - copyType = "multiple"; + let newComponents; + + if (selectedComponent.type === "group") { + // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 후 그룹 삭제 + const childComponents = layout.components.filter((comp) => comp.parentId === selectedComponent.id); + const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); + + newComponents = layout.components + .map((comp) => { + if (comp.parentId === selectedComponent.id) { + // 복원된 절대 위치로 업데이트 + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; + } + return comp; + }) + .filter((comp) => comp.id !== selectedComponent.id); // 그룹 컴포넌트 제거 + } else { + // 일반 컴포넌트 삭제 + newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); + } + + const newLayout = { ...layout, components: newComponents }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(null); + toast.success("컴포넌트가 삭제되었습니다."); + }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory]); + + // 컴포넌트 복사 + const copyComponent = useCallback(() => { + if (groupState.selectedComponents.length > 0) { + // 다중 선택된 컴포넌트들 복사 + const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + setClipboard(componentsToCopy); + console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개"); + toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`); } else if (selectedComponent) { // 단일 컴포넌트 복사 - componentsToCopy = [selectedComponent]; - copyType = "single"; + setClipboard([selectedComponent]); + console.log("단일 컴포넌트 복사:", selectedComponent.id); + toast.success("컴포넌트가 복사되었습니다."); } - - if (componentsToCopy.length === 0) return; - - // 바운딩 박스 계산 - const positions = componentsToCopy.map((comp) => ({ - x: comp.position.x, - y: comp.position.y, - width: comp.size.width, - height: comp.size.height, - })); - - const minX = Math.min(...positions.map((p) => p.x)); - const minY = Math.min(...positions.map((p) => p.y)); - const maxX = Math.max(...positions.map((p) => p.x + p.width)); - const maxY = Math.max(...positions.map((p) => p.y + p.height)); - - setClipboard({ - type: copyType, - data: componentsToCopy, - offset: { x: 20, y: 20 }, - boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, - }); }, [selectedComponent, groupState.selectedComponents, layout.components]); - // 범용 삭제 함수 - const deleteComponents = useCallback(() => { - if (!selectedComponent && groupState.selectedComponents.length === 0) return; - - let idsToRemove: string[] = []; - - if (selectedComponent?.type === "group") { - // 그룹 삭제 (자식 컴포넌트 포함) - const childrenIds = getGroupChildren(layout.components, selectedComponent.id).map((child) => child.id); - idsToRemove = [selectedComponent.id, ...childrenIds]; - } else if (groupState.selectedComponents.length > 1) { - // 다중 선택 삭제 - idsToRemove = [...groupState.selectedComponents]; - } else if (selectedComponent) { - // 단일 컴포넌트 삭제 - idsToRemove = [selectedComponent.id]; + // 컴포넌트 붙여넣기 + const pasteComponent = useCallback(() => { + if (clipboard.length === 0) { + toast.warning("복사된 컴포넌트가 없습니다."); + return; } - if (idsToRemove.length === 0) return; + const newComponents: ComponentData[] = []; + const offset = 20; // 붙여넣기 시 위치 오프셋 + + clipboard.forEach((clipComponent, index) => { + const newComponent: ComponentData = { + ...clipComponent, + id: generateComponentId(), + position: { + x: clipComponent.position.x + offset + index * 10, + y: clipComponent.position.y + offset + index * 10, + z: clipComponent.position.z || 1, + } as Position, + parentId: undefined, // 붙여넣기 시 부모 관계 해제 + }; + newComponents.push(newComponent); + }); const newLayout = { ...layout, - components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)), + components: [...layout.components, ...newComponents], }; + setLayout(newLayout); saveToHistory(newLayout); - // 선택 상태 초기화 - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory, setGroupState]); + // 붙여넣은 컴포넌트들을 선택 상태로 만들기 + setGroupState((prev) => ({ + ...prev, + selectedComponents: newComponents.map((comp) => comp.id), + })); - // 범용 붙여넣기 함수 - const pasteComponents = useCallback( - (pastePosition?: { x: number; y: number }) => { - if (!clipboard || clipboard.data.length === 0) return; + console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); + toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); + }, [clipboard, layout, saveToHistory]); - const idMap = new Map(); - const newComponents: ComponentData[] = []; - - // 붙여넣기 위치 결정 - let targetPosition = pastePosition; - if (!targetPosition && clipboard.boundingBox) { - targetPosition = { - x: clipboard.boundingBox.x + clipboard.offset.x, - y: clipboard.boundingBox.y + clipboard.offset.y, - }; - } - - const offsetX = targetPosition ? targetPosition.x - (clipboard.boundingBox?.x || 0) : clipboard.offset.x; - const offsetY = targetPosition ? targetPosition.y - (clipboard.boundingBox?.y || 0) : clipboard.offset.y; - - // 모든 컴포넌트에 대해 새 ID 생성 - clipboard.data.forEach((comp) => { - const newId = generateComponentId(); - idMap.set(comp.id, newId); - }); - - // 컴포넌트 복사 및 ID/위치 업데이트 - clipboard.data.forEach((comp) => { - const newComp: ComponentData = { - ...comp, - id: idMap.get(comp.id)!, - position: { - x: comp.position.x + offsetX, - y: comp.position.y + offsetY, - }, - // 부모 ID가 있고 매핑되는 경우 업데이트 - parentId: comp.parentId && idMap.has(comp.parentId) ? idMap.get(comp.parentId)! : undefined, - }; - newComponents.push(newComp); - }); - - const newLayout = { - ...layout, - components: [...layout.components, ...newComponents], - }; - setLayout(newLayout); - saveToHistory(newLayout); - }, - [clipboard, layout, saveToHistory], - ); - - // 캔버스 우클릭 컨텍스트 메뉴 - const handleCanvasContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - // 우클릭 시 붙여넣기 (클립보드에 데이터가 있는 경우) - if (clipboard && clipboard.data.length > 0) { - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - pasteComponents({ x, y }); - } - }, - [clipboard, pasteComponents], - ); - - // 키보드 단축키 지원 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey) { - switch (e.key) { - case "z": - e.preventDefault(); - if (e.shiftKey) { - redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z - } else { - undo(); // Ctrl+Z 또는 Cmd+Z - } - break; - case "y": - e.preventDefault(); - redo(); // Ctrl+Y 또는 Cmd+Y - break; - case "c": - e.preventDefault(); - // 선택된 컴포넌트(들) 복사 - copyComponents(); - break; - case "v": - e.preventDefault(); - // 클립보드 내용 붙여넣기 - if (clipboard && clipboard.data.length > 0) { - pasteComponents(); - } - break; - case "g": - case "G": - e.preventDefault(); - if (e.shiftKey) { - // Ctrl+Shift+G: 그룹 해제 - const selectedComponents = layout.components.filter((comp) => - groupState.selectedComponents.includes(comp.id), - ); - if (selectedComponents.length === 1 && selectedComponents[0].type === "group") { - // 그룹 해제 로직을 직접 실행 - const group = selectedComponents[0] as any; - const groupChildren = layout.components.filter((comp) => comp.parentId === group.id); - - // 자식 컴포넌트들의 절대 위치 복원 - const absoluteChildren = groupChildren.map((child) => ({ - ...child, - position: { - x: child.position.x + group.position.x, - y: child.position.y + group.position.y, - z: (child.position as any).z || 1, - }, - parentId: undefined, - })); - - const newLayout = { - ...layout, - components: [ - ...layout.components.filter((comp) => comp.id !== group.id && comp.parentId !== group.id), - ...absoluteChildren, - ], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - setGroupState((prev) => ({ - ...prev, - selectedComponents: [], - isGrouping: false, - })); - } - } else { - // Ctrl+G: 그룹 생성 다이얼로그 열기 - const selectedComponents = layout.components.filter((comp) => - groupState.selectedComponents.includes(comp.id), - ); - if (selectedComponents.length >= 2) { - setShowGroupCreateDialog(true); - } - } - break; - } - } else if (e.key === "Delete") { - e.preventDefault(); - // 선택된 컴포넌트(들) 삭제 - deleteComponents(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - undo, - redo, - copyComponents, - pasteComponents, - deleteComponents, - clipboard, - layout, - groupState, - saveToHistory, - setLayout, - setGroupState, - setShowGroupCreateDialog, - ]); - - // 컴포넌트 속성 업데이트 함수 - const updateComponentProperty = useCallback( - (componentId: string, propertyPath: string, value: any) => { - const newLayout = { - ...layout, - components: layout.components.map((comp) => { - if (comp.id === componentId) { - const newComp = { ...comp }; - const pathParts = propertyPath.split("."); - let current: any = newComp; - - for (let i = 0; i < pathParts.length - 1; i++) { - current = current[pathParts[i]]; - } - current[pathParts[pathParts.length - 1]] = value; - - // 크기 변경 시 격자 스냅 적용 - if ( - (propertyPath === "size.width" || propertyPath === "size.height") && - layout.gridSettings?.snapToGrid && - gridInfo - ) { - const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); - newComp.size = snappedSize; - } - - return newComp; - } - return comp; - }), - }; - setLayout(newLayout); - saveToHistory(newLayout); - // 선택된 컴포넌트인 경우 즉시 상태도 동기화하여 입력 즉시 반영되도록 처리 - if (selectedComponent && selectedComponent.id === componentId) { - const updated = newLayout.components.find((c) => c.id === componentId) || null; - if (updated) setSelectedComponent(updated); - } - }, - [layout, saveToHistory, selectedComponent, gridInfo], - ); - - // 그룹 생성 함수 + // 그룹 생성 const handleGroupCreate = useCallback( (componentIds: string[], title: string, style?: any) => { const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id)); - - if (selectedComponents.length < 2) { - return; - } + if (selectedComponents.length < 2) return; // 경계 박스 계산 const boundingBox = calculateBoundingBox(selectedComponents); - // 그룹 컴포넌트 생성 (경계 박스 정보 전달) + // 그룹 컴포넌트 생성 const groupComponent = createGroupComponent( componentIds, title, @@ -890,1217 +936,698 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 자식 컴포넌트들의 상대 위치 계산 const relativeChildren = calculateRelativePositions( selectedComponents, - { - x: boundingBox.minX, - y: boundingBox.minY, - }, + { x: boundingBox.minX, y: boundingBox.minY }, groupComponent.id, ); - // 새 레이아웃 생성 const newLayout = { ...layout, components: [ - // 그룹에 포함되지 않은 기존 컴포넌트들만 유지 ...layout.components.filter((comp) => !componentIds.includes(comp.id)), - // 그룹 컴포넌트 추가 groupComponent, - // 자식 컴포넌트들도 유지 (parentId로 그룹과 연결) ...relativeChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); }, [layout, saveToHistory], ); - // 그룹 해제 함수 - const handleGroupUngroup = useCallback( - (groupId: string) => { - const group = layout.components.find((comp) => comp.id === groupId) as GroupComponent; - if (!group || group.type !== "group") { - return; - } - - const groupChildren = getGroupChildren(layout.components, groupId); - - // 자식 컴포넌트들의 절대 위치 복원 - const absoluteChildren = restoreAbsolutePositions(groupChildren, group.position); - - // 새 레이아웃 생성 - const newLayout = { - ...layout, - components: [ - // 그룹과 그룹의 자식 컴포넌트들을 제외한 기존 컴포넌트들 - ...layout.components.filter((comp) => comp.id !== groupId && comp.parentId !== groupId), - // 절대 위치로 복원된 자식 컴포넌트들 - ...absoluteChildren, - ], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - }, - [layout, saveToHistory], - ); - - // 레이아웃 저장 함수 - const saveLayout = useCallback(async () => { - if (!selectedScreen) { - toast.error("저장할 화면이 선택되지 않았습니다."); + // 그룹 생성 함수 (다이얼로그 표시) + const createGroup = useCallback(() => { + if (groupState.selectedComponents.length < 2) { + toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다."); return; } - try { - setIsSaving(true); - await screenApi.saveLayout(selectedScreen.screenId, layout); - setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제 - toast.success("레이아웃이 성공적으로 저장되었습니다."); - } catch (error) { - console.error("레이아웃 저장 실패:", error); - toast.error("레이아웃 저장에 실패했습니다."); - } finally { - setIsSaving(false); - } - }, [layout, selectedScreen]); + console.log("🔄 그룹 생성 다이얼로그 표시"); + setShowGroupCreateDialog(true); + }, [groupState.selectedComponents]); - // 레이아웃 로드 함수 - const loadLayout = useCallback(async () => { - if (!selectedScreen) return; + // 그룹 해제 함수 + const ungroupComponents = useCallback(() => { + if (!selectedComponent || selectedComponent.type !== "group") return; - try { - setIsLoading(true); - const savedLayout = await screenApi.getLayout(selectedScreen.screenId); + const groupId = selectedComponent.id; - if (savedLayout && savedLayout.components) { - // 격자 설정이 없는 경우 기본값 추가 - if (!savedLayout.gridSettings) { - savedLayout.gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true }; - } else if (savedLayout.gridSettings.snapToGrid === undefined) { - savedLayout.gridSettings.snapToGrid = true; + // 자식 컴포넌트들의 절대 위치 복원 + const childComponents = layout.components.filter((comp) => comp.parentId === groupId); + const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); + + // 자식 컴포넌트들의 위치 복원 및 parentId 제거 + const updatedComponents = layout.components + .map((comp) => { + if (comp.parentId === groupId) { + const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); + return restoredChild || { ...comp, parentId: undefined }; } + return comp; + }) + .filter((comp) => comp.id !== groupId); // 그룹 컴포넌트 제거 - setLayout(savedLayout); - // 히스토리 초기화 - setHistory([savedLayout]); - setHistoryIndex(0); - setHasUnsavedChanges(false); // 로드 완료 시 변경사항 플래그 해제 - toast.success("레이아웃을 불러왔습니다."); - } else { - // 저장된 레이아웃이 없는 경우 기본 레이아웃 유지 - const defaultLayout = { - components: [], - gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, - }; - setLayout(defaultLayout); - setHistory([defaultLayout]); - setHistoryIndex(0); - setHasUnsavedChanges(false); - } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - // 에러 시에도 기본 레이아웃으로 초기화 - const defaultLayout = { - components: [], - gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, - }; - setLayout(defaultLayout); - setHistory([defaultLayout]); - setHistoryIndex(0); - setHasUnsavedChanges(false); - toast.error("레이아웃 로드에 실패했습니다. 새 레이아웃으로 시작합니다."); - } finally { - setIsLoading(false); - } - }, [selectedScreen]); + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); - // 화면 선택 시 레이아웃 로드 + // 선택 상태 초기화 + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + }, [selectedComponent, layout, saveToHistory]); + + // 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화 useEffect(() => { - if (selectedScreen) { - loadLayout(); + let animationFrameId: number; + + const handleMouseMove = (e: MouseEvent) => { + if (dragState.isDragging) { + // requestAnimationFrame으로 부드러운 애니메이션 + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(() => { + updateDragPosition(e); + }); + } else if (selectionDrag.isSelecting) { + updateSelectionDrag(e); + } + }; + + const handleMouseUp = () => { + if (dragState.isDragging) { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + endDrag(); + } else if (selectionDrag.isSelecting) { + endSelectionDrag(); + } + }; + + if (dragState.isDragging || selectionDrag.isSelecting) { + document.addEventListener("mousemove", handleMouseMove, { passive: true }); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; } - }, [selectedScreen, loadLayout]); + }, [ + dragState.isDragging, + selectionDrag.isSelecting, + updateDragPosition, + endDrag, + updateSelectionDrag, + endSelectionDrag, + ]); - // 스크롤 컨테이너 참조 (좌표 계산 정확도 향상) - const scrollContainerRef = useRef(null); + // 캔버스 크기 초기화 및 리사이즈 이벤트 처리 + useEffect(() => { + const updateCanvasSize = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + setCanvasSize({ width: rect.width, height: rect.height }); + } + }; - // 드래그 시작 (새 컴포넌트 추가) - const startDrag = useCallback((component: Partial, e: React.DragEvent) => { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; - const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; + // 초기 크기 설정 + updateCanvasSize(); - setDragState({ - isDragging: true, - draggedComponent: component as ComponentData, - draggedComponents: [component as ComponentData], - originalPosition: { x: 0, y: 0 }, - currentPosition: { x: relMouseX, y: relMouseY }, - isMultiDrag: false, - initialMouse: { x: relMouseX, y: relMouseY }, - grabOffset: { x: 0, y: 0 }, - }); - e.dataTransfer.setData("application/json", JSON.stringify(component)); + // 리사이즈 이벤트 리스너 + window.addEventListener("resize", updateCanvasSize); + + return () => window.removeEventListener("resize", updateCanvasSize); }, []); - // 기존 컴포넌트 드래그 시작 (재배치) - const startComponentDrag = useCallback( - (component: ComponentData, e: React.DragEvent) => { - e.stopPropagation(); - - // 다중선택된 컴포넌트들이 있는지 확인 - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id); - - // 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리) - const canvasRect = canvasRef.current?.getBoundingClientRect(); - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; - const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; - const grabOffsetX = relMouseX - component.position.x; - const grabOffsetY = relMouseY - component.position.y; - - if (isMultiDrag) { - // 다중 드래그 - setDragState({ - isDragging: true, - draggedComponent: component, - draggedComponents: selectedComponents, - originalPosition: component.position, - currentPosition: { x: relMouseX, y: relMouseY }, - isMultiDrag: true, - initialMouse: { x: relMouseX, y: relMouseY }, - grabOffset: { x: grabOffsetX, y: grabOffsetY }, - }); - e.dataTransfer.setData( - "application/json", - JSON.stringify({ - ...component, - isMoving: true, - isMultiDrag: true, - selectedComponentIds: groupState.selectedComponents, - }), - ); - } else { - // 단일 드래그 - setDragState({ - isDragging: true, - draggedComponent: component, - draggedComponents: [component], - originalPosition: component.position, - currentPosition: { x: relMouseX, y: relMouseY }, - isMultiDrag: false, - initialMouse: { x: relMouseX, y: relMouseY }, - grabOffset: { x: grabOffsetX, y: grabOffsetY }, - }); - e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); + // 컴포넌트 마운트 후 캔버스 크기 업데이트 + useEffect(() => { + const timer = setTimeout(() => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + setCanvasSize({ width: rect.width, height: rect.height }); } - }, - [layout.components, groupState.selectedComponents], - ); + }, 100); - // 드래그 중 - const onDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - if (dragState.isDragging) { - const rect = canvasRef.current?.getBoundingClientRect(); - // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const x = rect ? e.clientX - rect.left + scrollLeft : 0; - const y = rect ? e.clientY - rect.top + scrollTop : 0; + return () => clearTimeout(timer); + }, [selectedScreen]); - setDragState((prev) => ({ - ...prev, - currentPosition: { x, y }, - })); + // 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단) + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey }); + + // 🚫 브라우저 기본 단축키 완전 차단 목록 + const browserShortcuts = [ + // 검색 관련 + { ctrl: true, key: "f" }, // 페이지 내 검색 + { ctrl: true, key: "g" }, // 다음 검색 결과 + { ctrl: true, shift: true, key: "g" }, // 이전 검색 결과 + { ctrl: true, key: "h" }, // 검색 기록 + + // 탭/창 관리 + { ctrl: true, key: "t" }, // 새 탭 + { ctrl: true, key: "w" }, // 탭 닫기 + { ctrl: true, shift: true, key: "t" }, // 닫힌 탭 복원 + { ctrl: true, key: "n" }, // 새 창 + { ctrl: true, shift: true, key: "n" }, // 시크릿 창 + + // 페이지 관리 + { ctrl: true, key: "r" }, // 새로고침 + { ctrl: true, shift: true, key: "r" }, // 강제 새로고침 + { ctrl: true, key: "d" }, // 북마크 추가 + { ctrl: true, shift: true, key: "d" }, // 모든 탭 북마크 + + // 편집 관련 (필요시에만 허용) + { ctrl: true, key: "s" }, // 저장 (필요시 차단 해제) + { ctrl: true, key: "p" }, // 인쇄 + { ctrl: true, key: "o" }, // 파일 열기 + { ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단) + + // 개발자 도구 + { key: "F12" }, // 개발자 도구 + { ctrl: true, shift: true, key: "i" }, // 개발자 도구 + { ctrl: true, shift: true, key: "c" }, // 요소 검사 + { ctrl: true, shift: true, key: "j" }, // 콘솔 + { ctrl: true, key: "u" }, // 소스 보기 + + // 기타 + { ctrl: true, key: "j" }, // 다운로드 + { ctrl: true, shift: true, key: "delete" }, // 브라우징 데이터 삭제 + { ctrl: true, key: "+" }, // 확대 + { ctrl: true, key: "-" }, // 축소 + { ctrl: true, key: "0" }, // 확대/축소 초기화 + ]; + + // 브라우저 기본 단축키 체크 및 차단 + const isBrowserShortcut = browserShortcuts.some((shortcut) => { + const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true; + const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey; + const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase(); + return ctrlMatch && shiftMatch && keyMatch; + }); + + if (isBrowserShortcut) { + console.log("🚫 브라우저 기본 단축키 차단:", e.key); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); } - }, - [dragState.isDragging], - ); - // 드롭 처리 - const onDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); + // ✅ 애플리케이션 전용 단축키 처리 - try { - const data = JSON.parse(e.dataTransfer.getData("application/json")); + // 1. 그룹 관련 단축키 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g" && !e.shiftKey) { + console.log("🔄 그룹 생성 단축키"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); - if (data.isMoving) { - // 기존 컴포넌트 재배치 - const rect = canvasRef.current?.getBoundingClientRect(); - // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - const mouseX = rect ? e.clientX - rect.left + scrollLeft : 0; - const mouseY = rect ? e.clientY - rect.top + scrollTop : 0; + if (groupState.selectedComponents.length >= 2) { + console.log("✅ 그룹 생성 실행"); + createGroup(); + } else { + console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)"); + } + return false; + } - if (data.isMultiDrag && data.selectedComponentIds) { - // 다중 드래그 처리 - // 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영) - const dropX = mouseX - dragState.grabOffset.x; - const dropY = mouseY - dragState.grabOffset.y; - const deltaX = dropX - dragState.originalPosition.x; - const deltaY = dropY - dragState.originalPosition.y; + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "g") { + console.log("🔄 그룹 해제 단축키"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); - const newLayout = { - ...layout, - components: layout.components.map((comp) => { - if (data.selectedComponentIds.includes(comp.id)) { - let newX = comp.position.x + deltaX; - let newY = comp.position.y + deltaY; + if (selectedComponent && selectedComponent.type === "group") { + console.log("✅ 그룹 해제 실행"); + ungroupComponents(); + } else { + console.log("⚠️ 선택된 그룹이 없음"); + } + return false; + } - // 격자 스냅 적용 - if (layout.gridSettings?.snapToGrid && gridInfo) { - const snappedPosition = snapToGrid( - { x: newX, y: newY, z: comp.position.z || 1 } as Required, - gridInfo, - layout.gridSettings as GridUtilSettings, - ); - newX = snappedPosition.x; - newY = snappedPosition.y; - } + // 2. 전체 선택 (애플리케이션 내에서만) + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + console.log("🔄 전체 선택 (애플리케이션 내)"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const allComponentIds = layout.components.map((comp) => comp.id); + setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds })); + return false; + } - return { - ...comp, - position: { - x: newX, - y: newY, - z: comp.position.z || 1, - }, - }; - } - return comp; - }), - }; - setLayout(newLayout); - saveToHistory(newLayout); - } else { - // 단일 드래그 처리 - let x = mouseX - dragState.grabOffset.x; - let y = mouseY - dragState.grabOffset.y; + // 3. 실행취소/다시실행 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { + console.log("🔄 실행취소"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + undo(); + return false; + } - // 격자 스냅 적용 - if (layout.gridSettings?.snapToGrid && gridInfo) { - const snappedPosition = snapToGrid( - { x, y, z: 1 } as Required, - gridInfo, - layout.gridSettings as GridUtilSettings, - ); - x = snappedPosition.x; - y = snappedPosition.y; - } + if ( + ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") || + ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") + ) { + console.log("🔄 다시실행"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + redo(); + return false; + } - const newLayout = { - ...layout, - components: layout.components.map((comp) => - comp.id === data.id ? { ...comp, position: { x, y, z: comp.position.z || 1 } } : comp, - ), - }; - setLayout(newLayout); - saveToHistory(newLayout); + // 4. 복사 (컴포넌트 복사) + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { + console.log("🔄 컴포넌트 복사"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + copyComponent(); + return false; + } + + // 5. 붙여넣기 (컴포넌트 붙여넣기) + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") { + console.log("🔄 컴포넌트 붙여넣기"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + pasteComponent(); + return false; + } + + // 6. 삭제 (단일/다중 선택 지원) + if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { + console.log("🗑️ 컴포넌트 삭제 (단축키)"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + deleteComponent(); + return false; + } + + // 7. 선택 해제 + if (e.key === "Escape") { + console.log("🔄 선택 해제"); + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false })); + return false; + } + + // 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용) + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + console.log("💾 레이아웃 저장"); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + // 레이아웃 저장 실행 + if (layout.components.length > 0 && selectedScreen?.screenId) { + setIsSaving(true); + try { + await screenApi.saveLayout(selectedScreen.screenId, layout); + toast.success("레이아웃이 저장되었습니다."); + } catch (error) { + console.error("레이아웃 저장 실패:", error); + toast.error("레이아웃 저장에 실패했습니다."); + } finally { + setIsSaving(false); } } else { - // 새 컴포넌트 추가 - const rect = canvasRef.current?.getBoundingClientRect(); - // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) - const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; - const scrollTop = scrollContainerRef.current?.scrollTop || 0; - let x = rect ? e.clientX - rect.left + scrollLeft : 0; - let y = rect ? e.clientY - rect.top + scrollTop : 0; - - // 격자 스냅 적용 - if (layout.gridSettings?.snapToGrid && gridInfo) { - const snappedPosition = snapToGrid( - { x, y, z: 1 } as Required, - gridInfo, - layout.gridSettings as GridUtilSettings, - ); - x = snappedPosition.x; - y = snappedPosition.y; - } - - // 기본 크기를 격자에 맞춰 설정 - let defaultWidth = data.size?.width || 200; - const defaultHeight = data.size?.height || 100; - - if (layout.gridSettings?.snapToGrid && gridInfo) { - const { columnWidth } = gridInfo; - const { gap } = layout.gridSettings; - // 기본적으로 1컬럼 너비로 설정 - const gridColumns = 1; - defaultWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; - } - - const newComponent: ComponentData = { - ...data, - id: generateComponentId(), - position: { x, y, z: 1 }, - size: { width: defaultWidth, height: defaultHeight }, - } as ComponentData; - - const newLayout = { - ...layout, - components: [...layout.components, newComponent], - }; - setLayout(newLayout); - saveToHistory(newLayout); + console.log("⚠️ 저장할 컴포넌트가 없습니다"); + toast.warning("저장할 컴포넌트가 없습니다."); } - } catch (error) { - console.error("드롭 처리 중 오류:", error); + return false; } + }; - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0 }, - currentPosition: { x: 0, y: 0 }, - isMultiDrag: false, - initialMouse: { x: 0, y: 0 }, - grabOffset: { x: 0, y: 0 }, - }); - }, - [ - layout, - saveToHistory, - dragState.initialMouse.x, - dragState.initialMouse.y, - dragState.grabOffset.x, - dragState.grabOffset.y, - gridInfo, - ], - ); + // window 레벨에서 캡처 단계에서 가장 먼저 처리 + window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [ + selectedComponent, + deleteComponent, + copyComponent, + pasteComponent, + undo, + redo, + createGroup, + ungroupComponents, + groupState.selectedComponents, + layout, + selectedScreen, + ]); - // 드래그 종료 - const endDrag = useCallback(() => { - // 격자 스냅 적용 - if (dragState.isDragging && dragState.draggedComponent && gridInfo && layout.gridSettings?.snapToGrid) { - const component = dragState.draggedComponent; - const snappedPosition = snapToGrid(dragState.currentPosition, gridInfo, layout.gridSettings as GridUtilSettings); - - // 스냅된 위치로 컴포넌트 업데이트 - if (snappedPosition.x !== dragState.currentPosition.x || snappedPosition.y !== dragState.currentPosition.y) { - const updatedComponents = layout.components.map((comp) => - comp.id === component.id ? { ...comp, position: snappedPosition } : comp, - ); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - } - } - - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0 }, - currentPosition: { x: 0, y: 0 }, - isMultiDrag: false, - initialMouse: { x: 0, y: 0 }, - grabOffset: { x: 0, y: 0 }, - }); - }, [dragState, gridInfo, layout, saveToHistory]); - - // 컴포넌트 클릭 (선택) - const handleComponentClick = useCallback( - (component: ComponentData, event?: React.MouseEvent) => { - const isShiftPressed = event?.shiftKey || false; - const isGroupContainer = component.type === "group"; - - if (groupState.isGrouping || isShiftPressed) { - // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택 - if (isGroupContainer) { - // 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리 - setSelectedComponent(component); - setGroupState((prev) => ({ - ...prev, - selectedComponents: [component.id], - isGrouping: false, // 그룹 선택 시 그룹화 모드 해제 - })); - return; - } - const isSelected = groupState.selectedComponents.includes(component.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: isSelected - ? prev.selectedComponents.filter((id) => id !== component.id) - : [...prev.selectedComponents, component.id], - })); - - // 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정 - if (isShiftPressed) { - setSelectedComponent(component); - } - } else { - // 일반 모드에서는 단일 선택 - setSelectedComponent(component); - setGroupState((prev) => ({ - ...prev, - selectedComponents: [component.id], // 그룹도 선택 가능하도록 수정 - })); - } - }, - [groupState.isGrouping, groupState.selectedComponents], - ); - - // 화면이 선택되지 않았을 때 처리 if (!selectedScreen) { return (
-
- -

설계할 화면을 선택해주세요

-

화면 목록에서 화면을 선택한 후 설계기를 사용하세요

- +
+ +

화면을 선택하세요

+

설계할 화면을 먼저 선택해주세요.

); } return ( -
- {/* 상단 헤더 */} -
-
-

- {selectedScreen.screenName} - 화면 설계 - {isLoading && (로딩 중...)} -

- - {selectedScreen.tableName} - - {clipboard && clipboard.data.length > 0 && ( - - - {clipboard.type === "group" - ? "그룹 복사됨" - : clipboard.type === "multiple" - ? `${clipboard.data.length}개 복사됨` - : "컴포넌트 복사됨"} - - )} -
-
- - - {/* 복사/붙여넣기/삭제 버튼들 */} - {(selectedComponent || groupState.selectedComponents.length > 0) && ( - <> - - - - )} - - {/* 붙여넣기 버튼 */} - {clipboard && clipboard.data.length > 0 && ( - - )} - - - - -
-
- - {/* 그룹화 툴바 */} - groupState.selectedComponents.includes(comp.id))} - allComponents={layout.components} - onGroupAlign={(mode) => { - const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); - if (selected.length < 2) return; - - let newComponents = [...layout.components]; - const minX = Math.min(...selected.map((c) => c.position.x)); - const maxX = Math.max(...selected.map((c) => c.position.x + c.size.width)); - const minY = Math.min(...selected.map((c) => c.position.y)); - const maxY = Math.max(...selected.map((c) => c.position.y + c.size.height)); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - - newComponents = newComponents.map((c) => { - if (!groupState.selectedComponents.includes(c.id)) return c; - if (mode === "left") return { ...c, position: { x: minX, y: c.position.y } }; - if (mode === "right") return { ...c, position: { x: maxX - c.size.width, y: c.position.y } }; - if (mode === "centerX") - return { ...c, position: { x: Math.round(centerX - c.size.width / 2), y: c.position.y } }; - if (mode === "top") return { ...c, position: { x: c.position.x, y: minY } }; - if (mode === "bottom") return { ...c, position: { x: c.position.x, y: maxY - c.size.height } }; - if (mode === "centerY") - return { ...c, position: { x: c.position.x, y: Math.round(centerY - c.size.height / 2) } }; - return c; - }); - - const newLayout = { ...layout, components: newComponents }; - setLayout(newLayout); - saveToHistory(newLayout); +
+ {/* 상단 툴바 */} + { + toast.info("미리보기 기능은 준비 중입니다."); }} - onGroupDistribute={(orientation) => { - const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); - if (selected.length < 3) return; // 균등 분배는 3개 이상 권장 - - const sorted = [...selected].sort((a, b) => - orientation === "horizontal" ? a.position.x - b.position.x : a.position.y - b.position.y, - ); - - if (orientation === "horizontal") { - const left = sorted[0].position.x; - const right = Math.max(...sorted.map((c) => c.position.x + c.size.width)); - const totalWidth = right - left; - const gaps = sorted.length - 1; - const usedWidth = sorted.reduce((sum, c) => sum + c.size.width, 0); - const gapSize = gaps > 0 ? Math.max(0, Math.round((totalWidth - usedWidth) / gaps)) : 0; - - let cursor = left; - sorted.forEach((c, idx) => { - c.position.x = cursor; - cursor += c.size.width + gapSize; - }); - } else { - const top = sorted[0].position.y; - const bottom = Math.max(...sorted.map((c) => c.position.y + c.size.height)); - const totalHeight = bottom - top; - const gaps = sorted.length - 1; - const usedHeight = sorted.reduce((sum, c) => sum + c.size.height, 0); - const gapSize = gaps > 0 ? Math.max(0, Math.round((totalHeight - usedHeight) / gaps)) : 0; - - let cursor = top; - sorted.forEach((c, idx) => { - c.position.y = cursor; - cursor += c.size.height + gapSize; - }); - } - - const newLayout = { ...layout, components: [...layout.components] }; - setLayout(newLayout); - saveToHistory(newLayout); - }} - showCreateDialog={showGroupCreateDialog} - onShowCreateDialogChange={setShowGroupCreateDialog} + onTogglePanel={togglePanel} + panelStates={panelStates} + canUndo={historyIndex > 0} + canRedo={historyIndex < history.length - 1} + isSaving={isSaving} /> - {/* 메인 컨텐츠 영역 */} -
- {/* 좌측 사이드바 - 테이블 타입 */} -
-
-
-

테이블 타입

- {selectedScreen && ( -
-
선택된 화면
-
{selectedScreen.screenName}
-
- - {selectedScreen.tableName} -
-
- )} -
+ {/* 메인 캔버스 영역 (전체 화면) */} +
{ + if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }} + onMouseDown={(e) => { + if (e.target === e.currentTarget) { + startSelectionDrag(e); + } + }} + onDrop={handleDrop} + onDragOver={handleDragOver} + > + {/* 격자 라인 */} + {gridLines.map((line, index) => ( +
+ ))} - {/* 검색 입력창 */} -
- handleSearchChange(e.target.value)} - className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> -
+ {/* 컴포넌트들 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + const children = + component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; - {/* 검색 결과 정보 */} -
- 총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}- - {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째 -
+ // 드래그 중 시각적 피드백 (다중 선택 지원) + const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; + const isBeingDragged = + dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); -

테이블과 컬럼을 드래그하여 캔버스에 배치하세요.

-
+ let displayComponent = component; - {/* 테이블 목록 */} -
- {paginatedTables.length === 0 ? ( -
-
- -

- {selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"} -

-

- {selectedScreen - ? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.` - : "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."} -

-
-
- ) : ( - paginatedTables.map((table) => ( -
- {/* 테이블 헤더 */} -
- startDrag( - { - type: "container", - tableName: table.tableName, - label: table.tableLabel, - size: { width: 200, height: 80 }, // 픽셀 단위로 변경 + if (isBeingDragged) { + if (isDraggingThis) { + // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 + displayComponent = { + ...component, + position: dragState.currentPosition, + style: { + ...component.style, + opacity: 0.8, + transform: "scale(1.02)", + transition: "none", + zIndex: 9999, + }, + }; + } else { + // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 + const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === component.id); + if (originalComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayComponent = { + ...component, + position: { + x: originalComponent.position.x + deltaX, + y: originalComponent.position.y + deltaY, + z: originalComponent.position.z || 1, + } as Position, + style: { + ...component.style, + opacity: 0.8, + transition: "none", + zIndex: 8888, // 주 컴포넌트보다 약간 낮게 + }, + }; + } + } + } + + return ( + handleComponentClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + > + {children.map((child) => { + // 자식 컴포넌트에도 드래그 피드백 적용 + const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id; + const isChildBeingDragged = + dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); + + let displayChild = child; + + if (isChildBeingDragged) { + if (isChildDraggingThis) { + // 주 드래그 자식 컴포넌트 + displayChild = { + ...child, + position: dragState.currentPosition, + style: { + ...child.style, + opacity: 0.8, + transform: "scale(1.02)", + transition: "none", + zIndex: 9999, }, - e, - ) + }; + } else { + // 다른 선택된 자식 컴포넌트들 + const originalChildComponent = dragState.draggedComponents.find( + (dragComp) => dragComp.id === child.id, + ); + if (originalChildComponent) { + const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; + const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; + + displayChild = { + ...child, + position: { + x: originalChildComponent.position.x + deltaX, + y: originalChildComponent.position.y + deltaY, + z: originalChildComponent.position.z || 1, + } as Position, + style: { + ...child.style, + opacity: 0.8, + transition: "none", + zIndex: 8888, + }, + }; + } } - > -
- -
-
{table.tableLabel}
-
{table.tableName}
-
-
- -
+ } - {/* 컬럼 목록 */} - {expandedTables.has(table.tableName) && ( -
- {table.columns.map((column) => ( -
{ - console.log("Drag start - column:", column.columnName, "webType:", column.webType); - const widgetType = getWidgetTypeFromWebType(column.webType || "text"); - console.log("Drag start - widgetType:", widgetType); - startDrag( - { - type: "widget", - tableName: table.tableName, - columnName: column.columnName, - widgetType: widgetType as WebType, - label: column.columnLabel || column.columnName, - size: { width: 150, height: 40 }, // 픽셀 단위로 변경 - }, - e, - ); - }} - > -
- {column.webType === "text" && } - {column.webType === "email" && } - {column.webType === "tel" && } - {column.webType === "number" && } - {column.webType === "decimal" && } - {column.webType === "date" && } - {column.webType === "datetime" && } - {column.webType === "select" && } - {column.webType === "dropdown" && } - {column.webType === "textarea" && } - {column.webType === "text_area" && } - {column.webType === "checkbox" && } - {column.webType === "boolean" && } - {column.webType === "radio" && } - {column.webType === "code" && } - {column.webType === "entity" && } - {column.webType === "file" && } -
-
-
{column.columnLabel || column.columnName}
-
{column.columnName}
-
-
- ))} -
- )} -
- )) - )} -
+ return ( + handleComponentClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + /> + ); + })} + + ); + })} - {/* 페이징 컨트롤 */} - {totalPages > 1 && ( -
-
- + {/* 드래그 선택 영역 */} + {selectionDrag.isSelecting && ( +
+ )} -
- {currentPage} / {totalPages} -
- - -
-
- )} -
- - {/* 중앙: 캔버스 영역 */} -
-
-
- {/* 항상 격자와 캔버스 표시 */} -
- {/* 동적 그리드 가이드 */} -
-
- {Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => ( -
- ))} -
- - {/* 격자 스냅이 활성화된 경우 추가 가이드라인 */} - {layout.gridSettings?.snapToGrid && gridInfo && ( -
- {generateGridLines( - canvasRef.current?.clientWidth || 800, - canvasRef.current?.clientHeight || 600, - layout.gridSettings as GridUtilSettings, - ).verticalLines.map((x, i) => ( -
- ))} - {generateGridLines( - canvasRef.current?.clientWidth || 800, - canvasRef.current?.clientHeight || 600, - layout.gridSettings as GridUtilSettings, - ).horizontalLines.map((y, i) => ( -
- ))} -
- )} -
- - {/* 마키 선택 사각형 */} - {selectionState.isSelecting && ( -
- )} - - {/* 컴포넌트들 - 실시간 미리보기 */} - {layout.components - .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 - .map((component) => { - // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - return ( - handleComponentClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - onGroupToggle={(groupId) => { - // 그룹 접기/펼치기 토글 - const groupComp = component as GroupComponent; - updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); - }} - > - {children.map((child) => ( - handleComponentClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - /> - ))} - - ); - })} -
+ {/* 빈 캔버스 안내 */} + {layout.components.length === 0 && ( +
+
+ +

캔버스가 비어있습니다

+

좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요

+

+ 단축키: T(테이블), P(속성), S(스타일), R(격자) | Ctrl+G(그룹생성), Ctrl+Shift+G(그룹해제) +

+

+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) +

+

+ ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 +

-
- - {/* 우측: 컴포넌트 스타일 편집 */} -
-
- {/* 격자 설정 */} - - -

컴포넌트 속성

- - {selectedComponent ? ( -
- - - - {selectedComponent.type === "container" && "테이블 속성"} - {selectedComponent.type === "widget" && "위젯 속성"} - - - - {/* 위치 속성 */} -
-
- - { - const val = (e.target as HTMLInputElement).valueAsNumber; - if (Number.isFinite(val)) { - let newX = Math.round(val); - - // 격자 스냅이 활성화된 경우 격자에 맞춤 - if (layout.gridSettings?.snapToGrid && gridInfo) { - const snappedPos = snapToGrid( - { - x: newX, - y: selectedComponent.position.y, - z: selectedComponent.position.z || 1, - } as Required, - gridInfo, - layout.gridSettings as GridUtilSettings, - ); - newX = snappedPos.x; - } - - updateComponentProperty(selectedComponent.id, "position.x", newX); - } - }} - /> -
-
- - { - const val = (e.target as HTMLInputElement).valueAsNumber; - if (Number.isFinite(val)) { - let newY = Math.round(val); - - // 격자 스냅이 활성화된 경우 격자에 맞춤 - if (layout.gridSettings?.snapToGrid && gridInfo) { - const snappedPos = snapToGrid( - { - x: selectedComponent.position.x, - y: newY, - z: selectedComponent.position.z || 1, - } as Required, - gridInfo, - layout.gridSettings as GridUtilSettings, - ); - newY = snappedPos.y; - } - - updateComponentProperty(selectedComponent.id, "position.y", newY); - } - }} - /> -
-
- - {/* 크기 속성 */} -
-
- - {layout.gridSettings?.snapToGrid && gridInfo ? ( - // 격자 스냅이 활성화된 경우 컬럼 단위로 조정 -
- { - const { columnWidth } = gridInfo; - const { gap } = layout.gridSettings; - return Math.max( - 1, - Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)), - ); - })()} - onChange={(e) => { - const gridColumns = Math.max( - 1, - Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1), - ); - const { columnWidth } = gridInfo; - const { gap } = layout.gridSettings!; - const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; - updateComponentProperty(selectedComponent.id, "size.width", newWidth); - }} - /> -
실제 너비: {selectedComponent.size.width}px
-
- ) : ( - // 격자 스냅이 비활성화된 경우 픽셀 단위로 조정 - { - const val = (e.target as HTMLInputElement).valueAsNumber; - if (Number.isFinite(val)) { - const newWidth = Math.max(20, Math.round(val)); - updateComponentProperty(selectedComponent.id, "size.width", newWidth); - } - }} - /> - )} -
-
- - { - const val = (e.target as HTMLInputElement).valueAsNumber; - if (Number.isFinite(val)) { - let newHeight = Math.max(20, Math.round(val)); - - // 격자 스냅이 활성화된 경우 20px 단위로 조정 - if (layout.gridSettings?.snapToGrid) { - newHeight = Math.max(40, Math.round(newHeight / 20) * 20); - } - - updateComponentProperty(selectedComponent.id, "size.height", newHeight); - } - }} - /> -
-
- - {/* 테이블 정보 */} - -
- - -
- - {/* 위젯 전용 속성 */} - {selectedComponent.type === "widget" && ( - <> -
- - -
-
- - -
-
- - updateComponentProperty(selectedComponent.id, "label", e.target.value)} - /> -
-
- - - updateComponentProperty(selectedComponent.id, "placeholder", e.target.value) - } - /> -
-
-
- - updateComponentProperty(selectedComponent.id, "required", e.target.checked) - } - /> - -
-
- - updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) - } - /> - -
-
- - )} - - {/* 스타일 속성 */} - -
- - updateComponentProperty(selectedComponent.id, "style", newStyle)} - /> -
- - {/* 고급 속성 */} - -
- - -
- -
-
-
- ) : ( -
- -

컴포넌트를 선택하여 속성을 편집하세요

-
- )} -
-
+ )}
+ + {/* 플로팅 패널들 */} + closePanel("tables")} + position="left" + width={320} + height={600} + > + { + const dragData = { + type: column ? "column" : "table", + table, + column, + }; + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + }} + selectedTableName={selectedScreen.tableName} + /> + + + closePanel("properties")} + position="right" + width={320} + height={500} + > + { + if (selectedComponent) { + updateComponentProperty(selectedComponent.id, path, value); + } + }} + onDeleteComponent={deleteComponent} + onCopyComponent={copyComponent} + /> + + + closePanel("styles")} + position="right" + width={320} + height={400} + > + {selectedComponent ? ( +
+ updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> +
+ ) : ( +
+ 컴포넌트를 선택하여 스타일을 편집하세요 +
+ )} +
+ + closePanel("grid")} + position="right" + width={280} + height={450} + > + { + const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; + updateGridSettings(defaultSettings); + }} + /> + + + {/* 그룹 생성 툴바 (필요시) */} + {groupState.selectedComponents.length > 1 && ( +
+ groupState.selectedComponents.includes(comp.id))} + allComponents={layout.components} + groupState={groupState} + onGroupStateChange={setGroupState} + onGroupCreate={(componentIds: string[], title: string, style?: any) => { + handleGroupCreate(componentIds, title, style); + }} + onGroupUngroup={() => { + // TODO: 그룹 해제 구현 + }} + showCreateDialog={showGroupCreateDialog} + onShowCreateDialogChange={setShowGroupCreateDialog} + /> +
+ )}
); } diff --git a/frontend/components/screen/ScreenDesigner_new.tsx b/frontend/components/screen/ScreenDesigner_new.tsx new file mode 100644 index 00000000..55b651b4 --- /dev/null +++ b/frontend/components/screen/ScreenDesigner_new.tsx @@ -0,0 +1,667 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + GroupState, + WebType, + TableInfo, + GroupComponent, + Position, +} from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; +import { + createGroupComponent, + calculateBoundingBox, + calculateRelativePositions, + restoreAbsolutePositions, + getGroupChildren, +} from "@/lib/utils/groupingUtils"; +import { + calculateGridInfo, + snapToGrid, + snapSizeToGrid, + generateGridLines, + GridSettings as GridUtilSettings, +} from "@/lib/utils/gridUtils"; +import { GroupingToolbar } from "./GroupingToolbar"; +import { screenApi } from "@/lib/api/screen"; +import { toast } from "sonner"; + +import StyleEditor from "./StyleEditor"; +import { RealtimePreview } from "./RealtimePreview"; +import FloatingPanel from "./FloatingPanel"; +import DesignerToolbar from "./DesignerToolbar"; +import TablesPanel from "./panels/TablesPanel"; +import PropertiesPanel from "./panels/PropertiesPanel"; +import GridPanel from "./panels/GridPanel"; +import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; + +interface ScreenDesignerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; +} + +// 패널 설정 +const panelConfigs: PanelConfig[] = [ + { + id: "tables", + title: "테이블 목록", + defaultPosition: "left", + defaultWidth: 320, + defaultHeight: 600, + shortcutKey: "t", + }, + { + id: "properties", + title: "속성 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 500, + shortcutKey: "p", + }, + { + id: "styles", + title: "스타일 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 400, + shortcutKey: "s", + }, + { + id: "grid", + title: "격자 설정", + defaultPosition: "right", + defaultWidth: 280, + defaultHeight: 450, + shortcutKey: "g", + }, +]; + +export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { + // 패널 상태 관리 + const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs); + + const [layout, setLayout] = useState({ + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }, + }); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [selectedComponent, setSelectedComponent] = useState(null); + + // 실행취소/다시실행을 위한 히스토리 상태 + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + + // 그룹 상태 + const [groupState, setGroupState] = useState({ + selectedComponents: [], + isGrouping: false, + }); + + // 드래그 상태 + const [dragState, setDragState] = useState({ + isDragging: false, + draggedComponent: null as ComponentData | null, + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + }); + + // 테이블 데이터 + const [tables, setTables] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + + // 클립보드 + const [clipboard, setClipboard] = useState<{ + type: "single" | "multiple" | "group"; + data: ComponentData[]; + } | null>(null); + + // 그룹 생성 다이얼로그 + const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + + const canvasRef = useRef(null); + + // 격자 정보 계산 + const gridInfo = useMemo(() => { + if (!canvasRef.current || !layout.gridSettings) return null; + return calculateGridInfo(canvasRef.current, layout.gridSettings); + }, [layout.gridSettings]); + + // 격자 라인 생성 + const gridLines = useMemo(() => { + if (!gridInfo || !layout.gridSettings?.showGrid) return []; + return generateGridLines(gridInfo, layout.gridSettings); + }, [gridInfo, layout.gridSettings]); + + // 필터된 테이블 목록 + const filteredTables = useMemo(() => { + if (!searchTerm) return tables; + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), + ); + }, [tables, searchTerm]); + + // 히스토리에 저장 + const saveToHistory = useCallback( + (newLayout: LayoutData) => { + setHistory((prev) => { + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(newLayout); + return newHistory.slice(-50); // 최대 50개까지만 저장 + }); + setHistoryIndex((prev) => Math.min(prev + 1, 49)); + setHasUnsavedChanges(true); + }, + [historyIndex], + ); + + // 실행취소 + const undo = useCallback(() => { + if (historyIndex > 0) { + setHistoryIndex((prev) => prev - 1); + setLayout(history[historyIndex - 1]); + } + }, [history, historyIndex]); + + // 다시실행 + const redo = useCallback(() => { + if (historyIndex < history.length - 1) { + setHistoryIndex((prev) => prev + 1); + setLayout(history[historyIndex + 1]); + } + }, [history, historyIndex]); + + // 컴포넌트 속성 업데이트 + const updateComponentProperty = useCallback( + (componentId: string, path: string, value: any) => { + const pathParts = path.split("."); + const updatedComponents = layout.components.map((comp) => { + if (comp.id !== componentId) return comp; + + const newComp = { ...comp }; + let current: any = newComp; + + 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; + + // 크기 변경 시 격자 스냅 적용 + if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) { + const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); + newComp.size = snappedSize; + } + + return newComp; + }); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, gridInfo, saveToHistory], + ); + + // 테이블 데이터 로드 + useEffect(() => { + if (selectedScreen?.tableName) { + const loadTables = async () => { + try { + setIsLoading(true); + const response = await screenApi.getTableInfo([selectedScreen.tableName]); + setTables(response.data || []); + } catch (error) { + console.error("테이블 정보 로드 실패:", error); + toast.error("테이블 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + loadTables(); + } + }, [selectedScreen?.tableName]); + + // 화면 레이아웃 로드 + useEffect(() => { + if (selectedScreen?.screenId) { + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await screenApi.getScreenLayout(selectedScreen.screenId); + if (response.success && response.data) { + setLayout(response.data); + setHistory([response.data]); + setHistoryIndex(0); + setHasUnsavedChanges(false); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + loadLayout(); + } + }, [selectedScreen?.screenId]); + + // 저장 + const handleSave = useCallback(async () => { + if (!selectedScreen?.screenId) return; + + try { + setIsSaving(true); + const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout); + if (response.success) { + toast.success("화면이 저장되었습니다."); + setHasUnsavedChanges(false); + } else { + toast.error("저장에 실패했습니다."); + } + } catch (error) { + console.error("저장 실패:", error); + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }, [selectedScreen?.screenId, layout]); + + // 드래그 앤 드롭 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + + const dragData = e.dataTransfer.getData("application/json"); + if (!dragData) return; + + try { + const { type, table, column } = JSON.parse(dragData); + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + let newComponent: ComponentData; + + if (type === "table") { + // 테이블 컨테이너 생성 + newComponent = { + id: generateComponentId(), + type: "container", + label: table.tableName, + tableName: table.tableName, + position: { x, y, z: 1 }, + size: { width: 300, height: 200 }, + }; + } else if (type === "column") { + // 컬럼 위젯 생성 + newComponent = { + id: generateComponentId(), + type: "widget", + label: column.columnName, + tableName: table.tableName, + columnName: column.columnName, + widgetType: column.widgetType, + dataType: column.dataType, + required: column.required, + position: { x, y, z: 1 }, + size: { width: 200, height: 40 }, + }; + } else { + return; + } + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings); + newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings); + } + + const newLayout = { + ...layout, + components: [...layout.components, newComponent], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(newComponent); + + // 속성 패널 자동 열기 + openPanel("properties"); + } catch (error) { + console.error("드롭 처리 실패:", error); + } + }, + [layout, gridInfo, saveToHistory, openPanel], + ); + + // 컴포넌트 클릭 처리 + const handleComponentClick = useCallback( + (component: ComponentData, event?: React.MouseEvent) => { + event?.stopPropagation(); + setSelectedComponent(component); + + // 속성 패널 자동 열기 + openPanel("properties"); + }, + [openPanel], + ); + + // 컴포넌트 삭제 + const deleteComponent = useCallback(() => { + if (!selectedComponent) return; + + const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); + const newLayout = { ...layout, components: newComponents }; + + setLayout(newLayout); + saveToHistory(newLayout); + setSelectedComponent(null); + }, [selectedComponent, layout, saveToHistory]); + + // 컴포넌트 복사 + const copyComponent = useCallback(() => { + if (!selectedComponent) return; + + setClipboard({ + type: "single", + data: [{ ...selectedComponent, id: generateComponentId() }], + }); + + toast.success("컴포넌트가 복사되었습니다."); + }, [selectedComponent]); + + // 그룹 생성 + const handleGroupCreate = useCallback( + (componentIds: string[], title: string, style?: any) => { + const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id)); + if (selectedComponents.length < 2) return; + + // 경계 박스 계산 + const boundingBox = calculateBoundingBox(selectedComponents); + + // 그룹 컴포넌트 생성 + const groupComponent = createGroupComponent( + componentIds, + title, + { x: boundingBox.minX, y: boundingBox.minY }, + { width: boundingBox.width, height: boundingBox.height }, + style, + ); + + // 자식 컴포넌트들의 상대 위치 계산 + const relativeChildren = calculateRelativePositions( + selectedComponents, + { x: boundingBox.minX, y: boundingBox.minY }, + groupComponent.id, + ); + + const newLayout = { + ...layout, + components: [ + ...layout.components.filter((comp) => !componentIds.includes(comp.id)), + groupComponent, + ...relativeChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + }, + [layout, saveToHistory], + ); + + // 키보드 이벤트 처리 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Delete 키로 컴포넌트 삭제 + if (e.key === "Delete" && selectedComponent) { + deleteComponent(); + } + + // Ctrl+C로 복사 + if (e.ctrlKey && e.key === "c" && selectedComponent) { + copyComponent(); + } + + // Ctrl+Z로 실행취소 + if (e.ctrlKey && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } + + // Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행 + if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) { + e.preventDefault(); + redo(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [selectedComponent, deleteComponent, copyComponent, undo, redo]); + + if (!selectedScreen) { + return ( +
+
+ +

화면을 선택하세요

+

설계할 화면을 먼저 선택해주세요.

+
+
+ ); + } + + return ( +
+ {/* 상단 툴바 */} + { + toast.info("미리보기 기능은 준비 중입니다."); + }} + onTogglePanel={togglePanel} + panelStates={panelStates} + canUndo={historyIndex > 0} + canRedo={historyIndex < history.length - 1} + isSaving={isSaving} + /> + + {/* 메인 캔버스 영역 (전체 화면) */} +
{ + if (e.target === e.currentTarget) { + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }} + onDrop={handleDrop} + onDragOver={handleDragOver} + > + {/* 격자 라인 */} + {gridLines.map((line, index) => ( +
+ ))} + + {/* 컴포넌트들 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + const children = + component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; + + return ( + handleComponentClick(component, e)} + > + {children.map((child) => ( + handleComponentClick(child, e)} + /> + ))} + + ); + })} + + {/* 빈 캔버스 안내 */} + {layout.components.length === 0 && ( +
+
+ +

캔버스가 비어있습니다

+

좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요

+

단축키: T(테이블), P(속성), S(스타일), G(격자)

+
+
+ )} +
+ + {/* 플로팅 패널들 */} + closePanel("tables")} + position="left" + width={320} + height={600} + > + { + const dragData = { + type: column ? "column" : "table", + table, + column, + }; + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + }} + selectedTableName={selectedScreen.tableName} + /> + + + closePanel("properties")} + position="right" + width={320} + height={500} + > + + + + closePanel("styles")} + position="right" + width={320} + height={400} + > + {selectedComponent ? ( +
+ updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> +
+ ) : ( +
+ 컴포넌트를 선택하여 스타일을 편집하세요 +
+ )} +
+ + closePanel("grid")} + position="right" + width={280} + height={450} + > + { + const newLayout = { ...layout, gridSettings: settings }; + setLayout(newLayout); + saveToHistory(newLayout); + }} + onResetGrid={() => { + const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; + const newLayout = { ...layout, gridSettings: defaultSettings }; + setLayout(newLayout); + saveToHistory(newLayout); + }} + /> + + + {/* 그룹 생성 툴바 (필요시) */} + {groupState.selectedComponents.length > 1 && ( +
+ +
+ )} +
+ ); +} + diff --git a/frontend/components/screen/ScreenDesigner_old.tsx b/frontend/components/screen/ScreenDesigner_old.tsx new file mode 100644 index 00000000..ec019066 --- /dev/null +++ b/frontend/components/screen/ScreenDesigner_old.tsx @@ -0,0 +1,2157 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; + +import { + Group, + Database, + Trash2, + Copy, + Clipboard, +} from "lucide-react"; +import { + ScreenDefinition, + ComponentData, + LayoutData, + GroupState, + WebType, + TableInfo, + GroupComponent, + Position, +} from "@/types/screen"; +import { generateComponentId } from "@/lib/utils/generateId"; +import { + createGroupComponent, + calculateBoundingBox, + calculateRelativePositions, + restoreAbsolutePositions, + getGroupChildren, +} from "@/lib/utils/groupingUtils"; +import { + calculateGridInfo, + snapToGrid, + snapSizeToGrid, + generateGridLines, + GridSettings as GridUtilSettings, +} from "@/lib/utils/gridUtils"; +import { GroupingToolbar } from "./GroupingToolbar"; +import { screenApi } from "@/lib/api/screen"; +import { toast } from "sonner"; + +import StyleEditor from "./StyleEditor"; +import { RealtimePreview } from "./RealtimePreview"; +import FloatingPanel from "./FloatingPanel"; +import DesignerToolbar from "./DesignerToolbar"; +import TablesPanel from "./panels/TablesPanel"; +import PropertiesPanel from "./panels/PropertiesPanel"; +import GridPanel from "./panels/GridPanel"; +import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; + +interface ScreenDesignerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; +} + +// 패널 설정 +const panelConfigs: PanelConfig[] = [ + { + id: "tables", + title: "테이블 목록", + defaultPosition: "left", + defaultWidth: 320, + defaultHeight: 600, + shortcutKey: "t", + }, + { + id: "properties", + title: "속성 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 500, + shortcutKey: "p", + }, + { + id: "styles", + title: "스타일 편집", + defaultPosition: "right", + defaultWidth: 320, + defaultHeight: 400, + shortcutKey: "s", + }, + { + id: "grid", + title: "격자 설정", + defaultPosition: "right", + defaultWidth: 280, + defaultHeight: 450, + shortcutKey: "g", + }, +]; + +export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { + // 패널 상태 관리 + const { + panelStates, + togglePanel, + openPanel, + closePanel, + closeAllPanels, + } = usePanelState(panelConfigs); + + const [layout, setLayout] = useState({ + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }, + }); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [selectedComponent, setSelectedComponent] = useState(null); + + // 실행취소/다시실행을 위한 히스토리 상태 + const [history, setHistory] = useState([ + { + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, + }, + ]); + const [historyIndex, setHistoryIndex] = useState(0); + + // 클립보드 상태 (복사/붙여넣기용) + const [clipboard, setClipboard] = useState<{ + type: "single" | "multiple" | "group"; + data: ComponentData[]; + offset: { x: number; y: number }; + boundingBox?: { x: number; y: number; width: number; height: number }; + } | null>(null); + + // 히스토리에 상태 저장 + const saveToHistory = useCallback( + (newLayout: LayoutData) => { + setHistory((prevHistory) => { + const newHistory = prevHistory.slice(0, historyIndex + 1); + newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 + return newHistory.slice(-50); // 최대 50개 히스토리 유지 + }); + setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49)); + setHasUnsavedChanges(true); // 변경사항 표시 + }, + [historyIndex], + ); + + // 실행취소 + const undo = useCallback(() => { + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setLayout(JSON.parse(JSON.stringify(history[newIndex]))); + setSelectedComponent(null); // 선택 해제 + } + }, [historyIndex, history]); + + // 다시실행 + const redo = useCallback(() => { + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + setHistoryIndex(newIndex); + setLayout(JSON.parse(JSON.stringify(history[newIndex]))); + setSelectedComponent(null); // 선택 해제 + } + }, [historyIndex, history]); + + const [dragState, setDragState] = useState({ + isDragging: false, + draggedComponent: null as ComponentData | null, + draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들 + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, // 다중 드래그 여부 + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + }); + const [groupState, setGroupState] = useState({ + isGrouping: false, + selectedComponents: [], + groupTarget: null, + groupMode: "create", + }); + + // 그룹 생성 다이얼로그 상태 + const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); + + // 캔버스 컨테이너 참조 + const canvasRef = useRef(null); + + // 격자 정보 계산 + const gridInfo = useMemo(() => { + if (!layout.gridSettings) return null; + + // canvasRef가 없거나 크기가 0인 경우 기본값 사용 + let width = 800; + let height = 600; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + width = Math.max(rect.width || 800, 800); + height = Math.max(rect.height || 600, 600); + } + + return calculateGridInfo(width, height, layout.gridSettings as GridUtilSettings); + }, [layout.gridSettings]); + + // 격자 설정 변경 핸들러 + const handleGridSettingsChange = useCallback( + (newGridSettings: GridUtilSettings) => { + let updatedComponents = layout.components; + + // 격자 스냅이 활성화되어 있고 격자 정보가 있으며 컴포넌트가 있는 경우 기존 컴포넌트들을 새 격자에 맞춤 + if (newGridSettings.snapToGrid && gridInfo && layout.components.length > 0) { + // 현재 캔버스 크기 가져오기 + let canvasWidth = 800; + let canvasHeight = 600; + + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + canvasWidth = Math.max(rect.width || 800, 800); + canvasHeight = Math.max(rect.height || 600, 600); + } + + const newGridInfo = calculateGridInfo(canvasWidth, canvasHeight, newGridSettings); + + updatedComponents = layout.components.map((comp) => { + // 그룹의 자식 컴포넌트는 건드리지 않음 (그룹에서 처리) + if (comp.parentId) return comp; + + // 기존 격자에서의 상대적 위치 계산 (격자 컬럼 단위) + const oldGridInfo = gridInfo; + const oldColumnWidth = oldGridInfo.columnWidth; + const oldGap = layout.gridSettings?.gap || 16; + const oldPadding = layout.gridSettings?.padding || 16; + + // 기존 위치를 격자 컬럼/행 단위로 변환 + const oldGridX = Math.round((comp.position.x - oldPadding) / (oldColumnWidth + oldGap)); + const oldGridY = Math.round((comp.position.y - oldPadding) / 20); // 20px 단위 + + // 기존 크기를 격자 컬럼 단위로 변환 + const oldGridColumns = Math.max(1, Math.round((comp.size.width + oldGap) / (oldColumnWidth + oldGap))); + const oldGridRows = Math.max(2, Math.round(comp.size.height / 20)); // 20px 단위 + + // 새 격자에서의 위치와 크기 계산 + const newColumnWidth = newGridInfo.columnWidth; + const newGap = newGridSettings.gap; + const newPadding = newGridSettings.padding; + + // 새 위치 계산 (격자 비율 유지) + const newX = newPadding + oldGridX * (newColumnWidth + newGap); + const newY = newPadding + oldGridY * 20; + + // 새 크기 계산 (격자 비율 유지) + const newWidth = oldGridColumns * newColumnWidth + (oldGridColumns - 1) * newGap; + const newHeight = oldGridRows * 20; + + return { + ...comp, + position: { x: newX, y: newY, z: comp.position.z || 1 }, + size: { width: newWidth, height: newHeight }, + }; + }); + } + + const newLayout = { + ...layout, + components: updatedComponents, + gridSettings: newGridSettings, + }; + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, saveToHistory, gridInfo], + ); + + const [tables, setTables] = useState([]); + const [expandedTables, setExpandedTables] = useState>(new Set()); + + // 테이블 검색 및 페이징 상태 추가 + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage] = useState(10); + + // 드래그 박스(마키) 다중선택 상태 + const [selectionState, setSelectionState] = useState({ + isSelecting: false, + start: { x: 0, y: 0 }, + current: { x: 0, y: 0 }, + }); + + // 선택된 컴포넌트를 항상 레이아웃 최신 값으로 참조 (좌표 실시간 반영용) + const selectedFromLayout = useMemo(() => { + if (!selectedComponent) return null; + return layout.components.find((c) => c.id === selectedComponent.id) || null; + }, [selectedComponent, layout.components]); + + // 드래그 중에는 라이브 좌표를 계산하여 속성 패널에 표시 + const liveSelectedPosition = useMemo(() => { + if (!selectedFromLayout) return { x: 0, y: 0 }; + + let x = selectedFromLayout.position.x; + let y = selectedFromLayout.position.y; + + if (dragState.isDragging) { + const isSelectedInMulti = groupState.selectedComponents.includes(selectedFromLayout.id); + if (dragState.isMultiDrag && isSelectedInMulti) { + const deltaX = dragState.currentPosition.x - dragState.initialMouse.x; + const deltaY = dragState.currentPosition.y - dragState.initialMouse.y; + x = selectedFromLayout.position.x + deltaX; + y = selectedFromLayout.position.y + deltaY; + } else if (dragState.draggedComponent?.id === selectedFromLayout.id) { + x = dragState.currentPosition.x - dragState.grabOffset.x; + y = dragState.currentPosition.y - dragState.grabOffset.y; + } + } + + return { x: Math.round(x), y: Math.round(y) }; + }, [ + selectedFromLayout, + dragState.isDragging, + dragState.isMultiDrag, + dragState.currentPosition.x, + dragState.currentPosition.y, + dragState.initialMouse.x, + dragState.initialMouse.y, + dragState.grabOffset.x, + dragState.grabOffset.y, + groupState.selectedComponents, + ]); + + // 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적) + const getAbsolutePosition = useCallback( + (comp: ComponentData) => { + let x = comp.position.x; + let y = comp.position.y; + let cur: ComponentData | undefined = comp; + while (cur.parentId) { + const parent = layout.components.find((c) => c.id === cur!.parentId); + if (!parent) break; + x += parent.position.x; + y += parent.position.y; + cur = parent; + } + return { x, y }; + }, + [layout.components], + ); + + // 마키 선택 시작 (캔버스 빈 영역 마우스다운) + const handleMarqueeStart = useCallback( + (e: React.MouseEvent) => { + if (dragState.isDragging) return; // 드래그 중이면 무시 + const rect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; + setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } }); + // 기존 선택 초기화 (Shift 미사용 시) + if (!e.shiftKey) { + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }, + [dragState.isDragging], + ); + + // 마키 이동 + const handleMarqueeMove = useCallback( + (e: React.MouseEvent) => { + if (!selectionState.isSelecting) return; + const rect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; + setSelectionState((prev) => ({ ...prev, current: { x, y } })); + }, + [selectionState.isSelecting], + ); + + // 마키 종료 -> 영역 내 컴포넌트 선택 + const handleMarqueeEnd = useCallback(() => { + if (!selectionState.isSelecting) return; + const minX = Math.min(selectionState.start.x, selectionState.current.x); + const minY = Math.min(selectionState.start.y, selectionState.current.y); + const maxX = Math.max(selectionState.start.x, selectionState.current.x); + const maxY = Math.max(selectionState.start.y, selectionState.current.y); + + const selectedIds = layout.components + // 그룹 컨테이너는 제외 + .filter((c) => c.type !== "group") + .filter((c) => { + const abs = getAbsolutePosition(c); + const left = abs.x; + const top = abs.y; + const right = abs.x + c.size.width; + const bottom = abs.y + c.size.height; + // 영역과 교차 여부 판단 (일부라도 겹치면 선택) + return right >= minX && left <= maxX && bottom >= minY && top <= maxY; + }) + .map((c) => c.id); + + setGroupState((prev) => ({ + ...prev, + selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])), + })); + setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } }); + }, [selectionState, layout.components, getAbsolutePosition]); + + // 선택된 화면의 테이블만 로드 (최적화된 API 사용) + useEffect(() => { + const fetchScreenTable = async () => { + if (!selectedScreen?.tableName) { + setTables([]); + return; + } + + try { + console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`); + const startTime = performance.now(); + + // 최적화된 단일 테이블 조회 API 사용 + const response = await fetch(`http://localhost:8080/api/screen-management/tables/${selectedScreen.tableName}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken")}`, + }, + }); + + const endTime = performance.now(); + console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`); + + if (response.ok) { + const data = await response.json(); + if (data.success && data.data) { + setTables([data.data]); + console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`); + } else { + console.error("테이블 조회 실패:", data.message); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); + } + } else if (response.status === 404) { + console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`); + // 테이블이 존재하지 않는 경우 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); + } else { + console.error("테이블 조회 실패:", response.status); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); + } + } catch (error) { + console.error("테이블 조회 중 오류:", error); + // 선택된 화면의 테이블에 대한 임시 데이터 생성 + setTables([createMockTableForScreen(selectedScreen.tableName)]); + } + }; + + fetchScreenTable(); + }, [selectedScreen?.tableName]); + + // 검색된 테이블 필터링 + const filteredTables = useMemo(() => { + if (!searchTerm.trim()) return tables; + + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) || + table.columns.some( + (column) => + column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) || + (column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()), + ), + ); + }, [tables, searchTerm]); + + // 페이징된 테이블 + const paginatedTables = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return filteredTables.slice(startIndex, endIndex); + }, [filteredTables, currentPage, itemsPerPage]); + + // 총 페이지 수 계산 + const totalPages = Math.ceil(filteredTables.length / itemsPerPage); + + // 페이지 변경 핸들러 + const handlePageChange = (page: number) => { + setCurrentPage(page); + setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화 + }; + + // 검색어 변경 핸들러 + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setCurrentPage(1); // 검색 시 첫 페이지로 이동 + setExpandedTables(new Set()); // 검색 시 확장 상태 초기화 + }; + + // 임시 테이블 데이터 (API 실패 시 사용) + // 사용하지 않는 getMockTables 함수 제거됨 + + // 특정 테이블에 대한 임시 데이터 생성 + const createMockTableForScreen = (tableName: string): TableInfo => { + // 기본 컬럼들 생성 + const baseColumns = [ + { + tableName, + columnName: "id", + columnLabel: "ID", + webType: "number" as WebType, + dataType: "BIGINT", + isNullable: "NO", + }, + { + tableName, + columnName: "name", + columnLabel: "이름", + webType: "text" as WebType, + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName, + columnName: "description", + columnLabel: "설명", + webType: "textarea" as WebType, + dataType: "TEXT", + isNullable: "YES", + }, + { + tableName, + columnName: "created_date", + columnLabel: "생성일", + webType: "date" as WebType, + dataType: "TIMESTAMP", + isNullable: "NO", + }, + { + tableName, + columnName: "updated_date", + columnLabel: "수정일", + webType: "date" as WebType, + dataType: "TIMESTAMP", + isNullable: "YES", + }, + ]; + + return { + tableName, + tableLabel: `${tableName} (임시)`, + columns: baseColumns, + }; + }; + + // 테이블 확장/축소 토글 + const toggleTableExpansion = useCallback((tableName: string) => { + setExpandedTables((prev) => { + const newSet = new Set(prev); + if (newSet.has(tableName)) { + newSet.delete(tableName); + } else { + newSet.add(tableName); + } + return newSet; + }); + }, []); + + // 웹타입에 따른 위젯 타입 매핑 + const getWidgetTypeFromWebType = useCallback((webType: string): string => { + console.log("getWidgetTypeFromWebType - input webType:", webType); + switch (webType) { + case "text": + return "text"; + case "email": + return "email"; + case "tel": + return "tel"; + case "number": + return "number"; + case "decimal": + return "decimal"; + case "date": + return "date"; + case "datetime": + return "datetime"; + case "select": + return "select"; + case "dropdown": + return "dropdown"; + case "textarea": + return "textarea"; + case "text_area": + return "text_area"; + case "checkbox": + return "checkbox"; + case "boolean": + return "boolean"; + case "radio": + return "radio"; + case "code": + return "code"; + case "entity": + return "entity"; + case "file": + return "file"; + default: + console.log("getWidgetTypeFromWebType - default case, returning text for:", webType); + return "text"; + } + }, []); + + // 범용 복사 함수 + const copyComponents = useCallback(() => { + if (!selectedComponent && groupState.selectedComponents.length === 0) return; + + let componentsToCopy: ComponentData[] = []; + let copyType: "single" | "multiple" | "group" = "single"; + + if (selectedComponent?.type === "group") { + // 그룹 복사 + const children = getGroupChildren(layout.components, selectedComponent.id); + componentsToCopy = [selectedComponent, ...children]; + copyType = "group"; + } else if (groupState.selectedComponents.length > 1) { + // 다중 선택 복사 + componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + copyType = "multiple"; + } else if (selectedComponent) { + // 단일 컴포넌트 복사 + componentsToCopy = [selectedComponent]; + copyType = "single"; + } + + if (componentsToCopy.length === 0) return; + + // 바운딩 박스 계산 + const positions = componentsToCopy.map((comp) => ({ + x: comp.position.x, + y: comp.position.y, + width: comp.size.width, + height: comp.size.height, + })); + + const minX = Math.min(...positions.map((p) => p.x)); + const minY = Math.min(...positions.map((p) => p.y)); + const maxX = Math.max(...positions.map((p) => p.x + p.width)); + const maxY = Math.max(...positions.map((p) => p.y + p.height)); + + setClipboard({ + type: copyType, + data: componentsToCopy, + offset: { x: 20, y: 20 }, + boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, + }); + }, [selectedComponent, groupState.selectedComponents, layout.components]); + + // 범용 삭제 함수 + const deleteComponents = useCallback(() => { + if (!selectedComponent && groupState.selectedComponents.length === 0) return; + + let idsToRemove: string[] = []; + + if (selectedComponent?.type === "group") { + // 그룹 삭제 (자식 컴포넌트 포함) + const childrenIds = getGroupChildren(layout.components, selectedComponent.id).map((child) => child.id); + idsToRemove = [selectedComponent.id, ...childrenIds]; + } else if (groupState.selectedComponents.length > 1) { + // 다중 선택 삭제 + idsToRemove = [...groupState.selectedComponents]; + } else if (selectedComponent) { + // 단일 컴포넌트 삭제 + idsToRemove = [selectedComponent.id]; + } + + if (idsToRemove.length === 0) return; + + const newLayout = { + ...layout, + components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)), + }; + setLayout(newLayout); + saveToHistory(newLayout); + + // 선택 상태 초기화 + setSelectedComponent(null); + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory, setGroupState]); + + // 범용 붙여넣기 함수 + const pasteComponents = useCallback( + (pastePosition?: { x: number; y: number }) => { + if (!clipboard || clipboard.data.length === 0) return; + + const idMap = new Map(); + const newComponents: ComponentData[] = []; + + // 붙여넣기 위치 결정 + let targetPosition = pastePosition; + if (!targetPosition && clipboard.boundingBox) { + targetPosition = { + x: clipboard.boundingBox.x + clipboard.offset.x, + y: clipboard.boundingBox.y + clipboard.offset.y, + }; + } + + const offsetX = targetPosition ? targetPosition.x - (clipboard.boundingBox?.x || 0) : clipboard.offset.x; + const offsetY = targetPosition ? targetPosition.y - (clipboard.boundingBox?.y || 0) : clipboard.offset.y; + + // 모든 컴포넌트에 대해 새 ID 생성 + clipboard.data.forEach((comp) => { + const newId = generateComponentId(); + idMap.set(comp.id, newId); + }); + + // 컴포넌트 복사 및 ID/위치 업데이트 + clipboard.data.forEach((comp) => { + const newComp: ComponentData = { + ...comp, + id: idMap.get(comp.id)!, + position: { + x: comp.position.x + offsetX, + y: comp.position.y + offsetY, + }, + // 부모 ID가 있고 매핑되는 경우 업데이트 + parentId: comp.parentId && idMap.has(comp.parentId) ? idMap.get(comp.parentId)! : undefined, + }; + newComponents.push(newComp); + }); + + const newLayout = { + ...layout, + components: [...layout.components, ...newComponents], + }; + setLayout(newLayout); + saveToHistory(newLayout); + }, + [clipboard, layout, saveToHistory], + ); + + // 캔버스 우클릭 컨텍스트 메뉴 + const handleCanvasContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + // 우클릭 시 붙여넣기 (클립보드에 데이터가 있는 경우) + if (clipboard && clipboard.data.length > 0) { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + pasteComponents({ x, y }); + } + }, + [clipboard, pasteComponents], + ); + + // 키보드 단축키 지원 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key) { + case "z": + e.preventDefault(); + if (e.shiftKey) { + redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z + } else { + undo(); // Ctrl+Z 또는 Cmd+Z + } + break; + case "y": + e.preventDefault(); + redo(); // Ctrl+Y 또는 Cmd+Y + break; + case "c": + e.preventDefault(); + // 선택된 컴포넌트(들) 복사 + copyComponents(); + break; + case "v": + e.preventDefault(); + // 클립보드 내용 붙여넣기 + if (clipboard && clipboard.data.length > 0) { + pasteComponents(); + } + break; + case "g": + case "G": + e.preventDefault(); + if (e.shiftKey) { + // Ctrl+Shift+G: 그룹 해제 + const selectedComponents = layout.components.filter((comp) => + groupState.selectedComponents.includes(comp.id), + ); + if (selectedComponents.length === 1 && selectedComponents[0].type === "group") { + // 그룹 해제 로직을 직접 실행 + const group = selectedComponents[0] as any; + const groupChildren = layout.components.filter((comp) => comp.parentId === group.id); + + // 자식 컴포넌트들의 절대 위치 복원 + const absoluteChildren = groupChildren.map((child) => ({ + ...child, + position: { + x: child.position.x + group.position.x, + y: child.position.y + group.position.y, + z: (child.position as any).z || 1, + }, + parentId: undefined, + })); + + const newLayout = { + ...layout, + components: [ + ...layout.components.filter((comp) => comp.id !== group.id && comp.parentId !== group.id), + ...absoluteChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [], + isGrouping: false, + })); + } + } else { + // Ctrl+G: 그룹 생성 다이얼로그 열기 + const selectedComponents = layout.components.filter((comp) => + groupState.selectedComponents.includes(comp.id), + ); + if (selectedComponents.length >= 2) { + setShowGroupCreateDialog(true); + } + } + break; + } + } else if (e.key === "Delete") { + e.preventDefault(); + // 선택된 컴포넌트(들) 삭제 + deleteComponents(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + undo, + redo, + copyComponents, + pasteComponents, + deleteComponents, + clipboard, + layout, + groupState, + saveToHistory, + setLayout, + setGroupState, + setShowGroupCreateDialog, + ]); + + // 컴포넌트 속성 업데이트 함수 + const updateComponentProperty = useCallback( + (componentId: string, propertyPath: string, value: any) => { + const newLayout = { + ...layout, + components: layout.components.map((comp) => { + if (comp.id === componentId) { + const newComp = { ...comp }; + const pathParts = propertyPath.split("."); + let current: any = newComp; + + for (let i = 0; i < pathParts.length - 1; i++) { + current = current[pathParts[i]]; + } + current[pathParts[pathParts.length - 1]] = value; + + // 크기 변경 시 격자 스냅 적용 + if ( + (propertyPath === "size.width" || propertyPath === "size.height") && + layout.gridSettings?.snapToGrid && + gridInfo + ) { + const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); + newComp.size = snappedSize; + } + + return newComp; + } + return comp; + }), + }; + setLayout(newLayout); + saveToHistory(newLayout); + // 선택된 컴포넌트인 경우 즉시 상태도 동기화하여 입력 즉시 반영되도록 처리 + if (selectedComponent && selectedComponent.id === componentId) { + const updated = newLayout.components.find((c) => c.id === componentId) || null; + if (updated) setSelectedComponent(updated); + } + }, + [layout, saveToHistory, selectedComponent, gridInfo], + ); + + // 그룹 생성 함수 + const handleGroupCreate = useCallback( + (componentIds: string[], title: string, style?: any) => { + const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id)); + + if (selectedComponents.length < 2) { + return; + } + + // 경계 박스 계산 + const boundingBox = calculateBoundingBox(selectedComponents); + + // 그룹 컴포넌트 생성 (경계 박스 정보 전달) + const groupComponent = createGroupComponent( + componentIds, + title, + { x: boundingBox.minX, y: boundingBox.minY }, + { width: boundingBox.width, height: boundingBox.height }, + style, + ); + + // 자식 컴포넌트들의 상대 위치 계산 + const relativeChildren = calculateRelativePositions( + selectedComponents, + { + x: boundingBox.minX, + y: boundingBox.minY, + }, + groupComponent.id, + ); + + // 새 레이아웃 생성 + const newLayout = { + ...layout, + components: [ + // 그룹에 포함되지 않은 기존 컴포넌트들만 유지 + ...layout.components.filter((comp) => !componentIds.includes(comp.id)), + // 그룹 컴포넌트 추가 + groupComponent, + // 자식 컴포넌트들도 유지 (parentId로 그룹과 연결) + ...relativeChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, saveToHistory], + ); + + // 그룹 해제 함수 + const handleGroupUngroup = useCallback( + (groupId: string) => { + const group = layout.components.find((comp) => comp.id === groupId) as GroupComponent; + if (!group || group.type !== "group") { + return; + } + + const groupChildren = getGroupChildren(layout.components, groupId); + + // 자식 컴포넌트들의 절대 위치 복원 + const absoluteChildren = restoreAbsolutePositions(groupChildren, group.position); + + // 새 레이아웃 생성 + const newLayout = { + ...layout, + components: [ + // 그룹과 그룹의 자식 컴포넌트들을 제외한 기존 컴포넌트들 + ...layout.components.filter((comp) => comp.id !== groupId && comp.parentId !== groupId), + // 절대 위치로 복원된 자식 컴포넌트들 + ...absoluteChildren, + ], + }; + + setLayout(newLayout); + saveToHistory(newLayout); + }, + [layout, saveToHistory], + ); + + // 레이아웃 저장 함수 + const saveLayout = useCallback(async () => { + if (!selectedScreen) { + toast.error("저장할 화면이 선택되지 않았습니다."); + return; + } + + try { + setIsSaving(true); + await screenApi.saveLayout(selectedScreen.screenId, layout); + setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제 + toast.success("레이아웃이 성공적으로 저장되었습니다."); + } catch (error) { + console.error("레이아웃 저장 실패:", error); + toast.error("레이아웃 저장에 실패했습니다."); + } finally { + setIsSaving(false); + } + }, [layout, selectedScreen]); + + // 레이아웃 로드 함수 + const loadLayout = useCallback(async () => { + if (!selectedScreen) return; + + try { + setIsLoading(true); + const savedLayout = await screenApi.getLayout(selectedScreen.screenId); + + if (savedLayout && savedLayout.components) { + // 격자 설정이 없는 경우 기본값 추가 + if (!savedLayout.gridSettings) { + savedLayout.gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true }; + } else if (savedLayout.gridSettings.snapToGrid === undefined) { + savedLayout.gridSettings.snapToGrid = true; + } + + setLayout(savedLayout); + // 히스토리 초기화 + setHistory([savedLayout]); + setHistoryIndex(0); + setHasUnsavedChanges(false); // 로드 완료 시 변경사항 플래그 해제 + toast.success("레이아웃을 불러왔습니다."); + } else { + // 저장된 레이아웃이 없는 경우 기본 레이아웃 유지 + const defaultLayout = { + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, + }; + setLayout(defaultLayout); + setHistory([defaultLayout]); + setHistoryIndex(0); + setHasUnsavedChanges(false); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + // 에러 시에도 기본 레이아웃으로 초기화 + const defaultLayout = { + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true }, + }; + setLayout(defaultLayout); + setHistory([defaultLayout]); + setHistoryIndex(0); + setHasUnsavedChanges(false); + toast.error("레이아웃 로드에 실패했습니다. 새 레이아웃으로 시작합니다."); + } finally { + setIsLoading(false); + } + }, [selectedScreen]); + + // 화면 선택 시 레이아웃 로드 + useEffect(() => { + if (selectedScreen) { + loadLayout(); + } + }, [selectedScreen, loadLayout]); + + // 스크롤 컨테이너 참조 (좌표 계산 정확도 향상) + const scrollContainerRef = useRef(null); + + // 드래그 시작 (새 컴포넌트 추가) + const startDrag = useCallback((component: Partial, e: React.DragEvent) => { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; + const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; + + setDragState({ + isDragging: true, + draggedComponent: component as ComponentData, + draggedComponents: [component as ComponentData], + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: false, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: 0, y: 0 }, + }); + e.dataTransfer.setData("application/json", JSON.stringify(component)); + }, []); + + // 기존 컴포넌트 드래그 시작 (재배치) + const startComponentDrag = useCallback( + (component: ComponentData, e: React.DragEvent) => { + e.stopPropagation(); + + // 다중선택된 컴포넌트들이 있는지 확인 + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id); + + // 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리) + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; + const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; + const grabOffsetX = relMouseX - component.position.x; + const grabOffsetY = relMouseY - component.position.y; + + if (isMultiDrag) { + // 다중 드래그 + setDragState({ + isDragging: true, + draggedComponent: component, + draggedComponents: selectedComponents, + originalPosition: component.position, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: true, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: grabOffsetX, y: grabOffsetY }, + }); + e.dataTransfer.setData( + "application/json", + JSON.stringify({ + ...component, + isMoving: true, + isMultiDrag: true, + selectedComponentIds: groupState.selectedComponents, + }), + ); + } else { + // 단일 드래그 + setDragState({ + isDragging: true, + draggedComponent: component, + draggedComponents: [component], + originalPosition: component.position, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: false, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: grabOffsetX, y: grabOffsetY }, + }); + e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); + } + }, + [layout.components, groupState.selectedComponents], + ); + + // 드래그 중 + const onDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (dragState.isDragging) { + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; + + setDragState((prev) => ({ + ...prev, + currentPosition: { x, y }, + })); + } + }, + [dragState.isDragging], + ); + + // 드롭 처리 + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + + try { + const data = JSON.parse(e.dataTransfer.getData("application/json")); + + if (data.isMoving) { + // 기존 컴포넌트 재배치 + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const mouseX = rect ? e.clientX - rect.left + scrollLeft : 0; + const mouseY = rect ? e.clientY - rect.top + scrollTop : 0; + + if (data.isMultiDrag && data.selectedComponentIds) { + // 다중 드래그 처리 + // 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영) + const dropX = mouseX - dragState.grabOffset.x; + const dropY = mouseY - dragState.grabOffset.y; + const deltaX = dropX - dragState.originalPosition.x; + const deltaY = dropY - dragState.originalPosition.y; + + const newLayout = { + ...layout, + components: layout.components.map((comp) => { + if (data.selectedComponentIds.includes(comp.id)) { + let newX = comp.position.x + deltaX; + let newY = comp.position.y + deltaY; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x: newX, y: newY, z: comp.position.z || 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newX = snappedPosition.x; + newY = snappedPosition.y; + } + + return { + ...comp, + position: { + x: newX, + y: newY, + z: comp.position.z || 1, + }, + }; + } + return comp; + }), + }; + setLayout(newLayout); + saveToHistory(newLayout); + } else { + // 단일 드래그 처리 + let x = mouseX - dragState.grabOffset.x; + let y = mouseY - dragState.grabOffset.y; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x, y, z: 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + x = snappedPosition.x; + y = snappedPosition.y; + } + + const newLayout = { + ...layout, + components: layout.components.map((comp) => + comp.id === data.id ? { ...comp, position: { x, y, z: comp.position.z || 1 } } : comp, + ), + }; + setLayout(newLayout); + saveToHistory(newLayout); + } + } else { + // 새 컴포넌트 추가 + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + let x = rect ? e.clientX - rect.left + scrollLeft : 0; + let y = rect ? e.clientY - rect.top + scrollTop : 0; + + // 격자 스냅 적용 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPosition = snapToGrid( + { x, y, z: 1 } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + x = snappedPosition.x; + y = snappedPosition.y; + } + + // 기본 크기를 격자에 맞춰 설정 + let defaultWidth = data.size?.width || 200; + const defaultHeight = data.size?.height || 100; + + if (layout.gridSettings?.snapToGrid && gridInfo) { + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings; + // 기본적으로 1컬럼 너비로 설정 + const gridColumns = 1; + defaultWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + } + + const newComponent: ComponentData = { + ...data, + id: generateComponentId(), + position: { x, y, z: 1 }, + size: { width: defaultWidth, height: defaultHeight }, + } as ComponentData; + + const newLayout = { + ...layout, + components: [...layout.components, newComponent], + }; + setLayout(newLayout); + saveToHistory(newLayout); + } + } catch (error) { + console.error("드롭 처리 중 오류:", error); + } + + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + }); + }, + [ + layout, + saveToHistory, + dragState.initialMouse.x, + dragState.initialMouse.y, + dragState.grabOffset.x, + dragState.grabOffset.y, + gridInfo, + ], + ); + + // 드래그 종료 + const endDrag = useCallback(() => { + // 격자 스냅 적용 + if (dragState.isDragging && dragState.draggedComponent && gridInfo && layout.gridSettings?.snapToGrid) { + const component = dragState.draggedComponent; + const snappedPosition = snapToGrid(dragState.currentPosition, gridInfo, layout.gridSettings as GridUtilSettings); + + // 스냅된 위치로 컴포넌트 업데이트 + if (snappedPosition.x !== dragState.currentPosition.x || snappedPosition.y !== dragState.currentPosition.y) { + const updatedComponents = layout.components.map((comp) => + comp.id === component.id ? { ...comp, position: snappedPosition } : comp, + ); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + } + } + + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0 }, + currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + }); + }, [dragState, gridInfo, layout, saveToHistory]); + + // 컴포넌트 클릭 (선택) + const handleComponentClick = useCallback( + (component: ComponentData, event?: React.MouseEvent) => { + const isShiftPressed = event?.shiftKey || false; + const isGroupContainer = component.type === "group"; + + if (groupState.isGrouping || isShiftPressed) { + // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택 + if (isGroupContainer) { + // 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리 + setSelectedComponent(component); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [component.id], + isGrouping: false, // 그룹 선택 시 그룹화 모드 해제 + })); + return; + } + const isSelected = groupState.selectedComponents.includes(component.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: isSelected + ? prev.selectedComponents.filter((id) => id !== component.id) + : [...prev.selectedComponents, component.id], + })); + + // 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정 + if (isShiftPressed) { + setSelectedComponent(component); + } + } else { + // 일반 모드에서는 단일 선택 + setSelectedComponent(component); + setGroupState((prev) => ({ + ...prev, + selectedComponents: [component.id], // 그룹도 선택 가능하도록 수정 + })); + } + }, + [groupState.isGrouping, groupState.selectedComponents], + ); + + // 화면이 선택되지 않았을 때 처리 + if (!selectedScreen) { + return ( +
+
+ +

설계할 화면을 선택해주세요

+

화면 목록에서 화면을 선택한 후 설계기를 사용하세요

+ +
+
+ ); + } + + return ( +
+ {/* 상단 툴바 */} + { + // TODO: 미리보기 기능 구현 + toast.info("미리보기 기능은 준비 중입니다."); + }} + onTogglePanel={togglePanel} + panelStates={panelStates} + canUndo={historyIndex > 0} + canRedo={historyIndex < history.length - 1} + isSaving={isSaving} + /> + + {/* 메인 캔버스 영역 (전체 화면) */} +
{ + if (e.target === e.currentTarget) { + closeAllPanels(); + setSelectedComponent(null); + setGroupState(prev => ({ ...prev, selectedComponents: [] })); + } + }} + onDrop={handleDrop} + onDragOver={handleDragOver} + + {selectedScreen.tableName} + + {clipboard && clipboard.data.length > 0 && ( + + + {clipboard.type === "group" + ? "그룹 복사됨" + : clipboard.type === "multiple" + ? `${clipboard.data.length}개 복사됨` + : "컴포넌트 복사됨"} + + )} +
+
+ + + {/* 복사/붙여넣기/삭제 버튼들 */} + {(selectedComponent || groupState.selectedComponents.length > 0) && ( + <> + + + + )} + + {/* 붙여넣기 버튼 */} + {clipboard && clipboard.data.length > 0 && ( + + )} + + + + +
+
+ + {/* 그룹화 툴바 */} + groupState.selectedComponents.includes(comp.id))} + allComponents={layout.components} + onGroupAlign={(mode) => { + const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + if (selected.length < 2) return; + + let newComponents = [...layout.components]; + const minX = Math.min(...selected.map((c) => c.position.x)); + const maxX = Math.max(...selected.map((c) => c.position.x + c.size.width)); + const minY = Math.min(...selected.map((c) => c.position.y)); + const maxY = Math.max(...selected.map((c) => c.position.y + c.size.height)); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + newComponents = newComponents.map((c) => { + if (!groupState.selectedComponents.includes(c.id)) return c; + if (mode === "left") return { ...c, position: { x: minX, y: c.position.y } }; + if (mode === "right") return { ...c, position: { x: maxX - c.size.width, y: c.position.y } }; + if (mode === "centerX") + return { ...c, position: { x: Math.round(centerX - c.size.width / 2), y: c.position.y } }; + if (mode === "top") return { ...c, position: { x: c.position.x, y: minY } }; + if (mode === "bottom") return { ...c, position: { x: c.position.x, y: maxY - c.size.height } }; + if (mode === "centerY") + return { ...c, position: { x: c.position.x, y: Math.round(centerY - c.size.height / 2) } }; + return c; + }); + + const newLayout = { ...layout, components: newComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + }} + onGroupDistribute={(orientation) => { + const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + if (selected.length < 3) return; // 균등 분배는 3개 이상 권장 + + const sorted = [...selected].sort((a, b) => + orientation === "horizontal" ? a.position.x - b.position.x : a.position.y - b.position.y, + ); + + if (orientation === "horizontal") { + const left = sorted[0].position.x; + const right = Math.max(...sorted.map((c) => c.position.x + c.size.width)); + const totalWidth = right - left; + const gaps = sorted.length - 1; + const usedWidth = sorted.reduce((sum, c) => sum + c.size.width, 0); + const gapSize = gaps > 0 ? Math.max(0, Math.round((totalWidth - usedWidth) / gaps)) : 0; + + let cursor = left; + sorted.forEach((c, idx) => { + c.position.x = cursor; + cursor += c.size.width + gapSize; + }); + } else { + const top = sorted[0].position.y; + const bottom = Math.max(...sorted.map((c) => c.position.y + c.size.height)); + const totalHeight = bottom - top; + const gaps = sorted.length - 1; + const usedHeight = sorted.reduce((sum, c) => sum + c.size.height, 0); + const gapSize = gaps > 0 ? Math.max(0, Math.round((totalHeight - usedHeight) / gaps)) : 0; + + let cursor = top; + sorted.forEach((c, idx) => { + c.position.y = cursor; + cursor += c.size.height + gapSize; + }); + } + + const newLayout = { ...layout, components: [...layout.components] }; + setLayout(newLayout); + saveToHistory(newLayout); + }} + showCreateDialog={showGroupCreateDialog} + onShowCreateDialogChange={setShowGroupCreateDialog} + /> + + {/* 메인 컨텐츠 영역 */} +
+ {/* 좌측 사이드바 - 테이블 타입 */} +
+
+
+

테이블 타입

+ {selectedScreen && ( +
+
선택된 화면
+
{selectedScreen.screenName}
+
+ + {selectedScreen.tableName} +
+
+ )} +
+ + {/* 검색 입력창 */} +
+ handleSearchChange(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+ + {/* 검색 결과 정보 */} +
+ 총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}- + {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째 +
+ +

테이블과 컬럼을 드래그하여 캔버스에 배치하세요.

+
+ + {/* 테이블 목록 */} +
+ {paginatedTables.length === 0 ? ( +
+
+ +

+ {selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"} +

+

+ {selectedScreen + ? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.` + : "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."} +

+
+
+ ) : ( + paginatedTables.map((table) => ( +
+ {/* 테이블 헤더 */} +
+ startDrag( + { + type: "container", + tableName: table.tableName, + label: table.tableLabel, + size: { width: 200, height: 80 }, // 픽셀 단위로 변경 + }, + e, + ) + } + > +
+ +
+
{table.tableLabel}
+
{table.tableName}
+
+
+ +
+ + {/* 컬럼 목록 */} + {expandedTables.has(table.tableName) && ( +
+ {table.columns.map((column) => ( +
{ + console.log("Drag start - column:", column.columnName, "webType:", column.webType); + const widgetType = getWidgetTypeFromWebType(column.webType || "text"); + console.log("Drag start - widgetType:", widgetType); + startDrag( + { + type: "widget", + tableName: table.tableName, + columnName: column.columnName, + widgetType: widgetType as WebType, + label: column.columnLabel || column.columnName, + size: { width: 150, height: 40 }, // 픽셀 단위로 변경 + }, + e, + ); + }} + > +
+ {column.webType === "text" && } + {column.webType === "email" && } + {column.webType === "tel" && } + {column.webType === "number" && } + {column.webType === "decimal" && } + {column.webType === "date" && } + {column.webType === "datetime" && } + {column.webType === "select" && } + {column.webType === "dropdown" && } + {column.webType === "textarea" && } + {column.webType === "text_area" && } + {column.webType === "checkbox" && } + {column.webType === "boolean" && } + {column.webType === "radio" && } + {column.webType === "code" && } + {column.webType === "entity" && } + {column.webType === "file" && } +
+
+
{column.columnLabel || column.columnName}
+
{column.columnName}
+
+
+ ))} +
+ )} +
+ )) + )} +
+ + {/* 페이징 컨트롤 */} + {totalPages > 1 && ( +
+
+ + +
+ {currentPage} / {totalPages} +
+ + +
+
+ )} +
+ + {/* 중앙: 캔버스 영역 */} +
+
+
+ {/* 항상 격자와 캔버스 표시 */} +
+ {/* 동적 그리드 가이드 */} +
+
+ {Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => ( +
+ ))} +
+ + {/* 격자 스냅이 활성화된 경우 추가 가이드라인 */} + {layout.gridSettings?.snapToGrid && gridInfo && ( +
+ {generateGridLines( + canvasRef.current?.clientWidth || 800, + canvasRef.current?.clientHeight || 600, + layout.gridSettings as GridUtilSettings, + ).verticalLines.map((x, i) => ( +
+ ))} + {generateGridLines( + canvasRef.current?.clientWidth || 800, + canvasRef.current?.clientHeight || 600, + layout.gridSettings as GridUtilSettings, + ).horizontalLines.map((y, i) => ( +
+ ))} +
+ )} +
+ + {/* 마키 선택 사각형 */} + {selectionState.isSelecting && ( +
+ )} + + {/* 컴포넌트들 - 실시간 미리보기 */} + {layout.components + .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 + .map((component) => { + // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 + const children = + component.type === "group" + ? layout.components.filter((child) => child.parentId === component.id) + : []; + + return ( + handleComponentClick(component, e)} + onDragStart={(e) => startComponentDrag(component, e)} + onDragEnd={endDrag} + onGroupToggle={(groupId) => { + // 그룹 접기/펼치기 토글 + const groupComp = component as GroupComponent; + updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); + }} + > + {children.map((child) => ( + handleComponentClick(child, e)} + onDragStart={(e) => startComponentDrag(child, e)} + onDragEnd={endDrag} + /> + ))} + + ); + })} +
+
+
+
+ + {/* 우측: 컴포넌트 스타일 편집 */} +
+
+ {/* 격자 설정 */} + + +

컴포넌트 속성

+ + {selectedComponent ? ( +
+ + + + {selectedComponent.type === "container" && "테이블 속성"} + {selectedComponent.type === "widget" && "위젯 속성"} + + + + {/* 위치 속성 */} +
+
+ + { + const val = (e.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(val)) { + let newX = Math.round(val); + + // 격자 스냅이 활성화된 경우 격자에 맞춤 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPos = snapToGrid( + { + x: newX, + y: selectedComponent.position.y, + z: selectedComponent.position.z || 1, + } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newX = snappedPos.x; + } + + updateComponentProperty(selectedComponent.id, "position.x", newX); + } + }} + /> +
+
+ + { + const val = (e.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(val)) { + let newY = Math.round(val); + + // 격자 스냅이 활성화된 경우 격자에 맞춤 + if (layout.gridSettings?.snapToGrid && gridInfo) { + const snappedPos = snapToGrid( + { + x: selectedComponent.position.x, + y: newY, + z: selectedComponent.position.z || 1, + } as Required, + gridInfo, + layout.gridSettings as GridUtilSettings, + ); + newY = snappedPos.y; + } + + updateComponentProperty(selectedComponent.id, "position.y", newY); + } + }} + /> +
+
+ + {/* 크기 속성 */} +
+
+ + {layout.gridSettings?.snapToGrid && gridInfo ? ( + // 격자 스냅이 활성화된 경우 컬럼 단위로 조정 +
+ { + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings; + return Math.max( + 1, + Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)), + ); + })()} + onChange={(e) => { + const gridColumns = Math.max( + 1, + Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1), + ); + const { columnWidth } = gridInfo; + const { gap } = layout.gridSettings!; + const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + updateComponentProperty(selectedComponent.id, "size.width", newWidth); + }} + /> +
실제 너비: {selectedComponent.size.width}px
+
+ ) : ( + // 격자 스냅이 비활성화된 경우 픽셀 단위로 조정 + { + const val = (e.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(val)) { + const newWidth = Math.max(20, Math.round(val)); + updateComponentProperty(selectedComponent.id, "size.width", newWidth); + } + }} + /> + )} +
+
+ + { + const val = (e.target as HTMLInputElement).valueAsNumber; + if (Number.isFinite(val)) { + let newHeight = Math.max(20, Math.round(val)); + + // 격자 스냅이 활성화된 경우 20px 단위로 조정 + if (layout.gridSettings?.snapToGrid) { + newHeight = Math.max(40, Math.round(newHeight / 20) * 20); + } + + updateComponentProperty(selectedComponent.id, "size.height", newHeight); + } + }} + /> +
+
+ + {/* 테이블 정보 */} + +
+ + +
+ + {/* 위젯 전용 속성 */} + {selectedComponent.type === "widget" && ( + <> +
+ + +
+
+ + +
+
+ + updateComponentProperty(selectedComponent.id, "label", e.target.value)} + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "placeholder", e.target.value) + } + /> +
+
+
+ + updateComponentProperty(selectedComponent.id, "required", e.target.checked) + } + /> + +
+
+ + updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) + } + /> + +
+
+ + )} + + {/* 스타일 속성 */} + +
+ + updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> +
+ + {/* 고급 속성 */} + +
+ + +
+ +
+
+
+ ) : ( +
+ +

컴포넌트를 선택하여 속성을 편집하세요

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/screen/panels/GridPanel.tsx b/frontend/components/screen/panels/GridPanel.tsx new file mode 100644 index 00000000..b8e600bc --- /dev/null +++ b/frontend/components/screen/panels/GridPanel.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react"; +import { GridSettings } from "@/types/screen"; + +interface GridPanelProps { + gridSettings: GridSettings; + onGridSettingsChange: (settings: GridSettings) => void; + onResetGrid: () => void; +} + +export const GridPanel: React.FC = ({ gridSettings, onGridSettingsChange, onResetGrid }) => { + const updateSetting = (key: keyof GridSettings, value: any) => { + onGridSettingsChange({ + ...gridSettings, + [key]: value, + }); + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ +

격자 설정

+
+ + +
+ + {/* 주요 토글들 */} +
+
+
+ {gridSettings.showGrid ? ( + + ) : ( + + )} + +
+ updateSetting("showGrid", checked)} + /> +
+ +
+
+ + +
+ updateSetting("snapToGrid", checked)} + /> +
+
+
+ + {/* 설정 영역 */} +
+ {/* 격자 구조 */} +
+

격자 구조

+ +
+ + updateSetting("columns", value)} + className="w-full" + /> +
+ 1 + 24 +
+
+ +
+ + updateSetting("gap", value)} + className="w-full" + /> +
+ 0px + 40px +
+
+ +
+ + updateSetting("padding", value)} + className="w-full" + /> +
+ 0px + 60px +
+
+
+ + + + {/* 격자 스타일 */} +
+

격자 스타일

+ +
+ +
+ updateSetting("gridColor", e.target.value)} + className="h-8 w-12 rounded border p-1" + /> + updateSetting("gridColor", e.target.value)} + placeholder="#d1d5db" + className="flex-1" + /> +
+
+ +
+ + updateSetting("gridOpacity", value)} + className="w-full" + /> +
+ 10% + 100% +
+
+
+ + + + {/* 미리보기 */} +
+

미리보기

+ +
+
+ 컴포넌트 예시 +
+
+
+
+ + {/* 푸터 */} +
+
💡 격자 설정은 실시간으로 캔버스에 반영됩니다
+
+
+ ); +}; + +export default GridPanel; diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx new file mode 100644 index 00000000..0507fd73 --- /dev/null +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -0,0 +1,456 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Textarea } from "@/components/ui/textarea"; +import { Settings, Move, Resize, Type, Palette, Trash2, Copy, Group, Ungroup } from "lucide-react"; +import { ComponentData, WebType } from "@/types/screen"; + +interface PropertiesPanelProps { + selectedComponent?: ComponentData; + onUpdateProperty: (path: string, value: any) => void; + onDeleteComponent: () => void; + onCopyComponent: () => void; + onGroupComponents?: () => void; + onUngroupComponents?: () => void; + canGroup?: boolean; + canUngroup?: boolean; +} + +const webTypeOptions: { value: WebType; label: string }[] = [ + { value: "text", label: "텍스트" }, + { value: "email", label: "이메일" }, + { value: "tel", label: "전화번호" }, + { value: "number", label: "숫자" }, + { value: "decimal", label: "소수" }, + { value: "date", label: "날짜" }, + { value: "datetime", label: "날짜시간" }, + { value: "select", label: "선택박스" }, + { value: "dropdown", label: "드롭다운" }, + { value: "textarea", label: "텍스트영역" }, + { value: "boolean", label: "불린" }, + { value: "checkbox", label: "체크박스" }, + { value: "radio", label: "라디오" }, + { value: "code", label: "코드" }, + { value: "entity", label: "엔티티" }, + { value: "file", label: "파일" }, +]; + +// Debounce hook for better performance +const useDebounce = (value: any, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; + +export const PropertiesPanel: React.FC = ({ + selectedComponent, + onUpdateProperty, + onDeleteComponent, + onCopyComponent, + onGroupComponents, + onUngroupComponents, + canGroup = false, + canUngroup = false, +}) => { + // 최신 값들의 참조를 유지 + const selectedComponentRef = useRef(selectedComponent); + const onUpdatePropertyRef = useRef(onUpdateProperty); + + useEffect(() => { + selectedComponentRef.current = selectedComponent; + onUpdatePropertyRef.current = onUpdateProperty; + }); + + // 로컬 상태 관리 (실시간 입력 반영용) + const [localValues, setLocalValues] = useState({ + label: selectedComponent?.label || "", + placeholder: selectedComponent?.placeholder || "", + title: selectedComponent?.title || "", + positionX: selectedComponent?.position.x || 0, + positionY: selectedComponent?.position.y || 0, + positionZ: selectedComponent?.position.z || 1, + width: selectedComponent?.size.width || 0, + height: selectedComponent?.size.height || 0, + }); + + // 선택된 컴포넌트가 변경될 때 로컬 상태 업데이트 + useEffect(() => { + if (selectedComponent) { + setLocalValues({ + label: selectedComponent.label || "", + placeholder: selectedComponent.placeholder || "", + title: selectedComponent.title || "", + positionX: selectedComponent.position.x || 0, + positionY: selectedComponent.position.y || 0, + positionZ: selectedComponent.position.z || 1, + width: selectedComponent.size.width || 0, + height: selectedComponent.size.height || 0, + }); + } + }, [selectedComponent]); + + // Debounce된 값들 + const debouncedLabel = useDebounce(localValues.label, 300); + const debouncedPlaceholder = useDebounce(localValues.placeholder, 300); + const debouncedTitle = useDebounce(localValues.title, 300); + const debouncedPositionX = useDebounce(localValues.positionX, 150); + const debouncedPositionY = useDebounce(localValues.positionY, 150); + const debouncedPositionZ = useDebounce(localValues.positionZ, 150); + const debouncedWidth = useDebounce(localValues.width, 150); + const debouncedHeight = useDebounce(localValues.height, 150); + + // Debounce된 값이 변경될 때 실제 업데이트 + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedLabel !== currentComponent.label && debouncedLabel) { + updateProperty("label", debouncedLabel); + } + }, [debouncedLabel]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedPlaceholder !== currentComponent.placeholder) { + updateProperty("placeholder", debouncedPlaceholder); + } + }, [debouncedPlaceholder]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedTitle !== currentComponent.title) { + updateProperty("title", debouncedTitle); + } + }, [debouncedTitle]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedPositionX !== currentComponent.position.x) { + updateProperty("position.x", debouncedPositionX); + } + }, [debouncedPositionX]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedPositionY !== currentComponent.position.y) { + updateProperty("position.y", debouncedPositionY); + } + }, [debouncedPositionY]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedPositionZ !== currentComponent.position.z) { + updateProperty("position.z", debouncedPositionZ); + } + }, [debouncedPositionZ]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedWidth !== currentComponent.size.width) { + updateProperty("size.width", debouncedWidth); + } + }, [debouncedWidth]); + + useEffect(() => { + const currentComponent = selectedComponentRef.current; + const updateProperty = onUpdatePropertyRef.current; + + if (currentComponent && debouncedHeight !== currentComponent.size.height) { + updateProperty("size.height", debouncedHeight); + } + }, [debouncedHeight]); + + if (!selectedComponent) { + return ( +
+ +

컴포넌트를 선택하세요

+

캔버스에서 컴포넌트를 클릭하면 속성을 편집할 수 있습니다.

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+
+ +

속성 편집

+
+ + {selectedComponent.type} + +
+ + {/* 액션 버튼들 */} +
+ + + {canGroup && ( + + )} + + {canUngroup && ( + + )} + + +
+
+ + {/* 속성 편집 영역 */} +
+ {/* 기본 정보 */} +
+
+ +

기본 정보

+
+ +
+
+ + setLocalValues((prev) => ({ ...prev, label: e.target.value }))} + placeholder="컴포넌트 라벨" + className="mt-1" + /> +
+ + {selectedComponent.type === "widget" && ( + <> +
+ + +
+ +
+ + +
+ +
+ + setLocalValues((prev) => ({ ...prev, placeholder: e.target.value }))} + placeholder="입력 힌트 텍스트" + className="mt-1" + /> +
+ +
+
+ onUpdateProperty("required", checked)} + /> + +
+ +
+ onUpdateProperty("readonly", checked)} + /> + +
+
+ + )} +
+
+ + + + {/* 위치 및 크기 */} +
+
+ +

위치 및 크기

+
+ +
+
+ + setLocalValues((prev) => ({ ...prev, positionX: Number(e.target.value) }))} + className="mt-1" + /> +
+ +
+ + setLocalValues((prev) => ({ ...prev, positionY: Number(e.target.value) }))} + className="mt-1" + /> +
+ +
+ + setLocalValues((prev) => ({ ...prev, width: Number(e.target.value) }))} + className="mt-1" + /> +
+ +
+ + setLocalValues((prev) => ({ ...prev, height: Number(e.target.value) }))} + className="mt-1" + /> +
+ +
+ + setLocalValues((prev) => ({ ...prev, positionZ: Number(e.target.value) }))} + className="mt-1" + placeholder="1" + /> +
+
+
+ + {selectedComponent.type === "group" && ( + <> + + + {/* 그룹 설정 */} +
+
+ +

그룹 설정

+
+ +
+ + setLocalValues((prev) => ({ ...prev, title: e.target.value }))} + placeholder="그룹 제목" + className="mt-1" + /> +
+
+ + )} +
+
+ ); +}; + +export default PropertiesPanel; diff --git a/frontend/components/screen/panels/TablesPanel.tsx b/frontend/components/screen/panels/TablesPanel.tsx new file mode 100644 index 00000000..ccf7213f --- /dev/null +++ b/frontend/components/screen/panels/TablesPanel.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Database, + ChevronDown, + ChevronRight, + Type, + Hash, + Calendar, + CheckSquare, + List, + AlignLeft, + Code, + Building, + File, + Search, +} from "lucide-react"; +import { TableInfo, WebType } from "@/types/screen"; + +interface TablesPanelProps { + tables: TableInfo[]; + searchTerm: string; + onSearchChange: (term: string) => void; + onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void; + selectedTableName?: string; +} + +// 위젯 타입별 아이콘 +const getWidgetIcon = (widgetType: WebType) => { + switch (widgetType) { + case "text": + case "email": + case "tel": + return ; + case "number": + case "decimal": + return ; + case "date": + case "datetime": + return ; + case "select": + case "dropdown": + return ; + case "textarea": + case "text_area": + return ; + case "boolean": + case "checkbox": + return ; + case "code": + return ; + case "entity": + return ; + case "file": + return ; + default: + return ; + } +}; + +export const TablesPanel: React.FC = ({ + tables, + searchTerm, + onSearchChange, + onDragStart, + selectedTableName, +}) => { + const [expandedTables, setExpandedTables] = useState>(new Set()); + + const toggleTable = (tableName: string) => { + const newExpanded = new Set(expandedTables); + if (newExpanded.has(tableName)) { + newExpanded.delete(tableName); + } else { + newExpanded.add(tableName); + } + setExpandedTables(newExpanded); + }; + + const filteredTables = tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), + ); + + return ( +
+ {/* 헤더 */} +
+ {selectedTableName && ( +
+
선택된 테이블
+
+ + {selectedTableName} +
+
+ )} + + {/* 검색 */} +
+ + onSearchChange(e.target.value)} + className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500" + /> +
+ +
총 {filteredTables.length}개 테이블
+
+ + {/* 테이블 목록 */} +
+
+ {filteredTables.map((table) => { + const isExpanded = expandedTables.has(table.tableName); + + return ( +
+ {/* 테이블 헤더 */} +
toggleTable(table.tableName)} + > +
+ {isExpanded ? ( + + ) : ( + + )} + +
+
{table.tableName}
+
{table.columns.length}개 컬럼
+
+
+ + +
+ + {/* 컬럼 목록 */} + {isExpanded && ( +
+
8 + ? "scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-64 overflow-y-auto" + : "" + }`} + style={{ + scrollbarWidth: "thin", + scrollbarColor: "#cbd5e1 #f1f5f9", + }} + > + {table.columns.map((column, index) => ( +
onDragStart(e, table, column)} + > +
+ {getWidgetIcon(column.widgetType)} +
+
{column.columnName}
+
{column.dataType}
+
+
+ +
+ + {column.widgetType} + + {column.required && ( + + 필수 + + )} +
+
+ ))} + + {/* 컬럼 수가 많을 때 안내 메시지 */} + {table.columns.length > 8 && ( +
+
+ 📜 총 {table.columns.length}개 컬럼 (스크롤하여 더 보기) +
+
+ )} +
+
+ )} +
+ ); + })} +
+
+ + {/* 푸터 */} +
+
💡 테이블이나 컬럼을 캔버스로 드래그하세요
+
+
+ ); +}; + +export default TablesPanel; diff --git a/frontend/hooks/usePanelState.ts b/frontend/hooks/usePanelState.ts new file mode 100644 index 00000000..6a571059 --- /dev/null +++ b/frontend/hooks/usePanelState.ts @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; + +export interface PanelState { + isOpen: boolean; + position: { x: number; y: number }; + size: { width: number; height: number }; +} + +export interface PanelConfig { + id: string; + title: string; + defaultPosition: "left" | "right" | "top" | "bottom"; + defaultWidth: number; + defaultHeight: number; + shortcutKey?: string; +} + +export const usePanelState = (panels: PanelConfig[]) => { + const [panelStates, setPanelStates] = useState>(() => { + const initialStates: Record = {}; + + panels.forEach((panel) => { + initialStates[panel.id] = { + isOpen: false, + position: { x: 0, y: 0 }, + size: { width: panel.defaultWidth, height: panel.defaultHeight }, + }; + }); + + return initialStates; + }); + + // 패널 토글 + const togglePanel = useCallback((panelId: string) => { + setPanelStates((prev) => ({ + ...prev, + [panelId]: { + ...prev[panelId], + isOpen: !prev[panelId]?.isOpen, + }, + })); + }, []); + + // 패널 열기 + const openPanel = useCallback((panelId: string) => { + setPanelStates((prev) => ({ + ...prev, + [panelId]: { + ...prev[panelId], + isOpen: true, + }, + })); + }, []); + + // 패널 닫기 + const closePanel = useCallback((panelId: string) => { + setPanelStates((prev) => ({ + ...prev, + [panelId]: { + ...prev[panelId], + isOpen: false, + }, + })); + }, []); + + // 모든 패널 닫기 + const closeAllPanels = useCallback(() => { + setPanelStates((prev) => { + const newStates = { ...prev }; + Object.keys(newStates).forEach((panelId) => { + newStates[panelId] = { + ...newStates[panelId], + isOpen: false, + }; + }); + return newStates; + }); + }, []); + + // 패널 위치 업데이트 + const updatePanelPosition = useCallback((panelId: string, position: { x: number; y: number }) => { + setPanelStates((prev) => ({ + ...prev, + [panelId]: { + ...prev[panelId], + position, + }, + })); + }, []); + + // 패널 크기 업데이트 + const updatePanelSize = useCallback((panelId: string, size: { width: number; height: number }) => { + setPanelStates((prev) => ({ + ...prev, + [panelId]: { + ...prev[panelId], + size, + }, + })); + }, []); + + // 키보드 단축키 처리 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Esc 키로 모든 패널 닫기 + if (e.key === "Escape") { + closeAllPanels(); + return; + } + + // 단축키 처리 + panels.forEach((panel) => { + if (panel.shortcutKey && e.key.toLowerCase() === panel.shortcutKey.toLowerCase()) { + // Ctrl/Cmd 키와 함께 사용 + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + togglePanel(panel.id); + } + } + }); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [panels, togglePanel, closeAllPanels]); + + return { + panelStates, + togglePanel, + openPanel, + closePanel, + closeAllPanels, + updatePanelPosition, + updatePanelSize, + }; +}; + diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts index c064e3bc..49f819d1 100644 --- a/frontend/lib/utils/gridUtils.ts +++ b/frontend/lib/utils/gridUtils.ts @@ -75,12 +75,28 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri const { gap } = gridSettings; // 격자 단위로 너비 계산 - const gridColumns = Math.max(1, Math.round(size.width / (columnWidth + gap))); + // 컴포넌트가 차지하는 컬럼 수를 올바르게 계산 + let gridColumns = 1; + + // 현재 너비에서 가장 가까운 격자 컬럼 수 찾기 + for (let cols = 1; cols <= gridSettings.columns; cols++) { + const targetWidth = cols * columnWidth + (cols - 1) * gap; + if (size.width <= targetWidth + (columnWidth + gap) / 2) { + gridColumns = cols; + break; + } + gridColumns = cols; + } + const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; // 높이는 20px 단위로 스냅 const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20); + console.log( + `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`, + ); + return { width: Math.max(columnWidth, snappedWidth), height: snappedHeight, diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index d656425b..cc26e785 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -120,7 +120,7 @@ export interface ComponentStyle { export interface BaseComponent { id: string; type: ComponentType; - position: { x: number; y: number }; + position: Position; size: { width: number; height: number }; parentId?: string; style?: ComponentStyle; // 스타일 속성 추가 @@ -199,6 +199,9 @@ export interface GridSettings { gap: number; // 기본값: 16px padding: number; // 기본값: 16px snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true) + showGrid?: boolean; // 격자 표시 여부 (기본값: true) + gridColor?: string; // 격자 색상 (기본값: #d1d5db) + gridOpacity?: number; // 격자 투명도 (기본값: 0.5) } // 유효성 검증 규칙 @@ -291,8 +294,8 @@ export interface DropZone { export interface GroupState { isGrouping: boolean; selectedComponents: string[]; - groupTarget: string | null; - groupMode: "create" | "add" | "remove" | "ungroup"; + groupTarget?: string | null; + groupMode?: "create" | "add" | "remove" | "ungroup"; groupTitle?: string; groupStyle?: ComponentStyle; } @@ -313,7 +316,9 @@ export interface ColumnInfo { columnLabel?: string; dataType: string; webType?: WebType; + widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일) isNullable: string; + required?: boolean; // isNullable에서 변환된 필드 columnDefault?: string; characterMaximumLength?: number; numericPrecision?: number; From 162ab1280641ff65bd4652a1d126a4bfbb16b36a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 2 Sep 2025 16:46:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screen/RealtimePreview.tsx | 88 ++-- frontend/components/screen/ScreenDesigner.tsx | 14 + .../screen/panels/PropertiesPanel.tsx | 376 +++++++++++------- frontend/types/screen.ts | 13 + 4 files changed, 316 insertions(+), 175 deletions(-) diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index e23de4f5..ea3bec21 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -244,15 +244,33 @@ export const RealtimePreview: React.FC = ({ } : style; + // 라벨 스타일 계산 + const shouldShowLabel = component.style?.labelDisplay !== false && (component.label || component.style?.labelText); + const labelText = component.style?.labelText || component.label || ""; + + // 라벨 하단 여백 값 추출 (px 단위 숫자로 변환) + const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10); + + const labelStyle: React.CSSProperties = { + fontSize: component.style?.labelFontSize || "12px", + color: component.style?.labelColor || "#374151", + fontWeight: component.style?.labelFontWeight || "500", + fontFamily: component.style?.labelFontFamily || "inherit", + textAlign: component.style?.labelTextAlign || "left", + backgroundColor: component.style?.labelBackgroundColor || "transparent", + padding: component.style?.labelPadding || "0", + borderRadius: component.style?.labelBorderRadius || "0", + }; + return (
{ @@ -267,31 +285,53 @@ export const RealtimePreview: React.FC = ({ e.stopPropagation(); }} > - {type === "container" && ( -
-
- -
-
{label}
-
{tableName}
+ {/* 라벨 표시 */} + {shouldShowLabel && ( +
+ {labelText} + {component.required && *} +
+ )} + + {/* 컴포넌트 내용 */} +
+ {type === "container" && ( +
+
+ +
+
{label}
+
{tableName}
+
-
- )} + )} - {type === "group" && ( -
- {/* 그룹 박스/헤더 제거: 투명 컨테이너 */} -
{children}
-
- )} + {type === "group" && ( +
+ {/* 그룹 박스/헤더 제거: 투명 컨테이너 */} +
{children}
+
+ )} - {type === "widget" && ( -
- {/* 위젯 본체 - 실제 웹 위젯처럼 보이도록 */} -
{renderWidget(component)}
-
- )} + {type === "widget" && ( +
+ {/* 위젯 본체 - 실제 웹 위젯처럼 보이도록 */} +
{renderWidget(component)}
+
+ )} +
); }; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index d8ea08bd..6b534882 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -431,6 +431,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "600", + labelMarginBottom: "8px", + }, }; } else if (type === "column") { // 격자 기반 컬럼 너비 계산 @@ -449,6 +456,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD readonly: false, // 누락된 속성 추가 position: { x, y, z: 1 } as Position, size: { width: columnWidth, height: 40 }, + style: { + labelDisplay: true, + labelFontSize: "12px", + labelColor: "#374151", + labelFontWeight: "500", + labelMarginBottom: "6px", + }, }; } else { return; diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 0507fd73..98002bee 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -42,23 +42,6 @@ const webTypeOptions: { value: WebType; label: string }[] = [ { value: "file", label: "파일" }, ]; -// Debounce hook for better performance -const useDebounce = (value: any, delay: number) => { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; -}; - export const PropertiesPanel: React.FC = ({ selectedComponent, onUpdateProperty, @@ -73,122 +56,49 @@ export const PropertiesPanel: React.FC = ({ const selectedComponentRef = useRef(selectedComponent); const onUpdatePropertyRef = useRef(onUpdateProperty); + // 입력 필드들의 로컬 상태 (실시간 타이핑 반영용) + const [localInputs, setLocalInputs] = useState({ + placeholder: selectedComponent?.placeholder || "", + title: selectedComponent?.title || "", + positionX: selectedComponent?.position.x?.toString() || "0", + positionY: selectedComponent?.position.y?.toString() || "0", + positionZ: selectedComponent?.position.z?.toString() || "1", + width: selectedComponent?.size.width?.toString() || "0", + height: selectedComponent?.size.height?.toString() || "0", + labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", + labelFontSize: selectedComponent?.style?.labelFontSize || "12px", + labelColor: selectedComponent?.style?.labelColor || "#374151", + labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", + required: selectedComponent?.required || false, + readonly: selectedComponent?.readonly || false, + }); + useEffect(() => { selectedComponentRef.current = selectedComponent; onUpdatePropertyRef.current = onUpdateProperty; }); - // 로컬 상태 관리 (실시간 입력 반영용) - const [localValues, setLocalValues] = useState({ - label: selectedComponent?.label || "", - placeholder: selectedComponent?.placeholder || "", - title: selectedComponent?.title || "", - positionX: selectedComponent?.position.x || 0, - positionY: selectedComponent?.position.y || 0, - positionZ: selectedComponent?.position.z || 1, - width: selectedComponent?.size.width || 0, - height: selectedComponent?.size.height || 0, - }); - - // 선택된 컴포넌트가 변경될 때 로컬 상태 업데이트 + // 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트 useEffect(() => { if (selectedComponent) { - setLocalValues({ - label: selectedComponent.label || "", + setLocalInputs({ placeholder: selectedComponent.placeholder || "", title: selectedComponent.title || "", - positionX: selectedComponent.position.x || 0, - positionY: selectedComponent.position.y || 0, - positionZ: selectedComponent.position.z || 1, - width: selectedComponent.size.width || 0, - height: selectedComponent.size.height || 0, + positionX: selectedComponent.position.x?.toString() || "0", + positionY: selectedComponent.position.y?.toString() || "0", + positionZ: selectedComponent.position.z?.toString() || "1", + width: selectedComponent.size.width?.toString() || "0", + height: selectedComponent.size.height?.toString() || "0", + labelText: selectedComponent.style?.labelText || selectedComponent.label || "", + labelFontSize: selectedComponent.style?.labelFontSize || "12px", + labelColor: selectedComponent.style?.labelColor || "#374151", + labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px", + required: selectedComponent.required || false, + readonly: selectedComponent.readonly || false, }); } }, [selectedComponent]); - // Debounce된 값들 - const debouncedLabel = useDebounce(localValues.label, 300); - const debouncedPlaceholder = useDebounce(localValues.placeholder, 300); - const debouncedTitle = useDebounce(localValues.title, 300); - const debouncedPositionX = useDebounce(localValues.positionX, 150); - const debouncedPositionY = useDebounce(localValues.positionY, 150); - const debouncedPositionZ = useDebounce(localValues.positionZ, 150); - const debouncedWidth = useDebounce(localValues.width, 150); - const debouncedHeight = useDebounce(localValues.height, 150); - - // Debounce된 값이 변경될 때 실제 업데이트 - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedLabel !== currentComponent.label && debouncedLabel) { - updateProperty("label", debouncedLabel); - } - }, [debouncedLabel]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedPlaceholder !== currentComponent.placeholder) { - updateProperty("placeholder", debouncedPlaceholder); - } - }, [debouncedPlaceholder]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedTitle !== currentComponent.title) { - updateProperty("title", debouncedTitle); - } - }, [debouncedTitle]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedPositionX !== currentComponent.position.x) { - updateProperty("position.x", debouncedPositionX); - } - }, [debouncedPositionX]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedPositionY !== currentComponent.position.y) { - updateProperty("position.y", debouncedPositionY); - } - }, [debouncedPositionY]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedPositionZ !== currentComponent.position.z) { - updateProperty("position.z", debouncedPositionZ); - } - }, [debouncedPositionZ]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedWidth !== currentComponent.size.width) { - updateProperty("size.width", debouncedWidth); - } - }, [debouncedWidth]); - - useEffect(() => { - const currentComponent = selectedComponentRef.current; - const updateProperty = onUpdatePropertyRef.current; - - if (currentComponent && debouncedHeight !== currentComponent.size.height) { - updateProperty("size.height", debouncedHeight); - } - }, [debouncedHeight]); - if (!selectedComponent) { return (
@@ -251,19 +161,6 @@ export const PropertiesPanel: React.FC = ({
-
- - setLocalValues((prev) => ({ ...prev, label: e.target.value }))} - placeholder="컴포넌트 라벨" - className="mt-1" - /> -
- {selectedComponent.type === "widget" && ( <>
@@ -307,8 +204,12 @@ export const PropertiesPanel: React.FC = ({ setLocalValues((prev) => ({ ...prev, placeholder: e.target.value }))} + value={localInputs.placeholder} + onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, placeholder: newValue })); + onUpdateProperty("placeholder", newValue); + }} placeholder="입력 힌트 텍스트" className="mt-1" /> @@ -318,8 +219,11 @@ export const PropertiesPanel: React.FC = ({
onUpdateProperty("required", checked)} + checked={localInputs.required} + onCheckedChange={(checked) => { + setLocalInputs((prev) => ({ ...prev, required: !!checked })); + onUpdateProperty("required", checked); + }} />
@@ -385,8 +300,12 @@ export const PropertiesPanel: React.FC = ({ setLocalValues((prev) => ({ ...prev, width: Number(e.target.value) }))} + value={localInputs.width} + onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, width: newValue })); + onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) }); + }} className="mt-1" />
@@ -398,8 +317,12 @@ export const PropertiesPanel: React.FC = ({ setLocalValues((prev) => ({ ...prev, height: Number(e.target.value) }))} + value={localInputs.height} + onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, height: newValue })); + onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) }); + }} className="mt-1" />
@@ -413,8 +336,12 @@ export const PropertiesPanel: React.FC = ({ type="number" min="0" max="9999" - value={localValues.positionZ} - onChange={(e) => setLocalValues((prev) => ({ ...prev, positionZ: Number(e.target.value) }))} + value={localInputs.positionZ} + onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, positionZ: newValue })); + onUpdateProperty("position", { ...selectedComponent.position, z: Number(newValue) }); + }} className="mt-1" placeholder="1" /> @@ -422,6 +349,149 @@ export const PropertiesPanel: React.FC = ({
+ + + {/* 라벨 스타일 */} +
+
+ +

라벨 설정

+
+ + {/* 라벨 표시 토글 */} +
+ + onUpdateProperty("style.labelDisplay", checked)} + /> +
+ + {/* 라벨 텍스트 */} +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, labelText: newValue })); + // 기본 라벨과 스타일 라벨을 모두 업데이트 + onUpdateProperty("label", newValue); + onUpdateProperty("style.labelText", newValue); + }} + placeholder="라벨 텍스트" + className="mt-1" + /> +
+ + {/* 라벨 스타일 */} +
+
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, labelFontSize: newValue })); + onUpdateProperty("style.labelFontSize", newValue); + }} + placeholder="12px" + className="mt-1" + /> +
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, labelColor: newValue })); + onUpdateProperty("style.labelColor", newValue); + }} + className="mt-1 h-8" + /> +
+ +
+ + +
+ +
+ + +
+
+ + {/* 라벨 여백 */} +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, labelMarginBottom: newValue })); + onUpdateProperty("style.labelMarginBottom", newValue); + }} + placeholder="4px" + className="mt-1" + /> +
+
+ {selectedComponent.type === "group" && ( <> @@ -439,8 +509,12 @@ export const PropertiesPanel: React.FC = ({ setLocalValues((prev) => ({ ...prev, title: e.target.value }))} + value={localInputs.title} + onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, title: newValue })); + onUpdateProperty("title", newValue); + }} placeholder="그룹 제목" className="mt-1" /> diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index cc26e785..18a05cb6 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -114,6 +114,19 @@ export interface ComponentStyle { cursor?: string; transition?: string; transform?: string; + + // 라벨 스타일 + labelDisplay?: boolean; // 라벨 표시 여부 + labelText?: string; // 라벨 텍스트 (기본값은 label 속성 사용) + labelFontSize?: string | number; // 라벨 폰트 크기 + labelColor?: string; // 라벨 색상 + labelFontWeight?: "normal" | "bold" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900"; // 라벨 폰트 굵기 + labelFontFamily?: string; // 라벨 폰트 패밀리 + labelTextAlign?: "left" | "center" | "right"; // 라벨 텍스트 정렬 + labelMarginBottom?: string | number; // 라벨과 컴포넌트 사이의 간격 + labelBackgroundColor?: string; // 라벨 배경색 + labelPadding?: string; // 라벨 패딩 + labelBorderRadius?: string | number; // 라벨 모서리 둥글기 } // BaseComponent에 스타일 속성 추가 From f82d18575e10c5aae8fb9708536c22306e9f76cf Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Sep 2025 11:32:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/화면관리_시스템_설계.md | 200 ++++- .../components/screen/DesignerToolbar.tsx | 17 +- frontend/components/screen/FloatingPanel.tsx | 72 +- .../components/screen/RealtimePreview.tsx | 379 +++++++-- frontend/components/screen/ScreenDesigner.tsx | 726 ++++++++++++++++-- .../screen/panels/DetailSettingsPanel.tsx | 219 ++++++ .../screen/panels/PropertiesPanel.tsx | 55 +- .../CheckboxTypeConfigPanel.tsx | 206 +++++ .../webtype-configs/CodeTypeConfigPanel.tsx | 304 ++++++++ .../webtype-configs/DateTypeConfigPanel.tsx | 320 ++++++++ .../webtype-configs/EntityTypeConfigPanel.tsx | 394 ++++++++++ .../webtype-configs/FileTypeConfigPanel.tsx | 301 ++++++++ .../webtype-configs/NumberTypeConfigPanel.tsx | 234 ++++++ .../webtype-configs/RadioTypeConfigPanel.tsx | 295 +++++++ .../webtype-configs/SelectTypeConfigPanel.tsx | 290 +++++++ .../webtype-configs/TextTypeConfigPanel.tsx | 193 +++++ .../TextareaTypeConfigPanel.tsx | 210 +++++ frontend/hooks/usePanelState.ts | 37 +- frontend/lib/utils/gridUtils.ts | 199 +++++ frontend/types/screen.ts | 116 ++- 20 files changed, 4603 insertions(+), 164 deletions(-) create mode 100644 frontend/components/screen/panels/DetailSettingsPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/CheckboxTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/CodeTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/DateTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/FileTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/NumberTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/RadioTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/SelectTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx create mode 100644 frontend/components/screen/panels/webtype-configs/TextareaTypeConfigPanel.tsx diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index abb262ac..68d5b6b7 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -30,21 +30,30 @@ - **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능 - **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리 -### 🆕 최근 업데이트 (요약) +### 🆕 최근 업데이트 (2024.12) -- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지 -- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외 -- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거 -- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중 -- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI) -- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화 -- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y) -- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시 -- **복사/삭제/붙여넣기 범용화**: 단일/다중/그룹 선택 모두에서 동작. 우클릭 위치 붙여넣기, 단축키(Ctrl/Cmd+C, Ctrl/Cmd+V, Delete) 지원, 상단 툴바 버튼 제공. 클립보드 상태 배지 표시(예: "3개 복사됨", "그룹 복사됨") -- **화면 코드 자동 생성**: 회사 코드 기반 고유 화면 코드 자동 생성 (예: COMP_001) -- **레이아웃 저장/로드**: 설계한 화면 레이아웃을 데이터베이스에 저장하고 불러오는 기능 -- **메뉴-화면 할당**: 설계한 화면을 실제 메뉴에 할당하여 사용자가 접근할 수 있도록 연결 -- **인터랙티브 화면 뷰어**: 할당된 화면에서 실제 사용자 입력 및 상호작용이 가능한 완전 기능 화면 +#### ✅ 완료된 주요 기능들 + +- **컴포넌트 관리 시스템**: 드래그앤드롭, 다중 선택, 그룹 드래그, 실시간 위치 업데이트 +- **속성 편집 시스템**: 실시간 속성 편집, 라벨 관리 (텍스트, 폰트, 색상, 여백), 필수 입력 시 주황색 \* 표시 +- **격자 시스템**: 동적 격자 설정, 컴포넌트 스냅 및 크기 조정 +- **패널 관리**: 플로팅 패널, 수동 크기 조정, 위치 기억 +- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file + +#### 🔧 해결된 기술적 문제들 + +- **라벨 하단 여백 동적 적용**: 여백값에 따른 정확한 위치 계산 +- **스타일 속성 개별 업데이트**: 초기화 방지를 위한 `style.propertyName` 방식 적용 +- **체크박스 실시간 반영**: 로컬 상태 + 실제 속성 동시 업데이트 +- **다중 드래그 최적화**: 지연 없는 실시간 미리보기, 선택 해제 방지 +- **입력 필드 실시간 적용**: debounce 제거, 즉시 반영 시스템 + +#### 🎯 개발 진행 상황 + +- **현재 완성도**: 95% (핵심 기능 완료) +- **기술 스택**: Next.js 15.4.4, TypeScript, Tailwind CSS, Shadcn/ui +- **상태 관리**: React Hooks 기반 로컬 상태 + 실시간 업데이트 패턴 +- **드래그앤드롭**: HTML5 Drag & Drop API 기반 고도화된 시스템 ### 🎯 **현재 테이블 구조와 100% 호환** @@ -2369,7 +2378,66 @@ export class TableTypeIntegrationService { ## 🚀 다음 단계 계획 -### 1. 컴포넌트 그룹화 기능 (완료) +### 1. 웹타입별 상세 설정 기능 구현 (진행 예정) + +#### 📋 구현 계획 개요 + +각 웹 타입(date, number, select 등)에 대한 세부적인 설정을 가능하게 하여 더 정교한 폼 컨트롤을 제공 + +#### 🎯 단계별 구현 계획 + +##### Phase 1: 타입 정의 및 인터페이스 설계 + +```typescript +// 웹타입별 설정 인터페이스 +interface DateTypeConfig { + format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss"; + showTime: boolean; + minDate?: string; + maxDate?: string; + defaultValue?: string; +} + +interface NumberTypeConfig { + min?: number; + max?: number; + step?: number; + format?: "integer" | "decimal" | "currency" | "percentage"; + decimalPlaces?: number; + thousandSeparator?: boolean; +} + +interface SelectTypeConfig { + options: Array<{ label: string; value: string }>; + multiple?: boolean; + searchable?: boolean; + placeholder?: string; +} +``` + +##### Phase 2: PropertiesPanel 확장 + +- 웹 타입 선택 시 해당 타입의 세부 설정 UI 동적 표시 +- 각 타입별 전용 설정 컴포넌트 생성 +- 실시간 설정값 업데이트 및 미리보기 + +##### Phase 3: 우선순위 타입별 구현 + +1. **날짜/시간 (date, datetime)**: 날짜 형식, 시간 포함 여부, 날짜 범위 +2. **숫자 (number, decimal)**: 범위, 형식, 소수점, 천 단위 구분자 +3. **선택박스 (select, dropdown)**: 동적 옵션 관리, 다중 선택, 검색 기능 +4. **텍스트 (text, textarea)**: 길이 제한, 입력 패턴, 형식 검증 +5. **파일 (file)**: 파일 형식 제한, 크기 제한, 다중 업로드 + +##### Phase 4: RealtimePreview 업데이트 + +설정값에 따른 실제 렌더링 로직 구현 (input 속성, 검증 규칙 등) + +##### Phase 5: 저장/불러오기 + +컴포넌트 데이터에 webTypeConfig 포함하여 레이아웃 저장 시 설정값도 함께 저장 + +### 2. 컴포넌트 그룹화 기능 (완료) - [x] 여러 위젯을 컨테이너로 그룹화 - [x] 부모-자식 관계 설정(parentId) @@ -2463,6 +2531,105 @@ export class TableTypeIntegrationService { - **Version Control**: Git - **Package Manager**: npm +## 🔧 핵심 기술적 구현 패턴 + +### 1. 상태 관리 패턴 + +#### 로컬 상태 + 실시간 업데이트 패턴 + +PropertiesPanel에서 사용하는 입력 필드 관리 방식: + +```typescript +const [localInputs, setLocalInputs] = useState({ + placeholder: selectedComponent?.placeholder || "", + // ... 기타 필드들 +}); + +// 입력 시 로컬 상태 + 실제 컴포넌트 동시 업데이트 +onChange={(e) => { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, fieldName: newValue })); + onUpdateProperty("fieldName", newValue); +}} +``` + +#### 스타일 속성 개별 업데이트 패턴 + +스타일 초기화 방지를 위한 개별 속성 업데이트: + +```typescript +// Bad: 전체 객체 교체로 인한 다른 속성 손실 +onUpdateProperty("style", { ...selectedComponent.style, newProp: value }); + +// Good: 개별 속성 직접 업데이트 +onUpdateProperty("style.labelFontSize", value); +``` + +### 2. 드래그앤드롭 패턴 + +#### 다중 컴포넌트 드래그 처리 + +- dragState에 draggedComponents 배열로 선택된 모든 컴포넌트 관리 +- 실시간 미리보기를 위한 RealtimePreview와 실제 업데이트 분리 +- justFinishedDrag 플래그로 드래그 완료 후 의도치 않은 선택 해제 방지 + +#### 격자 스냅 시스템 + +- 컴포넌트 위치와 크기를 격자에 맞게 자동 조정 +- 격자 설정 변경 시 기존 컴포넌트들도 자동 재조정 + +### 3. 컴포넌트 렌더링 패턴 + +#### 웹타입별 동적 렌더링 + +RealtimePreview에서 switch-case로 웹타입별 적절한 입력 컴포넌트 렌더링: + +```typescript +switch (widgetType) { + case "text": + return ; + case "date": + return ; + case "select": + return ; +} +``` + +#### 라벨 동적 위치 계산 + +라벨 하단 여백 설정에 따른 동적 위치 계산: + +```typescript +const labelMarginBottomValue = parseInt(component.style?.labelMarginBottom || "4px", 10); +style={{ top: `${-20 - labelMarginBottomValue}px` }} +``` + +### 4. 패널 관리 패턴 + +#### 플로팅 패널 상태 관리 + +- 각 패널의 위치, 크기, 열림/닫힘 상태를 독립적으로 관리 +- 사용자가 수동으로 조정한 위치 기억 +- autoHeight 제거로 컨텐츠 변경 시에도 위치 유지 + +### 5. 타입 안전성 패턴 + +#### 인터페이스 확장 패턴 + +BaseComponent를 기본으로 각 컴포넌트 타입별 확장: + +```typescript +export interface WidgetComponent extends BaseComponent { + type: "widget"; + widgetType: WebType; + // 위젯 전용 속성들 +} +``` + +#### 유니온 타입 활용 + +ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로 타입 안전성 보장 + ## 🎯 결론 화면관리 시스템은 **회사별 권한 관리**와 **테이블 타입관리 연계**를 통해 사용자가 직관적으로 웹 화면을 설계할 수 있는 강력한 도구입니다. @@ -2493,7 +2660,8 @@ export class TableTypeIntegrationService { - ✅ **Phase 1-6 완료**: 기본 구조, 드래그앤드롭, 컴포넌트 라이브러리, 테이블 연계, 미리보기, 통합 테스트 - ✅ **핵심 기능 완료**: 컴포넌트 그룹화, 레이아웃 저장/로드, 메뉴-화면 할당, 인터랙티브 화면 뷰어 -- 📋 **향후 계획**: 반응형 레이아웃, 고급 기능, 실제 CRUD 연동 +- ✅ **고도화 완료**: 실시간 속성 편집, 라벨 관리, 다중 드래그, 격자 시스템 +- 📋 **다음 계획**: 웹타입별 상세 설정, 반응형 레이아웃, 고급 기능 ### 🎉 **완전 기능 화면관리 시스템 완성!** diff --git a/frontend/components/screen/DesignerToolbar.tsx b/frontend/components/screen/DesignerToolbar.tsx index cb31eb93..9b95a98e 100644 --- a/frontend/components/screen/DesignerToolbar.tsx +++ b/frontend/components/screen/DesignerToolbar.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft } from "lucide-react"; +import { Menu, Database, Settings, Palette, Grid3X3, Save, Undo, Redo, Play, ArrowLeft, Cog } from "lucide-react"; import { cn } from "@/lib/utils"; interface DesignerToolbarProps { @@ -110,7 +110,20 @@ export const DesignerToolbar: React.FC = ({ 격자 - G + R + + + +
diff --git a/frontend/components/screen/FloatingPanel.tsx b/frontend/components/screen/FloatingPanel.tsx index de8a0416..2de7ed12 100644 --- a/frontend/components/screen/FloatingPanel.tsx +++ b/frontend/components/screen/FloatingPanel.tsx @@ -35,13 +35,18 @@ export const FloatingPanel: React.FC = ({ minWidth = 280, minHeight = 300, maxWidth = 600, - maxHeight = 800, + maxHeight = 1200, // 800 → 1200 (더 큰 패널 지원) resizable = true, draggable = true, - autoHeight = false, // 자동 높이 조정 비활성화 (수동 크기 조절만 지원) + autoHeight = true, // 자동 높이 조정 활성화 (컨텐츠 크기에 맞게) className, }) => { const [panelSize, setPanelSize] = useState({ width, height }); + + // props 변경 시 패널 크기 업데이트 + useEffect(() => { + setPanelSize({ width, height }); + }, [width, height]); const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -91,7 +96,54 @@ export const FloatingPanel: React.FC = ({ } }, [isOpen, position, hasInitialized]); - // 자동 높이 조정 기능 제거됨 - 수동 크기 조절만 지원 + // 자동 높이 조정 기능 + useEffect(() => { + if (!autoHeight || !contentRef.current || isResizing) return; + + const updateHeight = () => { + if (!contentRef.current) return; + + // 일시적으로 높이 제한을 해제하여 실제 컨텐츠 높이 측정 + contentRef.current.style.maxHeight = "none"; + + // 컨텐츠의 실제 높이 측정 + const contentHeight = contentRef.current.scrollHeight; + const headerHeight = 60; // 헤더 높이 + const padding = 30; // 여유 공간 (좀 더 넉넉하게) + + const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight); + + console.log(`🔧 패널 높이 자동 조정:`, { + panelId: id, + contentHeight, + calculatedHeight: newHeight, + currentHeight: panelSize.height, + willUpdate: Math.abs(panelSize.height - newHeight) > 10, + }); + + // 현재 높이와 다르면 업데이트 + if (Math.abs(panelSize.height - newHeight) > 10) { + setPanelSize((prev) => ({ ...prev, height: newHeight })); + } + }; + + // 초기 높이 설정 + updateHeight(); + + // ResizeObserver로 컨텐츠 크기 변화 감지 + const resizeObserver = new ResizeObserver((entries) => { + // DOM 업데이트가 완료된 후에 높이 측정 + requestAnimationFrame(() => { + setTimeout(updateHeight, 50); // 약간의 지연으로 렌더링 완료 후 측정 + }); + }); + + resizeObserver.observe(contentRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [autoHeight, minHeight, maxHeight, isResizing, panelSize.height, children]); // 드래그 시작 - 성능 최적화 const handleDragStart = (e: React.MouseEvent) => { @@ -215,16 +267,20 @@ export const FloatingPanel: React.FC = ({ {/* 컨텐츠 */}
{children}
{/* 리사이즈 핸들 */} - {resizable && ( + {resizable && !autoHeight && (
diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index ea3bec21..66e58720 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -1,7 +1,21 @@ "use client"; import React from "react"; -import { ComponentData, WebType } from "@/types/screen"; +import { + ComponentData, + WebType, + WidgetComponent, + DateTypeConfig, + NumberTypeConfig, + SelectTypeConfig, + TextTypeConfig, + TextareaTypeConfig, + CheckboxTypeConfig, + RadioTypeConfig, + FileTypeConfig, + CodeTypeConfig, + EntityTypeConfig, +} from "@/types/screen"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -57,122 +71,387 @@ const renderWidget = (component: ComponentData) => { switch (widgetType) { case "text": case "email": - case "tel": - return ; + case "tel": { + const widget = component as WidgetComponent; + const config = widget.webTypeConfig as TextTypeConfig | undefined; + + // 플레이스홀더 처리 + const finalPlaceholder = config?.placeholder || placeholder || "텍스트를 입력하세요"; + + const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"; + + // multiline이면 Textarea로 렌더링 + if (config?.multiline) { + return ( +