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

2529 lines
92 KiB
TypeScript
Raw Normal View History

2025-09-01 11:48:12 +09:00
"use client";
2025-09-01 16:40:24 +09:00
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
2025-09-02 16:18:38 +09:00
import { Database } from "lucide-react";
2025-09-01 11:48:12 +09:00
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
TableInfo,
2025-09-02 11:16:40 +09:00
Position,
2025-09-02 16:18:38 +09:00
ColumnInfo,
GridSettings,
2025-09-01 11:48:12 +09:00
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
2025-09-01 15:22:47 +09:00
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
} from "@/lib/utils/groupingUtils";
2025-09-02 11:16:40 +09:00
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
2025-09-03 11:32:09 +09:00
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
2025-09-03 15:23:12 +09:00
calculateWidthFromColumns,
2025-09-02 11:16:40 +09:00
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
2025-09-01 15:22:47 +09:00
import { GroupingToolbar } from "./GroupingToolbar";
2025-09-02 16:18:38 +09:00
import { screenApi, tableTypeApi } from "@/lib/api/screen";
2025-09-01 18:42:59 +09:00
import { toast } from "sonner";
2025-09-01 15:22:47 +09:00
2025-09-01 11:48:12 +09:00
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
2025-09-02 16:18:38 +09:00
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
2025-09-03 15:23:12 +09:00
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
2025-09-02 16:18:38 +09:00
import PropertiesPanel from "./panels/PropertiesPanel";
2025-09-03 11:32:09 +09:00
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
2025-09-02 16:18:38 +09:00
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
2025-09-01 11:48:12 +09:00
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
2025-09-01 11:48:12 +09:00
}
2025-09-02 16:18:38 +09:00
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
2025-09-03 11:32:09 +09:00
defaultWidth: 380,
defaultHeight: 700, // 테이블 목록은 그대로 유지
2025-09-02 16:18:38 +09:00
shortcutKey: "t",
},
2025-09-03 15:23:12 +09:00
{
id: "templates",
title: "템플릿",
defaultPosition: "left",
defaultWidth: 380,
defaultHeight: 700,
shortcutKey: "m", // template의 m
},
2025-09-02 16:18:38 +09:00
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
2025-09-03 11:32:09 +09:00
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
2025-09-02 16:18:38 +09:00
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
2025-09-03 11:32:09 +09:00
defaultWidth: 360,
defaultHeight: 400, // autoHeight 시작점
2025-09-02 16:18:38 +09:00
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
2025-09-03 11:32:09 +09:00
defaultWidth: 320,
defaultHeight: 400, // autoHeight 시작점
2025-09-02 16:18:38 +09:00
shortcutKey: "r", // grid의 r로 변경 (그룹과 겹치지 않음)
},
2025-09-03 11:32:09 +09:00
{
id: "detailSettings",
title: "상세 설정",
defaultPosition: "right",
defaultWidth: 400,
defaultHeight: 400, // autoHeight 시작점
shortcutKey: "d",
},
2025-09-02 16:18:38 +09:00
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
2025-09-02 16:18:38 +09:00
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
2025-09-01 11:48:12 +09:00
const [layout, setLayout] = useState<LayoutData>({
components: [],
2025-09-02 16:18:38 +09:00
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
2025-09-01 11:48:12 +09:00
});
2025-09-01 18:42:59 +09:00
const [isSaving, setIsSaving] = useState(false);
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 클립보드 상태
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 드래그 상태
const [dragState, setDragState] = useState({
2025-09-01 11:48:12 +09:00
isDragging: false,
draggedComponent: null as ComponentData | null,
2025-09-02 16:18:38 +09:00
draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열
originalPosition: { x: 0, y: 0, z: 1 },
currentPosition: { x: 0, y: 0, z: 1 },
2025-09-01 16:40:24 +09:00
grabOffset: { x: 0, y: 0 },
2025-09-02 16:18:38 +09:00
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
2025-09-01 11:48:12 +09:00
});
2025-09-02 16:18:38 +09:00
// 드래그 선택 상태
const [selectionDrag, setSelectionDrag] = useState({
isSelecting: false,
startPoint: { x: 0, y: 0, z: 1 },
currentPoint: { x: 0, y: 0, z: 1 },
wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적
2025-09-01 11:48:12 +09:00
});
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
2025-09-02 11:16:40 +09:00
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
2025-09-02 16:18:38 +09:00
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
2025-09-02 11:16:40 +09:00
const gridInfo = useMemo(() => {
if (!layout.gridSettings) return null;
2025-09-02 16:18:38 +09:00
// 캔버스 크기 계산
let width = canvasSize.width || window.innerWidth - 100;
let height = canvasSize.height || window.innerHeight - 200;
2025-09-02 11:16:40 +09:00
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
2025-09-02 16:18:38 +09:00
width = rect.width || width;
height = rect.height || height;
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
return calculateGridInfo(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
}, [layout.gridSettings, canvasSize]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
// 캔버스 크기 계산
let width = window.innerWidth - 100;
let height = window.innerHeight - 200;
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
width = rect.width || width;
height = rect.height || height;
}
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const lines = generateGridLines(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 수직선과 수평선을 하나의 배열로 합치기
const allLines = [
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
];
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
return allLines;
}, [gridInfo, layout.gridSettings]);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 필터된 테이블 목록
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],
);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
2025-09-03 11:32:09 +09:00
console.log("⚙️ 컴포넌트 속성 업데이트:", {
componentId,
path,
value,
timestamp: new Date().toISOString(),
});
2025-09-02 16:18:38 +09:00
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const newComp = { ...comp };
let current: any = newComp;
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
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;
2025-09-02 11:16:40 +09:00
2025-09-03 11:32:09 +09:00
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 변경 시 크기 자동 업데이트
2025-09-03 15:23:12 +09:00
console.log("🔍 gridColumns 변경 감지:", {
path,
value,
componentType: newComp.type,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
currentGridColumns: (newComp as any).gridColumns,
});
2025-09-03 11:32:09 +09:00
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,
});
2025-09-03 15:23:12 +09:00
} else if (path === "gridColumns") {
console.log("❌ gridColumns 변경 실패:", {
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings,
gridInfo,
gridSettings: layout.gridSettings,
});
2025-09-03 11:32:09 +09:00
}
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
layout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
2025-09-02 16:18:38 +09:00
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
2025-09-03 11:32:09 +09:00
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
console.log("📏 크기 변경으로 gridColumns 자동 조정:", {
oldColumns: comp.gridColumns,
newColumns: adjustedColumns,
newSize: snappedSize,
});
}
}
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
if (
(path === "position.x" || path === "position.y" || path === "position") &&
layout.gridSettings?.snapToGrid &&
gridInfo
) {
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
if (newComp.parentId && gridInfo) {
const { columnWidth } = gridInfo;
const { gap } = layout.gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = newComp.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16)));
const snappedX = padding + columnIndex * (columnWidth + (gap || 16));
// Y 좌표는 20px 단위로 스냅
const effectiveY = newComp.position.y - padding;
const rowIndex = Math.round(effectiveY / 20);
const snappedY = padding + rowIndex * 20;
// 크기도 외부 격자와 동일하게 스냅
const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
console.log("🎯 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId,
parentId: newComp.parentId,
originalPosition: comp.position,
originalSize: comp.size,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
snappedPosition: newComp.position,
snappedSize: newComp.size,
});
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(newComp.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.position = snappedPosition;
console.log("🧲 일반 컴포넌트 격자 스냅:", {
componentId,
originalPosition: comp.position,
snappedPosition,
});
} else {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
componentId,
type: newComp.type,
position: newComp.position,
});
}
2025-09-02 16:18:38 +09:00
}
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
return newComp;
});
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: updatedComponents };
2025-09-02 11:16:40 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-03 11:32:09 +09:00
2025-09-03 15:23:12 +09:00
// 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);
}
}
2025-09-03 11:32:09 +09:00
// 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(),
});
}
2025-09-02 11:16:40 +09:00
},
2025-09-02 16:18:38 +09:00
[layout, gridInfo, saveToHistory],
2025-09-02 11:16:40 +09:00
);
2025-09-02 16:18:38 +09:00
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
useEffect(() => {
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
const loadTable = async () => {
try {
// 선택된 화면의 특정 테이블 정보만 조회 (성능 최적화)
const columnsResponse = await tableTypeApi.getColumns(selectedScreen.tableName);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || selectedScreen.tableName,
columnName: col.columnName || col.column_name,
columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
webType: col.webType || col.web_type,
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
}));
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
const tableInfo: TableInfo = {
tableName: selectedScreen.tableName,
tableLabel: selectedScreen.tableName, // 필요시 별도 API로 displayName 조회
columns: columns,
};
setTables([tableInfo]); // 단일 테이블 정보만 설정
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
}
};
2025-09-02 16:18:38 +09:00
loadTable();
} else {
// 테이블명이 없는 경우 테이블 목록 초기화
setTables([]);
}
}, [selectedScreen?.tableName]);
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
const loadLayout = async () => {
try {
const response = await screenApi.getLayout(selectedScreen.screenId);
if (response) {
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
...response,
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
gridColor: "#d1d5db",
gridOpacity: 0.5,
...response.gridSettings, // 기존 설정이 있으면 덮어쓰기
},
};
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
}
};
loadLayout();
2025-09-01 17:05:36 +09:00
}
2025-09-02 16:18:38 +09:00
}, [selectedScreen?.screenId]);
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && canvasSize.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산
const newGridInfo = calculateGridInfo(canvasSize.width, canvasSize.height, {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
2025-09-01 17:05:36 +09:00
2025-09-02 16:18:38 +09:00
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
};
2025-09-01 17:05:36 +09:00
2025-09-02 16:18:38 +09:00
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
2025-09-03 11:32:09 +09:00
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
2025-09-02 16:18:38 +09:00
return {
...comp,
position: snappedPosition,
size: snappedSize,
2025-09-03 11:32:09 +09:00
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
2025-09-02 16:18:38 +09:00
};
});
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
newLayout.components = adjustedComponents;
console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
console.log("새로운 격자 정보:", newGridInfo);
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 16:40:24 +09:00
},
2025-09-02 16:18:38 +09:00
[layout, canvasSize, saveToHistory],
2025-09-01 16:40:24 +09:00
);
2025-09-02 16:18:38 +09:00
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
try {
setIsSaving(true);
await screenApi.saveLayout(selectedScreen.screenId, layout);
toast.success("화면이 저장되었습니다.");
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
2025-09-01 16:40:24 +09:00
2025-09-03 15:23:12 +09:00
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, gridInfo, layout.gridSettings)
: { x: dropX, y: dropY, z: 1 };
console.log("🎨 템플릿 드롭:", {
templateName: template.name,
componentsCount: template.components.length,
dropPosition: { x: dropX, y: dropY },
snappedPosition,
});
// 템플릿의 모든 컴포넌트들을 생성
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = generateComponentId();
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
layout.gridSettings?.snapToGrid && gridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, gridInfo, layout.gridSettings)
: { x: absoluteX, y: absoluteY, z: 1 };
if (templateComp.type === "container") {
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: templateComp.size,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
gridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
hasGridInfo: !!gridInfo,
hasGridSettings: !!layout.gridSettings?.snapToGrid,
});
return {
id: componentId,
type: "datatable",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
position: finalPosition,
size: calculatedSize,
title: templateComp.label,
columns: [], // 초기에는 빈 배열, 나중에 설정
filters: [], // 초기에는 빈 배열, 나중에 설정
pagination: {
enabled: true,
pageSize: 10,
pageSizeOptions: [5, 10, 20, 50],
showPageSizeSelector: true,
showPageInfo: true,
showFirstLast: true,
},
showSearchButton: true,
searchButtonText: "검색",
enableExport: true,
enableRefresh: true,
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (wType: string) => {
switch (wType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: templateComp.placeholder || "날짜를 선택하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: templateComp.placeholder || "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: templateComp.placeholder || "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: templateComp.placeholder || "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: templateComp.placeholder || "텍스트를 입력하세요",
resizable: true,
wordWrap: true,
};
default:
return {
placeholder: templateComp.placeholder || "입력하세요",
};
}
};
return {
id: componentId,
type: "widget",
widgetType: widgetType as any,
label: templateComp.label,
placeholder: templateComp.placeholder,
columnName: `field_${index + 1}`,
position: finalPosition,
size: templateComp.size,
required: templateComp.required || false,
readonly: templateComp.readonly || false,
gridColumns: 1,
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
}
});
// 레이아웃에 새 컴포넌트들 추가
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 첫 번째 컴포넌트 선택
if (newComponents.length > 0) {
setSelectedComponent(newComponents[0]);
openPanel("properties");
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
);
2025-09-02 16:18:38 +09:00
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
2025-09-02 16:18:38 +09:00
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
try {
2025-09-03 15:23:12 +09:00
const parsedData = JSON.parse(dragData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
2025-09-02 16:18:38 +09:00
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
2025-09-02 16:18:38 +09:00
let newComponent: ComponentData;
2025-09-02 16:18:38 +09:00
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableName,
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
2025-09-02 16:46:54 +09:00
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#374151",
labelFontWeight: "600",
labelMarginBottom: "8px",
},
2025-09-02 16:18:38 +09:00
};
} else if (type === "column") {
// 격자 기반 컬럼 너비 계산
const columnWidth = gridInfo ? gridInfo.columnWidth : 200;
2025-09-02 11:16:40 +09:00
2025-09-03 11:32:09 +09:00
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (widgetType: string) => {
switch (widgetType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "datetime":
return {
format: "YYYY-MM-DD HH:mm" as const,
showTime: true,
placeholder: "날짜와 시간을 선택하세요",
};
case "number":
return {
format: "integer" as const,
placeholder: "숫자를 입력하세요",
};
case "decimal":
return {
format: "decimal" as const,
step: 0.01,
decimalPlaces: 2,
placeholder: "소수를 입력하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: "텍스트를 입력하세요",
resizable: true,
autoResize: false,
wordWrap: true,
};
case "checkbox":
case "boolean":
return {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
};
case "radio":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
],
layout: "vertical" as const,
defaultValue: "",
allowNone: false,
};
case "file":
return {
accept: "",
multiple: false,
maxSize: 10,
maxFiles: 1,
preview: true,
dragDrop: true,
allowedExtensions: [],
};
case "code":
return {
language: "javascript",
theme: "light",
fontSize: 14,
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "코드를 입력하세요...",
};
case "entity":
return {
entityName: "",
displayField: "name",
valueField: "id",
searchable: true,
multiple: false,
allowClear: true,
placeholder: "엔터티를 선택하세요",
apiEndpoint: "",
filters: [],
displayFormat: "simple" as const,
};
default:
return undefined;
}
};
2025-09-02 16:18:38 +09:00
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
// dataType: column.dataType, // WidgetComponent에 dataType 속성이 없음
required: column.required,
readonly: false, // 누락된 속성 추가
position: { x, y, z: 1 } as Position,
size: { width: columnWidth, height: 40 },
2025-09-03 11:32:09 +09:00
gridColumns: 1, // 기본 그리드 컬럼 수
2025-09-02 16:46:54 +09:00
style: {
labelDisplay: true,
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
2025-09-03 11:32:09 +09:00
webTypeConfig: getDefaultWebTypeConfig(column.widgetType),
2025-09-02 16:18:38 +09:00
};
} else {
return;
}
2025-09-01 11:48:12 +09:00
2025-09-03 11:32:09 +09:00
// 격자 스냅 적용 (그룹 컴포넌트 제외)
if (layout.gridSettings?.snapToGrid && gridInfo && newComponent.type !== "group") {
2025-09-02 16:18:38 +09:00
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
};
newComponent.position = snapToGrid(newComponent.position, gridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, gridUtilSettings);
2025-09-03 11:32:09 +09:00
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
2025-09-02 16:18:38 +09:00
}
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 컴포넌트 클릭 처리 (다중선택 지원)
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지)
if (dragState.justFinishedDrag) {
return;
}
const isShiftPressed = event?.shiftKey || false;
const isCtrlPressed = event?.ctrlKey || event?.metaKey || false;
const isGroupContainer = component.type === "group";
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
if (isShiftPressed || isCtrlPressed || groupState.isGrouping) {
// 다중 선택 모드
if (isGroupContainer) {
// 그룹 컨테이너는 단일 선택으로 처리
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
isGrouping: false,
}));
return;
}
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
const isSelected = groupState.selectedComponents.includes(component.id);
setGroupState((prev) => ({
...prev,
selectedComponents: isSelected
? prev.selectedComponents.filter((id) => id !== component.id)
: [...prev.selectedComponents, component.id],
}));
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (!isSelected) {
2025-09-03 11:32:09 +09:00
console.log("🎯 컴포넌트 선택 (다중 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
2025-09-02 16:18:38 +09:00
setSelectedComponent(component);
}
} else {
// 단일 선택 모드
2025-09-03 11:32:09 +09:00
console.log("🎯 컴포넌트 선택 (단일 모드):", {
componentId: component.id,
componentType: component.type,
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
timestamp: new Date().toISOString(),
});
2025-09-02 16:18:38 +09:00
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
selectedComponents: [component.id],
}));
}
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag],
);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 컴포넌트 드래그 시작
const startComponentDrag = useCallback(
(component: ComponentData, event: React.MouseEvent) => {
event.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 새로운 드래그 시작 시 justFinishedDrag 플래그 해제
if (dragState.justFinishedDrag) {
setDragState((prev) => ({
...prev,
justFinishedDrag: false,
}));
2025-09-01 17:57:52 +09:00
}
2025-09-02 16:18:38 +09:00
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
const componentsToMove = isDraggedComponentSelected
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component];
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
setDragState({
isDragging: true,
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들
originalPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
currentPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: {
x: event.clientX - rect.left - component.position.x,
y: event.clientY - rect.top - component.position.y,
},
justFinishedDrag: false,
2025-09-01 17:57:52 +09:00
});
},
2025-09-02 16:18:38 +09:00
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
2025-09-01 17:57:52 +09:00
);
2025-09-02 16:18:38 +09:00
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
const updateDragPosition = useCallback(
(event: MouseEvent) => {
if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const newPosition = {
x: event.clientX - rect.left - dragState.grabOffset.x,
y: event.clientY - rect.top - dragState.grabOffset.y,
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
setDragState((prev) => ({
...prev,
currentPosition: newPosition,
}));
// 실시간 피드백은 렌더링에서 처리하므로 setLayout 호출 제거
// 성능 최적화: 드래그 중에는 상태 업데이트만 하고, 실제 레이아웃 업데이트는 endDrag에서 처리
},
2025-09-02 16:18:38 +09:00
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 드래그 종료
const endDrag = useCallback(() => {
if (dragState.isDragging && dragState.draggedComponent) {
2025-09-03 11:32:09 +09:00
// 주 드래그 컴포넌트의 최종 위치 계산
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && gridInfo) {
finalPosition = snapToGrid(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
z: dragState.currentPosition.z ?? 1,
},
gridInfo,
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
}
2025-09-02 16:18:38 +09:00
// 스냅으로 인한 추가 이동 거리 계산
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)!;
2025-09-03 11:32:09 +09:00
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,
};
}
2025-09-02 16:18:38 +09:00
return {
...comp,
2025-09-03 11:32:09 +09:00
position: newPosition as Position,
2025-09-02 16:18:38 +09:00
};
}
return comp;
});
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
// 히스토리에 저장
saveToHistory(newLayout);
}
setDragState({
isDragging: false,
draggedComponent: null,
draggedComponents: [],
originalPosition: { x: 0, y: 0, z: 1 },
currentPosition: { x: 0, y: 0, z: 1 },
grabOffset: { x: 0, y: 0 },
justFinishedDrag: true,
});
// 짧은 시간 후 justFinishedDrag 플래그 해제
setTimeout(() => {
setDragState((prev) => ({
...prev,
justFinishedDrag: false,
}));
}, 100);
}, [dragState, layout, gridInfo, saveToHistory]);
// 드래그 선택 시작
const startSelectionDrag = useCallback(
(event: React.MouseEvent) => {
if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const startPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
z: 1,
};
setSelectionDrag({
isSelecting: true,
startPoint,
currentPoint: startPoint,
wasSelecting: false,
});
},
[dragState.isDragging],
);
// 드래그 선택 업데이트
const updateSelectionDrag = useCallback(
(event: MouseEvent) => {
if (!selectionDrag.isSelecting || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const currentPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
z: 1,
};
setSelectionDrag((prev) => ({
...prev,
currentPoint,
}));
// 선택 영역 내의 컴포넌트들 찾기
const selectionRect = {
left: Math.min(selectionDrag.startPoint.x, currentPoint.x),
top: Math.min(selectionDrag.startPoint.y, currentPoint.y),
right: Math.max(selectionDrag.startPoint.x, currentPoint.x),
bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y),
};
const selectedIds = layout.components
.filter((comp) => {
const compRect = {
left: comp.position.x,
top: comp.position.y,
right: comp.position.x + comp.size.width,
bottom: comp.position.y + comp.size.height,
};
return (
compRect.left < selectionRect.right &&
compRect.right > selectionRect.left &&
compRect.top < selectionRect.bottom &&
compRect.bottom > selectionRect.top
);
})
.map((comp) => comp.id);
setGroupState((prev) => ({
...prev,
selectedComponents: selectedIds,
}));
},
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components],
);
// 드래그 선택 종료
const endSelectionDrag = useCallback(() => {
// 최소 드래그 거리 확인 (5픽셀)
const minDragDistance = 5;
const dragDistance = Math.sqrt(
Math.pow(selectionDrag.currentPoint.x - selectionDrag.startPoint.x, 2) +
Math.pow(selectionDrag.currentPoint.y - selectionDrag.startPoint.y, 2),
);
const wasActualDrag = dragDistance > minDragDistance;
setSelectionDrag({
isSelecting: false,
startPoint: { x: 0, y: 0, z: 1 },
currentPoint: { x: 0, y: 0, z: 1 },
wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시
});
// 짧은 시간 후 wasSelecting을 false로 리셋
setTimeout(() => {
setSelectionDrag((prev) => ({
...prev,
wasSelecting: false,
}));
}, 100);
}, [selectionDrag.currentPoint, selectionDrag.startPoint]);
// 컴포넌트 삭제 (단일/다중 선택 지원)
const deleteComponent = useCallback(() => {
// 다중 선택된 컴포넌트가 있는 경우
if (groupState.selectedComponents.length > 0) {
console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개");
let newComponents = [...layout.components];
// 각 선택된 컴포넌트를 삭제 처리
groupState.selectedComponents.forEach((componentId) => {
const component = layout.components.find((comp) => comp.id === componentId);
if (!component) return;
if (component.type === "group") {
// 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원
const childComponents = newComponents.filter((comp) => comp.parentId === component.id);
const restoredChildren = restoreAbsolutePositions(childComponents, component.position);
newComponents = newComponents
.map((comp) => {
if (comp.parentId === component.id) {
// 복원된 절대 위치로 업데이트
const restoredChild = restoredChildren.find((restored) => restored.id === comp.id);
return restoredChild || { ...comp, parentId: undefined };
}
2025-09-02 16:18:38 +09:00
return comp;
})
.filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거
} else {
// 일반 컴포넌트 삭제
newComponents = newComponents.filter((comp) => comp.id !== component.id);
2025-09-01 17:57:52 +09:00
}
2025-09-02 16:18:38 +09:00
});
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 선택 상태 초기화
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);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
newComponents = layout.components
.map((comp) => {
if (comp.parentId === selectedComponent.id) {
// 복원된 절대 위치로 업데이트
const restoredChild = restoredChildren.find((restored) => restored.id === comp.id);
return restoredChild || { ...comp, parentId: undefined };
2025-09-01 11:48:12 +09:00
}
2025-09-01 15:22:47 +09:00
return comp;
2025-09-02 16:18:38 +09:00
})
.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, // 붙여넣기 시 부모 관계 해제
2025-09-01 15:22:47 +09:00
};
2025-09-02 16:18:38 +09:00
newComponents.push(newComponent);
});
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
};
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
// 붙여넣은 컴포넌트들을 선택 상태로 만들기
setGroupState((prev) => ({
...prev,
selectedComponents: newComponents.map((comp) => comp.id),
}));
console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]);
2025-09-03 11:32:09 +09:00
// 그룹 생성 (임시 비활성화)
2025-09-01 15:22:47 +09:00
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
2025-09-03 11:32:09 +09:00
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),
};
}),
});
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
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();
// 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요)
2025-09-01 15:57:49 +09:00
const relativeChildren = calculateRelativePositions(
2025-09-03 11:32:09 +09:00
scaledComponents,
groupPosition,
"temp", // 임시 그룹 ID
2025-09-01 15:57:49 +09:00
);
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
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,
}));
2025-09-01 15:22:47 +09:00
const newLayout = {
...layout,
components: [
2025-09-01 15:57:49 +09:00
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
2025-09-01 15:22:47 +09:00
groupComponent,
2025-09-03 11:32:09 +09:00
...finalChildren,
2025-09-01 15:22:47 +09:00
],
};
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-03 11:32:09 +09:00
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}개 컴포넌트)`);
2025-09-01 15:22:47 +09:00
},
2025-09-03 11:32:09 +09:00
[layout, saveToHistory, gridInfo],
2025-09-01 15:22:47 +09:00
);
2025-09-02 16:18:38 +09:00
// 그룹 생성 함수 (다이얼로그 표시)
const createGroup = useCallback(() => {
if (groupState.selectedComponents.length < 2) {
toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다.");
return;
}
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
console.log("🔄 그룹 생성 다이얼로그 표시");
setShowGroupCreateDialog(true);
}, [groupState.selectedComponents]);
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
// 그룹 해제 함수 (임시 비활성화)
2025-09-02 16:18:38 +09:00
const ungroupComponents = useCallback(() => {
2025-09-03 11:32:09 +09:00
console.log("그룹 해제 기능이 임시 비활성화되었습니다.");
toast.info("그룹 해제 기능이 임시 비활성화되었습니다.");
return;
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
const groupId = selectedComponent.id;
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 자식 컴포넌트들의 절대 위치 복원
const childComponents = layout.components.filter((comp) => comp.parentId === groupId);
const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 자식 컴포넌트들의 위치 복원 및 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); // 그룹 컴포넌트 제거
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 선택 상태 초기화
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}, [selectedComponent, layout, saveToHistory]);
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화
useEffect(() => {
let animationFrameId: number;
const handleMouseMove = (e: MouseEvent) => {
if (dragState.isDragging) {
// requestAnimationFrame으로 부드러운 애니메이션
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
animationFrameId = requestAnimationFrame(() => {
updateDragPosition(e);
});
} else if (selectionDrag.isSelecting) {
updateSelectionDrag(e);
}
};
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const handleMouseUp = () => {
if (dragState.isDragging) {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
endDrag();
} else if (selectionDrag.isSelecting) {
endSelectionDrag();
2025-09-01 18:42:59 +09:00
}
2025-09-02 16:18:38 +09:00
};
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);
2025-09-01 18:42:59 +09:00
};
}
2025-09-02 16:18:38 +09:00
}, [
dragState.isDragging,
selectionDrag.isSelecting,
updateDragPosition,
endDrag,
updateSelectionDrag,
endSelectionDrag,
]);
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 캔버스 크기 초기화 및 리사이즈 이벤트 처리
2025-09-01 18:42:59 +09:00
useEffect(() => {
2025-09-02 16:18:38 +09:00
const updateCanvasSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
};
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 초기 크기 설정
updateCanvasSize();
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 리사이즈 이벤트 리스너
window.addEventListener("resize", updateCanvasSize);
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
return () => window.removeEventListener("resize", updateCanvasSize);
2025-09-01 11:48:12 +09:00
}, []);
2025-09-02 16:18:38 +09:00
// 컴포넌트 마운트 후 캔버스 크기 업데이트
useEffect(() => {
const timer = setTimeout(() => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
}, 100);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
return () => clearTimeout(timer);
}, [selectedScreen]);
2025-09-02 16:18:38 +09:00
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
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;
2025-09-03 11:32:09 +09:00
const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase();
2025-09-02 16:18:38 +09:00
return ctrlMatch && shiftMatch && keyMatch;
});
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
if (isBrowserShortcut) {
console.log("🚫 브라우저 기본 단축키 차단:", e.key);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
2025-09-02 16:18:38 +09:00
// ✅ 애플리케이션 전용 단축키 처리
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 1. 그룹 관련 단축키
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
2025-09-02 16:18:38 +09:00
console.log("🔄 그룹 생성 단축키");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
if (groupState.selectedComponents.length >= 2) {
console.log("✅ 그룹 생성 실행");
createGroup();
2025-09-01 15:22:47 +09:00
} else {
2025-09-02 16:18:38 +09:00
console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)");
}
return false;
}
2025-09-02 11:16:40 +09:00
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") {
2025-09-02 16:18:38 +09:00
console.log("🔄 그룹 해제 단축키");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
if (selectedComponent && selectedComponent.type === "group") {
console.log("✅ 그룹 해제 실행");
ungroupComponents();
} else {
console.log("⚠️ 선택된 그룹이 없음");
}
return false;
}
2025-09-02 16:18:38 +09:00
// 2. 전체 선택 (애플리케이션 내에서만)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") {
2025-09-02 16:18:38 +09:00
console.log("🔄 전체 선택 (애플리케이션 내)");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const allComponentIds = layout.components.map((comp) => comp.id);
setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds }));
return false;
}
2025-09-02 16:18:38 +09:00
// 3. 실행취소/다시실행
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) {
2025-09-02 16:18:38 +09:00
console.log("🔄 실행취소");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
undo();
return false;
}
2025-09-02 16:18:38 +09:00
if (
2025-09-03 11:32:09 +09:00
((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z")
2025-09-02 16:18:38 +09:00
) {
console.log("🔄 다시실행");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
redo();
return false;
}
2025-09-02 16:18:38 +09:00
// 4. 복사 (컴포넌트 복사)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") {
2025-09-02 16:18:38 +09:00
console.log("🔄 컴포넌트 복사");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
copyComponent();
return false;
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
// 5. 붙여넣기 (컴포넌트 붙여넣기)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
2025-09-02 16:18:38 +09:00
console.log("🔄 컴포넌트 붙여넣기");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
pasteComponent();
return false;
}
2025-09-02 16:18:38 +09:00
// 6. 삭제 (단일/다중 선택 지원)
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
console.log("🗑️ 컴포넌트 삭제 (단축키)");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
deleteComponent();
return false;
}
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 7. 선택 해제
if (e.key === "Escape") {
console.log("🔄 선택 해제");
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false }));
return false;
}
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") {
2025-09-02 16:18:38 +09:00
console.log("💾 레이아웃 저장");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// 레이아웃 저장 실행
if (layout.components.length > 0 && selectedScreen?.screenId) {
setIsSaving(true);
try {
await screenApi.saveLayout(selectedScreen.screenId, layout);
toast.success("레이아웃이 저장되었습니다.");
} catch (error) {
console.error("레이아웃 저장 실패:", error);
toast.error("레이아웃 저장에 실패했습니다.");
} finally {
setIsSaving(false);
}
} else {
console.log("⚠️ 저장할 컴포넌트가 없습니다");
toast.warning("저장할 컴포넌트가 없습니다.");
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
return false;
}
2025-09-02 16:18:38 +09:00
};
// 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">
2025-09-02 16:18:38 +09:00
<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>
);
}
2025-09-01 11:48:12 +09:00
return (
2025-09-02 16:18:38 +09:00
<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("미리보기 기능은 준비 중입니다.");
2025-09-01 16:40:24 +09:00
}}
2025-09-02 16:18:38 +09:00
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
2025-09-01 16:40:24 +09:00
}
}}
2025-09-02 16:18:38 +09:00
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
startSelectionDrag(e);
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db",
opacity: layout.gridSettings?.gridOpacity || 0.5,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
// 드래그 중 시각적 피드백 (다중 선택 지원)
const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
const isBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
let displayComponent = component;
if (isBeingDragged) {
if (isDraggingThis) {
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
displayComponent = {
...component,
position: dragState.currentPosition,
style: {
...component.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
},
};
} else {
// 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === component.id);
if (originalComponent) {
const deltaX = dragState.currentPosition.x - dragState.originalPosition.x;
const deltaY = dragState.currentPosition.y - dragState.originalPosition.y;
displayComponent = {
...component,
position: {
x: originalComponent.position.x + deltaX,
y: originalComponent.position.y + deltaY,
z: originalComponent.position.z || 1,
} as Position,
style: {
...component.style,
opacity: 0.8,
transition: "none",
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
},
};
}
}
}
2025-09-02 16:18:38 +09:00
return (
<RealtimePreview
2025-09-03 11:32:09 +09:00
key={`${component.id}-${component.type === "widget" ? JSON.stringify((component as any).webTypeConfig) : ""}`}
2025-09-02 16:18:38 +09:00
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
onClick={(e) => handleComponentClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
{children.map((child) => {
// 자식 컴포넌트에도 드래그 피드백 적용
const isChildDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === child.id;
const isChildBeingDragged =
dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === child.id);
let displayChild = child;
if (isChildBeingDragged) {
if (isChildDraggingThis) {
// 주 드래그 자식 컴포넌트
displayChild = {
...child,
position: dragState.currentPosition,
style: {
...child.style,
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 9999,
2025-09-02 11:16:40 +09:00
},
2025-09-02 16:18:38 +09:00
};
} 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,
},
};
}
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
}
2025-09-02 16:18:38 +09:00
return (
<RealtimePreview
2025-09-03 11:32:09 +09:00
key={`${child.id}-${child.type === "widget" ? JSON.stringify((child as any).webTypeConfig) : ""}`}
2025-09-02 16:18:38 +09:00
component={displayChild}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
);
})}
</RealtimePreview>
);
})}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="pointer-events-none absolute"
style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
border: "2px dashed #3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
borderRadius: "4px",
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
2025-09-03 15:23:12 +09:00
<p className="text-sm"> / 릿 </p>
<p className="mt-2 text-xs">단축키: T(), M(릿), P(), S(), R(), D()</p>
2025-09-02 16:18:38 +09:00
<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>
2025-09-02 16:18:38 +09:00
)}
</div>
2025-09-02 16:18:38 +09:00
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
2025-09-03 11:32:09 +09:00
width={380}
height={700}
autoHeight={false}
2025-09-02 16:18:38 +09:00
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
/>
</FloatingPanel>
2025-09-03 15:23:12 +09:00
<FloatingPanel
id="templates"
title="템플릿"
isOpen={panelStates.templates?.isOpen || false}
onClose={() => closePanel("templates")}
position="left"
width={380}
height={700}
autoHeight={false}
>
<TemplatesPanel
onDragStart={(e, template) => {
const dragData = {
type: "template",
template,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
/>
</FloatingPanel>
2025-09-02 16:18:38 +09:00
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
2025-09-03 11:32:09 +09:00
width={360}
height={400}
autoHeight={true}
2025-09-02 16:18:38 +09:00
>
<PropertiesPanel
selectedComponent={selectedComponent || undefined}
2025-09-03 15:23:12 +09:00
tables={tables}
2025-09-02 16:18:38 +09:00
onUpdateProperty={(path: string, value: any) => {
2025-09-03 15:23:12 +09:00
console.log("🔧 속성 업데이트 요청:", {
componentId: selectedComponent?.id,
componentType: selectedComponent?.type,
path,
value: typeof value === "object" ? JSON.stringify(value).substring(0, 100) + "..." : value,
});
2025-09-02 16:18:38 +09:00
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"
2025-09-03 11:32:09 +09:00
width={360}
2025-09-02 16:18:38 +09:00
height={400}
2025-09-03 11:32:09 +09:00
autoHeight={true}
2025-09-02 16:18:38 +09:00
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
2025-09-02 11:16:40 +09:00
/>
</div>
2025-09-02 16:18:38 +09:00
) : (
<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"
2025-09-03 11:32:09 +09:00
width={320}
height={400}
autoHeight={true}
2025-09-02 16:18:38 +09:00
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={updateGridSettings}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
updateGridSettings(defaultSettings);
}}
/>
</FloatingPanel>
2025-09-03 11:32:09 +09:00
<FloatingPanel
id="detailSettings"
title="상세 설정"
isOpen={panelStates.detailSettings?.isOpen || false}
onClose={() => closePanel("detailSettings")}
position="right"
width={400}
height={400}
autoHeight={true}
>
<DetailSettingsPanel
selectedComponent={selectedComponent || undefined}
onUpdateProperty={(componentId: string, path: string, value: any) => {
updateComponentProperty(componentId, path, value);
}}
/>
</FloatingPanel>
2025-09-02 16:18:38 +09:00
{/* 그룹 생성 툴바 (필요시) */}
2025-09-03 11:32:09 +09:00
{false && groupState.selectedComponents.length > 1 && (
2025-09-02 16:18:38 +09:00
<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>
2025-09-02 16:18:38 +09:00
)}
2025-09-01 11:48:12 +09:00
</div>
);
}