2158 lines
84 KiB
TypeScript
2158 lines
84 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||
|
|
|
||
|
|
import {
|
||
|
|
Group,
|
||
|
|
Database,
|
||
|
|
Trash2,
|
||
|
|
Copy,
|
||
|
|
Clipboard,
|
||
|
|
} from "lucide-react";
|
||
|
|
import {
|
||
|
|
ScreenDefinition,
|
||
|
|
ComponentData,
|
||
|
|
LayoutData,
|
||
|
|
GroupState,
|
||
|
|
WebType,
|
||
|
|
TableInfo,
|
||
|
|
GroupComponent,
|
||
|
|
Position,
|
||
|
|
} from "@/types/screen";
|
||
|
|
import { generateComponentId } from "@/lib/utils/generateId";
|
||
|
|
import {
|
||
|
|
createGroupComponent,
|
||
|
|
calculateBoundingBox,
|
||
|
|
calculateRelativePositions,
|
||
|
|
restoreAbsolutePositions,
|
||
|
|
getGroupChildren,
|
||
|
|
} from "@/lib/utils/groupingUtils";
|
||
|
|
import {
|
||
|
|
calculateGridInfo,
|
||
|
|
snapToGrid,
|
||
|
|
snapSizeToGrid,
|
||
|
|
generateGridLines,
|
||
|
|
GridSettings as GridUtilSettings,
|
||
|
|
} from "@/lib/utils/gridUtils";
|
||
|
|
import { GroupingToolbar } from "./GroupingToolbar";
|
||
|
|
import { screenApi } from "@/lib/api/screen";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
|
||
|
|
import StyleEditor from "./StyleEditor";
|
||
|
|
import { RealtimePreview } from "./RealtimePreview";
|
||
|
|
import FloatingPanel from "./FloatingPanel";
|
||
|
|
import DesignerToolbar from "./DesignerToolbar";
|
||
|
|
import TablesPanel from "./panels/TablesPanel";
|
||
|
|
import PropertiesPanel from "./panels/PropertiesPanel";
|
||
|
|
import GridPanel from "./panels/GridPanel";
|
||
|
|
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||
|
|
|
||
|
|
interface ScreenDesignerProps {
|
||
|
|
selectedScreen: ScreenDefinition | null;
|
||
|
|
onBackToList: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 패널 설정
|
||
|
|
const panelConfigs: PanelConfig[] = [
|
||
|
|
{
|
||
|
|
id: "tables",
|
||
|
|
title: "테이블 목록",
|
||
|
|
defaultPosition: "left",
|
||
|
|
defaultWidth: 320,
|
||
|
|
defaultHeight: 600,
|
||
|
|
shortcutKey: "t",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "properties",
|
||
|
|
title: "속성 편집",
|
||
|
|
defaultPosition: "right",
|
||
|
|
defaultWidth: 320,
|
||
|
|
defaultHeight: 500,
|
||
|
|
shortcutKey: "p",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "styles",
|
||
|
|
title: "스타일 편집",
|
||
|
|
defaultPosition: "right",
|
||
|
|
defaultWidth: 320,
|
||
|
|
defaultHeight: 400,
|
||
|
|
shortcutKey: "s",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "grid",
|
||
|
|
title: "격자 설정",
|
||
|
|
defaultPosition: "right",
|
||
|
|
defaultWidth: 280,
|
||
|
|
defaultHeight: 450,
|
||
|
|
shortcutKey: "g",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
||
|
|
// 패널 상태 관리
|
||
|
|
const {
|
||
|
|
panelStates,
|
||
|
|
togglePanel,
|
||
|
|
openPanel,
|
||
|
|
closePanel,
|
||
|
|
closeAllPanels,
|
||
|
|
} = usePanelState(panelConfigs);
|
||
|
|
|
||
|
|
const [layout, setLayout] = useState<LayoutData>({
|
||
|
|
components: [],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
|
||
|
|
});
|
||
|
|
const [isSaving, setIsSaving] = useState(false);
|
||
|
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||
|
|
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
||
|
|
|
||
|
|
// 실행취소/다시실행을 위한 히스토리 상태
|
||
|
|
const [history, setHistory] = useState<LayoutData[]>([
|
||
|
|
{
|
||
|
|
components: [],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||
|
|
|
||
|
|
// 클립보드 상태 (복사/붙여넣기용)
|
||
|
|
const [clipboard, setClipboard] = useState<{
|
||
|
|
type: "single" | "multiple" | "group";
|
||
|
|
data: ComponentData[];
|
||
|
|
offset: { x: number; y: number };
|
||
|
|
boundingBox?: { x: number; y: number; width: number; height: number };
|
||
|
|
} | null>(null);
|
||
|
|
|
||
|
|
// 히스토리에 상태 저장
|
||
|
|
const saveToHistory = useCallback(
|
||
|
|
(newLayout: LayoutData) => {
|
||
|
|
setHistory((prevHistory) => {
|
||
|
|
const newHistory = prevHistory.slice(0, historyIndex + 1);
|
||
|
|
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
|
||
|
|
return newHistory.slice(-50); // 최대 50개 히스토리 유지
|
||
|
|
});
|
||
|
|
setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49));
|
||
|
|
setHasUnsavedChanges(true); // 변경사항 표시
|
||
|
|
},
|
||
|
|
[historyIndex],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 실행취소
|
||
|
|
const undo = useCallback(() => {
|
||
|
|
if (historyIndex > 0) {
|
||
|
|
const newIndex = historyIndex - 1;
|
||
|
|
setHistoryIndex(newIndex);
|
||
|
|
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
|
||
|
|
setSelectedComponent(null); // 선택 해제
|
||
|
|
}
|
||
|
|
}, [historyIndex, history]);
|
||
|
|
|
||
|
|
// 다시실행
|
||
|
|
const redo = useCallback(() => {
|
||
|
|
if (historyIndex < history.length - 1) {
|
||
|
|
const newIndex = historyIndex + 1;
|
||
|
|
setHistoryIndex(newIndex);
|
||
|
|
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
|
||
|
|
setSelectedComponent(null); // 선택 해제
|
||
|
|
}
|
||
|
|
}, [historyIndex, history]);
|
||
|
|
|
||
|
|
const [dragState, setDragState] = useState({
|
||
|
|
isDragging: false,
|
||
|
|
draggedComponent: null as ComponentData | null,
|
||
|
|
draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: 0, y: 0 },
|
||
|
|
isMultiDrag: false, // 다중 드래그 여부
|
||
|
|
initialMouse: { x: 0, y: 0 },
|
||
|
|
grabOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
const [groupState, setGroupState] = useState<GroupState>({
|
||
|
|
isGrouping: false,
|
||
|
|
selectedComponents: [],
|
||
|
|
groupTarget: null,
|
||
|
|
groupMode: "create",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 그룹 생성 다이얼로그 상태
|
||
|
|
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
|
||
|
|
|
||
|
|
// 캔버스 컨테이너 참조
|
||
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
// 격자 정보 계산
|
||
|
|
const gridInfo = useMemo(() => {
|
||
|
|
if (!layout.gridSettings) return null;
|
||
|
|
|
||
|
|
// canvasRef가 없거나 크기가 0인 경우 기본값 사용
|
||
|
|
let width = 800;
|
||
|
|
let height = 600;
|
||
|
|
|
||
|
|
if (canvasRef.current) {
|
||
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
||
|
|
width = Math.max(rect.width || 800, 800);
|
||
|
|
height = Math.max(rect.height || 600, 600);
|
||
|
|
}
|
||
|
|
|
||
|
|
return calculateGridInfo(width, height, layout.gridSettings as GridUtilSettings);
|
||
|
|
}, [layout.gridSettings]);
|
||
|
|
|
||
|
|
// 격자 설정 변경 핸들러
|
||
|
|
const handleGridSettingsChange = useCallback(
|
||
|
|
(newGridSettings: GridUtilSettings) => {
|
||
|
|
let updatedComponents = layout.components;
|
||
|
|
|
||
|
|
// 격자 스냅이 활성화되어 있고 격자 정보가 있으며 컴포넌트가 있는 경우 기존 컴포넌트들을 새 격자에 맞춤
|
||
|
|
if (newGridSettings.snapToGrid && gridInfo && layout.components.length > 0) {
|
||
|
|
// 현재 캔버스 크기 가져오기
|
||
|
|
let canvasWidth = 800;
|
||
|
|
let canvasHeight = 600;
|
||
|
|
|
||
|
|
if (canvasRef.current) {
|
||
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
||
|
|
canvasWidth = Math.max(rect.width || 800, 800);
|
||
|
|
canvasHeight = Math.max(rect.height || 600, 600);
|
||
|
|
}
|
||
|
|
|
||
|
|
const newGridInfo = calculateGridInfo(canvasWidth, canvasHeight, newGridSettings);
|
||
|
|
|
||
|
|
updatedComponents = layout.components.map((comp) => {
|
||
|
|
// 그룹의 자식 컴포넌트는 건드리지 않음 (그룹에서 처리)
|
||
|
|
if (comp.parentId) return comp;
|
||
|
|
|
||
|
|
// 기존 격자에서의 상대적 위치 계산 (격자 컬럼 단위)
|
||
|
|
const oldGridInfo = gridInfo;
|
||
|
|
const oldColumnWidth = oldGridInfo.columnWidth;
|
||
|
|
const oldGap = layout.gridSettings?.gap || 16;
|
||
|
|
const oldPadding = layout.gridSettings?.padding || 16;
|
||
|
|
|
||
|
|
// 기존 위치를 격자 컬럼/행 단위로 변환
|
||
|
|
const oldGridX = Math.round((comp.position.x - oldPadding) / (oldColumnWidth + oldGap));
|
||
|
|
const oldGridY = Math.round((comp.position.y - oldPadding) / 20); // 20px 단위
|
||
|
|
|
||
|
|
// 기존 크기를 격자 컬럼 단위로 변환
|
||
|
|
const oldGridColumns = Math.max(1, Math.round((comp.size.width + oldGap) / (oldColumnWidth + oldGap)));
|
||
|
|
const oldGridRows = Math.max(2, Math.round(comp.size.height / 20)); // 20px 단위
|
||
|
|
|
||
|
|
// 새 격자에서의 위치와 크기 계산
|
||
|
|
const newColumnWidth = newGridInfo.columnWidth;
|
||
|
|
const newGap = newGridSettings.gap;
|
||
|
|
const newPadding = newGridSettings.padding;
|
||
|
|
|
||
|
|
// 새 위치 계산 (격자 비율 유지)
|
||
|
|
const newX = newPadding + oldGridX * (newColumnWidth + newGap);
|
||
|
|
const newY = newPadding + oldGridY * 20;
|
||
|
|
|
||
|
|
// 새 크기 계산 (격자 비율 유지)
|
||
|
|
const newWidth = oldGridColumns * newColumnWidth + (oldGridColumns - 1) * newGap;
|
||
|
|
const newHeight = oldGridRows * 20;
|
||
|
|
|
||
|
|
return {
|
||
|
|
...comp,
|
||
|
|
position: { x: newX, y: newY, z: comp.position.z || 1 },
|
||
|
|
size: { width: newWidth, height: newHeight },
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: updatedComponents,
|
||
|
|
gridSettings: newGridSettings,
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
},
|
||
|
|
[layout, saveToHistory, gridInfo],
|
||
|
|
);
|
||
|
|
|
||
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||
|
|
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||
|
|
|
||
|
|
// 테이블 검색 및 페이징 상태 추가
|
||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
const [itemsPerPage] = useState(10);
|
||
|
|
|
||
|
|
// 드래그 박스(마키) 다중선택 상태
|
||
|
|
const [selectionState, setSelectionState] = useState({
|
||
|
|
isSelecting: false,
|
||
|
|
start: { x: 0, y: 0 },
|
||
|
|
current: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
|
||
|
|
// 선택된 컴포넌트를 항상 레이아웃 최신 값으로 참조 (좌표 실시간 반영용)
|
||
|
|
const selectedFromLayout = useMemo(() => {
|
||
|
|
if (!selectedComponent) return null;
|
||
|
|
return layout.components.find((c) => c.id === selectedComponent.id) || null;
|
||
|
|
}, [selectedComponent, layout.components]);
|
||
|
|
|
||
|
|
// 드래그 중에는 라이브 좌표를 계산하여 속성 패널에 표시
|
||
|
|
const liveSelectedPosition = useMemo(() => {
|
||
|
|
if (!selectedFromLayout) return { x: 0, y: 0 };
|
||
|
|
|
||
|
|
let x = selectedFromLayout.position.x;
|
||
|
|
let y = selectedFromLayout.position.y;
|
||
|
|
|
||
|
|
if (dragState.isDragging) {
|
||
|
|
const isSelectedInMulti = groupState.selectedComponents.includes(selectedFromLayout.id);
|
||
|
|
if (dragState.isMultiDrag && isSelectedInMulti) {
|
||
|
|
const deltaX = dragState.currentPosition.x - dragState.initialMouse.x;
|
||
|
|
const deltaY = dragState.currentPosition.y - dragState.initialMouse.y;
|
||
|
|
x = selectedFromLayout.position.x + deltaX;
|
||
|
|
y = selectedFromLayout.position.y + deltaY;
|
||
|
|
} else if (dragState.draggedComponent?.id === selectedFromLayout.id) {
|
||
|
|
x = dragState.currentPosition.x - dragState.grabOffset.x;
|
||
|
|
y = dragState.currentPosition.y - dragState.grabOffset.y;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { x: Math.round(x), y: Math.round(y) };
|
||
|
|
}, [
|
||
|
|
selectedFromLayout,
|
||
|
|
dragState.isDragging,
|
||
|
|
dragState.isMultiDrag,
|
||
|
|
dragState.currentPosition.x,
|
||
|
|
dragState.currentPosition.y,
|
||
|
|
dragState.initialMouse.x,
|
||
|
|
dragState.initialMouse.y,
|
||
|
|
dragState.grabOffset.x,
|
||
|
|
dragState.grabOffset.y,
|
||
|
|
groupState.selectedComponents,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적)
|
||
|
|
const getAbsolutePosition = useCallback(
|
||
|
|
(comp: ComponentData) => {
|
||
|
|
let x = comp.position.x;
|
||
|
|
let y = comp.position.y;
|
||
|
|
let cur: ComponentData | undefined = comp;
|
||
|
|
while (cur.parentId) {
|
||
|
|
const parent = layout.components.find((c) => c.id === cur!.parentId);
|
||
|
|
if (!parent) break;
|
||
|
|
x += parent.position.x;
|
||
|
|
y += parent.position.y;
|
||
|
|
cur = parent;
|
||
|
|
}
|
||
|
|
return { x, y };
|
||
|
|
},
|
||
|
|
[layout.components],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 마키 선택 시작 (캔버스 빈 영역 마우스다운)
|
||
|
|
const handleMarqueeStart = useCallback(
|
||
|
|
(e: React.MouseEvent) => {
|
||
|
|
if (dragState.isDragging) return; // 드래그 중이면 무시
|
||
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const x = rect ? e.clientX - rect.left + scrollLeft : 0;
|
||
|
|
const y = rect ? e.clientY - rect.top + scrollTop : 0;
|
||
|
|
setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } });
|
||
|
|
// 기존 선택 초기화 (Shift 미사용 시)
|
||
|
|
if (!e.shiftKey) {
|
||
|
|
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[dragState.isDragging],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 마키 이동
|
||
|
|
const handleMarqueeMove = useCallback(
|
||
|
|
(e: React.MouseEvent) => {
|
||
|
|
if (!selectionState.isSelecting) return;
|
||
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const x = rect ? e.clientX - rect.left + scrollLeft : 0;
|
||
|
|
const y = rect ? e.clientY - rect.top + scrollTop : 0;
|
||
|
|
setSelectionState((prev) => ({ ...prev, current: { x, y } }));
|
||
|
|
},
|
||
|
|
[selectionState.isSelecting],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 마키 종료 -> 영역 내 컴포넌트 선택
|
||
|
|
const handleMarqueeEnd = useCallback(() => {
|
||
|
|
if (!selectionState.isSelecting) return;
|
||
|
|
const minX = Math.min(selectionState.start.x, selectionState.current.x);
|
||
|
|
const minY = Math.min(selectionState.start.y, selectionState.current.y);
|
||
|
|
const maxX = Math.max(selectionState.start.x, selectionState.current.x);
|
||
|
|
const maxY = Math.max(selectionState.start.y, selectionState.current.y);
|
||
|
|
|
||
|
|
const selectedIds = layout.components
|
||
|
|
// 그룹 컨테이너는 제외
|
||
|
|
.filter((c) => c.type !== "group")
|
||
|
|
.filter((c) => {
|
||
|
|
const abs = getAbsolutePosition(c);
|
||
|
|
const left = abs.x;
|
||
|
|
const top = abs.y;
|
||
|
|
const right = abs.x + c.size.width;
|
||
|
|
const bottom = abs.y + c.size.height;
|
||
|
|
// 영역과 교차 여부 판단 (일부라도 겹치면 선택)
|
||
|
|
return right >= minX && left <= maxX && bottom >= minY && top <= maxY;
|
||
|
|
})
|
||
|
|
.map((c) => c.id);
|
||
|
|
|
||
|
|
setGroupState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])),
|
||
|
|
}));
|
||
|
|
setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } });
|
||
|
|
}, [selectionState, layout.components, getAbsolutePosition]);
|
||
|
|
|
||
|
|
// 선택된 화면의 테이블만 로드 (최적화된 API 사용)
|
||
|
|
useEffect(() => {
|
||
|
|
const fetchScreenTable = async () => {
|
||
|
|
if (!selectedScreen?.tableName) {
|
||
|
|
setTables([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`);
|
||
|
|
const startTime = performance.now();
|
||
|
|
|
||
|
|
// 최적화된 단일 테이블 조회 API 사용
|
||
|
|
const response = await fetch(`http://localhost:8080/api/screen-management/tables/${selectedScreen.tableName}`, {
|
||
|
|
headers: {
|
||
|
|
Authorization: `Bearer ${localStorage.getItem("authToken")}`,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const endTime = performance.now();
|
||
|
|
console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`);
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.success && data.data) {
|
||
|
|
setTables([data.data]);
|
||
|
|
console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`);
|
||
|
|
} else {
|
||
|
|
console.error("테이블 조회 실패:", data.message);
|
||
|
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||
|
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||
|
|
}
|
||
|
|
} else if (response.status === 404) {
|
||
|
|
console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`);
|
||
|
|
// 테이블이 존재하지 않는 경우 임시 데이터 생성
|
||
|
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||
|
|
} else {
|
||
|
|
console.error("테이블 조회 실패:", response.status);
|
||
|
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||
|
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 조회 중 오류:", error);
|
||
|
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||
|
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
fetchScreenTable();
|
||
|
|
}, [selectedScreen?.tableName]);
|
||
|
|
|
||
|
|
// 검색된 테이블 필터링
|
||
|
|
const filteredTables = useMemo(() => {
|
||
|
|
if (!searchTerm.trim()) return tables;
|
||
|
|
|
||
|
|
return tables.filter(
|
||
|
|
(table) =>
|
||
|
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
table.columns.some(
|
||
|
|
(column) =>
|
||
|
|
column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
(column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}, [tables, searchTerm]);
|
||
|
|
|
||
|
|
// 페이징된 테이블
|
||
|
|
const paginatedTables = useMemo(() => {
|
||
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
|
|
const endIndex = startIndex + itemsPerPage;
|
||
|
|
return filteredTables.slice(startIndex, endIndex);
|
||
|
|
}, [filteredTables, currentPage, itemsPerPage]);
|
||
|
|
|
||
|
|
// 총 페이지 수 계산
|
||
|
|
const totalPages = Math.ceil(filteredTables.length / itemsPerPage);
|
||
|
|
|
||
|
|
// 페이지 변경 핸들러
|
||
|
|
const handlePageChange = (page: number) => {
|
||
|
|
setCurrentPage(page);
|
||
|
|
setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화
|
||
|
|
};
|
||
|
|
|
||
|
|
// 검색어 변경 핸들러
|
||
|
|
const handleSearchChange = (value: string) => {
|
||
|
|
setSearchTerm(value);
|
||
|
|
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||
|
|
setExpandedTables(new Set()); // 검색 시 확장 상태 초기화
|
||
|
|
};
|
||
|
|
|
||
|
|
// 임시 테이블 데이터 (API 실패 시 사용)
|
||
|
|
// 사용하지 않는 getMockTables 함수 제거됨
|
||
|
|
|
||
|
|
// 특정 테이블에 대한 임시 데이터 생성
|
||
|
|
const createMockTableForScreen = (tableName: string): TableInfo => {
|
||
|
|
// 기본 컬럼들 생성
|
||
|
|
const baseColumns = [
|
||
|
|
{
|
||
|
|
tableName,
|
||
|
|
columnName: "id",
|
||
|
|
columnLabel: "ID",
|
||
|
|
webType: "number" as WebType,
|
||
|
|
dataType: "BIGINT",
|
||
|
|
isNullable: "NO",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tableName,
|
||
|
|
columnName: "name",
|
||
|
|
columnLabel: "이름",
|
||
|
|
webType: "text" as WebType,
|
||
|
|
dataType: "VARCHAR",
|
||
|
|
isNullable: "NO",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tableName,
|
||
|
|
columnName: "description",
|
||
|
|
columnLabel: "설명",
|
||
|
|
webType: "textarea" as WebType,
|
||
|
|
dataType: "TEXT",
|
||
|
|
isNullable: "YES",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tableName,
|
||
|
|
columnName: "created_date",
|
||
|
|
columnLabel: "생성일",
|
||
|
|
webType: "date" as WebType,
|
||
|
|
dataType: "TIMESTAMP",
|
||
|
|
isNullable: "NO",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
tableName,
|
||
|
|
columnName: "updated_date",
|
||
|
|
columnLabel: "수정일",
|
||
|
|
webType: "date" as WebType,
|
||
|
|
dataType: "TIMESTAMP",
|
||
|
|
isNullable: "YES",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return {
|
||
|
|
tableName,
|
||
|
|
tableLabel: `${tableName} (임시)`,
|
||
|
|
columns: baseColumns,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
// 테이블 확장/축소 토글
|
||
|
|
const toggleTableExpansion = useCallback((tableName: string) => {
|
||
|
|
setExpandedTables((prev) => {
|
||
|
|
const newSet = new Set(prev);
|
||
|
|
if (newSet.has(tableName)) {
|
||
|
|
newSet.delete(tableName);
|
||
|
|
} else {
|
||
|
|
newSet.add(tableName);
|
||
|
|
}
|
||
|
|
return newSet;
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 웹타입에 따른 위젯 타입 매핑
|
||
|
|
const getWidgetTypeFromWebType = useCallback((webType: string): string => {
|
||
|
|
console.log("getWidgetTypeFromWebType - input webType:", webType);
|
||
|
|
switch (webType) {
|
||
|
|
case "text":
|
||
|
|
return "text";
|
||
|
|
case "email":
|
||
|
|
return "email";
|
||
|
|
case "tel":
|
||
|
|
return "tel";
|
||
|
|
case "number":
|
||
|
|
return "number";
|
||
|
|
case "decimal":
|
||
|
|
return "decimal";
|
||
|
|
case "date":
|
||
|
|
return "date";
|
||
|
|
case "datetime":
|
||
|
|
return "datetime";
|
||
|
|
case "select":
|
||
|
|
return "select";
|
||
|
|
case "dropdown":
|
||
|
|
return "dropdown";
|
||
|
|
case "textarea":
|
||
|
|
return "textarea";
|
||
|
|
case "text_area":
|
||
|
|
return "text_area";
|
||
|
|
case "checkbox":
|
||
|
|
return "checkbox";
|
||
|
|
case "boolean":
|
||
|
|
return "boolean";
|
||
|
|
case "radio":
|
||
|
|
return "radio";
|
||
|
|
case "code":
|
||
|
|
return "code";
|
||
|
|
case "entity":
|
||
|
|
return "entity";
|
||
|
|
case "file":
|
||
|
|
return "file";
|
||
|
|
default:
|
||
|
|
console.log("getWidgetTypeFromWebType - default case, returning text for:", webType);
|
||
|
|
return "text";
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 범용 복사 함수
|
||
|
|
const copyComponents = useCallback(() => {
|
||
|
|
if (!selectedComponent && groupState.selectedComponents.length === 0) return;
|
||
|
|
|
||
|
|
let componentsToCopy: ComponentData[] = [];
|
||
|
|
let copyType: "single" | "multiple" | "group" = "single";
|
||
|
|
|
||
|
|
if (selectedComponent?.type === "group") {
|
||
|
|
// 그룹 복사
|
||
|
|
const children = getGroupChildren(layout.components, selectedComponent.id);
|
||
|
|
componentsToCopy = [selectedComponent, ...children];
|
||
|
|
copyType = "group";
|
||
|
|
} else if (groupState.selectedComponents.length > 1) {
|
||
|
|
// 다중 선택 복사
|
||
|
|
componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||
|
|
copyType = "multiple";
|
||
|
|
} else if (selectedComponent) {
|
||
|
|
// 단일 컴포넌트 복사
|
||
|
|
componentsToCopy = [selectedComponent];
|
||
|
|
copyType = "single";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (componentsToCopy.length === 0) return;
|
||
|
|
|
||
|
|
// 바운딩 박스 계산
|
||
|
|
const positions = componentsToCopy.map((comp) => ({
|
||
|
|
x: comp.position.x,
|
||
|
|
y: comp.position.y,
|
||
|
|
width: comp.size.width,
|
||
|
|
height: comp.size.height,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const minX = Math.min(...positions.map((p) => p.x));
|
||
|
|
const minY = Math.min(...positions.map((p) => p.y));
|
||
|
|
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
||
|
|
const maxY = Math.max(...positions.map((p) => p.y + p.height));
|
||
|
|
|
||
|
|
setClipboard({
|
||
|
|
type: copyType,
|
||
|
|
data: componentsToCopy,
|
||
|
|
offset: { x: 20, y: 20 },
|
||
|
|
boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
||
|
|
});
|
||
|
|
}, [selectedComponent, groupState.selectedComponents, layout.components]);
|
||
|
|
|
||
|
|
// 범용 삭제 함수
|
||
|
|
const deleteComponents = useCallback(() => {
|
||
|
|
if (!selectedComponent && groupState.selectedComponents.length === 0) return;
|
||
|
|
|
||
|
|
let idsToRemove: string[] = [];
|
||
|
|
|
||
|
|
if (selectedComponent?.type === "group") {
|
||
|
|
// 그룹 삭제 (자식 컴포넌트 포함)
|
||
|
|
const childrenIds = getGroupChildren(layout.components, selectedComponent.id).map((child) => child.id);
|
||
|
|
idsToRemove = [selectedComponent.id, ...childrenIds];
|
||
|
|
} else if (groupState.selectedComponents.length > 1) {
|
||
|
|
// 다중 선택 삭제
|
||
|
|
idsToRemove = [...groupState.selectedComponents];
|
||
|
|
} else if (selectedComponent) {
|
||
|
|
// 단일 컴포넌트 삭제
|
||
|
|
idsToRemove = [selectedComponent.id];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (idsToRemove.length === 0) return;
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)),
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
|
||
|
|
// 선택 상태 초기화
|
||
|
|
setSelectedComponent(null);
|
||
|
|
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
|
||
|
|
}, [selectedComponent, groupState.selectedComponents, layout, saveToHistory, setGroupState]);
|
||
|
|
|
||
|
|
// 범용 붙여넣기 함수
|
||
|
|
const pasteComponents = useCallback(
|
||
|
|
(pastePosition?: { x: number; y: number }) => {
|
||
|
|
if (!clipboard || clipboard.data.length === 0) return;
|
||
|
|
|
||
|
|
const idMap = new Map<string, string>();
|
||
|
|
const newComponents: ComponentData[] = [];
|
||
|
|
|
||
|
|
// 붙여넣기 위치 결정
|
||
|
|
let targetPosition = pastePosition;
|
||
|
|
if (!targetPosition && clipboard.boundingBox) {
|
||
|
|
targetPosition = {
|
||
|
|
x: clipboard.boundingBox.x + clipboard.offset.x,
|
||
|
|
y: clipboard.boundingBox.y + clipboard.offset.y,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const offsetX = targetPosition ? targetPosition.x - (clipboard.boundingBox?.x || 0) : clipboard.offset.x;
|
||
|
|
const offsetY = targetPosition ? targetPosition.y - (clipboard.boundingBox?.y || 0) : clipboard.offset.y;
|
||
|
|
|
||
|
|
// 모든 컴포넌트에 대해 새 ID 생성
|
||
|
|
clipboard.data.forEach((comp) => {
|
||
|
|
const newId = generateComponentId();
|
||
|
|
idMap.set(comp.id, newId);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 컴포넌트 복사 및 ID/위치 업데이트
|
||
|
|
clipboard.data.forEach((comp) => {
|
||
|
|
const newComp: ComponentData = {
|
||
|
|
...comp,
|
||
|
|
id: idMap.get(comp.id)!,
|
||
|
|
position: {
|
||
|
|
x: comp.position.x + offsetX,
|
||
|
|
y: comp.position.y + offsetY,
|
||
|
|
},
|
||
|
|
// 부모 ID가 있고 매핑되는 경우 업데이트
|
||
|
|
parentId: comp.parentId && idMap.has(comp.parentId) ? idMap.get(comp.parentId)! : undefined,
|
||
|
|
};
|
||
|
|
newComponents.push(newComp);
|
||
|
|
});
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: [...layout.components, ...newComponents],
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
},
|
||
|
|
[clipboard, layout, saveToHistory],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 캔버스 우클릭 컨텍스트 메뉴
|
||
|
|
const handleCanvasContextMenu = useCallback(
|
||
|
|
(e: React.MouseEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
// 우클릭 시 붙여넣기 (클립보드에 데이터가 있는 경우)
|
||
|
|
if (clipboard && clipboard.data.length > 0) {
|
||
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
||
|
|
const x = e.clientX - rect.left;
|
||
|
|
const y = e.clientY - rect.top;
|
||
|
|
pasteComponents({ x, y });
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[clipboard, pasteComponents],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 키보드 단축키 지원
|
||
|
|
useEffect(() => {
|
||
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||
|
|
if (e.ctrlKey || e.metaKey) {
|
||
|
|
switch (e.key) {
|
||
|
|
case "z":
|
||
|
|
e.preventDefault();
|
||
|
|
if (e.shiftKey) {
|
||
|
|
redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z
|
||
|
|
} else {
|
||
|
|
undo(); // Ctrl+Z 또는 Cmd+Z
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case "y":
|
||
|
|
e.preventDefault();
|
||
|
|
redo(); // Ctrl+Y 또는 Cmd+Y
|
||
|
|
break;
|
||
|
|
case "c":
|
||
|
|
e.preventDefault();
|
||
|
|
// 선택된 컴포넌트(들) 복사
|
||
|
|
copyComponents();
|
||
|
|
break;
|
||
|
|
case "v":
|
||
|
|
e.preventDefault();
|
||
|
|
// 클립보드 내용 붙여넣기
|
||
|
|
if (clipboard && clipboard.data.length > 0) {
|
||
|
|
pasteComponents();
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case "g":
|
||
|
|
case "G":
|
||
|
|
e.preventDefault();
|
||
|
|
if (e.shiftKey) {
|
||
|
|
// Ctrl+Shift+G: 그룹 해제
|
||
|
|
const selectedComponents = layout.components.filter((comp) =>
|
||
|
|
groupState.selectedComponents.includes(comp.id),
|
||
|
|
);
|
||
|
|
if (selectedComponents.length === 1 && selectedComponents[0].type === "group") {
|
||
|
|
// 그룹 해제 로직을 직접 실행
|
||
|
|
const group = selectedComponents[0] as any;
|
||
|
|
const groupChildren = layout.components.filter((comp) => comp.parentId === group.id);
|
||
|
|
|
||
|
|
// 자식 컴포넌트들의 절대 위치 복원
|
||
|
|
const absoluteChildren = groupChildren.map((child) => ({
|
||
|
|
...child,
|
||
|
|
position: {
|
||
|
|
x: child.position.x + group.position.x,
|
||
|
|
y: child.position.y + group.position.y,
|
||
|
|
z: (child.position as any).z || 1,
|
||
|
|
},
|
||
|
|
parentId: undefined,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: [
|
||
|
|
...layout.components.filter((comp) => comp.id !== group.id && comp.parentId !== group.id),
|
||
|
|
...absoluteChildren,
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
setGroupState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedComponents: [],
|
||
|
|
isGrouping: false,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Ctrl+G: 그룹 생성 다이얼로그 열기
|
||
|
|
const selectedComponents = layout.components.filter((comp) =>
|
||
|
|
groupState.selectedComponents.includes(comp.id),
|
||
|
|
);
|
||
|
|
if (selectedComponents.length >= 2) {
|
||
|
|
setShowGroupCreateDialog(true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
} else if (e.key === "Delete") {
|
||
|
|
e.preventDefault();
|
||
|
|
// 선택된 컴포넌트(들) 삭제
|
||
|
|
deleteComponents();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
window.addEventListener("keydown", handleKeyDown);
|
||
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
|
|
}, [
|
||
|
|
undo,
|
||
|
|
redo,
|
||
|
|
copyComponents,
|
||
|
|
pasteComponents,
|
||
|
|
deleteComponents,
|
||
|
|
clipboard,
|
||
|
|
layout,
|
||
|
|
groupState,
|
||
|
|
saveToHistory,
|
||
|
|
setLayout,
|
||
|
|
setGroupState,
|
||
|
|
setShowGroupCreateDialog,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// 컴포넌트 속성 업데이트 함수
|
||
|
|
const updateComponentProperty = useCallback(
|
||
|
|
(componentId: string, propertyPath: string, value: any) => {
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: layout.components.map((comp) => {
|
||
|
|
if (comp.id === componentId) {
|
||
|
|
const newComp = { ...comp };
|
||
|
|
const pathParts = propertyPath.split(".");
|
||
|
|
let current: any = newComp;
|
||
|
|
|
||
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
||
|
|
current = current[pathParts[i]];
|
||
|
|
}
|
||
|
|
current[pathParts[pathParts.length - 1]] = value;
|
||
|
|
|
||
|
|
// 크기 변경 시 격자 스냅 적용
|
||
|
|
if (
|
||
|
|
(propertyPath === "size.width" || propertyPath === "size.height") &&
|
||
|
|
layout.gridSettings?.snapToGrid &&
|
||
|
|
gridInfo
|
||
|
|
) {
|
||
|
|
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
|
||
|
|
newComp.size = snappedSize;
|
||
|
|
}
|
||
|
|
|
||
|
|
return newComp;
|
||
|
|
}
|
||
|
|
return comp;
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
// 선택된 컴포넌트인 경우 즉시 상태도 동기화하여 입력 즉시 반영되도록 처리
|
||
|
|
if (selectedComponent && selectedComponent.id === componentId) {
|
||
|
|
const updated = newLayout.components.find((c) => c.id === componentId) || null;
|
||
|
|
if (updated) setSelectedComponent(updated);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[layout, saveToHistory, selectedComponent, gridInfo],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 그룹 생성 함수
|
||
|
|
const handleGroupCreate = useCallback(
|
||
|
|
(componentIds: string[], title: string, style?: any) => {
|
||
|
|
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
|
||
|
|
|
||
|
|
if (selectedComponents.length < 2) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 경계 박스 계산
|
||
|
|
const boundingBox = calculateBoundingBox(selectedComponents);
|
||
|
|
|
||
|
|
// 그룹 컴포넌트 생성 (경계 박스 정보 전달)
|
||
|
|
const groupComponent = createGroupComponent(
|
||
|
|
componentIds,
|
||
|
|
title,
|
||
|
|
{ x: boundingBox.minX, y: boundingBox.minY },
|
||
|
|
{ width: boundingBox.width, height: boundingBox.height },
|
||
|
|
style,
|
||
|
|
);
|
||
|
|
|
||
|
|
// 자식 컴포넌트들의 상대 위치 계산
|
||
|
|
const relativeChildren = calculateRelativePositions(
|
||
|
|
selectedComponents,
|
||
|
|
{
|
||
|
|
x: boundingBox.minX,
|
||
|
|
y: boundingBox.minY,
|
||
|
|
},
|
||
|
|
groupComponent.id,
|
||
|
|
);
|
||
|
|
|
||
|
|
// 새 레이아웃 생성
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: [
|
||
|
|
// 그룹에 포함되지 않은 기존 컴포넌트들만 유지
|
||
|
|
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
|
||
|
|
// 그룹 컴포넌트 추가
|
||
|
|
groupComponent,
|
||
|
|
// 자식 컴포넌트들도 유지 (parentId로 그룹과 연결)
|
||
|
|
...relativeChildren,
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
},
|
||
|
|
[layout, saveToHistory],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 그룹 해제 함수
|
||
|
|
const handleGroupUngroup = useCallback(
|
||
|
|
(groupId: string) => {
|
||
|
|
const group = layout.components.find((comp) => comp.id === groupId) as GroupComponent;
|
||
|
|
if (!group || group.type !== "group") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const groupChildren = getGroupChildren(layout.components, groupId);
|
||
|
|
|
||
|
|
// 자식 컴포넌트들의 절대 위치 복원
|
||
|
|
const absoluteChildren = restoreAbsolutePositions(groupChildren, group.position);
|
||
|
|
|
||
|
|
// 새 레이아웃 생성
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: [
|
||
|
|
// 그룹과 그룹의 자식 컴포넌트들을 제외한 기존 컴포넌트들
|
||
|
|
...layout.components.filter((comp) => comp.id !== groupId && comp.parentId !== groupId),
|
||
|
|
// 절대 위치로 복원된 자식 컴포넌트들
|
||
|
|
...absoluteChildren,
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
},
|
||
|
|
[layout, saveToHistory],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 레이아웃 저장 함수
|
||
|
|
const saveLayout = useCallback(async () => {
|
||
|
|
if (!selectedScreen) {
|
||
|
|
toast.error("저장할 화면이 선택되지 않았습니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
setIsSaving(true);
|
||
|
|
await screenApi.saveLayout(selectedScreen.screenId, layout);
|
||
|
|
setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제
|
||
|
|
toast.success("레이아웃이 성공적으로 저장되었습니다.");
|
||
|
|
} catch (error) {
|
||
|
|
console.error("레이아웃 저장 실패:", error);
|
||
|
|
toast.error("레이아웃 저장에 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setIsSaving(false);
|
||
|
|
}
|
||
|
|
}, [layout, selectedScreen]);
|
||
|
|
|
||
|
|
// 레이아웃 로드 함수
|
||
|
|
const loadLayout = useCallback(async () => {
|
||
|
|
if (!selectedScreen) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
setIsLoading(true);
|
||
|
|
const savedLayout = await screenApi.getLayout(selectedScreen.screenId);
|
||
|
|
|
||
|
|
if (savedLayout && savedLayout.components) {
|
||
|
|
// 격자 설정이 없는 경우 기본값 추가
|
||
|
|
if (!savedLayout.gridSettings) {
|
||
|
|
savedLayout.gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true };
|
||
|
|
} else if (savedLayout.gridSettings.snapToGrid === undefined) {
|
||
|
|
savedLayout.gridSettings.snapToGrid = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
setLayout(savedLayout);
|
||
|
|
// 히스토리 초기화
|
||
|
|
setHistory([savedLayout]);
|
||
|
|
setHistoryIndex(0);
|
||
|
|
setHasUnsavedChanges(false); // 로드 완료 시 변경사항 플래그 해제
|
||
|
|
toast.success("레이아웃을 불러왔습니다.");
|
||
|
|
} else {
|
||
|
|
// 저장된 레이아웃이 없는 경우 기본 레이아웃 유지
|
||
|
|
const defaultLayout = {
|
||
|
|
components: [],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
|
||
|
|
};
|
||
|
|
setLayout(defaultLayout);
|
||
|
|
setHistory([defaultLayout]);
|
||
|
|
setHistoryIndex(0);
|
||
|
|
setHasUnsavedChanges(false);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("레이아웃 로드 실패:", error);
|
||
|
|
// 에러 시에도 기본 레이아웃으로 초기화
|
||
|
|
const defaultLayout = {
|
||
|
|
components: [],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
|
||
|
|
};
|
||
|
|
setLayout(defaultLayout);
|
||
|
|
setHistory([defaultLayout]);
|
||
|
|
setHistoryIndex(0);
|
||
|
|
setHasUnsavedChanges(false);
|
||
|
|
toast.error("레이아웃 로드에 실패했습니다. 새 레이아웃으로 시작합니다.");
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
}, [selectedScreen]);
|
||
|
|
|
||
|
|
// 화면 선택 시 레이아웃 로드
|
||
|
|
useEffect(() => {
|
||
|
|
if (selectedScreen) {
|
||
|
|
loadLayout();
|
||
|
|
}
|
||
|
|
}, [selectedScreen, loadLayout]);
|
||
|
|
|
||
|
|
// 스크롤 컨테이너 참조 (좌표 계산 정확도 향상)
|
||
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||
|
|
|
||
|
|
// 드래그 시작 (새 컴포넌트 추가)
|
||
|
|
const startDrag = useCallback((component: Partial<ComponentData>, e: React.DragEvent) => {
|
||
|
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft;
|
||
|
|
const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop;
|
||
|
|
|
||
|
|
setDragState({
|
||
|
|
isDragging: true,
|
||
|
|
draggedComponent: component as ComponentData,
|
||
|
|
draggedComponents: [component as ComponentData],
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: relMouseX, y: relMouseY },
|
||
|
|
isMultiDrag: false,
|
||
|
|
initialMouse: { x: relMouseX, y: relMouseY },
|
||
|
|
grabOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
e.dataTransfer.setData("application/json", JSON.stringify(component));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 기존 컴포넌트 드래그 시작 (재배치)
|
||
|
|
const startComponentDrag = useCallback(
|
||
|
|
(component: ComponentData, e: React.DragEvent) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
|
||
|
|
// 다중선택된 컴포넌트들이 있는지 확인
|
||
|
|
const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||
|
|
|
||
|
|
const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id);
|
||
|
|
|
||
|
|
// 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리)
|
||
|
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft;
|
||
|
|
const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop;
|
||
|
|
const grabOffsetX = relMouseX - component.position.x;
|
||
|
|
const grabOffsetY = relMouseY - component.position.y;
|
||
|
|
|
||
|
|
if (isMultiDrag) {
|
||
|
|
// 다중 드래그
|
||
|
|
setDragState({
|
||
|
|
isDragging: true,
|
||
|
|
draggedComponent: component,
|
||
|
|
draggedComponents: selectedComponents,
|
||
|
|
originalPosition: component.position,
|
||
|
|
currentPosition: { x: relMouseX, y: relMouseY },
|
||
|
|
isMultiDrag: true,
|
||
|
|
initialMouse: { x: relMouseX, y: relMouseY },
|
||
|
|
grabOffset: { x: grabOffsetX, y: grabOffsetY },
|
||
|
|
});
|
||
|
|
e.dataTransfer.setData(
|
||
|
|
"application/json",
|
||
|
|
JSON.stringify({
|
||
|
|
...component,
|
||
|
|
isMoving: true,
|
||
|
|
isMultiDrag: true,
|
||
|
|
selectedComponentIds: groupState.selectedComponents,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// 단일 드래그
|
||
|
|
setDragState({
|
||
|
|
isDragging: true,
|
||
|
|
draggedComponent: component,
|
||
|
|
draggedComponents: [component],
|
||
|
|
originalPosition: component.position,
|
||
|
|
currentPosition: { x: relMouseX, y: relMouseY },
|
||
|
|
isMultiDrag: false,
|
||
|
|
initialMouse: { x: relMouseX, y: relMouseY },
|
||
|
|
grabOffset: { x: grabOffsetX, y: grabOffsetY },
|
||
|
|
});
|
||
|
|
e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true }));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[layout.components, groupState.selectedComponents],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 드래그 중
|
||
|
|
const onDragOver = useCallback(
|
||
|
|
(e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (dragState.isDragging) {
|
||
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const x = rect ? e.clientX - rect.left + scrollLeft : 0;
|
||
|
|
const y = rect ? e.clientY - rect.top + scrollTop : 0;
|
||
|
|
|
||
|
|
setDragState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
currentPosition: { x, y },
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[dragState.isDragging],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 드롭 처리
|
||
|
|
const onDrop = useCallback(
|
||
|
|
(e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const data = JSON.parse(e.dataTransfer.getData("application/json"));
|
||
|
|
|
||
|
|
if (data.isMoving) {
|
||
|
|
// 기존 컴포넌트 재배치
|
||
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
const mouseX = rect ? e.clientX - rect.left + scrollLeft : 0;
|
||
|
|
const mouseY = rect ? e.clientY - rect.top + scrollTop : 0;
|
||
|
|
|
||
|
|
if (data.isMultiDrag && data.selectedComponentIds) {
|
||
|
|
// 다중 드래그 처리
|
||
|
|
// 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영)
|
||
|
|
const dropX = mouseX - dragState.grabOffset.x;
|
||
|
|
const dropY = mouseY - dragState.grabOffset.y;
|
||
|
|
const deltaX = dropX - dragState.originalPosition.x;
|
||
|
|
const deltaY = dropY - dragState.originalPosition.y;
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: layout.components.map((comp) => {
|
||
|
|
if (data.selectedComponentIds.includes(comp.id)) {
|
||
|
|
let newX = comp.position.x + deltaX;
|
||
|
|
let newY = comp.position.y + deltaY;
|
||
|
|
|
||
|
|
// 격자 스냅 적용
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const snappedPosition = snapToGrid(
|
||
|
|
{ x: newX, y: newY, z: comp.position.z || 1 } as Required<Position>,
|
||
|
|
gridInfo,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
);
|
||
|
|
newX = snappedPosition.x;
|
||
|
|
newY = snappedPosition.y;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
...comp,
|
||
|
|
position: {
|
||
|
|
x: newX,
|
||
|
|
y: newY,
|
||
|
|
z: comp.position.z || 1,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return comp;
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
} else {
|
||
|
|
// 단일 드래그 처리
|
||
|
|
let x = mouseX - dragState.grabOffset.x;
|
||
|
|
let y = mouseY - dragState.grabOffset.y;
|
||
|
|
|
||
|
|
// 격자 스냅 적용
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const snappedPosition = snapToGrid(
|
||
|
|
{ x, y, z: 1 } as Required<Position>,
|
||
|
|
gridInfo,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
);
|
||
|
|
x = snappedPosition.x;
|
||
|
|
y = snappedPosition.y;
|
||
|
|
}
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: layout.components.map((comp) =>
|
||
|
|
comp.id === data.id ? { ...comp, position: { x, y, z: comp.position.z || 1 } } : comp,
|
||
|
|
),
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// 새 컴포넌트 추가
|
||
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||
|
|
// 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
|
||
|
|
const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
|
||
|
|
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
|
||
|
|
let x = rect ? e.clientX - rect.left + scrollLeft : 0;
|
||
|
|
let y = rect ? e.clientY - rect.top + scrollTop : 0;
|
||
|
|
|
||
|
|
// 격자 스냅 적용
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const snappedPosition = snapToGrid(
|
||
|
|
{ x, y, z: 1 } as Required<Position>,
|
||
|
|
gridInfo,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
);
|
||
|
|
x = snappedPosition.x;
|
||
|
|
y = snappedPosition.y;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 기본 크기를 격자에 맞춰 설정
|
||
|
|
let defaultWidth = data.size?.width || 200;
|
||
|
|
const defaultHeight = data.size?.height || 100;
|
||
|
|
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const { columnWidth } = gridInfo;
|
||
|
|
const { gap } = layout.gridSettings;
|
||
|
|
// 기본적으로 1컬럼 너비로 설정
|
||
|
|
const gridColumns = 1;
|
||
|
|
defaultWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||
|
|
}
|
||
|
|
|
||
|
|
const newComponent: ComponentData = {
|
||
|
|
...data,
|
||
|
|
id: generateComponentId(),
|
||
|
|
position: { x, y, z: 1 },
|
||
|
|
size: { width: defaultWidth, height: defaultHeight },
|
||
|
|
} as ComponentData;
|
||
|
|
|
||
|
|
const newLayout = {
|
||
|
|
...layout,
|
||
|
|
components: [...layout.components, newComponent],
|
||
|
|
};
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("드롭 처리 중 오류:", error);
|
||
|
|
}
|
||
|
|
|
||
|
|
setDragState({
|
||
|
|
isDragging: false,
|
||
|
|
draggedComponent: null,
|
||
|
|
draggedComponents: [],
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: 0, y: 0 },
|
||
|
|
isMultiDrag: false,
|
||
|
|
initialMouse: { x: 0, y: 0 },
|
||
|
|
grabOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
},
|
||
|
|
[
|
||
|
|
layout,
|
||
|
|
saveToHistory,
|
||
|
|
dragState.initialMouse.x,
|
||
|
|
dragState.initialMouse.y,
|
||
|
|
dragState.grabOffset.x,
|
||
|
|
dragState.grabOffset.y,
|
||
|
|
gridInfo,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 드래그 종료
|
||
|
|
const endDrag = useCallback(() => {
|
||
|
|
// 격자 스냅 적용
|
||
|
|
if (dragState.isDragging && dragState.draggedComponent && gridInfo && layout.gridSettings?.snapToGrid) {
|
||
|
|
const component = dragState.draggedComponent;
|
||
|
|
const snappedPosition = snapToGrid(dragState.currentPosition, gridInfo, layout.gridSettings as GridUtilSettings);
|
||
|
|
|
||
|
|
// 스냅된 위치로 컴포넌트 업데이트
|
||
|
|
if (snappedPosition.x !== dragState.currentPosition.x || snappedPosition.y !== dragState.currentPosition.y) {
|
||
|
|
const updatedComponents = layout.components.map((comp) =>
|
||
|
|
comp.id === component.id ? { ...comp, position: snappedPosition } : comp,
|
||
|
|
);
|
||
|
|
|
||
|
|
const newLayout = { ...layout, components: updatedComponents };
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setDragState({
|
||
|
|
isDragging: false,
|
||
|
|
draggedComponent: null,
|
||
|
|
draggedComponents: [],
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: 0, y: 0 },
|
||
|
|
isMultiDrag: false,
|
||
|
|
initialMouse: { x: 0, y: 0 },
|
||
|
|
grabOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
}, [dragState, gridInfo, layout, saveToHistory]);
|
||
|
|
|
||
|
|
// 컴포넌트 클릭 (선택)
|
||
|
|
const handleComponentClick = useCallback(
|
||
|
|
(component: ComponentData, event?: React.MouseEvent) => {
|
||
|
|
const isShiftPressed = event?.shiftKey || false;
|
||
|
|
const isGroupContainer = component.type === "group";
|
||
|
|
|
||
|
|
if (groupState.isGrouping || isShiftPressed) {
|
||
|
|
// 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택
|
||
|
|
if (isGroupContainer) {
|
||
|
|
// 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리
|
||
|
|
setSelectedComponent(component);
|
||
|
|
setGroupState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedComponents: [component.id],
|
||
|
|
isGrouping: false, // 그룹 선택 시 그룹화 모드 해제
|
||
|
|
}));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const isSelected = groupState.selectedComponents.includes(component.id);
|
||
|
|
setGroupState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedComponents: isSelected
|
||
|
|
? prev.selectedComponents.filter((id) => id !== component.id)
|
||
|
|
: [...prev.selectedComponents, component.id],
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정
|
||
|
|
if (isShiftPressed) {
|
||
|
|
setSelectedComponent(component);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// 일반 모드에서는 단일 선택
|
||
|
|
setSelectedComponent(component);
|
||
|
|
setGroupState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedComponents: [component.id], // 그룹도 선택 가능하도록 수정
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[groupState.isGrouping, groupState.selectedComponents],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 화면이 선택되지 않았을 때 처리
|
||
|
|
if (!selectedScreen) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-full items-center justify-center">
|
||
|
|
<div className="text-center text-gray-500">
|
||
|
|
<Palette className="mx-auto mb-4 h-16 w-16 text-gray-300" />
|
||
|
|
<p className="mb-4 text-lg">설계할 화면을 선택해주세요</p>
|
||
|
|
<p className="mb-6 text-sm">화면 목록에서 화면을 선택한 후 설계기를 사용하세요</p>
|
||
|
|
<Button onClick={onBackToList} variant="outline">
|
||
|
|
화면 목록으로 돌아가기
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-screen w-full flex-col bg-gray-100">
|
||
|
|
{/* 상단 툴바 */}
|
||
|
|
<DesignerToolbar
|
||
|
|
screenName={selectedScreen?.screenName}
|
||
|
|
tableName={selectedScreen?.tableName}
|
||
|
|
onBack={onBackToList}
|
||
|
|
onSave={handleSave}
|
||
|
|
onUndo={undo}
|
||
|
|
onRedo={redo}
|
||
|
|
onPreview={() => {
|
||
|
|
// TODO: 미리보기 기능 구현
|
||
|
|
toast.info("미리보기 기능은 준비 중입니다.");
|
||
|
|
}}
|
||
|
|
onTogglePanel={togglePanel}
|
||
|
|
panelStates={panelStates}
|
||
|
|
canUndo={historyIndex > 0}
|
||
|
|
canRedo={historyIndex < history.length - 1}
|
||
|
|
isSaving={isSaving}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 메인 캔버스 영역 (전체 화면) */}
|
||
|
|
<div
|
||
|
|
className="flex-1 relative overflow-hidden bg-white"
|
||
|
|
onClick={(e) => {
|
||
|
|
if (e.target === e.currentTarget) {
|
||
|
|
closeAllPanels();
|
||
|
|
setSelectedComponent(null);
|
||
|
|
setGroupState(prev => ({ ...prev, selectedComponents: [] }));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
<Badge variant="outline" className="font-mono">
|
||
|
|
{selectedScreen.tableName}
|
||
|
|
</Badge>
|
||
|
|
{clipboard && clipboard.data.length > 0 && (
|
||
|
|
<Badge variant="secondary" className="text-xs">
|
||
|
|
<Clipboard className="mr-1 h-3 w-3" />
|
||
|
|
{clipboard.type === "group"
|
||
|
|
? "그룹 복사됨"
|
||
|
|
: clipboard.type === "multiple"
|
||
|
|
? `${clipboard.data.length}개 복사됨`
|
||
|
|
: "컴포넌트 복사됨"}
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Button
|
||
|
|
variant={groupState.isGrouping ? "default" : "outline"}
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
|
||
|
|
title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)"
|
||
|
|
>
|
||
|
|
<Group className="mr-2 h-4 w-4" />
|
||
|
|
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{/* 복사/붙여넣기/삭제 버튼들 */}
|
||
|
|
{(selectedComponent || groupState.selectedComponents.length > 0) && (
|
||
|
|
<>
|
||
|
|
<Button variant="outline" size="sm" onClick={copyComponents} title="복사 (Ctrl+C)">
|
||
|
|
<Copy className="mr-2 h-4 w-4" />
|
||
|
|
복사
|
||
|
|
</Button>
|
||
|
|
<Button variant="destructive" size="sm" onClick={deleteComponents} title="삭제 (Delete 키)">
|
||
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
||
|
|
삭제
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 붙여넣기 버튼 */}
|
||
|
|
{clipboard && clipboard.data.length > 0 && (
|
||
|
|
<Button variant="outline" size="sm" onClick={() => pasteComponents()} title="붙여넣기 (Ctrl+V)">
|
||
|
|
<Clipboard className="mr-2 h-4 w-4" />
|
||
|
|
붙여넣기
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Button variant="outline" size="sm" onClick={undo} disabled={historyIndex <= 0} title="실행 취소 (Ctrl+Z)">
|
||
|
|
<Undo className="mr-2 h-4 w-4" />
|
||
|
|
실행 취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={redo}
|
||
|
|
disabled={historyIndex >= history.length - 1}
|
||
|
|
title="다시 실행 (Ctrl+Y)"
|
||
|
|
>
|
||
|
|
<Redo className="mr-2 h-4 w-4" />
|
||
|
|
다시 실행
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={saveLayout}
|
||
|
|
disabled={isSaving || !selectedScreen}
|
||
|
|
className={`${hasUnsavedChanges ? "bg-orange-600 hover:bg-orange-700" : "bg-blue-600 hover:bg-blue-700"}`}
|
||
|
|
>
|
||
|
|
<Save className="mr-2 h-4 w-4" />
|
||
|
|
{isSaving ? "저장 중..." : hasUnsavedChanges ? "저장 *" : "저장"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 그룹화 툴바 */}
|
||
|
|
<GroupingToolbar
|
||
|
|
groupState={groupState}
|
||
|
|
onGroupStateChange={setGroupState}
|
||
|
|
onGroupCreate={handleGroupCreate}
|
||
|
|
onGroupUngroup={handleGroupUngroup}
|
||
|
|
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}
|
||
|
|
allComponents={layout.components}
|
||
|
|
onGroupAlign={(mode) => {
|
||
|
|
const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
|
||
|
|
if (selected.length < 2) return;
|
||
|
|
|
||
|
|
let newComponents = [...layout.components];
|
||
|
|
const minX = Math.min(...selected.map((c) => c.position.x));
|
||
|
|
const maxX = Math.max(...selected.map((c) => c.position.x + c.size.width));
|
||
|
|
const minY = Math.min(...selected.map((c) => c.position.y));
|
||
|
|
const maxY = Math.max(...selected.map((c) => c.position.y + c.size.height));
|
||
|
|
const centerX = (minX + maxX) / 2;
|
||
|
|
const centerY = (minY + maxY) / 2;
|
||
|
|
|
||
|
|
newComponents = newComponents.map((c) => {
|
||
|
|
if (!groupState.selectedComponents.includes(c.id)) return c;
|
||
|
|
if (mode === "left") return { ...c, position: { x: minX, y: c.position.y } };
|
||
|
|
if (mode === "right") return { ...c, position: { x: maxX - c.size.width, y: c.position.y } };
|
||
|
|
if (mode === "centerX")
|
||
|
|
return { ...c, position: { x: Math.round(centerX - c.size.width / 2), y: c.position.y } };
|
||
|
|
if (mode === "top") return { ...c, position: { x: c.position.x, y: minY } };
|
||
|
|
if (mode === "bottom") return { ...c, position: { x: c.position.x, y: maxY - c.size.height } };
|
||
|
|
if (mode === "centerY")
|
||
|
|
return { ...c, position: { x: c.position.x, y: Math.round(centerY - c.size.height / 2) } };
|
||
|
|
return c;
|
||
|
|
});
|
||
|
|
|
||
|
|
const newLayout = { ...layout, components: newComponents };
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
}}
|
||
|
|
onGroupDistribute={(orientation) => {
|
||
|
|
const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
|
||
|
|
if (selected.length < 3) return; // 균등 분배는 3개 이상 권장
|
||
|
|
|
||
|
|
const sorted = [...selected].sort((a, b) =>
|
||
|
|
orientation === "horizontal" ? a.position.x - b.position.x : a.position.y - b.position.y,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (orientation === "horizontal") {
|
||
|
|
const left = sorted[0].position.x;
|
||
|
|
const right = Math.max(...sorted.map((c) => c.position.x + c.size.width));
|
||
|
|
const totalWidth = right - left;
|
||
|
|
const gaps = sorted.length - 1;
|
||
|
|
const usedWidth = sorted.reduce((sum, c) => sum + c.size.width, 0);
|
||
|
|
const gapSize = gaps > 0 ? Math.max(0, Math.round((totalWidth - usedWidth) / gaps)) : 0;
|
||
|
|
|
||
|
|
let cursor = left;
|
||
|
|
sorted.forEach((c, idx) => {
|
||
|
|
c.position.x = cursor;
|
||
|
|
cursor += c.size.width + gapSize;
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const top = sorted[0].position.y;
|
||
|
|
const bottom = Math.max(...sorted.map((c) => c.position.y + c.size.height));
|
||
|
|
const totalHeight = bottom - top;
|
||
|
|
const gaps = sorted.length - 1;
|
||
|
|
const usedHeight = sorted.reduce((sum, c) => sum + c.size.height, 0);
|
||
|
|
const gapSize = gaps > 0 ? Math.max(0, Math.round((totalHeight - usedHeight) / gaps)) : 0;
|
||
|
|
|
||
|
|
let cursor = top;
|
||
|
|
sorted.forEach((c, idx) => {
|
||
|
|
c.position.y = cursor;
|
||
|
|
cursor += c.size.height + gapSize;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const newLayout = { ...layout, components: [...layout.components] };
|
||
|
|
setLayout(newLayout);
|
||
|
|
saveToHistory(newLayout);
|
||
|
|
}}
|
||
|
|
showCreateDialog={showGroupCreateDialog}
|
||
|
|
onShowCreateDialogChange={setShowGroupCreateDialog}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 메인 컨텐츠 영역 */}
|
||
|
|
<div className="flex flex-1 overflow-hidden">
|
||
|
|
{/* 좌측 사이드바 - 테이블 타입 */}
|
||
|
|
<div className="flex w-80 flex-col border-r bg-gray-50">
|
||
|
|
<div className="border-b bg-white p-4">
|
||
|
|
<div className="mb-4">
|
||
|
|
<h3 className="text-lg font-medium">테이블 타입</h3>
|
||
|
|
{selectedScreen && (
|
||
|
|
<div className="mt-2 rounded-md bg-blue-50 p-3">
|
||
|
|
<div className="text-sm font-medium text-blue-900">선택된 화면</div>
|
||
|
|
<div className="text-xs text-blue-700">{selectedScreen.screenName}</div>
|
||
|
|
<div className="mt-1 flex items-center space-x-2">
|
||
|
|
<Database className="h-3 w-3 text-blue-600" />
|
||
|
|
<span className="font-mono text-xs text-blue-800">{selectedScreen.tableName}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 검색 입력창 */}
|
||
|
|
<div className="mb-4">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="테이블명, 컬럼명으로 검색..."
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||
|
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 검색 결과 정보 */}
|
||
|
|
<div className="mb-2 text-sm text-gray-600">
|
||
|
|
총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}-
|
||
|
|
{Math.min(currentPage * itemsPerPage, filteredTables.length)}번째
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p className="mb-4 text-sm text-gray-600">테이블과 컬럼을 드래그하여 캔버스에 배치하세요.</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 테이블 목록 */}
|
||
|
|
<div className="flex-1 overflow-y-auto">
|
||
|
|
{paginatedTables.length === 0 ? (
|
||
|
|
<div className="flex h-full items-center justify-center p-8">
|
||
|
|
<div className="text-center">
|
||
|
|
<Database className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||
|
|
<h3 className="mb-2 text-sm font-medium text-gray-900">
|
||
|
|
{selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"}
|
||
|
|
</h3>
|
||
|
|
<p className="text-xs text-gray-500">
|
||
|
|
{selectedScreen
|
||
|
|
? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.`
|
||
|
|
: "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
paginatedTables.map((table) => (
|
||
|
|
<div key={table.tableName} className="border-b bg-white">
|
||
|
|
{/* 테이블 헤더 */}
|
||
|
|
<div
|
||
|
|
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-100"
|
||
|
|
draggable
|
||
|
|
onDragStart={(e) =>
|
||
|
|
startDrag(
|
||
|
|
{
|
||
|
|
type: "container",
|
||
|
|
tableName: table.tableName,
|
||
|
|
label: table.tableLabel,
|
||
|
|
size: { width: 200, height: 80 }, // 픽셀 단위로 변경
|
||
|
|
},
|
||
|
|
e,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Database className="h-4 w-4 text-blue-600" />
|
||
|
|
<div>
|
||
|
|
<div className="text-sm font-medium">{table.tableLabel}</div>
|
||
|
|
<div className="text-xs text-gray-500">{table.tableName}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => toggleTableExpansion(table.tableName)}
|
||
|
|
className="h-6 w-6 p-0"
|
||
|
|
>
|
||
|
|
{expandedTables.has(table.tableName) ? (
|
||
|
|
<ChevronDown className="h-4 w-4" />
|
||
|
|
) : (
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 컬럼 목록 */}
|
||
|
|
{expandedTables.has(table.tableName) && (
|
||
|
|
<div className="bg-gray-25 border-t">
|
||
|
|
{table.columns.map((column) => (
|
||
|
|
<div
|
||
|
|
key={column.columnName}
|
||
|
|
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
|
||
|
|
draggable
|
||
|
|
onDragStart={(e) => {
|
||
|
|
console.log("Drag start - column:", column.columnName, "webType:", column.webType);
|
||
|
|
const widgetType = getWidgetTypeFromWebType(column.webType || "text");
|
||
|
|
console.log("Drag start - widgetType:", widgetType);
|
||
|
|
startDrag(
|
||
|
|
{
|
||
|
|
type: "widget",
|
||
|
|
tableName: table.tableName,
|
||
|
|
columnName: column.columnName,
|
||
|
|
widgetType: widgetType as WebType,
|
||
|
|
label: column.columnLabel || column.columnName,
|
||
|
|
size: { width: 150, height: 40 }, // 픽셀 단위로 변경
|
||
|
|
},
|
||
|
|
e,
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="flex-shrink-0">
|
||
|
|
{column.webType === "text" && <Type className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "email" && <Type className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "tel" && <Type className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "number" && <Hash className="h-3 w-3 text-green-600" />}
|
||
|
|
{column.webType === "decimal" && <Hash className="h-3 w-3 text-green-600" />}
|
||
|
|
{column.webType === "date" && <Calendar className="h-3 w-3 text-purple-600" />}
|
||
|
|
{column.webType === "datetime" && <Calendar className="h-3 w-3 text-purple-600" />}
|
||
|
|
{column.webType === "select" && <List className="h-3 w-3 text-orange-600" />}
|
||
|
|
{column.webType === "dropdown" && <List className="h-3 w-3 text-orange-600" />}
|
||
|
|
{column.webType === "textarea" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
|
||
|
|
{column.webType === "text_area" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
|
||
|
|
{column.webType === "checkbox" && <CheckSquare className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "boolean" && <CheckSquare className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "radio" && <Radio className="h-3 w-3 text-blue-600" />}
|
||
|
|
{column.webType === "code" && <Code className="h-3 w-3 text-gray-600" />}
|
||
|
|
{column.webType === "entity" && <Building className="h-3 w-3 text-cyan-600" />}
|
||
|
|
{column.webType === "file" && <File className="h-3 w-3 text-yellow-600" />}
|
||
|
|
</div>
|
||
|
|
<div className="flex-1">
|
||
|
|
<div className="text-sm font-medium">{column.columnLabel || column.columnName}</div>
|
||
|
|
<div className="text-xs text-gray-500">{column.columnName}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 페이징 컨트롤 */}
|
||
|
|
{totalPages > 1 && (
|
||
|
|
<div className="border-t bg-white p-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
||
|
|
disabled={currentPage === 1}
|
||
|
|
>
|
||
|
|
이전
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<div className="text-sm text-gray-600">
|
||
|
|
{currentPage} / {totalPages}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
||
|
|
disabled={currentPage === totalPages}
|
||
|
|
>
|
||
|
|
다음
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 중앙: 캔버스 영역 */}
|
||
|
|
<div className="flex-1 bg-white" ref={scrollContainerRef}>
|
||
|
|
<div className="h-full w-full overflow-auto p-6">
|
||
|
|
<div
|
||
|
|
className="min-h-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4"
|
||
|
|
onDrop={onDrop}
|
||
|
|
onDragOver={onDragOver}
|
||
|
|
ref={canvasRef}
|
||
|
|
onMouseDown={handleMarqueeStart}
|
||
|
|
onMouseMove={handleMarqueeMove}
|
||
|
|
onMouseUp={handleMarqueeEnd}
|
||
|
|
onContextMenu={handleCanvasContextMenu}
|
||
|
|
>
|
||
|
|
{/* 항상 격자와 캔버스 표시 */}
|
||
|
|
<div className="relative min-h-[600px]" ref={canvasRef}>
|
||
|
|
{/* 동적 그리드 가이드 */}
|
||
|
|
<div className="pointer-events-none absolute inset-0">
|
||
|
|
<div
|
||
|
|
className="grid h-full gap-1"
|
||
|
|
style={{
|
||
|
|
gridTemplateColumns: `repeat(${layout.gridSettings?.columns || 12}, 1fr)`,
|
||
|
|
gap: `${layout.gridSettings?.gap || 16}px`,
|
||
|
|
padding: `${layout.gridSettings?.padding || 16}px`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => (
|
||
|
|
<div key={i} className="border-r border-gray-200 opacity-30 last:border-r-0" />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 격자 스냅이 활성화된 경우 추가 가이드라인 */}
|
||
|
|
{layout.gridSettings?.snapToGrid && gridInfo && (
|
||
|
|
<div className="absolute inset-0">
|
||
|
|
{generateGridLines(
|
||
|
|
canvasRef.current?.clientWidth || 800,
|
||
|
|
canvasRef.current?.clientHeight || 600,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
).verticalLines.map((x, i) => (
|
||
|
|
<div
|
||
|
|
key={`v-${i}`}
|
||
|
|
className="absolute top-0 bottom-0 w-px bg-blue-200 opacity-50"
|
||
|
|
style={{ left: `${x}px` }}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{generateGridLines(
|
||
|
|
canvasRef.current?.clientWidth || 800,
|
||
|
|
canvasRef.current?.clientHeight || 600,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
).horizontalLines.map((y, i) => (
|
||
|
|
<div
|
||
|
|
key={`h-${i}`}
|
||
|
|
className="absolute right-0 left-0 h-px bg-blue-200 opacity-50"
|
||
|
|
style={{ top: `${y}px` }}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 마키 선택 사각형 */}
|
||
|
|
{selectionState.isSelecting && (
|
||
|
|
<div
|
||
|
|
className="pointer-events-none absolute z-50 border border-blue-400 bg-blue-200/20"
|
||
|
|
style={{
|
||
|
|
left: `${Math.min(selectionState.start.x, selectionState.current.x)}px`,
|
||
|
|
top: `${Math.min(selectionState.start.y, selectionState.current.y)}px`,
|
||
|
|
width: `${Math.abs(selectionState.current.x - selectionState.start.x)}px`,
|
||
|
|
height: `${Math.abs(selectionState.current.y - selectionState.start.y)}px`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 컴포넌트들 - 실시간 미리보기 */}
|
||
|
|
{layout.components
|
||
|
|
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
|
||
|
|
.map((component) => {
|
||
|
|
// 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기
|
||
|
|
const children =
|
||
|
|
component.type === "group"
|
||
|
|
? layout.components.filter((child) => child.parentId === component.id)
|
||
|
|
: [];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<RealtimePreview
|
||
|
|
key={component.id}
|
||
|
|
component={component}
|
||
|
|
isSelected={
|
||
|
|
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||
|
|
}
|
||
|
|
onClick={(e) => handleComponentClick(component, e)}
|
||
|
|
onDragStart={(e) => startComponentDrag(component, e)}
|
||
|
|
onDragEnd={endDrag}
|
||
|
|
onGroupToggle={(groupId) => {
|
||
|
|
// 그룹 접기/펼치기 토글
|
||
|
|
const groupComp = component as GroupComponent;
|
||
|
|
updateComponentProperty(groupId, "collapsed", !groupComp.collapsed);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{children.map((child) => (
|
||
|
|
<RealtimePreview
|
||
|
|
key={child.id}
|
||
|
|
component={child}
|
||
|
|
isSelected={groupState.selectedComponents.includes(child.id)}
|
||
|
|
onClick={(e) => handleComponentClick(child, e)}
|
||
|
|
onDragStart={(e) => startComponentDrag(child, e)}
|
||
|
|
onDragEnd={endDrag}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</RealtimePreview>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 우측: 컴포넌트 스타일 편집 */}
|
||
|
|
<div className="w-80 border-l bg-gray-50">
|
||
|
|
<div className="h-full space-y-4 overflow-y-auto p-4">
|
||
|
|
{/* 격자 설정 */}
|
||
|
|
<GridControls
|
||
|
|
gridSettings={layout.gridSettings as GridUtilSettings}
|
||
|
|
onGridSettingsChange={handleGridSettingsChange}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<h3 className="text-lg font-medium">컴포넌트 속성</h3>
|
||
|
|
|
||
|
|
{selectedComponent ? (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-sm font-medium">
|
||
|
|
{selectedComponent.type === "container" && "테이블 속성"}
|
||
|
|
{selectedComponent.type === "widget" && "위젯 속성"}
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{/* 위치 속성 */}
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="positionX">X 위치</Label>
|
||
|
|
<Input
|
||
|
|
id="positionX"
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
value={liveSelectedPosition.x}
|
||
|
|
onChange={(e) => {
|
||
|
|
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||
|
|
if (Number.isFinite(val)) {
|
||
|
|
let newX = Math.round(val);
|
||
|
|
|
||
|
|
// 격자 스냅이 활성화된 경우 격자에 맞춤
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const snappedPos = snapToGrid(
|
||
|
|
{
|
||
|
|
x: newX,
|
||
|
|
y: selectedComponent.position.y,
|
||
|
|
z: selectedComponent.position.z || 1,
|
||
|
|
} as Required<Position>,
|
||
|
|
gridInfo,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
);
|
||
|
|
newX = snappedPos.x;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateComponentProperty(selectedComponent.id, "position.x", newX);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="positionY">Y 위치</Label>
|
||
|
|
<Input
|
||
|
|
id="positionY"
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
value={liveSelectedPosition.y}
|
||
|
|
onChange={(e) => {
|
||
|
|
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||
|
|
if (Number.isFinite(val)) {
|
||
|
|
let newY = Math.round(val);
|
||
|
|
|
||
|
|
// 격자 스냅이 활성화된 경우 격자에 맞춤
|
||
|
|
if (layout.gridSettings?.snapToGrid && gridInfo) {
|
||
|
|
const snappedPos = snapToGrid(
|
||
|
|
{
|
||
|
|
x: selectedComponent.position.x,
|
||
|
|
y: newY,
|
||
|
|
z: selectedComponent.position.z || 1,
|
||
|
|
} as Required<Position>,
|
||
|
|
gridInfo,
|
||
|
|
layout.gridSettings as GridUtilSettings,
|
||
|
|
);
|
||
|
|
newY = snappedPos.y;
|
||
|
|
}
|
||
|
|
|
||
|
|
updateComponentProperty(selectedComponent.id, "position.y", newY);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 크기 속성 */}
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="width">
|
||
|
|
{layout.gridSettings?.snapToGrid ? "너비 (격자 컬럼)" : "너비 (픽셀)"}
|
||
|
|
</Label>
|
||
|
|
{layout.gridSettings?.snapToGrid && gridInfo ? (
|
||
|
|
// 격자 스냅이 활성화된 경우 컬럼 단위로 조정
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Input
|
||
|
|
id="width"
|
||
|
|
type="number"
|
||
|
|
min="1"
|
||
|
|
max={layout.gridSettings.columns}
|
||
|
|
value={(() => {
|
||
|
|
const { columnWidth } = gridInfo;
|
||
|
|
const { gap } = layout.gridSettings;
|
||
|
|
return Math.max(
|
||
|
|
1,
|
||
|
|
Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)),
|
||
|
|
);
|
||
|
|
})()}
|
||
|
|
onChange={(e) => {
|
||
|
|
const gridColumns = Math.max(
|
||
|
|
1,
|
||
|
|
Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1),
|
||
|
|
);
|
||
|
|
const { columnWidth } = gridInfo;
|
||
|
|
const { gap } = layout.gridSettings!;
|
||
|
|
const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||
|
|
updateComponentProperty(selectedComponent.id, "size.width", newWidth);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="text-xs text-gray-500">실제 너비: {selectedComponent.size.width}px</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
// 격자 스냅이 비활성화된 경우 픽셀 단위로 조정
|
||
|
|
<Input
|
||
|
|
id="width"
|
||
|
|
type="number"
|
||
|
|
min="20"
|
||
|
|
value={selectedComponent.size.width}
|
||
|
|
onChange={(e) => {
|
||
|
|
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||
|
|
if (Number.isFinite(val)) {
|
||
|
|
const newWidth = Math.max(20, Math.round(val));
|
||
|
|
updateComponentProperty(selectedComponent.id, "size.width", newWidth);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="height">높이 (픽셀)</Label>
|
||
|
|
<Input
|
||
|
|
id="height"
|
||
|
|
type="number"
|
||
|
|
min="20"
|
||
|
|
value={selectedComponent.size.height}
|
||
|
|
onChange={(e) => {
|
||
|
|
const val = (e.target as HTMLInputElement).valueAsNumber;
|
||
|
|
if (Number.isFinite(val)) {
|
||
|
|
let newHeight = Math.max(20, Math.round(val));
|
||
|
|
|
||
|
|
// 격자 스냅이 활성화된 경우 20px 단위로 조정
|
||
|
|
if (layout.gridSettings?.snapToGrid) {
|
||
|
|
newHeight = Math.max(40, Math.round(newHeight / 20) * 20);
|
||
|
|
}
|
||
|
|
|
||
|
|
updateComponentProperty(selectedComponent.id, "size.height", newHeight);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 테이블 정보 */}
|
||
|
|
<Separator />
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="tableName">테이블명</Label>
|
||
|
|
<Input id="tableName" value={selectedComponent.tableName || ""} readOnly className="bg-gray-50" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 위젯 전용 속성 */}
|
||
|
|
{selectedComponent.type === "widget" && (
|
||
|
|
<>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="columnName">컬럼명</Label>
|
||
|
|
<Input
|
||
|
|
id="columnName"
|
||
|
|
value={selectedComponent.columnName || ""}
|
||
|
|
readOnly
|
||
|
|
className="bg-gray-50"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="widgetType">위젯 타입</Label>
|
||
|
|
<Input
|
||
|
|
id="widgetType"
|
||
|
|
value={selectedComponent.widgetType || ""}
|
||
|
|
readOnly
|
||
|
|
className="bg-gray-50"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="widgetLabel">라벨</Label>
|
||
|
|
<Input
|
||
|
|
id="widgetLabel"
|
||
|
|
value={selectedComponent.label || ""}
|
||
|
|
onChange={(e) => updateComponentProperty(selectedComponent.id, "label", e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="widgetPlaceholder">플레이스홀더</Label>
|
||
|
|
<Input
|
||
|
|
id="widgetPlaceholder"
|
||
|
|
value={selectedComponent.placeholder || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-4">
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
id="required"
|
||
|
|
checked={selectedComponent.required || false}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "required", e.target.checked)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Label htmlFor="required">필수</Label>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
id="readonly"
|
||
|
|
checked={selectedComponent.readonly || false}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Label htmlFor="readonly">읽기 전용</Label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 스타일 속성 */}
|
||
|
|
<Separator />
|
||
|
|
<div>
|
||
|
|
<Label className="text-sm font-medium">스타일 편집</Label>
|
||
|
|
<StyleEditor
|
||
|
|
style={selectedComponent.style || {}}
|
||
|
|
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 고급 속성 */}
|
||
|
|
<Separator />
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="parentId">부모 ID</Label>
|
||
|
|
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
|
||
|
|
</div>
|
||
|
|
<Button variant="destructive" size="sm" onClick={deleteComponents} className="w-full">
|
||
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
||
|
|
{selectedComponent.type === "group"
|
||
|
|
? "그룹 삭제"
|
||
|
|
: groupState.selectedComponents.length > 1
|
||
|
|
? `${groupState.selectedComponents.length}개 삭제`
|
||
|
|
: "컴포넌트 삭제"}
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="py-8 text-center text-gray-500">
|
||
|
|
<Settings className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||
|
|
<p>컴포넌트를 선택하여 속성을 편집하세요</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|