4006 lines
152 KiB
TypeScript
4006 lines
152 KiB
TypeScript
"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 { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||
import { initializeComponents } from "@/lib/registry/components";
|
||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||
|
||
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 [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
||
|
||
// 해상도 설정 상태
|
||
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 [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
||
|
||
// 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회)
|
||
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
|
||
if (!selectedScreen?.screenId) return;
|
||
|
||
// console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
||
|
||
try {
|
||
// 실제 DB에서 화면의 모든 파일 정보 조회
|
||
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||
|
||
if (!fileResponse.success) {
|
||
// console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
||
return;
|
||
}
|
||
|
||
const { componentFiles } = fileResponse;
|
||
|
||
if (typeof window !== 'undefined') {
|
||
// 전역 파일 상태 초기화
|
||
const globalFileState: {[key: string]: any[]} = {};
|
||
let restoredCount = 0;
|
||
|
||
// DB에서 조회한 파일 정보를 전역 상태로 복원
|
||
Object.keys(componentFiles).forEach(componentId => {
|
||
const files = componentFiles[componentId];
|
||
if (files && files.length > 0) {
|
||
globalFileState[componentId] = files;
|
||
restoredCount++;
|
||
|
||
// localStorage에도 백업
|
||
const backupKey = `fileComponent_${componentId}_files`;
|
||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||
|
||
console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", {
|
||
componentId: componentId,
|
||
fileCount: files.length,
|
||
files: files.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||
});
|
||
}
|
||
});
|
||
|
||
// 전역 상태 업데이트
|
||
(window as any).globalFileState = globalFileState;
|
||
|
||
// 모든 파일 컴포넌트에 복원 완료 이벤트 발생
|
||
Object.keys(globalFileState).forEach(componentId => {
|
||
const files = globalFileState[componentId];
|
||
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||
detail: {
|
||
componentId: componentId,
|
||
files: files,
|
||
fileCount: files.length,
|
||
timestamp: Date.now(),
|
||
isRestore: true
|
||
}
|
||
});
|
||
window.dispatchEvent(syncEvent);
|
||
});
|
||
|
||
console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", {
|
||
totalComponents: components.length,
|
||
restoredFileComponents: restoredCount,
|
||
totalFiles: fileResponse.totalFiles,
|
||
globalFileState: Object.keys(globalFileState).map(id => ({
|
||
id,
|
||
fileCount: globalFileState[id]?.length || 0
|
||
}))
|
||
});
|
||
|
||
if (restoredCount > 0) {
|
||
toast.success(`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
||
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
||
}
|
||
}, [selectedScreen?.screenId]);
|
||
|
||
// 드래그 선택 상태
|
||
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?.screenId) {
|
||
restoreScreenFiles();
|
||
}
|
||
}, [selectedScreen?.screenId]);
|
||
|
||
// 화면의 모든 파일 컴포넌트 파일 복원
|
||
const restoreScreenFiles = useCallback(async () => {
|
||
if (!selectedScreen?.screenId) return;
|
||
|
||
try {
|
||
// console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
||
|
||
// 해당 화면의 모든 파일 조회
|
||
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||
|
||
if (response.success && response.componentFiles) {
|
||
// console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||
|
||
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
||
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
||
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
|
||
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
|
||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||
const currentGlobalFiles = globalFileState[componentId] || [];
|
||
|
||
let currentLocalStorageFiles: any[] = [];
|
||
if (typeof window !== 'undefined') {
|
||
try {
|
||
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
|
||
if (storedFiles) {
|
||
currentLocalStorageFiles = JSON.parse(storedFiles);
|
||
}
|
||
} catch (e) {
|
||
// console.warn("localStorage 파일 파싱 실패:", e);
|
||
}
|
||
}
|
||
|
||
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
|
||
let finalFiles = serverFiles;
|
||
if (currentGlobalFiles.length > 0) {
|
||
finalFiles = currentGlobalFiles;
|
||
// console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
|
||
} else if (currentLocalStorageFiles.length > 0) {
|
||
finalFiles = currentLocalStorageFiles;
|
||
// console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
|
||
} else {
|
||
// console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
||
}
|
||
|
||
// 전역 상태에 파일 저장
|
||
globalFileState[componentId] = finalFiles;
|
||
if (typeof window !== 'undefined') {
|
||
(window as any).globalFileState = globalFileState;
|
||
}
|
||
|
||
// localStorage에도 백업
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
|
||
}
|
||
}
|
||
});
|
||
|
||
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
|
||
setLayout(prevLayout => {
|
||
const updatedComponents = prevLayout.components.map(comp => {
|
||
// 🎯 전역 상태에서 최신 파일 정보 가져오기
|
||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||
const finalFiles = globalFileState[comp.id] || [];
|
||
|
||
if (finalFiles.length > 0) {
|
||
return {
|
||
...comp,
|
||
uploadedFiles: finalFiles,
|
||
lastFileUpdate: Date.now()
|
||
};
|
||
}
|
||
return comp;
|
||
});
|
||
|
||
return {
|
||
...prevLayout,
|
||
components: updatedComponents
|
||
};
|
||
});
|
||
|
||
// console.log("✅ 화면 파일 복원 완료");
|
||
}
|
||
} catch (error) {
|
||
// console.error("❌ 화면 파일 복원 오류:", error);
|
||
}
|
||
}, [selectedScreen?.screenId]);
|
||
|
||
// 전역 파일 상태 변경 이벤트 리스너
|
||
useEffect(() => {
|
||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||
// console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
|
||
setForceRenderTrigger(prev => prev + 1);
|
||
};
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||
|
||
return () => {
|
||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||
};
|
||
}
|
||
}, []);
|
||
|
||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||
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) {
|
||
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
|
||
if (typeof window !== 'undefined') {
|
||
(window as any).__CURRENT_SCREEN_ID__ = 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);
|
||
|
||
// 파일 컴포넌트 데이터 복원 (비동기)
|
||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||
}
|
||
} 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,
|
||
};
|
||
case "table":
|
||
return {
|
||
tableName: "",
|
||
displayMode: "table" as const,
|
||
showHeader: true,
|
||
showFooter: true,
|
||
pagination: {
|
||
enabled: true,
|
||
pageSize: 10,
|
||
showPageSizeSelector: true,
|
||
showPageInfo: true,
|
||
showFirstLast: true,
|
||
},
|
||
columns: [],
|
||
searchable: true,
|
||
sortable: true,
|
||
filterable: true,
|
||
exportable: true,
|
||
};
|
||
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 handleFileComponentUpdate = useCallback(
|
||
(updates: Partial<ComponentData>) => {
|
||
if (!selectedFileComponent) return;
|
||
|
||
const updatedComponents = layout.components.map(comp =>
|
||
comp.id === selectedFileComponent.id
|
||
? { ...comp, ...updates }
|
||
: comp
|
||
);
|
||
|
||
const newLayout = { ...layout, components: updatedComponents };
|
||
setLayout(newLayout);
|
||
saveToHistory(newLayout);
|
||
|
||
// selectedFileComponent도 업데이트
|
||
setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null);
|
||
|
||
// selectedComponent가 같은 컴포넌트라면 업데이트
|
||
if (selectedComponent?.id === selectedFileComponent.id) {
|
||
setSelectedComponent(prev => prev ? { ...prev, ...updates } : null);
|
||
}
|
||
},
|
||
[selectedFileComponent, layout, saveToHistory, selectedComponent],
|
||
);
|
||
|
||
// 파일첨부 모달 닫기
|
||
const handleFileAttachmentModalClose = useCallback(() => {
|
||
setShowFileAttachmentModal(false);
|
||
setSelectedFileComponent(null);
|
||
}, []);
|
||
|
||
// 컴포넌트 더블클릭 처리
|
||
const handleComponentDoubleClick = useCallback(
|
||
(component: ComponentData, event?: React.MouseEvent) => {
|
||
event?.stopPropagation();
|
||
|
||
// 파일 컴포넌트인 경우 상세 모달 열기
|
||
if (component.type === "file") {
|
||
setSelectedFileComponent(component);
|
||
setShowFileAttachmentModal(true);
|
||
return;
|
||
}
|
||
|
||
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
||
// console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 컴포넌트 클릭 처리 (다중선택 지원)
|
||
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-gradient-to-br from-gray-50 to-slate-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-gradient-to-br from-gray-50 to-slate-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-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경
|
||
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: 50,
|
||
},
|
||
};
|
||
} 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: 40, // 주 컴포넌트보다 약간 낮게
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
|
||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||
const globalFiles = globalFileState[component.id] || [];
|
||
const componentFiles = (component as any).uploadedFiles || [];
|
||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||
|
||
return (
|
||
<RealtimePreview
|
||
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
||
component={displayComponent}
|
||
isSelected={
|
||
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||
}
|
||
isDesignMode={true} // 편집 모드로 설정
|
||
onClick={(e) => handleComponentClick(component, e)}
|
||
onDoubleClick={(e) => handleComponentDoubleClick(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: 50,
|
||
},
|
||
};
|
||
} 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}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
|
||
component={relativeChildComponent}
|
||
isSelected={
|
||
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
|
||
}
|
||
isDesignMode={true} // 편집 모드로 설정
|
||
onClick={(e) => handleComponentClick(child, e)}
|
||
onDoubleClick={(e) => handleComponentDoubleClick(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}
|
||
/>
|
||
|
||
{/* 파일첨부 상세 모달 */}
|
||
<FileAttachmentDetailModal
|
||
isOpen={showFileAttachmentModal}
|
||
onClose={handleFileAttachmentModalClose}
|
||
component={selectedFileComponent}
|
||
onUpdateComponent={handleFileComponentUpdate}
|
||
screenId={selectedScreen?.screenId}
|
||
tableName={selectedScreen?.tableName}
|
||
recordId={selectedScreen?.screenId}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|