ERP-node/frontend/components/screen/ScreenDesigner.tsx

1648 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database } from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
TableInfo,
Position,
ColumnInfo,
GridSettings,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { toast } from "sonner";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
}
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음)
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
});
const [isSaving, setIsSaving] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 클립보드 상태
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
// 드래그 상태
const [dragState, setDragState] = useState({
isDragging: false,
draggedComponent: null as ComponentData | null,
draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열
originalPosition: { x: 0, y: 0, z: 1 },
currentPosition: { x: 0, y: 0, z: 1 },
grabOffset: { x: 0, y: 0 },
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
});
// 드래그 선택 상태
const [selectionDrag, setSelectionDrag] = useState({
isSelecting: false,
startPoint: { x: 0, y: 0, z: 1 },
currentPoint: { x: 0, y: 0, z: 1 },
wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적
});
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const gridInfo = useMemo(() => {
if (!layout.gridSettings) return null;
// 캔버스 크기 계산
let width = canvasSize.width || window.innerWidth - 100;
let height = canvasSize.height || window.innerHeight - 200;
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
width = rect.width || width;
height = rect.height || height;
}
return calculateGridInfo(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
}, [layout.gridSettings, canvasSize]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
// 캔버스 크기 계산
let width = window.innerWidth - 100;
let height = window.innerHeight - 200;
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
width = rect.width || width;
height = rect.height || height;
}
const lines = generateGridLines(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// 수직선과 수평선을 하나의 배열로 합치기
const allLines = [
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
];
return allLines;
}, [gridInfo, layout.gridSettings]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((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(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, gridInfo, saveToHistory],
);
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
useEffect(() => {
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
const loadTable = async () => {
try {
// 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화)
const columnsResponse = await tableTypeApi.getColumns(selectedScreen.tableName);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || selectedScreen.tableName,
columnName: col.columnName || col.column_name,
columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
webType: col.webType || col.web_type,
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
}));
const tableInfo: TableInfo = {
tableName: selectedScreen.tableName,
tableLabel: selectedScreen.tableName, // 필요시 별도 API로 displayName 조회
columns: columns,
};
setTables([tableInfo]); // 단일 테이블 정보만 설정
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
}
};
loadTable();
} else {
// 테이블명이 없는 경우 테이블 목록 초기화
setTables([]);
}
}, [selectedScreen?.tableName]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
const loadLayout = async () => {
try {
const response = await screenApi.getLayout(selectedScreen.screenId);
if (response) {
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
...response,
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
...response.gridSettings, // 기존 설정이 있으면 덮어쓰기
},
};
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
}
};
loadLayout();
}
}, [selectedScreen?.screenId]);
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && canvasSize.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산
const newGridInfo = calculateGridInfo(canvasSize.width, canvasSize.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
return {
...comp,
position: snappedPosition,
size: snappedSize,
};
});
newLayout.components = adjustedComponents;
console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
console.log("새로운 격자 정보:", newGridInfo);
}
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, canvasSize, saveToHistory],
);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
setIsSaving(true);
await screenApi.saveLayout(selectedScreen.screenId, layout);
toast.success("화면이 저장되었습니다.");
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableName,
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
},
};
} else if (type === "column") {
// 격자 기반 컬럼 너비 계산
const columnWidth = gridInfo ? gridInfo.columnWidth : 200;
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
// dataType: column.dataType, // WidgetComponent에 dataType 속성이 없음
required: column.required,
readonly: false, // 누락된 속성 추가
position: { x, y, z: 1 } as Position,
size: { width: columnWidth, height: 40 },
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
};
} else {
return;
}
// 격자 스냅 적용 (올바른 타입 변환)
if (layout.gridSettings?.snapToGrid && gridInfo) {
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
};
newComponent.position = snapToGrid(newComponent.position, gridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, gridUtilSettings);
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
// 컴포넌트 클릭 처리 (다중선택 지원)
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
// 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지)
if (dragState.justFinishedDrag) {
return;
}
const isShiftPressed = event?.shiftKey || false;
const isCtrlPressed = event?.ctrlKey || event?.metaKey || false;
const isGroupContainer = component.type === "group";
if (isShiftPressed || isCtrlPressed || groupState.isGrouping) {
// 다중 선택 모드
if (isGroupContainer) {
// 그룹 컨테이너는 단일 선택으로 처리
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
isGrouping: false,
}));
return;
}
const isSelected = groupState.selectedComponents.includes(component.id);
setGroupState((prev) => ({
...prev,
selectedComponents: isSelected
? prev.selectedComponents.filter((id) => id !== component.id)
: [...prev.selectedComponents, component.id],
}));
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (!isSelected) {
setSelectedComponent(component);
}
} else {
// 단일 선택 모드
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
}));
}
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag],
);
// 컴포넌트 드래그 시작
const startComponentDrag = useCallback(
(component: ComponentData, event: React.MouseEvent) => {
event.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
// 새로운 드래그 시작 시 justFinishedDrag 플래그 해제
if (dragState.justFinishedDrag) {
setDragState((prev) => ({
...prev,
justFinishedDrag: false,
}));
}
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
const componentsToMove = isDraggedComponentSelected
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component];
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
setDragState({
isDragging: true,
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들
originalPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
currentPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: {
x: event.clientX - rect.left - component.position.x,
y: event.clientY - rect.top - component.position.y,
},
justFinishedDrag: false,
});
},
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
);
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
const updateDragPosition = useCallback(
(event: MouseEvent) => {
if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const newPosition = {
x: event.clientX - rect.left - dragState.grabOffset.x,
y: event.clientY - rect.top - dragState.grabOffset.y,
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
setDragState((prev) => ({
...prev,
currentPosition: newPosition,
}));
// 실시간 피드백은 렌더링에서 처리하므로 setLayout 호출 제거
// 성능 최적화: 드래그 중에는 상태 업데이트만 하고, 실제 레이아웃 업데이트는 endDrag에서 처리
},
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
);
// 드래그 종료
const endDrag = useCallback(() => {
if (dragState.isDragging && dragState.draggedComponent) {
// 주 드래그 컴포넌트의 최종 위치에 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid(dragState.currentPosition, gridInfo, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: dragState.currentPosition;
// 스냅으로 인한 추가 이동 거리 계산
const snapDeltaX = finalPosition.x - dragState.currentPosition.x;
const snapDeltaY = finalPosition.y - dragState.currentPosition.y;
// 원래 이동 거리 + 스냅 조정 거리
const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX;
const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY;
// 다중 컴포넌트들의 최종 위치 업데이트
const updatedComponents = layout.components.map((comp) => {
const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id);
if (isDraggedComponent) {
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!;
return {
...comp,
position: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
z: originalComponent.position.z || 1,
} as Position,
};
}
return comp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
// 히스토리에 저장
saveToHistory(newLayout);
}
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, gridInfo, saveToHistory]);
// 드래그 선택 시작
const startSelectionDrag = useCallback(
(event: React.MouseEvent) => {
if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const startPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
z: 1,
};
setSelectionDrag({
isSelecting: true,
startPoint,
currentPoint: startPoint,
wasSelecting: false,
});
},
[dragState.isDragging],
);
// 드래그 선택 업데이트
const updateSelectionDrag = useCallback(
(event: MouseEvent) => {
if (!selectionDrag.isSelecting || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const currentPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
z: 1,
};
setSelectionDrag((prev) => ({
...prev,
currentPoint,
}));
// 선택 영역 내의 컴포넌트들 찾기
const selectionRect = {
left: Math.min(selectionDrag.startPoint.x, currentPoint.x),
top: Math.min(selectionDrag.startPoint.y, currentPoint.y),
right: Math.max(selectionDrag.startPoint.x, currentPoint.x),
bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y),
};
const selectedIds = layout.components
.filter((comp) => {
const compRect = {
left: comp.position.x,
top: comp.position.y,
right: comp.position.x + comp.size.width,
bottom: comp.position.y + comp.size.height,
};
return (
compRect.left < selectionRect.right &&
compRect.right > selectionRect.left &&
compRect.top < selectionRect.bottom &&
compRect.bottom > selectionRect.top
);
})
.map((comp) => comp.id);
setGroupState((prev) => ({
...prev,
selectedComponents: selectedIds,
}));
},
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components],
);
// 드래그 선택 종료
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 handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
// 자식 컴포넌트들의 상대 위치 계산
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
);
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
},
[layout, saveToHistory],
);
// 그룹 생성 함수 (다이얼로그 표시)
const createGroup = useCallback(() => {
if (groupState.selectedComponents.length < 2) {
toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다.");
return;
}
console.log("🔄 그룹 생성 다이얼로그 표시");
setShowGroupCreateDialog(true);
}, [groupState.selectedComponents]);
// 그룹 해제 함수
const ungroupComponents = useCallback(() => {
if (!selectedComponent || selectedComponent.type !== "group") 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 {
await screenApi.saveLayout(selectedScreen.screenId, layout);
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,
]);
if (!selectedScreen) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900"> </h3>
<p className="text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
onRedo={redo}
onPreview={() => {
toast.info("미리보기 기능은 준비 중입니다.");
}}
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
startSelectionDrag(e);
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db",
opacity: layout.gridSettings?.gridOpacity || 0.5,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.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: 9999,
},
};
} else {
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === component.id);
if (originalComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayComponent = {
...component,
position: {
x: originalComponent.position.x + deltaX,
y: originalComponent.position.y + deltaY,
z: originalComponent.position.z || 1,
} as Position,
style: {
...component.style,
opacity: 0.8,
transition: "none",
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
},
};
}
}
}
return (
<RealtimePreview
key={component.id}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{children.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} 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,
},
};
}
}
}
return (
<RealtimePreview
key={child.id}
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
);
})}
</RealtimePreview>
);
})}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="pointer-events-none absolute"
style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
border: "2px dashed #3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
borderRadius: "4px",
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">
단축키: T(), P(), S(), R() | Ctrl+G(), Ctrl+Shift+G()
</p>
<p className="mt-1 text-xs">
편집: Ctrl+C(), Ctrl+V(), Ctrl+S(), Ctrl+Z(), Delete()
</p>
<p className="mt-1 text-xs text-amber-600">
</p>
</div>
</div>
)}
</div>
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
onUpdateProperty={(path: string, value: any) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, path, value);
}
}}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
/>
</FloatingPanel>
<FloatingPanel
id="styles"
title="스타일 편집"
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
height={400}
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</FloatingPanel>
<FloatingPanel
id="grid"
title="격자 설정"
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={updateGridSettings}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
updateGridSettings(defaultSettings);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}
allComponents={layout.components}
groupState={groupState}
onGroupStateChange={setGroupState}
onGroupCreate={(componentIds: string[], title: string, style?: any) => {
handleGroupCreate(componentIds, title, style);
}}
onGroupUngroup={() => {
// TODO: 그룹 해제 구현
}}
showCreateDialog={showGroupCreateDialog}
onShowCreateDialogChange={setShowGroupCreateDialog}
/>
</div>
)}
</div>
);
}