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

2548 lines
92 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,
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
calculateWidthFromColumns,
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 { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
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: 380,
defaultHeight: 700, // 테이블 목록은 그대로 유지
shortcutKey: "t",
},
{
id: "templates",
title: "템플릿",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "m", // template의 m
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음)
},
{
id: "detailSettings",
title: "상세 설정",
defaultPosition: "right",
defaultWidth: 400,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "d",
},
];
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) => {
console.log("⚙️ 컴포넌트 속성 업데이트:", {
componentId,
path,
value,
timestamp: new Date().toISOString(),
});
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;
console.log("✅ 컴포넌트 업데이트 완료:", {
componentId,
path,
newValue: current[pathParts[pathParts.length - 1]],
fullComponent: newComp,
webTypeConfig: newComp.type === "widget" ? (newComp as any).webTypeConfig : null,
});
// webTypeConfig 업데이트의 경우 추가 검증
if (path === "webTypeConfig") {
console.log("🎛️ webTypeConfig 특별 처리:", {
componentId,
oldConfig: comp.type === "widget" ? (comp as any).webTypeConfig : null,
newConfig: current[pathParts[pathParts.length - 1]],
configType: typeof current[pathParts[pathParts.length - 1]],
configStringified: JSON.stringify(current[pathParts[pathParts.length - 1]]),
oldConfigStringified: JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null),
isConfigChanged:
JSON.stringify(comp.type === "widget" ? (comp as any).webTypeConfig : null) !==
JSON.stringify(current[pathParts[pathParts.length - 1]]),
timestamp: new Date().toISOString(),
});
}
// gridColumns 변경 시 크기 자동 업데이트
console.log("🔍 gridColumns 변경 감지:", {
path,
value,
componentType: newComp.type,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
currentGridColumns: (newComp as any).gridColumns,
});
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
console.log("📏 gridColumns 변경으로 크기 업데이트:", {
gridColumns: value,
oldSize: comp.size,
newSize: updatedSize,
});
} else if (path === "gridColumns") {
console.log("❌ gridColumns 변경 실패:", {
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
gridInfo,
gridSettings: layout.gridSettings,
});
}
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
layout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
console.log("📏 크기 변경으로 gridColumns 자동 조정:", {
oldColumns: comp.gridColumns,
newColumns: adjustedColumns,
newSize: snappedSize,
});
}
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid &&
gridInfo
) {
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && gridInfo) {
const { columnWidth } = gridInfo;
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 좌표는 20px 단위로 스냅
const effectiveY = newComp.position.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(newComp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId,
parentId: newComp.parentId,
originalPosition: comp.position,
originalSize: comp.size,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
snappedPosition: newComp.position,
snappedSize: newComp.size,
});
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(newComp.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.position = snappedPosition;
console.log("🧲 일반 컴포넌트 격자 스냅:", {
componentId,
originalPosition: comp.position,
snappedPosition,
});
} else {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
componentId,
type: newComp.type,
position: newComp.position,
});
}
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
if (selectedComponent && selectedComponent.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
console.log("🔄 selectedComponent 동기화:", {
componentId,
path,
oldColumnsCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A",
newColumnsCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A",
oldFiltersCount:
selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A",
newFiltersCount:
updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A",
timestamp: new Date().toISOString(),
});
setSelectedComponent(updatedSelectedComponent);
}
}
// 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(),
});
}
},
[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);
// 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, 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 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 snappedPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
templateName: template.name,
componentsCount: template.components.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 템플릿의 모든 컴포넌트들을 생성
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: templateComp.size,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
gridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings?.snapToGrid,
});
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: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
// 웹타입별 기본 설정 생성
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 || "입력하세요",
};
}
};
return {
id: componentId,
type: "widget",
widgetType: widgetType as any,
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
position: finalPosition,
size: templateComp.size,
required: templateComp.required || false,
readonly: templateComp.readonly || false,
gridColumns: 1,
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
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]);
openPanel("properties");
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
// 드래그 앤 드롭 처리
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 parsedData = JSON.parse(dragData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
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;
// 웹타입별 기본 설정 생성
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 {
language: "javascript",
theme: "light",
fontSize: 14,
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "코드를 입력하세요...",
};
case "entity":
return {
entityName: "",
displayField: "name",
valueField: "id",
searchable: true,
multiple: false,
allowClear: true,
placeholder: "엔터티를 선택하세요",
apiEndpoint: "",
filters: [],
displayFormat: "simple" as const,
};
default:
return undefined;
}
};
// 컬럼 위젯 생성
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 },
gridColumns: 1, // 기본 그리드 컬럼 수
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
};
} else {
return;
}
// 격자 스냅 적용 (그룹 컴포넌트 제외)
if (layout.gridSettings?.snapToGrid && gridInfo && newComponent.type !== "group") {
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);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
});
}
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, 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) {
console.log("🎯 컴포넌트 선택 (다중 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
setSelectedComponent(component);
}
} else {
// 단일 선택 모드
console.log("🎯 컴포넌트 선택 (단일 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
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 draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && gridInfo) {
finalPosition = snapToGrid(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
z: dragState.currentPosition.z ?? 1,
},
gridInfo,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
}
// 스냅으로 인한 추가 이동 거리 계산
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,
};
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
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, Math.round(comp.size.height / 20) * 20);
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);
// 히스토리에 저장
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) => {
console.log("그룹 생성 기능이 임시 비활성화되었습니다.");
toast.info("그룹 기능이 임시 비활성화되었습니다.");
return;
// 격자 정보 계산
const currentGridInfo =
gridInfo ||
calculateGridInfo(
1200,
800,
layout.gridSettings || {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
);
console.log("🔧 그룹 생성 시작:", {
selectedCount: selectedComponents.length,
snapToGrid: layout.gridSettings?.snapToGrid,
gridInfo: currentGridInfo,
});
// 컴포넌트 크기 조정 기반 그룹 크기 계산
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?.snapToGrid,
});
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
},
[layout, saveToHistory, gridInfo],
);
// 그룹 생성 함수 (다이얼로그 표시)
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 {
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.type === "widget" ? JSON.stringify((component as any).webTypeConfig) : ""}`}
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}-${child.type === "widget" ? JSON.stringify((child as any).webTypeConfig) : ""}`}
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(), M(릿), P(), S(), R(), D()</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={380}
height={700}
autoHeight={false}
>
<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="templates"
title="템플릿"
isOpen={panelStates.templates?.isOpen || false}
onClose={() => closePanel("templates")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<TemplatesPanel
onDragStart={(e, template) => {
const dragData = {
type: "template",
template,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={360}
height={400}
autoHeight={true}
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={(path: string, value: any) => {
console.log("🔧 속성 업데이트 요청:", {
componentId: selectedComponent?.id,
componentType: selectedComponent?.type,
path,
value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value,
});
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={360}
height={400}
autoHeight={true}
>
{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={320}
height={400}
autoHeight={true}
>
<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>
<FloatingPanel
id="detailSettings"
title="상세 설정"
isOpen={panelStates.detailSettings?.isOpen || false}
onClose={() => closePanel("detailSettings")}
position="right"
width={400}
height={400}
autoHeight={true}
>
<DetailSettingsPanel
selectedComponent={selectedComponent || undefined}
onUpdateProperty={(componentId: string, path: string, value: any) => {
updateComponentProperty(componentId, path, value);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{false && 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>
);
}