"use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Database, Cog } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { ScreenDefinition, ComponentData, LayoutData, GroupState, TableInfo, Position, ColumnInfo, GridSettings, ScreenResolution, SCREEN_RESOLUTIONS, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping"; import { createGroupComponent, calculateBoundingBox, calculateRelativePositions, restoreAbsolutePositions, } from "@/lib/utils/groupingUtils"; // 10px 단위 스냅 함수 const snapTo10px = (value: number): number => { return Math.round(value / 10) * 10; }; const snapPositionTo10px = (position: Position): Position => { return { x: snapTo10px(position.x), y: snapTo10px(position.y), z: position.z, }; }; const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => { return { width: snapTo10px(size.width), height: snapTo10px(size.height), }; }; import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { toast } from "sonner"; import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { initializeComponents } from "@/lib/registry/components"; import { ScreenFileAPI } from "@/lib/api/screenFile"; import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreviewDynamic"; import FloatingPanel from "./FloatingPanel"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { ComponentsPanel } from "./panels/ComponentsPanel"; import PropertiesPanel from "./panels/PropertiesPanel"; import DetailSettingsPanel from "./panels/DetailSettingsPanel"; import ResolutionPanel from "./panels/ResolutionPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowVisibilityConfig } from "@/types/control-management"; import { areAllButtons, generateGroupId, groupButtons, ungroupButtons, findAllButtonGroups, } from "@/lib/utils/flowButtonGroupUtils"; import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; // 새로운 통합 UI 컴포넌트 import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar"; import { SlimToolbar } from "./toolbar/SlimToolbar"; import { UnifiedPropertiesPanel } from "./panels/UnifiedPropertiesPanel"; // 컴포넌트 초기화 (새 시스템) import "@/lib/registry/components"; // 성능 최적화 도구 초기화 (필요시 사용) import "@/lib/registry/utils/performanceOptimizer"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; } // 패널 설정 (통합 패널 1개) const panelConfigs: PanelConfig[] = [ // 통합 패널 (컴포넌트 + 편집 탭) { id: "unified", title: "패널", defaultPosition: "left", defaultWidth: 240, defaultHeight: 700, shortcutKey: "p", }, ]; 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: 0, snapToGrid: true, showGrid: false, // 기본값 false로 변경 gridColor: "#d1d5db", gridOpacity: 0.5, }, }); const [isSaving, setIsSaving] = useState(false); // 메뉴 할당 모달 상태 const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); // 파일첨부 상세 모달 상태 const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); const [selectedFileComponent, setSelectedFileComponent] = useState(null); // 해상도 설정 상태 const [screenResolution, setScreenResolution] = useState( SCREEN_RESOLUTIONS[0], // 기본값: Full HD ); const [selectedComponent, setSelectedComponent] = useState(null); // 컴포넌트 선택 시 통합 패널 자동 열기 const handleComponentSelect = useCallback( (component: ComponentData | null) => { setSelectedComponent(component); // 컴포넌트가 선택되면 통합 패널 자동 열기 if (component) { openPanel("unified"); } }, [openPanel], ); // 클립보드 상태 const [clipboard, setClipboard] = useState([]); // 실행취소/다시실행을 위한 히스토리 상태 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, draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열 originalPosition: { x: 0, y: 0, z: 1 }, currentPosition: { x: 0, y: 0, z: 1 }, grabOffset: { x: 0, y: 0 }, justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 }); // Pan 모드 상태 (스페이스바 + 드래그) const [isPanMode, setIsPanMode] = useState(false); const [panState, setPanState] = useState({ isPanning: false, startX: 0, startY: 0, outerScrollLeft: 0, outerScrollTop: 0, innerScrollLeft: 0, innerScrollTop: 0, }); const canvasContainerRef = useRef(null); // Zoom 상태 const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% const MIN_ZOOM = 0.1; // 10% const MAX_ZOOM = 3; // 300% // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 const [forceRenderTrigger, setForceRenderTrigger] = useState(0); // 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회) const restoreFileComponentsData = useCallback( async (components: ComponentData[]) => { if (!selectedScreen?.screenId) return; // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); try { // 실제 DB에서 화면의 모든 파일 정보 조회 const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); if (!fileResponse.success) { // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); return; } const { componentFiles } = fileResponse; if (typeof window !== "undefined") { // 전역 파일 상태 초기화 const globalFileState: { [key: string]: any[] } = {}; let restoredCount = 0; // DB에서 조회한 파일 정보를 전역 상태로 복원 Object.keys(componentFiles).forEach((componentId) => { const files = componentFiles[componentId]; if (files && files.length > 0) { globalFileState[componentId] = files; restoredCount++; // localStorage에도 백업 const backupKey = `fileComponent_${componentId}_files`; localStorage.setItem(backupKey, JSON.stringify(files)); console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { componentId: componentId, fileCount: files.length, files: files.map((f) => ({ objid: f.objid, name: f.realFileName })), }); } }); // 전역 상태 업데이트 (window as any).globalFileState = globalFileState; // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 Object.keys(globalFileState).forEach((componentId) => { const files = globalFileState[componentId]; const syncEvent = new CustomEvent("globalFileStateChanged", { detail: { componentId: componentId, files: files, fileCount: files.length, timestamp: Date.now(), isRestore: true, }, }); window.dispatchEvent(syncEvent); }); if (restoredCount > 0) { toast.success( `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, ); } } } catch (error) { // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); toast.error("파일 데이터 복원 중 오류가 발생했습니다."); } }, [selectedScreen?.screenId], ); // 드래그 선택 상태 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); // 10px 격자 라인 생성 (시각적 가이드용) const gridLines = useMemo(() => { if (!layout.gridSettings?.showGrid) return []; const width = screenResolution.width; const height = screenResolution.height; const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = []; // 10px 단위로 격자 라인 생성 for (let x = 0; x <= width; x += 10) { lines.push({ type: "vertical", position: x }); } for (let y = 0; y <= height; y += 10) { lines.push({ type: "horizontal", position: y }); } return lines; }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); // 필터된 테이블 목록 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 placedColumns = useMemo(() => { const placed = new Set(); const collectColumns = (components: ComponentData[]) => { components.forEach((comp) => { const anyComp = comp as any; // widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인 if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) { const key = `${anyComp.tableName}.${anyComp.columnName}`; placed.add(key); } // 자식 컴포넌트도 확인 (재귀) if (comp.children && comp.children.length > 0) { collectColumns(comp.children); } }); }; collectColumns(layout.components); return placed; }, [layout.components]); // 히스토리에 저장 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)); }, [historyIndex], ); // 실행취소 const undo = useCallback(() => { setHistoryIndex((prevIndex) => { if (prevIndex > 0) { const newIndex = prevIndex - 1; setHistory((prevHistory) => { if (prevHistory[newIndex]) { setLayout(prevHistory[newIndex]); } return prevHistory; }); return newIndex; } return prevIndex; }); }, []); // 다시실행 const redo = useCallback(() => { setHistoryIndex((prevIndex) => { let newIndex = prevIndex; setHistory((prevHistory) => { if (prevIndex < prevHistory.length - 1) { newIndex = prevIndex + 1; if (prevHistory[newIndex]) { setLayout(prevHistory[newIndex]); } } return prevHistory; }); return newIndex; }); }, []); // 컴포넌트 속성 업데이트 const updateComponentProperty = useCallback( (componentId: string, path: string, value: any) => { // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 setLayout((prevLayout) => { const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); const isLayoutComponent = targetComponent?.type === "layout"; // 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용 const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign"; let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만 if (isGroupSetting && targetComponent) { const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig; const currentGroupId = flowConfig?.groupId; if (currentGroupId) { // 같은 그룹의 모든 버튼 찾기 affectedComponents = prevLayout.components .filter((comp) => { const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig; return compConfig?.groupId === currentGroupId && compConfig?.enabled; }) .map((comp) => comp.id); console.log("🔄 그룹 설정 일괄 적용:", { groupId: currentGroupId, setting: path.split(".").pop(), value, affectedButtons: affectedComponents, }); } } // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 const positionDelta = { x: 0, y: 0 }; if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { const oldPosition = targetComponent.position; let newPosition = { ...oldPosition }; if (path === "position.x") { newPosition.x = value; positionDelta.x = value - oldPosition.x; } else if (path === "position.y") { newPosition.y = value; positionDelta.y = value - oldPosition.y; } else if (path === "position") { newPosition = value; positionDelta.x = value.x - oldPosition.x; positionDelta.y = value.y - oldPosition.y; } console.log("📐 레이아웃 이동 감지:", { layoutId: componentId, oldPosition, newPosition, positionDelta, }); } const pathParts = path.split("."); const updatedComponents = prevLayout.components.map((comp) => { // 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용 const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId; if (!shouldUpdate) { // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { // 이 레이아웃의 존에 속한 컴포넌트인지 확인 const isInLayoutZone = comp.parentId === componentId && comp.zoneId; if (isInLayoutZone) { console.log("🔄 존 컴포넌트 함께 이동:", { componentId: comp.id, zoneId: comp.zoneId, oldPosition: comp.position, delta: positionDelta, }); return { ...comp, position: { ...comp.position, x: comp.position.x + positionDelta.x, y: comp.position.y + positionDelta.y, }, }; } } return comp; } // 중첩 경로를 고려한 안전한 복사 const newComp = { ...comp }; // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { const key = pathParts[i]; // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { current[key] = {}; } else { // 기존 객체를 복사하여 불변성 유지 current[key] = { ...current[key] }; } current = current[key]; } // 최종 값 설정 const finalKey = pathParts[pathParts.length - 1]; current[finalKey] = value; // gridColumns 변경 시 크기 자동 업데이트 if (path === "gridColumns" && gridInfo) { const updatedSize = updateSizeFromGridColumns(newComp, layout.gridSettings as GridUtilSettings); newComp.size = updatedSize; } // 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외) if ( (path === "size.width" || path === "size.height") && prevLayout.gridSettings?.snapToGrid && gridInfo && newComp.type !== "group" ) { // 현재 해상도에 맞는 격자 정보로 스냅 적용 const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: prevLayout.gridSettings.columns, gap: prevLayout.gridSettings.gap, padding: prevLayout.gridSettings.padding, snapToGrid: prevLayout.gridSettings.snapToGrid || false, }); const snappedSize = snapSizeTo10px( newComp.size, currentGridInfo, prevLayout.gridSettings as GridUtilSettings, ); newComp.size = snappedSize; // 크기 변경 시 gridColumns도 자동 조정 const adjustedColumns = adjustGridColumnsFromSize( newComp, currentGridInfo, prevLayout.gridSettings as GridUtilSettings, ); if (newComp.gridColumns !== adjustedColumns) { newComp.gridColumns = adjustedColumns; } } // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: prevLayout.gridSettings.columns, gap: prevLayout.gridSettings.gap, padding: prevLayout.gridSettings.padding, snapToGrid: prevLayout.gridSettings.snapToGrid || false, }); // gridColumns에 맞는 정확한 너비 계산 const newWidth = calculateWidthFromColumns( newComp.gridColumns, currentGridInfo, prevLayout.gridSettings as GridUtilSettings, ); newComp.size = { ...newComp.size, width: newWidth, }; } // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) if ( (path === "position.x" || path === "position.y" || path === "position") && layout.gridSettings?.snapToGrid ) { // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 if (newComp.parentId && currentGridInfo) { const { columnWidth } = currentGridInfo; const { gap } = layout.gridSettings; // 그룹 내부 패딩 고려한 격자 정렬 const padding = 16; const effectiveX = newComp.position.x - padding; const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); // Y 좌표는 10px 단위로 스냅 const effectiveY = newComp.position.y - padding; const rowIndex = Math.round(effectiveY / 10); const snappedY = padding + rowIndex * 10; // 크기도 외부 격자와 동일하게 스냅 const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) const snappedHeight = Math.max(10, newComp.size.height); newComp.position = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 y: Math.max(padding, snappedY), z: newComp.position.z || 1, }; newComp.size = { width: snappedWidth, height: snappedHeight, }; } else if (newComp.type !== "group") { // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 const snappedPosition = snapPositionTo10px( newComp.position, currentGridInfo, layout.gridSettings as GridUtilSettings, ); newComp.position = snappedPosition; } } return newComp; }); // 🔥 새로운 layout 생성 const newLayout = { ...prevLayout, components: updatedComponents }; saveToHistory(newLayout); // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 setSelectedComponent((prevSelected) => { if (prevSelected && prevSelected.id === componentId) { const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); if (updatedSelectedComponent) { // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); return newSelectedComponent; } } return prevSelected; }); // webTypeConfig 업데이트 후 레이아웃 상태 확인 if (path === "webTypeConfig") { const updatedComponent = newLayout.components.find((c) => c.id === componentId); console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { componentId, updatedComponent: updatedComponent ? { id: updatedComponent.id, type: updatedComponent.type, webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, } : null, layoutComponentsCount: newLayout.components.length, timestamp: new Date().toISOString(), }); } return newLayout; }); }, [saveToHistory], ); // 컴포넌트 시스템 초기화 useEffect(() => { const initComponents = async () => { try { // console.log("🚀 컴포넌트 시스템 초기화 시작..."); await initializeComponents(); // console.log("✅ 컴포넌트 시스템 초기화 완료"); } catch (error) { // console.error("❌ 컴포넌트 시스템 초기화 실패:", error); } }; initComponents(); }, []); // 화면 선택 시 파일 복원 useEffect(() => { if (selectedScreen?.screenId) { restoreScreenFiles(); } }, [selectedScreen?.screenId]); // 화면의 모든 파일 컴포넌트 파일 복원 const restoreScreenFiles = useCallback(async () => { if (!selectedScreen?.screenId) return; try { // console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId); // 해당 화면의 모든 파일 조회 const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); if (response.success && response.componentFiles) { // console.log("📁 복원할 파일 데이터:", response.componentFiles); // 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용) Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => { if (Array.isArray(serverFiles) && serverFiles.length > 0) { // 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const currentGlobalFiles = globalFileState[componentId] || []; let currentLocalStorageFiles: any[] = []; if (typeof window !== "undefined") { try { const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`); if (storedFiles) { currentLocalStorageFiles = JSON.parse(storedFiles); } } catch (e) { // console.warn("localStorage 파일 파싱 실패:", e); } } // 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터 let finalFiles = serverFiles; if (currentGlobalFiles.length > 0) { finalFiles = currentGlobalFiles; // console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개"); } else if (currentLocalStorageFiles.length > 0) { finalFiles = currentLocalStorageFiles; // console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개"); } else { // console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개"); } // 전역 상태에 파일 저장 globalFileState[componentId] = finalFiles; if (typeof window !== "undefined") { (window as any).globalFileState = globalFileState; } // localStorage에도 백업 if (typeof window !== "undefined") { localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles)); } } }); // 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선) setLayout((prevLayout) => { const updatedComponents = prevLayout.components.map((comp) => { // 🎯 전역 상태에서 최신 파일 정보 가져오기 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const finalFiles = globalFileState[comp.id] || []; if (finalFiles.length > 0) { return { ...comp, uploadedFiles: finalFiles, lastFileUpdate: Date.now(), }; } return comp; }); return { ...prevLayout, components: updatedComponents, }; }); // console.log("✅ 화면 파일 복원 완료"); } } catch (error) { // console.error("❌ 화면 파일 복원 오류:", error); } }, [selectedScreen?.screenId]); // 전역 파일 상태 변경 이벤트 리스너 useEffect(() => { const handleGlobalFileStateChange = (event: CustomEvent) => { // console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); setForceRenderTrigger((prev) => prev + 1); }; if (typeof window !== "undefined") { window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); return () => { window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); }; } }, []); // 화면의 기본 테이블 정보 로드 (원래대로 복원) useEffect(() => { const loadScreenTable = async () => { const tableName = selectedScreen?.tableName; if (!tableName) { setTables([]); return; } try { // 테이블 라벨 조회 const tableListResponse = await tableManagementApi.getTableList(); const currentTable = tableListResponse.success && tableListResponse.data ? tableListResponse.data.find((t) => t.tableName === tableName) : null; const tableLabel = currentTable?.displayName || tableName; // 현재 화면의 테이블 컬럼 정보 조회 const columnsResponse = await tableTypeApi.getColumns(tableName); const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type; // 🔍 이미지 타입 디버깅 // if (widgetType === "image" || col.webType === "image" || col.web_type === "image") { // console.log("🖼️ 이미지 컬럼 발견:", { // columnName: col.columnName || col.column_name, // widgetType, // webType: col.webType || col.web_type, // rawData: col, // }); // } return { tableName: col.tableName || tableName, columnName: col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, dataType: col.dataType || col.data_type || col.dbType, webType: col.webType || col.web_type, input_type: col.inputType || col.input_type, widgetType, 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, codeCategory: col.codeCategory || col.code_category, codeValue: col.codeValue || col.code_value, }; }); const tableInfo: TableInfo = { tableName, tableLabel, columns, }; setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로) } catch (error) { console.error("화면 테이블 정보 로드 실패:", error); setTables([]); } }; loadScreenTable(); }, [selectedScreen?.tableName, selectedScreen?.screenName]); // 화면 레이아웃 로드 useEffect(() => { if (selectedScreen?.screenId) { // 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용) if (typeof window !== "undefined") { (window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId; } const loadLayout = async () => { try { const response = await screenApi.getLayout(selectedScreen.screenId); if (response) { // 🔄 마이그레이션 필요 여부 확인 let layoutToUse = response; if (needsMigration(response)) { const canvasWidth = response.screenResolution?.width || 1920; layoutToUse = safeMigrateLayout(response, canvasWidth); } // 🔄 webTypeConfig를 autoGeneration으로 변환 const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter"); const convertedComponents = convertLayoutComponents(layoutToUse.components); // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) const layoutWithDefaultGrid = { ...layoutToUse, components: convertedComponents, // 변환된 컴포넌트 사용 gridSettings: { columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12 gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16 padding: 0, // padding은 항상 0으로 강제 snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선 showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5, }, }; // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 if (layoutToUse.screenResolution) { setScreenResolution(layoutToUse.screenResolution); // console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution); } else { // 기본 해상도 (Full HD) const defaultResolution = SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0]; setScreenResolution(defaultResolution); // console.log("🔧 기본 해상도 적용:", defaultResolution); } setLayout(layoutWithDefaultGrid); setHistory([layoutWithDefaultGrid]); setHistoryIndex(0); // 파일 컴포넌트 데이터 복원 (비동기) restoreFileComponentsData(layoutWithDefaultGrid.components); } } catch (error) { // console.error("레이아웃 로드 실패:", error); toast.error("화면 레이아웃을 불러오는데 실패했습니다."); } }; loadLayout(); } }, [selectedScreen?.screenId]); // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // 입력 필드에서는 스페이스바 무시 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } if (e.code === "Space") { e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 if (!isPanMode) { setIsPanMode(true); // body에 커서 스타일 추가 document.body.style.cursor = "grab"; } } }; const handleKeyUp = (e: KeyboardEvent) => { if (e.code === "Space") { e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 setIsPanMode(false); setPanState((prev) => ({ ...prev, isPanning: false })); // body 커서 스타일 복원 document.body.style.cursor = "default"; } }; const handleMouseDown = (e: MouseEvent) => { if (isPanMode) { e.preventDefault(); // 외부와 내부 스크롤 컨테이너 모두 저장 setPanState({ isPanning: true, startX: e.pageX, startY: e.pageY, outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0, outerScrollTop: canvasContainerRef.current?.scrollTop || 0, innerScrollLeft: canvasRef.current?.scrollLeft || 0, innerScrollTop: canvasRef.current?.scrollTop || 0, }); // 드래그 중 커서 변경 document.body.style.cursor = "grabbing"; } }; const handleMouseMove = (e: MouseEvent) => { if (isPanMode && panState.isPanning) { e.preventDefault(); const dx = e.pageX - panState.startX; const dy = e.pageY - panState.startY; // 외부 컨테이너 스크롤 if (canvasContainerRef.current) { canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx; canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy; } // 내부 캔버스 스크롤 if (canvasRef.current) { canvasRef.current.scrollLeft = panState.innerScrollLeft - dx; canvasRef.current.scrollTop = panState.innerScrollTop - dy; } } }; const handleMouseUp = () => { if (isPanMode) { setPanState((prev) => ({ ...prev, isPanning: false })); // 드래그 종료 시 커서 복원 document.body.style.cursor = "grab"; } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("mousedown", handleMouseDown); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [ isPanMode, panState.isPanning, panState.startX, panState.startY, panState.outerScrollLeft, panState.outerScrollTop, panState.innerScrollLeft, panState.innerScrollTop, ]); // 마우스 휠로 줌 제어 useEffect(() => { const handleWheel = (e: WheelEvent) => { // 캔버스 컨테이너 내에서만 동작 if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) { // Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용) if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { // 기본 스크롤 동작 방지 e.preventDefault(); const delta = e.deltaY; const zoomFactor = 0.001; // 줌 속도 조절 setZoomLevel((prevZoom) => { const newZoom = prevZoom - delta * zoomFactor; return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); }); } } }; // passive: false로 설정하여 preventDefault() 가능하게 함 canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false }); const containerRef = canvasContainerRef.current; return () => { containerRef?.removeEventListener("wheel", handleWheel); }; }, [MIN_ZOOM, MAX_ZOOM]); // 격자 설정 업데이트 및 컴포넌트 자동 스냅 const updateGridSettings = useCallback( (newGridSettings: GridSettings) => { const newLayout = { ...layout, gridSettings: newGridSettings }; // 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정 if (newGridSettings.snapToGrid && screenResolution.width > 0) { // 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준) const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: newGridSettings.columns, gap: newGridSettings.gap, padding: newGridSettings.padding, snapToGrid: newGridSettings.snapToGrid || false, }); const gridUtilSettings = { columns: newGridSettings.columns, gap: newGridSettings.gap, padding: newGridSettings.padding, snapToGrid: true, // 항상 10px 스냅 활성화 }; const adjustedComponents = layout.components.map((comp) => { const snappedPosition = snapPositionTo10px(comp.position, newGridInfo, gridUtilSettings); const snappedSize = snapSizeTo10px(comp.size, newGridInfo, gridUtilSettings); // gridColumns가 없거나 범위를 벗어나면 자동 조정 let adjustedGridColumns = comp.gridColumns; if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) { adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); } return { ...comp, position: snappedPosition, size: snappedSize, gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정 }; }); newLayout.components = adjustedComponents; // console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개"); // console.log("새로운 격자 정보:", newGridInfo); } setLayout(newLayout); saveToHistory(newLayout); }, [layout, screenResolution, saveToHistory], ); // 해상도 변경 핸들러 (자동 스케일링 포함) const handleResolutionChange = useCallback( (newResolution: ScreenResolution) => { const oldWidth = screenResolution.width; const oldHeight = screenResolution.height; const newWidth = newResolution.width; const newHeight = newResolution.height; console.log("📱 해상도 변경 시작:", { from: `${oldWidth}x${oldHeight}`, to: `${newWidth}x${newHeight}`, hasComponents: layout.components.length > 0, snapToGrid: layout.gridSettings?.snapToGrid || false, }); setScreenResolution(newResolution); // 컴포넌트가 없으면 해상도만 변경 if (layout.components.length === 0) { const updatedLayout = { ...layout, screenResolution: newResolution, }; setLayout(updatedLayout); saveToHistory(updatedLayout); console.log("✅ 해상도 변경 완료 (컴포넌트 없음)"); return; } // 비율 계산 const scaleX = newWidth / oldWidth; const scaleY = newHeight / oldHeight; console.log("📐 스케일링 비율:", { scaleX: `${(scaleX * 100).toFixed(2)}%`, scaleY: `${(scaleY * 100).toFixed(2)}%`, }); // 컴포넌트 재귀적으로 스케일링하는 함수 const scaleComponent = (comp: ComponentData): ComponentData => { // 위치 스케일링 const scaledPosition = { x: comp.position.x * scaleX, y: comp.position.y * scaleY, z: comp.position.z || 1, }; // 크기 스케일링 const scaledSize = { width: comp.size.width * scaleX, height: comp.size.height * scaleY, }; return { ...comp, position: scaledPosition, size: scaledSize, }; }; // 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨) const scaledComponents = layout.components.map(scaleComponent); console.log("🔄 컴포넌트 스케일링 완료:", { totalComponents: scaledComponents.length, groupComponents: scaledComponents.filter((c) => c.type === "group").length, note: "그룹의 자식 컴포넌트도 모두 스케일링됨", }); // 격자 스냅이 활성화된 경우 격자에 맞춰 재조정 let finalComponents = scaledComponents; if (layout.gridSettings?.snapToGrid) { const newGridInfo = calculateGridInfo(newWidth, newHeight, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); const gridUtilSettings = { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: true, }; finalComponents = scaledComponents.map((comp) => { const snappedPosition = snapPositionTo10px(comp.position, newGridInfo, gridUtilSettings); const snappedSize = snapSizeTo10px(comp.size, newGridInfo, gridUtilSettings); // gridColumns 재계산 const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); return { ...comp, position: snappedPosition, size: snappedSize, gridColumns: adjustedGridColumns, }; }); console.log("🧲 격자 스냅 적용 완료"); } const updatedLayout = { ...layout, components: finalComponents, screenResolution: newResolution, }; setLayout(updatedLayout); saveToHistory(updatedLayout); toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, { description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, }); console.log("✅ 해상도 변경 완료:", { newResolution: `${newWidth}x${newHeight}`, scaledComponents: finalComponents.length, scaleX: `${(scaleX * 100).toFixed(2)}%`, scaleY: `${(scaleY * 100).toFixed(2)}%`, note: "모든 컴포넌트가 비율에 맞게 자동 조정됨", }); }, [layout, saveToHistory, screenResolution], ); // 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용) const handleForceGridUpdate = useCallback(() => { if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) { // console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음"); return; } console.log("🔄 격자 강제 재조정 시작:", { componentsCount: layout.components.length, resolution: `${screenResolution.width}x${screenResolution.height}`, gridSettings: layout.gridSettings, }); // 현재 해상도로 격자 정보 계산 const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); const gridUtilSettings = { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: true, }; const adjustedComponents = layout.components.map((comp) => { const snappedPosition = snapPositionTo10px(comp.position, currentGridInfo, gridUtilSettings); const snappedSize = snapSizeTo10px(comp.size, currentGridInfo, gridUtilSettings); // gridColumns가 없거나 범위를 벗어나면 자동 조정 let adjustedGridColumns = comp.gridColumns; if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings); } return { ...comp, position: snappedPosition, size: snappedSize, gridColumns: adjustedGridColumns, }; }); const newLayout = { ...layout, components: adjustedComponents }; setLayout(newLayout); saveToHistory(newLayout); console.log("✅ 격자 강제 재조정 완료:", { adjustedComponents: adjustedComponents.length, gridInfo: { columnWidth: currentGridInfo.columnWidth.toFixed(2), totalWidth: currentGridInfo.totalWidth, columns: layout.gridSettings.columns, }, }); toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); }, [layout, screenResolution, saveToHistory]); // 저장 const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) { console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen); toast.error("화면 정보가 없습니다."); return; } try { setIsSaving(true); // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 const updatedComponents = layout.components.map((comp) => { if (comp.type === "component" && comp.componentType === "split-panel-layout") { const config = comp.componentConfig || {}; const rightPanel = config.rightPanel || {}; const leftPanel = config.leftPanel || {}; const relationshipType = rightPanel.relation?.type || "detail"; // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 if (relationshipType === "detail" && leftPanel.tableName) { console.log("🔧 분할 패널 자동 수정:", { componentId: comp.id, leftTableName: leftPanel.tableName, rightTableName: leftPanel.tableName, }); return { ...comp, componentConfig: { ...config, rightPanel: { ...rightPanel, tableName: leftPanel.tableName, }, }, }; } } return comp; }); // 해상도 정보를 포함한 레이아웃 데이터 생성 const layoutWithResolution = { ...layout, components: updatedComponents, screenResolution: screenResolution, }; // 🔍 버튼 컴포넌트들의 action.type 확인 const buttonComponents = layoutWithResolution.components.filter( (c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary", ); console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length, gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, buttonComponents: buttonComponents.map((c: any) => ({ id: c.id, type: c.type, text: c.componentConfig?.text, actionType: c.componentConfig?.action?.type, fullAction: c.componentConfig?.action, })), }); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); toast.success("화면이 저장되었습니다."); // 저장 성공 후 메뉴 할당 모달 열기 setShowMenuAssignmentModal(true); } catch (error) { console.error("❌ 저장 실패:", error); toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } }, [selectedScreen, layout, screenResolution]); // 템플릿 드래그 처리 const handleTemplateDrop = useCallback( (e: React.DragEvent, template: TemplateComponent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const dropX = e.clientX - rect.left; const dropY = e.clientY - rect.top; // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = layout.gridSettings ? calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }) : null; // 격자 스냅 적용 const snappedPosition = layout.gridSettings?.snapToGrid && currentGridInfo ? snapPositionTo10px({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) : { x: dropX, y: dropY, z: 1 }; console.log("🎨 템플릿 드롭:", { templateName: template.name, componentsCount: template.components.length, dropPosition: { x: dropX, y: dropY }, snappedPosition, }); // 템플릿의 모든 컴포넌트들을 생성 // 먼저 ID 매핑을 생성 (parentId 참조를 위해) const idMapping: Record = {}; template.components.forEach((templateComp, index) => { const newId = generateComponentId(); if (index === 0) { // 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑 idMapping["form-container"] = newId; } idMapping[templateComp.parentId || `temp_${index}`] = newId; }); const newComponents: ComponentData[] = template.components.map((templateComp, index) => { const componentId = index === 0 ? idMapping["form-container"] : generateComponentId(); // 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정 const absoluteX = snappedPosition.x + templateComp.position.x; const absoluteY = snappedPosition.y + templateComp.position.y; // 격자 스냅 적용 const finalPosition = layout.gridSettings?.snapToGrid && currentGridInfo ? snapPositionTo10px( { x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings, ) : { x: absoluteX, y: absoluteY, z: 1 }; if (templateComp.type === "container") { // 그리드 컬럼 기반 크기 계산 const gridColumns = typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼 const calculatedSize = currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { width: newWidth, height: templateComp.size.height, }; })() : { width: 400, height: templateComp.size.height }; // 폴백 크기 return { id: componentId, type: "container", label: templateComp.label, tableName: selectedScreen?.tableName || "", title: templateComp.title || templateComp.label, position: finalPosition, size: calculatedSize, gridColumns, style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", ...templateComp.style, }, }; } else if (templateComp.type === "datatable") { // 데이터 테이블 컴포넌트 생성 const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) // gridColumns에 맞는 크기 계산 const calculatedSize = currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { width: newWidth, height: templateComp.size.height, // 높이는 템플릿 값 유지 }; })() : templateComp.size; console.log("📊 데이터 테이블 생성 시 크기 계산:", { gridColumns, templateSize: templateComp.size, calculatedSize, hasGridInfo: !!currentGridInfo, hasGridSettings: !!layout.gridSettings?. }); return { id: componentId, type: "datatable", label: templateComp.label, tableName: selectedScreen?.tableName || "", position: finalPosition, size: calculatedSize, title: templateComp.label, columns: [], // 초기에는 빈 배열, 나중에 설정 filters: [], // 초기에는 빈 배열, 나중에 설정 pagination: { enabled: true, pageSize: 10, pageSizeOptions: [5, 10, 20, 50], showPageSizeSelector: true, showPageInfo: true, showFirstLast: true, }, showSearchButton: true, searchButtonText: "검색", enableExport: true, enableRefresh: true, enableAdd: true, enableEdit: true, enableDelete: true, addButtonText: "추가", editButtonText: "수정", deleteButtonText: "삭제", addModalConfig: { title: "새 데이터 추가", description: `${templateComp.label}에 새로운 데이터를 추가합니다.`, width: "lg", layout: "two-column", gridColumns: 2, fieldOrder: [], // 초기에는 빈 배열, 나중에 컬럼 추가 시 설정 requiredFields: [], hiddenFields: [], advancedFieldConfigs: {}, // 초기에는 빈 객체, 나중에 컬럼별 설정 submitButtonText: "추가", cancelButtonText: "취소", }, gridColumns, style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", ...templateComp.style, }, } as ComponentData; } else if (templateComp.type === "file") { // 파일 첨부 컴포넌트 생성 const gridColumns = 6; // 기본값: 6컬럼 const calculatedSize = currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { width: newWidth, height: templateComp.size.height, }; })() : templateComp.size; return { id: componentId, type: "file", label: templateComp.label, position: finalPosition, size: calculatedSize, gridColumns, fileConfig: { accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"], multiple: true, maxSize: 10, // 10MB maxFiles: 5, docType: "DOCUMENT", docTypeName: "일반 문서", targetObjid: selectedScreen?.screenId || "", showPreview: true, showProgress: true, dragDropText: "파일을 드래그하여 업로드하세요", uploadButtonText: "파일 선택", autoUpload: true, chunkedUpload: false, }, uploadedFiles: [], style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", ...templateComp.style, }, } as ComponentData; } else if (templateComp.type === "area") { // 영역 컴포넌트 생성 const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) const calculatedSize = currentGridInfo && layout.gridSettings?.snapToGrid ? (() => { const newWidth = calculateWidthFromColumns( gridColumns, currentGridInfo, layout.gridSettings as GridUtilSettings, ); return { width: newWidth, height: templateComp.size.height, }; })() : templateComp.size; return { id: componentId, type: "area", label: templateComp.label, position: finalPosition, size: calculatedSize, gridColumns, layoutType: (templateComp as any).layoutType || "box", title: (templateComp as any).title || templateComp.label, description: (templateComp as any).description, layoutConfig: (templateComp as any).layoutConfig || {}, areaStyle: { backgroundColor: "#ffffff", borderWidth: 1, borderStyle: "solid", borderColor: "#e5e7eb", borderRadius: 8, padding: 0, margin: 0, shadow: "sm", ...(templateComp as any).areaStyle, }, children: [], style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", ...templateComp.style, }, } as ComponentData; } else { // 위젯 컴포넌트 const widgetType = templateComp.widgetType || "text"; // 웹타입별 기본 그리드 컬럼 수 계산 const getDefaultGridColumnsForTemplate = (wType: string): number => { const widthMap: Record = { text: 4, email: 4, tel: 3, url: 4, textarea: 6, number: 2, decimal: 2, date: 3, datetime: 3, time: 2, select: 3, radio: 3, checkbox: 2, boolean: 2, code: 3, entity: 4, file: 4, image: 3, button: 2, label: 2, }; return widthMap[wType] || 3; }; // 웹타입별 기본 설정 생성 const getDefaultWebTypeConfig = (wType: string) => { switch (wType) { case "date": return { format: "YYYY-MM-DD" as const, showTime: false, placeholder: templateComp.placeholder || "날짜를 선택하세요", }; case "select": case "dropdown": return { options: [ { label: "옵션 1", value: "option1" }, { label: "옵션 2", value: "option2" }, { label: "옵션 3", value: "option3" }, ], multiple: false, searchable: false, placeholder: templateComp.placeholder || "옵션을 선택하세요", }; case "text": return { format: "none" as const, placeholder: templateComp.placeholder || "텍스트를 입력하세요", multiline: false, }; case "email": return { format: "email" as const, placeholder: templateComp.placeholder || "이메일을 입력하세요", multiline: false, }; case "tel": return { format: "phone" as const, placeholder: templateComp.placeholder || "전화번호를 입력하세요", multiline: false, }; case "textarea": return { rows: 3, placeholder: templateComp.placeholder || "텍스트를 입력하세요", resizable: true, wordWrap: true, }; default: return { placeholder: templateComp.placeholder || "입력하세요", }; } }; // 위젯 크기도 격자에 맞게 조정 const widgetSize = currentGridInfo && layout.gridSettings?.snapToGrid ? { width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings), height: templateComp.size.height, } : templateComp.size; return { id: componentId, type: "widget", widgetType: widgetType as any, label: templateComp.label, placeholder: templateComp.placeholder, columnName: `field_${index + 1}`, parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined, position: finalPosition, size: widgetSize, required: templateComp.required || false, readonly: templateComp.readonly || false, gridColumns: getDefaultGridColumnsForTemplate(widgetType), webTypeConfig: getDefaultWebTypeConfig(widgetType), style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", ...templateComp.style, }, } as ComponentData; } }); // 레이아웃에 새 컴포넌트들 추가 const newLayout = { ...layout, components: [...layout.components, ...newComponents], }; setLayout(newLayout); saveToHistory(newLayout); // 첫 번째 컴포넌트 선택 if (newComponents.length > 0) { setSelectedComponent(newComponents[0]); } toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, [layout, selectedScreen, saveToHistory], ); // 레이아웃 드래그 처리 const handleLayoutDrop = useCallback( (e: React.DragEvent, layoutData: any) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 const dropX = (e.clientX - rect.left) / zoomLevel; const dropY = (e.clientY - rect.top) / zoomLevel; // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = layout.gridSettings ? calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }) : null; // 격자 스냅 적용 const snappedPosition = layout.gridSettings?.snapToGrid && currentGridInfo ? snapPositionTo10px({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) : { x: dropX, y: dropY, z: 1 }; console.log("🏗️ 레이아웃 드롭 (줌 보정):", { zoomLevel, layoutType: layoutData.layoutType, zonesCount: layoutData.zones.length, mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top }, dropPosition: { x: dropX, y: dropY }, snappedPosition, }); // 레이아웃 컴포넌트 생성 const newLayoutComponent: ComponentData = { id: layoutData.id, type: "layout", layoutType: layoutData.layoutType, layoutConfig: layoutData.layoutConfig, zones: layoutData.zones.map((zone: any) => ({ ...zone, id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가 })), children: [], position: snappedPosition, size: layoutData.size, label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, } as ComponentData; // 레이아웃에 새 컴포넌트 추가 const newLayout = { ...layout, components: [...layout.components, newLayoutComponent], }; setLayout(newLayout); saveToHistory(newLayout); // 레이아웃 컴포넌트 선택 setSelectedComponent(newLayoutComponent); toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, [layout, screenResolution, saveToHistory, zoomLevel], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 // 존 클릭 핸들러 const handleZoneClick = useCallback((zoneId: string) => { // console.log("🎯 존 클릭:", zoneId); // 필요시 존 선택 로직 추가 }, []); // 웹타입별 기본 설정 생성 함수를 상위로 이동 const getDefaultWebTypeConfig = useCallback((webType: string) => { switch (webType) { case "button": return { actionType: "custom", variant: "default", confirmationMessage: "", popupTitle: "", popupContent: "", navigateUrl: "", }; case "date": return { format: "YYYY-MM-DD", showTime: false, placeholder: "날짜를 선택하세요", }; case "number": return { format: "integer", placeholder: "숫자를 입력하세요", }; case "select": return { options: [ { label: "옵션 1", value: "option1" }, { label: "옵션 2", value: "option2" }, { label: "옵션 3", value: "option3" }, ], multiple: false, searchable: false, placeholder: "옵션을 선택하세요", }; case "file": return { accept: ["*/*"], maxSize: 10485760, // 10MB multiple: false, showPreview: true, autoUpload: false, }; default: return {}; } }, []); // 컴포넌트 드래그 처리 (캔버스 레벨 드롭) const handleComponentDrop = useCallback( (e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => { // 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출 if (!component) { const dragData = e.dataTransfer.getData("application/json"); if (!dragData) return; try { const parsedData = JSON.parse(dragData); if (parsedData.type === "component") { component = parsedData.component; } else { return; } } catch (error) { // console.error("드래그 데이터 파싱 오류:", error); return; } } const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; // 컴포넌트 크기 정보 const componentWidth = component.defaultSize?.width || 120; const componentHeight = component.defaultSize?.height || 36; // 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산 // 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center) // 2. 캔버스가 justify-center로 중앙 정렬되어 있음 // 실제 캔버스 논리적 크기 const canvasLogicalWidth = screenResolution.width; // 화면상 캔버스 실제 크기 (스케일 적용 후) const canvasVisualWidth = canvasLogicalWidth * zoomLevel; // 중앙 정렬로 인한 왼쪽 오프셋 계산 // rect.left는 이미 중앙 정렬된 위치를 반영하고 있음 // 마우스의 캔버스 내 상대 위치 (스케일 보정) const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel; const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel; // 방법 1: 마우스 포인터를 컴포넌트 중심으로 const dropX_centered = mouseXInCanvas - componentWidth / 2; const dropY_centered = mouseYInCanvas - componentHeight / 2; // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 const dropX_topleft = mouseXInCanvas; const dropY_topleft = mouseYInCanvas; // 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록 const dropX = dropX_topleft; const dropY = dropY_topleft; console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", { "1. 줌 레벨": zoomLevel, "2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY }, "3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, "4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height }, "5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel }, "6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top }, "7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas }, "8. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, "9a. 중심 방식": { x: dropX_centered, y: dropY_centered }, "9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, "10. 최종 선택": { dropX, dropY }, }); // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = layout.gridSettings ? calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }) : null; // 캔버스 경계 내로 위치 제한 const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth)); const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight)); // 격자 스냅 적용 const snappedPosition = layout.gridSettings?.snapToGrid && currentGridInfo ? snapPositionTo10px( { x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings, ) : { x: boundedX, y: boundedY, z: 1 }; console.log("🧩 컴포넌트 드롭:", { componentName: component.name, webType: component.webType, rawPosition: { x: dropX, y: dropY }, boundedPosition: { x: boundedX, y: boundedY }, snappedPosition, }); // 새 컴포넌트 생성 (새 컴포넌트 시스템 지원) console.log("🔍 ScreenDesigner handleComponentDrop:", { componentName: component.name, componentId: component.id, webType: component.webType, category: component.category, defaultConfig: component.defaultConfig, defaultSize: component.defaultSize, }); // 컴포넌트별 gridColumns 설정 및 크기 계산 let componentSize = component.defaultSize; const isCardDisplay = component.id === "card-display"; const isTableList = component.id === "table-list"; // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 let gridColumns = 1; // 기본값 // 특수 컴포넌트 if (isCardDisplay) { gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67% } else if (isTableList) { gridColumns = currentGridColumns; // 테이블은 전체 너비 } else { // 웹타입별 적절한 그리드 컬럼 수 설정 const webType = component.webType; const componentId = component.id; // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) const gridColumnsRatioMap: Record = { // 입력 컴포넌트 (INPUT 카테고리) "text-input": 4 / 12, // 텍스트 입력 (33%) "number-input": 2 / 12, // 숫자 입력 (16.67%) "email-input": 4 / 12, // 이메일 입력 (33%) "tel-input": 3 / 12, // 전화번호 입력 (25%) "date-input": 3 / 12, // 날짜 입력 (25%) "datetime-input": 4 / 12, // 날짜시간 입력 (33%) "time-input": 2 / 12, // 시간 입력 (16.67%) "textarea-basic": 6 / 12, // 텍스트 영역 (50%) "select-basic": 3 / 12, // 셀렉트 (25%) "checkbox-basic": 2 / 12, // 체크박스 (16.67%) "radio-basic": 3 / 12, // 라디오 (25%) "file-basic": 4 / 12, // 파일 (33%) "file-upload": 4 / 12, // 파일 업로드 (33%) "slider-basic": 3 / 12, // 슬라이더 (25%) "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) // 표시 컴포넌트 (DISPLAY 카테고리) "label-basic": 2 / 12, // 라벨 (16.67%) "text-display": 3 / 12, // 텍스트 표시 (25%) "card-display": 8 / 12, // 카드 (66.67%) "badge-basic": 1 / 12, // 배지 (8.33%) "alert-basic": 6 / 12, // 알림 (50%) "divider-basic": 1, // 구분선 (100%) "divider-line": 1, // 구분선 (100%) "accordion-basic": 1, // 아코디언 (100%) "table-list": 1, // 테이블 리스트 (100%) "image-display": 4 / 12, // 이미지 표시 (33%) "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) "flow-widget": 1, // 플로우 위젯 (100%) // 액션 컴포넌트 (ACTION 카테고리) "button-basic": 1 / 12, // 버튼 (8.33%) "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) // 레이아웃 컴포넌트 "container-basic": 6 / 12, // 컨테이너 (50%) "section-basic": 1, // 섹션 (100%) "panel-basic": 6 / 12, // 패널 (50%) // 기타 "image-basic": 4 / 12, // 이미지 (33%) "icon-basic": 1 / 12, // 아이콘 (8.33%) "progress-bar": 4 / 12, // 프로그레스 바 (33%) "chart-basic": 6 / 12, // 차트 (50%) }; // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 if (component.defaultSize?.gridColumnSpan === "full") { gridColumns = currentGridColumns; } else { // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); } console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { componentId, webType, gridColumns, }); } // 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산 if (layout.gridSettings?.snapToGrid && gridInfo) { // gridColumns에 맞는 정확한 너비 계산 const calculatedWidth = calculateWidthFromColumns( gridColumns, layout.gridSettings as GridUtilSettings, ); // 컴포넌트별 최소 크기 보장 const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width; componentSize = { ...component.defaultSize, width: Math.max(calculatedWidth, minWidth), }; } console.log("🎨 최종 컴포넌트 크기:", { componentId: component.id, componentName: component.name, defaultSize: component.defaultSize, finalSize: componentSize, gridColumns, }); // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 let enhancedDefaultConfig = { ...component.defaultConfig }; if ( component.id === "repeater-field-group" && tables && tables.length > 0 && tables[0].columns && tables[0].columns.length > 0 ) { const firstColumn = tables[0].columns[0]; enhancedDefaultConfig = { ...enhancedDefaultConfig, fields: [ { name: firstColumn.columnName, label: firstColumn.columnLabel || firstColumn.columnName, type: (firstColumn.widgetType as any) || "text", required: firstColumn.required || false, placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, }, ], }; } // gridColumns에 맞춰 width를 퍼센트로 계산 const widthPercent = (gridColumns / currentGridColumns) * 100; console.log("🎨 [컴포넌트 생성] 너비 계산:", { componentName: component.name, componentId: component.id, currentGridColumns, gridColumns, widthPercent: `${widthPercent}%`, calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, }); const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 label: component.name, widgetType: component.webType, componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 ...enhancedDefaultConfig, }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "500", labelMarginBottom: "4px", width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비 }, }; // 레이아웃에 컴포넌트 추가 const newLayout: LayoutData = { ...layout, components: [...layout.components, newComponent], }; setLayout(newLayout); saveToHistory(newLayout); // 새 컴포넌트 선택 setSelectedComponent(newComponent); // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 // openPanel("properties"); toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, [layout, selectedScreen, saveToHistory], ); // 드래그 앤 드롭 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); // console.log("🎯 드롭 이벤트:", { dragData }); if (!dragData) { // console.log("❌ 드래그 데이터가 없습니다"); return; } try { const parsedData = JSON.parse(dragData); // console.log("📋 파싱된 데이터:", parsedData); // 템플릿 드래그인 경우 if (parsedData.type === "template") { handleTemplateDrop(e, parsedData.template); return; } // 레이아웃 드래그인 경우 if (parsedData.type === "layout") { handleLayoutDrop(e, parsedData.layout); return; } // 컴포넌트 드래그인 경우 if (parsedData.type === "component") { handleComponentDrop(e, parsedData.component); return; } // 기존 테이블/컬럼 드래그 처리 const { type, table, column } = parsedData; // 드롭 대상이 폼 컨테이너인지 확인 const dropTarget = e.target as HTMLElement; const formContainer = dropTarget.closest('[data-form-container="true"]'); 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.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명 tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, style: { labelDisplay: true, labelFontSize: "14px", labelColor: "#212121", labelFontWeight: "600", labelMarginBottom: "8px", }, }; } else if (type === "column") { // console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName }); // 현재 해상도에 맞는 격자 정보로 기본 크기 계산 const currentGridInfo = layout.gridSettings ? calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }) : null; // 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값 const defaultWidth = currentGridInfo && layout.gridSettings?.snapToGrid ? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings) : 200; console.log("🎯 컴포넌트 생성 시 크기 계산:", { screenResolution: `${screenResolution.width}x${screenResolution.height}`, gridSettings: layout.gridSettings, currentGridInfo: currentGridInfo ? { columnWidth: currentGridInfo.columnWidth.toFixed(2), totalWidth: currentGridInfo.totalWidth, } : null, defaultWidth: defaultWidth.toFixed(2), snapToGrid: true, }); // 웹타입별 기본 너비 계산 (10px 단위 고정) const getDefaultWidth = (widgetType: string): number => { const widthMap: Record = { // 텍스트 입력 계열 text: 200, email: 200, tel: 150, url: 250, textarea: 300, // 숫자/날짜 입력 number: 120, decimal: 120, date: 150, datetime: 180, time: 120, // 선택 입력 select: 180, radio: 180, checkbox: 120, boolean: 120, // 코드/참조 code: 180, entity: 200, // 파일/이미지 file: 250, image: 200, // 기타 button: 100, label: 100, }; return widthMap[widgetType] || 200; // 기본값 200px }; // 웹타입별 기본 높이 계산 const getDefaultHeight = (widgetType: string): number => { const heightMap: Record = { textarea: 120, // 텍스트 영역은 3줄 (40 * 3) checkbox: 80, // 체크박스 그룹 (40 * 2) radio: 80, // 라디오 버튼 (40 * 2) file: 240, // 파일 업로드 (40 * 6) }; return heightMap[widgetType] || 30; // 기본값 30px로 변경 }; // 웹타입별 기본 설정 생성 const getDefaultWebTypeConfig = (widgetType: string) => { switch (widgetType) { case "date": return { format: "YYYY-MM-DD" as const, showTime: false, placeholder: "날짜를 선택하세요", }; case "datetime": return { format: "YYYY-MM-DD HH:mm" as const, showTime: true, placeholder: "날짜와 시간을 선택하세요", }; case "number": return { format: "integer" as const, placeholder: "숫자를 입력하세요", }; case "decimal": return { format: "decimal" as const, step: 0.01, decimalPlaces: 2, placeholder: "소수를 입력하세요", }; case "select": case "dropdown": return { options: [ { label: "옵션 1", value: "option1" }, { label: "옵션 2", value: "option2" }, { label: "옵션 3", value: "option3" }, ], multiple: false, searchable: false, placeholder: "옵션을 선택하세요", }; case "text": return { format: "none" as const, placeholder: "텍스트를 입력하세요", multiline: false, }; case "email": return { format: "email" as const, placeholder: "이메일을 입력하세요", multiline: false, }; case "tel": return { format: "phone" as const, placeholder: "전화번호를 입력하세요", multiline: false, }; case "textarea": return { rows: 3, placeholder: "텍스트를 입력하세요", resizable: true, autoResize: false, wordWrap: true, }; case "checkbox": case "boolean": return { defaultChecked: false, labelPosition: "right" as const, checkboxText: "", trueValue: true, falseValue: false, indeterminate: false, }; case "radio": return { options: [ { label: "옵션 1", value: "option1" }, { label: "옵션 2", value: "option2" }, ], layout: "vertical" as const, defaultValue: "", allowNone: false, }; case "file": return { accept: "", multiple: false, maxSize: 10, maxFiles: 1, preview: true, dragDrop: true, allowedExtensions: [], }; case "code": return { codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴 placeholder: "선택하세요", options: [], // 기본 빈 배열, 실제로는 API에서 로드 }; case "entity": return { entityName: "", displayField: "name", valueField: "id", searchable: true, multiple: false, allowClear: true, placeholder: "엔터티를 선택하세요", apiEndpoint: "", filters: [], displayFormat: "simple" as const, }; case "table": return { tableName: "", displayMode: "table" as const, showHeader: true, showFooter: true, pagination: { enabled: true, pageSize: 10, showPageSizeSelector: true, showPageInfo: true, showFirstLast: true, }, columns: [], searchable: true, sortable: true, filterable: true, exportable: true, }; default: return undefined; } }; // 폼 컨테이너에 드롭한 경우 if (formContainer) { const formContainerId = formContainer.getAttribute("data-component-id"); const formContainerComponent = layout.components.find((c) => c.id === formContainerId); if (formContainerComponent) { // 폼 내부에서의 상대적 위치 계산 const containerRect = formContainer.getBoundingClientRect(); const relativeX = e.clientX - containerRect.left; const relativeY = e.clientY - containerRect.top; // 웹타입을 새로운 컴포넌트 ID로 매핑 const componentId = getComponentIdFromWebType(column.widgetType); // console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`); // 웹타입별 기본 너비 계산 (10px 단위 고정) const componentWidth = getDefaultWidth(column.widgetType); console.log("🎯 폼 컨테이너 컴포넌트 생성:", { widgetType: column.widgetType, componentWidth, }); newComponent = { id: generateComponentId(), type: "component", // ✅ 새로운 컴포넌트 시스템 사용 label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, required: column.required, readonly: false, parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { codeCategory: column.codeCategory, }), style: { labelDisplay: false, // 라벨 숨김 labelFontSize: "12px", labelColor: "#212121", labelFontWeight: "500", labelMarginBottom: "6px", }, componentConfig: { type: componentId, // text-input, number-input 등 webType: column.widgetType, // 원본 웹타입 보존 inputType: column.inputType, // ✅ input_type 추가 (category 등) ...getDefaultWebTypeConfig(column.widgetType), placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { codeCategory: column.codeCategory, }), }, }; } else { return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 } } else { // 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용 const componentId = getComponentIdFromWebType(column.widgetType); // console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`); // 웹타입별 기본 너비 계산 (10px 단위 고정) const componentWidth = getDefaultWidth(column.widgetType); console.log("🎯 캔버스 컴포넌트 생성:", { widgetType: column.widgetType, componentWidth, }); newComponent = { id: generateComponentId(), type: "component", // ✅ 새로운 컴포넌트 시스템 사용 label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, required: column.required, readonly: false, componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { codeCategory: column.codeCategory, }), style: { labelDisplay: false, // 라벨 숨김 labelFontSize: "14px", labelColor: "#000000", // 순수한 검정 labelFontWeight: "500", labelMarginBottom: "8px", }, componentConfig: { type: componentId, // text-input, number-input 등 webType: column.widgetType, // 원본 웹타입 보존 inputType: column.inputType, // ✅ input_type 추가 (category 등) ...getDefaultWebTypeConfig(column.widgetType), placeholder: column.columnLabel || column.columnName, // placeholder에 라벨명 표시 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { codeCategory: column.codeCategory, }), }, }; } } else { return; } // 격자 스냅 적용 (그룹 컴포넌트 제외) if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") { // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }); const gridUtilSettings = { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }; newComponent.position = snapPositionTo10px(newComponent.position, currentGridInfo, gridUtilSettings); newComponent.size = snapSizeTo10px(newComponent.size, currentGridInfo, gridUtilSettings); console.log("🧲 새 컴포넌트 격자 스냅 적용:", { type: newComponent.type, resolution: `${screenResolution.width}x${screenResolution.height}`, snappedPosition: newComponent.position, snappedSize: newComponent.size, columnWidth: currentGridInfo.columnWidth, }); } if (newComponent.type === "group") { console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", { type: newComponent.type, position: newComponent.position, size: newComponent.size, }); } const newLayout = { ...layout, components: [...layout.components, newComponent], }; setLayout(newLayout); saveToHistory(newLayout); setSelectedComponent(newComponent); // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 // openPanel("properties"); } catch (error) { // console.error("드롭 처리 실패:", error); } }, [layout, saveToHistory], ); // 파일 컴포넌트 업데이트 처리 const handleFileComponentUpdate = useCallback( (updates: Partial) => { if (!selectedFileComponent) return; const updatedComponents = layout.components.map((comp) => comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp, ); const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); saveToHistory(newLayout); // selectedFileComponent도 업데이트 setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null)); // selectedComponent가 같은 컴포넌트라면 업데이트 if (selectedComponent?.id === selectedFileComponent.id) { setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null)); } }, [selectedFileComponent, layout, saveToHistory, selectedComponent], ); // 파일첨부 모달 닫기 const handleFileAttachmentModalClose = useCallback(() => { setShowFileAttachmentModal(false); setSelectedFileComponent(null); }, []); // 컴포넌트 더블클릭 처리 const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => { event?.stopPropagation(); // 파일 컴포넌트인 경우 상세 모달 열기 if (component.type === "file") { setSelectedFileComponent(component); setShowFileAttachmentModal(true); return; } // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 // console.log("더블클릭된 컴포넌트:", component.type, component.id); }, []); // 컴포넌트 클릭 처리 (다중선택 지원) const handleComponentClick = useCallback( (component: ComponentData, event?: React.MouseEvent) => { event?.stopPropagation(); // 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지) if (dragState.justFinishedDrag) { return; } // 🔧 layout.components에서 최신 버전의 컴포넌트 찾기 const latestComponent = layout.components.find((c) => c.id === component.id); if (!latestComponent) { console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id); return; } const isShiftPressed = event?.shiftKey || false; const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; const isGroupContainer = latestComponent.type === "group"; if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { // 다중 선택 모드 if (isGroupContainer) { // 그룹 컨테이너는 단일 선택으로 처리 handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 setGroupState((prev) => ({ ...prev, selectedComponents: [latestComponent.id], isGrouping: false, })); return; } const isSelected = groupState.selectedComponents.includes(latestComponent.id); setGroupState((prev) => ({ ...prev, selectedComponents: isSelected ? prev.selectedComponents.filter((id) => id !== latestComponent.id) : [...prev.selectedComponents, latestComponent.id], })); // 마지막 선택된 컴포넌트를 selectedComponent로 설정 if (!isSelected) { // console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id); handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 } } else { // 단일 선택 모드 // console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id); handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 setGroupState((prev) => ({ ...prev, selectedComponents: [latestComponent.id], })); } }, [ handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag, layout.components, ], ); // 컴포넌트 드래그 시작 const startComponentDrag = useCallback( (component: ComponentData, event: React.MouseEvent | React.DragEvent) => { event.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; // 새로운 드래그 시작 시 justFinishedDrag 플래그 해제 if (dragState.justFinishedDrag) { setDragState((prev) => ({ ...prev, justFinishedDrag: false, })); } // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 const relativeMouseX = (event.clientX - rect.left) / zoomLevel; const relativeMouseY = (event.clientY - rect.top) / zoomLevel; // 다중 선택된 컴포넌트들 확인 const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); let componentsToMove = isDraggedComponentSelected ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) : [component]; // 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동 if (component.type === "layout") { const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId); console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", { layoutId: component.id, zoneComponentsCount: zoneComponents.length, zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })), }); // 중복 제거하여 추가 const allComponentIds = new Set(componentsToMove.map((c) => c.id)); const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id)); componentsToMove = [...componentsToMove, ...additionalComponents]; } // console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length); console.log("마우스 위치 (줌 보정):", { zoomLevel, clientX: event.clientX, clientY: event.clientY, rectLeft: rect.left, rectTop: rect.top, mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top }, mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY }, componentX: component.position.x, componentY: component.position.y, grabOffsetX: relativeMouseX - component.position.x, grabOffsetY: relativeMouseY - component.position.y, }); console.log("🚀 드래그 시작:", { componentId: component.id, componentType: component.type, initialPosition: { x: component.position.x, y: component.position.y }, }); 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: relativeMouseX - component.position.x, y: relativeMouseY - component.position.y, }, justFinishedDrag: false, }); }, [groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel], ); // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) const updateDragPosition = useCallback( (event: MouseEvent) => { if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 const relativeMouseX = (event.clientX - rect.left) / zoomLevel; const relativeMouseY = (event.clientY - rect.top) / zoomLevel; // 컴포넌트 크기 가져오기 const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id); const componentWidth = draggedComp?.size?.width || 100; const componentHeight = draggedComp?.size?.height || 40; // 경계 제한 적용 const rawX = relativeMouseX - dragState.grabOffset.x; const rawY = relativeMouseY - dragState.grabOffset.y; const newPosition = { x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)), y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)), z: (dragState.draggedComponent.position as Position).z || 1, }; // 드래그 상태 업데이트 console.log("🔥 ScreenDesigner updateDragPosition (줌 보정):", { zoomLevel, draggedComponentId: dragState.draggedComponent.id, mouseRaw: { x: event.clientX - rect.left, y: event.clientY - rect.top }, mouseZoomCorrected: { x: relativeMouseX, y: relativeMouseY }, oldPosition: dragState.currentPosition, newPosition: newPosition, }); setDragState((prev) => { const newState = { ...prev, currentPosition: { ...newPosition }, // 새로운 객체 생성 }; console.log("🔄 ScreenDesigner dragState 업데이트:", { prevPosition: prev.currentPosition, newPosition: newState.currentPosition, stateChanged: prev.currentPosition.x !== newState.currentPosition.x || prev.currentPosition.y !== newState.currentPosition.y, }); return newState; }); // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, // 실제 레이아웃 업데이트는 endDrag에서 처리 // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 }, [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel], ); // 드래그 종료 const endDrag = useCallback(() => { if (dragState.isDragging && dragState.draggedComponent) { // 주 드래그 컴포넌트의 최종 위치 계산 const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); let finalPosition = dragState.currentPosition; // 현재 해상도에 맞는 격자 정보 계산 const currentGridInfo = layout.gridSettings ? calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }) : null; // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { finalPosition = snapPositionTo10px( { x: dragState.currentPosition.x, y: dragState.currentPosition.y, z: dragState.currentPosition.z ?? 1, }, currentGridInfo, { columns: layout.gridSettings.columns, gap: layout.gridSettings.gap, padding: layout.gridSettings.padding, snapToGrid: layout.gridSettings.snapToGrid || false, }, ); console.log("🎯 격자 스냅 적용됨:", { componentType: draggedComponent?.type, resolution: `${screenResolution.width}x${screenResolution.height}`, originalPosition: dragState.currentPosition, snappedPosition: finalPosition, columnWidth: currentGridInfo.columnWidth, }); } // 스냅으로 인한 추가 이동 거리 계산 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)!; let newPosition = { x: originalComponent.position.x + totalDeltaX, y: originalComponent.position.y + totalDeltaY, z: originalComponent.position.z || 1, }; // 캔버스 경계 제한 (컴포넌트가 화면 밖으로 나가지 않도록) const componentWidth = comp.size?.width || 100; const componentHeight = comp.size?.height || 40; // 최소 위치: 0, 최대 위치: 캔버스 크기 - 컴포넌트 크기 newPosition.x = Math.max(0, Math.min(newPosition.x, screenResolution.width - componentWidth)); newPosition.y = Math.max(0, Math.min(newPosition.y, screenResolution.height - componentHeight)); // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 if (comp.parentId && layout.gridSettings?.snapToGrid && gridInfo) { const { columnWidth } = gridInfo; const { gap } = layout.gridSettings; // 그룹 내부 패딩 고려한 격자 정렬 const padding = 16; const effectiveX = newPosition.x - padding; const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); // Y 좌표는 20px 단위로 스냅 const effectiveY = newPosition.y - padding; const rowIndex = Math.round(effectiveY / 20); const snappedY = padding + rowIndex * 20; // 크기도 외부 격자와 동일하게 스냅 const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) const snappedHeight = Math.max(40, comp.size.height); newPosition = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 y: Math.max(padding, snappedY), z: newPosition.z, }; // 크기도 업데이트 const newSize = { width: snappedWidth, height: snappedHeight, }; console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", { componentId: comp.id, parentId: comp.parentId, beforeSnap: { x: originalComponent.position.x + totalDeltaX, y: originalComponent.position.y + totalDeltaY, }, calculation: { effectiveX, effectiveY, columnIndex, rowIndex, columnWidth, fullColumnWidth, widthInColumns, gap: gap || 16, padding, }, afterSnap: newPosition, afterSizeSnap: newSize, }); return { ...comp, position: newPosition as Position, size: newSize, }; } return { ...comp, position: newPosition as Position, }; } return comp; }); const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); // 선택된 컴포넌트도 업데이트 (PropertiesPanel 동기화용) if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) { const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); if (updatedSelectedComponent) { console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", { componentId: selectedComponent.id, oldPosition: selectedComponent.position, newPosition: updatedSelectedComponent.position, }); setSelectedComponent(updatedSelectedComponent); } } // 히스토리에 저장 saveToHistory(newLayout); } 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, }); // 짧은 시간 후 justFinishedDrag 플래그 해제 setTimeout(() => { setDragState((prev) => ({ ...prev, justFinishedDrag: false, })); }, 100); }, [dragState, layout, saveToHistory]); // 드래그 선택 시작 const startSelectionDrag = useCallback( (event: React.MouseEvent) => { if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시 const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; // zoom 스케일을 고려한 좌표 변환 const startPoint = { x: (event.clientX - rect.left) / zoomLevel, y: (event.clientY - rect.top) / zoomLevel, z: 1, }; setSelectionDrag({ isSelecting: true, startPoint, currentPoint: startPoint, wasSelecting: false, }); }, [dragState.isDragging, zoomLevel], ); // 드래그 선택 업데이트 const updateSelectionDrag = useCallback( (event: MouseEvent) => { if (!selectionDrag.isSelecting || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); // zoom 스케일을 고려한 좌표 변환 const currentPoint = { x: (event.clientX - rect.left) / zoomLevel, y: (event.clientY - rect.top) / zoomLevel, 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, })); }, [selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel], ); // 드래그 선택 종료 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), ); const wasActualDrag = dragDistance > minDragDistance; setSelectionDrag({ isSelecting: false, startPoint: { x: 0, y: 0, z: 1 }, currentPoint: { x: 0, y: 0, z: 1 }, wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시 }); // 짧은 시간 후 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; } // 단일 선택된 컴포넌트 삭제 if (!selectedComponent) return; // console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id); 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) { // 단일 컴포넌트 복사 setClipboard([selectedComponent]); // console.log("단일 컴포넌트 복사:", selectedComponent.id); toast.success("컴포넌트가 복사되었습니다."); } }, [selectedComponent, groupState.selectedComponents, layout.components]); // 컴포넌트 붙여넣기 const pasteComponent = useCallback(() => { if (clipboard.length === 0) { toast.warning("복사된 컴포넌트가 없습니다."); 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, ...newComponents], }; setLayout(newLayout); saveToHistory(newLayout); // 붙여넣은 컴포넌트들을 선택 상태로 만들기 setGroupState((prev) => ({ ...prev, selectedComponents: newComponents.map((comp) => comp.id), })); // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); }, [clipboard, layout, saveToHistory]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 const [groupDialogOpen, setGroupDialogOpen] = useState(false); const handleFlowButtonGroup = useCallback(() => { const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); // 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가 if (selectedComponents.length < 2) { toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요"); return; } // 모두 버튼인지 확인 if (!areAllButtons(selectedComponents)) { toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다"); return; } // 🆕 다이얼로그 열기 setGroupDialogOpen(true); }, [layout, groupState.selectedComponents]); // 🆕 그룹 생성 확인 핸들러 const handleGroupConfirm = useCallback( (settings: { direction: "horizontal" | "vertical"; gap: number; align: "start" | "center" | "end" | "space-between" | "space-around"; }) => { const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); // 고유한 그룹 ID 생성 const newGroupId = generateGroupId(); // 🔧 그룹 위치 및 버튼 재배치 계산 const align = settings.align; const direction = settings.direction; const gap = settings.gap; const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); let anchorButton; // 기준이 되는 버튼 let groupX: number; // align에 따라 기준 버튼과 그룹 시작점 결정 if (direction === "horizontal") { if (align === "end") { // 끝점 정렬: 가장 오른쪽 버튼이 기준 anchorButton = selectedComponents.reduce((max, btn) => { const rightEdge = btn.position.x + (btn.size?.width || 100); const maxRightEdge = max.position.x + (max.size?.width || 100); return rightEdge > maxRightEdge ? btn : max; }); // 전체 그룹 너비 계산 const totalWidth = selectedComponents.reduce((total, btn, index) => { const buttonWidth = btn.size?.width || 100; const gapWidth = index < selectedComponents.length - 1 ? gap : 0; return total + buttonWidth + gapWidth; }, 0); // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; } else if (align === "center") { // 중앙 정렬: 버튼들의 중심점을 기준으로 const minX = Math.min(...selectedComponents.map((b) => b.position.x)); const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); const centerX = (minX + maxX) / 2; const totalWidth = selectedComponents.reduce((total, btn, index) => { const buttonWidth = btn.size?.width || 100; const gapWidth = index < selectedComponents.length - 1 ? gap : 0; return total + buttonWidth + gapWidth; }, 0); groupX = centerX - totalWidth / 2; anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 } else { // 시작점 정렬: 가장 왼쪽 버튼이 기준 anchorButton = selectedComponents.reduce((min, btn) => { return btn.position.x < min.position.x ? btn : min; }); groupX = anchorButton.position.x; } } else { // 세로 정렬: 가장 위쪽 버튼이 기준 anchorButton = selectedComponents.reduce((min, btn) => { return btn.position.y < min.position.y ? btn : min; }); groupX = Math.min(...selectedComponents.map((b) => b.position.x)); } // 🔧 버튼들의 위치를 그룹 기준으로 재배치 // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 const groupedButtons = selectedComponents.map((button) => { const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; // 모든 버튼을 그룹 시작점에 배치 // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 const newPosition = { x: groupX, y: groupY, z: button.position.z || 1, }; return { ...button, position: newPosition, webTypeConfig: { ...(button as any).webTypeConfig, flowVisibilityConfig: { ...currentConfig, enabled: true, layoutBehavior: "auto-compact", groupId: newGroupId, groupDirection: settings.direction, groupGap: settings.gap, groupAlign: settings.align, }, }, }; }); // 레이아웃 업데이트 const updatedComponents = layout.components.map((comp) => { const grouped = groupedButtons.find((gb) => gb.id === comp.id); return grouped || comp; }); const newLayout = { ...layout, components: updatedComponents, }; setLayout(newLayout); saveToHistory(newLayout); toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, { description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`, }); console.log("✅ 플로우 버튼 그룹 생성 완료:", { groupId: newGroupId, buttonCount: selectedComponents.length, buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), groupPosition: { x: groupX, y: groupY }, settings, }); }, [layout, groupState.selectedComponents, saveToHistory], ); // 🆕 플로우 버튼 그룹 해제 const handleFlowButtonUngroup = useCallback(() => { const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); if (selectedComponents.length === 0) { toast.error("그룹 해제할 버튼을 선택해주세요"); return; } // 버튼이 아닌 것 필터링 const buttons = selectedComponents.filter((comp) => areAllButtons([comp])); if (buttons.length === 0) { toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다"); return; } // 그룹 해제 const ungroupedButtons = ungroupButtons(buttons); // 레이아웃 업데이트 + 플로우 표시 제어 초기화 const updatedComponents = layout.components.map((comp, index) => { const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); if (ungrouped) { // 원래 위치 복원 또는 현재 위치 유지 + 간격 추가 const buttonIndex = buttons.findIndex((b) => b.id === comp.id); const basePosition = comp.position; // 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록) const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격 // 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화 return { ...ungrouped, position: { x: basePosition.x + offsetX, y: basePosition.y, z: basePosition.z || 1, }, webTypeConfig: { ...ungrouped.webTypeConfig, flowVisibilityConfig: { enabled: false, targetFlowComponentId: null, mode: "whitelist", visibleSteps: [], hiddenSteps: [], layoutBehavior: "auto-compact", groupId: null, groupDirection: "horizontal", groupGap: 8, groupAlign: "start", }, }, }; } return comp; }); const newLayout = { ...layout, components: updatedComponents, }; setLayout(newLayout); saveToHistory(newLayout); toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`); }, [layout, groupState.selectedComponents, saveToHistory]); // 그룹 생성 (임시 비활성화) const handleGroupCreate = useCallback( (componentIds: string[], title: string, style?: any) => { // console.log("그룹 생성 기능이 임시 비활성화되었습니다."); toast.info("그룹 기능이 임시 비활성화되었습니다."); return; // 격자 정보 계산 const currentGridInfo = gridInfo || calculateGridInfo( 1200, 800, layout.gridSettings || { columns: 12, gap: 16, padding: 0, snapToGrid: true, showGrid: false, gridColor: "#d1d5db", gridOpacity: 0.5, }, ); console.log("🔧 그룹 생성 시작:", { selectedCount: selectedComponents.length, snapToGrid: true, }); // 컴포넌트 크기 조정 기반 그룹 크기 계산 const calculateOptimalGroupSize = () => { if (!currentGridInfo || !layout.gridSettings?.snapToGrid) { // 격자 스냅이 비활성화된 경우 기본 패딩 사용 const boundingBox = calculateBoundingBox(selectedComponents); const padding = 40; return { boundingBox, groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 }, groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 }, gridColumns: 1, scaledComponents: selectedComponents, // 크기 조정 없음 padding: padding, }; } const { columnWidth } = currentGridInfo; const gap = layout.gridSettings?.gap || 16; const contentBoundingBox = calculateBoundingBox(selectedComponents); // 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기 const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap)); // 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산 const groupStartX = startColumn * (columnWidth + gap); const availableWidthFromStart = contentBoundingBox.maxX - groupStartX; const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap)); // 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정 const padding = 20; const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤 const groupY = contentBoundingBox.minY - padding; const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap const groupHeight = contentBoundingBox.height + padding * 2; // 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링 const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비 const scaleFactorX = availableWidth / contentBoundingBox.width; const scaledComponents = selectedComponents.map((comp) => { // 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산 const relativeX = comp.position.x - contentBoundingBox.minX; const relativeY = comp.position.y - contentBoundingBox.minY; return { ...comp, position: { x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치 y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용 z: comp.position.z || 1, }, size: { width: comp.size.width * scaleFactorX, // X 방향 스케일링 height: comp.size.height, // Y는 원본 크기 유지 }, }; }); console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", { originalBoundingBox: contentBoundingBox, gridCalculation: { columnWidthPlusGap: columnWidth + gap, startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`, groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`, availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`, currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`, finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`, actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`, }, groupPosition: { x: groupX, y: groupY }, groupSize: { width: groupWidth, height: groupHeight }, scaleFactorX, availableWidth, padding, scaledComponentsCount: scaledComponents.length, scaledComponentsDetails: scaledComponents.map((comp) => { const original = selectedComponents.find((c) => c.id === comp.id); return { id: comp.id, originalPos: original?.position, scaledPos: comp.position, originalSize: original?.size, scaledSize: comp.size, deltaX: comp.position.x - (original?.position.x || 0), deltaY: comp.position.y - (original?.position.y || 0), }; }), }); return { boundingBox: contentBoundingBox, groupPosition: { x: groupX, y: groupY, z: 1 }, groupSize: { width: groupWidth, height: groupHeight }, gridColumns: currentWidthInColumns, scaledComponents: scaledComponents, // 스케일된 컴포넌트들 padding: padding, }; }; const { boundingBox, groupPosition, groupSize: optimizedGroupSize, gridColumns, scaledComponents, padding, } = calculateOptimalGroupSize(); // 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요) const relativeChildren = calculateRelativePositions( scaledComponents, groupPosition, "temp", // 임시 그룹 ID ); console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", { gridColumns, groupSize: optimizedGroupSize, groupPosition, scaledComponentsCount: scaledComponents.length, padding, strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤", }); // 그룹 컴포넌트 생성 (gridColumns 속성 포함) const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style); // 그룹에 계산된 gridColumns 속성 추가 groupComponent.gridColumns = gridColumns; // 실제 그룹 ID로 자식들 업데이트 const finalChildren = relativeChildren.map((child) => ({ ...child, parentId: groupComponent.id, })); const newLayout = { ...layout, components: [ ...layout.components.filter((comp) => !componentIds.includes(comp.id)), groupComponent, ...finalChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); setGroupState((prev) => ({ ...prev, selectedComponents: [groupComponent.id], isGrouping: false, })); setSelectedComponent(groupComponent); console.log("🎯 최적화된 그룹 생성 완료:", { groupId: groupComponent.id, childrenCount: finalChildren.length, position: groupPosition, size: optimizedGroupSize, gridColumns: groupComponent.gridColumns, componentsScaled: !!scaledComponents.length, gridAligned: layout.gridSettings?. }); toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`); }, [layout, saveToHistory], ); // 그룹 생성 함수 (다이얼로그 표시) const createGroup = useCallback(() => { if (groupState.selectedComponents.length < 2) { toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다."); return; } // console.log("🔄 그룹 생성 다이얼로그 표시"); setShowGroupCreateDialog(true); }, [groupState.selectedComponents]); // 그룹 해제 함수 (임시 비활성화) const ungroupComponents = useCallback(() => { // console.log("그룹 해제 기능이 임시 비활성화되었습니다."); toast.info("그룹 해제 기능이 임시 비활성화되었습니다."); return; const groupId = selectedComponent.id; // 자식 컴포넌트들의 절대 위치 복원 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); // 그룹 컴포넌트 제거 const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); saveToHistory(newLayout); // 선택 상태 초기화 setSelectedComponent(null); setGroupState((prev) => ({ ...prev, selectedComponents: [] })); }, [selectedComponent, layout, saveToHistory]); // 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화 useEffect(() => { 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); }; } }, [ dragState.isDragging, selectionDrag.isSelecting, updateDragPosition, endDrag, updateSelectionDrag, endSelectionDrag, ]); // 캔버스 크기 초기화 및 리사이즈 이벤트 처리 useEffect(() => { const updateCanvasSize = () => { if (canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); setCanvasSize({ width: rect.width, height: rect.height }); } }; // 초기 크기 설정 updateCanvasSize(); // 리사이즈 이벤트 리스너 window.addEventListener("resize", updateCanvasSize); return () => window.removeEventListener("resize", updateCanvasSize); }, []); // 컴포넌트 마운트 후 캔버스 크기 업데이트 useEffect(() => { const timer = setTimeout(() => { if (canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); setCanvasSize({ width: rect.width, height: rect.height }); } }, 100); return () => clearTimeout(timer); }, [selectedScreen]); // 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단) 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(); } // ✅ 애플리케이션 전용 단축키 처리 // 1. 그룹 관련 단축키 if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) { // console.log("🔄 그룹 생성 단축키"); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (groupState.selectedComponents.length >= 2) { // console.log("✅ 그룹 생성 실행"); createGroup(); } else { // console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)"); } return false; } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") { // console.log("🔄 그룹 해제 단축키"); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (selectedComponent && selectedComponent.type === "group") { // console.log("✅ 그룹 해제 실행"); ungroupComponents(); } else { // console.log("⚠️ 선택된 그룹이 없음"); } return false; } // 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; } // 3. 실행취소/다시실행 if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) { // console.log("🔄 실행취소"); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); undo(); return false; } 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; } // 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 { // 해상도 정보를 포함한 레이아웃 데이터 생성 const layoutWithResolution = { ...layout, screenResolution: screenResolution, }; console.log("⚡ 자동 저장할 레이아웃 데이터:", { componentsCount: layoutWithResolution.components.length, gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, }); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); toast.success("레이아웃이 저장되었습니다."); } catch (error) { // console.error("레이아웃 저장 실패:", error); toast.error("레이아웃 저장에 실패했습니다."); } finally { setIsSaving(false); } } else { // console.log("⚠️ 저장할 컴포넌트가 없습니다"); toast.warning("저장할 컴포넌트가 없습니다."); } return false; } }; // 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, ]); // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 useEffect(() => { const handleComponentSizeUpdate = (event: CustomEvent) => { const { componentId, height } = event.detail; // 해당 컴포넌트 찾기 const targetComponent = layout.components.find((c) => c.id === componentId); if (!targetComponent) { return; } // 이미 같은 높이면 업데이트 안함 if (targetComponent.size?.height === height) { return; } // 컴포넌트 높이 업데이트 const updatedComponents = layout.components.map((comp) => { if (comp.id === componentId) { return { ...comp, size: { ...comp.size, width: comp.size?.width || 100, height: height, }, }; } return comp; }); const newLayout = { ...layout, components: updatedComponents, }; setLayout(newLayout); // 선택된 컴포넌트도 업데이트 if (selectedComponent?.id === componentId) { const updatedComponent = updatedComponents.find((c) => c.id === componentId); if (updatedComponent) { setSelectedComponent(updatedComponent); } } }; window.addEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); return () => { window.removeEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); }; }, [layout, selectedComponent]); if (!selectedScreen) { return (

