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

5015 lines
200 KiB
TypeScript
Raw Normal View History

2025-09-01 11:48:12 +09:00
"use client";
2025-09-01 16:40:24 +09:00
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
2025-10-22 17:19:47 +09:00
import { Database, Cog } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2025-10-24 10:37:02 +09:00
import { Button } from "@/components/ui/button";
2025-09-01 11:48:12 +09:00
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
TableInfo,
2025-09-02 11:16:40 +09:00
Position,
2025-09-02 16:18:38 +09:00
ColumnInfo,
GridSettings,
ScreenResolution,
SCREEN_RESOLUTIONS,
2025-09-01 11:48:12 +09:00
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
2025-09-12 14:24:25 +09:00
import { getComponentIdFromWebType } from "@/lib/utils/webTypeMapping";
2025-09-01 15:22:47 +09:00
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
} from "@/lib/utils/groupingUtils";
2025-09-02 11:16:40 +09:00
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
2025-09-03 11:32:09 +09:00
updateSizeFromGridColumns,
adjustGridColumnsFromSize,
alignGroupChildrenToGrid,
calculateOptimalGroupSize,
normalizeGroupChildPositions,
2025-09-03 15:23:12 +09:00
calculateWidthFromColumns,
2025-09-02 11:16:40 +09:00
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
2025-09-01 15:22:47 +09:00
import { GroupingToolbar } from "./GroupingToolbar";
2025-09-02 16:18:38 +09:00
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
2025-09-01 18:42:59 +09:00
import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
2025-09-12 14:24:25 +09:00
import { initializeComponents } from "@/lib/registry/components";
2025-09-26 17:12:03 +09:00
import { ScreenFileAPI } from "@/lib/api/screenFile";
2025-10-13 18:28:03 +09:00
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
2025-09-01 15:22:47 +09:00
2025-09-01 11:48:12 +09:00
import StyleEditor from "./StyleEditor";
2025-09-10 14:09:32 +09:00
import { RealtimePreview } from "./RealtimePreviewDynamic";
2025-09-02 16:18:38 +09:00
import FloatingPanel from "./FloatingPanel";
2025-10-24 10:37:02 +09:00
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
2025-09-02 16:18:38 +09:00
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
2025-09-03 15:23:12 +09:00
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
import { ComponentsPanel } from "./panels/ComponentsPanel";
2025-09-02 16:18:38 +09:00
import PropertiesPanel from "./panels/PropertiesPanel";
2025-09-03 11:32:09 +09:00
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
2025-09-02 16:18:38 +09:00
import GridPanel from "./panels/GridPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
2025-09-02 16:18:38 +09:00
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
2025-10-24 10:37:02 +09:00
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import {
areAllButtons,
generateGroupId,
groupButtons,
ungroupButtons,
findAllButtonGroups,
} from "@/lib/utils/flowButtonGroupUtils";
import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog";
2025-10-28 15:39:22 +09:00
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
2025-09-01 11:48:12 +09:00
2025-10-15 10:24:33 +09:00
// 새로운 통합 UI 컴포넌트
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
import { SlimToolbar } from "./toolbar/SlimToolbar";
import { UnifiedPropertiesPanel } from "./panels/UnifiedPropertiesPanel";
2025-09-10 18:36:28 +09:00
2025-09-11 18:38:28 +09:00
// 컴포넌트 초기화 (새 시스템)
import "@/lib/registry/components";
// 성능 최적화 도구 초기화 (필요시 사용)
import "@/lib/registry/utils/performanceOptimizer";
2025-09-01 11:48:12 +09:00
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
2025-09-01 11:48:12 +09:00
}
2025-10-28 17:33:03 +09:00
// 패널 설정 (통합 패널 1개)
2025-09-02 16:18:38 +09:00
const panelConfigs: PanelConfig[] = [
2025-10-28 17:33:03 +09:00
// 통합 패널 (컴포넌트 + 편집 탭)
2025-09-03 15:23:12 +09:00
{
2025-10-28 17:33:03 +09:00
id: "unified",
title: "패널",
2025-09-10 18:36:28 +09:00
defaultPosition: "left",
2025-10-27 16:40:59 +09:00
defaultWidth: 240,
2025-09-10 18:36:28 +09:00
defaultHeight: 700,
2025-09-02 16:18:38 +09:00
shortcutKey: "p",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
2025-09-02 16:18:38 +09:00
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
2025-09-01 11:48:12 +09:00
const [layout, setLayout] = useState<LayoutData>({
components: [],
2025-09-02 16:18:38 +09:00
gridSettings: {
columns: 12,
gap: 16,
2025-10-14 18:07:38 +09:00
padding: 0,
2025-09-02 16:18:38 +09:00
snapToGrid: true,
showGrid: false, // 기본값 false로 변경
2025-09-02 16:18:38 +09:00
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
2025-09-01 11:48:12 +09:00
});
2025-09-01 18:42:59 +09:00
const [isSaving, setIsSaving] = useState(false);
2025-09-01 15:22:47 +09:00
// 메뉴 할당 모달 상태
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
);
2025-09-02 16:18:38 +09:00
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
2025-09-01 17:57:52 +09:00
2025-10-28 17:33:03 +09:00
// 컴포넌트 선택 시 통합 패널 자동 열기
2025-10-15 10:24:33 +09:00
const handleComponentSelect = useCallback(
(component: ComponentData | null) => {
setSelectedComponent(component);
2025-10-28 17:33:03 +09:00
// 컴포넌트가 선택되면 통합 패널 자동 열기
2025-10-15 10:24:33 +09:00
if (component) {
2025-10-28 17:33:03 +09:00
openPanel("unified");
2025-10-15 10:24:33 +09:00
}
},
[openPanel],
);
2025-09-02 16:18:38 +09:00
// 클립보드 상태
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 드래그 상태
const [dragState, setDragState] = useState({
2025-09-01 11:48:12 +09:00
isDragging: false,
draggedComponent: null as ComponentData | null,
2025-09-02 16:18:38 +09:00
draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열
originalPosition: { x: 0, y: 0, z: 1 },
currentPosition: { x: 0, y: 0, z: 1 },
2025-09-01 16:40:24 +09:00
grabOffset: { x: 0, y: 0 },
2025-09-02 16:18:38 +09:00
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
2025-09-01 11:48:12 +09:00
});
2025-09-02 16:18:38 +09:00
2025-10-15 10:44:05 +09:00
// Pan 모드 상태 (스페이스바 + 드래그)
const [isPanMode, setIsPanMode] = useState(false);
const [panState, setPanState] = useState({
isPanning: false,
startX: 0,
startY: 0,
2025-10-22 17:19:47 +09:00
outerScrollLeft: 0,
outerScrollTop: 0,
innerScrollLeft: 0,
innerScrollTop: 0,
2025-10-15 10:44:05 +09:00
});
const canvasContainerRef = useRef<HTMLDivElement>(null);
// Zoom 상태
const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
const MIN_ZOOM = 0.1; // 10%
const MAX_ZOOM = 3; // 300%
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
2025-09-26 17:12:03 +09:00
// 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회)
2025-10-13 18:28:03 +09:00
const restoreFileComponentsData = useCallback(
async (components: ComponentData[]) => {
if (!selectedScreen?.screenId) return;
2025-09-26 17:12:03 +09:00
2025-10-13 18:28:03 +09:00
// console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
try {
// 실제 DB에서 화면의 모든 파일 정보 조회
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
if (!fileResponse.success) {
// console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
return;
}
const { componentFiles } = fileResponse;
2025-09-26 17:12:03 +09:00
2025-10-13 18:28:03 +09:00
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 })),
});
2025-09-26 17:12:03 +09:00
}
});
2025-10-13 18:28:03 +09:00
// 전역 상태 업데이트
(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);
});
2025-09-26 17:12:03 +09:00
2025-10-13 18:28:03 +09:00
if (restoredCount > 0) {
toast.success(
`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`,
);
}
2025-09-26 17:12:03 +09:00
}
2025-10-13 18:28:03 +09:00
} catch (error) {
// console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
2025-09-26 17:12:03 +09:00
}
2025-10-13 18:28:03 +09:00
},
[selectedScreen?.screenId],
);
2025-09-26 17:12:03 +09:00
2025-09-02 16:18:38 +09:00
// 드래그 선택 상태
const [selectionDrag, setSelectionDrag] = useState({
isSelecting: false,
startPoint: { x: 0, y: 0, z: 1 },
currentPoint: { x: 0, y: 0, z: 1 },
wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적
2025-09-01 11:48:12 +09:00
});
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
2025-09-02 11:16:40 +09:00
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
2025-09-02 16:18:38 +09:00
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
2025-09-02 11:16:40 +09:00
const gridInfo = useMemo(() => {
if (!layout.gridSettings) return null;
// 캔버스 크기 계산 (해상도 설정 우선)
let width = screenResolution.width;
let height = screenResolution.height;
// 해상도가 설정되지 않은 경우 기본값 사용
if (!width || !height) {
width = canvasSize.width || window.innerWidth - 100;
height = canvasSize.height || window.innerHeight - 200;
}
2025-09-02 11:16:40 +09:00
2025-09-04 17:01:07 +09:00
const newGridInfo = calculateGridInfo(width, height, {
2025-09-02 16:18:38 +09:00
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
2025-09-04 17:01:07 +09:00
return newGridInfo;
}, [layout.gridSettings, screenResolution]);
2025-09-02 16:18:38 +09:00
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
// 캔버스 크기는 해상도 크기 사용
const width = screenResolution.width;
const height = screenResolution.height;
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const lines = generateGridLines(width, height, {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
});
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 수직선과 수평선을 하나의 배열로 합치기
const allLines = [
...lines.verticalLines.map((pos) => ({ type: "vertical" as const, position: pos })),
...lines.horizontalLines.map((pos) => ({ type: "horizontal" as const, position: pos })),
];
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
return allLines;
}, [gridInfo, layout.gridSettings, screenResolution]);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
const placed = new Set<string>();
const collectColumns = (components: ComponentData[]) => {
components.forEach((comp) => {
const anyComp = comp as any;
// widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인
if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) {
const key = `${anyComp.tableName}.${anyComp.columnName}`;
placed.add(key);
}
// 자식 컴포넌트도 확인 (재귀)
if (comp.children && comp.children.length > 0) {
collectColumns(comp.children);
}
});
};
collectColumns(layout.components);
return placed;
}, [layout.components]);
2025-09-02 16:18:38 +09:00
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(newLayout);
return newHistory.slice(-50); // 최대 50개까지만 저장
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
},
[historyIndex],
);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 실행취소
const undo = useCallback(() => {
setHistoryIndex((prevIndex) => {
if (prevIndex > 0) {
const newIndex = prevIndex - 1;
setHistory((prevHistory) => {
if (prevHistory[newIndex]) {
setLayout(prevHistory[newIndex]);
}
return prevHistory;
});
return newIndex;
}
return prevIndex;
});
}, []);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 다시실행
const redo = useCallback(() => {
setHistoryIndex((prevIndex) => {
let newIndex = prevIndex;
setHistory((prevHistory) => {
if (prevIndex < prevHistory.length - 1) {
newIndex = prevIndex + 1;
if (prevHistory[newIndex]) {
setLayout(prevHistory[newIndex]);
}
}
return prevHistory;
});
return newIndex;
});
}, []);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
2025-10-21 17:32:54 +09:00
// 🔥 함수형 업데이트로 변경하여 최신 layout 사용
setLayout((prevLayout) => {
const targetComponent = prevLayout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
2025-10-29 11:26:00 +09:00
// 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용
const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign";
let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만
if (isGroupSetting && targetComponent) {
const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig;
const currentGroupId = flowConfig?.groupId;
if (currentGroupId) {
// 같은 그룹의 모든 버튼 찾기
affectedComponents = prevLayout.components
.filter((comp) => {
const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig;
return compConfig?.groupId === currentGroupId && compConfig?.enabled;
})
.map((comp) => comp.id);
console.log("🔄 그룹 설정 일괄 적용:", {
groupId: currentGroupId,
setting: path.split(".").pop(),
value,
affectedButtons: affectedComponents,
});
}
}
2025-10-21 17:32:54 +09:00
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
const 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;
}
2025-09-11 12:22:39 +09:00
2025-10-21 17:32:54 +09:00
console.log("📐 레이아웃 이동 감지:", {
layoutId: componentId,
oldPosition,
newPosition,
positionDelta,
});
}
2025-09-11 12:22:39 +09:00
2025-10-21 17:32:54 +09:00
const pathParts = path.split(".");
const updatedComponents = prevLayout.components.map((comp) => {
2025-10-29 11:26:00 +09:00
// 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용
const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId;
if (!shouldUpdate) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
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,
});
2025-09-11 12:22:39 +09:00
return {
...comp,
position: {
...comp.position,
x: comp.position.x + positionDelta.x,
y: comp.position.y + positionDelta.y,
},
};
}
2025-09-11 12:22:39 +09:00
}
return comp;
2025-09-11 12:22:39 +09:00
}
2025-09-02 11:16:40 +09:00
// 중첩 경로를 고려한 안전한 복사
const newComp = { ...comp };
2025-09-02 11:16:40 +09:00
// 경로를 따라 내려가면서 각 레벨을 새 객체로 복사
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
const key = pathParts[i];
2025-09-02 11:16:40 +09:00
// 다음 레벨이 없거나 객체가 아니면 새 객체 생성
if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) {
current[key] = {};
} else {
// 기존 객체를 복사하여 불변성 유지
current[key] = { ...current[key] };
}
current = current[key];
}
2025-09-03 11:32:09 +09:00
// 최종 값 설정
const finalKey = pathParts[pathParts.length - 1];
current[finalKey] = value;
2025-09-03 11:32:09 +09:00
// gridColumns 변경 시 크기 자동 업데이트
if (path === "gridColumns" && gridInfo) {
const updatedSize = updateSizeFromGridColumns(newComp, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = updatedSize;
2025-09-03 11:32:09 +09:00
}
2025-09-04 17:01:07 +09:00
// 크기 변경 시 격자 스냅 적용 (그룹 컴포넌트 제외)
if (
(path === "size.width" || path === "size.height") &&
prevLayout.gridSettings?.snapToGrid &&
gridInfo &&
newComp.type !== "group"
) {
// 현재 해상도에 맞는 격자 정보로 스냅 적용
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
const snappedSize = snapSizeToGrid(
newComp.size,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
newComp.size = snappedSize;
2025-09-03 11:32:09 +09:00
// 크기 변경 시 gridColumns도 자동 조정
const adjustedColumns = adjustGridColumnsFromSize(
newComp,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
if (newComp.gridColumns !== adjustedColumns) {
newComp.gridColumns = adjustedColumns;
}
}
2025-09-03 11:32:09 +09:00
// gridColumns 변경 시 크기를 격자에 맞게 자동 조정
if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") {
const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
columns: prevLayout.gridSettings.columns,
gap: prevLayout.gridSettings.gap,
padding: prevLayout.gridSettings.padding,
snapToGrid: prevLayout.gridSettings.snapToGrid || false,
});
2025-09-03 11:32:09 +09:00
// gridColumns에 맞는 정확한 너비 계산
const newWidth = calculateWidthFromColumns(
newComp.gridColumns,
currentGridInfo,
prevLayout.gridSettings as GridUtilSettings,
);
2025-09-03 11:32:09 +09:00
newComp.size = {
...newComp.size,
width: newWidth,
2025-09-03 11:32:09 +09:00
};
}
2025-09-03 11:32:09 +09:00
// 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함)
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,
2025-09-03 11:32:09 +09:00
});
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
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));
2025-11-04 17:44:10 +09:00
// Y 좌표는 10px 단위로 스냅
const effectiveY = newComp.position.y - padding;
2025-11-04 17:44:10 +09:00
const rowIndex = Math.round(effectiveY / 10);
const snappedY = padding + rowIndex * 10;
// 크기도 외부 격자와 동일하게 스냅
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(10, newComp.size.height);
newComp.position = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newComp.position.z || 1,
};
newComp.size = {
width: snappedWidth,
height: snappedHeight,
};
} else if (newComp.type !== "group") {
// 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용
const snappedPosition = snapToGrid(
newComp.position,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
newComp.position = snappedPosition;
}
2025-09-03 11:32:09 +09:00
}
2025-09-02 11:16:40 +09:00
2025-10-21 17:32:54 +09:00
return newComp;
});
2025-09-02 11:16:40 +09:00
2025-10-21 17:32:54 +09:00
// 🔥 새로운 layout 생성
const newLayout = { ...prevLayout, components: updatedComponents };
2025-09-03 11:32:09 +09:00
2025-10-21 17:32:54 +09:00
saveToHistory(newLayout);
2025-10-21 17:32:54 +09:00
// selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트
setSelectedComponent((prevSelected) => {
if (prevSelected && prevSelected.id === componentId) {
const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedSelectedComponent) {
// 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함
const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent));
return newSelectedComponent;
}
}
return prevSelected;
});
2025-09-03 11:32:09 +09:00
2025-10-21 17:32:54 +09:00
// webTypeConfig 업데이트 후 레이아웃 상태 확인
if (path === "webTypeConfig") {
const updatedComponent = newLayout.components.find((c) => c.id === componentId);
console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", {
2025-09-03 15:23:12 +09:00
componentId,
2025-10-21 17:32:54 +09:00
updatedComponent: updatedComponent
? {
id: updatedComponent.id,
type: updatedComponent.type,
webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null,
}
: null,
layoutComponentsCount: newLayout.components.length,
2025-09-03 15:23:12 +09:00
timestamp: new Date().toISOString(),
});
}
2025-10-21 17:32:54 +09:00
return newLayout;
});
2025-09-02 11:16:40 +09:00
},
2025-10-21 17:32:54 +09:00
[gridInfo, saveToHistory], // 🔧 layout, selectedComponent 제거!
2025-09-02 11:16:40 +09:00
);
2025-09-12 14:24:25 +09:00
// 컴포넌트 시스템 초기화
useEffect(() => {
const initComponents = async () => {
try {
// console.log("🚀 컴포넌트 시스템 초기화 시작...");
2025-09-12 14:24:25 +09:00
await initializeComponents();
// console.log("✅ 컴포넌트 시스템 초기화 완료");
2025-09-12 14:24:25 +09:00
} catch (error) {
// console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
2025-09-12 14:24:25 +09:00
}
};
initComponents();
}, []);
2025-09-29 13:29:03 +09:00
// 화면 선택 시 파일 복원
useEffect(() => {
if (selectedScreen?.screenId) {
restoreScreenFiles();
}
}, [selectedScreen?.screenId]);
// 화면의 모든 파일 컴포넌트 파일 복원
const restoreScreenFiles = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
// console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
2025-10-13 18:28:03 +09:00
2025-09-29 13:29:03 +09:00
// 해당 화면의 모든 파일 조회
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
2025-10-13 18:28:03 +09:00
2025-09-29 13:29:03 +09:00
if (response.success && response.componentFiles) {
// console.log("📁 복원할 파일 데이터:", response.componentFiles);
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
2025-10-13 18:28:03 +09:00
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
2025-09-29 17:21:47 +09:00
const currentGlobalFiles = globalFileState[componentId] || [];
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
let currentLocalStorageFiles: any[] = [];
2025-10-13 18:28:03 +09:00
if (typeof window !== "undefined") {
2025-09-29 17:21:47 +09:00
try {
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
if (storedFiles) {
currentLocalStorageFiles = JSON.parse(storedFiles);
}
} catch (e) {
// console.warn("localStorage 파일 파싱 실패:", e);
2025-09-29 17:21:47 +09:00
}
}
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
let finalFiles = serverFiles;
if (currentGlobalFiles.length > 0) {
finalFiles = currentGlobalFiles;
// console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
2025-09-29 17:21:47 +09:00
} else if (currentLocalStorageFiles.length > 0) {
finalFiles = currentLocalStorageFiles;
// console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
2025-09-29 17:21:47 +09:00
} else {
// console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
2025-09-29 17:21:47 +09:00
}
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
// 전역 상태에 파일 저장
globalFileState[componentId] = finalFiles;
2025-10-13 18:28:03 +09:00
if (typeof window !== "undefined") {
2025-09-29 13:29:03 +09:00
(window as any).globalFileState = globalFileState;
}
2025-10-13 18:28:03 +09:00
2025-09-29 13:29:03 +09:00
// localStorage에도 백업
2025-10-13 18:28:03 +09:00
if (typeof window !== "undefined") {
2025-09-29 17:21:47 +09:00
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
2025-09-29 13:29:03 +09:00
}
}
});
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
2025-10-13 18:28:03 +09:00
setLayout((prevLayout) => {
const updatedComponents = prevLayout.components.map((comp) => {
2025-09-29 17:21:47 +09:00
// 🎯 전역 상태에서 최신 파일 정보 가져오기
2025-10-13 18:28:03 +09:00
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
2025-09-29 17:21:47 +09:00
const finalFiles = globalFileState[comp.id] || [];
2025-10-13 18:28:03 +09:00
2025-09-29 17:21:47 +09:00
if (finalFiles.length > 0) {
2025-09-29 13:29:03 +09:00
return {
...comp,
2025-09-29 17:21:47 +09:00
uploadedFiles: finalFiles,
2025-10-13 18:28:03 +09:00
lastFileUpdate: Date.now(),
2025-09-29 13:29:03 +09:00
};
}
return comp;
});
2025-10-13 18:28:03 +09:00
2025-09-29 13:29:03 +09:00
return {
...prevLayout,
2025-10-13 18:28:03 +09:00
components: updatedComponents,
2025-09-29 13:29:03 +09:00
};
});
2025-10-13 18:28:03 +09:00
// console.log("✅ 화면 파일 복원 완료");
2025-09-29 13:29:03 +09:00
}
} catch (error) {
// console.error("❌ 화면 파일 복원 오류:", error);
2025-09-29 13:29:03 +09:00
}
}, [selectedScreen?.screenId]);
// 전역 파일 상태 변경 이벤트 리스너
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
// console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
2025-10-13 18:28:03 +09:00
setForceRenderTrigger((prev) => prev + 1);
};
2025-10-13 18:28:03 +09:00
if (typeof window !== "undefined") {
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
return () => {
2025-10-13 18:28:03 +09:00
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
};
}
}, []);
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
2025-09-02 16:18:38 +09:00
useEffect(() => {
const loadScreenTable = async () => {
const tableName = selectedScreen?.tableName;
if (!tableName) {
setTables([]);
return;
}
try {
// 테이블 라벨 조회
const tableListResponse = await tableManagementApi.getTableList();
const currentTable =
tableListResponse.success && tableListResponse.data
? tableListResponse.data.find((t) => t.tableName === tableName)
: null;
const tableLabel = currentTable?.displayName || tableName;
// 현재 화면의 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
// columnName: col.columnName || col.column_name,
// widgetType,
// webType: col.webType || col.web_type,
// rawData: col,
// });
// }
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.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,
widgetType,
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,
tableLabel,
columns,
};
setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로)
} catch (error) {
console.error("화면 테이블 정보 로드 실패:", error);
setTables([]);
}
};
loadScreenTable();
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
2025-09-26 17:12:03 +09:00
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
2025-10-13 18:28:03 +09:00
if (typeof window !== "undefined") {
2025-09-26 17:12:03 +09:00
(window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId;
}
2025-10-13 18:28:03 +09:00
2025-09-02 16:18:38 +09:00
const loadLayout = async () => {
try {
const response = await screenApi.getLayout(selectedScreen.screenId);
if (response) {
2025-10-13 18:28:03 +09:00
// 🔄 마이그레이션 필요 여부 확인
let layoutToUse = response;
if (needsMigration(response)) {
const canvasWidth = response.screenResolution?.width || 1920;
layoutToUse = safeMigrateLayout(response, canvasWidth);
}
2025-09-02 16:18:38 +09:00
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
const layoutWithDefaultGrid = {
2025-10-13 18:28:03 +09:00
...layoutToUse,
2025-09-02 16:18:38 +09:00
gridSettings: {
2025-10-14 18:07:38 +09:00
...layoutToUse.gridSettings, // 기존 설정 먼저 적용
2025-09-02 16:18:38 +09:00
columns: 12,
gap: 16,
2025-10-14 18:07:38 +09:00
padding: 0, // padding은 항상 0으로 강제
2025-09-02 16:18:38 +09:00
snapToGrid: true,
showGrid: false, // 기본값 false로 변경
2025-09-02 16:18:38 +09:00
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
};
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
2025-10-13 18:28:03 +09:00
if (layoutToUse.screenResolution) {
setScreenResolution(layoutToUse.screenResolution);
// console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution);
} else {
// 기본 해상도 (Full HD)
const defaultResolution =
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0];
setScreenResolution(defaultResolution);
// console.log("🔧 기본 해상도 적용:", defaultResolution);
}
2025-09-02 16:18:38 +09:00
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
2025-09-26 17:12:03 +09:00
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
2025-09-02 16:18:38 +09:00
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
2025-09-02 16:18:38 +09:00
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
}
};
loadLayout();
2025-09-01 17:05:36 +09:00
}
2025-09-02 16:18:38 +09:00
}, [selectedScreen?.screenId]);
// 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
2025-10-15 10:44:05 +09:00
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 스페이스바 무시
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.code === "Space") {
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
if (!isPanMode) {
setIsPanMode(true);
// body에 커서 스타일 추가
document.body.style.cursor = "grab";
2025-10-15 10:44:05 +09:00
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
setIsPanMode(false);
setPanState((prev) => ({ ...prev, isPanning: false }));
// body 커서 스타일 복원
document.body.style.cursor = "default";
}
};
const handleMouseDown = (e: MouseEvent) => {
2025-10-22 17:19:47 +09:00
if (isPanMode) {
e.preventDefault();
2025-10-22 17:19:47 +09:00
// 외부와 내부 스크롤 컨테이너 모두 저장
setPanState({
isPanning: true,
startX: e.pageX,
startY: e.pageY,
2025-10-22 17:19:47 +09:00
outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0,
outerScrollTop: canvasContainerRef.current?.scrollTop || 0,
innerScrollLeft: canvasRef.current?.scrollLeft || 0,
innerScrollTop: canvasRef.current?.scrollTop || 0,
});
// 드래그 중 커서 변경
document.body.style.cursor = "grabbing";
}
};
const handleMouseMove = (e: MouseEvent) => {
2025-10-22 17:19:47 +09:00
if (isPanMode && panState.isPanning) {
e.preventDefault();
const dx = e.pageX - panState.startX;
const dy = e.pageY - panState.startY;
2025-10-22 17:19:47 +09:00
// 외부 컨테이너 스크롤
if (canvasContainerRef.current) {
canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy;
}
// 내부 캔버스 스크롤
if (canvasRef.current) {
canvasRef.current.scrollLeft = panState.innerScrollLeft - dx;
canvasRef.current.scrollTop = panState.innerScrollTop - dy;
}
}
};
const handleMouseUp = () => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
// 드래그 종료 시 커서 복원
document.body.style.cursor = "grab";
2025-10-15 10:44:05 +09:00
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
2025-10-15 10:44:05 +09:00
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
2025-10-15 10:44:05 +09:00
};
2025-10-22 17:19:47 +09:00
}, [
isPanMode,
panState.isPanning,
panState.startX,
panState.startY,
panState.outerScrollLeft,
panState.outerScrollTop,
panState.innerScrollLeft,
panState.innerScrollTop,
]);
2025-10-15 10:44:05 +09:00
// 마우스 휠로 줌 제어
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
// 캔버스 컨테이너 내에서만 동작
if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) {
// Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용)
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// 기본 스크롤 동작 방지
e.preventDefault();
const delta = e.deltaY;
const zoomFactor = 0.001; // 줌 속도 조절
setZoomLevel((prevZoom) => {
const newZoom = prevZoom - delta * zoomFactor;
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
});
}
}
};
// passive: false로 설정하여 preventDefault() 가능하게 함
canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false });
const containerRef = canvasContainerRef.current;
return () => {
containerRef?.removeEventListener("wheel", handleWheel);
};
}, [MIN_ZOOM, MAX_ZOOM]);
2025-09-02 16:18:38 +09:00
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
const newLayout = { ...layout, gridSettings: newGridSettings };
// 격자 스냅이 활성화된 경우, 모든 컴포넌트를 새로운 격자에 맞게 조정
if (newGridSettings.snapToGrid && screenResolution.width > 0) {
// 새로운 격자 설정으로 격자 정보 재계산 (해상도 기준)
const newGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, {
2025-09-02 16:18:38 +09:00
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid || false,
});
2025-09-01 17:05:36 +09:00
2025-09-02 16:18:38 +09:00
const gridUtilSettings = {
columns: newGridSettings.columns,
gap: newGridSettings.gap,
padding: newGridSettings.padding,
snapToGrid: newGridSettings.snapToGrid,
};
2025-09-01 17:05:36 +09:00
2025-09-02 16:18:38 +09:00
const adjustedComponents = layout.components.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
2025-09-03 11:32:09 +09:00
// gridColumns가 없거나 범위를 벗어나면 자동 조정
let adjustedGridColumns = comp.gridColumns;
if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > newGridSettings.columns) {
adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
}
2025-09-02 16:18:38 +09:00
return {
...comp,
position: snappedPosition,
size: snappedSize,
2025-09-03 11:32:09 +09:00
gridColumns: adjustedGridColumns, // gridColumns 속성 추가/조정
2025-09-02 16:18:38 +09:00
};
});
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
newLayout.components = adjustedComponents;
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
// console.log("새로운 격자 정보:", newGridInfo);
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 16:40:24 +09:00
},
[layout, screenResolution, saveToHistory],
);
2025-10-23 11:25:28 +09:00
// 해상도 변경 핸들러 (자동 스케일링 포함)
const handleResolutionChange = useCallback(
(newResolution: ScreenResolution) => {
2025-10-23 11:25:28 +09:00
const oldWidth = screenResolution.width;
const oldHeight = screenResolution.height;
const newWidth = newResolution.width;
const newHeight = newResolution.height;
2025-09-04 17:01:07 +09:00
console.log("📱 해상도 변경 시작:", {
2025-10-23 11:25:28 +09:00
from: `${oldWidth}x${oldHeight}`,
to: `${newWidth}x${newHeight}`,
2025-09-04 17:01:07 +09:00
hasComponents: layout.components.length > 0,
snapToGrid: layout.gridSettings?.snapToGrid || false,
});
setScreenResolution(newResolution);
2025-10-23 11:25:28 +09:00
// 컴포넌트가 없으면 해상도만 변경
if (layout.components.length === 0) {
const updatedLayout = {
...layout,
screenResolution: newResolution,
};
setLayout(updatedLayout);
saveToHistory(updatedLayout);
console.log("✅ 해상도 변경 완료 (컴포넌트 없음)");
return;
}
// 비율 계산
const scaleX = newWidth / oldWidth;
const scaleY = newHeight / oldHeight;
console.log("📐 스케일링 비율:", {
scaleX: `${(scaleX * 100).toFixed(2)}%`,
scaleY: `${(scaleY * 100).toFixed(2)}%`,
});
// 컴포넌트 재귀적으로 스케일링하는 함수
const scaleComponent = (comp: ComponentData): ComponentData => {
// 위치 스케일링
const scaledPosition = {
x: comp.position.x * scaleX,
y: comp.position.y * scaleY,
z: comp.position.z || 1,
};
// 크기 스케일링
const scaledSize = {
width: comp.size.width * scaleX,
height: comp.size.height * scaleY,
};
return {
...comp,
position: scaledPosition,
size: scaledSize,
};
};
// 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨)
const scaledComponents = layout.components.map(scaleComponent);
console.log("🔄 컴포넌트 스케일링 완료:", {
totalComponents: scaledComponents.length,
groupComponents: scaledComponents.filter((c) => c.type === "group").length,
note: "그룹의 자식 컴포넌트도 모두 스케일링됨",
});
// 격자 스냅이 활성화된 경우 격자에 맞춰 재조정
let finalComponents = scaledComponents;
if (layout.gridSettings?.snapToGrid) {
const newGridInfo = calculateGridInfo(newWidth, newHeight, {
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,
};
finalComponents = scaledComponents.map((comp) => {
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
// gridColumns 재계산
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
return {
...comp,
position: snappedPosition,
size: snappedSize,
gridColumns: adjustedGridColumns,
};
});
console.log("🧲 격자 스냅 적용 완료");
}
2025-09-04 17:01:07 +09:00
const updatedLayout = {
...layout,
2025-10-23 11:25:28 +09:00
components: finalComponents,
2025-09-04 17:01:07 +09:00
screenResolution: newResolution,
};
2025-09-04 17:01:07 +09:00
setLayout(updatedLayout);
saveToHistory(updatedLayout);
2025-10-23 11:25:28 +09:00
toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, {
description: `${oldWidth}×${oldHeight}${newWidth}×${newHeight}`,
});
2025-09-04 17:01:07 +09:00
console.log("✅ 해상도 변경 완료:", {
2025-10-23 11:25:28 +09:00
newResolution: `${newWidth}x${newHeight}`,
scaledComponents: finalComponents.length,
scaleX: `${(scaleX * 100).toFixed(2)}%`,
scaleY: `${(scaleY * 100).toFixed(2)}%`,
note: "모든 컴포넌트가 비율에 맞게 자동 조정됨",
2025-09-04 17:01:07 +09:00
});
},
[layout, saveToHistory, screenResolution],
);
2025-09-04 17:01:07 +09:00
// 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용)
const handleForceGridUpdate = useCallback(() => {
if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) {
// console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음");
2025-09-04 17:01:07 +09:00
return;
}
2025-09-04 17:01:07 +09:00
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);
}
2025-09-04 17:01:07 +09:00
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]);
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) {
console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen);
toast.error("화면 정보가 없습니다.");
return;
}
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
try {
setIsSaving(true);
2025-10-15 17:25:38 +09:00
// 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정
const updatedComponents = layout.components.map((comp) => {
if (comp.type === "component" && comp.componentType === "split-panel-layout") {
const config = comp.componentConfig || {};
const rightPanel = config.rightPanel || {};
const leftPanel = config.leftPanel || {};
const relationshipType = rightPanel.relation?.type || "detail";
// 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정
if (relationshipType === "detail" && leftPanel.tableName) {
console.log("🔧 분할 패널 자동 수정:", {
componentId: comp.id,
leftTableName: leftPanel.tableName,
rightTableName: leftPanel.tableName,
});
return {
...comp,
componentConfig: {
...config,
rightPanel: {
...rightPanel,
tableName: leftPanel.tableName,
},
},
};
}
}
return comp;
});
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
2025-10-15 17:25:38 +09:00
components: updatedComponents,
screenResolution: screenResolution,
};
2025-10-21 17:32:54 +09:00
// 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary",
2025-10-21 17:32:54 +09:00
);
console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId,
2025-09-04 17:01:07 +09:00
componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
2025-10-21 17:32:54 +09:00
buttonComponents: buttonComponents.map((c: any) => ({
id: c.id,
type: c.type,
text: c.componentConfig?.text,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})),
2025-09-04 17:01:07 +09:00
});
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
2025-09-02 16:18:38 +09:00
toast.success("화면이 저장되었습니다.");
// 저장 성공 후 메뉴 할당 모달 열기
setShowMenuAssignmentModal(true);
2025-09-02 16:18:38 +09:00
} catch (error) {
console.error("❌ 저장 실패:", error);
2025-09-02 16:18:38 +09:00
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen, layout, screenResolution]);
2025-09-01 16:40:24 +09:00
2025-09-03 15:23:12 +09:00
// 템플릿 드래그 처리
const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropX = e.clientX - rect.left;
const dropY = e.clientY - rect.top;
2025-09-04 17:01:07 +09:00
// 현재 해상도에 맞는 격자 정보 계산
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;
2025-09-03 15:23:12 +09:00
// 격자 스냅 적용
const snappedPosition =
2025-09-04 17:01:07 +09:00
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
2025-09-03 15:23:12 +09:00
: { 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;
});
2025-09-03 15:23:12 +09:00
const newComponents: ComponentData[] = template.components.map((templateComp, index) => {
const componentId = index === 0 ? idMapping["form-container"] : generateComponentId();
2025-09-03 15:23:12 +09:00
// 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정
const absoluteX = snappedPosition.x + templateComp.position.x;
const absoluteY = snappedPosition.y + templateComp.position.y;
// 격자 스냅 적용
const finalPosition =
2025-09-04 17:01:07 +09:00
layout.gridSettings?.snapToGrid && currentGridInfo
? snapToGrid({ x: absoluteX, y: absoluteY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
2025-09-03 15:23:12 +09:00
: { 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 =
2025-09-04 17:01:07 +09:00
currentGridInfo && layout.gridSettings?.snapToGrid
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
2025-09-04 17:01:07 +09:00
currentGridInfo,
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height,
};
})()
: { width: 400, height: templateComp.size.height }; // 폴백 크기
2025-09-03 15:23:12 +09:00
return {
id: componentId,
type: "container",
label: templateComp.label,
tableName: selectedScreen?.tableName || "",
title: templateComp.title || templateComp.label,
2025-09-03 15:23:12 +09:00
position: finalPosition,
size: calculatedSize,
gridColumns,
2025-09-03 15:23:12 +09:00
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
2025-09-03 15:23:12 +09:00
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
};
} else if (templateComp.type === "datatable") {
// 데이터 테이블 컴포넌트 생성
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
// gridColumns에 맞는 크기 계산
const calculatedSize =
2025-09-04 17:01:07 +09:00
currentGridInfo && layout.gridSettings?.snapToGrid
2025-09-03 15:23:12 +09:00
? (() => {
const newWidth = calculateWidthFromColumns(
gridColumns,
2025-09-04 17:01:07 +09:00
currentGridInfo,
2025-09-03 15:23:12 +09:00
layout.gridSettings as GridUtilSettings,
);
return {
width: newWidth,
height: templateComp.size.height, // 높이는 템플릿 값 유지
};
})()
: templateComp.size;
console.log("📊 데이터 테이블 생성 시 크기 계산:", {
gridColumns,
templateSize: templateComp.size,
calculatedSize,
2025-09-04 17:01:07 +09:00
hasGridInfo: !!currentGridInfo,
2025-09-03 15:23:12 +09:00
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,
2025-09-03 16:38:10 +09:00
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: "취소",
},
2025-09-03 15:23:12 +09:00
gridColumns,
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
2025-09-03 15:23:12 +09:00
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
2025-09-05 21:52:19 +09:00
} 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: "#212121",
2025-09-05 21:52:19 +09:00
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
2025-09-08 13:10:09 +09:00
} 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,
2025-10-14 18:07:38 +09:00
padding: 0,
2025-09-08 13:10:09 +09:00
margin: 0,
shadow: "sm",
...(templateComp as any).areaStyle,
},
children: [],
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
2025-09-08 13:10:09 +09:00
labelFontWeight: "600",
labelMarginBottom: "8px",
...templateComp.style,
},
} as ComponentData;
2025-09-03 15:23:12 +09:00
} else {
// 위젯 컴포넌트
const widgetType = templateComp.widgetType || "text";
2025-10-14 16:45:30 +09:00
// 웹타입별 기본 그리드 컬럼 수 계산
const getDefaultGridColumnsForTemplate = (wType: string): number => {
const widthMap: Record<string, number> = {
text: 4,
email: 4,
tel: 3,
url: 4,
textarea: 6,
number: 2,
decimal: 2,
date: 3,
datetime: 3,
time: 2,
select: 3,
radio: 3,
checkbox: 2,
boolean: 2,
code: 3,
entity: 4,
file: 4,
image: 3,
button: 2,
label: 2,
};
return widthMap[wType] || 3;
};
2025-09-03 15:23:12 +09:00
// 웹타입별 기본 설정 생성
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 || "입력하세요",
};
}
};
2025-09-04 17:01:07 +09:00
// 위젯 크기도 격자에 맞게 조정
const widgetSize =
currentGridInfo && layout.gridSettings?.snapToGrid
? {
width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings),
height: templateComp.size.height,
}
: templateComp.size;
2025-09-03 15:23:12 +09:00
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,
2025-09-03 15:23:12 +09:00
position: finalPosition,
2025-09-04 17:01:07 +09:00
size: widgetSize,
2025-09-03 15:23:12 +09:00
required: templateComp.required || false,
readonly: templateComp.readonly || false,
2025-10-14 16:45:30 +09:00
gridColumns: getDefaultGridColumnsForTemplate(widgetType),
2025-09-03 15:23:12 +09:00
webTypeConfig: getDefaultWebTypeConfig(widgetType),
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
2025-09-03 15:23:12 +09:00
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]);
}
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
2025-10-22 14:52:13 +09:00
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
2025-09-03 15:23:12 +09:00
);
2025-09-10 18:36:28 +09:00
// 레이아웃 드래그 처리
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);
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
2025-10-22 14:52:13 +09:00
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory],
2025-09-10 18:36:28 +09:00
);
2025-09-11 16:21:00 +09:00
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
// 존 클릭 핸들러
const handleZoneClick = useCallback((zoneId: string) => {
// console.log("🎯 존 클릭:", zoneId);
2025-09-11 16:21:00 +09:00
// 필요시 존 선택 로직 추가
}, []);
// 웹타입별 기본 설정 생성 함수를 상위로 이동
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 {};
}
}, []);
// 컴포넌트 드래그 처리 (캔버스 레벨 드롭)
2025-09-09 17:42:23 +09:00
const handleComponentDrop = useCallback(
2025-09-11 16:21:00 +09:00
(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);
2025-09-11 16:21:00 +09:00
return;
}
}
2025-09-09 17:42:23 +09:00
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
2025-09-11 18:38:28 +09:00
// 컴포넌트 크기 정보
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,
},
});
2025-09-09 17:42:23 +09:00
// 현재 해상도에 맞는 격자 정보 계산
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;
2025-09-11 18:38:28 +09:00
// 캔버스 경계 내로 위치 제한
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
2025-09-09 17:42:23 +09:00
// 격자 스냅 적용
const snappedPosition =
layout.gridSettings?.snapToGrid && currentGridInfo
2025-09-11 18:38:28 +09:00
? snapToGrid({ x: boundedX, y: boundedY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
: { x: boundedX, y: boundedY, z: 1 };
2025-09-09 17:42:23 +09:00
console.log("🧩 컴포넌트 드롭:", {
componentName: component.name,
webType: component.webType,
2025-09-11 18:38:28 +09:00
rawPosition: { x: dropX, y: dropY },
boundedPosition: { x: boundedX, y: boundedY },
2025-09-09 17:42:23 +09:00
snappedPosition,
});
2025-09-11 18:38:28 +09:00
// 새 컴포넌트 생성 (새 컴포넌트 시스템 지원)
2025-09-10 14:09:32 +09:00
console.log("🔍 ScreenDesigner handleComponentDrop:", {
componentName: component.name,
2025-09-11 18:38:28 +09:00
componentId: component.id,
2025-09-10 14:09:32 +09:00
webType: component.webType,
2025-09-11 18:38:28 +09:00
category: component.category,
defaultConfig: component.defaultConfig,
defaultSize: component.defaultSize,
2025-09-10 14:09:32 +09:00
});
// 컴포넌트별 gridColumns 설정 및 크기 계산
2025-09-15 17:10:46 +09:00
let componentSize = component.defaultSize;
const isCardDisplay = component.id === "card-display";
const isTableList = component.id === "table-list";
2025-10-13 18:28:03 +09:00
2025-10-14 16:45:30 +09:00
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
2025-10-14 16:45:30 +09:00
let gridColumns = 1; // 기본값
// 특수 컴포넌트
if (isCardDisplay) {
gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67%
2025-10-14 16:45:30 +09:00
} else if (isTableList) {
gridColumns = currentGridColumns; // 테이블은 전체 너비
2025-10-14 16:45:30 +09:00
} else {
// 웹타입별 적절한 그리드 컬럼 수 설정
const webType = component.webType;
const componentId = component.id;
// 웹타입별 기본 비율 매핑 (12컬럼 기준 비율)
const gridColumnsRatioMap: Record<string, number> = {
// 입력 컴포넌트 (INPUT 카테고리)
"text-input": 4 / 12, // 텍스트 입력 (33%)
"number-input": 2 / 12, // 숫자 입력 (16.67%)
"email-input": 4 / 12, // 이메일 입력 (33%)
"tel-input": 3 / 12, // 전화번호 입력 (25%)
"date-input": 3 / 12, // 날짜 입력 (25%)
"datetime-input": 4 / 12, // 날짜시간 입력 (33%)
"time-input": 2 / 12, // 시간 입력 (16.67%)
"textarea-basic": 6 / 12, // 텍스트 영역 (50%)
"select-basic": 3 / 12, // 셀렉트 (25%)
"checkbox-basic": 2 / 12, // 체크박스 (16.67%)
"radio-basic": 3 / 12, // 라디오 (25%)
"file-basic": 4 / 12, // 파일 (33%)
"file-upload": 4 / 12, // 파일 업로드 (33%)
"slider-basic": 3 / 12, // 슬라이더 (25%)
"toggle-switch": 2 / 12, // 토글 스위치 (16.67%)
"repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%)
// 표시 컴포넌트 (DISPLAY 카테고리)
"label-basic": 2 / 12, // 라벨 (16.67%)
"text-display": 3 / 12, // 텍스트 표시 (25%)
"card-display": 8 / 12, // 카드 (66.67%)
"badge-basic": 1 / 12, // 배지 (8.33%)
"alert-basic": 6 / 12, // 알림 (50%)
"divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%)
"accordion-basic": 1, // 아코디언 (100%)
"table-list": 1, // 테이블 리스트 (100%)
"image-display": 4 / 12, // 이미지 표시 (33%)
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
"flow-widget": 1, // 플로우 위젯 (100%)
// 액션 컴포넌트 (ACTION 카테고리)
"button-basic": 1 / 12, // 버튼 (8.33%)
"button-primary": 1 / 12, // 프라이머리 버튼 (8.33%)
"button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%)
"icon-button": 1 / 12, // 아이콘 버튼 (8.33%)
// 레이아웃 컴포넌트
"container-basic": 6 / 12, // 컨테이너 (50%)
"section-basic": 1, // 섹션 (100%)
"panel-basic": 6 / 12, // 패널 (50%)
// 기타
"image-basic": 4 / 12, // 이미지 (33%)
"icon-basic": 1 / 12, // 아이콘 (8.33%)
"progress-bar": 4 / 12, // 프로그레스 바 (33%)
"chart-basic": 6 / 12, // 차트 (50%)
};
// defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용
2025-10-20 10:55:33 +09:00
if (component.defaultSize?.gridColumnSpan === "full") {
gridColumns = currentGridColumns;
2025-10-20 10:55:33 +09:00
} else {
// componentId 또는 webType으로 비율 찾기, 없으면 기본값 25%
const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25;
// 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns)
gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns)));
2025-10-20 10:55:33 +09:00
}
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
componentId,
webType,
gridColumns,
});
2025-10-14 16:45:30 +09:00
}
2025-09-15 17:10:46 +09:00
2025-10-14 16:45:30 +09:00
// 그리드 시스템이 활성화된 경우 gridColumns에 맞춰 너비 재계산
if (layout.gridSettings?.snapToGrid && gridInfo) {
2025-09-15 17:10:46 +09:00
// gridColumns에 맞는 정확한 너비 계산
const calculatedWidth = calculateWidthFromColumns(
gridColumns,
gridInfo,
layout.gridSettings as GridUtilSettings,
);
// 컴포넌트별 최소 크기 보장
2025-10-14 16:45:30 +09:00
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : component.defaultSize.width;
2025-10-13 18:28:03 +09:00
2025-09-15 17:10:46 +09:00
componentSize = {
...component.defaultSize,
width: Math.max(calculatedWidth, minWidth),
2025-09-15 17:10:46 +09:00
};
}
console.log("🎨 최종 컴포넌트 크기:", {
componentId: component.id,
componentName: component.name,
defaultSize: component.defaultSize,
finalSize: componentSize,
gridColumns,
});
// 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가
let enhancedDefaultConfig = { ...component.defaultConfig };
if (
component.id === "repeater-field-group" &&
tables &&
tables.length > 0 &&
tables[0].columns &&
tables[0].columns.length > 0
) {
const firstColumn = tables[0].columns[0];
enhancedDefaultConfig = {
...enhancedDefaultConfig,
fields: [
{
name: firstColumn.columnName,
label: firstColumn.columnLabel || firstColumn.columnName,
type: (firstColumn.widgetType as any) || "text",
required: firstColumn.required || false,
placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`,
},
],
};
}
// gridColumns에 맞춰 width를 퍼센트로 계산
const widthPercent = (gridColumns / currentGridColumns) * 100;
console.log("🎨 [컴포넌트 생성] 너비 계산:", {
componentName: component.name,
componentId: component.id,
currentGridColumns,
gridColumns,
widthPercent: `${widthPercent}%`,
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
});
2025-09-09 17:42:23 +09:00
const newComponent: ComponentData = {
id: generateComponentId(),
2025-09-12 14:24:25 +09:00
type: "component", // ✅ 새 컴포넌트 시스템 사용
2025-09-09 17:42:23 +09:00
label: component.name,
widgetType: component.webType,
2025-09-11 18:38:28 +09:00
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
2025-09-09 17:42:23 +09:00
position: snappedPosition,
2025-09-15 17:10:46 +09:00
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
2025-09-11 18:38:28 +09:00
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
2025-09-12 14:24:25 +09:00
webType: component.webType, // 웹타입 정보 추가
...enhancedDefaultConfig,
2025-09-11 18:38:28 +09:00
},
2025-09-09 17:42:23 +09:00
webTypeConfig: getDefaultWebTypeConfig(component.webType),
style: {
2025-09-12 16:47:02 +09:00
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
2025-09-09 17:42:23 +09:00
labelFontSize: "14px",
labelColor: "#212121",
2025-09-09 17:42:23 +09:00
labelFontWeight: "500",
labelMarginBottom: "4px",
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
2025-09-09 17:42:23 +09:00
},
};
// 레이아웃에 컴포넌트 추가
const newLayout: LayoutData = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 새 컴포넌트 선택
setSelectedComponent(newComponent);
2025-10-21 17:32:54 +09:00
// 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화
// openPanel("properties");
2025-09-09 17:42:23 +09:00
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
2025-10-22 14:52:13 +09:00
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory],
2025-09-09 17:42:23 +09:00
);
2025-09-02 16:18:38 +09:00
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
2025-09-02 16:18:38 +09:00
const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
2025-09-12 14:24:25 +09:00
if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
2025-09-12 14:24:25 +09:00
return;
}
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
try {
2025-09-03 15:23:12 +09:00
const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
2025-09-03 15:23:12 +09:00
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
handleTemplateDrop(e, parsedData.template);
return;
}
2025-09-10 18:36:28 +09:00
// 레이아웃 드래그인 경우
if (parsedData.type === "layout") {
handleLayoutDrop(e, parsedData.layout);
return;
}
2025-09-09 17:42:23 +09:00
// 컴포넌트 드래그인 경우
if (parsedData.type === "component") {
handleComponentDrop(e, parsedData.component);
return;
}
2025-09-03 15:23:12 +09:00
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
// 드롭 대상이 폼 컨테이너인지 확인
const dropTarget = e.target as HTMLElement;
const formContainer = dropTarget.closest('[data-form-container="true"]');
2025-09-02 16:18:38 +09:00
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
2025-09-02 16:18:38 +09:00
let newComponent: ComponentData;
2025-09-02 16:18:38 +09:00
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
2025-09-08 14:23:55 +09:00
label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명
2025-09-02 16:18:38 +09:00
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
2025-09-02 16:46:54 +09:00
style: {
labelDisplay: true,
labelFontSize: "14px",
labelColor: "#212121",
2025-09-02 16:46:54 +09:00
labelFontWeight: "600",
labelMarginBottom: "8px",
},
2025-09-02 16:18:38 +09:00
};
} else if (type === "column") {
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
2025-09-04 17:01:07 +09:00
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
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,
});
2025-09-02 11:16:40 +09:00
2025-10-14 16:45:30 +09:00
// 웹타입별 기본 그리드 컬럼 수 계산
const getDefaultGridColumns = (widgetType: string): number => {
const widthMap: Record<string, number> = {
// 텍스트 입력 계열 (넓게)
text: 4, // 1/3 (33%)
email: 4, // 1/3 (33%)
tel: 3, // 1/4 (25%)
url: 4, // 1/3 (33%)
textarea: 6, // 절반 (50%)
// 숫자/날짜 입력 (중간)
number: 2, // 2/12 (16.67%)
decimal: 2, // 2/12 (16.67%)
date: 3, // 1/4 (25%)
datetime: 3, // 1/4 (25%)
time: 2, // 2/12 (16.67%)
// 선택 입력 (중간)
select: 3, // 1/4 (25%)
radio: 3, // 1/4 (25%)
checkbox: 2, // 2/12 (16.67%)
boolean: 2, // 2/12 (16.67%)
// 코드/참조 (넓게)
code: 3, // 1/4 (25%)
entity: 4, // 1/3 (33%)
// 파일/이미지 (넓게)
file: 4, // 1/3 (33%)
image: 3, // 1/4 (25%)
// 기타
button: 2, // 2/12 (16.67%)
label: 2, // 2/12 (16.67%)
};
const defaultColumns = widthMap[widgetType] || 3; // 기본값 3 (1/4, 25%)
console.log("🎯 [ScreenDesigner] getDefaultGridColumns:", { widgetType, defaultColumns });
return defaultColumns;
};
// 웹타입별 기본 높이 계산
const getDefaultHeight = (widgetType: string): number => {
const heightMap: Record<string, number> = {
textarea: 120, // 텍스트 영역은 3줄 (40 * 3)
checkbox: 80, // 체크박스 그룹 (40 * 2)
radio: 80, // 라디오 버튼 (40 * 2)
file: 240, // 파일 업로드 (40 * 6)
};
return heightMap[widgetType] || 40; // 기본값 40
};
2025-09-03 11:32:09 +09:00
// 웹타입별 기본 설정 생성
const getDefaultWebTypeConfig = (widgetType: string) => {
switch (widgetType) {
case "date":
return {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "날짜를 선택하세요",
};
case "datetime":
return {
format: "YYYY-MM-DD HH:mm" as const,
showTime: true,
placeholder: "날짜와 시간을 선택하세요",
};
case "number":
return {
format: "integer" as const,
placeholder: "숫자를 입력하세요",
};
case "decimal":
return {
format: "decimal" as const,
step: 0.01,
decimalPlaces: 2,
placeholder: "소수를 입력하세요",
};
case "select":
case "dropdown":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
],
multiple: false,
searchable: false,
placeholder: "옵션을 선택하세요",
};
case "text":
return {
format: "none" as const,
placeholder: "텍스트를 입력하세요",
multiline: false,
};
case "email":
return {
format: "email" as const,
placeholder: "이메일을 입력하세요",
multiline: false,
};
case "tel":
return {
format: "phone" as const,
placeholder: "전화번호를 입력하세요",
multiline: false,
};
case "textarea":
return {
rows: 3,
placeholder: "텍스트를 입력하세요",
resizable: true,
autoResize: false,
wordWrap: true,
};
case "checkbox":
case "boolean":
return {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
};
case "radio":
return {
options: [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
],
layout: "vertical" as const,
defaultValue: "",
allowNone: false,
};
case "file":
return {
accept: "",
multiple: false,
maxSize: 10,
maxFiles: 1,
preview: true,
dragDrop: true,
allowedExtensions: [],
};
case "code":
return {
2025-09-15 15:38:48 +09:00
codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴
placeholder: "선택하세요",
options: [], // 기본 빈 배열, 실제로는 API에서 로드
2025-09-03 11:32:09 +09:00
};
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,
};
2025-09-03 11:32:09 +09:00
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;
2025-09-12 14:24:25 +09:00
// 웹타입을 새로운 컴포넌트 ID로 매핑
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
2025-09-12 14:24:25 +09:00
2025-10-14 16:45:30 +09:00
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 폼 컨테이너 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
newComponent = {
id: generateComponentId(),
2025-09-12 14:24:25 +09:00
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
2025-09-12 16:47:02 +09:00
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
2025-10-14 16:45:30 +09:00
gridColumns: calculatedGridColumns,
2025-09-15 15:38:48 +09:00
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
2025-09-12 16:47:02 +09:00
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px",
labelColor: "#212121",
labelFontWeight: "500",
labelMarginBottom: "6px",
},
2025-09-12 14:24:25 +09:00
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
2025-11-05 18:08:51 +09:00
inputType: column.inputType, // ✅ input_type 추가 (category 등)
2025-09-12 14:24:25 +09:00
...getDefaultWebTypeConfig(column.widgetType),
2025-09-15 15:38:48 +09:00
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
2025-09-12 14:24:25 +09:00
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
}
} else {
2025-09-12 14:24:25 +09:00
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
const componentId = getComponentIdFromWebType(column.widgetType);
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
2025-09-12 14:24:25 +09:00
2025-10-14 16:45:30 +09:00
// 웹타입별 적절한 gridColumns 계산
const calculatedGridColumns = getDefaultGridColumns(column.widgetType);
// gridColumns에 맞는 실제 너비 계산
const componentWidth =
currentGridInfo && layout.gridSettings?.snapToGrid
? calculateWidthFromColumns(
calculatedGridColumns,
currentGridInfo,
layout.gridSettings as GridUtilSettings,
)
: defaultWidth;
console.log("🎯 캔버스 컴포넌트 생성:", {
widgetType: column.widgetType,
calculatedGridColumns,
componentWidth,
defaultWidth,
});
// 🔍 이미지 타입 드래그앤드롭 디버깅
// if (column.widgetType === "image") {
// console.log("🖼️ 이미지 컬럼 드래그앤드롭:", {
// columnName: column.columnName,
// widgetType: column.widgetType,
// componentId,
// column,
// });
// }
newComponent = {
id: generateComponentId(),
2025-09-12 14:24:25 +09:00
type: "component", // ✅ 새로운 컴포넌트 시스템 사용
2025-09-08 14:23:55 +09:00
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
2025-09-12 16:47:02 +09:00
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
2025-10-14 16:45:30 +09:00
gridColumns: calculatedGridColumns,
2025-09-15 15:38:48 +09:00
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: {
2025-09-22 14:13:05 +09:00
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
2025-10-30 12:03:50 +09:00
labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정
labelFontWeight: "500",
2025-10-30 12:03:50 +09:00
labelMarginBottom: "8px",
},
2025-09-12 14:24:25 +09:00
componentConfig: {
type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존
2025-11-05 18:08:51 +09:00
inputType: column.inputType, // ✅ input_type 추가 (category 등)
2025-09-12 14:24:25 +09:00
...getDefaultWebTypeConfig(column.widgetType),
2025-09-15 15:38:48 +09:00
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
2025-09-12 14:24:25 +09:00
},
};
}
2025-09-02 16:18:38 +09:00
} else {
return;
}
2025-09-01 11:48:12 +09:00
2025-09-03 11:32:09 +09:00
// 격자 스냅 적용 (그룹 컴포넌트 제외)
2025-09-04 17:01:07 +09:00
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,
});
2025-09-02 16:18:38 +09:00
const gridUtilSettings = {
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
};
2025-09-04 17:01:07 +09:00
newComponent.position = snapToGrid(newComponent.position, currentGridInfo, gridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, currentGridInfo, gridUtilSettings);
2025-09-03 11:32:09 +09:00
console.log("🧲 새 컴포넌트 격자 스냅 적용:", {
type: newComponent.type,
2025-09-04 17:01:07 +09:00
resolution: `${screenResolution.width}x${screenResolution.height}`,
2025-09-03 11:32:09 +09:00
snappedPosition: newComponent.position,
snappedSize: newComponent.size,
2025-09-04 17:01:07 +09:00
columnWidth: currentGridInfo.columnWidth,
2025-09-03 11:32:09 +09:00
});
}
if (newComponent.type === "group") {
console.log("🔓 그룹 컴포넌트는 격자 스냅 제외:", {
type: newComponent.type,
position: newComponent.position,
size: newComponent.size,
});
2025-09-02 16:18:38 +09:00
}
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
2025-09-01 17:57:52 +09:00
2025-10-21 17:32:54 +09:00
// 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화
// openPanel("properties");
2025-09-02 16:18:38 +09:00
} catch (error) {
// console.error("드롭 처리 실패:", error);
2025-09-02 16:18:38 +09:00
}
},
2025-10-22 14:52:13 +09:00
[layout, gridInfo, saveToHistory],
2025-09-02 16:18:38 +09:00
);
2025-09-01 17:57:52 +09:00
// 파일 컴포넌트 업데이트 처리
const handleFileComponentUpdate = useCallback(
(updates: Partial<ComponentData>) => {
if (!selectedFileComponent) return;
2025-10-13 18:28:03 +09:00
const updatedComponents = layout.components.map((comp) =>
comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp,
);
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
2025-10-13 18:28:03 +09:00
// selectedFileComponent도 업데이트
2025-10-13 18:28:03 +09:00
setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null));
// selectedComponent가 같은 컴포넌트라면 업데이트
if (selectedComponent?.id === selectedFileComponent.id) {
2025-10-13 18:28:03 +09:00
setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null));
}
},
[selectedFileComponent, layout, saveToHistory, selectedComponent],
);
// 파일첨부 모달 닫기
const handleFileAttachmentModalClose = useCallback(() => {
setShowFileAttachmentModal(false);
setSelectedFileComponent(null);
}, []);
// 컴포넌트 더블클릭 처리
2025-10-13 18:28:03 +09:00
const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
2025-10-13 18:28:03 +09:00
// 파일 컴포넌트인 경우 상세 모달 열기
if (component.type === "file") {
setSelectedFileComponent(component);
setShowFileAttachmentModal(true);
return;
}
2025-10-13 18:28:03 +09:00
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
// console.log("더블클릭된 컴포넌트:", component.type, component.id);
}, []);
2025-09-02 16:18:38 +09:00
// 컴포넌트 클릭 처리 (다중선택 지원)
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지)
if (dragState.justFinishedDrag) {
return;
}
2025-10-21 17:32:54 +09:00
// 🔧 layout.components에서 최신 버전의 컴포넌트 찾기
const latestComponent = layout.components.find((c) => c.id === component.id);
if (!latestComponent) {
console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id);
return;
}
2025-09-02 16:18:38 +09:00
const isShiftPressed = event?.shiftKey || false;
const isCtrlPressed = event?.ctrlKey || event?.metaKey || false;
2025-10-21 17:32:54 +09:00
const isGroupContainer = latestComponent.type === "group";
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
if (isShiftPressed || isCtrlPressed || groupState.isGrouping) {
// 다중 선택 모드
if (isGroupContainer) {
// 그룹 컨테이너는 단일 선택으로 처리
2025-10-21 17:32:54 +09:00
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
2025-09-02 16:18:38 +09:00
setGroupState((prev) => ({
...prev,
2025-10-21 17:32:54 +09:00
selectedComponents: [latestComponent.id],
2025-09-02 16:18:38 +09:00
isGrouping: false,
}));
return;
}
2025-09-01 17:57:52 +09:00
2025-10-21 17:32:54 +09:00
const isSelected = groupState.selectedComponents.includes(latestComponent.id);
2025-09-02 16:18:38 +09:00
setGroupState((prev) => ({
...prev,
selectedComponents: isSelected
2025-10-21 17:32:54 +09:00
? prev.selectedComponents.filter((id) => id !== latestComponent.id)
: [...prev.selectedComponents, latestComponent.id],
2025-09-02 16:18:38 +09:00
}));
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (!isSelected) {
2025-10-21 17:32:54 +09:00
// console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id);
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
2025-09-02 16:18:38 +09:00
}
} else {
// 단일 선택 모드
2025-10-21 17:32:54 +09:00
// console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id);
handleComponentSelect(latestComponent); // 🔧 최신 버전 사용
2025-09-02 16:18:38 +09:00
setGroupState((prev) => ({
...prev,
2025-10-21 17:32:54 +09:00
selectedComponents: [latestComponent.id],
2025-09-02 16:18:38 +09:00
}));
}
},
[
handleComponentSelect,
groupState.isGrouping,
groupState.selectedComponents,
dragState.justFinishedDrag,
layout.components,
],
2025-09-02 16:18:38 +09:00
);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 컴포넌트 드래그 시작
const startComponentDrag = useCallback(
(component: ComponentData, event: React.MouseEvent) => {
event.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 새로운 드래그 시작 시 justFinishedDrag 플래그 해제
if (dragState.justFinishedDrag) {
setDragState((prev) => ({
...prev,
justFinishedDrag: false,
}));
2025-09-01 17:57:52 +09:00
}
2025-09-09 18:02:07 +09:00
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
const relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
2025-09-02 16:18:38 +09:00
// 다중 선택된 컴포넌트들 확인
const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id);
2025-09-11 12:22:39 +09:00
let componentsToMove = isDraggedComponentSelected
2025-09-02 16:18:38 +09:00
? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))
: [component];
2025-09-01 17:57:52 +09:00
2025-09-11 12:22:39 +09:00
// 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동
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);
2025-09-09 18:02:07 +09:00
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,
});
2025-09-01 17:57:52 +09:00
console.log("🚀 드래그 시작:", {
componentId: component.id,
componentType: component.type,
initialPosition: { x: component.position.x, y: component.position.y },
});
2025-09-02 16:18:38 +09:00
setDragState({
isDragging: true,
draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준)
draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들
originalPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
currentPosition: {
x: component.position.x,
y: component.position.y,
z: (component.position as Position).z || 1,
},
grabOffset: {
2025-09-09 18:02:07 +09:00
x: relativeMouseX - component.position.x,
y: relativeMouseY - component.position.y,
2025-09-02 16:18:38 +09:00
},
justFinishedDrag: false,
2025-09-01 17:57:52 +09:00
});
},
2025-09-02 16:18:38 +09:00
[groupState.selectedComponents, layout.components, dragState.justFinishedDrag],
2025-09-01 17:57:52 +09:00
);
2025-09-02 16:18:38 +09:00
// 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트)
const updateDragPosition = useCallback(
(event: MouseEvent) => {
if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
2025-09-09 18:02:07 +09:00
// 캔버스 내부의 상대 좌표 계산 (스크롤 없는 고정 캔버스)
const relativeMouseX = event.clientX - rect.left;
const relativeMouseY = event.clientY - rect.top;
// 컴포넌트 크기 가져오기
const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id);
const componentWidth = draggedComp?.size?.width || 100;
const componentHeight = draggedComp?.size?.height || 40;
// 경계 제한 적용
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
2025-09-02 16:18:38 +09:00
const newPosition = {
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
2025-09-02 16:18:38 +09:00
z: (dragState.draggedComponent.position as Position).z || 1,
};
// 드래그 상태 업데이트
console.log("🔥 ScreenDesigner updateDragPosition:", {
draggedComponentId: dragState.draggedComponent.id,
oldPosition: dragState.currentPosition,
newPosition: newPosition,
});
2025-09-02 16:18:38 +09:00
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을 참조하여 실시간 표시
},
2025-09-02 16:18:38 +09:00
[dragState.isDragging, dragState.draggedComponent, dragState.grabOffset],
);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 드래그 종료
const endDrag = useCallback(() => {
if (dragState.isDragging && dragState.draggedComponent) {
2025-09-03 11:32:09 +09:00
// 주 드래그 컴포넌트의 최종 위치 계산
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
let finalPosition = dragState.currentPosition;
2025-09-04 17:01:07 +09:00
// 현재 해상도에 맞는 격자 정보 계산
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;
2025-10-24 17:27:22 +09:00
// 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
2025-09-04 17:01:07 +09:00
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
2025-09-03 11:32:09 +09:00
finalPosition = snapToGrid(
{
x: dragState.currentPosition.x,
y: dragState.currentPosition.y,
z: dragState.currentPosition.z ?? 1,
},
2025-09-04 17:01:07 +09:00
currentGridInfo,
2025-09-03 11:32:09 +09:00
{
columns: layout.gridSettings.columns,
gap: layout.gridSettings.gap,
padding: layout.gridSettings.padding,
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
2025-09-04 17:01:07 +09:00
console.log("🎯 격자 스냅 적용됨:", {
2025-10-24 17:27:22 +09:00
componentType: draggedComponent?.type,
2025-09-04 17:01:07 +09:00
resolution: `${screenResolution.width}x${screenResolution.height}`,
originalPosition: dragState.currentPosition,
snappedPosition: finalPosition,
columnWidth: currentGridInfo.columnWidth,
});
2025-09-03 11:32:09 +09:00
}
2025-09-02 16:18:38 +09:00
// 스냅으로 인한 추가 이동 거리 계산
const snapDeltaX = finalPosition.x - dragState.currentPosition.x;
const snapDeltaY = finalPosition.y - dragState.currentPosition.y;
// 원래 이동 거리 + 스냅 조정 거리
const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX;
const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY;
// 다중 컴포넌트들의 최종 위치 업데이트
const updatedComponents = layout.components.map((comp) => {
const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id);
if (isDraggedComponent) {
const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!;
2025-09-03 11:32:09 +09:00
let newPosition = {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
z: originalComponent.position.z || 1,
};
// 캔버스 경계 제한 (컴포넌트가 화면 밖으로 나가지 않도록)
const componentWidth = comp.size?.width || 100;
const componentHeight = comp.size?.height || 40;
// 최소 위치: 0, 최대 위치: 캔버스 크기 - 컴포넌트 크기
newPosition.x = Math.max(0, Math.min(newPosition.x, screenResolution.width - componentWidth));
newPosition.y = Math.max(0, Math.min(newPosition.y, screenResolution.height - componentHeight));
2025-09-03 11:32:09 +09:00
// 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용
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, comp.size.height);
2025-09-03 11:32:09 +09:00
newPosition = {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: newPosition.z,
};
// 크기도 업데이트
const newSize = {
width: snappedWidth,
height: snappedHeight,
};
console.log("🎯 드래그 종료 시 그룹 내부 컴포넌트 격자 스냅 (패딩 고려):", {
componentId: comp.id,
parentId: comp.parentId,
beforeSnap: {
x: originalComponent.position.x + totalDeltaX,
y: originalComponent.position.y + totalDeltaY,
},
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
columnWidth,
fullColumnWidth,
widthInColumns,
gap: gap || 16,
padding,
},
afterSnap: newPosition,
afterSizeSnap: newSize,
});
return {
...comp,
position: newPosition as Position,
size: newSize,
};
}
2025-09-02 16:18:38 +09:00
return {
...comp,
2025-09-03 11:32:09 +09:00
position: newPosition as Position,
2025-09-02 16:18:38 +09:00
};
}
return comp;
});
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
// 선택된 컴포넌트도 업데이트 (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);
}
}
2025-09-02 16:18:38 +09:00
// 히스토리에 저장
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;
2025-10-24 10:40:12 +09:00
// zoom 스케일을 고려한 좌표 변환
2025-09-02 16:18:38 +09:00
const startPoint = {
2025-10-24 10:40:12 +09:00
x: (event.clientX - rect.left) / zoomLevel,
y: (event.clientY - rect.top) / zoomLevel,
2025-09-02 16:18:38 +09:00
z: 1,
};
setSelectionDrag({
isSelecting: true,
startPoint,
currentPoint: startPoint,
wasSelecting: false,
});
},
2025-10-24 10:40:12 +09:00
[dragState.isDragging, zoomLevel],
2025-09-02 16:18:38 +09:00
);
// 드래그 선택 업데이트
const updateSelectionDrag = useCallback(
(event: MouseEvent) => {
if (!selectionDrag.isSelecting || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
2025-10-24 10:40:12 +09:00
// zoom 스케일을 고려한 좌표 변환
2025-09-02 16:18:38 +09:00
const currentPoint = {
2025-10-24 10:40:12 +09:00
x: (event.clientX - rect.left) / zoomLevel,
y: (event.clientY - rect.top) / zoomLevel,
2025-09-02 16:18:38 +09:00
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,
}));
},
2025-10-24 10:40:12 +09:00
[selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel],
2025-09-02 16:18:38 +09:00
);
// 드래그 선택 종료
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, "개");
2025-09-02 16:18:38 +09:00
let newComponents = [...layout.components];
// 각 선택된 컴포넌트를 삭제 처리
groupState.selectedComponents.forEach((componentId) => {
const component = layout.components.find((comp) => comp.id === componentId);
if (!component) return;
if (component.type === "group") {
// 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원
const childComponents = newComponents.filter((comp) => comp.parentId === component.id);
const restoredChildren = restoreAbsolutePositions(childComponents, component.position);
newComponents = newComponents
.map((comp) => {
if (comp.parentId === component.id) {
// 복원된 절대 위치로 업데이트
const restoredChild = restoredChildren.find((restored) => restored.id === comp.id);
return restoredChild || { ...comp, parentId: undefined };
}
2025-09-02 16:18:38 +09:00
return comp;
})
.filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거
} else {
// 일반 컴포넌트 삭제
newComponents = newComponents.filter((comp) => comp.id !== component.id);
2025-09-01 17:57:52 +09:00
}
2025-09-02 16:18:38 +09:00
});
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 17:57:52 +09:00
2025-09-02 16:18:38 +09:00
// 선택 상태 초기화
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
toast.success(`${groupState.selectedComponents.length}개 컴포넌트가 삭제되었습니다.`);
return;
}
// 단일 선택된 컴포넌트 삭제
if (!selectedComponent) return;
// console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id);
2025-09-02 16:18:38 +09:00
let newComponents;
if (selectedComponent.type === "group") {
// 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 후 그룹 삭제
const childComponents = layout.components.filter((comp) => comp.parentId === selectedComponent.id);
const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position);
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
newComponents = layout.components
.map((comp) => {
if (comp.parentId === selectedComponent.id) {
// 복원된 절대 위치로 업데이트
const restoredChild = restoredChildren.find((restored) => restored.id === comp.id);
return restoredChild || { ...comp, parentId: undefined };
2025-09-01 11:48:12 +09:00
}
2025-09-01 15:22:47 +09:00
return comp;
2025-09-02 16:18:38 +09:00
})
.filter((comp) => comp.id !== selectedComponent.id); // 그룹 컴포넌트 제거
} else {
// 일반 컴포넌트 삭제
newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
}
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(null);
toast.success("컴포넌트가 삭제되었습니다.");
}, [selectedComponent, groupState.selectedComponents, layout, saveToHistory]);
// 컴포넌트 복사
const copyComponent = useCallback(() => {
if (groupState.selectedComponents.length > 0) {
// 다중 선택된 컴포넌트들 복사
const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
setClipboard(componentsToCopy);
// console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개");
2025-09-02 16:18:38 +09:00
toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`);
} else if (selectedComponent) {
// 단일 컴포넌트 복사
setClipboard([selectedComponent]);
// console.log("단일 컴포넌트 복사:", selectedComponent.id);
2025-09-02 16:18:38 +09:00
toast.success("컴포넌트가 복사되었습니다.");
}
}, [selectedComponent, groupState.selectedComponents, layout.components]);
// 컴포넌트 붙여넣기
const pasteComponent = useCallback(() => {
if (clipboard.length === 0) {
toast.warning("복사된 컴포넌트가 없습니다.");
return;
}
const newComponents: ComponentData[] = [];
const offset = 20; // 붙여넣기 시 위치 오프셋
clipboard.forEach((clipComponent, index) => {
const newComponent: ComponentData = {
...clipComponent,
id: generateComponentId(),
position: {
x: clipComponent.position.x + offset + index * 10,
y: clipComponent.position.y + offset + index * 10,
z: clipComponent.position.z || 1,
} as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제
2025-09-01 15:22:47 +09:00
};
2025-09-02 16:18:38 +09:00
newComponents.push(newComponent);
});
const newLayout = {
...layout,
components: [...layout.components, ...newComponents],
};
2025-09-02 16:18:38 +09:00
setLayout(newLayout);
saveToHistory(newLayout);
// 붙여넣은 컴포넌트들을 선택 상태로 만들기
setGroupState((prev) => ({
...prev,
selectedComponents: newComponents.map((comp) => comp.id),
}));
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
2025-09-02 16:18:38 +09:00
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory]);
2025-10-24 10:37:02 +09:00
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
const handleFlowButtonGroup = useCallback(() => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
// 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가
if (selectedComponents.length < 2) {
toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요");
return;
}
// 모두 버튼인지 확인
if (!areAllButtons(selectedComponents)) {
toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다");
return;
}
// 🆕 다이얼로그 열기
setGroupDialogOpen(true);
}, [layout, groupState.selectedComponents]);
// 🆕 그룹 생성 확인 핸들러
const handleGroupConfirm = useCallback(
(settings: {
direction: "horizontal" | "vertical";
gap: number;
align: "start" | "center" | "end" | "space-between" | "space-around";
}) => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
// 고유한 그룹 ID 생성
const newGroupId = generateGroupId();
2025-10-24 17:27:22 +09:00
// 🔧 그룹 위치 및 버튼 재배치 계산
const align = settings.align;
const direction = settings.direction;
const gap = settings.gap;
const groupY = Math.min(...selectedComponents.map((b) => b.position.y));
let anchorButton; // 기준이 되는 버튼
let groupX: number;
// align에 따라 기준 버튼과 그룹 시작점 결정
if (direction === "horizontal") {
if (align === "end") {
// 끝점 정렬: 가장 오른쪽 버튼이 기준
anchorButton = selectedComponents.reduce((max, btn) => {
const rightEdge = btn.position.x + (btn.size?.width || 100);
const maxRightEdge = max.position.x + (max.size?.width || 100);
return rightEdge > maxRightEdge ? btn : max;
});
// 전체 그룹 너비 계산
const totalWidth = selectedComponents.reduce((total, btn, index) => {
const buttonWidth = btn.size?.width || 100;
const gapWidth = index < selectedComponents.length - 1 ? gap : 0;
return total + buttonWidth + gapWidth;
}, 0);
// 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비
groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth;
} else if (align === "center") {
// 중앙 정렬: 버튼들의 중심점을 기준으로
const minX = Math.min(...selectedComponents.map((b) => b.position.x));
const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100)));
const centerX = (minX + maxX) / 2;
const totalWidth = selectedComponents.reduce((total, btn, index) => {
const buttonWidth = btn.size?.width || 100;
const gapWidth = index < selectedComponents.length - 1 ? gap : 0;
return total + buttonWidth + gapWidth;
}, 0);
groupX = centerX - totalWidth / 2;
anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준
} else {
// 시작점 정렬: 가장 왼쪽 버튼이 기준
anchorButton = selectedComponents.reduce((min, btn) => {
return btn.position.x < min.position.x ? btn : min;
});
groupX = anchorButton.position.x;
}
} else {
// 세로 정렬: 가장 위쪽 버튼이 기준
anchorButton = selectedComponents.reduce((min, btn) => {
return btn.position.y < min.position.y ? btn : min;
});
groupX = Math.min(...selectedComponents.map((b) => b.position.x));
}
// 🔧 버튼들의 위치를 그룹 기준으로 재배치
// 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬
2025-10-24 10:37:02 +09:00
const groupedButtons = selectedComponents.map((button) => {
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
2025-10-24 17:27:22 +09:00
// 모든 버튼을 그룹 시작점에 배치
// FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨
const newPosition = {
x: groupX,
y: groupY,
z: button.position.z || 1,
};
2025-10-24 10:37:02 +09:00
return {
...button,
2025-10-24 17:27:22 +09:00
position: newPosition,
2025-10-24 10:37:02 +09:00
webTypeConfig: {
...(button as any).webTypeConfig,
flowVisibilityConfig: {
...currentConfig,
enabled: true,
layoutBehavior: "auto-compact",
groupId: newGroupId,
groupDirection: settings.direction,
groupGap: settings.gap,
groupAlign: settings.align,
},
},
};
});
// 레이아웃 업데이트
const updatedComponents = layout.components.map((comp) => {
const grouped = groupedButtons.find((gb) => gb.id === comp.id);
return grouped || comp;
});
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, {
description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`,
});
console.log("✅ 플로우 버튼 그룹 생성 완료:", {
groupId: newGroupId,
buttonCount: selectedComponents.length,
2025-10-24 17:27:22 +09:00
buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })),
groupPosition: { x: groupX, y: groupY },
2025-10-24 10:37:02 +09:00
settings,
});
},
[layout, groupState.selectedComponents, saveToHistory],
);
// 🆕 플로우 버튼 그룹 해제
const handleFlowButtonUngroup = useCallback(() => {
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
if (selectedComponents.length === 0) {
toast.error("그룹 해제할 버튼을 선택해주세요");
return;
}
// 버튼이 아닌 것 필터링
const buttons = selectedComponents.filter((comp) => areAllButtons([comp]));
if (buttons.length === 0) {
toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다");
return;
}
// 그룹 해제
const ungroupedButtons = ungroupButtons(buttons);
2025-10-29 11:26:00 +09:00
// 레이아웃 업데이트 + 플로우 표시 제어 초기화
const updatedComponents = layout.components.map((comp, index) => {
2025-10-24 10:37:02 +09:00
const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id);
2025-10-29 11:26:00 +09:00
if (ungrouped) {
// 원래 위치 복원 또는 현재 위치 유지 + 간격 추가
const buttonIndex = buttons.findIndex((b) => b.id === comp.id);
const basePosition = comp.position;
// 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록)
const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격
// 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화
return {
...ungrouped,
position: {
x: basePosition.x + offsetX,
y: basePosition.y,
z: basePosition.z || 1,
},
webTypeConfig: {
...ungrouped.webTypeConfig,
flowVisibilityConfig: {
enabled: false,
targetFlowComponentId: null,
mode: "whitelist",
visibleSteps: [],
hiddenSteps: [],
layoutBehavior: "auto-compact",
groupId: null,
groupDirection: "horizontal",
groupGap: 8,
groupAlign: "start",
},
},
};
}
return comp;
2025-10-24 10:37:02 +09:00
});
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
2025-10-29 11:26:00 +09:00
toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`);
2025-10-24 10:37:02 +09:00
}, [layout, groupState.selectedComponents, saveToHistory]);
2025-09-03 11:32:09 +09:00
// 그룹 생성 (임시 비활성화)
2025-09-01 15:22:47 +09:00
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
// console.log("그룹 생성 기능이 임시 비활성화되었습니다.");
2025-09-03 11:32:09 +09:00
toast.info("그룹 기능이 임시 비활성화되었습니다.");
return;
// 격자 정보 계산
const currentGridInfo =
gridInfo ||
calculateGridInfo(
1200,
800,
layout.gridSettings || {
columns: 12,
gap: 16,
2025-10-14 18:07:38 +09:00
padding: 0,
2025-09-03 11:32:09 +09:00
snapToGrid: true,
showGrid: false,
2025-09-03 11:32:09 +09:00
gridColor: "#d1d5db",
gridOpacity: 0.5,
},
);
console.log("🔧 그룹 생성 시작:", {
selectedCount: selectedComponents.length,
snapToGrid: layout.gridSettings?.snapToGrid,
gridInfo: currentGridInfo,
});
// 컴포넌트 크기 조정 기반 그룹 크기 계산
const calculateOptimalGroupSize = () => {
if (!currentGridInfo || !layout.gridSettings?.snapToGrid) {
// 격자 스냅이 비활성화된 경우 기본 패딩 사용
const boundingBox = calculateBoundingBox(selectedComponents);
const padding = 40;
return {
boundingBox,
groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 },
groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 },
gridColumns: 1,
scaledComponents: selectedComponents, // 크기 조정 없음
padding: padding,
};
}
const { columnWidth } = currentGridInfo;
const gap = layout.gridSettings?.gap || 16;
const contentBoundingBox = calculateBoundingBox(selectedComponents);
// 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기
const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap));
// 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산
const groupStartX = startColumn * (columnWidth + gap);
const availableWidthFromStart = contentBoundingBox.maxX - groupStartX;
const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap));
// 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정
const padding = 20;
const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤
const groupY = contentBoundingBox.minY - padding;
const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap
const groupHeight = contentBoundingBox.height + padding * 2;
// 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링
const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비
const scaleFactorX = availableWidth / contentBoundingBox.width;
const scaledComponents = selectedComponents.map((comp) => {
// 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산
const relativeX = comp.position.x - contentBoundingBox.minX;
const relativeY = comp.position.y - contentBoundingBox.minY;
return {
...comp,
position: {
x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치
y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용
z: comp.position.z || 1,
},
size: {
width: comp.size.width * scaleFactorX, // X 방향 스케일링
height: comp.size.height, // Y는 원본 크기 유지
},
};
});
console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", {
originalBoundingBox: contentBoundingBox,
gridCalculation: {
columnWidthPlusGap: columnWidth + gap,
startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`,
groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`,
availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`,
currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`,
finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`,
actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`,
},
groupPosition: { x: groupX, y: groupY },
groupSize: { width: groupWidth, height: groupHeight },
scaleFactorX,
availableWidth,
padding,
scaledComponentsCount: scaledComponents.length,
scaledComponentsDetails: scaledComponents.map((comp) => {
const original = selectedComponents.find((c) => c.id === comp.id);
return {
id: comp.id,
originalPos: original?.position,
scaledPos: comp.position,
originalSize: original?.size,
scaledSize: comp.size,
deltaX: comp.position.x - (original?.position.x || 0),
deltaY: comp.position.y - (original?.position.y || 0),
};
}),
});
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
return {
boundingBox: contentBoundingBox,
groupPosition: { x: groupX, y: groupY, z: 1 },
groupSize: { width: groupWidth, height: groupHeight },
gridColumns: currentWidthInColumns,
scaledComponents: scaledComponents, // 스케일된 컴포넌트들
padding: padding,
};
};
const {
boundingBox,
groupPosition,
groupSize: optimizedGroupSize,
gridColumns,
scaledComponents,
padding,
} = calculateOptimalGroupSize();
// 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요)
2025-09-01 15:57:49 +09:00
const relativeChildren = calculateRelativePositions(
2025-09-03 11:32:09 +09:00
scaledComponents,
groupPosition,
"temp", // 임시 그룹 ID
2025-09-01 15:57:49 +09:00
);
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", {
gridColumns,
groupSize: optimizedGroupSize,
groupPosition,
scaledComponentsCount: scaledComponents.length,
padding,
strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤",
});
// 그룹 컴포넌트 생성 (gridColumns 속성 포함)
const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style);
// 그룹에 계산된 gridColumns 속성 추가
groupComponent.gridColumns = gridColumns;
// 실제 그룹 ID로 자식들 업데이트
const finalChildren = relativeChildren.map((child) => ({
...child,
parentId: groupComponent.id,
}));
2025-09-01 15:22:47 +09:00
const newLayout = {
...layout,
components: [
2025-09-01 15:57:49 +09:00
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
2025-09-01 15:22:47 +09:00
groupComponent,
2025-09-03 11:32:09 +09:00
...finalChildren,
2025-09-01 15:22:47 +09:00
],
};
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-03 11:32:09 +09:00
setGroupState((prev) => ({
...prev,
selectedComponents: [groupComponent.id],
isGrouping: false,
}));
setSelectedComponent(groupComponent);
console.log("🎯 최적화된 그룹 생성 완료:", {
groupId: groupComponent.id,
childrenCount: finalChildren.length,
position: groupPosition,
size: optimizedGroupSize,
gridColumns: groupComponent.gridColumns,
componentsScaled: !!scaledComponents.length,
gridAligned: layout.gridSettings?.snapToGrid,
});
toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`);
2025-09-01 15:22:47 +09:00
},
2025-09-03 11:32:09 +09:00
[layout, saveToHistory, gridInfo],
2025-09-01 15:22:47 +09:00
);
2025-09-02 16:18:38 +09:00
// 그룹 생성 함수 (다이얼로그 표시)
const createGroup = useCallback(() => {
if (groupState.selectedComponents.length < 2) {
toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다.");
return;
}
2025-09-01 15:22:47 +09:00
// console.log("🔄 그룹 생성 다이얼로그 표시");
2025-09-02 16:18:38 +09:00
setShowGroupCreateDialog(true);
}, [groupState.selectedComponents]);
2025-09-01 15:22:47 +09:00
2025-09-03 11:32:09 +09:00
// 그룹 해제 함수 (임시 비활성화)
2025-09-02 16:18:38 +09:00
const ungroupComponents = useCallback(() => {
// console.log("그룹 해제 기능이 임시 비활성화되었습니다.");
2025-09-03 11:32:09 +09:00
toast.info("그룹 해제 기능이 임시 비활성화되었습니다.");
return;
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
const groupId = selectedComponent.id;
2025-09-01 15:22:47 +09:00
2025-09-02 16:18:38 +09:00
// 자식 컴포넌트들의 절대 위치 복원
const childComponents = layout.components.filter((comp) => comp.parentId === groupId);
const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 자식 컴포넌트들의 위치 복원 및 parentId 제거
const updatedComponents = layout.components
.map((comp) => {
if (comp.parentId === groupId) {
const restoredChild = restoredChildren.find((restored) => restored.id === comp.id);
return restoredChild || { ...comp, parentId: undefined };
}
return comp;
})
.filter((comp) => comp.id !== groupId); // 그룹 컴포넌트 제거
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
// 선택 상태 초기화
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}, [selectedComponent, layout, saveToHistory]);
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화
useEffect(() => {
let animationFrameId: number;
const handleMouseMove = (e: MouseEvent) => {
if (dragState.isDragging) {
// requestAnimationFrame으로 부드러운 애니메이션
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
animationFrameId = requestAnimationFrame(() => {
updateDragPosition(e);
});
} else if (selectionDrag.isSelecting) {
updateSelectionDrag(e);
}
};
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
const handleMouseUp = () => {
if (dragState.isDragging) {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
endDrag();
} else if (selectionDrag.isSelecting) {
endSelectionDrag();
2025-09-01 18:42:59 +09:00
}
2025-09-02 16:18:38 +09:00
};
if (dragState.isDragging || selectionDrag.isSelecting) {
document.addEventListener("mousemove", handleMouseMove, { passive: true });
document.addEventListener("mouseup", handleMouseUp);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
2025-09-01 18:42:59 +09:00
};
}
2025-09-02 16:18:38 +09:00
}, [
dragState.isDragging,
selectionDrag.isSelecting,
updateDragPosition,
endDrag,
updateSelectionDrag,
endSelectionDrag,
]);
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 캔버스 크기 초기화 및 리사이즈 이벤트 처리
2025-09-01 18:42:59 +09:00
useEffect(() => {
2025-09-02 16:18:38 +09:00
const updateCanvasSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
}
};
2025-09-01 18:42:59 +09:00
2025-09-02 16:18:38 +09:00
// 초기 크기 설정
updateCanvasSize();
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 리사이즈 이벤트 리스너
window.addEventListener("resize", updateCanvasSize);
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
return () => window.removeEventListener("resize", updateCanvasSize);
2025-09-01 11:48:12 +09:00
}, []);
2025-09-02 16:18:38 +09:00
// 컴포넌트 마운트 후 캔버스 크기 업데이트
useEffect(() => {
const timer = setTimeout(() => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
}, 100);
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
return () => clearTimeout(timer);
}, [selectedScreen]);
2025-09-02 16:18:38 +09:00
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
// console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey });
2025-09-02 16:18:38 +09:00
// 🚫 브라우저 기본 단축키 완전 차단 목록
const browserShortcuts = [
// 검색 관련
{ ctrl: true, key: "f" }, // 페이지 내 검색
{ ctrl: true, key: "g" }, // 다음 검색 결과
{ ctrl: true, shift: true, key: "g" }, // 이전 검색 결과
{ ctrl: true, key: "h" }, // 검색 기록
// 탭/창 관리
{ ctrl: true, key: "t" }, // 새 탭
{ ctrl: true, key: "w" }, // 탭 닫기
{ ctrl: true, shift: true, key: "t" }, // 닫힌 탭 복원
{ ctrl: true, key: "n" }, // 새 창
{ ctrl: true, shift: true, key: "n" }, // 시크릿 창
// 페이지 관리
{ ctrl: true, key: "r" }, // 새로고침
{ ctrl: true, shift: true, key: "r" }, // 강제 새로고침
{ ctrl: true, key: "d" }, // 북마크 추가
{ ctrl: true, shift: true, key: "d" }, // 모든 탭 북마크
// 편집 관련 (필요시에만 허용)
{ ctrl: true, key: "s" }, // 저장 (필요시 차단 해제)
{ ctrl: true, key: "p" }, // 인쇄
{ ctrl: true, key: "o" }, // 파일 열기
{ ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단)
// 개발자 도구
{ key: "F12" }, // 개발자 도구
{ ctrl: true, shift: true, key: "i" }, // 개발자 도구
{ ctrl: true, shift: true, key: "c" }, // 요소 검사
{ ctrl: true, shift: true, key: "j" }, // 콘솔
{ ctrl: true, key: "u" }, // 소스 보기
// 기타
{ ctrl: true, key: "j" }, // 다운로드
{ ctrl: true, shift: true, key: "delete" }, // 브라우징 데이터 삭제
{ ctrl: true, key: "+" }, // 확대
{ ctrl: true, key: "-" }, // 축소
{ ctrl: true, key: "0" }, // 확대/축소 초기화
];
// 브라우저 기본 단축키 체크 및 차단
const isBrowserShortcut = browserShortcuts.some((shortcut) => {
const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true;
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
2025-09-03 11:32:09 +09:00
const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase();
2025-09-02 16:18:38 +09:00
return ctrlMatch && shiftMatch && keyMatch;
});
2025-09-01 11:48:12 +09:00
2025-09-02 16:18:38 +09:00
if (isBrowserShortcut) {
// console.log("🚫 브라우저 기본 단축키 차단:", e.key);
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
2025-09-02 16:18:38 +09:00
// ✅ 애플리케이션 전용 단축키 처리
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
// 1. 그룹 관련 단축키
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
// console.log("🔄 그룹 생성 단축키");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
if (groupState.selectedComponents.length >= 2) {
// console.log("✅ 그룹 생성 실행");
2025-09-02 16:18:38 +09:00
createGroup();
2025-09-01 15:22:47 +09:00
} else {
// console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)");
2025-09-02 16:18:38 +09:00
}
return false;
}
2025-09-02 11:16:40 +09:00
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") {
// console.log("🔄 그룹 해제 단축키");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
2025-09-02 11:16:40 +09:00
2025-09-02 16:18:38 +09:00
if (selectedComponent && selectedComponent.type === "group") {
// console.log("✅ 그룹 해제 실행");
2025-09-02 16:18:38 +09:00
ungroupComponents();
} else {
// console.log("⚠️ 선택된 그룹이 없음");
2025-09-02 16:18:38 +09:00
}
return false;
}
2025-09-02 16:18:38 +09:00
// 2. 전체 선택 (애플리케이션 내에서만)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") {
// console.log("🔄 전체 선택 (애플리케이션 내)");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const allComponentIds = layout.components.map((comp) => comp.id);
setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds }));
return false;
}
2025-09-02 16:18:38 +09:00
// 3. 실행취소/다시실행
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) {
// console.log("🔄 실행취소");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
undo();
return false;
}
2025-09-02 16:18:38 +09:00
if (
2025-09-03 11:32:09 +09:00
((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z")
2025-09-02 16:18:38 +09:00
) {
// console.log("🔄 다시실행");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
redo();
return false;
}
2025-09-02 16:18:38 +09:00
// 4. 복사 (컴포넌트 복사)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") {
// console.log("🔄 컴포넌트 복사");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
copyComponent();
return false;
2025-09-02 11:16:40 +09:00
}
2025-09-02 16:18:38 +09:00
// 5. 붙여넣기 (컴포넌트 붙여넣기)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
// console.log("🔄 컴포넌트 붙여넣기");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
pasteComponent();
return false;
}
2025-09-02 16:18:38 +09:00
// 6. 삭제 (단일/다중 선택 지원)
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
deleteComponent();
return false;
}
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 7. 선택 해제
if (e.key === "Escape") {
// console.log("🔄 선택 해제");
2025-09-02 16:18:38 +09:00
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false }));
return false;
}
2025-09-01 16:40:24 +09:00
2025-09-02 16:18:38 +09:00
// 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용)
2025-09-03 11:32:09 +09:00
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") {
// console.log("💾 레이아웃 저장");
2025-09-02 16:18:38 +09:00
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// 레이아웃 저장 실행
if (layout.components.length > 0 && selectedScreen?.screenId) {
setIsSaving(true);
try {
// 해상도 정보를 포함한 레이아웃 데이터 생성
const layoutWithResolution = {
...layout,
screenResolution: screenResolution,
};
2025-09-04 17:01:07 +09:00
console.log("⚡ 자동 저장할 레이아웃 데이터:", {
componentsCount: layoutWithResolution.components.length,
gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution,
});
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
2025-09-02 16:18:38 +09:00
toast.success("레이아웃이 저장되었습니다.");
} catch (error) {
// console.error("레이아웃 저장 실패:", error);
2025-09-02 16:18:38 +09:00
toast.error("레이아웃 저장에 실패했습니다.");
} finally {
setIsSaving(false);
}
} else {
// console.log("⚠️ 저장할 컴포넌트가 없습니다");
2025-09-02 16:18:38 +09:00
toast.warning("저장할 컴포넌트가 없습니다.");
2025-09-01 16:40:24 +09:00
}
2025-09-02 16:18:38 +09:00
return false;
}
2025-09-02 16:18:38 +09:00
};
// window 레벨에서 캡처 단계에서 가장 먼저 처리
window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [
selectedComponent,
deleteComponent,
copyComponent,
pasteComponent,
undo,
redo,
createGroup,
ungroupComponents,
groupState.selectedComponents,
layout,
selectedScreen,
]);
2025-10-23 15:06:00 +09:00
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
useEffect(() => {
const handleComponentSizeUpdate = (event: CustomEvent) => {
const { componentId, height } = event.detail;
// 해당 컴포넌트 찾기
const targetComponent = layout.components.find((c) => c.id === componentId);
if (!targetComponent) {
return;
}
// 이미 같은 높이면 업데이트 안함
if (targetComponent.size?.height === height) {
return;
}
// 컴포넌트 높이 업데이트
const updatedComponents = layout.components.map((comp) => {
if (comp.id === componentId) {
return {
...comp,
size: {
...comp.size,
width: comp.size?.width || 100,
height: height,
},
};
}
return comp;
});
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
// 선택된 컴포넌트도 업데이트
if (selectedComponent?.id === componentId) {
const updatedComponent = updatedComponents.find((c) => c.id === componentId);
if (updatedComponent) {
setSelectedComponent(updatedComponent);
}
}
};
window.addEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener);
return () => {
window.removeEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener);
};
}, [layout, selectedComponent]);
if (!selectedScreen) {
return (
2025-10-22 17:19:47 +09:00
<div className="bg-background flex h-full items-center justify-center">
<div className="space-y-4 text-center">
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<Database className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-foreground text-lg font-semibold"> </h3>
<p className="text-muted-foreground max-w-sm text-sm"> .</p>
</div>
</div>
);
}
2025-09-01 11:48:12 +09:00
return (
2025-10-28 15:39:22 +09:00
<ScreenPreviewProvider isPreviewMode={true}>
<div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */}
<SlimToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
screenResolution={screenResolution}
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
/>
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 통합 툴바 */}
<LeftUnifiedToolbar buttons={defaultToolbarButtons} panelStates={panelStates} onTogglePanel={togglePanel} />
2025-10-28 17:33:03 +09:00
{/* 통합 패널 */}
{panelStates.unified?.isOpen && (
2025-10-28 15:39:22 +09:00
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
2025-10-28 17:33:03 +09:00
<div className="border-border flex items-center justify-between border-b px-4 py-3">
<h3 className="text-foreground text-sm font-semibold"></h3>
2025-10-28 15:39:22 +09:00
<button
2025-10-28 17:33:03 +09:00
onClick={() => closePanel("unified")}
2025-10-28 15:39:22 +09:00
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
</button>
</div>
2025-10-28 17:33:03 +09:00
<div className="flex min-h-0 flex-1 flex-col">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1">
<TabsTrigger value="components" className="text-xs">
</TabsTrigger>
<TabsTrigger value="properties" className="text-xs">
</TabsTrigger>
</TabsList>
<TabsContent value="components" className="mt-0 flex-1 overflow-hidden">
<ComponentsPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onTableDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
placedColumns={placedColumns}
/>
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
<UnifiedPropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
gridSettings={layout.gridSettings}
2025-10-28 17:33:03 +09:00
onUpdateProperty={updateComponentProperty}
onGridSettingsChange={(newSettings) => {
setLayout((prev) => ({
...prev,
gridSettings: newSettings,
}));
}}
2025-10-28 17:33:03 +09:00
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
dragState={dragState}
onStyleChange={(style) => {
if (selectedComponent) {
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
currentResolution={screenResolution}
onResolutionChange={handleResolutionChange}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
/>
</TabsContent>
</Tabs>
2025-10-28 15:39:22 +09:00
</div>
2025-10-15 10:24:33 +09:00
</div>
2025-10-28 15:39:22 +09:00
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
{/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */}
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
🔍 {Math.round(zoomLevel * 100)}%
</div>
{/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
{(() => {
// 선택된 컴포넌트들
const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
// 버튼 컴포넌트만 필터링
const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
// 플로우 그룹에 속한 버튼이 있는지 확인
const hasFlowGroupButton = selectedButtons.some((btn) => {
const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
});
2025-10-15 10:24:33 +09:00
2025-10-28 15:39:22 +09:00
// 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
2025-10-24 17:27:22 +09:00
2025-10-28 15:39:22 +09:00
if (!shouldShow) return null;
2025-10-24 17:27:22 +09:00
2025-10-28 15:39:22 +09:00
return (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
2025-10-24 17:27:22 +09:00
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
2025-10-28 15:39:22 +09:00
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline>
<line x1="12" y1="22" x2="12" y2="12"></line>
2025-10-24 17:27:22 +09:00
</svg>
2025-10-28 15:39:22 +09:00
<span className="font-medium">{selectedButtons.length} </span>
</div>
2025-10-24 17:27:22 +09:00
2025-10-28 15:39:22 +09:00
{/* 그룹 생성 버튼 (2개 이상 선택 시) */}
{selectedButtons.length >= 2 && (
<Button
size="sm"
variant="default"
onClick={handleFlowButtonGroup}
disabled={selectedButtons.length < 2}
className="flex items-center gap-2 text-xs"
2025-10-24 17:27:22 +09:00
>
2025-10-28 15:39:22 +09:00
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
</Button>
)}
{/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
{hasFlowGroupButton && (
<Button
size="sm"
variant="outline"
onClick={handleFlowButtonUngroup}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</Button>
)}
{/* 상태 표시 */}
{hasFlowGroupButton && <p className="mt-1 text-[10px] text-blue-600"> </p>}
</div>
2025-10-24 10:37:02 +09:00
</div>
2025-10-28 15:39:22 +09:00
);
})()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
2025-10-15 10:24:33 +09:00
<div
2025-10-28 15:39:22 +09:00
className="flex justify-center"
style={{
2025-10-28 15:39:22 +09:00
width: "100%",
minHeight: screenResolution.height * zoomLevel,
2025-10-15 10:24:33 +09:00
}}
>
2025-10-28 15:39:22 +09:00
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
<div
2025-10-28 15:39:22 +09:00
className="bg-background border-border border shadow-lg"
style={{
width: `${screenResolution.width}px`,
height: `${screenResolution.height}px`,
minWidth: `${screenResolution.width}px`,
maxWidth: `${screenResolution.width}px`,
minHeight: `${screenResolution.height}px`,
flexShrink: 0,
transform: `scale(${zoomLevel})`,
transformOrigin: "top center",
}}
>
2025-10-28 15:39:22 +09:00
<div
ref={canvasRef}
className="bg-background relative h-full w-full overflow-visible"
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
// Pan 모드가 아닐 때만 다중 선택 시작
if (e.target === e.currentTarget && !isPanMode) {
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="bg-border 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%",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
const topLevelComponents = layout.components.filter((component) => !component.parentId);
// auto-compact 모드의 버튼들을 그룹별로 묶기
const buttonGroups: Record<string, ComponentData[]> = {};
const processedButtonIds = new Set<string>();
topLevelComponents.forEach((component) => {
const isButton =
component.type === "button" ||
(component.type === "component" &&
["button-primary", "button-secondary"].includes((component as any).componentType));
if (isButton) {
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
| FlowVisibilityConfig
| undefined;
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
if (!buttonGroups[flowConfig.groupId]) {
buttonGroups[flowConfig.groupId] = [];
}
buttonGroups[flowConfig.groupId].push(component);
processedButtonIds.add(component.id);
}
2025-10-15 10:24:33 +09:00
}
2025-10-28 15:39:22 +09:00
});
// 그룹에 속하지 않은 일반 컴포넌트들
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
return (
<>
{/* 일반 컴포넌트들 */}
{regularComponents.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) {
// 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
2025-10-24 10:37:02 +09:00
displayComponent = {
...component,
2025-10-28 15:39:22 +09:00
position: dragState.currentPosition,
2025-10-24 10:37:02 +09:00
style: {
...component.style,
opacity: 0.8,
2025-10-28 15:39:22 +09:00
transform: "scale(1.02)",
2025-10-24 10:37:02 +09:00
transition: "none",
2025-10-28 15:39:22 +09:00
zIndex: 50,
},
};
2025-10-28 15:39:22 +09:00
} 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, // 주 컴포넌트보다 약간 낮게
},
};
}
}
2025-10-24 10:37:02 +09:00
}
2025-10-28 15:39:22 +09:00
// 전역 파일 상태도 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;
});
2025-10-24 10:37:02 +09:00
2025-10-28 15:39:22 +09:00
const newLayout = {
...layout,
components: updatedComponents,
};
setLayout(newLayout);
saveToHistory(newLayout);
2025-10-24 10:37:02 +09:00
2025-10-28 15:39:22 +09:00
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>
);
})}
{/* 🆕 플로우 버튼 그룹들 */}
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
if (buttons.length === 0) return null;
const firstButton = buttons[0];
const groupConfig = (firstButton as any).webTypeConfig
?.flowVisibilityConfig as FlowVisibilityConfig;
// 🔧 그룹의 위치 및 크기 계산
// 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
// 첫 번째 버튼의 위치를 그룹 시작점으로 사용
const direction = groupConfig.groupDirection || "horizontal";
const gap = groupConfig.groupGap ?? 8;
const align = groupConfig.groupAlign || "start";
const groupPosition = {
x: buttons[0].position.x,
y: buttons[0].position.y,
z: buttons[0].position.z || 2,
};
// 버튼들의 실제 크기 계산
let groupWidth = 0;
let groupHeight = 0;
if (direction === "horizontal") {
// 가로 정렬: 모든 버튼의 너비 + 간격
groupWidth = buttons.reduce((total, button, index) => {
const buttonWidth = button.size?.width || 100;
const gapWidth = index < buttons.length - 1 ? gap : 0;
return total + buttonWidth + gapWidth;
}, 0);
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
} else {
// 세로 정렬
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
groupHeight = buttons.reduce((total, button, index) => {
const buttonHeight = button.size?.height || 40;
const gapHeight = index < buttons.length - 1 ? gap : 0;
return total + buttonHeight + gapHeight;
}, 0);
}
// 🆕 그룹 전체가 선택되었는지 확인
const isGroupSelected = buttons.every(
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
);
const hasAnySelected = buttons.some(
(btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id),
);
return (
<div
key={`flow-button-group-${groupId}`}
style={{
position: "absolute",
left: `${groupPosition.x}px`,
top: `${groupPosition.y}px`,
zIndex: groupPosition.z,
width: `${groupWidth}px`, // 🆕 명시적 너비
height: `${groupHeight}px`, // 🆕 명시적 높이
pointerEvents: "none", // 그룹 컨테이너는 이벤트 차단하여 개별 버튼 클릭 가능
}}
className={hasAnySelected ? "rounded outline-2 outline-offset-2 outline-blue-500" : ""}
>
<FlowButtonGroup
buttons={buttons}
groupConfig={groupConfig}
isDesignMode={true}
renderButton={(button, isVisible) => {
// 드래그 피드백
const isDraggingThis =
dragState.isDragging && dragState.draggedComponent?.id === button.id;
const isBeingDragged =
2025-10-24 10:37:02 +09:00
dragState.isDragging &&
2025-10-28 15:39:22 +09:00
dragState.draggedComponents.some((dragComp) => dragComp.id === button.id);
2025-10-24 10:37:02 +09:00
2025-10-28 15:39:22 +09:00
let displayButton = button;
2025-10-24 10:37:02 +09:00
2025-10-28 15:39:22 +09:00
if (isBeingDragged) {
if (isDraggingThis) {
displayButton = {
...button,
2025-10-24 10:37:02 +09:00
position: dragState.currentPosition,
style: {
2025-10-28 15:39:22 +09:00
...button.style,
2025-10-24 10:37:02 +09:00
opacity: 0.8,
transform: "scale(1.02)",
transition: "none",
zIndex: 50,
},
};
}
}
2025-10-28 15:39:22 +09:00
// 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리)
const relativeButton = {
...displayButton,
2025-10-24 10:37:02 +09:00
position: {
2025-10-28 15:39:22 +09:00
x: 0,
y: 0,
z: displayButton.position.z || 1,
2025-10-24 10:37:02 +09:00
},
};
return (
2025-10-28 15:39:22 +09:00
<div
key={button.id}
style={{
position: "relative",
opacity: isVisible ? 1 : 0.5,
display: "inline-block",
width: button.size?.width || 100,
height: button.size?.height || 40,
pointerEvents: "auto", // 개별 버튼은 이벤트 활성화
cursor: "pointer",
2025-10-24 10:37:02 +09:00
}}
2025-10-28 15:39:22 +09:00
onMouseDown={(e) => {
// 클릭이 아닌 드래그인 경우에만 드래그 시작
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
let isDragging = false;
let dragStarted = false;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = Math.abs(moveEvent.clientX - startX);
const deltaY = Math.abs(moveEvent.clientY - startY);
// 5픽셀 이상 움직이면 드래그로 간주
if ((deltaX > 5 || deltaY > 5) && !dragStarted) {
isDragging = true;
dragStarted = true;
// Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
if (!e.shiftKey) {
const buttonIds = buttons.map((b) => b.id);
setGroupState((prev) => ({
...prev,
selectedComponents: buttonIds,
}));
}
// 드래그 시작
startComponentDrag(button, e as any);
2025-10-24 17:27:22 +09:00
}
2025-10-28 15:39:22 +09:00
};
2025-10-24 17:27:22 +09:00
2025-10-28 15:39:22 +09:00
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// 드래그가 아니면 클릭으로 처리
if (!isDragging) {
// Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택
if (!e.shiftKey) {
const buttonIds = buttons.map((b) => b.id);
setGroupState((prev) => ({
...prev,
selectedComponents: buttonIds,
}));
}
handleComponentClick(button, e);
2025-10-24 17:27:22 +09:00
}
2025-10-28 15:39:22 +09:00
};
2025-10-24 17:27:22 +09:00
2025-10-28 15:39:22 +09:00
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
onDoubleClick={(e) => {
e.stopPropagation();
handleComponentDoubleClick(button, e);
}}
className={
selectedComponent?.id === button.id ||
groupState.selectedComponents.includes(button.id)
? "outline-1 outline-offset-1 outline-blue-400"
: ""
}
>
{/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
<div style={{ width: "100%", height: "100%", pointerEvents: "none" }}>
<DynamicComponentRenderer
component={relativeButton}
isDesignMode={true}
formData={{}}
onDataflowComplete={() => {}}
/>
</div>
2025-10-24 10:37:02 +09:00
</div>
2025-10-28 15:39:22 +09:00
);
}}
/>
</div>
);
})}
</>
);
})()}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="border-primary bg-primary/5 pointer-events-none absolute rounded-md border-2 border-dashed"
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`,
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="max-w-2xl space-y-4 px-6 text-center">
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<Database className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-foreground text-xl font-semibold"> </h3>
<p className="text-muted-foreground text-sm">
/ 릿
2025-10-22 17:19:47 +09:00
</p>
2025-10-28 15:39:22 +09:00
<div className="text-muted-foreground space-y-2 text-xs">
<p>
<span className="font-medium">:</span> T(), M(릿), P(), S(),
R(), D(), E()
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>
<span> </span>
</p>
</div>
2025-10-22 17:19:47 +09:00
</div>
</div>
2025-10-28 15:39:22 +09:00
)}
</div>
</div>
2025-10-28 15:39:22 +09:00
</div>{" "}
{/* 🔥 줌 래퍼 닫기 */}
</div>
</div>{" "}
{/* 메인 컨테이너 닫기 */}
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
<FlowButtonGroupDialog
open={groupDialogOpen}
onOpenChange={setGroupDialogOpen}
buttonCount={groupState.selectedComponents.length}
onConfirm={handleGroupConfirm}
2025-09-03 11:32:09 +09:00
/>
2025-10-28 15:39:22 +09:00
{/* 모달들 */}
{/* 메뉴 할당 모달 */}
{showMenuAssignmentModal && selectedScreen && (
<MenuAssignmentModal
screenInfo={selectedScreen}
isOpen={showMenuAssignmentModal}
onClose={() => setShowMenuAssignmentModal(false)}
onAssignmentComplete={() => {
// 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
// setShowMenuAssignmentModal(false);
// toast.success("메뉴에 화면이 할당되었습니다.");
}}
onBackToList={onBackToList}
/>
)}
{/* 파일첨부 상세 모달 */}
{showFileAttachmentModal && selectedFileComponent && (
<FileAttachmentDetailModal
isOpen={showFileAttachmentModal}
onClose={() => {
setShowFileAttachmentModal(false);
setSelectedFileComponent(null);
}}
component={selectedFileComponent}
screenId={selectedScreen.screenId}
/>
)}
</div>
</ScreenPreviewProvider>
2025-09-01 11:48:12 +09:00
);
}