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

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

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

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

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

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

테이블 타입

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

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

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

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

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

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

컴포넌트 속성

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

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

)}
); }