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

3712 lines
140 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.

This file contains Unicode characters that might be confused with other characters. 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,
ScreenResolution,
SCREEN_RESOLUTIONS,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
} from "@/lib/utils/groupingUtils";
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 { MenuAssignmentModal } from "./MenuAssignmentModal";
import { initializeComponents } from "@/lib/registry/components";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import ComponentsPanel from "./panels/ComponentsPanel";
import LayoutsPanel from "./panels/LayoutsPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
// 레이아웃 초기화
import "@/lib/registry/layouts";
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
// 성능 최적화 도구 초기화 (필요시 사용)
import "@/lib/registry/utils/performanceOptimizer";
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: "layouts",
title: "레이아웃",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "l", // layout의 l
},
{
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",
},
{
id: "resolution",
title: "해상도 설정",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "e", // resolution의 e
},
];
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 [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
// 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
);
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 = screenResolution.width;
let height = screenResolution.height;
// 해상도가 설정되지 않은 경우 기본값 사용
if (!width || !height) {
width = canvasSize.width || window.innerWidth - 100;
height = canvasSize.height || window.innerHeight - 200;
}
const newGridInfo = calculateGridInfo(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
console.log("🧮 격자 정보 재계산:", {
resolution: `${width}x${height}`,
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
columnWidth: newGridInfo.columnWidth.toFixed(2),
snapToGrid: layout.gridSettings.snapToGrid,
});
return newGridInfo;
}, [layout.gridSettings, screenResolution]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
// 캔버스 크기는 해상도 크기 사용
const width = screenResolution.width;
const height = screenResolution.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, screenResolution]);
// 필터된 테이블 목록
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 targetComponent = layout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
let positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
const oldPosition = targetComponent.position;
let newPosition = { ...oldPosition };
if (path === "position.x") {
newPosition.x = value;
positionDelta.x = value - oldPosition.x;
} else if (path === "position.y") {
newPosition.y = value;
positionDelta.y = value - oldPosition.y;
} else if (path === "position") {
newPosition = value;
positionDelta.x = value.x - oldPosition.x;
positionDelta.y = value.y - oldPosition.y;
}
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
// 이 레이아웃의 존에 속한 컴포넌트인지 확인
const isInLayoutZone = comp.parentId === componentId && comp.zoneId;
if (isInLayoutZone) {
console.log("🔄 존 컴포넌트 함께 이동:", {
componentId: comp.id,
zoneId: comp.zoneId,
oldPosition: comp.position,
delta: positionDelta,
});
return {
...comp,
position: {
...comp.position,
x: comp.position.x + positionDelta.x,
y: comp.position.y + positionDelta.y,
},
};
}
}
return comp;
}
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
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 currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
console.log("📏 크기 변경으로 gridColumns 자동 조정:", {
oldColumns: comp.gridColumns,
newColumns: adjustedColumns,
newSize: snappedSize,
});
}
}
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
newComp.size = {
...newComp.size,
width: newWidth,
};
console.log("📐 gridColumns 변경으로 크기 자동 조정:", {
componentId,
gridColumns: newComp.gridColumns,
oldWidth: comp.size.width,
newWidth: newWidth,
columnWidth: currentGridInfo.columnWidth,
gap: layout.gridSettings.gap,
});
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid
) {
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && currentGridInfo) {
const { columnWidth } = currentGridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newComp.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 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,
currentGridInfo,
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(() => {
const initComponents = async () => {
try {
console.log("🚀 컴포넌트 시스템 초기화 시작...");
await initializeComponents();
console.log("✅ 컴포넌트 시스템 초기화 완료");
} catch (error) {
console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
}
};
initComponents();
}, []);
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
useEffect(() => {
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
const loadTable = async () => {
try {
// 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화)
const [columnsResponse, tableLabelResponse] = await Promise.all([
tableTypeApi.getColumns(selectedScreen.tableName),
tableTypeApi.getTableLabel(selectedScreen.tableName),
]);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || selectedScreen.tableName,
columnName: col.columnName || col.column_name,
// 우선순위: displayName(라벨) > columnLabel > column_label > columnName > column_name
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type, // 🎯 input_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,
// 코드 카테고리 정보 추가
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
}));
const tableInfo: TableInfo = {
tableName: selectedScreen.tableName,
// 테이블 라벨이 있으면 우선 표시, 없으면 테이블명 그대로
tableLabel: tableLabelResponse.tableLabel || selectedScreen.tableName,
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, // 기존 설정이 있으면 덮어쓰기
},
};
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
if (response.screenResolution) {
setScreenResolution(response.screenResolution);
console.log("💾 저장된 해상도 불러옴:", response.screenResolution);
} else {
// 기본 해상도 (Full HD)
const defaultResolution =
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0];
setScreenResolution(defaultResolution);
console.log("🔧 기본 해상도 적용:", defaultResolution);
}
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
}
};
loadLayout();
}
}, [selectedScreen?.screenId]);
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: 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, screenResolution, saveToHistory],
);
// 해상도 변경 핸들러
const handleResolutionChange = useCallback(
(newResolution: ScreenResolution) => {
console.log("📱 해상도 변경 시작:", {
from: `${screenResolution.width}x${screenResolution.height}`,
to: `${newResolution.width}x${newResolution.height}`,
hasComponents: layout.components.length > 0,
snapToGrid: layout.gridSettings?.snapToGrid || false,
});
setScreenResolution(newResolution);
// 해상도 변경 시에는 격자 스냅을 적용하지 않고 해상도 정보만 업데이트
// 이는 기존 컴포넌트들의 위치를 보존하기 위함
const updatedLayout = {
...layout,
screenResolution: newResolution,
};
setLayout(updatedLayout);
saveToHistory(updatedLayout);
console.log("✅ 해상도 변경 완료:", {
newResolution: `${newResolution.width}x${newResolution.height}`,
preservedComponents: layout.components.length,
note: "컴포넌트 위치는 보존됨 (격자 스냅 생략)",
});
},
[layout, saveToHistory, screenResolution],
);
// 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용)
const handleForceGridUpdate = useCallback(() => {
if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) {
console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음");
return;
}
console.log("🔄 격자 강제 재조정 시작:", {
componentsCount: layout.components.length,
resolution: `${screenResolution.width}x${screenResolution.height}`,
gridSettings: layout.gridSettings,
});
// 현재 해상도로 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid,
};
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings);
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings);
}
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
const newLayout = { ...layout, components: adjustedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
console.log("✅ 격자 강제 재조정 완료:", {
adjustedComponents: adjustedComponents.length,
gridInfo: {
columnWidth: currentGridInfo.columnWidth.toFixed(2),
totalWidth: currentGridInfo.totalWidth,
columns: layout.gridSettings.columns,
},
});
toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`);
}, [layout, screenResolution, saveToHistory]);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
setIsSaving(true);
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
screenResolution: screenResolution,
};
console.log("💾 저장할 레이아웃 데이터:", {
componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
});
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
toast.success("화면이 저장되었습니다.");
// 저장 성공 후 메뉴 할당 모달 열기
setShowMenuAssignmentModal(true);
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout, screenResolution]);
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
templateName: template.name,
componentsCount: template.components.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 템플릿의 모든 컴포넌트들을 생성
// 먼저 ID 매핑을 생성 (parentId 참조를 위해)
const idMapping: Record<string, string> = {};
template.components.forEach((templateComp, index) => {
const newId = generateComponentId();
if (index === 0) {
// 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑
idMapping["form-container"] = newId;
}
idMapping[templateComp.parentId || `temp_${index}`] = newId;
});
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = index === 0 ? idMapping["form-container"] : generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
// 그리드 컬럼 기반 크기 계산
const gridColumns =
typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: { width: 400, height: templateComp.size.height }; // 폴백 크기
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
title: templateComp.title || templateComp.label,
position: finalPosition,
size: calculatedSize,
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#3b83f6",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!currentGridInfo,
hasGridSettings: !!layout.gridSettings?.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: "#3b83f6",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else if (templateComp.type === "file") {
// 파일 첨부 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
type: "file",
label: templateComp.label,
position: finalPosition,
size: calculatedSize,
gridColumns,
fileConfig: {
accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"],
multiple: true,
maxSize: 10, // 10MB
maxFiles: 5,
docType: "DOCUMENT",
docTypeName: "일반 문서",
targetObjid: selectedScreen?.screenId || "",
showPreview: true,
showProgress: true,
dragDropText: "파일을 드래그하여 업로드하세요",
uploadButtonText: "파일 선택",
autoUpload: true,
chunkedUpload: false,
},
uploadedFiles: [],
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#3b83f6",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else if (templateComp.type === "area") {
// 영역 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
const calculatedSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: templateComp.size;
return {
id: componentId,
type: "area",
label: templateComp.label,
position: finalPosition,
size: calculatedSize,
gridColumns,
layoutType: (templateComp as any).layoutType || "box",
title: (templateComp as any).title || templateComp.label,
description: (templateComp as any).description,
layoutConfig: (templateComp as any).layoutConfig || {},
areaStyle: {
backgroundColor: "#ffffff",
borderWidth: 1,
borderStyle: "solid",
borderColor: "#e5e7eb",
borderRadius: 8,
padding: 16,
margin: 0,
shadow: "sm",
...(templateComp as any).areaStyle,
},
children: [],
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#3b83f6",
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 || "입력하세요",
};
}
};
// 위젯 크기도 격자에 맞게 조정
const widgetSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? {
width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
height: templateComp.size.height,
}
: templateComp.size;
return {
id: componentId,
type: "widget",
widgetType: widgetType as any,
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined,
position: finalPosition,
size: widgetSize,
required: templateComp.required || false,
readonly: templateComp.readonly || false,
gridColumns: 1,
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#3b83f6",
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 handleLayoutDrop = useCallback(
(e: React.DragEvent, layoutData: any) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🏗️ 레이아웃 드롭:", {
layoutType: layoutData.layoutType,
zonesCount: layoutData.zones.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 레이아웃 컴포넌트 생성
const newLayoutComponent: ComponentData = {
id: layoutData.id,
type: "layout",
layoutType: layoutData.layoutType,
layoutConfig: layoutData.layoutConfig,
zones: layoutData.zones.map((zone: any) => ({
...zone,
id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
})),
children: [],
position: snappedPosition,
size: layoutData.size,
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
const newLayout = {
...layout,
components: [...layout.components, newLayoutComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 레이아웃 컴포넌트 선택
setSelectedComponent(newLayoutComponent);
openPanel("properties");
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
// 존 클릭 핸들러
const handleZoneClick = useCallback((zoneId: string) => {
console.log("🎯 존 클릭:", zoneId);
// 필요시 존 선택 로직 추가
}, []);
// 웹타입별 기본 설정 생성 함수를 상위로 이동
const getDefaultWebTypeConfig = useCallback((webType: string) => {
switch (webType) {
case "button":
return {
actionType: "custom",
variant: "default",
confirmationMessage: "",
popupTitle: "",
popupContent: "",
navigateUrl: "",
};
case "date":
return {
format: "YYYY-MM-DD",
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "number":
return {
format: "integer",
placeholder: "숫자를 입력하세요",
};
case "select":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "file":
return {
accept: ["*/*"],
maxSize: 10485760, // 10MB
multiple: false,
showPreview: true,
autoUpload: false,
};
default:
return {};
}
}, []);
// 컴포넌트 드래그 처리 (캔버스 레벨 드롭)
const handleComponentDrop = useCallback(
(e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => {
// 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출
if (!component) {
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const parsedData = JSON.parse(dragData);
if (parsedData.type === "component") {
component = parsedData.component;
} else {
return;
}
} catch (error) {
console.error("드래그 데이터 파싱 오류:", error);
return;
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
// 컴포넌트 크기 정보
const componentWidth = component.defaultSize?.width || 120;
const componentHeight = component.defaultSize?.height || 36;
// 방법 1: 마우스 포인터를 컴포넌트 중심으로 (현재 방식)
const dropX_centered = e.clientX - rect.left - componentWidth / 2;
const dropY_centered = e.clientY - rect.top - componentHeight / 2;
// 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 (사용자가 원할 수도 있는 방식)
const dropX_topleft = e.clientX - rect.left;
const dropY_topleft = e.clientY - rect.top;
// 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록
const dropX = dropX_topleft;
const dropY = dropY_topleft;
console.log("🎯 위치 계산 디버깅:", {
"1. 마우스 위치": { clientX: e.clientX, clientY: e.clientY },
"2. 캔버스 위치": { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
"3. 캔버스 내 상대 위치": { x: e.clientX - rect.left, y: e.clientY - rect.top },
"4. 컴포넌트 크기": { width: componentWidth, height: componentHeight },
"5a. 중심 방식 좌상단": { x: dropX_centered, y: dropY_centered },
"5b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft },
"6. 선택된 방식": { dropX, dropY },
"7. 예상 컴포넌트 중심": { x: dropX + componentWidth / 2, y: dropY + componentHeight / 2 },
"8. 마우스와 중심 일치 확인": {
match:
Math.abs(dropX + componentWidth / 2 - (e.clientX - rect.left)) < 1 &&
Math.abs(dropY + componentHeight / 2 - (e.clientY - rect.top)) < 1,
},
});
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 캔버스 경계 내로 위치 제한
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: boundedX, y: boundedY, z: 1 };
console.log("🧩 컴포넌트 드롭:", {
componentName: component.name,
webType: component.webType,
rawPosition: { x: dropX, y: dropY },
boundedPosition: { x: boundedX, y: boundedY },
snappedPosition,
});
// 새 컴포넌트 생성 (새 컴포넌트 시스템 지원)
console.log("🔍 ScreenDesigner handleComponentDrop:", {
componentName: component.name,
componentId: component.id,
webType: component.webType,
category: component.category,
defaultConfig: component.defaultConfig,
});
// 컴포넌트별 gridColumns 설정 및 크기 계산
let componentSize = component.defaultSize;
const isCardDisplay = component.id === "card-display";
const isTableList = component.id === "table-list";
// 컴포넌트별 기본 그리드 컬럼 수 설정
const gridColumns = isCardDisplay ? 8 : isTableList ? 1 : 1;
if ((isCardDisplay || isTableList) && layout.gridSettings?.snapToGrid && gridInfo) {
// gridColumns에 맞는 정확한 너비 계산
const calculatedWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
// 컴포넌트별 최소 크기 보장
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : 100;
componentSize = {
...component.defaultSize,
width: Math.max(calculatedWidth, minWidth),
};
console.log(`📐 ${component.name} 초기 크기 자동 조정:`, {
componentId: component.id,
gridColumns,
defaultWidth: component.defaultSize.width,
calculatedWidth,
gridInfo,
gridSettings: layout.gridSettings,
});
}
const newComponent: ComponentData = {
id: generateComponentId(),
type: "component", // ✅ 새 컴포넌트 시스템 사용
label: component.name,
widgetType: component.webType,
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
position: snappedPosition,
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
...component.defaultConfig,
},
webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "14px",
labelColor: "#3b83f6",
labelFontWeight: "500",
labelMarginBottom: "4px",
},
};
// 레이아웃에 컴포넌트 추가
const newLayout: LayoutData = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 새 컴포넌트 선택
setSelectedComponent(newComponent);
openPanel("properties");
toast.success(`${component.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");
console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
console.log("❌ 드래그 데이터가 없습니다");
return;
}
try {
const parsedData = JSON.parse(dragData);
console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
// 레이아웃 드래그인 경우
if (parsedData.type === "layout") {
handleLayoutDrop(e, parsedData.layout);
return;
}
// 컴포넌트 드래그인 경우
if (parsedData.type === "component") {
handleComponentDrop(e, parsedData.component);
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
// 드롭 대상이 폼 컨테이너인지 확인
const dropTarget = e.target as HTMLElement;
const formContainer = dropTarget.closest('[data-form-container="true"]');
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#3b83f6",
labelFontWeight: "600",
labelMarginBottom: "8px",
},
};
} else if (type === "column") {
console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 격자 스냅이 활성화된 경우 정확한 격자 크기로 생성, 아니면 기본값
const defaultWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings)
: 200;
console.log("🎯 컴포넌트 생성 시 크기 계산:", {
screenResolution: `${screenResolution.width}x${screenResolution.height}`,
gridSettings: layout.gridSettings,
currentGridInfo: currentGridInfo
? {
columnWidth: currentGridInfo.columnWidth.toFixed(2),
totalWidth: currentGridInfo.totalWidth,
}
: null,
defaultWidth: defaultWidth.toFixed(2),
snapToGrid: layout.gridSettings?.snapToGrid,
});
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (widgetType: string) => {
switch (widgetType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "datetime":
return {
format: "YYYY-MM-DD HH:mm" as const,
showTime: true,
placeholder: "날짜와 시간을 선택하세요",
};
case "number":
return {
format: "integer" as const,
placeholder: "숫자를 입력하세요",
};
case "decimal":
return {
format: "decimal" as const,
step: 0.01,
decimalPlaces: 2,
placeholder: "소수를 입력하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: "텍스트를 입력하세요",
resizable: true,
autoResize: false,
wordWrap: true,
};
case "checkbox":
case "boolean":
return {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
};
case "radio":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
],
layout: "vertical" as const,
defaultValue: "",
allowNone: false,
};
case "file":
return {
accept: "",
multiple: false,
maxSize: 10,
maxFiles: 1,
preview: true,
dragDrop: true,
allowedExtensions: [],
};
case "code":
return {
codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴
placeholder: "선택하세요",
options: [], // 기본 빈 배열, 실제로는 API에서 로드
};
case "entity":
return {
entityName: "",
displayField: "name",
valueField: "id",
searchable: true,
multiple: false,
allowClear: true,
placeholder: "엔터티를 선택하세요",
apiEndpoint: "",
filters: [],
displayFormat: "simple" as const,
};
default:
return undefined;
}
};
// 폼 컨테이너에 드롭한 경우
if (formContainer) {
const formContainerId = formContainer.getAttribute("data-component-id");
const formContainerComponent = layout.components.find((c) => c.id === formContainerId);
if (formContainerComponent) {
// 폼 내부에서의 상대적 위치 계산
const containerRect = formContainer.getBoundingClientRect();
const relativeX = e.clientX - containerRect.left;
const relativeY = e.clientY - containerRect.top;
// 웹타입을 새로운 컴포넌트 ID로 매핑
const componentId = getComponentIdFromWebType(column.widgetType);
console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType}${componentId}`);
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: defaultWidth, height: 40 },
gridColumns: 1,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px",
labelColor: "#3b83f6",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
}
} else {
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
const componentId = getComponentIdFromWebType(column.widgetType);
console.log(`🔄 캔버스 드롭: ${column.widgetType}${componentId}`);
newComponent = {
id: generateComponentId(),
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x, y, z: 1 } as Position,
size: { width: defaultWidth, height: 40 },
gridColumns: 1,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelFontSize: "12px",
labelColor: "#3b83f6",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
},
};
}
} else {
return;
}
// 격자 스냅 적용 (그룹 컴포넌트 제외)
if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") {
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
};
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
resolution: `${screenResolution.width}x${screenResolution.height}`,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
columnWidth: currentGridInfo.columnWidth,
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
console.error("드롭 처리 실패:", error);
}
},
[layout, 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("🎯 컴포넌트 선택 (다중 모드):", component.id);
setSelectedComponent(component);
}
} else {
// 단일 선택 모드
// console.log("🎯 컴포넌트 선택 (단일 모드):", component.id);
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 relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
let componentsToMove = isDraggedComponentSelected
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component];
// 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동
if (component.type === "layout") {
const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId);
console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", {
layoutId: component.id,
zoneComponentsCount: zoneComponents.length,
zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })),
});
// 중복 제거하여 추가
const allComponentIds = new Set(componentsToMove.map((c) => c.id));
const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id));
componentsToMove = [...componentsToMove, ...additionalComponents];
}
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
console.log("마우스 위치:", {
clientX: event.clientX,
clientY: event.clientY,
rectLeft: rect.left,
rectTop: rect.top,
relativeX: relativeMouseX,
relativeY: relativeMouseY,
componentX: component.position.x,
componentY: component.position.y,
grabOffsetX: relativeMouseX - component.position.x,
grabOffsetY: relativeMouseY - component.position.y,
});
console.log("🚀 드래그 시작:", {
componentId: component.id,
componentType: component.type,
initialPosition: { x: component.position.x, y: component.position.y },
});
setDragState({
isDragging: true,
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들
originalPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
currentPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: {
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
},
justFinishedDrag: false,
});
},
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
);
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
const updateDragPosition = useCallback(
(event: MouseEvent) => {
if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
const relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
const newPosition = {
x: relativeMouseX - dragState.grabOffset.x,
y: relativeMouseY - dragState.grabOffset.y,
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
console.log("🔥 ScreenDesigner updateDragPosition:", {
draggedComponentId: dragState.draggedComponent.id,
oldPosition: dragState.currentPosition,
newPosition: newPosition,
});
setDragState((prev) => {
const newState = {
...prev,
currentPosition: { ...newPosition }, // 새로운 객체 생성
};
console.log("🔄 ScreenDesigner dragState 업데이트:", {
prevPosition: prev.currentPosition,
newPosition: newState.currentPosition,
stateChanged:
prev.currentPosition.x !== newState.currentPosition.x ||
prev.currentPosition.y !== newState.currentPosition.y,
});
return newState;
});
// 성능 최적화: 드래그 중에는 상태 업데이트만 하고,
// 실제 레이아웃 업데이트는 endDrag에서 처리
// 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시
},
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
);
// 드래그 종료
const endDrag = useCallback(() => {
if (dragState.isDragging && dragState.draggedComponent) {
// 주 드래그 컴포넌트의 최종 위치 계산
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 현재 해상도에 맞는 격자 정보 계산
const currentGridInfo = layout.gridSettings
? calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
})
: null;
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
finalPosition = snapToGrid(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
z: dragState.currentPosition.z ?? 1,
},
currentGridInfo,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
console.log("🎯 격자 스냅 적용됨:", {
resolution: `${screenResolution.width}x${screenResolution.height}`,
originalPosition: dragState.currentPosition,
snappedPosition: finalPosition,
columnWidth: currentGridInfo.columnWidth,
});
}
// 스냅으로 인한 추가 이동 거리 계산
const snapDeltaX = finalPosition.x - dragState.currentPosition.x;
const snapDeltaY = finalPosition.y - dragState.currentPosition.y;
// 원래 이동 거리 + 스냅 조정 거리
const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX;
const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY;
// 다중 컴포넌트들의 최종 위치 업데이트
const updatedComponents = layout.components.map((comp) => {
const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id);
if (isDraggedComponent) {
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!;
let newPosition = {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
z: originalComponent.position.z || 1,
};
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
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);
// 선택된 컴포넌트도 업데이트 (PropertiesPanel 동기화용)
if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id);
if (updatedSelectedComponent) {
console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", {
componentId: selectedComponent.id,
oldPosition: selectedComponent.position,
newPosition: updatedSelectedComponent.position,
});
setSelectedComponent(updatedSelectedComponent);
}
}
// 히스토리에 저장
saveToHistory(newLayout);
}
setDragState({
isDragging: false,
draggedComponent: null,
draggedComponents: [],
originalPosition: { x: 0, y: 0, z: 1 },
currentPosition: { x: 0, y: 0, z: 1 },
grabOffset: { x: 0, y: 0 },
justFinishedDrag: true,
});
// 짧은 시간 후 justFinishedDrag 플래그 해제
setTimeout(() => {
setDragState((prev) => ({
...prev,
justFinishedDrag: false,
}));
}, 100);
}, [dragState, layout, 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 {
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
screenResolution: screenResolution,
};
console.log("⚡ 자동 저장할 레이아웃 데이터:", {
componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
});
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
toast.success("레이아웃이 저장되었습니다.");
} catch (error) {
console.error("레이아웃 저장 실패:", error);
toast.error("레이아웃 저장에 실패했습니다.");
} finally {
setIsSaving(false);
}
} else {
console.log("⚠️ 저장할 컴포넌트가 없습니다");
toast.warning("저장할 컴포넌트가 없습니다.");
}
return false;
}
};
// window 레벨에서 캡처 단계에서 가장 먼저 처리
window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [
selectedComponent,
deleteComponent,
copyComponent,
pasteComponent,
undo,
redo,
createGroup,
ungroupComponents,
groupState.selectedComponents,
layout,
selectedScreen,
]);
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 className="relative flex-1 overflow-auto bg-gray-100 px-2 py-6">
{/* 해상도 정보 표시 - 적당한 여백 */}
<div className="mb-4 flex items-center justify-center">
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
<span className="text-sm font-medium text-gray-700">
{screenResolution.name} ({screenResolution.width} × {screenResolution.height})
</span>
</div>
</div>
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
<div
className="mx-auto bg-white shadow-lg"
style={{
width: screenResolution.width,
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
minHeight: screenResolution.height
}}
>
<div
ref={canvasRef}
className="relative h-full w-full overflow-visible bg-white" // overflow-visible로 변경
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
startSelectionDrag(e);
}
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
e.preventDefault();
console.log("🎯 캔버스 드롭 이벤트 발생");
handleDrop(e);
}}
>
{/* 격자 라인 */}
{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)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 설정을 상세설정에 반영)
onConfigChange={(config) => {
console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
// 컴포넌트의 componentConfig 업데이트
const updatedComponents = layout.components.map(comp => {
if (comp.id === component.id) {
return {
...comp,
componentConfig: {
...comp.componentConfig,
...config
}
};
}
return comp;
});
const newLayout = {
...layout,
components: updatedComponents
};
setLayout(newLayout);
saveToHistory(newLayout);
console.log("✅ 컴포넌트 설정 업데이트 완료:", {
componentId: component.id,
updatedConfig: config
});
}}
>
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis =
dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging &&
dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 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,
},
};
}
}
}
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
const relativeChildComponent = {
...displayChild,
position: {
x: displayChild.position.x - component.position.x,
y: displayChild.position.y - component.position.y,
z: displayChild.position.z || 1,
},
};
return (
<RealtimePreview
key={child.id}
component={relativeChildComponent}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (자식 컴포넌트용)
onConfigChange={(config) => {
console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
}}
/>
);
})}
</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(), E()
</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>
</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) => {
console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
const dragData = {
type: column ? "column" : "table",
table,
column,
};
console.log("📦 드래그 데이터:", dragData);
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) => {
// React 컴포넌트(icon)를 제외하고 JSON으로 직렬화 가능한 데이터만 전송
const serializableTemplate = {
id: template.id,
name: template.name,
description: template.description,
category: template.category,
defaultSize: template.defaultSize,
components: template.components,
};
const dragData = {
type: "template",
template: serializableTemplate,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
<FloatingPanel
id="layouts"
title="레이아웃"
isOpen={panelStates.layouts?.isOpen || false}
onClose={() => closePanel("layouts")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<LayoutsPanel
onDragStart={(e, layoutData) => {
const dragData = {
type: "layout",
layout: layoutData,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true }}
screenResolution={screenResolution}
/>
</FloatingPanel>
<FloatingPanel
id="components"
title="컴포넌트"
isOpen={panelStates.components?.isOpen || false}
onClose={() => closePanel("components")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<ComponentsPanel />
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={360}
height={400}
autoHeight={true}
>
<PropertiesPanel
key={`properties-${selectedComponent?.id}-${dragState.isDragging ? dragState.currentPosition.x + dragState.currentPosition.y : "static"}`}
selectedComponent={selectedComponent || undefined}
tables={tables}
dragState={dragState}
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) => {
console.log("🔧 StyleEditor 크기 변경:", {
componentId: selectedComponent.id,
newStyle,
currentSize: selectedComponent.size,
hasWidth: !!newStyle.width,
hasHeight: !!newStyle.height,
});
// 스타일 업데이트
updateComponentProperty(selectedComponent.id, "style", newStyle);
// 크기가 변경된 경우 component.size도 업데이트
if (newStyle.width || newStyle.height) {
const width = newStyle.width
? parseInt(newStyle.width.replace("px", ""))
: selectedComponent.size.width;
const height = newStyle.height
? parseInt(newStyle.height.replace("px", ""))
: selectedComponent.size.height;
console.log("📏 크기 업데이트:", {
originalWidth: selectedComponent.size.width,
originalHeight: selectedComponent.size.height,
newWidth: width,
newHeight: height,
styleWidth: newStyle.width,
styleHeight: newStyle.height,
});
updateComponentProperty(selectedComponent.id, "size.width", width);
updateComponentProperty(selectedComponent.id, "size.height", height);
}
}}
/>
</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);
}}
onForceGridUpdate={handleForceGridUpdate}
screenResolution={screenResolution}
/>
</FloatingPanel>
<FloatingPanel
id="detailSettings"
title="상세 설정"
isOpen={panelStates.detailSettings?.isOpen || false}
onClose={() => closePanel("detailSettings")}
position="right"
width={400}
height={400}
autoHeight={true}
>
<DetailSettingsPanel
key={`detail-settings-${selectedComponent?.id}-${selectedComponent?.type === "widget" ? (selectedComponent as any).widgetType : "non-widget"}`}
selectedComponent={selectedComponent || undefined}
onUpdateProperty={(componentId: string, path: string, value: any) => {
updateComponentProperty(componentId, path, value);
}}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
/>
</FloatingPanel>
<FloatingPanel
id="resolution"
title="해상도 설정"
isOpen={panelStates.resolution?.isOpen || false}
onClose={() => closePanel("resolution")}
position="right"
width={320}
height={400}
autoHeight={true}
>
<div className="p-4">
<ResolutionPanel currentResolution={screenResolution} onResolutionChange={handleResolutionChange} />
</div>
</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>
)}
{/* 메뉴 할당 모달 */}
<MenuAssignmentModal
isOpen={showMenuAssignmentModal}
onClose={() => setShowMenuAssignmentModal(false)}
screenInfo={selectedScreen}
onAssignmentComplete={() => {
console.log("메뉴 할당 완료");
// 필요시 추가 작업 수행
}}
onBackToList={onBackToList}
/>
</div>
);
}