화면을 선택하세요

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

); } return (
{/* 상단 슬림 툴바 */} {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
{/* 좌측 통합 툴바 */} {/* 통합 패널 */} {panelStates.unified?.isOpen && (

패널

컴포넌트 편집 { const dragData = { type: column ? "column" : "table", table, column, }; e.dataTransfer.setData("application/json", JSON.stringify(dragData)); }} selectedTableName={selectedScreen.tableName} placedColumns={placedColumns} /> 0 ? tables[0] : undefined} currentTableName={selectedScreen?.tableName} dragState={dragState} onStyleChange={(style) => { if (selectedComponent) { updateComponentProperty(selectedComponent.id, "style", style); } }} currentResolution={screenResolution} onResolutionChange={handleResolutionChange} allComponents={layout.components} // 🆕 플로우 위젯 감지용 />
)} {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
{/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */}
🔍 {Math.round(zoomLevel * 100)}%
{/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} {(() => { // 선택된 컴포넌트들 const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); // 버튼 컴포넌트만 필터링 const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); // 플로우 그룹에 속한 버튼이 있는지 확인 const hasFlowGroupButton = selectedButtons.some((btn) => { const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; }); // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); if (!shouldShow) return null; return (
{selectedButtons.length}개 버튼 선택됨
{/* 그룹 생성 버튼 (2개 이상 선택 시) */} {selectedButtons.length >= 2 && ( )} {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} {hasFlowGroupButton && ( )} {/* 상태 표시 */} {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

}
); })()} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
{ if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { setSelectedComponent(null); setGroupState((prev) => ({ ...prev, selectedComponents: [] })); } }} onMouseDown={(e) => { // Pan 모드가 아닐 때만 다중 선택 시작 if (e.target === e.currentTarget && !isPanMode) { startSelectionDrag(e); } }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }} onDrop={(e) => { e.preventDefault(); // console.log("🎯 캔버스 드롭 이벤트 발생"); handleDrop(e); }} > {/* 격자 라인 */} {gridLines.map((line, index) => (
))} {/* 컴포넌트들 */} {(() => { // 🆕 플로우 버튼 그룹 감지 및 처리 const topLevelComponents = layout.components.filter((component) => !component.parentId); // auto-compact 모드의 버튼들을 그룹별로 묶기 const buttonGroups: Record = {}; const processedButtonIds = new Set(); topLevelComponents.forEach((component) => { const isButton = component.type === "button" || (component.type === "component" && ["button-primary", "button-secondary"].includes((component as any).componentType)); if (isButton) { const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as | FlowVisibilityConfig | undefined; if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) { if (!buttonGroups[flowConfig.groupId]) { buttonGroups[flowConfig.groupId] = []; } buttonGroups[flowConfig.groupId].push(component); processedButtonIds.add(component.id); } } }); // 그룹에 속하지 않은 일반 컴포넌트들 const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); return ( <> {/* 일반 컴포넌트들 */} {regularComponents.map((component) => { const children = component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; // 드래그 중 시각적 피드백 (다중 선택 지원) const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id; const isBeingDragged = dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); let displayComponent = component; if (isBeingDragged) { if (isDraggingThis) { // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 displayComponent = { ...component, position: dragState.currentPosition, style: { ...component.style, opacity: 0.8, transform: "scale(1.02)", transition: "none", zIndex: 50, }, }; } 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: 40, // 주 컴포넌트보다 약간 낮게 }, }; } } } // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const globalFiles = globalFileState[component.id] || []; const componentFiles = (component as any).uploadedFiles || []; const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; return ( handleComponentClick(component, e)} onDoubleClick={(e) => handleComponentDoubleClick(component, e)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} selectedScreen={selectedScreen} // onZoneComponentDrop 제거 onZoneClick={handleZoneClick} // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) onConfigChange={(config) => { // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); // 컴포넌트의 componentConfig 업데이트 const updatedComponents = layout.components.map((comp) => { if (comp.id === component.id) { return { ...comp, componentConfig: { ...comp.componentConfig, ...config, }, }; } return comp; }); const newLayout = { ...layout, components: updatedComponents, }; setLayout(newLayout); saveToHistory(newLayout); console.log("✅ 컴포넌트 설정 업데이트 완료:", { componentId: component.id, updatedConfig: config, }); }} > {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {(component.type === "group" || component.type === "container" || component.type === "area") && layout.components .filter((child) => child.parentId === component.id) .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: 50, }, }; } 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, }, }; } } } // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 const relativeChildComponent = { ...displayChild, position: { x: displayChild.position.x - component.position.x, y: displayChild.position.y - component.position.y, z: displayChild.position.z || 1, }, }; return ( f.objid) || [])}`} component={relativeChildComponent} isSelected={ selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id) } isDesignMode={true} // 편집 모드로 설정 onClick={(e) => handleComponentClick(child, e)} onDoubleClick={(e) => handleComponentDoubleClick(child, e)} onDragStart={(e) => startComponentDrag(child, e)} onDragEnd={endDrag} selectedScreen={selectedScreen} // onZoneComponentDrop 제거 onZoneClick={handleZoneClick} // 설정 변경 핸들러 (자식 컴포넌트용) onConfigChange={(config) => { // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 }} /> ); })} ); })} {/* 🆕 플로우 버튼 그룹들 */} {Object.entries(buttonGroups).map(([groupId, buttons]) => { if (buttons.length === 0) return null; const firstButton = buttons[0]; const groupConfig = (firstButton as any).webTypeConfig ?.flowVisibilityConfig as FlowVisibilityConfig; // 🔧 그룹의 위치 및 크기 계산 // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 const direction = groupConfig.groupDirection || "horizontal"; const gap = groupConfig.groupGap ?? 8; const align = groupConfig.groupAlign || "start"; const groupPosition = { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2, }; // 버튼들의 실제 크기 계산 let groupWidth = 0; let groupHeight = 0; if (direction === "horizontal") { // 가로 정렬: 모든 버튼의 너비 + 간격 groupWidth = buttons.reduce((total, button, index) => { const buttonWidth = button.size?.width || 100; const gapWidth = index < buttons.length - 1 ? gap : 0; return total + buttonWidth + gapWidth; }, 0); groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); } else { // 세로 정렬 groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); groupHeight = buttons.reduce((total, button, index) => { const buttonHeight = button.size?.height || 40; const gapHeight = index < buttons.length - 1 ? gap : 0; return total + buttonHeight + gapHeight; }, 0); } // 🆕 그룹 전체가 선택되었는지 확인 const isGroupSelected = buttons.every( (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), ); const hasAnySelected = buttons.some( (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), ); return (
{ // 드래그 피드백 const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === button.id; const isBeingDragged = dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === button.id); let displayButton = button; if (isBeingDragged) { if (isDraggingThis) { displayButton = { ...button, position: dragState.currentPosition, style: { ...button.style, opacity: 0.8, transform: "scale(1.02)", transition: "none", zIndex: 50, }, }; } } // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) const relativeButton = { ...displayButton, position: { x: 0, y: 0, z: displayButton.position.z || 1, }, }; return (
{ // 클릭이 아닌 드래그인 경우에만 드래그 시작 e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; let isDragging = false; let dragStarted = false; const handleMouseMove = (moveEvent: MouseEvent) => { const deltaX = Math.abs(moveEvent.clientX - startX); const deltaY = Math.abs(moveEvent.clientY - startY); // 5픽셀 이상 움직이면 드래그로 간주 if ((deltaX > 5 || deltaY > 5) && !dragStarted) { isDragging = true; dragStarted = true; // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 if (!e.shiftKey) { const buttonIds = buttons.map((b) => b.id); setGroupState((prev) => ({ ...prev, selectedComponents: buttonIds, })); } // 드래그 시작 startComponentDrag(button, e as any); } }; const handleMouseUp = () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); // 드래그가 아니면 클릭으로 처리 if (!isDragging) { // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 if (!e.shiftKey) { const buttonIds = buttons.map((b) => b.id); setGroupState((prev) => ({ ...prev, selectedComponents: buttonIds, })); } handleComponentClick(button, e); } }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }} onDoubleClick={(e) => { e.stopPropagation(); handleComponentDoubleClick(button, e); }} className={ selectedComponent?.id === button.id || groupState.selectedComponents.includes(button.id) ? "outline-1 outline-offset-1 outline-blue-400" : "" } > {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
{}} />
); }} />
); })} ); })()} {/* 드래그 선택 영역 */} {selectionDrag.isSelecting && (
)} {/* 빈 캔버스 안내 */} {layout.components.length === 0 && (

캔버스가 비어있습니다

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

단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도)

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

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

)}
{" "} {/* 🔥 줌 래퍼 닫기 */}
{" "} {/* 메인 컨테이너 닫기 */} {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} {/* 모달들 */} {/* 메뉴 할당 모달 */} {showMenuAssignmentModal && selectedScreen && ( setShowMenuAssignmentModal(false)} onAssignmentComplete={() => { // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 // setShowMenuAssignmentModal(false); // toast.success("메뉴에 화면이 할당되었습니다."); }} onBackToList={onBackToList} /> )} {/* 파일첨부 상세 모달 */} {showFileAttachmentModal && selectedFileComponent && ( { setShowFileAttachmentModal(false); setSelectedFileComponent(null); }} component={selectedFileComponent} screenId={selectedScreen.screenId} /> )}
); }